@open-mercato/cli 0.6.7 → 0.6.8-develop.6875.1.871a4afc94

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 (278) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/build.mjs +24 -7
  3. package/dist/agentic/claude-code/hooks/entity-migration-check.ts +32 -88
  4. package/dist/agentic/codex/enforcement-rules.md +2 -16
  5. package/dist/agentic/cursor/hooks/entity-migration-check.mjs +19 -50
  6. package/dist/agentic/cursor/rules/entity-guard.mdc +9 -21
  7. package/dist/agentic/cursor/rules/generated-guard.mdc +3 -5
  8. package/dist/agentic/cursor/rules/open-mercato.mdc +3 -29
  9. package/dist/agentic/guides/ai-workflows.md +76 -0
  10. package/dist/agentic/guides/architecture.md +107 -0
  11. package/dist/agentic/guides/backend-ui.md +80 -0
  12. package/dist/agentic/guides/contracts.md +92 -0
  13. package/dist/agentic/guides/extensions.md +121 -0
  14. package/dist/agentic/guides/framework-extension-points.md +36 -0
  15. package/dist/agentic/guides/integrations.md +81 -0
  16. package/dist/agentic/guides/module-facts.json +115537 -3951
  17. package/dist/agentic/guides/module-system.md +12 -127
  18. package/dist/agentic/guides/modules/ai_assistant.md +227 -28
  19. package/dist/agentic/guides/modules/api_docs.md +58 -2
  20. package/dist/agentic/guides/modules/api_keys.md +92 -7
  21. package/dist/agentic/guides/modules/attachments.md +119 -14
  22. package/dist/agentic/guides/modules/audit_logs.md +131 -10
  23. package/dist/agentic/guides/modules/auth.md +276 -33
  24. package/dist/agentic/guides/modules/business_rules.md +149 -14
  25. package/dist/agentic/guides/modules/catalog.md +475 -43
  26. package/dist/agentic/guides/modules/channel_gmail.md +82 -3
  27. package/dist/agentic/guides/modules/channel_imap.md +82 -3
  28. package/dist/agentic/guides/modules/checkout.md +299 -27
  29. package/dist/agentic/guides/modules/communication_channels.md +315 -33
  30. package/dist/agentic/guides/modules/configs.md +104 -8
  31. package/dist/agentic/guides/modules/content.md +57 -2
  32. package/dist/agentic/guides/modules/currencies.md +152 -17
  33. package/dist/agentic/guides/modules/customer_accounts.md +387 -45
  34. package/dist/agentic/guides/modules/customers.md +746 -85
  35. package/dist/agentic/guides/modules/dashboards.md +155 -10
  36. package/dist/agentic/guides/modules/data_sync.md +149 -16
  37. package/dist/agentic/guides/modules/design_system.md +102 -0
  38. package/dist/agentic/guides/modules/dictionaries.md +107 -12
  39. package/dist/agentic/guides/modules/directory.md +148 -15
  40. package/dist/agentic/guides/modules/entities.md +145 -13
  41. package/dist/agentic/guides/modules/events.md +59 -2
  42. package/dist/agentic/guides/modules/feature_toggles.md +119 -9
  43. package/dist/agentic/guides/modules/gateway_stripe.md +92 -4
  44. package/dist/agentic/guides/modules/generators.md +45 -2
  45. package/dist/agentic/guides/modules/inbox_ops.md +207 -27
  46. package/dist/agentic/guides/modules/integrations.md +146 -16
  47. package/dist/agentic/guides/modules/messages.md +223 -24
  48. package/dist/agentic/guides/modules/notifications.md +96 -8
  49. package/dist/agentic/guides/modules/onboarding.md +77 -6
  50. package/dist/agentic/guides/modules/payment_gateways.md +164 -21
  51. package/dist/agentic/guides/modules/perspectives.md +77 -7
  52. package/dist/agentic/guides/modules/planner.md +136 -18
  53. package/dist/agentic/guides/modules/portal.md +93 -2
  54. package/dist/agentic/guides/modules/progress.md +104 -14
  55. package/dist/agentic/guides/modules/query_index.md +154 -15
  56. package/dist/agentic/guides/modules/record_locks.md +206 -21
  57. package/dist/agentic/guides/modules/resources.md +197 -30
  58. package/dist/agentic/guides/modules/sales.md +678 -84
  59. package/dist/agentic/guides/modules/scheduler.md +133 -14
  60. package/dist/agentic/guides/modules/search.md +170 -6
  61. package/dist/agentic/guides/modules/security.md +294 -35
  62. package/dist/agentic/guides/modules/shipping_carriers.md +134 -18
  63. package/dist/agentic/guides/modules/sso.md +151 -26
  64. package/dist/agentic/guides/modules/staff.md +369 -57
  65. package/dist/agentic/guides/modules/storage_s3.md +95 -5
  66. package/dist/agentic/guides/modules/sync_akeneo.md +85 -3
  67. package/dist/agentic/guides/modules/sync_excel.md +75 -6
  68. package/dist/agentic/guides/modules/system_status_overlays.md +60 -2
  69. package/dist/agentic/guides/modules/translations.md +104 -10
  70. package/dist/agentic/guides/modules/webhooks.md +203 -24
  71. package/dist/agentic/guides/modules/wms.md +441 -46
  72. package/dist/agentic/guides/modules/workflows.md +302 -43
  73. package/dist/agentic/guides/spec-delivery.md +22 -0
  74. package/dist/agentic/guides/testing-debugging.md +80 -0
  75. package/dist/agentic/guides/upstream/AGENTS.md +299 -0
  76. package/dist/agentic/guides/upstream/BACKWARD_COMPATIBILITY.md +316 -0
  77. package/dist/agentic/guides/upstream/manifest.json +8 -0
  78. package/dist/agentic/shared/AGENTS.md.template +124 -311
  79. package/dist/agentic/shared/ai/agentic.config.json +2 -0
  80. package/dist/agentic/shared/ai/harness/README.md +50 -0
  81. package/dist/agentic/shared/ai/harness/RELEASE.md +57 -0
  82. package/dist/agentic/shared/ai/harness/cases.json +13680 -0
  83. package/dist/agentic/shared/ai/harness/cases.schema.json +133 -0
  84. package/dist/agentic/shared/ai/harness/fixtures/index.json +49 -0
  85. package/dist/agentic/shared/ai/harness/fixtures/seeds.json +162 -0
  86. package/dist/agentic/shared/ai/harness/generated-code-review-policy.md +21 -0
  87. package/dist/agentic/shared/ai/harness/generated-code-review-response.schema.json +96 -0
  88. package/dist/agentic/shared/ai/harness/generated-code-review-result.schema.json +172 -0
  89. package/dist/agentic/shared/ai/harness/release-matrix.json +158 -0
  90. package/dist/agentic/shared/ai/harness/release-result.schema.json +219 -0
  91. package/dist/agentic/shared/ai/harness/result.schema.json +76 -0
  92. package/dist/agentic/shared/ai/harness/routing-response.schema.json +12 -0
  93. package/dist/agentic/shared/ai/harness/target-validation-result.schema.json +73 -0
  94. package/dist/agentic/shared/ai/harness/validators.json +78 -0
  95. package/dist/agentic/shared/ai/harness/writable-ast-oracles.mjs +2121 -0
  96. package/dist/agentic/shared/ai/harness/writable-behavior-oracles.mjs +1096 -0
  97. package/dist/agentic/shared/ai/lessons/_template.md +16 -0
  98. package/dist/agentic/shared/ai/lessons.md +26 -3
  99. package/dist/agentic/shared/ai/review-checklist.md +36 -0
  100. package/dist/agentic/shared/ai/skills/om-auto-implement-spec/SKILL.md +21 -0
  101. package/dist/agentic/shared/ai/skills/om-auto-review-pr/SKILL.md +4 -0
  102. package/dist/agentic/shared/ai/skills/om-auto-upgrade-0.6.6-to-0.6.7/SKILL.md +98 -0
  103. package/dist/agentic/shared/ai/skills/om-backend-ui-design/SKILL.md +20 -241
  104. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/crud-surfaces.md +16 -0
  105. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/frontend-and-design-system.md +53 -0
  106. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/page-and-navigation.md +13 -0
  107. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/quality-states.md +11 -0
  108. package/dist/agentic/shared/ai/skills/om-build-workflow/SKILL.md +30 -0
  109. package/dist/agentic/shared/ai/skills/om-build-workflow/references/activity-contracts.md +12 -0
  110. package/dist/agentic/shared/ai/skills/om-build-workflow/references/durability-and-progress.md +13 -0
  111. package/dist/agentic/shared/ai/skills/om-build-workflow/references/workflow-design.md +14 -0
  112. package/dist/agentic/shared/ai/skills/om-create-ai-agent/SKILL.md +28 -0
  113. package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/attachments-and-overrides.md +12 -0
  114. package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/module-agents-and-tools.md +22 -0
  115. package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/orchestrator-agents.md +12 -0
  116. package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/surface-selector.md +19 -0
  117. package/dist/agentic/shared/ai/skills/om-data-model-design/SKILL.md +16 -601
  118. package/dist/agentic/shared/ai/skills/om-data-model-design/references/integrity-and-concurrency.md +19 -0
  119. package/dist/agentic/shared/ai/skills/om-data-model-design/references/migration-workflow.md +13 -0
  120. package/dist/agentic/shared/ai/skills/om-data-model-design/references/schema-design.md +14 -0
  121. package/dist/agentic/shared/ai/skills/om-data-model-design/references/sensitive-data.md +10 -0
  122. package/dist/agentic/shared/ai/skills/om-eject-and-customize/SKILL.md +14 -310
  123. package/dist/agentic/shared/ai/skills/om-eject-and-customize/references/decision-and-procedure.md +17 -0
  124. package/dist/agentic/shared/ai/skills/om-evolve-harness/SKILL.md +29 -0
  125. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-template.md +100 -0
  126. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-workflow.md +16 -0
  127. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/owner-selection.md +17 -0
  128. package/dist/agentic/shared/ai/skills/om-framework-context/SKILL.md +25 -0
  129. package/dist/agentic/shared/ai/skills/om-framework-context/references/resolver-procedure.md +11 -0
  130. package/dist/agentic/shared/ai/skills/om-framework-context/references/skew-and-escalation.md +13 -0
  131. package/dist/agentic/shared/ai/skills/om-help/SKILL.md +15 -97
  132. package/dist/agentic/shared/ai/skills/om-help/references/delivery-workflows.md +18 -0
  133. package/dist/agentic/shared/ai/skills/om-help/references/task-families.md +20 -0
  134. package/dist/agentic/shared/ai/skills/om-implement-spec/SKILL.md +33 -152
  135. package/dist/agentic/shared/ai/skills/om-implement-spec/references/phases-and-gates.md +33 -0
  136. package/dist/agentic/shared/ai/skills/om-implement-spec/references/planning-and-progress.md +40 -0
  137. package/dist/agentic/shared/ai/skills/om-implement-spec/references/report-templates.md +27 -0
  138. package/dist/agentic/shared/ai/skills/om-implement-spec/references/spec-resolution.md +24 -0
  139. package/dist/agentic/shared/ai/skills/om-integration-builder/SKILL.md +24 -708
  140. package/dist/agentic/shared/ai/skills/om-integration-builder/references/package-and-activation.md +16 -0
  141. package/dist/agentic/shared/ai/skills/om-integration-builder/references/provider-families.md +14 -0
  142. package/dist/agentic/shared/ai/skills/om-integration-builder/references/security-and-reliability.md +15 -0
  143. package/dist/agentic/shared/ai/skills/om-integration-builder/references/sync-and-files.md +12 -0
  144. package/dist/agentic/shared/ai/skills/om-judge-agent-session/SKILL.md +34 -0
  145. package/dist/agentic/shared/ai/skills/om-judge-agent-session/agents/openai.yaml +4 -0
  146. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/agentic-setup.md +11 -0
  147. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/input-normalization.md +31 -0
  148. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/judge-workflow.md +39 -0
  149. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/report-template.md +43 -0
  150. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/rules.md +11 -0
  151. package/dist/agentic/shared/ai/skills/om-module-scaffold/SKILL.md +20 -820
  152. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/api-and-domain.md +19 -0
  153. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/business-one-shot-blueprints.md +172 -0
  154. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/data-and-migrations.md +12 -0
  155. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/discovery-surface-catalog.md +83 -0
  156. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/module-surfaces.md +21 -0
  157. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/planning.md +11 -0
  158. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/runtime-cache-and-queues.md +22 -0
  159. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/verification.md +13 -0
  160. package/dist/agentic/shared/ai/skills/om-share-this-session/SKILL.md +41 -0
  161. package/dist/agentic/shared/ai/skills/om-share-this-session/references/agentic-setup.md +19 -0
  162. package/dist/agentic/shared/ai/skills/om-share-this-session/references/bundle-preparation.md +44 -0
  163. package/dist/agentic/shared/ai/skills/om-share-this-session/references/consent-and-review.md +39 -0
  164. package/dist/agentic/shared/ai/skills/om-share-this-session/references/publication.md +20 -0
  165. package/dist/agentic/shared/ai/skills/om-share-this-session/references/report-templates.md +57 -0
  166. package/dist/agentic/shared/ai/skills/om-share-this-session/references/rules.md +11 -0
  167. package/dist/agentic/shared/ai/skills/om-share-this-session/scripts/prepare-share-bundle.mjs +486 -0
  168. package/dist/agentic/shared/ai/skills/om-system-extension/SKILL.md +19 -843
  169. package/dist/agentic/shared/ai/skills/om-system-extension/references/extension-branches.md +18 -0
  170. package/dist/agentic/shared/ai/skills/om-system-extension/references/mechanism-selector.md +27 -0
  171. package/dist/agentic/shared/ai/skills/om-system-extension/references/read-write-roundtrip.md +13 -0
  172. package/dist/agentic/shared/ai/skills/om-system-extension/references/unified-overrides.md +41 -0
  173. package/dist/agentic/shared/ai/skills/om-system-extension/references/verification.md +11 -0
  174. package/dist/agentic/shared/ai/skills/om-trim-unused-modules/SKILL.md +15 -63
  175. package/dist/agentic/shared/ai/skills/om-trim-unused-modules/references/dependency-and-redirect.md +15 -0
  176. package/dist/agentic/shared/ai/skills/om-troubleshooter/SKILL.md +16 -467
  177. package/dist/agentic/shared/ai/skills/om-troubleshooter/references/diagnosis-map.md +16 -0
  178. package/dist/agentic/shared/ai/skills/om-troubleshooter/references/regression-oracles.md +14 -0
  179. package/dist/agentic/shared/ai/skills/tiers.json +143 -21
  180. package/dist/agentic/shared/ai/skills/tiers.schema.json +52 -40
  181. package/dist/agentic/shared/ai/specs/README.md +4 -3
  182. package/dist/agentic/shared/ai/specs/SPEC-000-template.md +226 -10
  183. package/dist/agentic/shared/ai/trackers/github.md +108 -4
  184. package/dist/agentic/shared/scripts/agent-harness-tool-server.mjs +187 -0
  185. package/dist/agentic/shared/scripts/check-lessons.mjs +178 -0
  186. package/dist/agentic/shared/scripts/evaluate-agent-harness.mjs +2958 -0
  187. package/dist/agentic/shared/scripts/execution-sandbox.mjs +329 -0
  188. package/dist/agentic/shared/scripts/framework-context.mjs +888 -0
  189. package/dist/agentic/shared/scripts/install-skills.mjs +1029 -0
  190. package/dist/agentic/shared/scripts/install-skills.sh +4 -694
  191. package/dist/agentic/shared/scripts/prepare-agent-harness-fixture.mjs +222 -0
  192. package/dist/agentic/shared/scripts/run-agent-harness-release.mjs +1774 -0
  193. package/dist/bin.js +13 -1
  194. package/dist/bin.js.map +2 -2
  195. package/dist/lib/__integration__/TC-INT-008.spec.js +88 -8
  196. package/dist/lib/__integration__/TC-INT-008.spec.js.map +2 -2
  197. package/dist/lib/agentic-init.js +13 -3
  198. package/dist/lib/agentic-init.js.map +2 -2
  199. package/dist/lib/agentic-setup.js +434 -186
  200. package/dist/lib/agentic-setup.js.map +2 -2
  201. package/dist/lib/generators/extensions/injection-widgets.js +20 -0
  202. package/dist/lib/generators/extensions/injection-widgets.js.map +2 -2
  203. package/dist/lib/generators/module-extension-facts.js +1840 -0
  204. package/dist/lib/generators/module-extension-facts.js.map +7 -0
  205. package/dist/lib/generators/module-fact-sources.js +70 -0
  206. package/dist/lib/generators/module-fact-sources.js.map +7 -0
  207. package/dist/lib/generators/module-facts-discovery.js +41 -7
  208. package/dist/lib/generators/module-facts-discovery.js.map +2 -2
  209. package/dist/lib/generators/module-facts.js +1387 -45
  210. package/dist/lib/generators/module-facts.js.map +3 -3
  211. package/dist/lib/generators/module-override-targets.js +688 -0
  212. package/dist/lib/generators/module-override-targets.js.map +7 -0
  213. package/dist/lib/generators/module-registry.js +11 -5
  214. package/dist/lib/generators/module-registry.js.map +2 -2
  215. package/dist/lib/load-env.js +24 -0
  216. package/dist/lib/load-env.js.map +7 -0
  217. package/dist/lib/resolver.js +2 -0
  218. package/dist/lib/resolver.js.map +2 -2
  219. package/dist/lib/telemetry-init.js +358 -0
  220. package/dist/lib/telemetry-init.js.map +7 -0
  221. package/dist/lib/testing/integration.js +5 -0
  222. package/dist/lib/testing/integration.js.map +2 -2
  223. package/dist/mercato.js +23 -1
  224. package/dist/mercato.js.map +2 -2
  225. package/package.json +13 -11
  226. package/src/bin.ts +33 -1
  227. package/src/lib/__integration__/TC-INT-008.spec.ts +107 -9
  228. package/src/lib/__tests__/agentic-init.test.ts +41 -5
  229. package/src/lib/__tests__/agentic-setup.ownership.test.ts +386 -0
  230. package/src/lib/__tests__/injection-widget-id-validation.test.ts +184 -0
  231. package/src/lib/__tests__/load-env.test.ts +65 -0
  232. package/src/lib/__tests__/telemetry-init.test.ts +298 -0
  233. package/src/lib/agentic-init.ts +15 -3
  234. package/src/lib/agentic-setup.ts +573 -222
  235. package/src/lib/generators/__tests__/activation-adapter-coverage.test.ts +92 -0
  236. package/src/lib/generators/__tests__/module-extension-activations.test.ts +762 -0
  237. package/src/lib/generators/__tests__/module-extension-facts.test.ts +404 -0
  238. package/src/lib/generators/__tests__/module-facts.bc-guard.test.ts +88 -2
  239. package/src/lib/generators/__tests__/module-facts.customers.fixture.test.ts +1 -0
  240. package/src/lib/generators/__tests__/module-facts.discovery.test.ts +54 -10
  241. package/src/lib/generators/__tests__/module-facts.extension-hosts.test.ts +108 -0
  242. package/src/lib/generators/__tests__/module-facts.owned-contracts.test.ts +561 -0
  243. package/src/lib/generators/__tests__/module-facts.source-links.test.ts +119 -0
  244. package/src/lib/generators/__tests__/module-override-targets.roundtrip.test.ts +149 -0
  245. package/src/lib/generators/__tests__/module-override-targets.test.ts +304 -0
  246. package/src/lib/generators/__tests__/module-subset.test.ts +18 -0
  247. package/src/lib/generators/extensions/injection-widgets.ts +51 -0
  248. package/src/lib/generators/module-extension-facts.ts +2365 -0
  249. package/src/lib/generators/module-fact-sources.ts +108 -0
  250. package/src/lib/generators/module-facts-discovery.ts +58 -7
  251. package/src/lib/generators/module-facts.ts +1851 -46
  252. package/src/lib/generators/module-override-targets.ts +863 -0
  253. package/src/lib/generators/module-registry.ts +16 -6
  254. package/src/lib/load-env.ts +30 -0
  255. package/src/lib/resolver.ts +3 -0
  256. package/src/lib/telemetry-init.ts +461 -0
  257. package/src/lib/testing/__tests__/integration.test.ts +4 -0
  258. package/src/lib/testing/integration.ts +5 -0
  259. package/src/mercato.ts +26 -1
  260. package/dist/agentic/guides/cache.md +0 -45
  261. package/dist/agentic/guides/core.md +0 -453
  262. package/dist/agentic/guides/events.md +0 -117
  263. package/dist/agentic/guides/queue.md +0 -53
  264. package/dist/agentic/guides/search.md +0 -115
  265. package/dist/agentic/guides/shared.md +0 -108
  266. package/dist/agentic/guides/ui.md +0 -347
  267. package/dist/agentic/shared/ai/skills/om-auto-continue-pr-loop/SKILL.md +0 -27
  268. package/dist/agentic/shared/ai/skills/om-auto-create-pr-loop/SKILL.md +0 -27
  269. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/ui-components.md +0 -260
  270. package/dist/agentic/shared/ai/skills/om-data-model-design/references/mikro-orm-cheatsheet.md +0 -146
  271. package/dist/agentic/shared/ai/skills/om-help/references/skills-catalog.md +0 -84
  272. package/dist/agentic/shared/ai/skills/om-help/references/workflow-sequences.md +0 -193
  273. package/dist/agentic/shared/ai/skills/om-integration-builder/STANDALONE.md +0 -34
  274. package/dist/agentic/shared/ai/skills/om-integration-builder/references/adapter-contracts.md +0 -762
  275. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/naming-conventions.md +0 -50
  276. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/navigation-patterns.md +0 -97
  277. package/dist/agentic/shared/ai/skills/om-system-extension/references/extension-contracts.md +0 -271
  278. package/dist/agentic/shared/ai/skills/om-troubleshooter/references/diagnostic-commands.md +0 -101
@@ -0,0 +1,2365 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import ts from 'typescript-js'
4
+ import {
5
+ CRUD_FORM_EXTENSION_SURFACES,
6
+ CRUD_FORM_LIFECYCLE_PHASES,
7
+ DATA_TABLE_EXTENSION_SURFACES,
8
+ } from '@open-mercato/shared/modules/widgets/extension-points'
9
+ import type {
10
+ ExtensionHostCapability,
11
+ ExtensionHostFamily,
12
+ ModuleContributionResolution,
13
+ ModuleExtensionActivation,
14
+ ModuleExtensionActivationKind,
15
+ ModuleExtensionContributionFact,
16
+ ModuleExtensionContributionBase,
17
+ ModuleExtensionContributionKind,
18
+ ModuleExtensionHostFact,
19
+ ModuleExtensionResolution,
20
+ ModuleExtensionSurfaceFacts,
21
+ ModuleExtensionTargetFact,
22
+ ModuleExtensionTargetRef,
23
+ ModuleExtensionUnresolvedFact,
24
+ ModuleFactSourceRef,
25
+ ModuleIncomingExtensionRef,
26
+ } from '@open-mercato/shared/modules/widgets/extension-points'
27
+ import { extractCommandIdsFromSource } from './module-registry'
28
+ import { scanModuleDir, SCAN_CONFIGS } from './scanner'
29
+
30
+ type StaticScalar = string | number | boolean | null
31
+ type StaticValue = StaticScalar | StaticValue[] | { [key: string]: StaticValue }
32
+ type StaticObject = { [key: string]: StaticValue }
33
+ type SpecializedRegistry = Extract<ModuleExtensionContributionFact, {
34
+ kind: 'specialized-registry'
35
+ }>['details']['registry']
36
+
37
+ export interface ExtensionFactEntityRef {
38
+ id: string
39
+ }
40
+
41
+ export interface ExtensionFactEventRef {
42
+ id: string
43
+ clientBroadcast?: boolean
44
+ portalBroadcast?: boolean
45
+ }
46
+
47
+ export interface ExtensionFactApiRouteRef {
48
+ path: string
49
+ methods: string[]
50
+ }
51
+
52
+ export interface ExtractModuleExtensionFactsOptions {
53
+ moduleId: string
54
+ moduleRoot: string
55
+ sourceRoot: string
56
+ entities: readonly ExtensionFactEntityRef[]
57
+ events: readonly ExtensionFactEventRef[]
58
+ apiRoutes: readonly ExtensionFactApiRouteRef[]
59
+ searchEntities: readonly string[]
60
+ notifications?: readonly string[]
61
+ aiTools?: ReadonlyArray<{ name: string; sourcePath: string }>
62
+ aiAgents?: ReadonlyArray<{ id: string; sourcePath: string }>
63
+ }
64
+
65
+ export interface CorrelateExtensionFactsOptions {
66
+ surfacesByModule: Readonly<Record<string, ModuleExtensionSurfaceFacts>>
67
+ entityIds: ReadonlySet<string>
68
+ eventIds: ReadonlySet<string>
69
+ apiRoutes: ReadonlySet<string>
70
+ commandIds?: ReadonlySet<string>
71
+ contributingModuleId?: string
72
+ }
73
+
74
+ type StaticContext = {
75
+ initializers: Map<string, ts.Expression | ts.FunctionDeclaration>
76
+ sourceFile: ts.SourceFile
77
+ resolving: Set<string>
78
+ }
79
+
80
+ type ApiExtensionHostIds = {
81
+ entityIds: string[]
82
+ /** Declaring `api/**` file per entity host id, so the host links to a real source. */
83
+ entitySourceFiles: Record<string, string>
84
+ commandIds: string[]
85
+ routeIds: string[]
86
+ }
87
+
88
+ type ModuleExtensionFactExtractionCache = {
89
+ sourceFiles: Map<string, ts.SourceFile | null>
90
+ sourceFilesBelow: Map<string, string[]>
91
+ apiExtensionHostIds: Map<string, ApiExtensionHostIds>
92
+ }
93
+
94
+ let activeExtractionCache: ModuleExtensionFactExtractionCache | null = null
95
+
96
+ /** @internal */
97
+ export function withModuleExtensionFactExtractionCache<Result>(operation: () => Result): Result {
98
+ if (activeExtractionCache) return operation()
99
+ activeExtractionCache = {
100
+ sourceFiles: new Map(),
101
+ sourceFilesBelow: new Map(),
102
+ apiExtensionHostIds: new Map(),
103
+ }
104
+ try {
105
+ return operation()
106
+ } finally {
107
+ activeExtractionCache = null
108
+ }
109
+ }
110
+
111
+ const FRAMEWORK_PREFIXES = [
112
+ 'dashboard:',
113
+ 'menu:',
114
+ 'notification:',
115
+ 'integration:',
116
+ 'integrations.detail:',
117
+ 'system-status:',
118
+ 'topbar:',
119
+ ]
120
+
121
+ const FRAMEWORK_HOSTS: ModuleExtensionHostFact[] = [
122
+ frameworkHost('framework.backend.sidebar', 'menu:backend.sidebar', 'menu', ['menu-item']),
123
+ frameworkHost('framework.settings.sidebar', 'menu:settings.sidebar', 'menu', ['menu-item']),
124
+ frameworkHost('framework.profile.sidebar', 'menu:profile.sidebar', 'menu', ['menu-item']),
125
+ frameworkHost('framework.portal.sidebar', 'menu:portal.sidebar', 'menu', ['menu-item']),
126
+ frameworkHost('framework.dashboard', 'dashboard:*', 'dashboard', ['registry-contribution']),
127
+ frameworkHost('framework.notifications', 'notification:*', 'notification', ['registry-contribution']),
128
+ frameworkHost('framework.integration-detail', 'integrations.detail:{integrationId}', 'integration', ['render-widget']),
129
+ frameworkHost('framework.integration-detail-fallback', 'integrations.detail:tabs', 'integration', ['render-widget']),
130
+ frameworkHost('framework.backend-mutation', 'backend-mutation:global', 'generic', ['headless-widget']),
131
+ frameworkHost('framework.crud-form-wildcard', 'crud-form:*', 'crud-form', ['field-widget', 'lifecycle-handler']),
132
+ ]
133
+
134
+ function frameworkHost(
135
+ key: string,
136
+ id: string,
137
+ family: ExtensionHostFamily,
138
+ capabilities: ExtensionHostCapability[],
139
+ ): ModuleExtensionHostFact {
140
+ return {
141
+ key,
142
+ id,
143
+ resolution: id.includes('*') || id.includes('{') ? 'pattern' : 'framework',
144
+ family,
145
+ ownerModule: 'framework',
146
+ capabilities,
147
+ bound: true,
148
+ stability: 'frozen',
149
+ source: { kind: 'framework', path: 'packages/ui/src', symbol: key },
150
+ }
151
+ }
152
+
153
+ function frameworkOverrideHost(
154
+ key: string,
155
+ mode: 'disable-replace' | 'replace' | 'additive',
156
+ ): ModuleExtensionHostFact {
157
+ return {
158
+ key: `framework.module-override.${key}`,
159
+ id: `module-override:${key}`,
160
+ resolution: 'framework',
161
+ family: 'module-override',
162
+ ownerModule: 'framework',
163
+ capabilities: ['module-override'],
164
+ operations: [mode],
165
+ bound: true,
166
+ stability: 'stable',
167
+ source: {
168
+ kind: 'framework',
169
+ path: 'packages/shared/src/modules/overrides.ts',
170
+ symbol: `ModuleOverrides.${key}`,
171
+ },
172
+ }
173
+ }
174
+
175
+ const FRAMEWORK_OVERRIDE_HOSTS = [
176
+ frameworkOverrideHost('ai.agents', 'disable-replace'),
177
+ frameworkOverrideHost('ai.tools', 'disable-replace'),
178
+ frameworkOverrideHost('ai.extensions', 'additive'),
179
+ frameworkOverrideHost('routes.api', 'disable-replace'),
180
+ frameworkOverrideHost('routes.pages', 'disable-replace'),
181
+ frameworkOverrideHost('events.subscribers', 'disable-replace'),
182
+ frameworkOverrideHost('workers', 'disable-replace'),
183
+ frameworkOverrideHost('widgets.injection', 'disable-replace'),
184
+ frameworkOverrideHost('widgets.components', 'disable-replace'),
185
+ frameworkOverrideHost('widgets.dashboard', 'disable-replace'),
186
+ frameworkOverrideHost('notifications.types', 'disable-replace'),
187
+ frameworkOverrideHost('notifications.handlers', 'disable-replace'),
188
+ frameworkOverrideHost('interceptors', 'disable-replace'),
189
+ frameworkOverrideHost('commandInterceptors', 'disable-replace'),
190
+ frameworkOverrideHost('enrichers', 'disable-replace'),
191
+ frameworkOverrideHost('guards', 'disable-replace'),
192
+ frameworkOverrideHost('cli', 'disable-replace'),
193
+ frameworkOverrideHost('setup', 'replace'),
194
+ frameworkOverrideHost('acl.features', 'disable-replace'),
195
+ frameworkOverrideHost('di', 'disable-replace'),
196
+ frameworkOverrideHost('encryption.maps', 'disable-replace'),
197
+ frameworkOverrideHost('nav.groupOrder', 'additive'),
198
+ ] as const
199
+
200
+ function allFrameworkHosts(): ModuleExtensionHostFact[] {
201
+ return [...FRAMEWORK_HOSTS, ...FRAMEWORK_OVERRIDE_HOSTS]
202
+ }
203
+
204
+ function sourceFile(filePath: string): ts.SourceFile | null {
205
+ const cached = activeExtractionCache?.sourceFiles.get(filePath)
206
+ if (cached !== undefined) return cached
207
+ if (!fs.existsSync(filePath)) return null
208
+ const scriptKind = filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
209
+ const parsed = ts.createSourceFile(filePath, fs.readFileSync(filePath, 'utf8'), ts.ScriptTarget.ES2020, true, scriptKind)
210
+ activeExtractionCache?.sourceFiles.set(filePath, parsed)
211
+ return parsed
212
+ }
213
+
214
+ function unwrap(expression: ts.Expression): ts.Expression {
215
+ let current = expression
216
+ while (
217
+ ts.isAsExpression(current)
218
+ || ts.isTypeAssertionExpression(current)
219
+ || ts.isParenthesizedExpression(current)
220
+ || ts.isSatisfiesExpression(current)
221
+ ) {
222
+ current = current.expression
223
+ }
224
+ return current
225
+ }
226
+
227
+ function propertyName(node: ts.PropertyName): string | null {
228
+ if (ts.isIdentifier(node) || ts.isStringLiteralLike(node) || ts.isNumericLiteral(node)) return node.text
229
+ return null
230
+ }
231
+
232
+ function buildStaticContext(file: ts.SourceFile): StaticContext {
233
+ const initializers = new Map<string, ts.Expression | ts.FunctionDeclaration>()
234
+ const visit = (node: ts.Node): void => {
235
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
236
+ initializers.set(node.name.text, node.initializer)
237
+ }
238
+ if (ts.isFunctionDeclaration(node) && node.name && node.body) {
239
+ initializers.set(node.name.text, node)
240
+ }
241
+ node.forEachChild(visit)
242
+ }
243
+ file.forEachChild(visit)
244
+ return { initializers, sourceFile: file, resolving: new Set() }
245
+ }
246
+
247
+ function entityRegistryValue(expression: ts.PropertyAccessExpression): string | null {
248
+ const parts: string[] = []
249
+ let current: ts.Expression = expression
250
+ while (ts.isPropertyAccessExpression(current)) {
251
+ parts.unshift(current.name.text)
252
+ current = current.expression
253
+ }
254
+ return ts.isIdentifier(current) && current.text === 'E' && parts.length === 2
255
+ ? `${parts[0]}:${parts[1]}`
256
+ : null
257
+ }
258
+
259
+ function staticTemplate(expression: ts.TemplateExpression, context: StaticContext): string | null {
260
+ let value = expression.head.text
261
+ for (const span of expression.templateSpans) {
262
+ const substitution = staticValue(span.expression, context)
263
+ if (typeof substitution !== 'string' && typeof substitution !== 'number') return null
264
+ value += `${substitution}${span.literal.text}`
265
+ }
266
+ return value
267
+ }
268
+
269
+ function staticObject(expression: ts.ObjectLiteralExpression, context: StaticContext): StaticObject {
270
+ const result: StaticObject = {}
271
+ for (const property of expression.properties) {
272
+ if (ts.isPropertyAssignment(property)) {
273
+ const name = propertyName(property.name)
274
+ if (!name) continue
275
+ const value = staticValue(property.initializer, context)
276
+ if (value !== undefined) result[name] = value
277
+ continue
278
+ }
279
+ if (ts.isShorthandPropertyAssignment(property)) {
280
+ const value = staticValue(property.name, context)
281
+ if (value !== undefined) result[property.name.text] = value
282
+ continue
283
+ }
284
+ if (ts.isMethodDeclaration(property)) {
285
+ const name = propertyName(property.name)
286
+ if (name) result[name] = true
287
+ continue
288
+ }
289
+ if (ts.isSpreadAssignment(property)) {
290
+ const value = staticValue(property.expression, context)
291
+ if (isStaticObject(value)) Object.assign(result, value)
292
+ }
293
+ }
294
+ return result
295
+ }
296
+
297
+ function staticValue(expression: ts.Expression, context: StaticContext): StaticValue | undefined {
298
+ const current = unwrap(expression)
299
+ if (ts.isStringLiteralLike(current) || ts.isNumericLiteral(current)) return current.text
300
+ if (current.kind === ts.SyntaxKind.TrueKeyword) return true
301
+ if (current.kind === ts.SyntaxKind.FalseKeyword) return false
302
+ if (current.kind === ts.SyntaxKind.NullKeyword) return null
303
+ if (ts.isNoSubstitutionTemplateLiteral(current)) return current.text
304
+ if (ts.isTemplateExpression(current)) return staticTemplate(current, context) ?? undefined
305
+ if (ts.isArrayLiteralExpression(current)) {
306
+ return current.elements.flatMap((element) => {
307
+ if (ts.isSpreadElement(element)) {
308
+ const spread = staticValue(element.expression, context)
309
+ return Array.isArray(spread) ? spread : []
310
+ }
311
+ const value = staticValue(element, context)
312
+ return value === undefined ? [] : [value]
313
+ })
314
+ }
315
+ if (ts.isObjectLiteralExpression(current)) return staticObject(current, context)
316
+ if (ts.isPropertyAccessExpression(current)) {
317
+ return entityRegistryValue(current) ?? undefined
318
+ }
319
+ if (ts.isIdentifier(current)) {
320
+ if (context.resolving.has(current.text)) return undefined
321
+ const initializer = context.initializers.get(current.text)
322
+ if (!initializer) return undefined
323
+ context.resolving.add(current.text)
324
+ const value = ts.isFunctionDeclaration(initializer) ? undefined : staticValue(initializer, context)
325
+ context.resolving.delete(current.text)
326
+ return value
327
+ }
328
+ if (ts.isCallExpression(current)) {
329
+ if (ts.isIdentifier(current.expression)) {
330
+ const callable = context.initializers.get(current.expression.text)
331
+ if (callable && (ts.isArrowFunction(callable) || ts.isFunctionExpression(callable) || ts.isFunctionDeclaration(callable))) {
332
+ const initializers = new Map(context.initializers)
333
+ callable.parameters.forEach((parameter, index) => {
334
+ if (ts.isIdentifier(parameter.name) && current.arguments[index]) {
335
+ initializers.set(parameter.name.text, current.arguments[index])
336
+ }
337
+ })
338
+ const childContext: StaticContext = {
339
+ initializers,
340
+ sourceFile: context.sourceFile,
341
+ resolving: new Set(context.resolving),
342
+ }
343
+ if (callable.body && ts.isExpression(callable.body)) return staticValue(callable.body, childContext)
344
+ const returnStatement = callable.body?.statements.find(ts.isReturnStatement)
345
+ if (returnStatement?.expression) return staticValue(returnStatement.expression, childContext)
346
+ }
347
+ }
348
+ const firstArgument = current.arguments[0]
349
+ if (firstArgument) {
350
+ const value = staticValue(firstArgument, context)
351
+ if (!isStaticObject(value) || !ts.isIdentifier(current.expression)) return value
352
+ if (current.expression.text === 'dataTableExtensionHost') return { family: 'data-table', ...value }
353
+ if (current.expression.text === 'crudFormExtensionHost') return { family: 'crud-form', ...value }
354
+ if (current.expression.text === 'componentExtensionHost') return { family: 'component-handle', ...value }
355
+ return value
356
+ }
357
+ }
358
+ if (ts.isConditionalExpression(current)) {
359
+ const left = staticValue(current.whenTrue, context)
360
+ const right = staticValue(current.whenFalse, context)
361
+ return left !== undefined && JSON.stringify(left) === JSON.stringify(right) ? left : undefined
362
+ }
363
+ return undefined
364
+ }
365
+
366
+ function isStaticObject(value: StaticValue | undefined): value is StaticObject {
367
+ return !!value && typeof value === 'object' && !Array.isArray(value)
368
+ }
369
+
370
+ function strings(value: StaticValue | undefined): string[] {
371
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
372
+ }
373
+
374
+ function stringValue(value: StaticValue | undefined): string | undefined {
375
+ return typeof value === 'string' ? value : undefined
376
+ }
377
+
378
+ function numberValue(value: StaticValue | undefined): number | undefined {
379
+ if (typeof value === 'number') return value
380
+ if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value)
381
+ return undefined
382
+ }
383
+
384
+ function booleanValue(value: StaticValue | undefined): boolean | undefined {
385
+ return typeof value === 'boolean' ? value : undefined
386
+ }
387
+
388
+ function portablePath(moduleRoot: string, sourceRoot: string, filePath: string): string {
389
+ return path.posix.join(sourceRoot, path.relative(moduleRoot, filePath).split(path.sep).join('/'))
390
+ }
391
+
392
+ function conventionPath(moduleRoot: string, relativePath: string): string | null {
393
+ const direct = path.join(moduleRoot, relativePath)
394
+ if (fs.existsSync(direct)) return direct
395
+ if (relativePath.endsWith('.ts')) {
396
+ const tsx = `${direct.slice(0, -3)}.tsx`
397
+ if (fs.existsSync(tsx)) return tsx
398
+ }
399
+ return null
400
+ }
401
+
402
+ export function readConventionObjectArray(filePath: string, exportName: string): StaticObject[] {
403
+ const file = sourceFile(filePath)
404
+ if (!file) return []
405
+ const context = buildStaticContext(file)
406
+ const initializer = context.initializers.get(exportName)
407
+ if (!initializer || ts.isFunctionDeclaration(initializer)) return []
408
+ const value = staticValue(initializer, context)
409
+ return Array.isArray(value) ? value.filter(isStaticObject) : []
410
+ }
411
+
412
+ function readRootObject(filePath: string, variableName: string): StaticObject | null {
413
+ const file = sourceFile(filePath)
414
+ if (!file) return null
415
+ const context = buildStaticContext(file)
416
+ const initializer = context.initializers.get(variableName)
417
+ if (!initializer || ts.isFunctionDeclaration(initializer)) return null
418
+ const value = staticValue(initializer, context)
419
+ return isStaticObject(value) ? value : null
420
+ }
421
+
422
+ function readCallObjectArguments(filePath: string, calleeNames: ReadonlySet<string>): Array<{
423
+ callee: string
424
+ value: StaticObject
425
+ }> {
426
+ const file = sourceFile(filePath)
427
+ if (!file) return []
428
+ const context = buildStaticContext(file)
429
+ const result: Array<{ callee: string; value: StaticObject }> = []
430
+ const visit = (node: ts.Node): void => {
431
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && calleeNames.has(node.expression.text)) {
432
+ const firstArgument = node.arguments[0]
433
+ const value = firstArgument ? staticValue(firstArgument, context) : undefined
434
+ if (isStaticObject(value)) result.push({ callee: node.expression.text, value })
435
+ }
436
+ node.forEachChild(visit)
437
+ }
438
+ file.forEachChild(visit)
439
+ return result
440
+ }
441
+
442
+ function sortHosts(hosts: ModuleExtensionHostFact[]): ModuleExtensionHostFact[] {
443
+ const sourceKey = (host: ModuleExtensionHostFact): string => host.source.kind === 'fact-ref'
444
+ ? `${host.source.factSection}:${host.source.factKey}`
445
+ : `${host.source.path}:${host.source.symbol}`
446
+ return hosts.sort((left, right) =>
447
+ left.family.localeCompare(right.family)
448
+ || left.id.localeCompare(right.id)
449
+ || sourceKey(left).localeCompare(sourceKey(right))
450
+ || left.key.localeCompare(right.key))
451
+ }
452
+
453
+ function sortContributions(contributions: ModuleExtensionContributionFact[]): ModuleExtensionContributionFact[] {
454
+ const targetKey = (contribution: ModuleExtensionContributionFact): string => contribution.targets
455
+ .map((entry) => entry.id)
456
+ .join('\u0000')
457
+ const sourceKey = (contribution: ModuleExtensionContributionFact): string =>
458
+ `${contribution.source.path}:${contribution.source.symbol ?? ''}`
459
+ return contributions.sort((left, right) =>
460
+ left.kind.localeCompare(right.kind)
461
+ || left.id.localeCompare(right.id)
462
+ || targetKey(left).localeCompare(targetKey(right))
463
+ || sourceKey(left).localeCompare(sourceKey(right)))
464
+ }
465
+
466
+ function target(id: string, resolution: ModuleExtensionTargetFact['resolution'] = 'unresolved'): ModuleExtensionTargetFact {
467
+ return { id, resolution }
468
+ }
469
+
470
+ function factTarget(id: string, factSection: string, factKey = id): ModuleExtensionTargetFact {
471
+ return { id, resolution: 'fact-ref', factRef: { factSection, factKey } }
472
+ }
473
+
474
+ function openTarget(id: string): ModuleExtensionTargetFact {
475
+ return target(id)
476
+ }
477
+
478
+ function declarationSource(sourceRoot: string, symbol: string): ModuleExtensionHostFact['source'] {
479
+ return { kind: 'declaration', path: path.posix.join(sourceRoot, 'extension-points.ts'), symbol }
480
+ }
481
+
482
+ function hasDeclarationBinding(
483
+ moduleRoot: string,
484
+ hostKey: string,
485
+ declaration: StaticObject,
486
+ ): boolean {
487
+ const relativeSource = stringValue(declaration.source)
488
+ if (!relativeSource) return false
489
+ const bindingFile = sourceFile(path.join(moduleRoot, relativeSource))
490
+ if (!bindingFile) return false
491
+ const escapedHostKey = hostKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
492
+ const bindingPattern = new RegExp(
493
+ `extensionPoints\\s*\\.\\s*hosts(?:\\s*\\.\\s*${escapedHostKey}|\\s*\\[\\s*['\"]${escapedHostKey}['\"]\\s*\\])`,
494
+ )
495
+ if (bindingPattern.test(bindingFile.text)) return true
496
+ return declaration.family === 'integration'
497
+ && typeof declaration.pattern === 'string'
498
+ && /resolveIntegrationDetailWidgetSpotId\s*\(/.test(bindingFile.text)
499
+ }
500
+
501
+ function baseHost(options: {
502
+ key: string
503
+ id: string
504
+ moduleId: string
505
+ family: ExtensionHostFamily
506
+ capabilities: ExtensionHostCapability[]
507
+ sourceRoot: string
508
+ declaration: StaticObject
509
+ bound?: boolean
510
+ resolution?: ModuleExtensionHostFact['resolution']
511
+ }): ModuleExtensionHostFact {
512
+ const { declaration } = options
513
+ const aliases = strings(declaration.aliases)
514
+ const fallbacks = strings(declaration.fallbacks)
515
+ const fact: ModuleExtensionHostFact = {
516
+ key: options.key,
517
+ id: options.id,
518
+ resolution: options.resolution ?? (options.id.includes('{') || options.id.includes('*') ? 'pattern' : 'exact'),
519
+ family: options.family,
520
+ ownerModule: options.moduleId,
521
+ capabilities: options.capabilities,
522
+ bound: options.bound ?? true,
523
+ stability: 'frozen',
524
+ source: declarationSource(options.sourceRoot, `extensionPoints.hosts.${options.key.split('.')[0]}`),
525
+ }
526
+ const contextContract = stringValue(declaration.contextContract)
527
+ const dataContract = stringValue(declaration.dataContract)
528
+ const scopeContract = stringValue(declaration.scopeContract)
529
+ const runtimeContract = stringValue(declaration.runtimeContract)
530
+ const activation = stringValue(declaration.activation) as ModuleExtensionHostFact['activation']
531
+ if (contextContract) fact.contextContract = contextContract
532
+ if (dataContract) fact.dataContract = dataContract
533
+ if (scopeContract) fact.scopeContract = scopeContract
534
+ if (runtimeContract) fact.runtimeContract = runtimeContract
535
+ if (activation) fact.activation = activation
536
+ if (aliases.length > 0) fact.aliases = aliases
537
+ if (fallbacks.length > 0) fact.fallbacks = fallbacks
538
+ return fact
539
+ }
540
+
541
+ function extractDeclaredHosts(options: ExtractModuleExtensionFactsOptions): {
542
+ hosts: ModuleExtensionHostFact[]
543
+ unresolved: ModuleExtensionUnresolvedFact[]
544
+ } {
545
+ const filePath = conventionPath(options.moduleRoot, 'extension-points.ts')
546
+ if (!filePath) return { hosts: [], unresolved: [] }
547
+ const declaration = readRootObject(filePath, 'extensionPoints')
548
+ const hostsObject = declaration && isStaticObject(declaration.hosts) ? declaration.hosts : null
549
+ if (!hostsObject) {
550
+ return {
551
+ hosts: [],
552
+ unresolved: [{
553
+ key: `${options.moduleId}.extension-points`,
554
+ source: { path: portablePath(options.moduleRoot, options.sourceRoot, filePath), symbol: 'extensionPoints' },
555
+ reason: 'unclassified-binding',
556
+ }],
557
+ }
558
+ }
559
+
560
+ const hosts: ModuleExtensionHostFact[] = []
561
+ const unresolved: ModuleExtensionUnresolvedFact[] = []
562
+ for (const hostKey of Object.keys(hostsObject).sort((left, right) => left.localeCompare(right))) {
563
+ const host = hostsObject[hostKey]
564
+ if (!isStaticObject(host)) continue
565
+ const family = stringValue(host.family) as ExtensionHostFamily | undefined
566
+ if (!family) continue
567
+ const declarationBound = hasDeclarationBinding(options.moduleRoot, hostKey, host)
568
+ if (!declarationBound) {
569
+ unresolved.push({
570
+ key: `${options.moduleId}.${hostKey}`,
571
+ source: {
572
+ path: stringValue(host.source) ?? portablePath(options.moduleRoot, options.sourceRoot, filePath),
573
+ symbol: `extensionPoints.hosts.${hostKey}`,
574
+ },
575
+ reason: 'unbound-declaration',
576
+ })
577
+ }
578
+ if (family === 'data-table') {
579
+ const tableId = stringValue(host.tableId)
580
+ if (!tableId) continue
581
+ const baseSpotId = stringValue(host.baseSpotId) ?? `data-table:${tableId}`
582
+ for (const surface of DATA_TABLE_EXTENSION_SURFACES) {
583
+ const usesBase = ['header', 'footer', 'toolbar', 'searchTrailing', 'emptyState'].includes(surface.key)
584
+ const id = surface.key === 'replacement'
585
+ ? `data-table:${tableId}`
586
+ : `${usesBase ? baseSpotId : `data-table:${tableId}`}:${surface.suffix}`
587
+ hosts.push(baseHost({
588
+ key: `${hostKey}.${surface.key}`,
589
+ id,
590
+ moduleId: options.moduleId,
591
+ family,
592
+ capabilities: [...surface.capabilities],
593
+ sourceRoot: options.sourceRoot,
594
+ declaration: host,
595
+ bound: surface.bound && declarationBound,
596
+ }))
597
+ }
598
+ continue
599
+ }
600
+ if (family === 'crud-form') {
601
+ const entityId = stringValue(host.entityId)
602
+ if (!entityId) continue
603
+ const spotId = stringValue(host.spotId) ?? `crud-form:${entityId}`
604
+ for (const surface of CRUD_FORM_EXTENSION_SURFACES) {
605
+ const id = surface.suffix ? `${spotId}:${surface.suffix}` : spotId
606
+ const fact = baseHost({
607
+ key: `${hostKey}.${surface.key}`,
608
+ id,
609
+ moduleId: options.moduleId,
610
+ family,
611
+ capabilities: [...surface.capabilities],
612
+ sourceRoot: options.sourceRoot,
613
+ declaration: host,
614
+ bound: surface.bound && declarationBound,
615
+ })
616
+ if (surface.key === 'base') fact.phases = [...CRUD_FORM_LIFECYCLE_PHASES]
617
+ hosts.push(fact)
618
+ }
619
+ continue
620
+ }
621
+ const id = stringValue(host.spotId) ?? stringValue(host.pattern) ?? stringValue(host.componentId)
622
+ if (!id) {
623
+ unresolved.push({
624
+ key: `${options.moduleId}.${hostKey}`,
625
+ source: { path: portablePath(options.moduleRoot, options.sourceRoot, filePath), symbol: hostKey },
626
+ reason: 'dynamic-without-pattern',
627
+ })
628
+ continue
629
+ }
630
+ const capabilities = family === 'component-handle'
631
+ ? ['component-replacement'] as ExtensionHostCapability[]
632
+ : strings(host.supported) as ExtensionHostCapability[]
633
+ const fact = baseHost({
634
+ key: hostKey,
635
+ id,
636
+ moduleId: options.moduleId,
637
+ family,
638
+ capabilities,
639
+ sourceRoot: options.sourceRoot,
640
+ declaration: host,
641
+ bound: declarationBound,
642
+ })
643
+ if (isStaticObject(host.parameters)) {
644
+ fact.patternParameters = Object.fromEntries(Object.entries(host.parameters).flatMap(([key, value]) => {
645
+ if (!isStaticObject(value) || typeof value.source !== 'string') return []
646
+ return [[key, {
647
+ source: value.source,
648
+ ...(typeof value.pattern === 'string' ? { pattern: value.pattern } : {}),
649
+ }]]
650
+ }))
651
+ }
652
+ hosts.push(fact)
653
+ }
654
+ return { hosts: sortHosts(hosts), unresolved }
655
+ }
656
+
657
+ function factRefHost(options: {
658
+ key: string
659
+ id: string
660
+ moduleId: string
661
+ family: ExtensionHostFamily
662
+ capabilities: ExtensionHostCapability[]
663
+ factSection: string
664
+ /** Key inside `factSection`; defaults to the host id when the two coincide. */
665
+ factKey?: string
666
+ phases?: string[]
667
+ activation?: ModuleExtensionHostFact['activation']
668
+ scopeContract?: string
669
+ }): ModuleExtensionHostFact {
670
+ return {
671
+ key: options.key,
672
+ id: options.id,
673
+ resolution: 'fact-ref',
674
+ family: options.family,
675
+ ownerModule: options.moduleId,
676
+ capabilities: options.capabilities,
677
+ ...(options.phases ? { phases: options.phases } : {}),
678
+ ...(options.activation ? { activation: options.activation } : {}),
679
+ ...(options.scopeContract ? { scopeContract: options.scopeContract } : {}),
680
+ bound: true,
681
+ stability: 'stable',
682
+ source: { kind: 'fact-ref', factSection: options.factSection, factKey: options.factKey ?? options.id },
683
+ }
684
+ }
685
+
686
+ function sourceFilesBelow(directory: string): string[] {
687
+ const cached = activeExtractionCache?.sourceFilesBelow.get(directory)
688
+ if (cached) return cached
689
+ if (!fs.existsSync(directory)) return []
690
+ const files: string[] = []
691
+ const visit = (current: string): void => {
692
+ for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
693
+ const entryPath = path.join(current, entry.name)
694
+ if (entry.isDirectory()) {
695
+ if (entry.name !== '__tests__' && entry.name !== '__integration__') visit(entryPath)
696
+ continue
697
+ }
698
+ if (entry.isFile() && /\.(?:ts|tsx)$/.test(entry.name) && !/\.(?:test|spec)\./.test(entry.name)) files.push(entryPath)
699
+ }
700
+ }
701
+ visit(directory)
702
+ activeExtractionCache?.sourceFilesBelow.set(directory, files)
703
+ return files
704
+ }
705
+
706
+ function apiRouteId(moduleId: string, moduleRoot: string, filePath: string): string | null {
707
+ const relative = path.relative(path.join(moduleRoot, 'api'), filePath).split(path.sep).join('/')
708
+ if (relative === 'interceptors.ts' || relative === 'openapi.ts') return null
709
+ const withoutExtension = relative.replace(/\.tsx?$/, '')
710
+ const suffix = withoutExtension.endsWith('/route') ? withoutExtension.slice(0, -6) : withoutExtension
711
+ return suffix && !suffix.includes('__tests__') ? `${moduleId.replace(/_/g, '-')}/${suffix}` : null
712
+ }
713
+
714
+ function apiExtensionHostIds(moduleId: string, moduleRoot: string): ApiExtensionHostIds {
715
+ const cacheKey = `${moduleId}\0${moduleRoot}`
716
+ const cached = activeExtractionCache?.apiExtensionHostIds.get(cacheKey)
717
+ if (cached) return cached
718
+ const entityIds = new Set<string>()
719
+ const entitySourceFiles: Record<string, string> = {}
720
+ const commandIds = new Set<string>()
721
+ const routeIds = new Set<string>()
722
+ for (const filePath of sourceFilesBelow(path.join(moduleRoot, 'api'))) {
723
+ const routeId = apiRouteId(moduleId, moduleRoot, filePath)
724
+ if (routeId) routeIds.add(routeId)
725
+ const file = sourceFile(filePath)
726
+ if (!file) continue
727
+ const context = buildStaticContext(file)
728
+ const visit = (node: ts.Node): void => {
729
+ if (ts.isPropertyAssignment(node)) {
730
+ const name = propertyName(node.name)
731
+ if (name === 'commandId') {
732
+ const id = stringValue(staticValue(node.initializer, context))
733
+ if (id) commandIds.add(id)
734
+ }
735
+ if (name === 'enrichers') {
736
+ const config = staticValue(node.initializer, context)
737
+ const entityId = isStaticObject(config) ? stringValue(config.entityId) : undefined
738
+ if (entityId) {
739
+ entityIds.add(entityId)
740
+ if (!entitySourceFiles[entityId]) entitySourceFiles[entityId] = filePath
741
+ }
742
+ }
743
+ }
744
+ if (
745
+ ts.isCallExpression(node)
746
+ && ts.isIdentifier(node.expression)
747
+ && node.expression.text === 'validateCrudMutationGuard'
748
+ ) {
749
+ const input = node.arguments[1] ? staticValue(node.arguments[1], context) : undefined
750
+ const entityId = isStaticObject(input) ? stringValue(input.resourceKind) : undefined
751
+ if (entityId) {
752
+ entityIds.add(entityId)
753
+ if (!entitySourceFiles[entityId]) entitySourceFiles[entityId] = filePath
754
+ }
755
+ }
756
+ node.forEachChild(visit)
757
+ }
758
+ file.forEachChild(visit)
759
+ }
760
+ const result = {
761
+ entityIds: [...entityIds].sort((left, right) => left.localeCompare(right)),
762
+ entitySourceFiles,
763
+ commandIds: [...commandIds].sort((left, right) => left.localeCompare(right)),
764
+ routeIds: [...routeIds].sort((left, right) => left.localeCompare(right)),
765
+ }
766
+ activeExtractionCache?.apiExtensionHostIds.set(cacheKey, result)
767
+ return result
768
+ }
769
+
770
+ function moduleCommandIds(moduleRoot: string, apiCommandIds: readonly string[]): string[] {
771
+ const commandIds = new Set(apiCommandIds)
772
+ for (const filePath of sourceFilesBelow(path.join(moduleRoot, 'commands'))) {
773
+ if (path.basename(filePath).replace(/\.(?:ts|tsx)$/, '') === 'interceptors') continue
774
+ for (const id of extractCommandIdsFromSource(filePath)) commandIds.add(id)
775
+ }
776
+ return [...commandIds].sort((left, right) => left.localeCompare(right))
777
+ }
778
+
779
+ export function extractKnownApiRouteIds(moduleId: string, moduleRoot: string): string[] {
780
+ return apiExtensionHostIds(moduleId, moduleRoot).routeIds
781
+ }
782
+
783
+ export function extractKnownCommandIds(moduleId: string, moduleRoot: string): string[] {
784
+ const apiHosts = apiExtensionHostIds(moduleId, moduleRoot)
785
+ return moduleCommandIds(moduleRoot, apiHosts.commandIds)
786
+ }
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
+
924
+ function extractFactRefHosts(options: ExtractModuleExtensionFactsOptions): ModuleExtensionHostFact[] {
925
+ const hosts: ModuleExtensionHostFact[] = []
926
+ for (const entity of options.entities) {
927
+ hosts.push(factRefHost({
928
+ key: `entity.${entity.id}`,
929
+ id: entity.id,
930
+ moduleId: options.moduleId,
931
+ family: 'entity',
932
+ capabilities: ['response-enricher', 'query-enricher', 'mutation-guard', 'entity-extension'],
933
+ factSection: 'entities',
934
+ activation: 'host-opt-in',
935
+ scopeContract: 'tenant-and-organization',
936
+ }))
937
+ }
938
+ for (const route of options.apiRoutes) {
939
+ hosts.push(factRefHost({
940
+ key: `api-route.${route.path}`,
941
+ id: route.path,
942
+ moduleId: options.moduleId,
943
+ family: 'api-route',
944
+ capabilities: ['api-interceptor', 'mutation-guard'],
945
+ factSection: 'apiRoutes',
946
+ phases: ['before', 'after'],
947
+ activation: 'host-opt-in',
948
+ scopeContract: 'request-auth-scope',
949
+ }))
950
+ }
951
+ for (const event of options.events) {
952
+ const capabilities: ExtensionHostCapability[] = ['async-subscriber', 'sync-subscriber']
953
+ if (event.clientBroadcast) capabilities.push('browser-client')
954
+ if (event.portalBroadcast) capabilities.push('browser-portal')
955
+ hosts.push(factRefHost({
956
+ key: `event.${event.id}`,
957
+ id: event.id,
958
+ moduleId: options.moduleId,
959
+ family: 'event',
960
+ capabilities,
961
+ factSection: 'events',
962
+ scopeContract: 'tenant-organization-and-audience',
963
+ }))
964
+ }
965
+ for (const entityId of options.searchEntities) {
966
+ const eventStem = entityId.replace(':', '.')
967
+ for (const phase of ['querying', 'queried'] as const) {
968
+ hosts.push(factRefHost({
969
+ key: `query-lifecycle.${eventStem}.${phase}`,
970
+ id: `${eventStem}.${phase}`,
971
+ moduleId: options.moduleId,
972
+ family: 'query-lifecycle',
973
+ capabilities: ['sync-subscriber'],
974
+ factSection: 'searchEntities',
975
+ factKey: entityId,
976
+ phases: phase === 'querying' ? ['block', 'query-transform'] : ['result-transform', 'scope-reapply'],
977
+ activation: 'caller-opt-in',
978
+ scopeContract: 'tenant-and-organization-reapplied-after-result',
979
+ }))
980
+ }
981
+ }
982
+ const apiHosts = apiExtensionHostIds(options.moduleId, options.moduleRoot)
983
+ for (const entityId of apiHosts.entityIds) {
984
+ const declaringFile = apiHosts.entitySourceFiles[entityId]
985
+ const host = factRefHost({
986
+ key: `api-entity.${entityId}`,
987
+ id: entityId,
988
+ moduleId: options.moduleId,
989
+ family: 'entity',
990
+ capabilities: ['response-enricher', 'query-enricher', 'mutation-guard'],
991
+ factSection: 'apiRoutes',
992
+ activation: 'host-opt-in',
993
+ scopeContract: 'tenant-and-organization',
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)
1005
+ }
1006
+ return sortHosts([...new Map(hosts.map((host) => [`${host.family}:${host.id}`, host])).values()])
1007
+ }
1008
+
1009
+ function contributionBase(
1010
+ id: string,
1011
+ sourcePath: string,
1012
+ sourceSymbol?: string,
1013
+ ): ModuleExtensionContributionBase {
1014
+ return {
1015
+ id,
1016
+ targets: [],
1017
+ scopeContract: 'tenant-and-organization',
1018
+ source: { path: sourcePath, ...(sourceSymbol ? { symbol: sourceSymbol } : {}) },
1019
+ }
1020
+ }
1021
+
1022
+ function extractInjectionTable(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1023
+ const filePath = conventionPath(options.moduleRoot, 'widgets/injection-table.ts')
1024
+ if (!filePath) return []
1025
+ const table = readRootObject(filePath, 'injectionTable')
1026
+ if (!table) return []
1027
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, filePath)
1028
+ const facts: ModuleExtensionContributionFact[] = []
1029
+ for (const targetId of Object.keys(table).sort((left, right) => left.localeCompare(right))) {
1030
+ const entries = table[targetId]
1031
+ if (!Array.isArray(entries)) continue
1032
+ for (const entry of entries) {
1033
+ if (!isStaticObject(entry)) continue
1034
+ const widgetId = stringValue(entry.widgetId)
1035
+ if (!widgetId) continue
1036
+ const payload = targetId.endsWith(':columns') ? 'column'
1037
+ : targetId.endsWith(':row-actions') ? 'row-action'
1038
+ : targetId.endsWith(':bulk-actions') ? 'bulk-action'
1039
+ : targetId.endsWith(':filters') ? 'filter'
1040
+ : targetId.endsWith(':fields') ? 'field'
1041
+ : 'render'
1042
+ const base = contributionBase(`${widgetId}@${targetId}`, sourcePath, 'injectionTable')
1043
+ const shared = {
1044
+ ...base,
1045
+ targets: [target(targetId)],
1046
+ features: strings(entry.features),
1047
+ placement: numberValue(entry.priority) !== undefined ? { priority: numberValue(entry.priority) } : undefined,
1048
+ }
1049
+ if (targetId.startsWith('data-table:')) {
1050
+ facts.push({
1051
+ ...shared,
1052
+ kind: 'data-table',
1053
+ details: {
1054
+ payload: payload === 'field' ? 'render' : payload,
1055
+ tableId: targetId.replace(/^data-table:/, '').replace(/:(?:columns|row-actions|bulk-actions|filters|toolbar|header|footer|search-trailing)$/, ''),
1056
+ executionGuard: strings(entry.features).length > 0 ? 'both' : 'host',
1057
+ },
1058
+ })
1059
+ } else if (targetId.startsWith('crud-form:')) {
1060
+ facts.push({
1061
+ ...shared,
1062
+ kind: 'crud-form',
1063
+ details: {
1064
+ payload: payload === 'field' ? 'field' : 'render',
1065
+ entityId: targetId.replace(/^crud-form:/, '').replace(/:(?:fields|header)$/, ''),
1066
+ requestHeaderCapability: payload === 'field',
1067
+ },
1068
+ })
1069
+ } else {
1070
+ facts.push({
1071
+ ...shared,
1072
+ kind: 'widget',
1073
+ details: {
1074
+ payload: 'render',
1075
+ registryKey: widgetId,
1076
+ executionGuard: strings(entry.features).length > 0 ? 'both' : 'host',
1077
+ },
1078
+ })
1079
+ }
1080
+ }
1081
+ }
1082
+ return facts
1083
+ }
1084
+
1085
+ function extractObjectConvention(options: {
1086
+ module: ExtractModuleExtensionFactsOptions
1087
+ relativePath: string
1088
+ exportName: string
1089
+ exportAliases?: string[]
1090
+ build: (entry: StaticObject, sourcePath: string, index: number) => ModuleExtensionContributionFact | null
1091
+ }): ModuleExtensionContributionFact[] {
1092
+ const filePath = conventionPath(options.module.moduleRoot, options.relativePath)
1093
+ if (!filePath) return []
1094
+ const exportNames = [options.exportName, ...(options.exportAliases ?? [])]
1095
+ const entries = exportNames.reduce<StaticObject[]>((found, exportName) =>
1096
+ found.length > 0 ? found : readConventionObjectArray(filePath, exportName), [])
1097
+ const sourcePath = portablePath(options.module.moduleRoot, options.module.sourceRoot, filePath)
1098
+ return entries.flatMap((entry, index) => options.build(entry, sourcePath, index) ?? [])
1099
+ }
1100
+
1101
+ function methodPhases(entry: StaticObject, map: ReadonlyArray<[string, string]>): string[] {
1102
+ return map.flatMap(([property, phase]) => entry[property] === true ? [phase] : [])
1103
+ }
1104
+
1105
+ function extractEnrichers(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1106
+ return extractObjectConvention({
1107
+ module: options,
1108
+ relativePath: 'data/enrichers.ts',
1109
+ exportName: 'enrichers',
1110
+ build(entry, sourcePath, index) {
1111
+ const id = stringValue(entry.id) ?? `${options.moduleId}.enricher.${index}`
1112
+ const targetEntity = stringValue(entry.targetEntity)
1113
+ if (!targetEntity) return null
1114
+ const timeoutMs = numberValue(entry.timeout) ?? 2000
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
1121
+ const surfaces = [entry.enrichMany === true ? 'list' : null, entry.enrichOne === true ? 'detail' : null]
1122
+ .filter((value): value is 'list' | 'detail' => value !== null)
1123
+ const base = contributionBase(id, sourcePath, id)
1124
+ return {
1125
+ ...base,
1126
+ kind: 'response-enricher',
1127
+ targets: [openTarget(targetEntity)],
1128
+ features: strings(entry.features),
1129
+ phases: surfaces,
1130
+ activation: queryEngine ? 'caller-opt-in' : 'host-opt-in',
1131
+ roundTripId: `${targetEntity}:read`,
1132
+ details: {
1133
+ targetEntity,
1134
+ surfaces,
1135
+ timeoutMs,
1136
+ fallback: entry.fallback === undefined ? 'none' : 'configured',
1137
+ critical: booleanValue(entry.critical) ?? false,
1138
+ cachePosture: 'rerun-on-list-cache-hit',
1139
+ ...(queryEngine ? { queryEngine: {
1140
+ engines: strings(queryEngine.engines),
1141
+ applyOn: (strings(queryEngine.applyOn).length > 0 ? strings(queryEngine.applyOn) : ['list', 'detail'])
1142
+ .filter((value): value is 'list' | 'detail' => value === 'list' || value === 'detail'),
1143
+ activation: 'caller-opt-in' as const,
1144
+ } } : {}),
1145
+ },
1146
+ }
1147
+ },
1148
+ })
1149
+ }
1150
+
1151
+ function extractApiInterceptors(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1152
+ return extractObjectConvention({
1153
+ module: options,
1154
+ relativePath: 'api/interceptors.ts',
1155
+ exportName: 'interceptors',
1156
+ build(entry, sourcePath, index) {
1157
+ const id = stringValue(entry.id) ?? `${options.moduleId}.api-interceptor.${index}`
1158
+ const targetRoute = stringValue(entry.targetRoute)
1159
+ if (!targetRoute) return null
1160
+ const phases = methodPhases(entry, [['before', 'before'], ['after', 'after']])
1161
+ .filter((phase): phase is 'before' | 'after' => phase === 'before' || phase === 'after')
1162
+ const methods = strings(entry.methods)
1163
+ const base = contributionBase(id, sourcePath, id)
1164
+ return {
1165
+ ...base,
1166
+ kind: 'api-interceptor',
1167
+ targets: [openTarget(targetRoute)],
1168
+ phases,
1169
+ features: strings(entry.features),
1170
+ details: {
1171
+ route: targetRoute,
1172
+ methods,
1173
+ phases,
1174
+ activation: targetRoute.startsWith('/') ? 'custom-route-bridge' : 'crud-pipeline',
1175
+ timeoutMs: numberValue(entry.timeout) ?? 2000,
1176
+ failurePosture: booleanValue(entry.critical) ? 'fail-closed' : 'fallback',
1177
+ },
1178
+ }
1179
+ },
1180
+ })
1181
+ }
1182
+
1183
+ function extractCommandInterceptors(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1184
+ return extractObjectConvention({
1185
+ module: options,
1186
+ relativePath: 'commands/interceptors.ts',
1187
+ exportName: 'interceptors',
1188
+ build(entry, sourcePath, index) {
1189
+ const id = stringValue(entry.id) ?? `${options.moduleId}.command-interceptor.${index}`
1190
+ const targetCommand = stringValue(entry.targetCommand)
1191
+ if (!targetCommand) return null
1192
+ const phases = methodPhases(entry, [
1193
+ ['beforeExecute', 'before-execute'],
1194
+ ['afterExecute', 'after-execute'],
1195
+ ['beforeUndo', 'before-undo'],
1196
+ ['afterUndo', 'after-undo'],
1197
+ ]).filter((phase): phase is 'before-execute' | 'after-execute' | 'before-undo' | 'after-undo' =>
1198
+ phase === 'before-execute' || phase === 'after-execute' || phase === 'before-undo' || phase === 'after-undo')
1199
+ const base = contributionBase(id, sourcePath, id)
1200
+ return {
1201
+ ...base,
1202
+ kind: 'command-interceptor',
1203
+ targets: [openTarget(targetCommand)],
1204
+ phases,
1205
+ features: strings(entry.features),
1206
+ details: { targetCommand, phases },
1207
+ }
1208
+ },
1209
+ })
1210
+ }
1211
+
1212
+ function extractGuards(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1213
+ return extractObjectConvention({
1214
+ module: options,
1215
+ relativePath: 'data/guards.ts',
1216
+ exportName: 'guards',
1217
+ build(entry, sourcePath, index) {
1218
+ const id = stringValue(entry.id) ?? `${options.moduleId}.guard.${index}`
1219
+ const targetEntity = stringValue(entry.targetEntity)
1220
+ if (!targetEntity) return null
1221
+ const operations = strings(entry.operations)
1222
+ .filter((operation): operation is 'create' | 'update' | 'delete' =>
1223
+ operation === 'create' || operation === 'update' || operation === 'delete')
1224
+ const capabilities = [entry.validate === true ? 'block' : null, entry.validate === true ? 'rewrite' : null, entry.afterSuccess === true ? 'after-success' : null]
1225
+ .filter((value): value is 'block' | 'rewrite' | 'after-success' => value !== null)
1226
+ const base = contributionBase(id, sourcePath, id)
1227
+ return {
1228
+ ...base,
1229
+ kind: 'mutation-guard',
1230
+ targets: [openTarget(targetEntity)],
1231
+ operations,
1232
+ features: strings(entry.features),
1233
+ roundTripId: `${targetEntity}:write`,
1234
+ details: { entityId: targetEntity, operations, capabilities, optimisticLock: 'preserved' },
1235
+ }
1236
+ },
1237
+ })
1238
+ }
1239
+
1240
+ function extractEntityExtensions(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1241
+ return extractObjectConvention({
1242
+ module: options,
1243
+ relativePath: 'data/extensions.ts',
1244
+ exportName: 'entityExtensions',
1245
+ exportAliases: ['extensions'],
1246
+ build(entry, sourcePath, index) {
1247
+ const baseEntity = stringValue(entry.base)
1248
+ const extensionEntity = stringValue(entry.extension)
1249
+ const join = isStaticObject(entry.join) ? entry.join : null
1250
+ if (!baseEntity || !extensionEntity || !join) return null
1251
+ const id = `${options.moduleId}.entity-extension.${index}:${baseEntity}->${extensionEntity}`
1252
+ const contribution = contributionBase(id, sourcePath, 'extensions')
1253
+ return {
1254
+ ...contribution,
1255
+ kind: 'entity-extension',
1256
+ targets: [openTarget(baseEntity)],
1257
+ details: {
1258
+ hostEntityId: baseEntity,
1259
+ extensionEntityId: extensionEntity,
1260
+ linkId: `${stringValue(join.baseKey) ?? 'id'}:${stringValue(join.extensionKey) ?? 'id'}`,
1261
+ scopeContract: 'tenant-and-organization',
1262
+ orphanContract: booleanValue(entry.required) ? 'required' : 'optional',
1263
+ },
1264
+ }
1265
+ },
1266
+ })
1267
+ }
1268
+
1269
+ function extractSubscribers(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1270
+ const directory = path.join(options.moduleRoot, 'subscribers')
1271
+ if (!fs.existsSync(directory)) return []
1272
+ const facts: ModuleExtensionContributionFact[] = []
1273
+ for (const filePath of sourceFilesBelow(directory)) {
1274
+ const metadata = readRootObject(filePath, 'metadata')
1275
+ if (!metadata) continue
1276
+ const event = stringValue(metadata.event)
1277
+ if (!event) continue
1278
+ const relativeStem = path.relative(directory, filePath).replace(/\.tsx?$/, '').split(path.sep).join('/')
1279
+ const subscriberId = stringValue(metadata.id) ?? `${options.moduleId}:${relativeStem}`
1280
+ const persistent = booleanValue(metadata.persistent) ?? false
1281
+ const sync = booleanValue(metadata.sync) ?? !persistent
1282
+ const contribution = contributionBase(subscriberId, portablePath(options.moduleRoot, options.sourceRoot, filePath), 'metadata')
1283
+ facts.push({
1284
+ ...contribution,
1285
+ kind: 'subscriber',
1286
+ targets: [openTarget(event)],
1287
+ phases: sync ? ['before-or-after'] : ['async-delivery'],
1288
+ details: {
1289
+ event,
1290
+ subscriberId,
1291
+ persistent,
1292
+ sync,
1293
+ ...(numberValue(metadata.priority) !== undefined ? { priority: numberValue(metadata.priority) as number } : {}),
1294
+ },
1295
+ })
1296
+ }
1297
+ return facts
1298
+ }
1299
+
1300
+ function extractBrowserReactions(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1301
+ return options.events.flatMap((event) => {
1302
+ const transports = [event.clientBroadcast ? 'client' : null, event.portalBroadcast ? 'portal' : null]
1303
+ .filter((value): value is 'client' | 'portal' => value !== null)
1304
+ if (transports.length === 0) return []
1305
+ const contribution = contributionBase(`${event.id}.browser`, path.posix.join(options.sourceRoot, 'events.ts'), 'events')
1306
+ return [{
1307
+ ...contribution,
1308
+ kind: 'browser-reaction' as const,
1309
+ targets: [factTarget(event.id, 'events')],
1310
+ details: {
1311
+ transports,
1312
+ hooks: transports.flatMap((transport) => transport === 'client' ? ['useAppEvent', 'useOperationProgress'] : ['usePortalAppEvent']),
1313
+ audienceScopeContract: 'tenant-organization-user-role-and-customer',
1314
+ },
1315
+ }]
1316
+ })
1317
+ }
1318
+
1319
+ function extractNotificationReactions(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1320
+ return extractObjectConvention({
1321
+ module: options,
1322
+ relativePath: 'notifications.handlers.ts',
1323
+ exportName: 'notificationHandlers',
1324
+ build(entry, sourcePath, index) {
1325
+ const notificationType = stringValue(entry.notificationType)
1326
+ if (!notificationType) return null
1327
+ const declaredId = stringValue(entry.id)
1328
+ const id = declaredId ?? `${options.moduleId}.notification-handler.${index}`
1329
+ const contribution = contributionBase(id, sourcePath, 'notificationHandlers')
1330
+ return {
1331
+ ...contribution,
1332
+ kind: 'browser-reaction',
1333
+ targets: [factTarget(notificationType, 'notifications')],
1334
+ features: strings(entry.features),
1335
+ placement: numberValue(entry.priority) !== undefined ? { priority: numberValue(entry.priority) } : undefined,
1336
+ details: {
1337
+ transports: ['notification-effect'],
1338
+ hooks: ['useNotificationEffect'],
1339
+ audienceScopeContract: 'tenant-organization-user-role-and-customer',
1340
+ ...(declaredId ? { overrideKey: declaredId } : {}),
1341
+ },
1342
+ }
1343
+ },
1344
+ })
1345
+ }
1346
+
1347
+ function extractComponentOverrides(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1348
+ return extractObjectConvention({
1349
+ module: options,
1350
+ relativePath: 'widgets/components.ts',
1351
+ exportName: 'componentOverrides',
1352
+ build(entry, sourcePath, index) {
1353
+ const targetDefinition = isStaticObject(entry.target) ? entry.target : null
1354
+ const handle = targetDefinition ? stringValue(targetDefinition.componentId) : undefined
1355
+ if (!handle) return null
1356
+ const mode = entry.wrapper !== undefined ? 'wrapper' : entry.props !== undefined ? 'props' : 'replace'
1357
+ const id = `${options.moduleId}.component-override.${index}:${handle}`
1358
+ const contribution = contributionBase(id, sourcePath, 'componentOverrides')
1359
+ return {
1360
+ ...contribution,
1361
+ kind: 'component-override',
1362
+ targets: [target(handle)],
1363
+ details: {
1364
+ handle,
1365
+ mode,
1366
+ propsContract: stringValue(entry.propsContract) ?? 'component-props-schema',
1367
+ },
1368
+ }
1369
+ },
1370
+ })
1371
+ }
1372
+
1373
+ function extractSpecializedRegistries(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1374
+ const facts: ModuleExtensionContributionFact[] = []
1375
+ const add = (
1376
+ id: string,
1377
+ registry: SpecializedRegistry,
1378
+ sourcePath: string,
1379
+ specialistRoute: string,
1380
+ symbol = id,
1381
+ ): void => {
1382
+ const contributionId = `${registry}:${id}`
1383
+ const contribution = contributionBase(contributionId, sourcePath, id)
1384
+ const fact: ModuleExtensionContributionFact = {
1385
+ ...contribution,
1386
+ kind: 'specialized-registry',
1387
+ targets: [factTarget(id, specialistRoute)],
1388
+ source: { path: sourcePath, symbol },
1389
+ details: { registry, registryId: id, specialistRoute },
1390
+ }
1391
+ const existingIndex = facts.findIndex((entry) => entry.id === contributionId)
1392
+ if (existingIndex >= 0) facts[existingIndex] = fact
1393
+ else facts.push(fact)
1394
+ }
1395
+ for (const id of options.notifications ?? []) add(id, 'notification', path.posix.join(options.sourceRoot, 'notifications.ts'), 'notifications')
1396
+ for (const id of options.searchEntities) add(id, 'search', path.posix.join(options.sourceRoot, 'search.ts'), 'search')
1397
+ for (const tool of options.aiTools ?? []) add(tool.name, 'ai', tool.sourcePath, 'aiTools')
1398
+ for (const agent of options.aiAgents ?? []) add(agent.id, 'ai', agent.sourcePath, 'aiAgents')
1399
+
1400
+ const vectorPath = conventionPath(options.moduleRoot, 'vector.ts')
1401
+ if (vectorPath) {
1402
+ const vectorConfig = readRootObject(vectorPath, 'vectorConfig') ?? readRootObject(vectorPath, 'config')
1403
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, vectorPath)
1404
+ if (vectorConfig && Array.isArray(vectorConfig.entities)) {
1405
+ for (const entity of vectorConfig.entities) {
1406
+ if (!isStaticObject(entity)) continue
1407
+ const entityId = stringValue(entity.entityId)
1408
+ if (entityId) add(entityId, 'vector', sourcePath, 'vector', 'vectorConfig')
1409
+ }
1410
+ }
1411
+ }
1412
+
1413
+ const integrationPath = conventionPath(options.moduleRoot, 'integration.ts')
1414
+ if (integrationPath) {
1415
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, integrationPath)
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
+ }
1446
+ }
1447
+ }
1448
+
1449
+ const workflowsPath = conventionPath(options.moduleRoot, 'workflows.ts')
1450
+ if (workflowsPath) {
1451
+ const config = readRootObject(workflowsPath, 'workflowsConfig')
1452
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, workflowsPath)
1453
+ if (config && Array.isArray(config.workflows)) {
1454
+ for (const workflow of config.workflows) {
1455
+ if (!isStaticObject(workflow)) continue
1456
+ const workflowId = stringValue(workflow.workflowId)
1457
+ if (workflowId) add(workflowId, 'workflow', sourcePath, 'workflows', 'workflowsConfig')
1458
+ }
1459
+ }
1460
+ }
1461
+
1462
+ const diPath = conventionPath(options.moduleRoot, 'di.ts')
1463
+ if (diPath) {
1464
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, diPath)
1465
+ const registrations = readCallObjectArguments(diPath, new Set([
1466
+ 'registerPaymentGatewayDescriptor',
1467
+ 'registerShippingAdapter',
1468
+ 'registerCurrencyProvider',
1469
+ ]))
1470
+ for (const registration of registrations) {
1471
+ const registry = registration.callee === 'registerPaymentGatewayDescriptor' ? 'payment'
1472
+ : registration.callee === 'registerShippingAdapter' ? 'shipping'
1473
+ : 'currency'
1474
+ const registryId = stringValue(registration.value.providerKey)
1475
+ ?? stringValue(registration.value.key)
1476
+ ?? stringValue(registration.value.id)
1477
+ if (!registryId) continue
1478
+ const specialistRoute = registry === 'payment' ? 'paymentGateways'
1479
+ : registry === 'shipping' ? 'shippingCarriers'
1480
+ : 'currencies'
1481
+ add(registryId, registry, sourcePath, specialistRoute, registration.callee)
1482
+ }
1483
+ }
1484
+
1485
+ const dashboardRoots = { appBase: path.join(options.moduleRoot, '__app__'), pkgBase: options.moduleRoot }
1486
+ for (const widgetFile of scanModuleDir(dashboardRoots, SCAN_CONFIGS.dashboardWidgets)) {
1487
+ const filePath = path.join(options.moduleRoot, 'widgets', 'dashboard', widgetFile.relPath)
1488
+ const widget = readRootObject(filePath, 'widget')
1489
+ const metadata = widget && isStaticObject(widget.metadata) ? widget.metadata : null
1490
+ if (!metadata) continue
1491
+ const widgetId = stringValue(metadata.id)
1492
+ if (!widgetId) continue
1493
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, filePath)
1494
+ const contribution = contributionBase(`dashboard:${widgetId}`, sourcePath, 'widget.metadata')
1495
+ facts.push({
1496
+ ...contribution,
1497
+ kind: 'widget',
1498
+ targets: [target(`dashboard:${widgetId}`)],
1499
+ features: strings(metadata.features),
1500
+ details: {
1501
+ payload: 'dashboard',
1502
+ registryKey: widgetId,
1503
+ executionGuard: strings(metadata.features).length > 0 ? 'both' : 'host',
1504
+ },
1505
+ })
1506
+ }
1507
+ return facts
1508
+ }
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
+
1745
+ export function extractModuleExtensionFacts(options: ExtractModuleExtensionFactsOptions): ModuleExtensionSurfaceFacts {
1746
+ const declared = extractDeclaredHosts(options)
1747
+ const hosts = sortHosts([...declared.hosts, ...extractFactRefHosts(options)])
1748
+ const contributions = sortContributions([
1749
+ ...extractInjectionTable(options),
1750
+ ...extractEnrichers(options),
1751
+ ...extractApiInterceptors(options),
1752
+ ...extractCommandInterceptors(options),
1753
+ ...extractGuards(options),
1754
+ ...extractEntityExtensions(options),
1755
+ ...extractSubscribers(options),
1756
+ ...extractBrowserReactions(options),
1757
+ ...extractNotificationReactions(options),
1758
+ ...extractComponentOverrides(options),
1759
+ ...extractSpecializedRegistries(options),
1760
+ ])
1761
+ return {
1762
+ hosts,
1763
+ contributions,
1764
+ unresolved: declared.unresolved,
1765
+ activations: extractCallSiteActivations(options),
1766
+ }
1767
+ }
1768
+
1769
+ function patternMatches(pattern: string, targetId: string): boolean {
1770
+ const escaped = pattern
1771
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
1772
+ .replace(/\\\{[^}]+\\\}/g, '[^:]+')
1773
+ .replace(/\*/g, '.*')
1774
+ return new RegExp(`^${escaped}$`).test(targetId)
1775
+ }
1776
+
1777
+ function targetModuleId(targetId: string): string | null {
1778
+ const withoutPrefix = targetId.replace(/^(?:data-table|crud-form|detail|portal-page):/, '')
1779
+ const match = /^([a-z][a-z0-9_]*)[.:]/.exec(withoutPrefix)
1780
+ return match?.[1] ?? null
1781
+ }
1782
+
1783
+ export function correlateExtensionTarget(
1784
+ targetFact: ModuleExtensionTargetFact,
1785
+ options: CorrelateExtensionFactsOptions,
1786
+ ): ModuleExtensionTargetFact {
1787
+ if (targetFact.resolution !== 'unresolved') return targetFact
1788
+ const allHosts = Object.values(options.surfacesByModule).flatMap((surface) => surface.hosts)
1789
+ if (targetFact.id.includes('*') || targetFact.id.includes('{')) {
1790
+ const knownIds = [
1791
+ ...allHosts.filter((host) => host.bound).map((host) => host.id),
1792
+ ...options.entityIds,
1793
+ ...options.eventIds,
1794
+ ...options.apiRoutes,
1795
+ ...(options.commandIds ?? []),
1796
+ ]
1797
+ if (targetFact.id === '*' || knownIds.some((knownId) => patternMatches(targetFact.id, knownId))) {
1798
+ return { id: targetFact.id, resolution: 'pattern' }
1799
+ }
1800
+ }
1801
+ const exact = allHosts.find((host) => host.bound && (
1802
+ host.id === targetFact.id
1803
+ || host.aliases?.includes(targetFact.id)
1804
+ || (host.family === 'api-route' && host.id.endsWith(`/${targetFact.id}`))
1805
+ ))
1806
+ if (exact) {
1807
+ return exact.source.kind === 'fact-ref'
1808
+ ? factTarget(targetFact.id, exact.source.factSection, exact.source.factKey)
1809
+ : { id: targetFact.id, resolution: 'exact' }
1810
+ }
1811
+ const patterned = allHosts.find((host) => host.bound && host.resolution === 'pattern' && patternMatches(host.id, targetFact.id))
1812
+ if (patterned) return { id: targetFact.id, resolution: 'pattern' }
1813
+ const framework = allFrameworkHosts().find((host) => patternMatches(host.id, targetFact.id))
1814
+ ?? (FRAMEWORK_PREFIXES.some((prefix) => targetFact.id.startsWith(prefix)) ? FRAMEWORK_HOSTS[0] : undefined)
1815
+ if (framework) return { id: targetFact.id, resolution: 'framework' }
1816
+ if (options.entityIds.has(targetFact.id)) {
1817
+ return { id: targetFact.id, resolution: 'fact-ref', factRef: { factSection: 'entities', factKey: targetFact.id } }
1818
+ }
1819
+ if (options.eventIds.has(targetFact.id)) {
1820
+ return { id: targetFact.id, resolution: 'fact-ref', factRef: { factSection: 'events', factKey: targetFact.id } }
1821
+ }
1822
+ if (options.commandIds?.has(targetFact.id)) {
1823
+ return factTarget(targetFact.id, 'ownedContracts.command')
1824
+ }
1825
+ const route = [...options.apiRoutes].find((apiRoute) => apiRoute === targetFact.id || apiRoute.endsWith(`/${targetFact.id}`))
1826
+ if (route) return { id: targetFact.id, resolution: 'fact-ref', factRef: { factSection: 'apiRoutes', factKey: route } }
1827
+ const ownerModule = targetModuleId(targetFact.id)
1828
+ if (ownerModule && !options.surfacesByModule[ownerModule]) {
1829
+ return { id: targetFact.id, resolution: 'optional-external', optionalOwnerPackage: `module:${ownerModule}` }
1830
+ }
1831
+ if (ownerModule && options.contributingModuleId && ownerModule !== options.contributingModuleId) {
1832
+ return { id: targetFact.id, resolution: 'optional-external', optionalOwnerPackage: `module:${ownerModule}` }
1833
+ }
1834
+ return targetFact
1835
+ }
1836
+
1837
+ export function correlateModuleExtensionFacts(options: CorrelateExtensionFactsOptions): Record<string, ModuleExtensionSurfaceFacts> {
1838
+ const result: Record<string, ModuleExtensionSurfaceFacts> = {}
1839
+ for (const moduleId of Object.keys(options.surfacesByModule).sort((left, right) => left.localeCompare(right))) {
1840
+ const surface = options.surfacesByModule[moduleId]
1841
+ const unresolved: ModuleExtensionUnresolvedFact[] = [...surface.unresolved]
1842
+ const contributions = surface.contributions.map((contribution) => {
1843
+ const targets = contribution.targets.map((entry) => correlateExtensionTarget(entry, {
1844
+ ...options,
1845
+ contributingModuleId: moduleId,
1846
+ }))
1847
+ for (const entry of targets) {
1848
+ if (entry.resolution !== 'unresolved') continue
1849
+ unresolved.push({
1850
+ key: `${contribution.id}:${entry.id}`,
1851
+ source: contribution.source,
1852
+ reason: 'unresolved-first-party-target',
1853
+ })
1854
+ }
1855
+ return { ...contribution, targets }
1856
+ })
1857
+ result[moduleId] = {
1858
+ hosts: sortHosts([...surface.hosts]),
1859
+ contributions: sortContributions(contributions),
1860
+ unresolved: unresolved.sort((left, right) => left.key.localeCompare(right.key)),
1861
+ activations: sortActivations([...(surface.activations ?? [])]),
1862
+ }
1863
+ }
1864
+ return result
1865
+ }
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
+
2315
+ export function assertNoUnresolvedExtensionTargets(
2316
+ surfacesByModule: Readonly<Record<string, ModuleExtensionSurfaceFacts>>,
2317
+ ): void {
2318
+ const unresolved = Object.entries(surfacesByModule).flatMap(([moduleId, surface]) =>
2319
+ surface.unresolved
2320
+ .filter((entry) => entry.reason === 'unresolved-first-party-target')
2321
+ .map((entry) => `${moduleId}:${entry.key}`),
2322
+ ).sort((left, right) => left.localeCompare(right))
2323
+ if (unresolved.length > 0) {
2324
+ throw new Error(`[module-facts] unresolved first-party extension targets: ${unresolved.join(', ')}`)
2325
+ }
2326
+ }
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
+
2345
+ export function getFrameworkExtensionHosts(): ModuleExtensionHostFact[] {
2346
+ return sortHosts(allFrameworkHosts().map((host) => ({
2347
+ ...host,
2348
+ capabilities: [...host.capabilities],
2349
+ ...(host.operations ? { operations: [...host.operations] } : {}),
2350
+ })))
2351
+ }
2352
+
2353
+ export function renderFrameworkExtensionPointsMarkdown(): string {
2354
+ const rows = getFrameworkExtensionHosts().map((host) =>
2355
+ `| ${host.id} | ${host.family} | ${[...host.capabilities, ...(host.operations ?? [])].join(', ')} | ${host.stability.toUpperCase()} |`,
2356
+ )
2357
+ return [
2358
+ '# Framework extension points (generated, do not edit)',
2359
+ '',
2360
+ '| ID / pattern | Family | Supports | Stability |',
2361
+ '|---|---|---|---|',
2362
+ ...rows,
2363
+ '',
2364
+ ].join('\n')
2365
+ }