@open-mercato/cli 0.6.7 → 0.6.8-canary.319.1.5b4be4bc52

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 (287) hide show
  1. package/.turbo/turbo-build.log +77 -4
  2. package/build.mjs +68 -11
  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 +84 -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 +116757 -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 +482 -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 +305 -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 +149 -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 +168 -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 +687 -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 +372 -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 +469 -47
  72. package/dist/agentic/guides/modules/workflows.md +302 -43
  73. package/dist/agentic/guides/reference-module-facts.json +6370 -0
  74. package/dist/agentic/guides/reference-modules/example.md +451 -0
  75. package/dist/agentic/guides/spec-delivery.md +42 -0
  76. package/dist/agentic/guides/testing-debugging.md +80 -0
  77. package/dist/agentic/guides/upstream/AGENTS.md +299 -0
  78. package/dist/agentic/guides/upstream/BACKWARD_COMPATIBILITY.md +316 -0
  79. package/dist/agentic/guides/upstream/manifest.json +8 -0
  80. package/dist/agentic/shared/AGENTS.md.template +120 -311
  81. package/dist/agentic/shared/ai/agentic.config.json +2 -0
  82. package/dist/agentic/shared/ai/harness/README.md +50 -0
  83. package/dist/agentic/shared/ai/harness/RELEASE.md +57 -0
  84. package/dist/agentic/shared/ai/harness/cases.json +15050 -0
  85. package/dist/agentic/shared/ai/harness/cases.schema.json +188 -0
  86. package/dist/agentic/shared/ai/harness/fixtures/index.json +51 -0
  87. package/dist/agentic/shared/ai/harness/fixtures/seeds.json +166 -0
  88. package/dist/agentic/shared/ai/harness/generated-code-review-policy.md +21 -0
  89. package/dist/agentic/shared/ai/harness/generated-code-review-response.schema.json +96 -0
  90. package/dist/agentic/shared/ai/harness/generated-code-review-result.schema.json +172 -0
  91. package/dist/agentic/shared/ai/harness/knowledge-change.schema.json +228 -0
  92. package/dist/agentic/shared/ai/harness/release-matrix.json +163 -0
  93. package/dist/agentic/shared/ai/harness/release-result.schema.json +219 -0
  94. package/dist/agentic/shared/ai/harness/result.schema.json +109 -0
  95. package/dist/agentic/shared/ai/harness/routing-response.schema.json +22 -0
  96. package/dist/agentic/shared/ai/harness/target-validation-result.schema.json +73 -0
  97. package/dist/agentic/shared/ai/harness/validators.json +82 -0
  98. package/dist/agentic/shared/ai/harness/writable-ast-oracles.mjs +2121 -0
  99. package/dist/agentic/shared/ai/harness/writable-behavior-oracles.mjs +1096 -0
  100. package/dist/agentic/shared/ai/harness/writable-spec-oracles.mjs +412 -0
  101. package/dist/agentic/shared/ai/lessons/_template.md +16 -0
  102. package/dist/agentic/shared/ai/lessons.md +26 -3
  103. package/dist/agentic/shared/ai/review-checklist.md +36 -0
  104. package/dist/agentic/shared/ai/skills/om-auto-implement-spec/SKILL.md +21 -0
  105. package/dist/agentic/shared/ai/skills/om-auto-review-pr/SKILL.md +4 -0
  106. package/dist/agentic/shared/ai/skills/om-auto-upgrade-0.6.6-to-0.6.7/SKILL.md +98 -0
  107. package/dist/agentic/shared/ai/skills/om-backend-ui-design/SKILL.md +21 -241
  108. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/crud-surfaces.md +31 -0
  109. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/frontend-and-design-system.md +53 -0
  110. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/page-and-navigation.md +15 -0
  111. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/quality-states.md +11 -0
  112. package/dist/agentic/shared/ai/skills/om-build-workflow/SKILL.md +30 -0
  113. package/dist/agentic/shared/ai/skills/om-build-workflow/references/activity-contracts.md +12 -0
  114. package/dist/agentic/shared/ai/skills/om-build-workflow/references/durability-and-progress.md +13 -0
  115. package/dist/agentic/shared/ai/skills/om-build-workflow/references/workflow-design.md +14 -0
  116. package/dist/agentic/shared/ai/skills/om-create-ai-agent/SKILL.md +28 -0
  117. package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/attachments-and-overrides.md +12 -0
  118. package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/module-agents-and-tools.md +22 -0
  119. package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/orchestrator-agents.md +12 -0
  120. package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/surface-selector.md +19 -0
  121. package/dist/agentic/shared/ai/skills/om-data-model-design/SKILL.md +17 -601
  122. package/dist/agentic/shared/ai/skills/om-data-model-design/references/integrity-and-concurrency.md +21 -0
  123. package/dist/agentic/shared/ai/skills/om-data-model-design/references/migration-workflow.md +15 -0
  124. package/dist/agentic/shared/ai/skills/om-data-model-design/references/schema-design.md +16 -0
  125. package/dist/agentic/shared/ai/skills/om-data-model-design/references/sensitive-data.md +10 -0
  126. package/dist/agentic/shared/ai/skills/om-eject-and-customize/SKILL.md +15 -310
  127. package/dist/agentic/shared/ai/skills/om-eject-and-customize/references/decision-and-procedure.md +19 -0
  128. package/dist/agentic/shared/ai/skills/om-evolve-harness/SKILL.md +31 -0
  129. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-template.md +100 -0
  130. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-workflow.md +16 -0
  131. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/knowledge-change.md +115 -0
  132. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/owner-selection.md +17 -0
  133. package/dist/agentic/shared/ai/skills/om-framework-context/SKILL.md +25 -0
  134. package/dist/agentic/shared/ai/skills/om-framework-context/references/resolver-procedure.md +11 -0
  135. package/dist/agentic/shared/ai/skills/om-framework-context/references/skew-and-escalation.md +13 -0
  136. package/dist/agentic/shared/ai/skills/om-help/SKILL.md +15 -97
  137. package/dist/agentic/shared/ai/skills/om-help/references/delivery-workflows.md +18 -0
  138. package/dist/agentic/shared/ai/skills/om-help/references/task-families.md +20 -0
  139. package/dist/agentic/shared/ai/skills/om-implement-spec/SKILL.md +33 -152
  140. package/dist/agentic/shared/ai/skills/om-implement-spec/references/phases-and-gates.md +33 -0
  141. package/dist/agentic/shared/ai/skills/om-implement-spec/references/planning-and-progress.md +40 -0
  142. package/dist/agentic/shared/ai/skills/om-implement-spec/references/report-templates.md +27 -0
  143. package/dist/agentic/shared/ai/skills/om-implement-spec/references/spec-resolution.md +24 -0
  144. package/dist/agentic/shared/ai/skills/om-integration-builder/SKILL.md +25 -708
  145. package/dist/agentic/shared/ai/skills/om-integration-builder/references/package-and-activation.md +16 -0
  146. package/dist/agentic/shared/ai/skills/om-integration-builder/references/provider-families.md +25 -0
  147. package/dist/agentic/shared/ai/skills/om-integration-builder/references/security-and-reliability.md +15 -0
  148. package/dist/agentic/shared/ai/skills/om-integration-builder/references/sync-and-files.md +12 -0
  149. package/dist/agentic/shared/ai/skills/om-judge-agent-session/SKILL.md +34 -0
  150. package/dist/agentic/shared/ai/skills/om-judge-agent-session/agents/openai.yaml +4 -0
  151. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/agentic-setup.md +11 -0
  152. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/input-normalization.md +31 -0
  153. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/judge-workflow.md +39 -0
  154. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/report-template.md +43 -0
  155. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/rules.md +11 -0
  156. package/dist/agentic/shared/ai/skills/om-module-scaffold/SKILL.md +20 -820
  157. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/api-and-domain.md +32 -0
  158. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/business-one-shot-blueprints.md +172 -0
  159. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/data-and-migrations.md +14 -0
  160. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/discovery-surface-catalog.md +83 -0
  161. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/module-surfaces.md +40 -0
  162. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/planning.md +11 -0
  163. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/runtime-cache-and-queues.md +22 -0
  164. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/verification.md +13 -0
  165. package/dist/agentic/shared/ai/skills/om-share-this-session/SKILL.md +41 -0
  166. package/dist/agentic/shared/ai/skills/om-share-this-session/references/agentic-setup.md +19 -0
  167. package/dist/agentic/shared/ai/skills/om-share-this-session/references/bundle-preparation.md +44 -0
  168. package/dist/agentic/shared/ai/skills/om-share-this-session/references/consent-and-review.md +39 -0
  169. package/dist/agentic/shared/ai/skills/om-share-this-session/references/publication.md +20 -0
  170. package/dist/agentic/shared/ai/skills/om-share-this-session/references/report-templates.md +57 -0
  171. package/dist/agentic/shared/ai/skills/om-share-this-session/references/rules.md +11 -0
  172. package/dist/agentic/shared/ai/skills/om-share-this-session/scripts/prepare-share-bundle.mjs +486 -0
  173. package/dist/agentic/shared/ai/skills/om-system-extension/SKILL.md +20 -843
  174. package/dist/agentic/shared/ai/skills/om-system-extension/references/extension-branches.md +42 -0
  175. package/dist/agentic/shared/ai/skills/om-system-extension/references/mechanism-selector.md +27 -0
  176. package/dist/agentic/shared/ai/skills/om-system-extension/references/read-write-roundtrip.md +13 -0
  177. package/dist/agentic/shared/ai/skills/om-system-extension/references/unified-overrides.md +43 -0
  178. package/dist/agentic/shared/ai/skills/om-system-extension/references/verification.md +11 -0
  179. package/dist/agentic/shared/ai/skills/om-trim-unused-modules/SKILL.md +15 -63
  180. package/dist/agentic/shared/ai/skills/om-trim-unused-modules/references/dependency-and-redirect.md +15 -0
  181. package/dist/agentic/shared/ai/skills/om-troubleshooter/SKILL.md +17 -467
  182. package/dist/agentic/shared/ai/skills/om-troubleshooter/references/diagnosis-map.md +29 -0
  183. package/dist/agentic/shared/ai/skills/om-troubleshooter/references/regression-oracles.md +14 -0
  184. package/dist/agentic/shared/ai/skills/tiers.json +143 -21
  185. package/dist/agentic/shared/ai/skills/tiers.schema.json +52 -40
  186. package/dist/agentic/shared/ai/specs/README.md +4 -3
  187. package/dist/agentic/shared/ai/specs/SPEC-000-template.md +226 -10
  188. package/dist/agentic/shared/ai/trackers/github.md +108 -4
  189. package/dist/agentic/shared/scripts/agent-harness-tool-server.mjs +224 -0
  190. package/dist/agentic/shared/scripts/check-lessons.mjs +178 -0
  191. package/dist/agentic/shared/scripts/evaluate-agent-harness.mjs +3585 -0
  192. package/dist/agentic/shared/scripts/execution-sandbox.mjs +329 -0
  193. package/dist/agentic/shared/scripts/framework-context.mjs +888 -0
  194. package/dist/agentic/shared/scripts/install-skills.mjs +1029 -0
  195. package/dist/agentic/shared/scripts/install-skills.sh +4 -694
  196. package/dist/agentic/shared/scripts/prepare-agent-harness-fixture.mjs +222 -0
  197. package/dist/agentic/shared/scripts/run-agent-harness-release.mjs +1774 -0
  198. package/dist/agentic/shared/scripts/validate-knowledge-change.mjs +911 -0
  199. package/dist/bin.js +13 -1
  200. package/dist/bin.js.map +2 -2
  201. package/dist/lib/__integration__/TC-INT-008.spec.js +88 -8
  202. package/dist/lib/__integration__/TC-INT-008.spec.js.map +2 -2
  203. package/dist/lib/agentic-init.js +13 -3
  204. package/dist/lib/agentic-init.js.map +2 -2
  205. package/dist/lib/agentic-setup.js +434 -186
  206. package/dist/lib/agentic-setup.js.map +2 -2
  207. package/dist/lib/generators/extensions/injection-widgets.js +20 -0
  208. package/dist/lib/generators/extensions/injection-widgets.js.map +2 -2
  209. package/dist/lib/generators/module-extension-facts.js +1872 -0
  210. package/dist/lib/generators/module-extension-facts.js.map +7 -0
  211. package/dist/lib/generators/module-fact-sources.js +70 -0
  212. package/dist/lib/generators/module-fact-sources.js.map +7 -0
  213. package/dist/lib/generators/module-facts-discovery.js +64 -7
  214. package/dist/lib/generators/module-facts-discovery.js.map +2 -2
  215. package/dist/lib/generators/module-facts.js +1629 -52
  216. package/dist/lib/generators/module-facts.js.map +3 -3
  217. package/dist/lib/generators/module-override-targets.js +688 -0
  218. package/dist/lib/generators/module-override-targets.js.map +7 -0
  219. package/dist/lib/generators/module-registry.js +11 -5
  220. package/dist/lib/generators/module-registry.js.map +2 -2
  221. package/dist/lib/load-env.js +24 -0
  222. package/dist/lib/load-env.js.map +7 -0
  223. package/dist/lib/resolver.js +2 -0
  224. package/dist/lib/resolver.js.map +2 -2
  225. package/dist/lib/telemetry-init.js +358 -0
  226. package/dist/lib/telemetry-init.js.map +7 -0
  227. package/dist/lib/testing/integration.js +5 -2
  228. package/dist/lib/testing/integration.js.map +2 -2
  229. package/dist/mercato.js +23 -1
  230. package/dist/mercato.js.map +2 -2
  231. package/package.json +13 -11
  232. package/src/bin.ts +33 -1
  233. package/src/lib/__integration__/TC-INT-008.spec.ts +107 -9
  234. package/src/lib/__tests__/agentic-init.test.ts +41 -5
  235. package/src/lib/__tests__/agentic-setup.ownership.test.ts +386 -0
  236. package/src/lib/__tests__/injection-widget-id-validation.test.ts +184 -0
  237. package/src/lib/__tests__/load-env.test.ts +65 -0
  238. package/src/lib/__tests__/telemetry-init.test.ts +298 -0
  239. package/src/lib/agentic-init.ts +15 -3
  240. package/src/lib/agentic-setup.ts +573 -222
  241. package/src/lib/generators/__tests__/activation-adapter-coverage.test.ts +92 -0
  242. package/src/lib/generators/__tests__/module-extension-activations.test.ts +762 -0
  243. package/src/lib/generators/__tests__/module-extension-facts.test.ts +603 -0
  244. package/src/lib/generators/__tests__/module-facts.bc-guard.test.ts +139 -2
  245. package/src/lib/generators/__tests__/module-facts.computed-di-token.test.ts +88 -0
  246. package/src/lib/generators/__tests__/module-facts.customers.fixture.test.ts +1 -0
  247. package/src/lib/generators/__tests__/module-facts.discovery.test.ts +54 -10
  248. package/src/lib/generators/__tests__/module-facts.extension-hosts.test.ts +108 -0
  249. package/src/lib/generators/__tests__/module-facts.local-reference.test.ts +391 -0
  250. package/src/lib/generators/__tests__/module-facts.owned-contracts.test.ts +561 -0
  251. package/src/lib/generators/__tests__/module-facts.search-entity-id.test.ts +151 -0
  252. package/src/lib/generators/__tests__/module-facts.source-links.test.ts +160 -0
  253. package/src/lib/generators/__tests__/module-override-targets.roundtrip.test.ts +149 -0
  254. package/src/lib/generators/__tests__/module-override-targets.test.ts +328 -0
  255. package/src/lib/generators/__tests__/module-subset.test.ts +18 -0
  256. package/src/lib/generators/extensions/injection-widgets.ts +51 -0
  257. package/src/lib/generators/module-extension-facts.ts +2433 -0
  258. package/src/lib/generators/module-fact-sources.ts +108 -0
  259. package/src/lib/generators/module-facts-discovery.ts +104 -7
  260. package/src/lib/generators/module-facts.ts +2387 -138
  261. package/src/lib/generators/module-override-targets.ts +863 -0
  262. package/src/lib/generators/module-registry.ts +16 -6
  263. package/src/lib/load-env.ts +30 -0
  264. package/src/lib/resolver.ts +3 -0
  265. package/src/lib/telemetry-init.ts +461 -0
  266. package/src/lib/testing/__tests__/integration.test.ts +4 -0
  267. package/src/lib/testing/integration.ts +5 -2
  268. package/src/mercato.ts +26 -1
  269. package/dist/agentic/guides/cache.md +0 -45
  270. package/dist/agentic/guides/core.md +0 -453
  271. package/dist/agentic/guides/events.md +0 -117
  272. package/dist/agentic/guides/queue.md +0 -53
  273. package/dist/agentic/guides/search.md +0 -115
  274. package/dist/agentic/guides/shared.md +0 -108
  275. package/dist/agentic/guides/ui.md +0 -347
  276. package/dist/agentic/shared/ai/skills/om-auto-continue-pr-loop/SKILL.md +0 -27
  277. package/dist/agentic/shared/ai/skills/om-auto-create-pr-loop/SKILL.md +0 -27
  278. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/ui-components.md +0 -260
  279. package/dist/agentic/shared/ai/skills/om-data-model-design/references/mikro-orm-cheatsheet.md +0 -146
  280. package/dist/agentic/shared/ai/skills/om-help/references/skills-catalog.md +0 -84
  281. package/dist/agentic/shared/ai/skills/om-help/references/workflow-sequences.md +0 -193
  282. package/dist/agentic/shared/ai/skills/om-integration-builder/STANDALONE.md +0 -34
  283. package/dist/agentic/shared/ai/skills/om-integration-builder/references/adapter-contracts.md +0 -762
  284. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/naming-conventions.md +0 -50
  285. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/navigation-patterns.md +0 -97
  286. package/dist/agentic/shared/ai/skills/om-system-extension/references/extension-contracts.md +0 -271
  287. package/dist/agentic/shared/ai/skills/om-troubleshooter/references/diagnostic-commands.md +0 -101
@@ -0,0 +1,2433 @@
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
+ /**
270
+ * A function-valued property carries no statically readable value, but its
271
+ * PRESENCE is the discriminant several conventions are keyed on (a
272
+ * `ComponentOverride` is a wrapper/props-transform/replacement depending on
273
+ * which callable it declares; an enricher declares `enrichOne`/`enrichMany`).
274
+ * Method shorthand (`enrichOne() {}`) is already recorded as `true`, so an
275
+ * arrow/function-expression property — or an identifier bound to one — records
276
+ * the same marker instead of vanishing from the object.
277
+ */
278
+ function isFunctionLikeInitializer(expression: ts.Expression, context: StaticContext): boolean {
279
+ const current = unwrap(expression)
280
+ if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) return true
281
+ if (!ts.isIdentifier(current)) return false
282
+ const initializer = context.initializers.get(current.text)
283
+ if (!initializer) return false
284
+ if (ts.isFunctionDeclaration(initializer)) return true
285
+ const resolved = unwrap(initializer)
286
+ return ts.isArrowFunction(resolved) || ts.isFunctionExpression(resolved)
287
+ }
288
+
289
+ function staticObject(expression: ts.ObjectLiteralExpression, context: StaticContext): StaticObject {
290
+ const result: StaticObject = {}
291
+ for (const property of expression.properties) {
292
+ if (ts.isPropertyAssignment(property)) {
293
+ const name = propertyName(property.name)
294
+ if (!name) continue
295
+ const value = staticValue(property.initializer, context)
296
+ if (value !== undefined) result[name] = value
297
+ else if (isFunctionLikeInitializer(property.initializer, context)) result[name] = true
298
+ continue
299
+ }
300
+ if (ts.isShorthandPropertyAssignment(property)) {
301
+ const value = staticValue(property.name, context)
302
+ if (value !== undefined) result[property.name.text] = value
303
+ continue
304
+ }
305
+ if (ts.isMethodDeclaration(property)) {
306
+ const name = propertyName(property.name)
307
+ if (name) result[name] = true
308
+ continue
309
+ }
310
+ if (ts.isSpreadAssignment(property)) {
311
+ const value = staticValue(property.expression, context)
312
+ if (isStaticObject(value)) Object.assign(result, value)
313
+ }
314
+ }
315
+ return result
316
+ }
317
+
318
+ function staticValue(expression: ts.Expression, context: StaticContext): StaticValue | undefined {
319
+ const current = unwrap(expression)
320
+ if (ts.isStringLiteralLike(current) || ts.isNumericLiteral(current)) return current.text
321
+ if (current.kind === ts.SyntaxKind.TrueKeyword) return true
322
+ if (current.kind === ts.SyntaxKind.FalseKeyword) return false
323
+ if (current.kind === ts.SyntaxKind.NullKeyword) return null
324
+ if (ts.isNoSubstitutionTemplateLiteral(current)) return current.text
325
+ if (ts.isTemplateExpression(current)) return staticTemplate(current, context) ?? undefined
326
+ if (ts.isArrayLiteralExpression(current)) {
327
+ return current.elements.flatMap((element) => {
328
+ if (ts.isSpreadElement(element)) {
329
+ const spread = staticValue(element.expression, context)
330
+ return Array.isArray(spread) ? spread : []
331
+ }
332
+ const value = staticValue(element, context)
333
+ return value === undefined ? [] : [value]
334
+ })
335
+ }
336
+ if (ts.isObjectLiteralExpression(current)) return staticObject(current, context)
337
+ if (ts.isPropertyAccessExpression(current)) {
338
+ return entityRegistryValue(current) ?? undefined
339
+ }
340
+ if (ts.isIdentifier(current)) {
341
+ if (context.resolving.has(current.text)) return undefined
342
+ const initializer = context.initializers.get(current.text)
343
+ if (!initializer) return undefined
344
+ context.resolving.add(current.text)
345
+ const value = ts.isFunctionDeclaration(initializer) ? undefined : staticValue(initializer, context)
346
+ context.resolving.delete(current.text)
347
+ return value
348
+ }
349
+ if (ts.isCallExpression(current)) {
350
+ const handle = componentReplacementHandle(current, context)
351
+ if (handle) return handle.resolved
352
+ if (ts.isIdentifier(current.expression)) {
353
+ const callable = context.initializers.get(current.expression.text)
354
+ if (callable && (ts.isArrowFunction(callable) || ts.isFunctionExpression(callable) || ts.isFunctionDeclaration(callable))) {
355
+ const initializers = new Map(context.initializers)
356
+ callable.parameters.forEach((parameter, index) => {
357
+ if (ts.isIdentifier(parameter.name) && current.arguments[index]) {
358
+ initializers.set(parameter.name.text, current.arguments[index])
359
+ }
360
+ })
361
+ const childContext: StaticContext = {
362
+ initializers,
363
+ sourceFile: context.sourceFile,
364
+ resolving: new Set(context.resolving),
365
+ }
366
+ if (callable.body && ts.isExpression(callable.body)) return staticValue(callable.body, childContext)
367
+ const returnStatement = callable.body?.statements.find(ts.isReturnStatement)
368
+ if (returnStatement?.expression) return staticValue(returnStatement.expression, childContext)
369
+ }
370
+ }
371
+ // Factory-style `defineThing({ … })` calls forward their configuration object,
372
+ // but a member call (`Handles.section('a', 'b')`) computes a value from its
373
+ // arguments — forwarding the first one there invents a wrong id, so it stays
374
+ // unresolved unless a formula above knows the builder.
375
+ const firstArgument = current.arguments[0]
376
+ if (firstArgument && ts.isIdentifier(current.expression)) {
377
+ const value = staticValue(firstArgument, context)
378
+ if (!isStaticObject(value)) return value
379
+ if (current.expression.text === 'dataTableExtensionHost') return { family: 'data-table', ...value }
380
+ if (current.expression.text === 'crudFormExtensionHost') return { family: 'crud-form', ...value }
381
+ if (current.expression.text === 'componentExtensionHost') return { family: 'component-handle', ...value }
382
+ return value
383
+ }
384
+ }
385
+ if (ts.isConditionalExpression(current)) {
386
+ const left = staticValue(current.whenTrue, context)
387
+ const right = staticValue(current.whenFalse, context)
388
+ return left !== undefined && JSON.stringify(left) === JSON.stringify(right) ? left : undefined
389
+ }
390
+ return undefined
391
+ }
392
+
393
+ /**
394
+ * `ComponentReplacementHandles` (packages/shared/src/modules/widgets/component-registry.ts)
395
+ * is the framework-owned builder every `widgets/components.ts` uses to name a
396
+ * replacement handle. Without these formulas the generic call fallback would read
397
+ * `ComponentReplacementHandles.section('ui.detail', 'NotesSection')` as its first
398
+ * argument and publish `ui.detail` as the handle — a target id that exists nowhere.
399
+ */
400
+ const COMPONENT_REPLACEMENT_HANDLE_BUILDERS: Record<string, (args: Array<string | undefined>) => string | undefined> = {
401
+ page: ([routePath]) => routePath ? `page:${routePath}` : undefined,
402
+ dataTable: ([tableId]) => tableId ? `data-table:${tableId}` : undefined,
403
+ crudForm: ([entityId]) => entityId ? `crud-form:${entityId}` : undefined,
404
+ section: ([scope, sectionId]) => scope && sectionId ? `section:${scope}.${sectionId}` : undefined,
405
+ }
406
+
407
+ function componentReplacementHandle(
408
+ expression: ts.CallExpression,
409
+ context: StaticContext,
410
+ ): { resolved: string | undefined } | null {
411
+ if (!ts.isPropertyAccessExpression(expression.expression)) return null
412
+ const callee = expression.expression
413
+ if (!ts.isIdentifier(callee.expression) || callee.expression.text !== 'ComponentReplacementHandles') return null
414
+ const builder = COMPONENT_REPLACEMENT_HANDLE_BUILDERS[callee.name.text]
415
+ if (!builder) return { resolved: undefined }
416
+ const args = expression.arguments.map((argument) => stringValue(staticValue(argument, context)))
417
+ return { resolved: builder(args) }
418
+ }
419
+
420
+ function isStaticObject(value: StaticValue | undefined): value is StaticObject {
421
+ return !!value && typeof value === 'object' && !Array.isArray(value)
422
+ }
423
+
424
+ function strings(value: StaticValue | undefined): string[] {
425
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
426
+ }
427
+
428
+ function stringValue(value: StaticValue | undefined): string | undefined {
429
+ return typeof value === 'string' ? value : undefined
430
+ }
431
+
432
+ function numberValue(value: StaticValue | undefined): number | undefined {
433
+ if (typeof value === 'number') return value
434
+ if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value)
435
+ return undefined
436
+ }
437
+
438
+ function booleanValue(value: StaticValue | undefined): boolean | undefined {
439
+ return typeof value === 'boolean' ? value : undefined
440
+ }
441
+
442
+ function portablePath(moduleRoot: string, sourceRoot: string, filePath: string): string {
443
+ return path.posix.join(sourceRoot, path.relative(moduleRoot, filePath).split(path.sep).join('/'))
444
+ }
445
+
446
+ function conventionPath(moduleRoot: string, relativePath: string): string | null {
447
+ const direct = path.join(moduleRoot, relativePath)
448
+ if (fs.existsSync(direct)) return direct
449
+ if (relativePath.endsWith('.ts')) {
450
+ const tsx = `${direct.slice(0, -3)}.tsx`
451
+ if (fs.existsSync(tsx)) return tsx
452
+ }
453
+ return null
454
+ }
455
+
456
+ export function readConventionObjectArray(filePath: string, exportName: string): StaticObject[] {
457
+ const file = sourceFile(filePath)
458
+ if (!file) return []
459
+ const context = buildStaticContext(file)
460
+ const initializer = context.initializers.get(exportName)
461
+ if (!initializer || ts.isFunctionDeclaration(initializer)) return []
462
+ const value = staticValue(initializer, context)
463
+ return Array.isArray(value) ? value.filter(isStaticObject) : []
464
+ }
465
+
466
+ function readRootObject(filePath: string, variableName: string): StaticObject | null {
467
+ const file = sourceFile(filePath)
468
+ if (!file) return null
469
+ const context = buildStaticContext(file)
470
+ const initializer = context.initializers.get(variableName)
471
+ if (!initializer || ts.isFunctionDeclaration(initializer)) return null
472
+ const value = staticValue(initializer, context)
473
+ return isStaticObject(value) ? value : null
474
+ }
475
+
476
+ function readCallObjectArguments(filePath: string, calleeNames: ReadonlySet<string>): Array<{
477
+ callee: string
478
+ value: StaticObject
479
+ }> {
480
+ const file = sourceFile(filePath)
481
+ if (!file) return []
482
+ const context = buildStaticContext(file)
483
+ const result: Array<{ callee: string; value: StaticObject }> = []
484
+ const visit = (node: ts.Node): void => {
485
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && calleeNames.has(node.expression.text)) {
486
+ const firstArgument = node.arguments[0]
487
+ const value = firstArgument ? staticValue(firstArgument, context) : undefined
488
+ if (isStaticObject(value)) result.push({ callee: node.expression.text, value })
489
+ }
490
+ node.forEachChild(visit)
491
+ }
492
+ file.forEachChild(visit)
493
+ return result
494
+ }
495
+
496
+ function sortHosts(hosts: ModuleExtensionHostFact[]): ModuleExtensionHostFact[] {
497
+ const sourceKey = (host: ModuleExtensionHostFact): string => host.source.kind === 'fact-ref'
498
+ ? `${host.source.factSection}:${host.source.factKey}`
499
+ : `${host.source.path}:${host.source.symbol}`
500
+ return hosts.sort((left, right) =>
501
+ left.family.localeCompare(right.family)
502
+ || left.id.localeCompare(right.id)
503
+ || sourceKey(left).localeCompare(sourceKey(right))
504
+ || left.key.localeCompare(right.key))
505
+ }
506
+
507
+ function sortContributions(contributions: ModuleExtensionContributionFact[]): ModuleExtensionContributionFact[] {
508
+ const targetKey = (contribution: ModuleExtensionContributionFact): string => contribution.targets
509
+ .map((entry) => entry.id)
510
+ .join('\u0000')
511
+ const sourceKey = (contribution: ModuleExtensionContributionFact): string =>
512
+ `${contribution.source.path}:${contribution.source.symbol ?? ''}`
513
+ return contributions.sort((left, right) =>
514
+ left.kind.localeCompare(right.kind)
515
+ || left.id.localeCompare(right.id)
516
+ || targetKey(left).localeCompare(targetKey(right))
517
+ || sourceKey(left).localeCompare(sourceKey(right)))
518
+ }
519
+
520
+ function target(id: string, resolution: ModuleExtensionTargetFact['resolution'] = 'unresolved'): ModuleExtensionTargetFact {
521
+ return { id, resolution }
522
+ }
523
+
524
+ function factTarget(id: string, factSection: string, factKey = id): ModuleExtensionTargetFact {
525
+ return { id, resolution: 'fact-ref', factRef: { factSection, factKey } }
526
+ }
527
+
528
+ function openTarget(id: string): ModuleExtensionTargetFact {
529
+ return target(id)
530
+ }
531
+
532
+ function declarationSource(sourceRoot: string, symbol: string): ModuleExtensionHostFact['source'] {
533
+ return { kind: 'declaration', path: path.posix.join(sourceRoot, 'extension-points.ts'), symbol }
534
+ }
535
+
536
+ function hasDeclarationBinding(
537
+ moduleRoot: string,
538
+ hostKey: string,
539
+ declaration: StaticObject,
540
+ ): boolean {
541
+ const relativeSource = stringValue(declaration.source)
542
+ if (!relativeSource) return false
543
+ const bindingFile = sourceFile(path.join(moduleRoot, relativeSource))
544
+ if (!bindingFile) return false
545
+ const escapedHostKey = hostKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
546
+ const bindingPattern = new RegExp(
547
+ `extensionPoints\\s*\\.\\s*hosts(?:\\s*\\.\\s*${escapedHostKey}|\\s*\\[\\s*['\"]${escapedHostKey}['\"]\\s*\\])`,
548
+ )
549
+ if (bindingPattern.test(bindingFile.text)) return true
550
+ return declaration.family === 'integration'
551
+ && typeof declaration.pattern === 'string'
552
+ && /resolveIntegrationDetailWidgetSpotId\s*\(/.test(bindingFile.text)
553
+ }
554
+
555
+ function baseHost(options: {
556
+ key: string
557
+ id: string
558
+ moduleId: string
559
+ family: ExtensionHostFamily
560
+ capabilities: ExtensionHostCapability[]
561
+ sourceRoot: string
562
+ declaration: StaticObject
563
+ bound?: boolean
564
+ resolution?: ModuleExtensionHostFact['resolution']
565
+ }): ModuleExtensionHostFact {
566
+ const { declaration } = options
567
+ const aliases = strings(declaration.aliases)
568
+ const fallbacks = strings(declaration.fallbacks)
569
+ const fact: ModuleExtensionHostFact = {
570
+ key: options.key,
571
+ id: options.id,
572
+ resolution: options.resolution ?? (options.id.includes('{') || options.id.includes('*') ? 'pattern' : 'exact'),
573
+ family: options.family,
574
+ ownerModule: options.moduleId,
575
+ capabilities: options.capabilities,
576
+ bound: options.bound ?? true,
577
+ stability: 'frozen',
578
+ source: declarationSource(options.sourceRoot, `extensionPoints.hosts.${options.key.split('.')[0]}`),
579
+ }
580
+ const contextContract = stringValue(declaration.contextContract)
581
+ const dataContract = stringValue(declaration.dataContract)
582
+ const scopeContract = stringValue(declaration.scopeContract)
583
+ const runtimeContract = stringValue(declaration.runtimeContract)
584
+ const activation = stringValue(declaration.activation) as ModuleExtensionHostFact['activation']
585
+ if (contextContract) fact.contextContract = contextContract
586
+ if (dataContract) fact.dataContract = dataContract
587
+ if (scopeContract) fact.scopeContract = scopeContract
588
+ if (runtimeContract) fact.runtimeContract = runtimeContract
589
+ if (activation) fact.activation = activation
590
+ if (aliases.length > 0) fact.aliases = aliases
591
+ if (fallbacks.length > 0) fact.fallbacks = fallbacks
592
+ return fact
593
+ }
594
+
595
+ function extractDeclaredHosts(options: ExtractModuleExtensionFactsOptions): {
596
+ hosts: ModuleExtensionHostFact[]
597
+ unresolved: ModuleExtensionUnresolvedFact[]
598
+ } {
599
+ const filePath = conventionPath(options.moduleRoot, 'extension-points.ts')
600
+ if (!filePath) return { hosts: [], unresolved: [] }
601
+ const declaration = readRootObject(filePath, 'extensionPoints')
602
+ const hostsObject = declaration && isStaticObject(declaration.hosts) ? declaration.hosts : null
603
+ if (!hostsObject) {
604
+ return {
605
+ hosts: [],
606
+ unresolved: [{
607
+ key: `${options.moduleId}.extension-points`,
608
+ source: { path: portablePath(options.moduleRoot, options.sourceRoot, filePath), symbol: 'extensionPoints' },
609
+ reason: 'unclassified-binding',
610
+ }],
611
+ }
612
+ }
613
+
614
+ const hosts: ModuleExtensionHostFact[] = []
615
+ const unresolved: ModuleExtensionUnresolvedFact[] = []
616
+ for (const hostKey of Object.keys(hostsObject).sort((left, right) => left.localeCompare(right))) {
617
+ const host = hostsObject[hostKey]
618
+ if (!isStaticObject(host)) continue
619
+ const family = stringValue(host.family) as ExtensionHostFamily | undefined
620
+ if (!family) continue
621
+ const declarationBound = hasDeclarationBinding(options.moduleRoot, hostKey, host)
622
+ if (!declarationBound) {
623
+ unresolved.push({
624
+ key: `${options.moduleId}.${hostKey}`,
625
+ source: {
626
+ path: stringValue(host.source) ?? portablePath(options.moduleRoot, options.sourceRoot, filePath),
627
+ symbol: `extensionPoints.hosts.${hostKey}`,
628
+ },
629
+ reason: 'unbound-declaration',
630
+ })
631
+ }
632
+ if (family === 'data-table') {
633
+ const tableId = stringValue(host.tableId)
634
+ if (!tableId) continue
635
+ const baseSpotId = stringValue(host.baseSpotId) ?? `data-table:${tableId}`
636
+ for (const surface of DATA_TABLE_EXTENSION_SURFACES) {
637
+ const usesBase = ['header', 'footer', 'toolbar', 'searchTrailing', 'emptyState'].includes(surface.key)
638
+ const id = surface.key === 'replacement'
639
+ ? `data-table:${tableId}`
640
+ : `${usesBase ? baseSpotId : `data-table:${tableId}`}:${surface.suffix}`
641
+ hosts.push(baseHost({
642
+ key: `${hostKey}.${surface.key}`,
643
+ id,
644
+ moduleId: options.moduleId,
645
+ family,
646
+ capabilities: [...surface.capabilities],
647
+ sourceRoot: options.sourceRoot,
648
+ declaration: host,
649
+ bound: surface.bound && declarationBound,
650
+ }))
651
+ }
652
+ continue
653
+ }
654
+ if (family === 'crud-form') {
655
+ const entityId = stringValue(host.entityId)
656
+ if (!entityId) continue
657
+ const spotId = stringValue(host.spotId) ?? `crud-form:${entityId}`
658
+ for (const surface of CRUD_FORM_EXTENSION_SURFACES) {
659
+ const id = surface.suffix ? `${spotId}:${surface.suffix}` : spotId
660
+ const fact = baseHost({
661
+ key: `${hostKey}.${surface.key}`,
662
+ id,
663
+ moduleId: options.moduleId,
664
+ family,
665
+ capabilities: [...surface.capabilities],
666
+ sourceRoot: options.sourceRoot,
667
+ declaration: host,
668
+ bound: surface.bound && declarationBound,
669
+ })
670
+ if (surface.key === 'base') fact.phases = [...CRUD_FORM_LIFECYCLE_PHASES]
671
+ hosts.push(fact)
672
+ }
673
+ continue
674
+ }
675
+ const id = stringValue(host.spotId) ?? stringValue(host.pattern) ?? stringValue(host.componentId)
676
+ if (!id) {
677
+ unresolved.push({
678
+ key: `${options.moduleId}.${hostKey}`,
679
+ source: { path: portablePath(options.moduleRoot, options.sourceRoot, filePath), symbol: hostKey },
680
+ reason: 'dynamic-without-pattern',
681
+ })
682
+ continue
683
+ }
684
+ const capabilities = family === 'component-handle'
685
+ ? ['component-replacement'] as ExtensionHostCapability[]
686
+ : strings(host.supported) as ExtensionHostCapability[]
687
+ const fact = baseHost({
688
+ key: hostKey,
689
+ id,
690
+ moduleId: options.moduleId,
691
+ family,
692
+ capabilities,
693
+ sourceRoot: options.sourceRoot,
694
+ declaration: host,
695
+ bound: declarationBound,
696
+ })
697
+ if (isStaticObject(host.parameters)) {
698
+ fact.patternParameters = Object.fromEntries(Object.entries(host.parameters).flatMap(([key, value]) => {
699
+ if (!isStaticObject(value) || typeof value.source !== 'string') return []
700
+ return [[key, {
701
+ source: value.source,
702
+ ...(typeof value.pattern === 'string' ? { pattern: value.pattern } : {}),
703
+ }]]
704
+ }))
705
+ }
706
+ hosts.push(fact)
707
+ }
708
+ return { hosts: sortHosts(hosts), unresolved }
709
+ }
710
+
711
+ function factRefHost(options: {
712
+ key: string
713
+ id: string
714
+ moduleId: string
715
+ family: ExtensionHostFamily
716
+ capabilities: ExtensionHostCapability[]
717
+ factSection: string
718
+ /** Key inside `factSection`; defaults to the host id when the two coincide. */
719
+ factKey?: string
720
+ phases?: string[]
721
+ activation?: ModuleExtensionHostFact['activation']
722
+ scopeContract?: string
723
+ }): ModuleExtensionHostFact {
724
+ return {
725
+ key: options.key,
726
+ id: options.id,
727
+ resolution: 'fact-ref',
728
+ family: options.family,
729
+ ownerModule: options.moduleId,
730
+ capabilities: options.capabilities,
731
+ ...(options.phases ? { phases: options.phases } : {}),
732
+ ...(options.activation ? { activation: options.activation } : {}),
733
+ ...(options.scopeContract ? { scopeContract: options.scopeContract } : {}),
734
+ bound: true,
735
+ stability: 'stable',
736
+ source: { kind: 'fact-ref', factSection: options.factSection, factKey: options.factKey ?? options.id },
737
+ }
738
+ }
739
+
740
+ function sourceFilesBelow(directory: string): string[] {
741
+ const cached = activeExtractionCache?.sourceFilesBelow.get(directory)
742
+ if (cached) return cached
743
+ if (!fs.existsSync(directory)) return []
744
+ const files: string[] = []
745
+ const visit = (current: string): void => {
746
+ for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
747
+ const entryPath = path.join(current, entry.name)
748
+ if (entry.isDirectory()) {
749
+ if (entry.name !== '__tests__' && entry.name !== '__integration__') visit(entryPath)
750
+ continue
751
+ }
752
+ if (entry.isFile() && /\.(?:ts|tsx)$/.test(entry.name) && !/\.(?:test|spec)\./.test(entry.name)) files.push(entryPath)
753
+ }
754
+ }
755
+ visit(directory)
756
+ activeExtractionCache?.sourceFilesBelow.set(directory, files)
757
+ return files
758
+ }
759
+
760
+ function apiRouteId(moduleId: string, moduleRoot: string, filePath: string): string | null {
761
+ const relative = path.relative(path.join(moduleRoot, 'api'), filePath).split(path.sep).join('/')
762
+ if (relative === 'interceptors.ts' || relative === 'openapi.ts') return null
763
+ const withoutExtension = relative.replace(/\.tsx?$/, '')
764
+ const suffix = withoutExtension.endsWith('/route') ? withoutExtension.slice(0, -6) : withoutExtension
765
+ return suffix && !suffix.includes('__tests__') ? `${moduleId.replace(/_/g, '-')}/${suffix}` : null
766
+ }
767
+
768
+ function apiExtensionHostIds(moduleId: string, moduleRoot: string): ApiExtensionHostIds {
769
+ const cacheKey = `${moduleId}\0${moduleRoot}`
770
+ const cached = activeExtractionCache?.apiExtensionHostIds.get(cacheKey)
771
+ if (cached) return cached
772
+ const entityIds = new Set<string>()
773
+ const entitySourceFiles: Record<string, string> = {}
774
+ const commandIds = new Set<string>()
775
+ const routeIds = new Set<string>()
776
+ for (const filePath of sourceFilesBelow(path.join(moduleRoot, 'api'))) {
777
+ const routeId = apiRouteId(moduleId, moduleRoot, filePath)
778
+ if (routeId) routeIds.add(routeId)
779
+ const file = sourceFile(filePath)
780
+ if (!file) continue
781
+ const context = buildStaticContext(file)
782
+ const visit = (node: ts.Node): void => {
783
+ if (ts.isPropertyAssignment(node)) {
784
+ const name = propertyName(node.name)
785
+ if (name === 'commandId') {
786
+ const id = stringValue(staticValue(node.initializer, context))
787
+ if (id) commandIds.add(id)
788
+ }
789
+ if (name === 'enrichers') {
790
+ const config = staticValue(node.initializer, context)
791
+ const entityId = isStaticObject(config) ? stringValue(config.entityId) : undefined
792
+ if (entityId) {
793
+ entityIds.add(entityId)
794
+ if (!entitySourceFiles[entityId]) entitySourceFiles[entityId] = filePath
795
+ }
796
+ }
797
+ }
798
+ if (
799
+ ts.isCallExpression(node)
800
+ && ts.isIdentifier(node.expression)
801
+ && node.expression.text === 'validateCrudMutationGuard'
802
+ ) {
803
+ const input = node.arguments[1] ? staticValue(node.arguments[1], context) : undefined
804
+ const entityId = isStaticObject(input) ? stringValue(input.resourceKind) : undefined
805
+ if (entityId) {
806
+ entityIds.add(entityId)
807
+ if (!entitySourceFiles[entityId]) entitySourceFiles[entityId] = filePath
808
+ }
809
+ }
810
+ node.forEachChild(visit)
811
+ }
812
+ file.forEachChild(visit)
813
+ }
814
+ const result = {
815
+ entityIds: [...entityIds].sort((left, right) => left.localeCompare(right)),
816
+ entitySourceFiles,
817
+ commandIds: [...commandIds].sort((left, right) => left.localeCompare(right)),
818
+ routeIds: [...routeIds].sort((left, right) => left.localeCompare(right)),
819
+ }
820
+ activeExtractionCache?.apiExtensionHostIds.set(cacheKey, result)
821
+ return result
822
+ }
823
+
824
+ function moduleCommandIds(moduleRoot: string, apiCommandIds: readonly string[]): string[] {
825
+ const commandIds = new Set(apiCommandIds)
826
+ for (const filePath of sourceFilesBelow(path.join(moduleRoot, 'commands'))) {
827
+ if (path.basename(filePath).replace(/\.(?:ts|tsx)$/, '') === 'interceptors') continue
828
+ for (const id of extractCommandIdsFromSource(filePath)) commandIds.add(id)
829
+ }
830
+ return [...commandIds].sort((left, right) => left.localeCompare(right))
831
+ }
832
+
833
+ export function extractKnownApiRouteIds(moduleId: string, moduleRoot: string): string[] {
834
+ return apiExtensionHostIds(moduleId, moduleRoot).routeIds
835
+ }
836
+
837
+ export function extractKnownCommandIds(moduleId: string, moduleRoot: string): string[] {
838
+ const apiHosts = apiExtensionHostIds(moduleId, moduleRoot)
839
+ return moduleCommandIds(moduleRoot, apiHosts.commandIds)
840
+ }
841
+
842
+ const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const
843
+ export type ApiInterceptorPhase = 'before' | 'after'
844
+ export type ApiRouteInterceptorBridge = {
845
+ method: (typeof HTTP_METHODS)[number]
846
+ phases: ApiInterceptorPhase[]
847
+ }
848
+
849
+ const API_INTERCEPTOR_BRIDGE_CALLS: Record<string, ApiInterceptorPhase> = {
850
+ runApiInterceptorsBefore: 'before',
851
+ runApiInterceptorsAfter: 'after',
852
+ }
853
+
854
+ function isHttpMethodName(value: string): value is (typeof HTTP_METHODS)[number] {
855
+ return (HTTP_METHODS as readonly string[]).includes(value)
856
+ }
857
+
858
+ /** HTTP method handlers a route file actually exports. */
859
+ function exportedHttpMethods(file: ts.SourceFile): Array<(typeof HTTP_METHODS)[number]> {
860
+ const methods = new Set<(typeof HTTP_METHODS)[number]>()
861
+ const consider = (name: string | undefined): void => {
862
+ if (name && isHttpMethodName(name)) methods.add(name)
863
+ }
864
+ const considerBindingName = (name: ts.BindingName): void => {
865
+ if (ts.isIdentifier(name)) {
866
+ consider(name.text)
867
+ return
868
+ }
869
+ for (const element of name.elements) {
870
+ if (ts.isBindingElement(element)) considerBindingName(element.name)
871
+ }
872
+ }
873
+ const visit = (node: ts.Node): void => {
874
+ if (ts.isVariableStatement(node) && node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
875
+ for (const declaration of node.declarationList.declarations) {
876
+ considerBindingName(declaration.name)
877
+ }
878
+ }
879
+ if (
880
+ ts.isFunctionDeclaration(node)
881
+ && node.name
882
+ && node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
883
+ ) {
884
+ consider(node.name.text)
885
+ }
886
+ if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
887
+ for (const element of node.exportClause.elements) consider(element.name.text)
888
+ }
889
+ node.forEachChild(visit)
890
+ }
891
+ file.forEachChild(visit)
892
+ return [...methods].sort((left, right) => left.localeCompare(right))
893
+ }
894
+
895
+ /**
896
+ * The interceptor pipeline runs only where a route wires it: `makeCrudRoute` runs
897
+ * both phases for every method it handles, and a hand-written route runs whichever
898
+ * of `runApiInterceptorsBefore` / `runApiInterceptorsAfter` it calls. A custom route
899
+ * that calls neither has no bridge, so an interceptor targeting it is never bound.
900
+ */
901
+ function extractApiRouteInterceptorBridges(file: ts.SourceFile, context: StaticContext): ApiRouteInterceptorBridge[] {
902
+ const exportedMethods = exportedHttpMethods(file)
903
+ const phasesByMethod = new Map<(typeof HTTP_METHODS)[number], Set<ApiInterceptorPhase>>()
904
+ const addPhase = (method: (typeof HTTP_METHODS)[number], phase: ApiInterceptorPhase): void => {
905
+ const phases = phasesByMethod.get(method) ?? new Set<ApiInterceptorPhase>()
906
+ phases.add(phase)
907
+ phasesByMethod.set(method, phases)
908
+ }
909
+ const visit = (node: ts.Node): void => {
910
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
911
+ const calleeName = node.expression.text
912
+ if (calleeName === 'makeCrudRoute') {
913
+ for (const method of exportedMethods) {
914
+ addPhase(method, 'before')
915
+ addPhase(method, 'after')
916
+ }
917
+ }
918
+ const phase = API_INTERCEPTOR_BRIDGE_CALLS[calleeName]
919
+ if (phase) {
920
+ const callOptions = node.arguments[0] ? staticValue(node.arguments[0], context) : undefined
921
+ const declaredMethod = isStaticObject(callOptions) ? stringValue(callOptions.method) : undefined
922
+ const methods = declaredMethod && isHttpMethodName(declaredMethod) ? [declaredMethod] : exportedMethods
923
+ for (const method of methods) addPhase(method, phase)
924
+ }
925
+ }
926
+ node.forEachChild(visit)
927
+ }
928
+ file.forEachChild(visit)
929
+ return [...phasesByMethod.entries()]
930
+ .map(([method, phases]) => ({
931
+ method,
932
+ phases: [...phases].sort((left, right) => left.localeCompare(right)),
933
+ }))
934
+ .sort((left, right) => left.method.localeCompare(right.method))
935
+ }
936
+
937
+ /**
938
+ * Concrete runtime-id owners a module contributes as activation targets: its real
939
+ * `api/**` route ids (with the interceptor bridges each route actually wires) and
940
+ * its `commands/**` command ids (dispatched through the command bus). Used
941
+ * post-selection to synthesize `host-reference`/`owner-reference` bindings with a
942
+ * portable source.
943
+ */
944
+ export function extractActivationTargetOwners(options: {
945
+ moduleId: string
946
+ moduleRoot: string
947
+ sourceRoot: string
948
+ }): {
949
+ apiRoutes: Array<{ id: string; source: ModuleFactSourceRef; bridges: ApiRouteInterceptorBridge[] }>
950
+ commands: Array<{ id: string; source: ModuleFactSourceRef }>
951
+ } {
952
+ const apiRoutes: Array<{ id: string; source: ModuleFactSourceRef; bridges: ApiRouteInterceptorBridge[] }> = []
953
+ for (const filePath of sourceFilesBelow(path.join(options.moduleRoot, 'api'))) {
954
+ const routeId = apiRouteId(options.moduleId, options.moduleRoot, filePath)
955
+ if (!routeId) continue
956
+ const file = sourceFile(filePath)
957
+ const bridges = file ? extractApiRouteInterceptorBridges(file, buildStaticContext(file)) : []
958
+ apiRoutes.push({
959
+ id: routeId,
960
+ source: { sourcePath: portablePath(options.moduleRoot, options.sourceRoot, filePath) },
961
+ bridges,
962
+ })
963
+ }
964
+ const commands: Array<{ id: string; source: ModuleFactSourceRef }> = []
965
+ const seenCommands = new Set<string>()
966
+ for (const filePath of sourceFilesBelow(path.join(options.moduleRoot, 'commands'))) {
967
+ if (path.basename(filePath).replace(/\.(?:ts|tsx)$/, '') === 'interceptors') continue
968
+ const source: ModuleFactSourceRef = { sourcePath: portablePath(options.moduleRoot, options.sourceRoot, filePath) }
969
+ for (const id of extractCommandIdsFromSource(filePath)) {
970
+ if (seenCommands.has(id)) continue
971
+ seenCommands.add(id)
972
+ commands.push({ id, source })
973
+ }
974
+ }
975
+ return { apiRoutes, commands }
976
+ }
977
+
978
+ function extractFactRefHosts(options: ExtractModuleExtensionFactsOptions): ModuleExtensionHostFact[] {
979
+ const hosts: ModuleExtensionHostFact[] = []
980
+ for (const entity of options.entities) {
981
+ hosts.push(factRefHost({
982
+ key: `entity.${entity.id}`,
983
+ id: entity.id,
984
+ moduleId: options.moduleId,
985
+ family: 'entity',
986
+ capabilities: ['response-enricher', 'query-enricher', 'mutation-guard', 'entity-extension'],
987
+ factSection: 'entities',
988
+ activation: 'host-opt-in',
989
+ scopeContract: 'tenant-and-organization',
990
+ }))
991
+ }
992
+ for (const route of options.apiRoutes) {
993
+ hosts.push(factRefHost({
994
+ key: `api-route.${route.path}`,
995
+ id: route.path,
996
+ moduleId: options.moduleId,
997
+ family: 'api-route',
998
+ capabilities: ['api-interceptor', 'mutation-guard'],
999
+ factSection: 'apiRoutes',
1000
+ phases: ['before', 'after'],
1001
+ activation: 'host-opt-in',
1002
+ scopeContract: 'request-auth-scope',
1003
+ }))
1004
+ }
1005
+ for (const event of options.events) {
1006
+ const capabilities: ExtensionHostCapability[] = ['async-subscriber', 'sync-subscriber']
1007
+ if (event.clientBroadcast) capabilities.push('browser-client')
1008
+ if (event.portalBroadcast) capabilities.push('browser-portal')
1009
+ hosts.push(factRefHost({
1010
+ key: `event.${event.id}`,
1011
+ id: event.id,
1012
+ moduleId: options.moduleId,
1013
+ family: 'event',
1014
+ capabilities,
1015
+ factSection: 'events',
1016
+ scopeContract: 'tenant-organization-and-audience',
1017
+ }))
1018
+ }
1019
+ for (const entityId of options.searchEntities) {
1020
+ const eventStem = entityId.replace(':', '.')
1021
+ for (const phase of ['querying', 'queried'] as const) {
1022
+ hosts.push(factRefHost({
1023
+ key: `query-lifecycle.${eventStem}.${phase}`,
1024
+ id: `${eventStem}.${phase}`,
1025
+ moduleId: options.moduleId,
1026
+ family: 'query-lifecycle',
1027
+ capabilities: ['sync-subscriber'],
1028
+ factSection: 'searchEntities',
1029
+ factKey: entityId,
1030
+ phases: phase === 'querying' ? ['block', 'query-transform'] : ['result-transform', 'scope-reapply'],
1031
+ activation: 'caller-opt-in',
1032
+ scopeContract: 'tenant-and-organization-reapplied-after-result',
1033
+ }))
1034
+ }
1035
+ }
1036
+ const apiHosts = apiExtensionHostIds(options.moduleId, options.moduleRoot)
1037
+ for (const entityId of apiHosts.entityIds) {
1038
+ const declaringFile = apiHosts.entitySourceFiles[entityId]
1039
+ const host = factRefHost({
1040
+ key: `api-entity.${entityId}`,
1041
+ id: entityId,
1042
+ moduleId: options.moduleId,
1043
+ family: 'entity',
1044
+ capabilities: ['response-enricher', 'query-enricher', 'mutation-guard'],
1045
+ factSection: 'apiRoutes',
1046
+ activation: 'host-opt-in',
1047
+ scopeContract: 'tenant-and-organization',
1048
+ })
1049
+ hosts.push(declaringFile
1050
+ ? {
1051
+ ...host,
1052
+ source: {
1053
+ kind: 'declaration',
1054
+ path: portablePath(options.moduleRoot, options.sourceRoot, declaringFile),
1055
+ symbol: entityId,
1056
+ },
1057
+ }
1058
+ : host)
1059
+ }
1060
+ return sortHosts([...new Map(hosts.map((host) => [`${host.family}:${host.id}`, host])).values()])
1061
+ }
1062
+
1063
+ function contributionBase(
1064
+ id: string,
1065
+ sourcePath: string,
1066
+ sourceSymbol?: string,
1067
+ ): ModuleExtensionContributionBase {
1068
+ return {
1069
+ id,
1070
+ targets: [],
1071
+ scopeContract: 'tenant-and-organization',
1072
+ source: { path: sourcePath, ...(sourceSymbol ? { symbol: sourceSymbol } : {}) },
1073
+ }
1074
+ }
1075
+
1076
+ /**
1077
+ * `ModuleInjectionTable` maps a spot to `ModuleInjectionSlot | ModuleInjectionSlot[]`,
1078
+ * and a slot is either a bare widget-id string or a placement object. The runtime
1079
+ * loader normalizes all three shapes (`injection-loader.ts` → `loadInjectionTable`);
1080
+ * reading only the array form here silently dropped every string and single-object
1081
+ * slot from the generated contribution facts.
1082
+ */
1083
+ function injectionTableSlots(value: StaticValue | undefined): StaticValue[] {
1084
+ if (value === undefined) return []
1085
+ return Array.isArray(value) ? value : [value]
1086
+ }
1087
+
1088
+ function extractInjectionTable(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1089
+ const filePath = conventionPath(options.moduleRoot, 'widgets/injection-table.ts')
1090
+ if (!filePath) return []
1091
+ const table = readRootObject(filePath, 'injectionTable')
1092
+ if (!table) return []
1093
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, filePath)
1094
+ const facts: ModuleExtensionContributionFact[] = []
1095
+ for (const targetId of Object.keys(table).sort((left, right) => left.localeCompare(right))) {
1096
+ for (const entry of injectionTableSlots(table[targetId])) {
1097
+ const slot = isStaticObject(entry) ? entry : null
1098
+ const widgetId = typeof entry === 'string' ? entry : slot ? stringValue(slot.widgetId) : undefined
1099
+ if (!widgetId) continue
1100
+ const features = slot ? strings(slot.features) : []
1101
+ const priority = slot ? numberValue(slot.priority) : undefined
1102
+ const payload = targetId.endsWith(':columns') ? 'column'
1103
+ : targetId.endsWith(':row-actions') ? 'row-action'
1104
+ : targetId.endsWith(':bulk-actions') ? 'bulk-action'
1105
+ : targetId.endsWith(':filters') ? 'filter'
1106
+ : targetId.endsWith(':fields') ? 'field'
1107
+ : 'render'
1108
+ const base = contributionBase(`${widgetId}@${targetId}`, sourcePath, 'injectionTable')
1109
+ const shared = {
1110
+ ...base,
1111
+ targets: [target(targetId)],
1112
+ features,
1113
+ placement: priority !== undefined ? { priority } : undefined,
1114
+ }
1115
+ if (targetId.startsWith('data-table:')) {
1116
+ facts.push({
1117
+ ...shared,
1118
+ kind: 'data-table',
1119
+ details: {
1120
+ payload: payload === 'field' ? 'render' : payload,
1121
+ tableId: targetId.replace(/^data-table:/, '').replace(/:(?:columns|row-actions|bulk-actions|filters|toolbar|header|footer|search-trailing)$/, ''),
1122
+ executionGuard: features.length > 0 ? 'both' : 'host',
1123
+ },
1124
+ })
1125
+ } else if (targetId.startsWith('crud-form:')) {
1126
+ facts.push({
1127
+ ...shared,
1128
+ kind: 'crud-form',
1129
+ details: {
1130
+ payload: payload === 'field' ? 'field' : 'render',
1131
+ entityId: targetId.replace(/^crud-form:/, '').replace(/:(?:fields|header)$/, ''),
1132
+ requestHeaderCapability: payload === 'field',
1133
+ },
1134
+ })
1135
+ } else {
1136
+ facts.push({
1137
+ ...shared,
1138
+ kind: 'widget',
1139
+ details: {
1140
+ payload: 'render',
1141
+ registryKey: widgetId,
1142
+ executionGuard: features.length > 0 ? 'both' : 'host',
1143
+ },
1144
+ })
1145
+ }
1146
+ }
1147
+ }
1148
+ return facts
1149
+ }
1150
+
1151
+ function extractObjectConvention(options: {
1152
+ module: ExtractModuleExtensionFactsOptions
1153
+ relativePath: string
1154
+ exportName: string
1155
+ exportAliases?: string[]
1156
+ build: (entry: StaticObject, sourcePath: string, index: number) => ModuleExtensionContributionFact | null
1157
+ }): ModuleExtensionContributionFact[] {
1158
+ const filePath = conventionPath(options.module.moduleRoot, options.relativePath)
1159
+ if (!filePath) return []
1160
+ const exportNames = [options.exportName, ...(options.exportAliases ?? [])]
1161
+ const entries = exportNames.reduce<StaticObject[]>((found, exportName) =>
1162
+ found.length > 0 ? found : readConventionObjectArray(filePath, exportName), [])
1163
+ const sourcePath = portablePath(options.module.moduleRoot, options.module.sourceRoot, filePath)
1164
+ return entries.flatMap((entry, index) => options.build(entry, sourcePath, index) ?? [])
1165
+ }
1166
+
1167
+ function methodPhases(entry: StaticObject, map: ReadonlyArray<[string, string]>): string[] {
1168
+ return map.flatMap(([property, phase]) => entry[property] === true ? [phase] : [])
1169
+ }
1170
+
1171
+ function extractEnrichers(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1172
+ return extractObjectConvention({
1173
+ module: options,
1174
+ relativePath: 'data/enrichers.ts',
1175
+ exportName: 'enrichers',
1176
+ build(entry, sourcePath, index) {
1177
+ const id = stringValue(entry.id) ?? `${options.moduleId}.enricher.${index}`
1178
+ const targetEntity = stringValue(entry.targetEntity)
1179
+ if (!targetEntity) return null
1180
+ const timeoutMs = numberValue(entry.timeout) ?? 2000
1181
+ const queryEngineConfig = isStaticObject(entry.queryEngine) ? entry.queryEngine : null
1182
+ // `enricher-registry` selects query-engine enrichers by `queryEngine.enabled === true`,
1183
+ // so a config object that omits it (or sets it false) is NOT query-enabled.
1184
+ const queryEngine = queryEngineConfig && booleanValue(queryEngineConfig.enabled) === true
1185
+ ? queryEngineConfig
1186
+ : null
1187
+ const surfaces = [entry.enrichMany === true ? 'list' : null, entry.enrichOne === true ? 'detail' : null]
1188
+ .filter((value): value is 'list' | 'detail' => value !== null)
1189
+ const base = contributionBase(id, sourcePath, id)
1190
+ return {
1191
+ ...base,
1192
+ kind: 'response-enricher',
1193
+ targets: [openTarget(targetEntity)],
1194
+ features: strings(entry.features),
1195
+ phases: surfaces,
1196
+ activation: queryEngine ? 'caller-opt-in' : 'host-opt-in',
1197
+ roundTripId: `${targetEntity}:read`,
1198
+ details: {
1199
+ targetEntity,
1200
+ surfaces,
1201
+ timeoutMs,
1202
+ fallback: entry.fallback === undefined ? 'none' : 'configured',
1203
+ critical: booleanValue(entry.critical) ?? false,
1204
+ cachePosture: 'rerun-on-list-cache-hit',
1205
+ ...(queryEngine ? { queryEngine: {
1206
+ engines: strings(queryEngine.engines),
1207
+ applyOn: (strings(queryEngine.applyOn).length > 0 ? strings(queryEngine.applyOn) : ['list', 'detail'])
1208
+ .filter((value): value is 'list' | 'detail' => value === 'list' || value === 'detail'),
1209
+ activation: 'caller-opt-in' as const,
1210
+ } } : {}),
1211
+ },
1212
+ }
1213
+ },
1214
+ })
1215
+ }
1216
+
1217
+ function extractApiInterceptors(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1218
+ return extractObjectConvention({
1219
+ module: options,
1220
+ relativePath: 'api/interceptors.ts',
1221
+ exportName: 'interceptors',
1222
+ build(entry, sourcePath, index) {
1223
+ const id = stringValue(entry.id) ?? `${options.moduleId}.api-interceptor.${index}`
1224
+ const targetRoute = stringValue(entry.targetRoute)
1225
+ if (!targetRoute) return null
1226
+ const phases = methodPhases(entry, [['before', 'before'], ['after', 'after']])
1227
+ .filter((phase): phase is 'before' | 'after' => phase === 'before' || phase === 'after')
1228
+ const methods = strings(entry.methods)
1229
+ const base = contributionBase(id, sourcePath, id)
1230
+ return {
1231
+ ...base,
1232
+ kind: 'api-interceptor',
1233
+ targets: [openTarget(targetRoute)],
1234
+ phases,
1235
+ features: strings(entry.features),
1236
+ details: {
1237
+ route: targetRoute,
1238
+ methods,
1239
+ phases,
1240
+ activation: targetRoute.startsWith('/') ? 'custom-route-bridge' : 'crud-pipeline',
1241
+ timeoutMs: numberValue(entry.timeout) ?? 2000,
1242
+ failurePosture: booleanValue(entry.critical) ? 'fail-closed' : 'fallback',
1243
+ },
1244
+ }
1245
+ },
1246
+ })
1247
+ }
1248
+
1249
+ function extractCommandInterceptors(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1250
+ return extractObjectConvention({
1251
+ module: options,
1252
+ relativePath: 'commands/interceptors.ts',
1253
+ exportName: 'interceptors',
1254
+ build(entry, sourcePath, index) {
1255
+ const id = stringValue(entry.id) ?? `${options.moduleId}.command-interceptor.${index}`
1256
+ const targetCommand = stringValue(entry.targetCommand)
1257
+ if (!targetCommand) return null
1258
+ const phases = methodPhases(entry, [
1259
+ ['beforeExecute', 'before-execute'],
1260
+ ['afterExecute', 'after-execute'],
1261
+ ['beforeUndo', 'before-undo'],
1262
+ ['afterUndo', 'after-undo'],
1263
+ ]).filter((phase): phase is 'before-execute' | 'after-execute' | 'before-undo' | 'after-undo' =>
1264
+ phase === 'before-execute' || phase === 'after-execute' || phase === 'before-undo' || phase === 'after-undo')
1265
+ const base = contributionBase(id, sourcePath, id)
1266
+ return {
1267
+ ...base,
1268
+ kind: 'command-interceptor',
1269
+ targets: [openTarget(targetCommand)],
1270
+ phases,
1271
+ features: strings(entry.features),
1272
+ details: { targetCommand, phases },
1273
+ }
1274
+ },
1275
+ })
1276
+ }
1277
+
1278
+ function extractGuards(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1279
+ return extractObjectConvention({
1280
+ module: options,
1281
+ relativePath: 'data/guards.ts',
1282
+ exportName: 'guards',
1283
+ build(entry, sourcePath, index) {
1284
+ const id = stringValue(entry.id) ?? `${options.moduleId}.guard.${index}`
1285
+ const targetEntity = stringValue(entry.targetEntity)
1286
+ if (!targetEntity) return null
1287
+ const operations = strings(entry.operations)
1288
+ .filter((operation): operation is 'create' | 'update' | 'delete' =>
1289
+ operation === 'create' || operation === 'update' || operation === 'delete')
1290
+ const capabilities = [entry.validate === true ? 'block' : null, entry.validate === true ? 'rewrite' : null, entry.afterSuccess === true ? 'after-success' : null]
1291
+ .filter((value): value is 'block' | 'rewrite' | 'after-success' => value !== null)
1292
+ const base = contributionBase(id, sourcePath, id)
1293
+ return {
1294
+ ...base,
1295
+ kind: 'mutation-guard',
1296
+ targets: [openTarget(targetEntity)],
1297
+ operations,
1298
+ features: strings(entry.features),
1299
+ roundTripId: `${targetEntity}:write`,
1300
+ details: { entityId: targetEntity, operations, capabilities, optimisticLock: 'preserved' },
1301
+ }
1302
+ },
1303
+ })
1304
+ }
1305
+
1306
+ function extractEntityExtensions(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1307
+ return extractObjectConvention({
1308
+ module: options,
1309
+ relativePath: 'data/extensions.ts',
1310
+ exportName: 'entityExtensions',
1311
+ exportAliases: ['extensions'],
1312
+ build(entry, sourcePath, index) {
1313
+ const baseEntity = stringValue(entry.base)
1314
+ const extensionEntity = stringValue(entry.extension)
1315
+ const join = isStaticObject(entry.join) ? entry.join : null
1316
+ if (!baseEntity || !extensionEntity || !join) return null
1317
+ const id = `${options.moduleId}.entity-extension.${index}:${baseEntity}->${extensionEntity}`
1318
+ const contribution = contributionBase(id, sourcePath, 'extensions')
1319
+ return {
1320
+ ...contribution,
1321
+ kind: 'entity-extension',
1322
+ targets: [openTarget(baseEntity)],
1323
+ details: {
1324
+ hostEntityId: baseEntity,
1325
+ extensionEntityId: extensionEntity,
1326
+ linkId: `${stringValue(join.baseKey) ?? 'id'}:${stringValue(join.extensionKey) ?? 'id'}`,
1327
+ scopeContract: 'tenant-and-organization',
1328
+ orphanContract: booleanValue(entry.required) ? 'required' : 'optional',
1329
+ },
1330
+ }
1331
+ },
1332
+ })
1333
+ }
1334
+
1335
+ function extractSubscribers(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1336
+ const directory = path.join(options.moduleRoot, 'subscribers')
1337
+ if (!fs.existsSync(directory)) return []
1338
+ const facts: ModuleExtensionContributionFact[] = []
1339
+ for (const filePath of sourceFilesBelow(directory)) {
1340
+ const metadata = readRootObject(filePath, 'metadata')
1341
+ if (!metadata) continue
1342
+ const event = stringValue(metadata.event)
1343
+ if (!event) continue
1344
+ const relativeStem = path.relative(directory, filePath).replace(/\.tsx?$/, '').split(path.sep).join('/')
1345
+ const subscriberId = stringValue(metadata.id) ?? `${options.moduleId}:${relativeStem}`
1346
+ const persistent = booleanValue(metadata.persistent) ?? false
1347
+ const sync = booleanValue(metadata.sync) ?? !persistent
1348
+ const contribution = contributionBase(subscriberId, portablePath(options.moduleRoot, options.sourceRoot, filePath), 'metadata')
1349
+ facts.push({
1350
+ ...contribution,
1351
+ kind: 'subscriber',
1352
+ targets: [openTarget(event)],
1353
+ phases: sync ? ['before-or-after'] : ['async-delivery'],
1354
+ details: {
1355
+ event,
1356
+ subscriberId,
1357
+ persistent,
1358
+ sync,
1359
+ ...(numberValue(metadata.priority) !== undefined ? { priority: numberValue(metadata.priority) as number } : {}),
1360
+ },
1361
+ })
1362
+ }
1363
+ return facts
1364
+ }
1365
+
1366
+ function extractBrowserReactions(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1367
+ return options.events.flatMap((event) => {
1368
+ const transports = [event.clientBroadcast ? 'client' : null, event.portalBroadcast ? 'portal' : null]
1369
+ .filter((value): value is 'client' | 'portal' => value !== null)
1370
+ if (transports.length === 0) return []
1371
+ const contribution = contributionBase(`${event.id}.browser`, path.posix.join(options.sourceRoot, 'events.ts'), 'events')
1372
+ return [{
1373
+ ...contribution,
1374
+ kind: 'browser-reaction' as const,
1375
+ targets: [factTarget(event.id, 'events')],
1376
+ details: {
1377
+ transports,
1378
+ hooks: transports.flatMap((transport) => transport === 'client' ? ['useAppEvent', 'useOperationProgress'] : ['usePortalAppEvent']),
1379
+ audienceScopeContract: 'tenant-organization-user-role-and-customer',
1380
+ },
1381
+ }]
1382
+ })
1383
+ }
1384
+
1385
+ function extractNotificationReactions(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1386
+ return extractObjectConvention({
1387
+ module: options,
1388
+ relativePath: 'notifications.handlers.ts',
1389
+ exportName: 'notificationHandlers',
1390
+ build(entry, sourcePath, index) {
1391
+ const notificationType = stringValue(entry.notificationType)
1392
+ if (!notificationType) return null
1393
+ const declaredId = stringValue(entry.id)
1394
+ const id = declaredId ?? `${options.moduleId}.notification-handler.${index}`
1395
+ const contribution = contributionBase(id, sourcePath, 'notificationHandlers')
1396
+ return {
1397
+ ...contribution,
1398
+ kind: 'browser-reaction',
1399
+ targets: [factTarget(notificationType, 'notifications')],
1400
+ features: strings(entry.features),
1401
+ placement: numberValue(entry.priority) !== undefined ? { priority: numberValue(entry.priority) } : undefined,
1402
+ details: {
1403
+ transports: ['notification-effect'],
1404
+ hooks: ['useNotificationEffect'],
1405
+ audienceScopeContract: 'tenant-organization-user-role-and-customer',
1406
+ ...(declaredId ? { overrideKey: declaredId } : {}),
1407
+ },
1408
+ }
1409
+ },
1410
+ })
1411
+ }
1412
+
1413
+ function extractComponentOverrides(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1414
+ return extractObjectConvention({
1415
+ module: options,
1416
+ relativePath: 'widgets/components.ts',
1417
+ exportName: 'componentOverrides',
1418
+ build(entry, sourcePath, index) {
1419
+ const targetDefinition = isStaticObject(entry.target) ? entry.target : null
1420
+ const handle = targetDefinition ? stringValue(targetDefinition.componentId) : undefined
1421
+ if (!handle) return null
1422
+ // `ComponentOverride` discriminates on `wrapper` / `propsTransform` /
1423
+ // `replacement` — never on a `props` property, which the union has no member for.
1424
+ const mode = entry.wrapper !== undefined ? 'wrapper' : entry.propsTransform !== undefined ? 'props' : 'replace'
1425
+ const id = `${options.moduleId}.component-override.${index}:${handle}`
1426
+ const contribution = contributionBase(id, sourcePath, 'componentOverrides')
1427
+ return {
1428
+ ...contribution,
1429
+ kind: 'component-override',
1430
+ targets: [target(handle)],
1431
+ details: {
1432
+ handle,
1433
+ mode,
1434
+ propsContract: stringValue(entry.propsContract) ?? 'component-props-schema',
1435
+ },
1436
+ }
1437
+ },
1438
+ })
1439
+ }
1440
+
1441
+ function extractSpecializedRegistries(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1442
+ const facts: ModuleExtensionContributionFact[] = []
1443
+ const add = (
1444
+ id: string,
1445
+ registry: SpecializedRegistry,
1446
+ sourcePath: string,
1447
+ specialistRoute: string,
1448
+ symbol = id,
1449
+ ): void => {
1450
+ const contributionId = `${registry}:${id}`
1451
+ const contribution = contributionBase(contributionId, sourcePath, id)
1452
+ const fact: ModuleExtensionContributionFact = {
1453
+ ...contribution,
1454
+ kind: 'specialized-registry',
1455
+ targets: [factTarget(id, specialistRoute)],
1456
+ source: { path: sourcePath, symbol },
1457
+ details: { registry, registryId: id, specialistRoute },
1458
+ }
1459
+ const existingIndex = facts.findIndex((entry) => entry.id === contributionId)
1460
+ if (existingIndex >= 0) facts[existingIndex] = fact
1461
+ else facts.push(fact)
1462
+ }
1463
+ for (const id of options.notifications ?? []) add(id, 'notification', path.posix.join(options.sourceRoot, 'notifications.ts'), 'notifications')
1464
+ for (const id of options.searchEntities) add(id, 'search', path.posix.join(options.sourceRoot, 'search.ts'), 'search')
1465
+ for (const tool of options.aiTools ?? []) add(tool.name, 'ai', tool.sourcePath, 'aiTools')
1466
+ for (const agent of options.aiAgents ?? []) add(agent.id, 'ai', agent.sourcePath, 'aiAgents')
1467
+
1468
+ const vectorPath = conventionPath(options.moduleRoot, 'vector.ts')
1469
+ if (vectorPath) {
1470
+ const vectorConfig = readRootObject(vectorPath, 'vectorConfig') ?? readRootObject(vectorPath, 'config')
1471
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, vectorPath)
1472
+ if (vectorConfig && Array.isArray(vectorConfig.entities)) {
1473
+ for (const entity of vectorConfig.entities) {
1474
+ if (!isStaticObject(entity)) continue
1475
+ const entityId = stringValue(entity.entityId)
1476
+ if (entityId) add(entityId, 'vector', sourcePath, 'vector', 'vectorConfig')
1477
+ }
1478
+ }
1479
+ }
1480
+
1481
+ const integrationPath = conventionPath(options.moduleRoot, 'integration.ts')
1482
+ if (integrationPath) {
1483
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, integrationPath)
1484
+ const addIntegration = (integration: StaticObject, symbol: string, bundleId?: string): void => {
1485
+ const integrationId = stringValue(integration.id)
1486
+ if (integrationId) {
1487
+ add(integrationId, 'integration', sourcePath, 'integrations', bundleId ? `${symbol}[${bundleId}]` : symbol)
1488
+ }
1489
+ const providerKey = stringValue(integration.providerKey)
1490
+ const category = stringValue(integration.category)
1491
+ const categoryRegistry = category === 'payment' ? 'payment'
1492
+ : category === 'shipping' ? 'shipping'
1493
+ : category === 'currency' ? 'currency'
1494
+ : null
1495
+ if (providerKey && categoryRegistry) {
1496
+ const specialistRoute = categoryRegistry === 'payment' ? 'paymentGateways'
1497
+ : categoryRegistry === 'shipping' ? 'shippingCarriers'
1498
+ : 'currencies'
1499
+ add(providerKey, categoryRegistry, sourcePath, specialistRoute, `${symbol}.providerKey`)
1500
+ }
1501
+ }
1502
+ const singular = readRootObject(integrationPath, 'integration')
1503
+ if (singular) addIntegration(singular, 'integration')
1504
+ for (const integration of readConventionObjectArray(integrationPath, 'integrations')) {
1505
+ addIntegration(integration, 'integrations')
1506
+ }
1507
+ const bundle = readRootObject(integrationPath, 'integrationBundle') ?? readRootObject(integrationPath, 'bundle')
1508
+ if (bundle) {
1509
+ const bundleId = stringValue(bundle.id) ?? 'bundle'
1510
+ const members = Array.isArray(bundle.integrations) ? bundle.integrations : []
1511
+ for (const member of members) {
1512
+ if (isStaticObject(member)) addIntegration(member, 'integrationBundle', bundleId)
1513
+ }
1514
+ }
1515
+ }
1516
+
1517
+ const workflowsPath = conventionPath(options.moduleRoot, 'workflows.ts')
1518
+ if (workflowsPath) {
1519
+ const config = readRootObject(workflowsPath, 'workflowsConfig')
1520
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, workflowsPath)
1521
+ if (config && Array.isArray(config.workflows)) {
1522
+ for (const workflow of config.workflows) {
1523
+ if (!isStaticObject(workflow)) continue
1524
+ const workflowId = stringValue(workflow.workflowId)
1525
+ if (workflowId) add(workflowId, 'workflow', sourcePath, 'workflows', 'workflowsConfig')
1526
+ }
1527
+ }
1528
+ }
1529
+
1530
+ const diPath = conventionPath(options.moduleRoot, 'di.ts')
1531
+ if (diPath) {
1532
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, diPath)
1533
+ const registrations = readCallObjectArguments(diPath, new Set([
1534
+ 'registerPaymentGatewayDescriptor',
1535
+ 'registerShippingAdapter',
1536
+ 'registerCurrencyProvider',
1537
+ ]))
1538
+ for (const registration of registrations) {
1539
+ const registry = registration.callee === 'registerPaymentGatewayDescriptor' ? 'payment'
1540
+ : registration.callee === 'registerShippingAdapter' ? 'shipping'
1541
+ : 'currency'
1542
+ const registryId = stringValue(registration.value.providerKey)
1543
+ ?? stringValue(registration.value.key)
1544
+ ?? stringValue(registration.value.id)
1545
+ if (!registryId) continue
1546
+ const specialistRoute = registry === 'payment' ? 'paymentGateways'
1547
+ : registry === 'shipping' ? 'shippingCarriers'
1548
+ : 'currencies'
1549
+ add(registryId, registry, sourcePath, specialistRoute, registration.callee)
1550
+ }
1551
+ }
1552
+
1553
+ const dashboardRoots = { appBase: path.join(options.moduleRoot, '__app__'), pkgBase: options.moduleRoot }
1554
+ for (const widgetFile of scanModuleDir(dashboardRoots, SCAN_CONFIGS.dashboardWidgets)) {
1555
+ const filePath = path.join(options.moduleRoot, 'widgets', 'dashboard', widgetFile.relPath)
1556
+ const widget = readRootObject(filePath, 'widget')
1557
+ const metadata = widget && isStaticObject(widget.metadata) ? widget.metadata : null
1558
+ if (!metadata) continue
1559
+ const widgetId = stringValue(metadata.id)
1560
+ if (!widgetId) continue
1561
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, filePath)
1562
+ const contribution = contributionBase(`dashboard:${widgetId}`, sourcePath, 'widget.metadata')
1563
+ facts.push({
1564
+ ...contribution,
1565
+ kind: 'widget',
1566
+ targets: [target(`dashboard:${widgetId}`)],
1567
+ features: strings(metadata.features),
1568
+ details: {
1569
+ payload: 'dashboard',
1570
+ registryKey: widgetId,
1571
+ executionGuard: strings(metadata.features).length > 0 ? 'both' : 'host',
1572
+ },
1573
+ })
1574
+ }
1575
+ return facts
1576
+ }
1577
+
1578
+ /**
1579
+ * Closed activation-adapter registry (Spec 2). Each `ModuleExtensionActivationKind`
1580
+ * maps to the contribution kinds it can bind and to the mode by which a `bound`
1581
+ * resolution is proven:
1582
+ *
1583
+ * - `call-site-object` — the host module owns a distinct opt-in call site (a
1584
+ * `makeCrudRoute` option or a mutation-guard bridge). Presence of the option —
1585
+ * NOT mere entity/route existence — produces an emitted `ModuleExtensionActivation`
1586
+ * object. This is what makes "the entity could host X" (capability-only) different
1587
+ * from "this route actually activates X" (bound).
1588
+ * - `host-reference` — the runtime bridge is universal for any matching host
1589
+ * (every api route runs the interceptor pipeline; a bound widget/component host
1590
+ * proves consumption). No object is duplicated; `bound` is derived from the
1591
+ * existing bound host fact and the incoming row carries no `activationId`.
1592
+ * - `owner-reference` — like host-reference but proven by the target module owning
1593
+ * the concrete runtime id (command ids have no host fact of their own).
1594
+ *
1595
+ * The registry is exhaustive by construction: the `Record` key set is the full
1596
+ * `ModuleExtensionActivationKind` union, so adding a kind fails the typecheck until
1597
+ * it is classified. `activation-adapter-coverage.test.ts` re-checks this at runtime.
1598
+ */
1599
+ type ActivationAdapterMode = 'call-site-object' | 'host-reference' | 'owner-reference'
1600
+
1601
+ export const ACTIVATION_ADAPTERS: Record<ModuleExtensionActivationKind, {
1602
+ contributionKinds: ModuleExtensionContributionKind[]
1603
+ mode: ActivationAdapterMode
1604
+ }> = {
1605
+ 'crud-response-enricher': { contributionKinds: ['response-enricher'], mode: 'call-site-object' },
1606
+ 'query-enricher': { contributionKinds: ['response-enricher'], mode: 'call-site-object' },
1607
+ 'mutation-guard': { contributionKinds: ['mutation-guard'], mode: 'call-site-object' },
1608
+ 'api-interceptor-bridge': { contributionKinds: ['api-interceptor'], mode: 'host-reference' },
1609
+ 'command-interceptor-bridge': { contributionKinds: ['command-interceptor'], mode: 'owner-reference' },
1610
+ 'widget-injection-consumer': { contributionKinds: ['widget', 'data-table', 'crud-form'], mode: 'host-reference' },
1611
+ 'component-extension-consumer': { contributionKinds: ['component-override'], mode: 'host-reference' },
1612
+ 'dashboard-host-consumer': { contributionKinds: ['widget'], mode: 'host-reference' },
1613
+ }
1614
+
1615
+ /**
1616
+ * Every UMES contribution kind classified against the activation registry: either
1617
+ * the ordered set of activation kinds that can bind it, or `capability-only` for
1618
+ * contributions that are always active by declaration/registration and never gain
1619
+ * a route/entity activation. Exhaustive by `Record` construction — a new kind fails
1620
+ * the typecheck until classified.
1621
+ */
1622
+ export const CONTRIBUTION_ACTIVATION_CLASSIFICATION: Record<
1623
+ ModuleExtensionContributionKind,
1624
+ ModuleExtensionActivationKind[] | 'capability-only'
1625
+ > = {
1626
+ 'response-enricher': ['crud-response-enricher', 'query-enricher'],
1627
+ 'mutation-guard': ['mutation-guard'],
1628
+ 'api-interceptor': ['api-interceptor-bridge'],
1629
+ 'command-interceptor': ['command-interceptor-bridge'],
1630
+ 'widget': ['widget-injection-consumer', 'dashboard-host-consumer'],
1631
+ 'data-table': ['widget-injection-consumer'],
1632
+ 'crud-form': ['widget-injection-consumer'],
1633
+ 'component-override': ['component-extension-consumer'],
1634
+ 'entity-extension': 'capability-only',
1635
+ 'subscriber': 'capability-only',
1636
+ 'browser-reaction': 'capability-only',
1637
+ 'specialized-registry': 'capability-only',
1638
+ 'module-override': 'capability-only',
1639
+ }
1640
+
1641
+ export const ALL_ACTIVATION_KINDS = Object.keys(ACTIVATION_ADAPTERS) as ModuleExtensionActivationKind[]
1642
+ export const ALL_CONTRIBUTION_KINDS = Object.keys(CONTRIBUTION_ACTIVATION_CLASSIFICATION) as ModuleExtensionContributionKind[]
1643
+
1644
+ /**
1645
+ * Verified mutation-guard bridge shapes. The canonical route helper nests the
1646
+ * resource under `input` (`runRouteMutationGuards({ …, input: { resourceKind } })`),
1647
+ * while the legacy helpers take the resource object directly. Module wrappers around
1648
+ * the canonical helper live under `lib/` as often as under `api/`, so both trees are
1649
+ * scanned.
1650
+ */
1651
+ const MUTATION_GUARD_BRIDGE_ADAPTERS: Record<string, { resourceArgument: 'input-property' | 'any-argument' }> = {
1652
+ runRouteMutationGuards: { resourceArgument: 'input-property' },
1653
+ validateCrudMutationGuard: { resourceArgument: 'any-argument' },
1654
+ runMutationGuards: { resourceArgument: 'any-argument' },
1655
+ }
1656
+
1657
+ const MUTATION_GUARD_SOURCE_DIRECTORIES = ['api', 'lib'] as const
1658
+
1659
+ const MUTATION_GUARD_OPERATIONS = new Set(['create', 'update', 'delete', 'custom'])
1660
+
1661
+ /**
1662
+ * `'custom'` action endpoints are mapped by `toRegistryMutationOperation` onto the
1663
+ * closest registry operation, `'update'`.
1664
+ */
1665
+ function toRegistryGuardOperation(operation: string): string {
1666
+ return operation === 'custom' ? 'update' : operation
1667
+ }
1668
+
1669
+ function readMutationGuardBridge(
1670
+ node: ts.CallExpression,
1671
+ context: StaticContext,
1672
+ ): { entityId: string; operations: string[] } | null {
1673
+ const callee = node.expression
1674
+ const calleeName = ts.isIdentifier(callee)
1675
+ ? callee.text
1676
+ : ts.isPropertyAccessExpression(callee)
1677
+ ? callee.name.text
1678
+ : null
1679
+ const adapter = calleeName ? MUTATION_GUARD_BRIDGE_ADAPTERS[calleeName] : undefined
1680
+ if (!adapter) return null
1681
+
1682
+ const argumentObjects = node.arguments
1683
+ .map((argument) => staticValue(argument, context))
1684
+ .filter(isStaticObject)
1685
+ const resourceObjects = adapter.resourceArgument === 'input-property'
1686
+ ? argumentObjects.flatMap((argumentObject) => (isStaticObject(argumentObject.input) ? [argumentObject.input] : []))
1687
+ : argumentObjects
1688
+ for (const resourceObject of resourceObjects) {
1689
+ const entityId = stringValue(resourceObject.resourceKind) ?? stringValue(resourceObject.entityId)
1690
+ if (!entityId) continue
1691
+ const declaredOperation = stringValue(resourceObject.operation)
1692
+ const operations = declaredOperation && MUTATION_GUARD_OPERATIONS.has(declaredOperation)
1693
+ ? [toRegistryGuardOperation(declaredOperation)]
1694
+ : []
1695
+ return { entityId, operations }
1696
+ }
1697
+ return null
1698
+ }
1699
+
1700
+ function nodeLine(file: ts.SourceFile, node: ts.Node): number {
1701
+ return file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1
1702
+ }
1703
+
1704
+ function activationId(host: ModuleExtensionTargetRef, kind: ModuleExtensionActivationKind): string {
1705
+ const method = host.method ? `:${host.method}` : ''
1706
+ return `${host.kind}:${host.id}${method}:${kind}`
1707
+ }
1708
+
1709
+ function sortActivations(activations: ModuleExtensionActivation[]): ModuleExtensionActivation[] {
1710
+ return [...activations].sort((left, right) => left.id.localeCompare(right.id))
1711
+ }
1712
+
1713
+ /**
1714
+ * Extract the `call-site-object` activations (CRUD response/query enrichers and
1715
+ * mutation guards) from a module's real `api/**` opt-in sites. Entity or route
1716
+ * presence alone never lands here — only a literal `enrichers: { entityId }`
1717
+ * option or a mutation-guard bridge call with a static `resourceKind`/`entityId`.
1718
+ */
1719
+ /**
1720
+ * `<engine>.query(entityId, { …, extensions })` — the only call shape that enables the
1721
+ * query-engine enricher and sync query-lifecycle stages.
1722
+ */
1723
+ function isQueryWithExtensionsCall(node: ts.Node, context: StaticContext): node is ts.CallExpression {
1724
+ if (!ts.isCallExpression(node)) return false
1725
+ const callee = node.expression
1726
+ const calleeName = ts.isPropertyAccessExpression(callee)
1727
+ ? callee.name.text
1728
+ : ts.isIdentifier(callee)
1729
+ ? callee.text
1730
+ : null
1731
+ if (calleeName !== 'query') return false
1732
+ const queryOptions = node.arguments[1] ? staticValue(node.arguments[1], context) : undefined
1733
+ return isStaticObject(queryOptions) && queryOptions.extensions !== undefined
1734
+ }
1735
+
1736
+ function extractCallSiteActivations(options: ExtractModuleExtensionFactsOptions): ModuleExtensionActivation[] {
1737
+ const activations: ModuleExtensionActivation[] = []
1738
+ const byId = new Map<string, ModuleExtensionActivation>()
1739
+ const push = (activation: ModuleExtensionActivation): void => {
1740
+ const existing = byId.get(activation.id)
1741
+ if (existing) {
1742
+ if (!existing.operations || !activation.operations) {
1743
+ delete existing.operations
1744
+ } else {
1745
+ existing.operations = [...new Set([...existing.operations, ...activation.operations])]
1746
+ .sort((left, right) => left.localeCompare(right))
1747
+ }
1748
+ return
1749
+ }
1750
+ byId.set(activation.id, activation)
1751
+ activations.push(activation)
1752
+ }
1753
+ const scannedFiles = MUTATION_GUARD_SOURCE_DIRECTORIES
1754
+ .flatMap((directory) => sourceFilesBelow(path.join(options.moduleRoot, directory)))
1755
+ for (const filePath of scannedFiles) {
1756
+ const file = sourceFile(filePath)
1757
+ if (!file) continue
1758
+ const context = buildStaticContext(file)
1759
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, filePath)
1760
+ const visit = (node: ts.Node): void => {
1761
+ // `enrichers: { entityId }` on a CRUD route runs `applyResponseEnrichers` after the
1762
+ // list/detail hook. It does NOT enable the query-engine enricher stage.
1763
+ if (ts.isPropertyAssignment(node) && propertyName(node.name) === 'enrichers') {
1764
+ const config = staticValue(node.initializer, context)
1765
+ const entityId = isStaticObject(config) ? stringValue(config.entityId) : undefined
1766
+ if (entityId) {
1767
+ const host: ModuleExtensionTargetRef = { kind: 'entity', id: entityId, moduleId: options.moduleId }
1768
+ push({
1769
+ id: activationId(host, 'crud-response-enricher'),
1770
+ kind: 'crud-response-enricher',
1771
+ host,
1772
+ contributionKinds: ['response-enricher'],
1773
+ source: { sourcePath, line: nodeLine(file, node) },
1774
+ })
1775
+ }
1776
+ }
1777
+ // The query-engine enricher stage runs only for a query that passes `extensions`,
1778
+ // so the bound call site is `queryEngine.query('<entityId>', { …, extensions })`.
1779
+ if (isQueryWithExtensionsCall(node, context)) {
1780
+ const entityId = stringValue(staticValue(node.arguments[0], context))
1781
+ if (entityId) {
1782
+ const host: ModuleExtensionTargetRef = { kind: 'entity', id: entityId, moduleId: options.moduleId }
1783
+ push({
1784
+ id: activationId(host, 'query-enricher'),
1785
+ kind: 'query-enricher',
1786
+ host,
1787
+ contributionKinds: ['response-enricher'],
1788
+ source: { sourcePath, line: nodeLine(file, node) },
1789
+ })
1790
+ }
1791
+ }
1792
+ if (ts.isCallExpression(node)) {
1793
+ const bridge = readMutationGuardBridge(node, context)
1794
+ if (bridge) {
1795
+ const host: ModuleExtensionTargetRef = { kind: 'entity', id: bridge.entityId, moduleId: options.moduleId }
1796
+ push({
1797
+ id: activationId(host, 'mutation-guard'),
1798
+ kind: 'mutation-guard',
1799
+ host,
1800
+ contributionKinds: ['mutation-guard'],
1801
+ ...(bridge.operations.length > 0 ? { operations: bridge.operations } : {}),
1802
+ source: { sourcePath, line: nodeLine(file, node) },
1803
+ })
1804
+ }
1805
+ }
1806
+ node.forEachChild(visit)
1807
+ }
1808
+ file.forEachChild(visit)
1809
+ }
1810
+ return sortActivations(activations)
1811
+ }
1812
+
1813
+ export function extractModuleExtensionFacts(options: ExtractModuleExtensionFactsOptions): ModuleExtensionSurfaceFacts {
1814
+ const declared = extractDeclaredHosts(options)
1815
+ const hosts = sortHosts([...declared.hosts, ...extractFactRefHosts(options)])
1816
+ const contributions = sortContributions([
1817
+ ...extractInjectionTable(options),
1818
+ ...extractEnrichers(options),
1819
+ ...extractApiInterceptors(options),
1820
+ ...extractCommandInterceptors(options),
1821
+ ...extractGuards(options),
1822
+ ...extractEntityExtensions(options),
1823
+ ...extractSubscribers(options),
1824
+ ...extractBrowserReactions(options),
1825
+ ...extractNotificationReactions(options),
1826
+ ...extractComponentOverrides(options),
1827
+ ...extractSpecializedRegistries(options),
1828
+ ])
1829
+ return {
1830
+ hosts,
1831
+ contributions,
1832
+ unresolved: declared.unresolved,
1833
+ activations: extractCallSiteActivations(options),
1834
+ }
1835
+ }
1836
+
1837
+ function patternMatches(pattern: string, targetId: string): boolean {
1838
+ const escaped = pattern
1839
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
1840
+ .replace(/\\\{[^}]+\\\}/g, '[^:]+')
1841
+ .replace(/\*/g, '.*')
1842
+ return new RegExp(`^${escaped}$`).test(targetId)
1843
+ }
1844
+
1845
+ function targetModuleId(targetId: string): string | null {
1846
+ const withoutPrefix = targetId.replace(/^(?:data-table|crud-form|detail|portal-page):/, '')
1847
+ const match = /^([a-z][a-z0-9_]*)[.:]/.exec(withoutPrefix)
1848
+ return match?.[1] ?? null
1849
+ }
1850
+
1851
+ export function correlateExtensionTarget(
1852
+ targetFact: ModuleExtensionTargetFact,
1853
+ options: CorrelateExtensionFactsOptions,
1854
+ ): ModuleExtensionTargetFact {
1855
+ if (targetFact.resolution !== 'unresolved') return targetFact
1856
+ const allHosts = Object.values(options.surfacesByModule).flatMap((surface) => surface.hosts)
1857
+ if (targetFact.id.includes('*') || targetFact.id.includes('{')) {
1858
+ const knownIds = [
1859
+ ...allHosts.filter((host) => host.bound).map((host) => host.id),
1860
+ ...options.entityIds,
1861
+ ...options.eventIds,
1862
+ ...options.apiRoutes,
1863
+ ...(options.commandIds ?? []),
1864
+ ]
1865
+ if (targetFact.id === '*' || knownIds.some((knownId) => patternMatches(targetFact.id, knownId))) {
1866
+ return { id: targetFact.id, resolution: 'pattern' }
1867
+ }
1868
+ }
1869
+ const exact = allHosts.find((host) => host.bound && (
1870
+ host.id === targetFact.id
1871
+ || host.aliases?.includes(targetFact.id)
1872
+ || (host.family === 'api-route' && host.id.endsWith(`/${targetFact.id}`))
1873
+ ))
1874
+ if (exact) {
1875
+ return exact.source.kind === 'fact-ref'
1876
+ ? factTarget(targetFact.id, exact.source.factSection, exact.source.factKey)
1877
+ : { id: targetFact.id, resolution: 'exact' }
1878
+ }
1879
+ const patterned = allHosts.find((host) => host.bound && host.resolution === 'pattern' && patternMatches(host.id, targetFact.id))
1880
+ if (patterned) return { id: targetFact.id, resolution: 'pattern' }
1881
+ const framework = allFrameworkHosts().find((host) => patternMatches(host.id, targetFact.id))
1882
+ ?? (FRAMEWORK_PREFIXES.some((prefix) => targetFact.id.startsWith(prefix)) ? FRAMEWORK_HOSTS[0] : undefined)
1883
+ if (framework) return { id: targetFact.id, resolution: 'framework' }
1884
+ if (options.entityIds.has(targetFact.id)) {
1885
+ return { id: targetFact.id, resolution: 'fact-ref', factRef: { factSection: 'entities', factKey: targetFact.id } }
1886
+ }
1887
+ if (options.eventIds.has(targetFact.id)) {
1888
+ return { id: targetFact.id, resolution: 'fact-ref', factRef: { factSection: 'events', factKey: targetFact.id } }
1889
+ }
1890
+ if (options.commandIds?.has(targetFact.id)) {
1891
+ return factTarget(targetFact.id, 'ownedContracts.command')
1892
+ }
1893
+ const route = [...options.apiRoutes].find((apiRoute) => apiRoute === targetFact.id || apiRoute.endsWith(`/${targetFact.id}`))
1894
+ if (route) return { id: targetFact.id, resolution: 'fact-ref', factRef: { factSection: 'apiRoutes', factKey: route } }
1895
+ const ownerModule = targetModuleId(targetFact.id)
1896
+ if (ownerModule && !options.surfacesByModule[ownerModule]) {
1897
+ return { id: targetFact.id, resolution: 'optional-external', optionalOwnerPackage: `module:${ownerModule}` }
1898
+ }
1899
+ if (ownerModule && options.contributingModuleId && ownerModule !== options.contributingModuleId) {
1900
+ return { id: targetFact.id, resolution: 'optional-external', optionalOwnerPackage: `module:${ownerModule}` }
1901
+ }
1902
+ return targetFact
1903
+ }
1904
+
1905
+ export function correlateModuleExtensionFacts(options: CorrelateExtensionFactsOptions): Record<string, ModuleExtensionSurfaceFacts> {
1906
+ const result: Record<string, ModuleExtensionSurfaceFacts> = {}
1907
+ for (const moduleId of Object.keys(options.surfacesByModule).sort((left, right) => left.localeCompare(right))) {
1908
+ const surface = options.surfacesByModule[moduleId]
1909
+ const unresolved: ModuleExtensionUnresolvedFact[] = [...surface.unresolved]
1910
+ const contributions = surface.contributions.map((contribution) => {
1911
+ const targets = contribution.targets.map((entry) => correlateExtensionTarget(entry, {
1912
+ ...options,
1913
+ contributingModuleId: moduleId,
1914
+ }))
1915
+ for (const entry of targets) {
1916
+ if (entry.resolution !== 'unresolved') continue
1917
+ unresolved.push({
1918
+ key: `${contribution.id}:${entry.id}`,
1919
+ source: contribution.source,
1920
+ reason: 'unresolved-first-party-target',
1921
+ })
1922
+ }
1923
+ return { ...contribution, targets }
1924
+ })
1925
+ result[moduleId] = {
1926
+ hosts: sortHosts([...surface.hosts]),
1927
+ contributions: sortContributions(contributions),
1928
+ unresolved: unresolved.sort((left, right) => left.key.localeCompare(right.key)),
1929
+ activations: sortActivations([...(surface.activations ?? [])]),
1930
+ }
1931
+ }
1932
+ return result
1933
+ }
1934
+
1935
+ type IncomingTargetOwner = {
1936
+ moduleId: string
1937
+ source: ModuleFactSourceRef
1938
+ /** Interceptor bridges the owning api route wires (api-route owners only). */
1939
+ bridges?: ApiRouteInterceptorBridge[]
1940
+ }
1941
+
1942
+ export interface CorrelateIncomingExtensionsOptions {
1943
+ surfacesByModule: Readonly<Record<string, ModuleExtensionSurfaceFacts>>
1944
+ /** Concrete runtime-id owner maps derived from the fully-selected module set. */
1945
+ apiRouteOwners?: ReadonlyMap<string, IncomingTargetOwner>
1946
+ commandOwners?: ReadonlyMap<string, IncomingTargetOwner>
1947
+ }
1948
+
1949
+ function normalizeRouteId(routeId: string): string {
1950
+ return routeId.replace(/^\//, '')
1951
+ }
1952
+
1953
+ /**
1954
+ * Owner marker for a contribution bound to a framework host (dashboard, menus,
1955
+ * notifications, …). Framework hosts have no module surface of their own, so the
1956
+ * activation is recorded on the contributor and never produces an incoming row.
1957
+ */
1958
+ const FRAMEWORK_HOST_OWNER = 'framework'
1959
+
1960
+ /**
1961
+ * Framework host families each host-reference adapter may bind, so a dashboard
1962
+ * contribution resolves through `dashboard-host-consumer` rather than being claimed
1963
+ * by the generic widget-injection adapter that runs first.
1964
+ */
1965
+ const FRAMEWORK_HOST_FAMILIES_BY_ACTIVATION: Partial<Record<ModuleExtensionActivationKind, ExtensionHostFamily[]>> = {
1966
+ 'dashboard-host-consumer': ['dashboard'],
1967
+ 'widget-injection-consumer': ['menu', 'generic', 'crud-form', 'data-table', 'notification', 'integration'],
1968
+ 'component-extension-consumer': ['component-handle'],
1969
+ }
1970
+
1971
+ /**
1972
+ * Resolves a target id against the framework host catalog — exact ids first, then
1973
+ * the patterned entries (`dashboard:*`, `crud-form:*`, `integrations.detail:{id}`),
1974
+ * so a concrete `dashboard:<widget>` contribution reaches `framework.dashboard`.
1975
+ */
1976
+ function findFrameworkHost(
1977
+ targetId: string,
1978
+ activationKind: ModuleExtensionActivationKind,
1979
+ ): ModuleExtensionHostFact | null {
1980
+ const families = FRAMEWORK_HOST_FAMILIES_BY_ACTIVATION[activationKind]
1981
+ if (!families) return null
1982
+ const candidates = FRAMEWORK_HOSTS.filter((host) => families.includes(host.family))
1983
+ const exact = candidates.find((host) => host.id === targetId)
1984
+ if (exact) return exact
1985
+ return candidates.find((host) => host.resolution === 'pattern' && patternMatches(host.id, targetId)) ?? null
1986
+ }
1987
+
1988
+ /**
1989
+ * A call site that guards only some operations does not activate a contribution
1990
+ * declaring none of them. Either side leaving operations undeclared means "any".
1991
+ */
1992
+ function operationsIntersect(
1993
+ activationOperations: readonly string[] | undefined,
1994
+ contributionOperations: readonly string[] | undefined,
1995
+ ): boolean {
1996
+ if (!activationOperations || activationOperations.length === 0) return true
1997
+ if (!contributionOperations || contributionOperations.length === 0) return true
1998
+ return contributionOperations.some((operation) => activationOperations.includes(operation))
1999
+ }
2000
+
2001
+ function contributionSourceRef(contribution: ModuleExtensionContributionFact): ModuleFactSourceRef {
2002
+ return {
2003
+ sourcePath: contribution.source.path,
2004
+ ...(contribution.source.symbol ? { exportName: contribution.source.symbol } : {}),
2005
+ }
2006
+ }
2007
+
2008
+ function targetRefKindFor(
2009
+ contribution: ModuleExtensionContributionFact,
2010
+ targetId: string,
2011
+ ): ModuleExtensionTargetRef['kind'] {
2012
+ if (targetId === '*' || targetId.includes('*') || targetId.includes('{')) return 'wildcard'
2013
+ switch (contribution.kind) {
2014
+ case 'response-enricher':
2015
+ case 'mutation-guard':
2016
+ case 'entity-extension':
2017
+ return 'entity'
2018
+ case 'api-interceptor':
2019
+ return 'api-route'
2020
+ case 'command-interceptor':
2021
+ return 'command'
2022
+ case 'component-override':
2023
+ return 'component'
2024
+ case 'subscriber':
2025
+ return 'event'
2026
+ case 'browser-reaction':
2027
+ return contribution.details.transports.includes('notification-effect') ? 'notification' : 'event'
2028
+ case 'widget':
2029
+ case 'data-table':
2030
+ case 'crud-form':
2031
+ return 'widget-spot'
2032
+ case 'specialized-registry':
2033
+ case 'module-override':
2034
+ return 'module'
2035
+ default:
2036
+ return 'module'
2037
+ }
2038
+ }
2039
+
2040
+ /**
2041
+ * Builds the bidirectional incoming/resolution index after every module fact is
2042
+ * extracted and target-correlated. For each contribution target it records one
2043
+ * contributor-owned `ModuleContributionResolution` and, when a concrete target
2044
+ * owner exists, one target-owned `ModuleIncomingExtensionRef` per matching
2045
+ * activation. It never duplicates contribution behavior metadata — incoming rows
2046
+ * point back to the source-owned contribution by `contributorModuleId + contributionId`.
2047
+ */
2048
+ export function correlateIncomingExtensions(
2049
+ options: CorrelateIncomingExtensionsOptions,
2050
+ ): Record<string, ModuleExtensionSurfaceFacts> {
2051
+ const moduleIds = Object.keys(options.surfacesByModule).sort((left, right) => left.localeCompare(right))
2052
+ const apiRouteOwners = options.apiRouteOwners ?? new Map<string, IncomingTargetOwner>()
2053
+ const commandOwners = options.commandOwners ?? new Map<string, IncomingTargetOwner>()
2054
+
2055
+ // Index call-site activations by (kind, normalized host id).
2056
+ const activationIndex = new Map<string, Array<{ moduleId: string; activation: ModuleExtensionActivation }>>()
2057
+ const activationKey = (kind: ModuleExtensionActivationKind, hostKind: ModuleExtensionTargetRef['kind'], id: string): string => {
2058
+ const normId = hostKind === 'api-route' ? normalizeRouteId(id) : id
2059
+ return JSON.stringify([kind, hostKind, normId])
2060
+ }
2061
+ // Index bound hosts by their runtime id for host-reference resolution.
2062
+ const boundHostIndex = new Map<string, { moduleId: string; host: ModuleExtensionHostFact }>()
2063
+
2064
+ for (const moduleId of moduleIds) {
2065
+ const surface = options.surfacesByModule[moduleId]
2066
+ for (const activation of surface.activations ?? []) {
2067
+ const key = activationKey(activation.kind, activation.host.kind, activation.host.id)
2068
+ const bucket = activationIndex.get(key)
2069
+ if (bucket) bucket.push({ moduleId, activation })
2070
+ else activationIndex.set(key, [{ moduleId, activation }])
2071
+ }
2072
+ for (const host of surface.hosts) {
2073
+ if (!host.bound) continue
2074
+ if (!boundHostIndex.has(host.id)) boundHostIndex.set(host.id, { moduleId, host })
2075
+ }
2076
+ }
2077
+
2078
+ const incomingByModule: Record<string, ModuleIncomingExtensionRef[]> = {}
2079
+ const resolutionsByModule: Record<string, ModuleContributionResolution[]> = {}
2080
+ const syntheticActivations: Record<string, ModuleExtensionActivation[]> = {}
2081
+ for (const moduleId of moduleIds) {
2082
+ incomingByModule[moduleId] = []
2083
+ resolutionsByModule[moduleId] = []
2084
+ syntheticActivations[moduleId] = []
2085
+ }
2086
+ const registerSyntheticActivation = (ownerModule: string, activation: ModuleExtensionActivation): void => {
2087
+ if (!syntheticActivations[ownerModule]) syntheticActivations[ownerModule] = []
2088
+ if (!syntheticActivations[ownerModule].some((entry) => entry.id === activation.id)) {
2089
+ syntheticActivations[ownerModule].push(activation)
2090
+ }
2091
+ }
2092
+
2093
+ const findCallSiteActivations = (
2094
+ contributionKind: ModuleExtensionContributionKind,
2095
+ activationKinds: ModuleExtensionActivationKind[],
2096
+ targetRef: ModuleExtensionTargetRef,
2097
+ contributionOperations: readonly string[] | undefined,
2098
+ ): Array<{ moduleId: string; activation: ModuleExtensionActivation }> => {
2099
+ const matches: Array<{ moduleId: string; activation: ModuleExtensionActivation }> = []
2100
+ for (const kind of activationKinds) {
2101
+ if (ACTIVATION_ADAPTERS[kind].mode !== 'call-site-object') continue
2102
+ const bucket = activationIndex.get(activationKey(kind, targetRef.kind, targetRef.id)) ?? []
2103
+ for (const entry of bucket) {
2104
+ if (!entry.activation.contributionKinds.includes(contributionKind)) continue
2105
+ if (!operationsIntersect(entry.activation.operations, contributionOperations)) continue
2106
+ matches.push(entry)
2107
+ }
2108
+ }
2109
+ return matches.sort((left, right) => left.activation.id.localeCompare(right.activation.id))
2110
+ }
2111
+
2112
+ for (const contributorModuleId of moduleIds) {
2113
+ const surface = options.surfacesByModule[contributorModuleId]
2114
+ for (const contribution of surface.contributions) {
2115
+ const classification = CONTRIBUTION_ACTIVATION_CLASSIFICATION[contribution.kind]
2116
+ for (const resolvedTarget of contribution.targets) {
2117
+ const targetKind = targetRefKindFor(contribution, resolvedTarget.id)
2118
+ const baseTarget: ModuleExtensionTargetRef = { kind: targetKind, id: resolvedTarget.id }
2119
+
2120
+ let resolution: ModuleExtensionResolution
2121
+ let ownerModule: string | null = null
2122
+ let boundActivationIds: string[] = []
2123
+
2124
+ if (targetKind === 'wildcard' || resolvedTarget.resolution === 'pattern') {
2125
+ resolution = 'wildcard'
2126
+ } else if (resolvedTarget.resolution === 'optional-external') {
2127
+ resolution = 'optional-target-missing'
2128
+ } else if (resolvedTarget.resolution === 'unresolved') {
2129
+ resolution = 'unresolved'
2130
+ } else {
2131
+ const activationKinds = classification === 'capability-only' ? [] : classification
2132
+ const callSiteMatches = findCallSiteActivations(
2133
+ contribution.kind,
2134
+ activationKinds,
2135
+ baseTarget,
2136
+ contribution.operations,
2137
+ )
2138
+ if (callSiteMatches.length > 0) {
2139
+ resolution = 'bound'
2140
+ ownerModule = callSiteMatches[0].moduleId
2141
+ boundActivationIds = callSiteMatches.map((entry) => entry.activation.id)
2142
+ } else {
2143
+ const referenceBinding = resolveReferenceBinding({
2144
+ contribution,
2145
+ activationKinds,
2146
+ targetRef: baseTarget,
2147
+ boundHostIndex,
2148
+ apiRouteOwners,
2149
+ commandOwners,
2150
+ })
2151
+ if (referenceBinding) {
2152
+ resolution = 'bound'
2153
+ // A framework host owns no module surface: record the activation on the
2154
+ // contributor and leave `ownerModule` unset so no incoming row is emitted.
2155
+ const isFrameworkHost = referenceBinding.ownerModule === FRAMEWORK_HOST_OWNER
2156
+ ownerModule = isFrameworkHost ? null : referenceBinding.ownerModule
2157
+ const activationOwner = isFrameworkHost ? contributorModuleId : referenceBinding.ownerModule
2158
+ for (const activation of referenceBinding.activations) {
2159
+ registerSyntheticActivation(activationOwner, activation)
2160
+ }
2161
+ boundActivationIds = referenceBinding.activations.map((activation) => activation.id)
2162
+ } else {
2163
+ const hostOwner = boundHostIndex.get(resolvedTarget.id)
2164
+ if (hostOwner) {
2165
+ resolution = 'capability-only'
2166
+ ownerModule = hostOwner.moduleId
2167
+ } else {
2168
+ resolution = 'capability-only'
2169
+ ownerModule = null
2170
+ }
2171
+ }
2172
+ }
2173
+ }
2174
+
2175
+ const resolutionTarget: ModuleExtensionTargetRef = ownerModule
2176
+ ? { ...baseTarget, moduleId: ownerModule }
2177
+ : baseTarget
2178
+ resolutionsByModule[contributorModuleId].push({
2179
+ contributionId: contribution.id,
2180
+ target: resolutionTarget,
2181
+ resolution,
2182
+ activationIds: [...boundActivationIds].sort((left, right) => left.localeCompare(right)),
2183
+ })
2184
+
2185
+ // Incoming rows document contributions installed by OTHER modules onto a
2186
+ // target module's surface (cross-module discovery). Same-module targets are
2187
+ // already fully covered by the contributor-owned resolution row above, so
2188
+ // emitting a self-incoming row would only duplicate that fact and bloat the
2189
+ // generated context.
2190
+ if (ownerModule && ownerModule !== contributorModuleId && (resolution === 'bound' || resolution === 'capability-only')) {
2191
+ const source = contributionSourceRef(contribution)
2192
+ const incomingTarget: ModuleExtensionTargetRef = { ...baseTarget, moduleId: ownerModule }
2193
+ if (resolution === 'bound' && boundActivationIds.length > 0) {
2194
+ for (const activationRefId of boundActivationIds) {
2195
+ incomingByModule[ownerModule].push({
2196
+ contributionId: contribution.id,
2197
+ contributionKind: contribution.kind,
2198
+ contributorModuleId,
2199
+ target: incomingTarget,
2200
+ activationId: activationRefId,
2201
+ resolution: 'bound',
2202
+ source,
2203
+ })
2204
+ }
2205
+ } else {
2206
+ incomingByModule[ownerModule].push({
2207
+ contributionId: contribution.id,
2208
+ contributionKind: contribution.kind,
2209
+ contributorModuleId,
2210
+ target: incomingTarget,
2211
+ resolution,
2212
+ source,
2213
+ })
2214
+ }
2215
+ }
2216
+ }
2217
+ }
2218
+ }
2219
+
2220
+ const result: Record<string, ModuleExtensionSurfaceFacts> = {}
2221
+ for (const moduleId of moduleIds) {
2222
+ const surface = options.surfacesByModule[moduleId]
2223
+ result[moduleId] = {
2224
+ ...surface,
2225
+ activations: sortActivations([...(surface.activations ?? []), ...syntheticActivations[moduleId]]),
2226
+ incoming: dedupeIncoming(incomingByModule[moduleId]),
2227
+ contributionResolutions: sortResolutions(resolutionsByModule[moduleId]),
2228
+ }
2229
+ }
2230
+ return result
2231
+ }
2232
+
2233
+ /**
2234
+ * Intersects an api-interceptor contribution's declared methods and phases with the
2235
+ * bridges its target route actually wires. A route with no bridge, a phase the route
2236
+ * never runs, or a method the route does not handle yields no binding, so the
2237
+ * contribution stays `capability-only` instead of being reported as active.
2238
+ */
2239
+ function matchApiInterceptorBridges(
2240
+ contribution: ModuleExtensionContributionFact,
2241
+ owner: IncomingTargetOwner,
2242
+ ): ApiRouteInterceptorBridge[] {
2243
+ if (contribution.kind !== 'api-interceptor') return []
2244
+ const bridges = owner.bridges ?? []
2245
+ const declaredMethods = contribution.details.methods ?? []
2246
+ const declaredPhases = contribution.phases ?? []
2247
+ const matched: ApiRouteInterceptorBridge[] = []
2248
+ for (const bridge of bridges) {
2249
+ if (declaredMethods.length > 0 && !declaredMethods.includes(bridge.method)) continue
2250
+ const phases = declaredPhases.length > 0
2251
+ ? bridge.phases.filter((phase) => declaredPhases.includes(phase))
2252
+ : bridge.phases
2253
+ if (phases.length === 0) continue
2254
+ matched.push({ method: bridge.method, phases })
2255
+ }
2256
+ return matched
2257
+ }
2258
+
2259
+ function resolveReferenceBinding(input: {
2260
+ contribution: ModuleExtensionContributionFact
2261
+ activationKinds: ModuleExtensionActivationKind[]
2262
+ targetRef: ModuleExtensionTargetRef
2263
+ boundHostIndex: ReadonlyMap<string, { moduleId: string; host: ModuleExtensionHostFact }>
2264
+ apiRouteOwners: ReadonlyMap<string, IncomingTargetOwner>
2265
+ commandOwners: ReadonlyMap<string, IncomingTargetOwner>
2266
+ }): { ownerModule: string; activations: ModuleExtensionActivation[] } | null {
2267
+ for (const kind of input.activationKinds) {
2268
+ const adapter = ACTIVATION_ADAPTERS[kind]
2269
+ if (adapter.mode === 'call-site-object') continue
2270
+ if (!adapter.contributionKinds.includes(input.contribution.kind)) continue
2271
+
2272
+ if (adapter.mode === 'host-reference' && kind === 'api-interceptor-bridge') {
2273
+ const routeId = normalizeRouteId(input.targetRef.id)
2274
+ const owner = input.apiRouteOwners.get(routeId)
2275
+ if (!owner) continue
2276
+ const matchedBridges = matchApiInterceptorBridges(input.contribution, owner)
2277
+ if (matchedBridges.length === 0) continue
2278
+ return {
2279
+ ownerModule: owner.moduleId,
2280
+ activations: matchedBridges.map((bridge) => {
2281
+ const host: ModuleExtensionTargetRef = {
2282
+ kind: 'api-route',
2283
+ id: routeId,
2284
+ moduleId: owner.moduleId,
2285
+ method: bridge.method,
2286
+ }
2287
+ return {
2288
+ id: activationId(host, kind),
2289
+ kind,
2290
+ host,
2291
+ contributionKinds: ['api-interceptor'],
2292
+ phases: bridge.phases,
2293
+ source: owner.source,
2294
+ bridge: { factSection: 'apiRoutes', factKey: `/${routeId}` },
2295
+ }
2296
+ }),
2297
+ }
2298
+ }
2299
+
2300
+ if (adapter.mode === 'owner-reference' && kind === 'command-interceptor-bridge') {
2301
+ const owner = input.commandOwners.get(input.targetRef.id)
2302
+ if (!owner) continue
2303
+ const host: ModuleExtensionTargetRef = { kind: 'command', id: input.targetRef.id, moduleId: owner.moduleId }
2304
+ return {
2305
+ ownerModule: owner.moduleId,
2306
+ activations: [{
2307
+ id: activationId(host, kind),
2308
+ kind,
2309
+ host,
2310
+ contributionKinds: ['command-interceptor'],
2311
+ source: owner.source,
2312
+ bridge: { factSection: 'ownedContracts.command', factKey: input.targetRef.id },
2313
+ }],
2314
+ }
2315
+ }
2316
+
2317
+ if (adapter.mode === 'host-reference' && (kind === 'widget-injection-consumer' || kind === 'component-extension-consumer' || kind === 'dashboard-host-consumer')) {
2318
+ const frameworkHostFact = input.boundHostIndex.has(input.targetRef.id)
2319
+ ? null
2320
+ : findFrameworkHost(input.targetRef.id, kind)
2321
+ const hostEntry = input.boundHostIndex.get(input.targetRef.id)
2322
+ ?? (frameworkHostFact ? { moduleId: FRAMEWORK_HOST_OWNER, host: frameworkHostFact } : undefined)
2323
+ if (!hostEntry) continue
2324
+ const hostSource = hostReferenceSource(hostEntry.host)
2325
+ if (!hostSource) continue
2326
+ const host: ModuleExtensionTargetRef = { ...input.targetRef, moduleId: hostEntry.moduleId }
2327
+ return {
2328
+ ownerModule: hostEntry.moduleId,
2329
+ activations: [{
2330
+ id: activationId(host, kind),
2331
+ kind,
2332
+ host,
2333
+ contributionKinds: adapter.contributionKinds,
2334
+ source: hostSource,
2335
+ bridge: { factSection: 'hosts', factKey: hostEntry.host.key },
2336
+ }],
2337
+ }
2338
+ }
2339
+ }
2340
+ return null
2341
+ }
2342
+
2343
+ function hostReferenceSource(host: ModuleExtensionHostFact): ModuleFactSourceRef | null {
2344
+ if (host.source.kind === 'declaration' || host.source.kind === 'framework') {
2345
+ return { sourcePath: host.source.path, exportName: host.source.symbol }
2346
+ }
2347
+ return null
2348
+ }
2349
+
2350
+ function incomingIdentity(entry: ModuleIncomingExtensionRef): string {
2351
+ const target = JSON.stringify([entry.target.kind, entry.target.id])
2352
+ return JSON.stringify([entry.contributorModuleId, entry.contributionId, target, entry.activationId ?? entry.resolution])
2353
+ }
2354
+
2355
+ function dedupeIncoming(entries: ModuleIncomingExtensionRef[]): ModuleIncomingExtensionRef[] {
2356
+ const seen = new Set<string>()
2357
+ const unique: ModuleIncomingExtensionRef[] = []
2358
+ for (const entry of entries) {
2359
+ const identity = incomingIdentity(entry)
2360
+ if (seen.has(identity)) continue
2361
+ seen.add(identity)
2362
+ unique.push(entry)
2363
+ }
2364
+ return unique.sort((left, right) => incomingIdentity(left).localeCompare(incomingIdentity(right)))
2365
+ }
2366
+
2367
+ function resolutionIdentity(entry: ModuleContributionResolution): string {
2368
+ return JSON.stringify([entry.contributionId, entry.target.kind, entry.target.id, entry.resolution])
2369
+ }
2370
+
2371
+ function sortResolutions(entries: ModuleContributionResolution[]): ModuleContributionResolution[] {
2372
+ const seen = new Set<string>()
2373
+ const unique: ModuleContributionResolution[] = []
2374
+ for (const entry of entries) {
2375
+ const identity = resolutionIdentity(entry)
2376
+ if (seen.has(identity)) continue
2377
+ seen.add(identity)
2378
+ unique.push(entry)
2379
+ }
2380
+ return unique.sort((left, right) => resolutionIdentity(left).localeCompare(resolutionIdentity(right)))
2381
+ }
2382
+
2383
+ export function assertNoUnresolvedExtensionTargets(
2384
+ surfacesByModule: Readonly<Record<string, ModuleExtensionSurfaceFacts>>,
2385
+ ): void {
2386
+ const unresolved = Object.entries(surfacesByModule).flatMap(([moduleId, surface]) =>
2387
+ surface.unresolved
2388
+ .filter((entry) => entry.reason === 'unresolved-first-party-target')
2389
+ .map((entry) => `${moduleId}:${entry.key}`),
2390
+ ).sort((left, right) => left.localeCompare(right))
2391
+ if (unresolved.length > 0) {
2392
+ throw new Error(`[module-facts] unresolved first-party extension targets: ${unresolved.join(', ')}`)
2393
+ }
2394
+ }
2395
+
2396
+ /**
2397
+ * Supported merge/replace/disable modes for each dotted unified-override host,
2398
+ * derived from {@link FRAMEWORK_OVERRIDE_HOSTS} (the framework catalog authority
2399
+ * — spec 2026-08-02-module-facts-exact-override-targets). Keyed by the dotted
2400
+ * host id (e.g. `routes.api`, `ai.extensions`, `nav.groupOrder`) so the module
2401
+ * override-target adapters share one mode source of truth with the catalog.
2402
+ */
2403
+ export function getFrameworkOverrideModes(): Record<string, 'disable-replace' | 'replace' | 'additive'> {
2404
+ const modes: Record<string, 'disable-replace' | 'replace' | 'additive'> = {}
2405
+ for (const host of FRAMEWORK_OVERRIDE_HOSTS) {
2406
+ const dotted = host.key.replace(/^framework\.module-override\./, '')
2407
+ const mode = host.operations?.[0]
2408
+ if (mode === 'disable-replace' || mode === 'replace' || mode === 'additive') modes[dotted] = mode
2409
+ }
2410
+ return modes
2411
+ }
2412
+
2413
+ export function getFrameworkExtensionHosts(): ModuleExtensionHostFact[] {
2414
+ return sortHosts(allFrameworkHosts().map((host) => ({
2415
+ ...host,
2416
+ capabilities: [...host.capabilities],
2417
+ ...(host.operations ? { operations: [...host.operations] } : {}),
2418
+ })))
2419
+ }
2420
+
2421
+ export function renderFrameworkExtensionPointsMarkdown(): string {
2422
+ const rows = getFrameworkExtensionHosts().map((host) =>
2423
+ `| ${host.id} | ${host.family} | ${[...host.capabilities, ...(host.operations ?? [])].join(', ')} | ${host.stability.toUpperCase()} |`,
2424
+ )
2425
+ return [
2426
+ '# Framework extension points (generated, do not edit)',
2427
+ '',
2428
+ '| ID / pattern | Family | Supports | Stability |',
2429
+ '|---|---|---|---|',
2430
+ ...rows,
2431
+ '',
2432
+ ].join('\n')
2433
+ }