@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
@@ -1,7 +1,35 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import fs from 'node:fs'
2
3
  import path from 'node:path'
3
4
  import ts from 'typescript-js'
5
+ import type {
6
+ ModuleExtensionContributionFact,
7
+ ModuleExtensionHostFact,
8
+ ModuleExtensionSurfaceFacts,
9
+ ModuleExtensionTargetRef,
10
+ } from '@open-mercato/shared/modules/widgets/extension-points'
4
11
  import { toSnake } from '../utils'
12
+ import { extractCommandIdsFromSource } from './module-registry'
13
+ import { MODULE_CODE_EXTENSIONS } from './scanner'
14
+ import type { ApiRouteInterceptorBridge } from './module-extension-facts'
15
+ import {
16
+ assertNoUnresolvedExtensionTargets,
17
+ correlateIncomingExtensions,
18
+ correlateModuleExtensionFacts,
19
+ extractActivationTargetOwners,
20
+ extractKnownApiRouteIds,
21
+ extractKnownCommandIds,
22
+ extractModuleExtensionFacts,
23
+ renderFrameworkExtensionPointsMarkdown,
24
+ withModuleExtensionFactExtractionCache,
25
+ } from './module-extension-facts'
26
+ import {
27
+ collectModuleOverrideTargets,
28
+ type ModuleOverrideTarget,
29
+ type ModuleOverrideTargetDiagnostic,
30
+ } from './module-override-targets'
31
+ import { buildFactSourceLookup, type FactSourceLookup } from './module-fact-sources'
32
+ import { appendLocalReferenceModuleSource } from './module-facts-discovery'
5
33
 
6
34
  export interface ModuleEntityFact {
7
35
  id: string
@@ -21,6 +49,49 @@ export interface ModuleApiRouteFact {
21
49
  path: string
22
50
  methods: string[]
23
51
  auth: Record<string, ApiRouteAuthRule>
52
+ sourcePath: string | null
53
+ }
54
+
55
+ /**
56
+ * Statically declared page metadata that affects extensibility or access, using the
57
+ * runtime `PageMetadata` contract keys and the same alias precedence as
58
+ * `resolvePageRouteMetadata` (`pageGroup ?? group`, `pageGroupKey ?? groupKey`,
59
+ * `pageOrder ?? order`). Executable guards, icons, titles and translation results are
60
+ * never serialized.
61
+ */
62
+ export interface ModulePageMetadata {
63
+ requireAuth?: boolean
64
+ requireCustomerAuth?: boolean
65
+ requireFeatures?: string[]
66
+ requireCustomerFeatures?: string[]
67
+ navHidden?: boolean
68
+ navGroup?: string
69
+ navGroupKey?: string
70
+ navOrder?: number
71
+ pageContext?: 'main' | 'admin' | 'settings' | 'profile'
72
+ }
73
+
74
+ export interface ModulePageFact {
75
+ path: string
76
+ sourcePath: string
77
+ /** Companion metadata file (`page.meta.*` / `meta.*`) when runtime reads metadata from one. */
78
+ metadataSourcePath?: string
79
+ metadata?: ModulePageMetadata
80
+ }
81
+
82
+ export interface ModuleCliCommandFact {
83
+ command: string
84
+ sourcePath: string
85
+ }
86
+
87
+ export interface ModuleAiToolFact {
88
+ name: string
89
+ sourcePath: string
90
+ }
91
+
92
+ export interface ModuleAiAgentFact {
93
+ id: string
94
+ sourcePath: string
24
95
  }
25
96
 
26
97
  export interface ModuleEventFact {
@@ -28,6 +99,8 @@ export interface ModuleEventFact {
28
99
  label?: string
29
100
  category: string | null
30
101
  entity: string | null
102
+ clientBroadcast?: boolean
103
+ portalBroadcast?: boolean
31
104
  }
32
105
 
33
106
  export interface ModuleHostTokens {
@@ -35,11 +108,127 @@ export interface ModuleHostTokens {
35
108
  tableIds: string[]
36
109
  }
37
110
 
111
+ export type ModuleFactSourceRef = {
112
+ sourcePath: string
113
+ exportName?: string
114
+ line?: number
115
+ }
116
+
117
+ export type ModuleFactRef = {
118
+ factSection: string
119
+ factKey: string
120
+ }
121
+
122
+ export type ModuleFactSourceKind =
123
+ | 'module-metadata'
124
+ | 'entity'
125
+ | 'event'
126
+ | 'acl-feature'
127
+ | 'api-route'
128
+ | 'di-registration'
129
+ | 'search'
130
+ | 'vector'
131
+ | 'notification'
132
+ | 'cli-command'
133
+ | 'backend-page'
134
+ | 'frontend-page'
135
+ | 'ai-tool'
136
+ | 'ai-agent'
137
+ | 'ai-extension'
138
+ | 'command'
139
+ | 'subscriber'
140
+ | 'worker'
141
+ | 'page-middleware'
142
+ | 'setup'
143
+ | 'encryption'
144
+ | 'custom-entity'
145
+ | 'integration'
146
+ | 'generator-plugin'
147
+ | 'extension-host'
148
+ | 'extension-contribution'
149
+
150
+ export type ModuleOwnedContractKind =
151
+ | 'module-metadata'
152
+ | 'command'
153
+ | 'worker'
154
+ | 'page-middleware'
155
+ | 'setup'
156
+ | 'encryption'
157
+ | 'di-registration'
158
+ | 'custom-entity'
159
+ | 'ai-extension'
160
+ | 'generator-plugin'
161
+
162
+ export type ModuleFactSafeScalar = string | number | boolean | null
163
+
164
+ export type ModuleOwnedContractFact = {
165
+ kind: ModuleOwnedContractKind
166
+ id: string
167
+ source: ModuleFactSourceRef
168
+ metadata?: Record<string, ModuleFactSafeScalar | ModuleFactSafeScalar[]>
169
+ }
170
+
171
+ export type ModuleOwnedContracts = Partial<
172
+ Record<ModuleOwnedContractKind, ModuleOwnedContractFact[]>
173
+ >
174
+
175
+ /**
176
+ * Provenance pointer into a fact section that already serializes the declaration site.
177
+ * `factKey` is omitted when it equals the owning entry's `id`, which is the common case.
178
+ */
179
+ export type ModuleFactIndexRef = {
180
+ factSection: string
181
+ factKey?: string
182
+ }
183
+
184
+ /**
185
+ * One provenance entry per proven `(kind, id)` in the module. Exactly one of `source`
186
+ * (the declaration site, for facts that have no other home in the JSON) or `factRef`
187
+ * (a deterministic pointer into a fact section that already carries the source inline)
188
+ * is present, so a consumer resolves any fact's origin through this single index.
189
+ */
190
+ export type ModuleFactSourceEntry = {
191
+ kind: ModuleFactSourceKind
192
+ id: string
193
+ source?: ModuleFactSourceRef
194
+ factRef?: ModuleFactIndexRef
195
+ }
196
+
197
+ export type ModuleFactDiagnostic = {
198
+ code: 'duplicate-source' | 'unresolved-static-contract'
199
+ kind: ModuleFactSourceKind
200
+ id: string
201
+ source?: ModuleFactSourceRef
202
+ }
203
+
204
+ export type ModuleDiRegistrationFact = {
205
+ token: string
206
+ registrationKind: 'function' | 'class' | 'value' | 'alias'
207
+ providerSymbol?: string
208
+ lifetime?: 'singleton' | 'scoped' | 'transient'
209
+ injectionMode?: 'classic' | 'proxy'
210
+ source: ModuleFactSourceRef
211
+ }
212
+
213
+ /**
214
+ * How the extracted module reached the batch. `package` is the implicit default for
215
+ * every installed `@open-mercato/*` module and never appears in emitted output, so the
216
+ * package sidecar contract stays byte-identical. `local-reference` marks an app-local
217
+ * module projected into the explicit reference bundle; it is an independent discriminator
218
+ * from `sourcePackage`, which keeps its legacy "null means core package" meaning.
219
+ */
220
+ export type ModuleFactProjectionSourceKind = 'package' | 'local-reference'
221
+
38
222
  export interface ModuleFacts {
39
223
  module: string
40
224
  title: string | null
41
225
  description: string | null
42
226
  coreVersion: string | null
227
+ sourcePackage: string | null
228
+ sourceVersion: string | null
229
+ sourceRoot: string
230
+ /** Present only for `local-reference` projections; package modules omit it entirely. */
231
+ sourceKind?: ModuleFactProjectionSourceKind
43
232
  entities: ModuleEntityFact[]
44
233
  events: ModuleEventFact[]
45
234
  aclFeatures: string[]
@@ -49,6 +238,17 @@ export interface ModuleFacts {
49
238
  hostTokens: ModuleHostTokens
50
239
  notifications: string[]
51
240
  cli: string[]
241
+ backendPages: ModulePageFact[]
242
+ frontendPages: ModulePageFact[]
243
+ cliCommands: ModuleCliCommandFact[]
244
+ aiTools: ModuleAiToolFact[]
245
+ aiAgents: ModuleAiAgentFact[]
246
+ extensionSurfaces?: ModuleExtensionSurfaceFacts
247
+ factSources?: ModuleFactSourceEntry[]
248
+ ownedContracts?: ModuleOwnedContracts
249
+ factDiagnostics?: ModuleFactDiagnostic[]
250
+ overrideTargets?: ModuleOverrideTarget[]
251
+ overrideTargetDiagnostics?: ModuleOverrideTargetDiagnostic[]
52
252
  warnings: string[]
53
253
  }
54
254
 
@@ -64,8 +264,17 @@ export interface ExtractModuleFactsOptions {
64
264
  /** Explicit module source directory. When set it overrides `coreSrcRoot + moduleId`. */
65
265
  moduleRoot?: string
66
266
  coreVersion?: string | null
267
+ sourcePackage?: string | null
268
+ sourceVersion?: string | null
67
269
  registryPath?: string | null
68
270
  registrySource?: string | null
271
+ /**
272
+ * App-root-relative POSIX root written into every emitted source path. Overrides the
273
+ * default `node_modules/<package>/src/modules/<moduleId>` layout so an app-local module
274
+ * emits `src/modules/<moduleId>` instead of a package path it does not live in.
275
+ */
276
+ portableSourceRoot?: string
277
+ sourceKind?: ModuleFactProjectionSourceKind
69
278
  }
70
279
 
71
280
  /** A discovered module and the source directory its facts are extracted from. */
@@ -73,6 +282,9 @@ export interface ModuleFactSource {
73
282
  moduleId: string
74
283
  moduleRoot: string
75
284
  from?: string
285
+ packageVersion?: string | null
286
+ portableSourceRoot?: string
287
+ sourceKind?: ModuleFactProjectionSourceKind
76
288
  }
77
289
 
78
290
  function readSourceFile(filePath: string): ts.SourceFile | null {
@@ -153,6 +365,49 @@ function classHasUpdatedAtColumn(node: ts.ClassDeclaration): boolean {
153
365
  return false
154
366
  }
155
367
 
368
+ function readStaticAccessPath(candidate: ts.Expression): string[] | null {
369
+ const access = unwrapExpression(candidate)
370
+ if (ts.isIdentifier(access)) return [access.text]
371
+ if (ts.isPropertyAccessExpression(access)) {
372
+ const parent = readStaticAccessPath(access.expression)
373
+ return parent ? [...parent, access.name.text] : null
374
+ }
375
+ if (ts.isElementAccessExpression(access) && access.argumentExpression) {
376
+ const parent = readStaticAccessPath(access.expression)
377
+ const key = unwrapExpression(access.argumentExpression)
378
+ return parent && ts.isStringLiteralLike(key) ? [...parent, key.text] : null
379
+ }
380
+ return null
381
+ }
382
+
383
+ /**
384
+ * Resolves an entity-id expression to its literal `module:entity` value.
385
+ *
386
+ * Modules reference entity ids three ways and all three are statically knowable:
387
+ * a string literal, `E.<module>.<entity>` from the generated entity-id registry
388
+ * (`entity-ids.ts` emits `E[module][entity] = '<module>:<entity>'`), and — when
389
+ * `initializers` is supplied — a same-file `const ENTITY_ID = 'module:entity'`.
390
+ * Anything else stays undefined: a guessed id is worse than a missing one.
391
+ */
392
+ function resolveEntityIdReference(
393
+ expression: ts.Expression,
394
+ initializers?: Map<string, ts.Expression>,
395
+ visitedIdentifiers: Set<string> = new Set(),
396
+ ): string | undefined {
397
+ const current = unwrapExpression(expression)
398
+ if (ts.isStringLiteralLike(current)) return current.text
399
+ const accessPath = readStaticAccessPath(current)
400
+ if (accessPath?.length === 3 && accessPath[0] === 'E') {
401
+ return `${accessPath[1]}:${accessPath[2]}`
402
+ }
403
+ if (initializers && ts.isIdentifier(current) && !visitedIdentifiers.has(current.text)) {
404
+ visitedIdentifiers.add(current.text)
405
+ const declaration = initializers.get(current.text)
406
+ if (declaration) return resolveEntityIdReference(declaration, initializers, visitedIdentifiers)
407
+ }
408
+ return undefined
409
+ }
410
+
156
411
  function collectCustomFieldEntityIds(ceFilePath: string | null): Set<string> {
157
412
  const result = new Set<string>()
158
413
  if (!ceFilePath) return result
@@ -161,8 +416,11 @@ function collectCustomFieldEntityIds(ceFilePath: string | null): Set<string> {
161
416
 
162
417
  const visit = (node: ts.Node): void => {
163
418
  if (ts.isObjectLiteralExpression(node)) {
164
- const id = readStringPropertyInitializer(node, 'id')
165
- if (id && id.includes(':')) result.add(id)
419
+ const initializer = getObjectPropertyInitializer(node, 'id')
420
+ if (initializer) {
421
+ const id = resolveEntityIdReference(initializer)
422
+ if (id && id.includes(':')) result.add(id)
423
+ }
166
424
  }
167
425
  node.forEachChild(visit)
168
426
  }
@@ -222,9 +480,38 @@ function getPropertyName(property: ts.ObjectLiteralElementLike): string | undefi
222
480
  if (!name) return undefined
223
481
  if (ts.isIdentifier(name)) return name.text
224
482
  if (ts.isStringLiteralLike(name)) return name.text
483
+ // A computed key whose expression is a string literal or a same-file `const NAME = 'token'`
484
+ // is still a statically known token. Without this, `{ [SERVICE_TOKEN]: asFunction(...) }` —
485
+ // the idiomatic, type-safe way to register a DI token from an exported const — yielded
486
+ // undefined and the registration vanished from module facts with NO diagnostic, because the
487
+ // unresolved-token path is only reached for a NAMED token whose Awilix kind is unrecognised.
488
+ if (ts.isComputedPropertyName(name)) return resolveComputedPropertyKey(name.expression)
225
489
  return undefined
226
490
  }
227
491
 
492
+ /** Resolves a computed key to its literal token when the value is statically knowable. */
493
+ function resolveComputedPropertyKey(expression: ts.Expression): string | undefined {
494
+ if (ts.isStringLiteralLike(expression)) return expression.text
495
+ // `X as const` / `X satisfies Y` wrappers.
496
+ if (ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression)) {
497
+ return resolveComputedPropertyKey(expression.expression)
498
+ }
499
+ if (ts.isParenthesizedExpression(expression)) return resolveComputedPropertyKey(expression.expression)
500
+ if (!ts.isIdentifier(expression)) return undefined
501
+ const sourceFile = expression.getSourceFile()
502
+ if (!sourceFile) return undefined
503
+ let resolved: string | undefined
504
+ const visit = (node: ts.Node): void => {
505
+ if (resolved) return
506
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === expression.text) {
507
+ if (node.initializer) resolved = resolveComputedPropertyKey(node.initializer)
508
+ }
509
+ node.forEachChild(visit)
510
+ }
511
+ sourceFile.forEachChild(visit)
512
+ return resolved
513
+ }
514
+
228
515
  function getObjectPropertyInitializer(
229
516
  objectLiteral: ts.ObjectLiteralExpression,
230
517
  propertyName: string,
@@ -351,7 +638,11 @@ function extractEvents(eventsFilePath: string | null): ModuleEventFact[] {
351
638
  category: category ?? null,
352
639
  entity: entity ?? null,
353
640
  }
641
+ const clientBroadcast = readBooleanPropertyInitializer(element, 'clientBroadcast')
642
+ const portalBroadcast = readBooleanPropertyInitializer(element, 'portalBroadcast')
354
643
  if (label !== undefined) fact.label = label
644
+ if (clientBroadcast !== undefined) fact.clientBroadcast = clientBroadcast
645
+ if (portalBroadcast !== undefined) fact.portalBroadcast = portalBroadcast
355
646
  facts.push(fact)
356
647
  }
357
648
 
@@ -459,13 +750,14 @@ function parseApiRouteEntry(entryLiteral: ts.ObjectLiteralExpression): ModuleApi
459
750
  }
460
751
  }
461
752
 
462
- return { path: routePath, methods, auth }
753
+ return { path: routePath, methods, auth, sourcePath: null }
463
754
  }
464
755
 
465
756
  function extractApiRoutes(
466
757
  moduleId: string,
467
758
  registrySource: string | null,
468
759
  registryDescription: string,
760
+ routeSourcePaths: ReadonlyMap<string, string>,
469
761
  warnings: string[],
470
762
  ): ModuleApiRouteFact[] {
471
763
  if (registrySource == null) {
@@ -493,7 +785,7 @@ function extractApiRoutes(
493
785
  const route = parseApiRouteEntry(element)
494
786
  if (route && !seenPaths.has(route.path)) {
495
787
  seenPaths.add(route.path)
496
- routes.push(route)
788
+ routes.push({ ...route, sourcePath: routeSourcePaths.get(route.path) ?? null })
497
789
  }
498
790
  }
499
791
  }
@@ -581,15 +873,29 @@ function extractSearchEntities(searchFilePath: string | null, warnings: string[]
581
873
  return []
582
874
  }
583
875
 
876
+ // `entityId` is rarely a bare string literal: modules reference the entity through
877
+ // `E.<module>.<entity>` or a same-file `const ENTITY_ID`. Reading only string literals
878
+ // dropped every such entry with NO diagnostic, so a module shipping `search.ts` scored
879
+ // zero search facts while looking healthy. Unresolvable entries now warn instead.
880
+ const initializers = buildVariableInitializerMap(sourceFile)
584
881
  const entityIds: string[] = []
585
882
  const seen = new Set<string>()
586
883
  for (const element of entitiesArray.elements) {
587
884
  if (!ts.isObjectLiteralExpression(element)) continue
588
- const entityId = readStringPropertyInitializer(element, 'entityId')
589
- if (entityId && !seen.has(entityId)) {
590
- seen.add(entityId)
591
- entityIds.push(entityId)
885
+ const initializer = getObjectPropertyInitializer(element, 'entityId')
886
+ const location = `${searchFilePath}:${nodeLine(sourceFile, element)}`
887
+ if (!initializer) {
888
+ warnings.push(`[module-facts] searchConfig entity declares no entityId: ${location}`)
889
+ continue
890
+ }
891
+ const entityId = resolveEntityIdReference(initializer, initializers)
892
+ if (!entityId) {
893
+ warnings.push(`[module-facts] searchConfig entityId is not statically resolvable: ${location}`)
894
+ continue
592
895
  }
896
+ if (seen.has(entityId)) continue
897
+ seen.add(entityId)
898
+ entityIds.push(entityId)
593
899
  }
594
900
  return entityIds
595
901
  }
@@ -666,36 +972,261 @@ function extractCli(cliFilePath: string | null, warnings: string[]): string[] {
666
972
  function listSourceFilesRecursive(directory: string): string[] {
667
973
  const files: string[] = []
668
974
  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
669
- if (entry.name === '__tests__' || entry.name === 'node_modules') continue
975
+ if (entry.name === '__tests__' || entry.name === '__mocks__' || entry.name === 'node_modules') continue
670
976
  const fullPath = path.join(directory, entry.name)
671
977
  if (entry.isDirectory()) {
672
978
  files.push(...listSourceFilesRecursive(fullPath))
673
- } else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
979
+ } else if (
980
+ /\.tsx?$/.test(entry.name)
981
+ && !entry.name.endsWith('.d.ts')
982
+ && !/\.(?:test|spec)\.tsx?$/.test(entry.name)
983
+ ) {
674
984
  files.push(fullPath)
675
985
  }
676
986
  }
677
987
  return files
678
988
  }
679
989
 
680
- function extractTableIds(backendDir: string): string[] {
681
- if (!fs.existsSync(backendDir)) return []
990
+ function toPortableSourceRoot(moduleId: string, sourcePackage: string | null): string {
991
+ return path.posix.join('node_modules', sourcePackage ?? '@open-mercato/core', 'src', 'modules', moduleId)
992
+ }
993
+
994
+ /**
995
+ * An explicit portable root must stay app-root-relative and POSIX-shaped: every emitted
996
+ * fact path and markdown link is resolved against the generated app root, so an absolute
997
+ * path, a Windows separator, or a traversal segment would emit an unclickable or
998
+ * machine-specific reference into a distributed artifact.
999
+ */
1000
+ export function assertPortableSourceRoot(moduleId: string, portableSourceRoot: string): string {
1001
+ const invalid = portableSourceRoot.length === 0
1002
+ || path.posix.isAbsolute(portableSourceRoot)
1003
+ || /^[A-Za-z]:/.test(portableSourceRoot)
1004
+ || portableSourceRoot.includes('\\')
1005
+ || portableSourceRoot.split('/').includes('..')
1006
+ if (invalid) {
1007
+ throw new Error(
1008
+ `[module-facts] portableSourceRoot for module "${moduleId}" must be a relative POSIX app path, received "${portableSourceRoot}"`,
1009
+ )
1010
+ }
1011
+ return portableSourceRoot
1012
+ }
1013
+
1014
+ function toPortableSourcePath(moduleRoot: string, sourceRoot: string, filePath: string): string {
1015
+ const relativePath = path.relative(moduleRoot, filePath).split(path.sep).join('/')
1016
+ return path.posix.join(sourceRoot, relativePath)
1017
+ }
1018
+
1019
+ function sourceHasDefaultExport(sourceFile: ts.SourceFile): boolean {
1020
+ return sourceFile.statements.some((statement) => {
1021
+ if (ts.isExportAssignment(statement) && !statement.isExportEquals) return true
1022
+ if (!ts.isFunctionDeclaration(statement) && !ts.isClassDeclaration(statement)) return false
1023
+ return statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) ?? false
1024
+ })
1025
+ }
1026
+
1027
+ function readRouteMetadataPath(sourceFile: ts.SourceFile): string | null {
1028
+ const metadata = findObjectLiteralDeclaration(sourceFile, 'metadata')
1029
+ return metadata ? readStringPropertyInitializer(metadata, 'path') ?? null : null
1030
+ }
1031
+
1032
+ function extractApiRouteSourcePaths(
1033
+ moduleId: string,
1034
+ moduleRoot: string,
1035
+ sourceRoot: string,
1036
+ ): Map<string, string> {
1037
+ const apiRoot = path.join(moduleRoot, 'api')
1038
+ const sources = new Map<string, string>()
1039
+ if (!fs.existsSync(apiRoot)) return sources
1040
+ const methodDirectories = new Set(['get', 'post', 'put', 'patch', 'delete'])
1041
+
1042
+ for (const filePath of listSourceFilesRecursive(apiRoot)) {
1043
+ const relativePath = path.relative(apiRoot, filePath).split(path.sep).join('/')
1044
+ const segments = relativePath.split('/')
1045
+ const fileName = segments.pop() as string
1046
+ const fileStem = fileName.replace(/\.tsx?$/, '')
1047
+ let routeSegments: string[]
1048
+ if (fileStem === 'route') {
1049
+ routeSegments = segments
1050
+ } else if (segments[0] && methodDirectories.has(segments[0].toLowerCase())) {
1051
+ routeSegments = [...segments.slice(1), fileStem]
1052
+ } else {
1053
+ routeSegments = [...segments, fileStem]
1054
+ }
1055
+ const defaultPath = `/${[moduleId, ...routeSegments].filter(Boolean).join('/')}`
1056
+ const sourceFile = readSourceFile(filePath)
1057
+ const routePath = sourceFile ? readRouteMetadataPath(sourceFile) ?? defaultPath : defaultPath
1058
+ sources.set(routePath, toPortableSourcePath(moduleRoot, sourceRoot, filePath))
1059
+ }
1060
+ return sources
1061
+ }
1062
+
1063
+ function extractModulePages(
1064
+ moduleId: string,
1065
+ moduleRoot: string,
1066
+ sourceRoot: string,
1067
+ surface: 'backend' | 'frontend',
1068
+ ): ModulePageFact[] {
1069
+ const pageRoot = path.join(moduleRoot, surface)
1070
+ if (!fs.existsSync(pageRoot)) return []
1071
+ const pages: ModulePageFact[] = []
1072
+ const seen = new Set<string>()
1073
+
1074
+ for (const filePath of listSourceFilesRecursive(pageRoot)) {
1075
+ if (!filePath.endsWith('.tsx')) continue
1076
+ const relativePath = path.relative(pageRoot, filePath).split(path.sep).join('/')
1077
+ const segments = relativePath.split('/')
1078
+ const fileName = segments.pop() as string
1079
+ const fileStem = fileName.replace(/\.tsx$/, '')
1080
+ const isModernPage = fileStem === 'page'
1081
+ if (!isModernPage && (fileStem === 'meta' || fileStem.endsWith('.meta') || /^[A-Z]/.test(fileStem))) continue
1082
+ const sourceFile = readSourceFile(filePath)
1083
+ if (!sourceFile || !sourceHasDefaultExport(sourceFile)) continue
1084
+ const routeSegments = isModernPage ? segments : [...segments, fileStem]
1085
+ const routePath = surface === 'frontend'
1086
+ ? `/${routeSegments.filter(Boolean).join('/')}`
1087
+ : isModernPage
1088
+ ? `/backend/${routeSegments.join('/') || moduleId}`
1089
+ : `/backend/${routeSegments[0] === moduleId
1090
+ ? routeSegments.filter(Boolean).join('/')
1091
+ : [moduleId, ...routeSegments].filter(Boolean).join('/')}`
1092
+ if (seen.has(routePath)) continue
1093
+ seen.add(routePath)
1094
+ const metadataFilePath = resolvePageMetadataFile(path.dirname(filePath))
1095
+ const metadataSourceFile = metadataFilePath ? readSourceFile(metadataFilePath) : sourceFile
1096
+ const metadata = metadataSourceFile ? readPageMetadata(metadataSourceFile) : undefined
1097
+ pages.push({
1098
+ path: routePath,
1099
+ sourcePath: toPortableSourcePath(moduleRoot, sourceRoot, filePath),
1100
+ ...(metadataFilePath
1101
+ ? { metadataSourcePath: toPortableSourcePath(moduleRoot, sourceRoot, metadataFilePath) }
1102
+ : {}),
1103
+ ...(metadata ? { metadata } : {}),
1104
+ })
1105
+ }
1106
+ return pages.sort((left, right) => left.path.localeCompare(right.path))
1107
+ }
1108
+
1109
+ function findAncestorVariableDeclaration(node: ts.Node): ts.VariableDeclaration | null {
1110
+ let current: ts.Node | undefined = node.parent
1111
+ while (current) {
1112
+ if (ts.isVariableDeclaration(current)) return current
1113
+ current = current.parent
1114
+ }
1115
+ return null
1116
+ }
1117
+
1118
+ function findAncestorCallExpression(node: ts.Node): ts.CallExpression | null {
1119
+ let current: ts.Node | undefined = node.parent
1120
+ while (current) {
1121
+ if (ts.isCallExpression(current)) return current
1122
+ if (ts.isVariableDeclaration(current) || ts.isStatement(current)) return null
1123
+ current = current.parent
1124
+ }
1125
+ return null
1126
+ }
1127
+
1128
+ function extractNamedAiFacts(
1129
+ filePaths: readonly string[],
1130
+ moduleRoot: string,
1131
+ sourceRoot: string,
1132
+ propertyName: 'name' | 'id',
1133
+ ): Array<{ value: string; sourcePath: string }> {
1134
+ const facts: Array<{ value: string; sourcePath: string }> = []
1135
+ const seen = new Set<string>()
1136
+ for (const filePath of filePaths) {
1137
+ const sourceFile = readSourceFile(filePath)
1138
+ if (!sourceFile) continue
1139
+ const initializers = buildVariableInitializerMap(sourceFile)
1140
+ const readDefinitionValue = (objectLiteral: ts.ObjectLiteralExpression): string | undefined => {
1141
+ const initializer = getObjectPropertyInitializer(objectLiteral, propertyName)
1142
+ if (!initializer) return undefined
1143
+ const resolved = unwrapExpression(initializer)
1144
+ if (ts.isStringLiteralLike(resolved)) return resolved.text
1145
+ if (ts.isIdentifier(resolved)) {
1146
+ const declarationInitializer = initializers.get(resolved.text)
1147
+ if (declarationInitializer) {
1148
+ const declarationValue = unwrapExpression(declarationInitializer)
1149
+ if (ts.isStringLiteralLike(declarationValue)) return declarationValue.text
1150
+ }
1151
+ }
1152
+ return undefined
1153
+ }
1154
+ const visit = (node: ts.Node): void => {
1155
+ if (ts.isObjectLiteralExpression(node)) {
1156
+ const value = readDefinitionValue(node)
1157
+ if (value && !seen.has(value)) {
1158
+ const declaration = findAncestorVariableDeclaration(node)
1159
+ const declarationName = declaration && ts.isIdentifier(declaration.name) ? declaration.name.text : ''
1160
+ const declarationType = declaration?.type?.getText(sourceFile) ?? ''
1161
+ const call = findAncestorCallExpression(node)
1162
+ const callee = call?.expression.getText(sourceFile) ?? ''
1163
+ const isDefinition = propertyName === 'name'
1164
+ ? /AiTool/.test(declarationType) || /tool$/i.test(declarationName) || /AiTool/.test(callee)
1165
+ : /AiAgentDefinition/.test(declarationType) || /agent$/i.test(declarationName) || /defineAiAgent/.test(callee)
1166
+ if (isDefinition) {
1167
+ seen.add(value)
1168
+ facts.push({
1169
+ value,
1170
+ sourcePath: toPortableSourcePath(moduleRoot, sourceRoot, filePath),
1171
+ })
1172
+ }
1173
+ }
1174
+ }
1175
+ node.forEachChild(visit)
1176
+ }
1177
+ sourceFile.forEachChild(visit)
1178
+ }
1179
+ return facts.sort((left, right) => left.value.localeCompare(right.value))
1180
+ }
1181
+
1182
+ function extractAiTools(moduleRoot: string, sourceRoot: string): ModuleAiToolFact[] {
1183
+ const files = new Set<string>()
1184
+ const rootFile = resolveConventionFile(moduleRoot, 'ai-tools')
1185
+ if (rootFile) files.add(rootFile)
1186
+ const toolsDirectory = path.join(moduleRoot, 'ai-tools')
1187
+ if (fs.existsSync(toolsDirectory)) {
1188
+ for (const filePath of listSourceFilesRecursive(toolsDirectory)) files.add(filePath)
1189
+ }
1190
+ return extractNamedAiFacts([...files], moduleRoot, sourceRoot, 'name')
1191
+ .map((fact) => ({ name: fact.value, sourcePath: fact.sourcePath }))
1192
+ }
1193
+
1194
+ function extractAiAgents(moduleRoot: string, sourceRoot: string): ModuleAiAgentFact[] {
1195
+ const agentsFile = resolveConventionFile(moduleRoot, 'ai-agents')
1196
+ if (!agentsFile) return []
1197
+ return extractNamedAiFacts([agentsFile], moduleRoot, sourceRoot, 'id')
1198
+ .map((fact) => ({ id: fact.value, sourcePath: fact.sourcePath }))
1199
+ }
1200
+
1201
+ function extractTableIds(moduleRoot: string): string[] {
1202
+ if (!fs.existsSync(moduleRoot)) return []
682
1203
 
683
1204
  const tableIds: string[] = []
684
1205
  const seen = new Set<string>()
685
- for (const filePath of listSourceFilesRecursive(backendDir)) {
1206
+ const collectLiteralValues = (expression: ts.Expression): string[] => {
1207
+ const current = unwrapExpression(expression)
1208
+ if (ts.isStringLiteralLike(current)) return [current.text]
1209
+ if (ts.isConditionalExpression(current)) {
1210
+ return [
1211
+ ...collectLiteralValues(current.whenTrue),
1212
+ ...collectLiteralValues(current.whenFalse),
1213
+ ]
1214
+ }
1215
+ return []
1216
+ }
1217
+
1218
+ for (const filePath of listSourceFilesRecursive(moduleRoot)) {
686
1219
  const sourceFile = readSourceFile(filePath)
687
1220
  if (!sourceFile) continue
688
1221
  const visit = (node: ts.Node): void => {
689
1222
  if (ts.isPropertyAssignment(node)) {
690
1223
  const propertyName = getPropertyName(node)
691
- if (
692
- (propertyName === 'tableId' || propertyName === 'extensionTableId') &&
693
- ts.isStringLiteralLike(node.initializer)
694
- ) {
695
- const value = node.initializer.text
696
- if (value && !seen.has(value)) {
697
- seen.add(value)
698
- tableIds.push(value)
1224
+ if (propertyName === 'tableId' || propertyName === 'extensionTableId') {
1225
+ for (const value of collectLiteralValues(node.initializer)) {
1226
+ if (value && !seen.has(value)) {
1227
+ seen.add(value)
1228
+ tableIds.push(value)
1229
+ }
699
1230
  }
700
1231
  }
701
1232
  }
@@ -723,130 +1254,1227 @@ function extractModuleMeta(indexFilePath: string | null): { title: string | null
723
1254
  }
724
1255
  }
725
1256
 
726
- export function extractModuleFacts(options: ExtractModuleFactsOptions): ModuleFacts {
727
- const { moduleId, coreVersion = null } = options
728
- const moduleRoot = options.moduleRoot
729
- ?? (options.coreSrcRoot ? path.join(options.coreSrcRoot, moduleId) : null)
730
- if (!moduleRoot) {
731
- throw new Error(`[internal] extractModuleFacts requires moduleRoot or coreSrcRoot for module "${moduleId}"`)
732
- }
1257
+ function nodeLine(sourceFile: ts.SourceFile, node: ts.Node): number {
1258
+ return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1
1259
+ }
733
1260
 
734
- const entitiesFilePath =
735
- resolveConventionFile(path.join(moduleRoot, 'data'), 'entities') ??
736
- resolveConventionFile(path.join(moduleRoot, 'db'), 'entities') ??
737
- resolveConventionFile(path.join(moduleRoot, 'data'), 'schema')
738
- const ceFilePath = resolveConventionFile(moduleRoot, 'ce')
739
- const eventsFilePath = resolveConventionFile(moduleRoot, 'events')
740
- const aclFilePath = resolveConventionFile(moduleRoot, 'acl')
741
- const diFilePath = resolveConventionFile(moduleRoot, 'di')
742
- const searchFilePath = resolveConventionFile(moduleRoot, 'search')
743
- const notificationsFilePath = resolveConventionFile(moduleRoot, 'notifications')
744
- const cliFilePath = resolveConventionFile(moduleRoot, 'cli')
745
- const indexFilePath = resolveConventionFile(moduleRoot, 'index')
746
- const backendDir = path.join(moduleRoot, 'backend')
1261
+ function readPropertyNameText(name: ts.PropertyName): string | undefined {
1262
+ if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) return name.text
1263
+ return undefined
1264
+ }
747
1265
 
748
- const warnings: string[] = []
749
- const { title, description } = extractModuleMeta(indexFilePath)
750
- const customFieldEntityIds = collectCustomFieldEntityIds(ceFilePath)
751
- const entities = extractEntities(moduleId, entitiesFilePath, customFieldEntityIds)
752
- const events = extractEvents(eventsFilePath)
753
- const aclFeatures = extractAclFeatures(aclFilePath)
1266
+ function readMethodBearingPropertyNames(objectLiteral: ts.ObjectLiteralExpression): string[] {
1267
+ const names: string[] = []
1268
+ for (const property of objectLiteral.properties) {
1269
+ if (ts.isMethodDeclaration(property)) {
1270
+ const name = readPropertyNameText(property.name)
1271
+ if (name) names.push(name)
1272
+ continue
1273
+ }
1274
+ if (ts.isPropertyAssignment(property)) {
1275
+ const name = readPropertyNameText(property.name)
1276
+ if (!name) continue
1277
+ const initializer = unwrapExpression(property.initializer)
1278
+ if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) names.push(name)
1279
+ }
1280
+ }
1281
+ return names.sort((left, right) => left.localeCompare(right))
1282
+ }
754
1283
 
755
- const { source: registrySource, description: registryDescription } = resolveRegistrySource(options)
756
- const apiRoutes = extractApiRoutes(moduleId, registrySource, registryDescription, warnings)
757
- const diTokens = extractDiTokens(diFilePath)
758
- const searchEntities = extractSearchEntities(searchFilePath, warnings)
759
- const notifications = extractNotifications(notificationsFilePath, warnings)
760
- const cli = extractCli(cliFilePath, warnings)
761
- const hostTokens: ModuleHostTokens = {
762
- entityIds: extractHostEntityIds(entities),
763
- tableIds: extractTableIds(backendDir),
1284
+ function readObjectKeyNames(objectLiteral: ts.ObjectLiteralExpression): string[] {
1285
+ const names: string[] = []
1286
+ for (const property of objectLiteral.properties) {
1287
+ const name = getPropertyName(property)
1288
+ if (name) names.push(name)
764
1289
  }
1290
+ return names.sort((left, right) => left.localeCompare(right))
1291
+ }
765
1292
 
766
- return {
767
- module: moduleId,
768
- title,
769
- description,
770
- coreVersion,
771
- entities,
772
- events,
773
- aclFeatures,
774
- apiRoutes,
775
- diTokens,
776
- searchEntities,
777
- hostTokens,
778
- notifications,
779
- cli,
780
- warnings,
1293
+ function mapDiRegistrationKind(base: string | null): ModuleDiRegistrationFact['registrationKind'] | null {
1294
+ switch (base) {
1295
+ case 'asFunction':
1296
+ return 'function'
1297
+ case 'asClass':
1298
+ return 'class'
1299
+ case 'asValue':
1300
+ return 'value'
1301
+ case 'aliasTo':
1302
+ return 'alias'
1303
+ default:
1304
+ return null
781
1305
  }
782
1306
  }
783
1307
 
784
- export interface ModuleFactsJsonEvent {
785
- id: string
786
- category: string | null
787
- entity: string | null
1308
+ function readDiChainMetadata(initializer: ts.Expression): {
1309
+ lifetime?: ModuleDiRegistrationFact['lifetime']
1310
+ injectionMode?: ModuleDiRegistrationFact['injectionMode']
1311
+ } {
1312
+ const result: {
1313
+ lifetime?: ModuleDiRegistrationFact['lifetime']
1314
+ injectionMode?: ModuleDiRegistrationFact['injectionMode']
1315
+ } = {}
1316
+ let current: ts.Expression = unwrapExpression(initializer)
1317
+ while (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) {
1318
+ const method = current.expression.name.text
1319
+ if ((method === 'singleton' || method === 'scoped' || method === 'transient') && !result.lifetime) {
1320
+ result.lifetime = method
1321
+ } else if (method === 'proxy' && !result.injectionMode) {
1322
+ result.injectionMode = 'proxy'
1323
+ } else if (method === 'classic' && !result.injectionMode) {
1324
+ result.injectionMode = 'classic'
1325
+ }
1326
+ current = unwrapExpression(current.expression.expression)
1327
+ }
1328
+ return result
788
1329
  }
789
1330
 
790
- export interface ModuleFactsJsonEntry {
791
- title: string | null
792
- description: string | null
793
- coreVersion: string | null
794
- entities: ModuleEntityFact[]
795
- events: ModuleFactsJsonEvent[]
796
- aclFeatures: string[]
797
- apiRoutes: ModuleApiRouteFact[]
798
- diTokens: string[]
799
- searchEntities: string[]
800
- hostTokens: ModuleHostTokens
801
- notifications: string[]
802
- cli: string[]
1331
+ function readDiProviderSymbol(
1332
+ initializer: ts.Expression,
1333
+ kind: ModuleDiRegistrationFact['registrationKind'],
1334
+ ): string | undefined {
1335
+ let current: ts.Expression = unwrapExpression(initializer)
1336
+ while (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) {
1337
+ current = unwrapExpression(current.expression.expression)
1338
+ }
1339
+ if (!ts.isCallExpression(current)) return undefined
1340
+ const argument = current.arguments[0]
1341
+ if (!argument) return undefined
1342
+ if (kind === 'value') return undefined
1343
+ if (kind === 'alias' && ts.isStringLiteralLike(argument)) return argument.text
1344
+ if ((kind === 'function' || kind === 'class') && ts.isIdentifier(argument)) return argument.text
1345
+ return undefined
803
1346
  }
804
1347
 
805
- const EMPTY_SECTION_MARKER = '_none_'
1348
+ function extractDiRegistrationFacts(
1349
+ diFilePath: string | null,
1350
+ sourcePath: string,
1351
+ ): { facts: ModuleDiRegistrationFact[]; unresolvedTokens: string[] } {
1352
+ if (!diFilePath) return { facts: [], unresolvedTokens: [] }
1353
+ const sourceFile = readSourceFile(diFilePath)
1354
+ if (!sourceFile) return { facts: [], unresolvedTokens: [] }
806
1355
 
807
- function renderVersionStamp(coreVersion: string | null): string {
808
- const version = coreVersion && coreVersion.length > 0 ? coreVersion : '<unknown>'
809
- return `<!-- generated from @open-mercato/core ${version} — R1 staleness stamp -->`
1356
+ const facts: ModuleDiRegistrationFact[] = []
1357
+ const unresolvedTokens: string[] = []
1358
+ const visit = (node: ts.Node): void => {
1359
+ if (
1360
+ ts.isCallExpression(node) &&
1361
+ ts.isPropertyAccessExpression(node.expression) &&
1362
+ node.expression.name.text === 'register'
1363
+ ) {
1364
+ const argument = node.arguments[0]
1365
+ if (argument && ts.isObjectLiteralExpression(argument)) {
1366
+ for (const property of argument.properties) {
1367
+ if (!ts.isPropertyAssignment(property)) continue
1368
+ const token = getPropertyName(property)
1369
+ if (!token) continue
1370
+ const kind = mapDiRegistrationKind(detectAwilixRegistrationKind(property.initializer))
1371
+ if (!kind) {
1372
+ if (!unresolvedTokens.includes(token)) unresolvedTokens.push(token)
1373
+ continue
1374
+ }
1375
+ const chain = readDiChainMetadata(property.initializer)
1376
+ const providerSymbol = readDiProviderSymbol(property.initializer, kind)
1377
+ facts.push({
1378
+ token,
1379
+ registrationKind: kind,
1380
+ ...(providerSymbol ? { providerSymbol } : {}),
1381
+ ...(chain.lifetime ? { lifetime: chain.lifetime } : {}),
1382
+ ...(chain.injectionMode ? { injectionMode: chain.injectionMode } : {}),
1383
+ source: { sourcePath, exportName: token, line: nodeLine(sourceFile, property) },
1384
+ })
1385
+ }
1386
+ }
1387
+ }
1388
+ node.forEachChild(visit)
1389
+ }
1390
+ sourceFile.forEachChild(visit)
1391
+ return { facts, unresolvedTokens }
810
1392
  }
811
1393
 
812
- function renderEntitiesSection(entities: ModuleEntityFact[]): string {
813
- if (entities.length === 0) return `## Entities\n\n${EMPTY_SECTION_MARKER}`
814
- const header = '| Entity ID | Class | Table | Editable | CustomFields |'
815
- const divider = '|---|---|---|---|---|'
816
- const rows = entities.map(
817
- (entity) =>
818
- `| ${entity.id} | ${entity.class} | ${entity.table} | ${entity.editable ? 'yes' : 'no'} | ${entity.customFields ? 'yes' : 'no'} |`,
819
- )
820
- return ['## Entities', '', header, divider, ...rows].join('\n')
1394
+ function deriveLegacyDiTokens(facts: readonly ModuleDiRegistrationFact[]): string[] {
1395
+ const tokens: string[] = []
1396
+ const seen = new Set<string>()
1397
+ for (const fact of facts) {
1398
+ if (fact.registrationKind !== 'function' && fact.registrationKind !== 'class') continue
1399
+ if (seen.has(fact.token)) continue
1400
+ seen.add(fact.token)
1401
+ tokens.push(fact.token)
1402
+ }
1403
+ return tokens
821
1404
  }
822
1405
 
823
- function renderEventsSection(events: ModuleEventFact[]): string {
824
- const heading = `## Events (${events.length})`
825
- if (events.length === 0) return `${heading}\n\n${EMPTY_SECTION_MARKER}`
826
- const header = '| ID | Category | Entity |'
827
- const divider = '|---|---|---|'
828
- const rows = events.map((event) => `| ${event.id} | ${event.category ?? '—'} | ${event.entity ?? '—'} |`)
829
- return [heading, '', header, divider, ...rows].join('\n')
1406
+ function dedupeOwnedContractFacts(facts: readonly ModuleOwnedContractFact[]): ModuleOwnedContractFact[] {
1407
+ const byId = new Map<string, ModuleOwnedContractFact>()
1408
+ for (const fact of facts) {
1409
+ const existing = byId.get(fact.id)
1410
+ if (!existing) {
1411
+ byId.set(fact.id, fact)
1412
+ continue
1413
+ }
1414
+ if (fact.source.sourcePath.localeCompare(existing.source.sourcePath) < 0) byId.set(fact.id, fact)
1415
+ }
1416
+ return [...byId.values()].sort((left, right) => left.id.localeCompare(right.id))
830
1417
  }
831
1418
 
832
- function renderInlineListSection(heading: string, values: string[]): string {
833
- if (values.length === 0) return `${heading}\n\n${EMPTY_SECTION_MARKER}`
834
- return `${heading}\n\n${values.join(' · ')}`
1419
+ const PAGE_CONTEXT_VALUES = new Set(['main', 'admin', 'settings', 'profile'])
1420
+
1421
+ function readAliasedStringProperty(
1422
+ metadata: ts.ObjectLiteralExpression,
1423
+ preferred: string,
1424
+ fallback: string,
1425
+ ): string | undefined {
1426
+ return readStringPropertyInitializer(metadata, preferred) ?? readStringPropertyInitializer(metadata, fallback)
835
1427
  }
836
1428
 
837
- function describeAuthRule(rule: ApiRouteAuthRule | undefined): string {
838
- if (!rule) return 'public'
839
- if (rule.requireFeatures && rule.requireFeatures.length > 0) return rule.requireFeatures.join(', ')
840
- if (rule.requireRoles && rule.requireRoles.length > 0) return rule.requireRoles.join(', ')
841
- if (rule.requireAuth) return 'auth'
842
- return 'public'
1429
+ function readNumericProperty(metadata: ts.ObjectLiteralExpression, propertyName: string): number | undefined {
1430
+ const initializer = getObjectPropertyInitializer(metadata, propertyName)
1431
+ if (!initializer || !ts.isNumericLiteral(initializer)) return undefined
1432
+ return Number(initializer.text)
843
1433
  }
844
1434
 
845
- function renderApiRouteAuthCell(route: ModuleApiRouteFact): string {
846
- if (route.methods.length === 0) return '—'
847
- const groups: Array<{ label: string; methods: string[] }> = []
848
- for (const method of route.methods) {
849
- const label = describeAuthRule(route.auth[method])
1435
+ function readPageMetadata(sourceFile: ts.SourceFile): ModulePageMetadata | undefined {
1436
+ const metadata = findObjectLiteralDeclaration(sourceFile, 'metadata')
1437
+ if (!metadata) return undefined
1438
+ const result: ModulePageMetadata = {}
1439
+ const requireAuth = readBooleanPropertyInitializer(metadata, 'requireAuth')
1440
+ if (requireAuth !== undefined) result.requireAuth = requireAuth
1441
+ const requireCustomerAuth = readBooleanPropertyInitializer(metadata, 'requireCustomerAuth')
1442
+ if (requireCustomerAuth !== undefined) result.requireCustomerAuth = requireCustomerAuth
1443
+ const requireFeatures = readStringArrayPropertyInitializer(metadata, 'requireFeatures')
1444
+ if (requireFeatures && requireFeatures.length > 0) result.requireFeatures = requireFeatures
1445
+ const requireCustomerFeatures = readStringArrayPropertyInitializer(metadata, 'requireCustomerFeatures')
1446
+ if (requireCustomerFeatures && requireCustomerFeatures.length > 0) {
1447
+ result.requireCustomerFeatures = requireCustomerFeatures
1448
+ }
1449
+ const navHidden = readBooleanPropertyInitializer(metadata, 'navHidden')
1450
+ if (navHidden !== undefined) result.navHidden = navHidden
1451
+ const navGroup = readAliasedStringProperty(metadata, 'pageGroup', 'group')
1452
+ if (navGroup) result.navGroup = navGroup
1453
+ const navGroupKey = readAliasedStringProperty(metadata, 'pageGroupKey', 'groupKey')
1454
+ if (navGroupKey) result.navGroupKey = navGroupKey
1455
+ const navOrder = readNumericProperty(metadata, 'pageOrder') ?? readNumericProperty(metadata, 'order')
1456
+ if (navOrder !== undefined) result.navOrder = navOrder
1457
+ const pageContext = readStringPropertyInitializer(metadata, 'pageContext')
1458
+ if (pageContext && PAGE_CONTEXT_VALUES.has(pageContext)) {
1459
+ result.pageContext = pageContext as ModulePageMetadata['pageContext']
1460
+ }
1461
+ return Object.keys(result).length > 0 ? result : undefined
1462
+ }
1463
+
1464
+ /**
1465
+ * Mirrors the runtime companion-file convention: a page's metadata comes from
1466
+ * `page.meta.*` (or `meta.*`) in the page directory when one exists, and only
1467
+ * otherwise from the page component itself.
1468
+ */
1469
+ function resolvePageMetadataFile(pageDirectory: string): string | null {
1470
+ for (const baseName of ['page.meta', 'meta']) {
1471
+ for (const extension of MODULE_CODE_EXTENSIONS) {
1472
+ const candidate = path.join(pageDirectory, `${baseName}${extension}`)
1473
+ if (fs.existsSync(candidate)) return candidate
1474
+ }
1475
+ }
1476
+ return null
1477
+ }
1478
+
1479
+ function extractModuleMetadataContract(
1480
+ indexFilePath: string | null,
1481
+ moduleId: string,
1482
+ sourcePath: string | null,
1483
+ ): ModuleOwnedContractFact | null {
1484
+ if (!indexFilePath || !sourcePath) return null
1485
+ const sourceFile = readSourceFile(indexFilePath)
1486
+ if (!sourceFile) return null
1487
+ const metadata = findObjectLiteralDeclaration(sourceFile, 'metadata')
1488
+ if (!metadata) return null
1489
+ const facts: Record<string, ModuleFactSafeScalar | ModuleFactSafeScalar[]> = {}
1490
+ const name = readStringPropertyInitializer(metadata, 'name')
1491
+ if (name) facts.name = name
1492
+ const version = readStringPropertyInitializer(metadata, 'version')
1493
+ if (version) facts.version = version
1494
+ const requires = readStringArrayPropertyInitializer(metadata, 'requires')
1495
+ if (requires && requires.length > 0) facts.requires = requires
1496
+ const ejectable = readBooleanPropertyInitializer(metadata, 'ejectable')
1497
+ if (ejectable !== undefined) facts.ejectable = ejectable
1498
+ return {
1499
+ kind: 'module-metadata',
1500
+ id: moduleId,
1501
+ source: { sourcePath, exportName: 'metadata', line: nodeLine(sourceFile, metadata) },
1502
+ ...(Object.keys(facts).length > 0 ? { metadata: facts } : {}),
1503
+ }
1504
+ }
1505
+
1506
+ function extractCommandContractCandidates(
1507
+ moduleId: string,
1508
+ moduleRoot: string,
1509
+ sourceRoot: string,
1510
+ ): Array<{ id: string; sourcePath: string }> {
1511
+ const commandsRoot = path.join(moduleRoot, 'commands')
1512
+ if (!fs.existsSync(commandsRoot)) return []
1513
+ const candidates: Array<{ id: string; sourcePath: string }> = []
1514
+ for (const filePath of listSourceFilesRecursive(commandsRoot)) {
1515
+ if (path.basename(filePath).replace(/\.tsx?$/, '') === 'interceptors') continue
1516
+ const sourcePath = toPortableSourcePath(moduleRoot, sourceRoot, filePath)
1517
+ for (const id of extractCommandIdsFromSource(filePath)) candidates.push({ id, sourcePath })
1518
+ }
1519
+ return candidates
1520
+ }
1521
+
1522
+ /**
1523
+ * Runtime worker id: `metadata.id` when declared, otherwise the path-derived
1524
+ * `<moduleId>:workers:<...segments>:<fileStem>` that `discoverWorkers` builds.
1525
+ */
1526
+ function deriveWorkerId(moduleId: string, workersRoot: string, filePath: string): string {
1527
+ const relativePath = path.relative(workersRoot, filePath).split(path.sep).join('/')
1528
+ const segments = relativePath.split('/')
1529
+ const fileName = segments.pop() as string
1530
+ const fileStem = fileName.replace(/\.tsx?$/, '')
1531
+ return [moduleId, 'workers', ...segments, fileStem].filter(Boolean).join(':')
1532
+ }
1533
+
1534
+ /** Resolves a string property directly or through a local `const` in the same file. */
1535
+ function readResolvedStringProperty(
1536
+ objectLiteral: ts.ObjectLiteralExpression,
1537
+ propertyName: string,
1538
+ initializers: Map<string, ts.Expression>,
1539
+ ): string | undefined {
1540
+ const initializer = getObjectPropertyInitializer(objectLiteral, propertyName)
1541
+ if (!initializer) return undefined
1542
+ const resolved = unwrapExpression(initializer)
1543
+ if (ts.isStringLiteralLike(resolved)) return resolved.text
1544
+ if (ts.isIdentifier(resolved)) {
1545
+ const declaration = initializers.get(resolved.text)
1546
+ if (declaration) {
1547
+ const declarationValue = unwrapExpression(declaration)
1548
+ if (ts.isStringLiteralLike(declarationValue)) return declarationValue.text
1549
+ }
1550
+ }
1551
+ return undefined
1552
+ }
1553
+
1554
+ function extractWorkerContracts(
1555
+ moduleId: string,
1556
+ moduleRoot: string,
1557
+ sourceRoot: string,
1558
+ ): { facts: ModuleOwnedContractFact[]; unresolved: ModuleFactDiagnostic[] } {
1559
+ const workersRoot = path.join(moduleRoot, 'workers')
1560
+ if (!fs.existsSync(workersRoot)) return { facts: [], unresolved: [] }
1561
+ const facts: ModuleOwnedContractFact[] = []
1562
+ const unresolved: ModuleFactDiagnostic[] = []
1563
+ for (const filePath of listSourceFilesRecursive(workersRoot)) {
1564
+ const sourceFile = readSourceFile(filePath)
1565
+ if (!sourceFile) continue
1566
+ const metadata = findObjectLiteralDeclaration(sourceFile, 'metadata')
1567
+ if (!metadata) continue
1568
+ const sourcePath = toPortableSourcePath(moduleRoot, sourceRoot, filePath)
1569
+ const source = { sourcePath, exportName: 'metadata', line: nodeLine(sourceFile, metadata) }
1570
+ const initializers = buildVariableInitializerMap(sourceFile)
1571
+ const workerId = readResolvedStringProperty(metadata, 'id', initializers)
1572
+ ?? deriveWorkerId(moduleId, workersRoot, filePath)
1573
+
1574
+ // Runtime skips any worker without a queue, so a worker missing the required
1575
+ // property is not a contract; a present-but-dynamic queue is reported instead.
1576
+ const hasQueueProperty = getObjectPropertyInitializer(metadata, 'queue') !== undefined
1577
+ const queue = readResolvedStringProperty(metadata, 'queue', initializers)
1578
+ if (!hasQueueProperty) {
1579
+ unresolved.push({ code: 'unresolved-static-contract', kind: 'worker', id: workerId, source })
1580
+ continue
1581
+ }
1582
+
1583
+ const workerMetadata: Record<string, ModuleFactSafeScalar | ModuleFactSafeScalar[]> = {}
1584
+ if (queue) workerMetadata.queue = queue
1585
+ else unresolved.push({ code: 'unresolved-static-contract', kind: 'worker', id: workerId, source })
1586
+ const name = readResolvedStringProperty(metadata, 'name', initializers)
1587
+ if (name) workerMetadata.name = name
1588
+ const concurrencyInitializer = getObjectPropertyInitializer(metadata, 'concurrency')
1589
+ if (concurrencyInitializer && ts.isNumericLiteral(concurrencyInitializer)) {
1590
+ workerMetadata.concurrency = Number(concurrencyInitializer.text)
1591
+ }
1592
+ facts.push({
1593
+ kind: 'worker',
1594
+ id: workerId,
1595
+ source,
1596
+ ...(Object.keys(workerMetadata).length > 0 ? { metadata: workerMetadata } : {}),
1597
+ })
1598
+ }
1599
+ return { facts: dedupeOwnedContractFacts(facts), unresolved }
1600
+ }
1601
+
1602
+ function extractPageMiddlewareContracts(
1603
+ moduleRoot: string,
1604
+ sourceRoot: string,
1605
+ ): { facts: ModuleOwnedContractFact[]; unresolved: ModuleFactDiagnostic[] } {
1606
+ const facts: ModuleOwnedContractFact[] = []
1607
+ const unresolved: ModuleFactDiagnostic[] = []
1608
+ for (const surface of ['backend', 'frontend'] as const) {
1609
+ const filePath = resolveConventionFile(path.join(moduleRoot, surface), 'middleware')
1610
+ if (!filePath) continue
1611
+ const sourceFile = readSourceFile(filePath)
1612
+ if (!sourceFile) continue
1613
+ const sourcePath = toPortableSourcePath(moduleRoot, sourceRoot, filePath)
1614
+ const middlewareArray =
1615
+ findArrayLiteralDeclaration(sourceFile, 'middleware') ??
1616
+ (() => {
1617
+ const defaultExport = findDefaultExportExpression(sourceFile)
1618
+ return defaultExport ? unwrapArrayLiteral(defaultExport) : null
1619
+ })()
1620
+ if (!middlewareArray) continue
1621
+ let index = 0
1622
+ for (const element of middlewareArray.elements) {
1623
+ if (!ts.isObjectLiteralExpression(element)) continue
1624
+ const middlewareId = readStringPropertyInitializer(element, 'id')
1625
+ if (!middlewareId) {
1626
+ unresolved.push({
1627
+ code: 'unresolved-static-contract',
1628
+ kind: 'page-middleware',
1629
+ id: `${sourcePath}#${index}`,
1630
+ source: { sourcePath, line: nodeLine(sourceFile, element) },
1631
+ })
1632
+ index += 1
1633
+ continue
1634
+ }
1635
+ const metadata: Record<string, ModuleFactSafeScalar | ModuleFactSafeScalar[]> = { surface }
1636
+ const mode = readStringPropertyInitializer(element, 'mode')
1637
+ if (mode) metadata.mode = mode
1638
+ const priorityInitializer = getObjectPropertyInitializer(element, 'priority')
1639
+ if (priorityInitializer && ts.isNumericLiteral(priorityInitializer)) {
1640
+ metadata.priority = Number(priorityInitializer.text)
1641
+ }
1642
+ facts.push({
1643
+ kind: 'page-middleware',
1644
+ id: middlewareId,
1645
+ source: { sourcePath, exportName: 'middleware', line: nodeLine(sourceFile, element) },
1646
+ metadata,
1647
+ })
1648
+ index += 1
1649
+ }
1650
+ }
1651
+ return { facts: dedupeOwnedContractFacts(facts), unresolved }
1652
+ }
1653
+
1654
+ function extractSetupContract(
1655
+ moduleId: string,
1656
+ setupFilePath: string | null,
1657
+ sourcePath: string | null,
1658
+ ): ModuleOwnedContractFact | null {
1659
+ if (!setupFilePath || !sourcePath) return null
1660
+ const sourceFile = readSourceFile(setupFilePath)
1661
+ if (!sourceFile) return null
1662
+ const setupObject =
1663
+ findObjectLiteralDeclaration(sourceFile, 'setup') ??
1664
+ (() => {
1665
+ const defaultExport = findDefaultExportExpression(sourceFile)
1666
+ if (!defaultExport) return null
1667
+ const unwrapped = unwrapExpression(defaultExport)
1668
+ return ts.isObjectLiteralExpression(unwrapped) ? unwrapped : null
1669
+ })()
1670
+ if (!setupObject) return null
1671
+ const hooks = readMethodBearingPropertyNames(setupObject)
1672
+ const metadata: Record<string, ModuleFactSafeScalar | ModuleFactSafeScalar[]> = {}
1673
+ if (hooks.length > 0) metadata.hooks = hooks
1674
+ const roleFeatures = getObjectPropertyInitializer(setupObject, 'defaultRoleFeatures')
1675
+ if (roleFeatures && ts.isObjectLiteralExpression(roleFeatures)) {
1676
+ const roles = readObjectKeyNames(roleFeatures)
1677
+ if (roles.length > 0) metadata.roles = roles
1678
+ }
1679
+ const customerRoleFeatures = getObjectPropertyInitializer(setupObject, 'defaultCustomerRoleFeatures')
1680
+ if (customerRoleFeatures && ts.isObjectLiteralExpression(customerRoleFeatures)) {
1681
+ const customerRoles = readObjectKeyNames(customerRoleFeatures)
1682
+ if (customerRoles.length > 0) metadata.customerRoles = customerRoles
1683
+ }
1684
+ return {
1685
+ kind: 'setup',
1686
+ id: `${moduleId}:setup`,
1687
+ source: { sourcePath, exportName: 'setup', line: nodeLine(sourceFile, setupObject) },
1688
+ ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
1689
+ }
1690
+ }
1691
+
1692
+ function extractEncryptionContracts(
1693
+ encryptionFilePath: string | null,
1694
+ sourcePath: string | null,
1695
+ ): ModuleOwnedContractFact[] {
1696
+ if (!encryptionFilePath || !sourcePath) return []
1697
+ const sourceFile = readSourceFile(encryptionFilePath)
1698
+ if (!sourceFile) return []
1699
+ const mapsArray =
1700
+ findArrayLiteralDeclaration(sourceFile, 'defaultEncryptionMaps') ??
1701
+ findArrayLiteralDeclaration(sourceFile, 'encryptionMaps')
1702
+ if (!mapsArray) return []
1703
+ const facts: ModuleOwnedContractFact[] = []
1704
+ for (const element of mapsArray.elements) {
1705
+ if (!ts.isObjectLiteralExpression(element)) continue
1706
+ const entityId = readStringPropertyInitializer(element, 'entityId')
1707
+ if (!entityId) continue
1708
+ const fieldsInitializer = getObjectPropertyInitializer(element, 'fields')
1709
+ const fields: string[] = []
1710
+ if (fieldsInitializer) {
1711
+ const fieldsArray = unwrapArrayLiteral(fieldsInitializer)
1712
+ if (fieldsArray) {
1713
+ for (const fieldElement of fieldsArray.elements) {
1714
+ if (!ts.isObjectLiteralExpression(fieldElement)) continue
1715
+ const field = readStringPropertyInitializer(fieldElement, 'field')
1716
+ if (field) fields.push(field)
1717
+ }
1718
+ }
1719
+ }
1720
+ facts.push({
1721
+ kind: 'encryption',
1722
+ id: entityId,
1723
+ source: { sourcePath, exportName: 'defaultEncryptionMaps', line: nodeLine(sourceFile, element) },
1724
+ ...(fields.length > 0 ? { metadata: { fields: fields.sort((left, right) => left.localeCompare(right)) } } : {}),
1725
+ })
1726
+ }
1727
+ return dedupeOwnedContractFacts(facts)
1728
+ }
1729
+
1730
+ function extractCustomEntityContracts(
1731
+ ceFilePath: string | null,
1732
+ sourcePath: string | null,
1733
+ ): ModuleOwnedContractFact[] {
1734
+ if (!ceFilePath || !sourcePath) return []
1735
+ const entityIds = [...collectCustomFieldEntityIds(ceFilePath)].sort((left, right) => left.localeCompare(right))
1736
+ return entityIds.map((id) => ({
1737
+ kind: 'custom-entity' as const,
1738
+ id,
1739
+ source: { sourcePath, exportName: 'default' },
1740
+ }))
1741
+ }
1742
+
1743
+ /**
1744
+ * Keyed AI override exports. Agent *extensions* (`aiAgentExtensions`) patch an agent
1745
+ * additively and belong to the `ai.extensions` array; these two replace or disable an
1746
+ * entry by key and map to exact `ai.agents.<id>` / `ai.tools.<id>` override targets.
1747
+ */
1748
+ const AI_OVERRIDE_EXPORTS = [
1749
+ { exportName: 'aiAgentOverrides', target: 'agent' },
1750
+ { exportName: 'aiToolOverrides', target: 'tool' },
1751
+ ] as const
1752
+
1753
+ function extractAiExtensionContracts(
1754
+ moduleRoot: string,
1755
+ sourceRoot: string,
1756
+ ): ModuleOwnedContractFact[] {
1757
+ const facts: ModuleOwnedContractFact[] = []
1758
+ const sources: Array<{ basename: string; relativeDir: string }> = [
1759
+ { basename: 'ai-agents', relativeDir: '.' },
1760
+ { basename: 'ai-tools', relativeDir: '.' },
1761
+ { basename: 'ai-agents.extensions', relativeDir: '.' },
1762
+ { basename: 'ai-tools.extensions', relativeDir: '.' },
1763
+ ]
1764
+ for (const entry of sources) {
1765
+ const filePath = resolveConventionFile(path.join(moduleRoot, entry.relativeDir), entry.basename)
1766
+ if (!filePath) continue
1767
+ const sourceFile = readSourceFile(filePath)
1768
+ if (!sourceFile) continue
1769
+ const sourcePath = toPortableSourcePath(moduleRoot, sourceRoot, filePath)
1770
+ const extensionsArray = findArrayLiteralDeclaration(sourceFile, 'aiAgentExtensions')
1771
+ if (extensionsArray) {
1772
+ for (const element of extensionsArray.elements) {
1773
+ const objectLiteral = ts.isObjectLiteralExpression(element)
1774
+ ? element
1775
+ : ts.isCallExpression(element) && element.arguments[0] && ts.isObjectLiteralExpression(element.arguments[0])
1776
+ ? element.arguments[0]
1777
+ : null
1778
+ if (!objectLiteral) continue
1779
+ const targetAgentId = readStringPropertyInitializer(objectLiteral, 'targetAgentId')
1780
+ if (!targetAgentId) continue
1781
+ facts.push({
1782
+ kind: 'ai-extension',
1783
+ id: `agent-extension:${targetAgentId}`,
1784
+ source: { sourcePath, exportName: 'aiAgentExtensions', line: nodeLine(sourceFile, objectLiteral) },
1785
+ metadata: { target: 'agent', mode: 'extension', targetAgentId },
1786
+ })
1787
+ }
1788
+ }
1789
+ // `aiAgentOverrides` / `aiToolOverrides` are keyed replace-or-disable maps; only
1790
+ // their keys are contract facts. Values (agent/tool definitions, `null` disables)
1791
+ // are never read or serialized.
1792
+ for (const { exportName, target } of AI_OVERRIDE_EXPORTS) {
1793
+ const overridesObject = findObjectLiteralDeclaration(sourceFile, exportName)
1794
+ if (!overridesObject) continue
1795
+ for (const property of overridesObject.properties) {
1796
+ const overrideId = getPropertyName(property)
1797
+ if (!overrideId) continue
1798
+ facts.push({
1799
+ kind: 'ai-extension',
1800
+ id: `${target}-override:${overrideId}`,
1801
+ source: { sourcePath, exportName, line: nodeLine(sourceFile, property) },
1802
+ metadata: { target, mode: 'override', overrideKey: overrideId },
1803
+ })
1804
+ }
1805
+ }
1806
+ }
1807
+ return dedupeOwnedContractFacts(facts)
1808
+ }
1809
+
1810
+ function extractGeneratorPluginContracts(
1811
+ moduleRoot: string,
1812
+ sourceRoot: string,
1813
+ ): { facts: ModuleOwnedContractFact[]; unresolved: ModuleFactDiagnostic[] } {
1814
+ const filePath = resolveConventionFile(moduleRoot, 'generators')
1815
+ if (!filePath) return { facts: [], unresolved: [] }
1816
+ const sourceFile = readSourceFile(filePath)
1817
+ if (!sourceFile) return { facts: [], unresolved: [] }
1818
+ const pluginsArray = findArrayLiteralDeclaration(sourceFile, 'generatorPlugins')
1819
+ if (!pluginsArray) return { facts: [], unresolved: [] }
1820
+ const sourcePath = toPortableSourcePath(moduleRoot, sourceRoot, filePath)
1821
+ const initializers = buildVariableInitializerMap(sourceFile)
1822
+ const facts: ModuleOwnedContractFact[] = []
1823
+ const unresolved: ModuleFactDiagnostic[] = []
1824
+ let index = 0
1825
+ for (const element of pluginsArray.elements) {
1826
+ const objectLiteral = resolveToObjectLiteral(element, initializers)
1827
+ if (!objectLiteral) {
1828
+ index += 1
1829
+ continue
1830
+ }
1831
+ const pluginId = readStringPropertyInitializer(objectLiteral, 'id')
1832
+ if (!pluginId) {
1833
+ unresolved.push({
1834
+ code: 'unresolved-static-contract',
1835
+ kind: 'generator-plugin',
1836
+ id: `${sourcePath}#${index}`,
1837
+ source: { sourcePath, exportName: 'generatorPlugins', line: nodeLine(sourceFile, objectLiteral) },
1838
+ })
1839
+ index += 1
1840
+ continue
1841
+ }
1842
+ const metadata: Record<string, ModuleFactSafeScalar | ModuleFactSafeScalar[]> = {}
1843
+ const conventionFile = readStringPropertyInitializer(objectLiteral, 'conventionFile')
1844
+ if (conventionFile) metadata.conventionFile = conventionFile
1845
+ const outputFileName = readStringPropertyInitializer(objectLiteral, 'outputFileName')
1846
+ if (outputFileName) metadata.outputFileName = outputFileName
1847
+ facts.push({
1848
+ kind: 'generator-plugin',
1849
+ id: pluginId,
1850
+ source: { sourcePath, exportName: 'generatorPlugins', line: nodeLine(sourceFile, objectLiteral) },
1851
+ ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
1852
+ })
1853
+ index += 1
1854
+ }
1855
+ return { facts: dedupeOwnedContractFacts(facts), unresolved }
1856
+ }
1857
+
1858
+ function compareSourceRefs(left: ModuleFactSourceRef, right: ModuleFactSourceRef): number {
1859
+ return (
1860
+ left.sourcePath.localeCompare(right.sourcePath) ||
1861
+ (left.line ?? 0) - (right.line ?? 0) ||
1862
+ (left.exportName ?? '').localeCompare(right.exportName ?? '')
1863
+ )
1864
+ }
1865
+
1866
+ function sameSourceRef(left: ModuleFactSourceRef, right: ModuleFactSourceRef): boolean {
1867
+ return (
1868
+ left.sourcePath === right.sourcePath &&
1869
+ (left.line ?? null) === (right.line ?? null) &&
1870
+ (left.exportName ?? null) === (right.exportName ?? null)
1871
+ )
1872
+ }
1873
+
1874
+ // Every proven `(kind, id)` reaches `factSources`. Facts whose declaration site is already
1875
+ // serialized inline (pages, CLI commands, routes, AI tools/agents, reused contributions) and
1876
+ // the owned families (which carry `source` inside `ownedContracts`) are emitted as a typed
1877
+ // `factRef` pointer instead of a duplicated source ref, so the index stays uniform without
1878
+ // copying provenance payloads past the determinism byte guard. Duplicate detection runs over
1879
+ // ALL candidates regardless of which projection they take.
1880
+ type ProvenanceCandidate = {
1881
+ kind: ModuleFactSourceKind
1882
+ id: string
1883
+ source?: ModuleFactSourceRef
1884
+ factRef?: ModuleFactRef
1885
+ }
1886
+
1887
+ function provenanceCandidateOrderKey(candidate: ProvenanceCandidate): string {
1888
+ if (candidate.source) {
1889
+ return JSON.stringify([
1890
+ 0,
1891
+ candidate.source.sourcePath,
1892
+ candidate.source.line ?? 0,
1893
+ candidate.source.exportName ?? '',
1894
+ ])
1895
+ }
1896
+ return JSON.stringify([1, candidate.factRef?.factSection ?? '', candidate.factRef?.factKey ?? ''])
1897
+ }
1898
+
1899
+ function buildProvenanceIndex(candidates: readonly ProvenanceCandidate[]): {
1900
+ factSources: ModuleFactSourceEntry[]
1901
+ diagnostics: ModuleFactDiagnostic[]
1902
+ } {
1903
+ const grouped = new Map<string, ProvenanceCandidate[]>()
1904
+ for (const candidate of candidates) {
1905
+ const key = JSON.stringify([candidate.kind, candidate.id])
1906
+ const group = grouped.get(key) ?? []
1907
+ group.push(candidate)
1908
+ grouped.set(key, group)
1909
+ }
1910
+ // Keep the provenance index compact: sourcePath is the provenance and (kind,id) the
1911
+ // stable identity, so exportName and line (recoverable from source) are dropped here.
1912
+ // Owned families retain the richer source ref inside `ownedContracts`.
1913
+ const factSources: ModuleFactSourceEntry[] = []
1914
+ const diagnostics: ModuleFactDiagnostic[] = []
1915
+ for (const group of grouped.values()) {
1916
+ const sorted = [...group].sort(
1917
+ (left, right) => provenanceCandidateOrderKey(left).localeCompare(provenanceCandidateOrderKey(right)),
1918
+ )
1919
+ const canonical = sorted[0]
1920
+ factSources.push(
1921
+ canonical.factRef
1922
+ ? {
1923
+ kind: canonical.kind,
1924
+ id: canonical.id,
1925
+ factRef: {
1926
+ factSection: canonical.factRef.factSection,
1927
+ ...(canonical.factRef.factKey === canonical.id
1928
+ ? {}
1929
+ : { factKey: canonical.factRef.factKey }),
1930
+ },
1931
+ }
1932
+ : {
1933
+ kind: canonical.kind,
1934
+ id: canonical.id,
1935
+ source: { sourcePath: (canonical.source as ModuleFactSourceRef).sourcePath },
1936
+ },
1937
+ )
1938
+ const canonicalKey = provenanceCandidateOrderKey(canonical)
1939
+ for (const rejected of sorted.slice(1)) {
1940
+ if (provenanceCandidateOrderKey(rejected) === canonicalKey) continue
1941
+ diagnostics.push({
1942
+ code: 'duplicate-source',
1943
+ kind: rejected.kind,
1944
+ id: rejected.id,
1945
+ ...(rejected.source ? { source: rejected.source } : {}),
1946
+ })
1947
+ }
1948
+ }
1949
+ factSources.sort(
1950
+ (left, right) => left.kind.localeCompare(right.kind) || left.id.localeCompare(right.id),
1951
+ )
1952
+ diagnostics.sort(
1953
+ (left, right) =>
1954
+ left.code.localeCompare(right.code) ||
1955
+ left.kind.localeCompare(right.kind) ||
1956
+ left.id.localeCompare(right.id) ||
1957
+ compareSourceRefs(left.source ?? { sourcePath: '' }, right.source ?? { sourcePath: '' }),
1958
+ )
1959
+ return { factSources, diagnostics }
1960
+ }
1961
+
1962
+ function buildOwnedContracts(
1963
+ families: ReadonlyArray<[ModuleOwnedContractKind, ModuleOwnedContractFact[]]>,
1964
+ ): ModuleOwnedContracts | undefined {
1965
+ const result: ModuleOwnedContracts = {}
1966
+ for (const [kind, facts] of families) {
1967
+ if (facts.length === 0) continue
1968
+ // Drop the (constant/redundant) exportName from the emitted source ref; the line
1969
+ // anchor plus sourcePath already pin the declaration and keep the JSON compact.
1970
+ result[kind] = facts.map((fact) => ({
1971
+ ...fact,
1972
+ source: {
1973
+ sourcePath: fact.source.sourcePath,
1974
+ ...(fact.source.line ? { line: fact.source.line } : {}),
1975
+ },
1976
+ }))
1977
+ }
1978
+ return Object.keys(result).length > 0 ? result : undefined
1979
+ }
1980
+
1981
+ const CONTRIBUTION_KIND_TO_SOURCE_KIND: Partial<Record<string, ModuleFactSourceKind>> = {
1982
+ subscriber: 'subscriber',
1983
+ }
1984
+
1985
+ const REGISTRY_TO_SOURCE_KIND: Partial<Record<string, ModuleFactSourceKind>> = {
1986
+ vector: 'vector',
1987
+ integration: 'integration',
1988
+ payment: 'integration',
1989
+ shipping: 'integration',
1990
+ currency: 'integration',
1991
+ }
1992
+
1993
+ export function extractModuleFacts(options: ExtractModuleFactsOptions): ModuleFacts {
1994
+ const { moduleId, coreVersion = null, sourcePackage = null, sourceVersion = null } = options
1995
+ const moduleRoot = options.moduleRoot
1996
+ ?? (options.coreSrcRoot ? path.join(options.coreSrcRoot, moduleId) : null)
1997
+ if (!moduleRoot) {
1998
+ throw new Error(`[internal] extractModuleFacts requires moduleRoot or coreSrcRoot for module "${moduleId}"`)
1999
+ }
2000
+ const sourceRoot = options.portableSourceRoot
2001
+ ? assertPortableSourceRoot(moduleId, options.portableSourceRoot)
2002
+ : toPortableSourceRoot(moduleId, sourcePackage)
2003
+
2004
+ const entitiesFilePath =
2005
+ resolveConventionFile(path.join(moduleRoot, 'data'), 'entities') ??
2006
+ resolveConventionFile(path.join(moduleRoot, 'db'), 'entities') ??
2007
+ resolveConventionFile(path.join(moduleRoot, 'data'), 'schema')
2008
+ const ceFilePath = resolveConventionFile(moduleRoot, 'ce')
2009
+ const eventsFilePath = resolveConventionFile(moduleRoot, 'events')
2010
+ const aclFilePath = resolveConventionFile(moduleRoot, 'acl')
2011
+ const diFilePath = resolveConventionFile(moduleRoot, 'di')
2012
+ const searchFilePath = resolveConventionFile(moduleRoot, 'search')
2013
+ const notificationsFilePath = resolveConventionFile(moduleRoot, 'notifications')
2014
+ const cliFilePath = resolveConventionFile(moduleRoot, 'cli')
2015
+ const indexFilePath = resolveConventionFile(moduleRoot, 'index')
2016
+ const setupFilePath = resolveConventionFile(moduleRoot, 'setup')
2017
+ const encryptionFilePath = resolveConventionFile(moduleRoot, 'encryption')
2018
+
2019
+ const portableOf = (filePath: string | null): string | null =>
2020
+ filePath ? toPortableSourcePath(moduleRoot, sourceRoot, filePath) : null
2021
+
2022
+ const warnings: string[] = []
2023
+ const { title, description } = extractModuleMeta(indexFilePath)
2024
+ const customFieldEntityIds = collectCustomFieldEntityIds(ceFilePath)
2025
+ const entities = extractEntities(moduleId, entitiesFilePath, customFieldEntityIds)
2026
+ const events = extractEvents(eventsFilePath)
2027
+ const aclFeatures = extractAclFeatures(aclFilePath)
2028
+
2029
+ const { source: registrySource, description: registryDescription } = resolveRegistrySource(options)
2030
+ const apiRouteSourcePaths = extractApiRouteSourcePaths(moduleId, moduleRoot, sourceRoot)
2031
+ const apiRoutes = extractApiRoutes(moduleId, registrySource, registryDescription, apiRouteSourcePaths, warnings)
2032
+ const diRegistrations = extractDiRegistrationFacts(diFilePath, portableOf(diFilePath) ?? '')
2033
+ const diTokens = deriveLegacyDiTokens(diRegistrations.facts)
2034
+ for (const legacyToken of extractDiTokens(diFilePath)) {
2035
+ if (!diTokens.includes(legacyToken)) diTokens.push(legacyToken)
2036
+ }
2037
+ const searchEntities = extractSearchEntities(searchFilePath, warnings)
2038
+ const notifications = extractNotifications(notificationsFilePath, warnings)
2039
+ const cli = extractCli(cliFilePath, warnings)
2040
+ const cliCommands = cliFilePath
2041
+ ? cli.map((command) => ({
2042
+ command,
2043
+ sourcePath: toPortableSourcePath(moduleRoot, sourceRoot, cliFilePath),
2044
+ }))
2045
+ : []
2046
+ const backendPages = extractModulePages(moduleId, moduleRoot, sourceRoot, 'backend')
2047
+ const frontendPages = extractModulePages(moduleId, moduleRoot, sourceRoot, 'frontend')
2048
+ const aiTools = extractAiTools(moduleRoot, sourceRoot)
2049
+ const aiAgents = extractAiAgents(moduleRoot, sourceRoot)
2050
+ const hostTokens: ModuleHostTokens = {
2051
+ entityIds: extractHostEntityIds(entities),
2052
+ tableIds: extractTableIds(moduleRoot),
2053
+ }
2054
+ const extensionSurfaces = extractModuleExtensionFacts({
2055
+ moduleId,
2056
+ moduleRoot,
2057
+ sourceRoot,
2058
+ entities,
2059
+ events,
2060
+ apiRoutes,
2061
+ searchEntities,
2062
+ notifications,
2063
+ aiTools,
2064
+ aiAgents,
2065
+ })
2066
+
2067
+ const indexSourcePath = portableOf(indexFilePath)
2068
+ const diSourcePath = portableOf(diFilePath)
2069
+ const moduleMetadataFact = extractModuleMetadataContract(indexFilePath, moduleId, indexSourcePath)
2070
+ const commandCandidates = extractCommandContractCandidates(moduleId, moduleRoot, sourceRoot)
2071
+ const commandFacts = dedupeOwnedContractFacts(
2072
+ commandCandidates.map((candidate) => ({
2073
+ kind: 'command' as const,
2074
+ id: candidate.id,
2075
+ source: { sourcePath: candidate.sourcePath },
2076
+ })),
2077
+ )
2078
+ const workers = extractWorkerContracts(moduleId, moduleRoot, sourceRoot)
2079
+ const pageMiddleware = extractPageMiddlewareContracts(moduleRoot, sourceRoot)
2080
+ const setupFact = extractSetupContract(moduleId, setupFilePath, portableOf(setupFilePath))
2081
+ const encryptionFacts = extractEncryptionContracts(encryptionFilePath, portableOf(encryptionFilePath))
2082
+ const customEntityFacts = extractCustomEntityContracts(ceFilePath, portableOf(ceFilePath))
2083
+ const aiExtensionFacts = extractAiExtensionContracts(moduleRoot, sourceRoot)
2084
+ const generatorPlugins = extractGeneratorPluginContracts(moduleRoot, sourceRoot)
2085
+ const diContractFacts = dedupeOwnedContractFacts(
2086
+ diRegistrations.facts.map((fact) => ({
2087
+ kind: 'di-registration' as const,
2088
+ id: fact.token,
2089
+ source: fact.source,
2090
+ metadata: {
2091
+ registrationKind: fact.registrationKind,
2092
+ ...(fact.providerSymbol ? { providerSymbol: fact.providerSymbol } : {}),
2093
+ ...(fact.lifetime ? { lifetime: fact.lifetime } : {}),
2094
+ ...(fact.injectionMode ? { injectionMode: fact.injectionMode } : {}),
2095
+ },
2096
+ })),
2097
+ )
2098
+
2099
+ const ownedContracts = buildOwnedContracts([
2100
+ ['module-metadata', moduleMetadataFact ? [moduleMetadataFact] : []],
2101
+ ['command', commandFacts],
2102
+ ['worker', workers.facts],
2103
+ ['page-middleware', pageMiddleware.facts],
2104
+ ['setup', setupFact ? [setupFact] : []],
2105
+ ['encryption', encryptionFacts],
2106
+ ['di-registration', diContractFacts],
2107
+ ['custom-entity', customEntityFacts],
2108
+ ['ai-extension', aiExtensionFacts],
2109
+ ['generator-plugin', generatorPlugins.facts],
2110
+ ])
2111
+
2112
+ const provenanceCandidates: ProvenanceCandidate[] = []
2113
+ const pushSource = (kind: ModuleFactSourceKind, id: string, source: ModuleFactSourceRef): void => {
2114
+ provenanceCandidates.push({ kind, id, source })
2115
+ }
2116
+ const pushRef = (
2117
+ kind: ModuleFactSourceKind,
2118
+ id: string,
2119
+ source: ModuleFactSourceRef,
2120
+ factSection: string,
2121
+ factKey: string,
2122
+ ): void => {
2123
+ provenanceCandidates.push({ kind, id, source, factRef: { factSection, factKey } })
2124
+ }
2125
+ const pushOwnedRefs = (kind: ModuleOwnedContractKind, facts: readonly ModuleOwnedContractFact[]): void => {
2126
+ for (const fact of facts) pushRef(kind, fact.id, fact.source, `ownedContracts.${kind}`, fact.id)
2127
+ }
2128
+ if (moduleMetadataFact) {
2129
+ pushRef(
2130
+ 'module-metadata',
2131
+ moduleId,
2132
+ moduleMetadataFact.source,
2133
+ 'ownedContracts.module-metadata',
2134
+ moduleMetadataFact.id,
2135
+ )
2136
+ }
2137
+ if (entitiesFilePath) {
2138
+ const entitiesSourcePath = portableOf(entitiesFilePath) as string
2139
+ for (const entity of entities) {
2140
+ pushSource('entity', entity.id, { sourcePath: entitiesSourcePath, exportName: entity.class })
2141
+ }
2142
+ }
2143
+ if (eventsFilePath) {
2144
+ const eventsSourcePath = portableOf(eventsFilePath) as string
2145
+ for (const event of events) pushSource('event', event.id, { sourcePath: eventsSourcePath, exportName: 'events' })
2146
+ }
2147
+ if (aclFilePath) {
2148
+ const aclSourcePath = portableOf(aclFilePath) as string
2149
+ for (const feature of aclFeatures) pushSource('acl-feature', feature, { sourcePath: aclSourcePath, exportName: 'features' })
2150
+ }
2151
+ for (const route of apiRoutes) {
2152
+ if (route.sourcePath) {
2153
+ pushRef('api-route', route.path, { sourcePath: route.sourcePath }, 'apiRoutes', route.path)
2154
+ }
2155
+ }
2156
+ if (searchFilePath) {
2157
+ const searchSourcePath = portableOf(searchFilePath) as string
2158
+ for (const entityId of searchEntities) pushSource('search', entityId, { sourcePath: searchSourcePath, exportName: 'searchConfig' })
2159
+ }
2160
+ if (notificationsFilePath) {
2161
+ const notificationsSourcePath = portableOf(notificationsFilePath) as string
2162
+ for (const id of notifications) pushSource('notification', id, { sourcePath: notificationsSourcePath })
2163
+ }
2164
+ for (const command of cliCommands) {
2165
+ pushRef('cli-command', command.command, { sourcePath: command.sourcePath }, 'cliCommands', command.command)
2166
+ }
2167
+ for (const page of backendPages) {
2168
+ pushRef('backend-page', page.path, { sourcePath: page.sourcePath }, 'backendPages', page.path)
2169
+ }
2170
+ for (const page of frontendPages) {
2171
+ pushRef('frontend-page', page.path, { sourcePath: page.sourcePath }, 'frontendPages', page.path)
2172
+ }
2173
+ for (const tool of aiTools) pushRef('ai-tool', tool.name, { sourcePath: tool.sourcePath }, 'aiTools', tool.name)
2174
+ for (const agent of aiAgents) pushRef('ai-agent', agent.id, { sourcePath: agent.sourcePath }, 'aiAgents', agent.id)
2175
+ for (const candidate of commandCandidates) {
2176
+ pushRef('command', candidate.id, { sourcePath: candidate.sourcePath }, 'ownedContracts.command', candidate.id)
2177
+ }
2178
+ pushOwnedRefs('worker', workers.facts)
2179
+ pushOwnedRefs('page-middleware', pageMiddleware.facts)
2180
+ if (setupFact) pushOwnedRefs('setup', [setupFact])
2181
+ pushOwnedRefs('encryption', encryptionFacts)
2182
+ for (const fact of diRegistrations.facts) {
2183
+ pushRef('di-registration', fact.token, fact.source, 'ownedContracts.di-registration', fact.token)
2184
+ }
2185
+ pushOwnedRefs('custom-entity', customEntityFacts)
2186
+ pushOwnedRefs('ai-extension', aiExtensionFacts)
2187
+ pushOwnedRefs('generator-plugin', generatorPlugins.facts)
2188
+ const contributionRef = (contributionId: string): { factSection: string; factKey: string } => ({
2189
+ factSection: 'extensionSurfaces.contributions',
2190
+ factKey: contributionId,
2191
+ })
2192
+ for (const contribution of extensionSurfaces.contributions) {
2193
+ const contributionSource: ModuleFactSourceRef = {
2194
+ sourcePath: contribution.source.path,
2195
+ ...(contribution.source.symbol ? { exportName: contribution.source.symbol } : {}),
2196
+ }
2197
+ const ref = contributionRef(contribution.id)
2198
+ if (contribution.kind === 'subscriber') {
2199
+ pushRef('subscriber', contribution.id, contributionSource, ref.factSection, ref.factKey)
2200
+ continue
2201
+ }
2202
+ if (contribution.kind === 'specialized-registry') {
2203
+ const details = contribution.details as { registry?: string; registryId?: string }
2204
+ const sourceKind = details.registry ? REGISTRY_TO_SOURCE_KIND[details.registry] : undefined
2205
+ if (sourceKind) {
2206
+ pushRef(
2207
+ sourceKind,
2208
+ details.registryId ?? contribution.id,
2209
+ contributionSource,
2210
+ ref.factSection,
2211
+ ref.factKey,
2212
+ )
2213
+ }
2214
+ continue
2215
+ }
2216
+ const mapped = CONTRIBUTION_KIND_TO_SOURCE_KIND[contribution.kind]
2217
+ if (mapped) pushRef(mapped, contribution.id, contributionSource, ref.factSection, ref.factKey)
2218
+ }
2219
+ // extension-host / extension-contribution provenance is emitted as typed `factRef` pointers
2220
+ // rather than duplicated source refs: those facts already carry their own `source` inside
2221
+ // `extensionSurfaces.{hosts,contributions}`, and copying every declared host spot (hundreds
2222
+ // per large module) would push the shared JSON past the determinism byte guard asserted in
2223
+ // `module-facts.bc-guard.test.ts`.
2224
+ // Hosts are indexed by their registry `key`: `id` repeats across families (an entity id is
2225
+ // also an api-entity id), while `key` is unique inside a module.
2226
+ for (const host of extensionSurfaces.hosts) {
2227
+ provenanceCandidates.push({
2228
+ kind: 'extension-host',
2229
+ id: host.key,
2230
+ ...(host.source.kind === 'fact-ref' ? {} : { source: { sourcePath: host.source.path } }),
2231
+ factRef: { factSection: 'extensionSurfaces.hosts', factKey: host.key },
2232
+ })
2233
+ }
2234
+ for (const contribution of extensionSurfaces.contributions) {
2235
+ pushRef(
2236
+ 'extension-contribution',
2237
+ contribution.id,
2238
+ { sourcePath: contribution.source.path },
2239
+ 'extensionSurfaces.contributions',
2240
+ contribution.id,
2241
+ )
2242
+ }
2243
+
2244
+ const provenance = buildProvenanceIndex(provenanceCandidates)
2245
+ const factDiagnostics = [
2246
+ ...provenance.diagnostics,
2247
+ ...workers.unresolved,
2248
+ ...pageMiddleware.unresolved,
2249
+ ...generatorPlugins.unresolved,
2250
+ ...diRegistrations.unresolvedTokens.map((token): ModuleFactDiagnostic => ({
2251
+ code: 'unresolved-static-contract',
2252
+ kind: 'di-registration',
2253
+ id: token,
2254
+ ...(diSourcePath ? { source: { sourcePath: diSourcePath, exportName: token } } : {}),
2255
+ })),
2256
+ ].sort(
2257
+ (left, right) =>
2258
+ left.code.localeCompare(right.code) ||
2259
+ left.kind.localeCompare(right.kind) ||
2260
+ left.id.localeCompare(right.id),
2261
+ )
2262
+
2263
+ const facts: ModuleFacts = {
2264
+ module: moduleId,
2265
+ title,
2266
+ description,
2267
+ coreVersion,
2268
+ sourcePackage,
2269
+ sourceVersion,
2270
+ sourceRoot,
2271
+ ...(options.sourceKind === 'local-reference' ? { sourceKind: options.sourceKind } : {}),
2272
+ entities,
2273
+ events,
2274
+ aclFeatures,
2275
+ apiRoutes,
2276
+ diTokens,
2277
+ searchEntities,
2278
+ hostTokens,
2279
+ notifications,
2280
+ cli,
2281
+ backendPages,
2282
+ frontendPages,
2283
+ cliCommands,
2284
+ aiTools,
2285
+ aiAgents,
2286
+ extensionSurfaces,
2287
+ ...(provenance.factSources.length > 0 ? { factSources: provenance.factSources } : {}),
2288
+ ...(ownedContracts ? { ownedContracts } : {}),
2289
+ ...(factDiagnostics.length > 0 ? { factDiagnostics } : {}),
2290
+ warnings,
2291
+ }
2292
+
2293
+ // Exact unified override targets (spec 2026-08-02-module-facts-exact-override-targets).
2294
+ // Adapters consume the fully-populated owned facts + contributions above.
2295
+ const overrides = collectModuleOverrideTargets(facts, buildFactSourceLookup(facts))
2296
+ if (overrides.overrideTargets.length > 0) facts.overrideTargets = overrides.overrideTargets
2297
+ if (overrides.overrideTargetDiagnostics.length > 0) {
2298
+ facts.overrideTargetDiagnostics = overrides.overrideTargetDiagnostics
2299
+ }
2300
+ return facts
2301
+ }
2302
+
2303
+ export interface ModuleFactsJsonEvent {
2304
+ id: string
2305
+ category: string | null
2306
+ entity: string | null
2307
+ clientBroadcast?: boolean
2308
+ portalBroadcast?: boolean
2309
+ }
2310
+
2311
+ export interface ModuleFactsJsonEntry {
2312
+ title: string | null
2313
+ description: string | null
2314
+ coreVersion: string | null
2315
+ sourcePackage: string | null
2316
+ sourceVersion: string | null
2317
+ sourceRoot: string
2318
+ /** Present only for `local-reference` projections; package sidecar entries omit it. */
2319
+ sourceKind?: ModuleFactProjectionSourceKind
2320
+ entities: ModuleEntityFact[]
2321
+ events: ModuleFactsJsonEvent[]
2322
+ aclFeatures: string[]
2323
+ apiRoutes: ModuleApiRouteFact[]
2324
+ diTokens: string[]
2325
+ searchEntities: string[]
2326
+ hostTokens: ModuleHostTokens
2327
+ notifications: string[]
2328
+ cli: string[]
2329
+ backendPages: ModulePageFact[]
2330
+ frontendPages: ModulePageFact[]
2331
+ cliCommands: ModuleCliCommandFact[]
2332
+ aiTools: ModuleAiToolFact[]
2333
+ aiAgents: ModuleAiAgentFact[]
2334
+ extensionSurfaces?: ModuleExtensionSurfaceFacts
2335
+ factSources?: ModuleFactSourceEntry[]
2336
+ ownedContracts?: ModuleOwnedContracts
2337
+ factDiagnostics?: ModuleFactDiagnostic[]
2338
+ overrideTargets?: ModuleOverrideTarget[]
2339
+ overrideTargetDiagnostics?: ModuleOverrideTargetDiagnostic[]
2340
+ }
2341
+
2342
+ const EMPTY_SECTION_MARKER = '_none_'
2343
+
2344
+ function renderVersionStamp(
2345
+ coreVersion: string | null,
2346
+ sourcePackage: string | null,
2347
+ sourceVersion: string | null,
2348
+ ): string {
2349
+ if (sourcePackage) {
2350
+ return `<!-- generated from ${sourcePackage} ${sourceVersion || '<unknown>'}; core ${coreVersion || '<unknown>'} — R1 staleness stamp -->`
2351
+ }
2352
+ const version = coreVersion && coreVersion.length > 0 ? coreVersion : '<unknown>'
2353
+ return `<!-- generated from @open-mercato/core ${version} — R1 staleness stamp -->`
2354
+ }
2355
+
2356
+ function renderEntitiesSection(entities: ModuleEntityFact[], lookup: FactSourceLookup): string {
2357
+ if (entities.length === 0) return `## Entities\n\n${EMPTY_SECTION_MARKER}`
2358
+ const header = '| Entity ID | Class | Table | Editable | CustomFields | Source |'
2359
+ const divider = '|---|---|---|---|---|---|'
2360
+ const rows = entities.map(
2361
+ (entity) =>
2362
+ `| ${entity.id} | ${entity.class} | ${entity.table} | ${entity.editable ? 'yes' : 'no'} | ${entity.customFields ? 'yes' : 'no'} | ${renderLookedUpSource(lookup, 'entity', entity.id)} |`,
2363
+ )
2364
+ return ['## Entities', '', header, divider, ...rows].join('\n')
2365
+ }
2366
+
2367
+ function renderEventsSection(events: ModuleEventFact[], lookup: FactSourceLookup): string {
2368
+ const heading = `## Events (${events.length})`
2369
+ if (events.length === 0) return `${heading}\n\n${EMPTY_SECTION_MARKER}`
2370
+ const header = '| ID | Category | Entity | Browser transport | Source |'
2371
+ const divider = '|---|---|---|---|---|'
2372
+ const rows = events.map((event) => {
2373
+ const transports = [event.clientBroadcast ? 'client' : null, event.portalBroadcast ? 'portal' : null]
2374
+ .filter((value): value is string => value !== null)
2375
+ return `| ${event.id} | ${event.category ?? '—'} | ${event.entity ?? '—'} | ${transports.join(', ') || '—'} | ${renderLookedUpSource(lookup, 'event', event.id)} |`
2376
+ })
2377
+ return [heading, '', header, divider, ...rows].join('\n')
2378
+ }
2379
+
2380
+ function renderInlineListSection(heading: string, values: string[]): string {
2381
+ if (values.length === 0) return `${heading}\n\n${EMPTY_SECTION_MARKER}`
2382
+ return `${heading}\n\n${values.join(' · ')}`
2383
+ }
2384
+
2385
+ function renderSourceLinkedListSection(
2386
+ heading: string,
2387
+ values: readonly string[],
2388
+ lookup: FactSourceLookup,
2389
+ kind: ModuleFactSourceKind,
2390
+ idColumnLabel: string,
2391
+ ): string {
2392
+ if (values.length === 0) return `${heading}\n\n${EMPTY_SECTION_MARKER}`
2393
+ const rows = values.map((value) => `| ${value} | ${renderLookedUpSource(lookup, kind, value)} |`)
2394
+ return [heading, '', `| ${idColumnLabel} | Source |`, '|---|---|', ...rows].join('\n')
2395
+ }
2396
+
2397
+ /**
2398
+ * A generated fact only earns a markdown link when it names an exact module code
2399
+ * file. Directory-valued provenance (the module source root, framework hosts that
2400
+ * only know their owning package folder) renders as plain text: a link to a folder
2401
+ * resolves to nothing in an editor or on the repository browser, so emitting one
2402
+ * costs a consumer a dead click instead of taking them to the declaration site.
2403
+ */
2404
+ export function isExactSourceFilePath(sourcePath: string): boolean {
2405
+ return MODULE_CODE_EXTENSIONS.some((extension) => sourcePath.endsWith(extension))
2406
+ }
2407
+
2408
+ function renderSourceLink(sourcePath: string): string {
2409
+ if (!isExactSourceFilePath(sourcePath)) return sourcePath
2410
+ return `[${sourcePath}](../../../${sourcePath})`
2411
+ }
2412
+
2413
+ function renderSourceRefLink(source: ModuleFactSourceRef): string {
2414
+ const anchor = source.line ? `#L${source.line}` : ''
2415
+ const label = source.line ? `${source.sourcePath}:${source.line}` : source.sourcePath
2416
+ if (!isExactSourceFilePath(source.sourcePath)) return label
2417
+ return `[${label}](../../../${source.sourcePath}${anchor})`
2418
+ }
2419
+
2420
+
2421
+ function renderLookedUpSource(lookup: FactSourceLookup, kind: ModuleFactSourceKind, id: string): string {
2422
+ const source = lookup(kind, id)
2423
+ return source ? renderSourceRefLink(source) : '—'
2424
+ }
2425
+
2426
+ function renderSafeScalar(value: ModuleFactSafeScalar | ModuleFactSafeScalar[] | undefined): string {
2427
+ if (value === undefined || value === null) return '—'
2428
+ if (Array.isArray(value)) return value.length > 0 ? value.map((entry) => String(entry)).join(', ') : '—'
2429
+ return String(value)
2430
+ }
2431
+
2432
+ function renderOwnedContractMetadata(metadata: ModuleOwnedContractFact['metadata']): string {
2433
+ if (!metadata) return '—'
2434
+ const keys = Object.keys(metadata).sort((left, right) => left.localeCompare(right))
2435
+ if (keys.length === 0) return '—'
2436
+ return keys.map((key) => `${key}=${renderSafeScalar(metadata[key])}`).join('; ')
2437
+ }
2438
+
2439
+ function renderOwnedContractSection(
2440
+ heading: string,
2441
+ facts: readonly ModuleOwnedContractFact[] | undefined,
2442
+ ): string {
2443
+ if (!facts || facts.length === 0) return ''
2444
+ const rows = facts.map(
2445
+ (fact) => `| ${fact.id} | ${renderOwnedContractMetadata(fact.metadata)} | ${renderSourceRefLink(fact.source)} |`,
2446
+ )
2447
+ return [heading, '', '| ID | Metadata | Source |', '|---|---|---|', ...rows].join('\n')
2448
+ }
2449
+
2450
+ function renderDiRegistrationsSection(facts: readonly ModuleOwnedContractFact[] | undefined): string {
2451
+ if (!facts || facts.length === 0) return ''
2452
+ const rows = facts.map((fact) => {
2453
+ const metadata = fact.metadata ?? {}
2454
+ return `| ${fact.id} | ${renderSafeScalar(metadata.registrationKind)} | ${renderSafeScalar(metadata.lifetime)} | ${renderSafeScalar(metadata.injectionMode)} | ${renderSafeScalar(metadata.providerSymbol)} | ${renderSourceRefLink(fact.source)} |`
2455
+ })
2456
+ return [
2457
+ '## DI registrations (rich)',
2458
+ '',
2459
+ '| Token | Kind | Lifetime | Injection | Provider | Source |',
2460
+ '|---|---|---|---|---|---|',
2461
+ ...rows,
2462
+ ].join('\n')
2463
+ }
2464
+
2465
+ function describeAuthRule(rule: ApiRouteAuthRule | undefined): string {
2466
+ if (!rule) return 'public'
2467
+ if (rule.requireFeatures && rule.requireFeatures.length > 0) return rule.requireFeatures.join(', ')
2468
+ if (rule.requireRoles && rule.requireRoles.length > 0) return rule.requireRoles.join(', ')
2469
+ if (rule.requireAuth) return 'auth'
2470
+ return 'public'
2471
+ }
2472
+
2473
+ function renderApiRouteAuthCell(route: ModuleApiRouteFact): string {
2474
+ if (route.methods.length === 0) return '—'
2475
+ const groups: Array<{ label: string; methods: string[] }> = []
2476
+ for (const method of route.methods) {
2477
+ const label = describeAuthRule(route.auth[method])
850
2478
  const existing = groups.find((group) => group.label === label)
851
2479
  if (existing) existing.methods.push(method)
852
2480
  else groups.push({ label, methods: [method] })
@@ -856,44 +2484,284 @@ function renderApiRouteAuthCell(route: ModuleApiRouteFact): string {
856
2484
 
857
2485
  function renderApiRoutesSection(routes: ModuleApiRouteFact[]): string {
858
2486
  if (routes.length === 0) return `## API routes\n\n${EMPTY_SECTION_MARKER}`
859
- const header = '| Path | Methods | Auth (per-method requireFeatures) |'
860
- const divider = '|---|---|---|'
2487
+ const header = '| Path | Methods | Auth (per-method requireFeatures) | Source |'
2488
+ const divider = '|---|---|---|---|'
861
2489
  const rows = routes.map(
862
- (route) => `| ${route.path} | ${route.methods.join(' ')} | ${renderApiRouteAuthCell(route)} |`,
2490
+ (route) => `| ${route.path} | ${route.methods.join(' ')} | ${renderApiRouteAuthCell(route)} | ${route.sourcePath ? renderSourceLink(route.sourcePath) : '—'} |`,
863
2491
  )
864
2492
  return ['## API routes', '', header, divider, ...rows].join('\n')
865
2493
  }
866
2494
 
2495
+ function renderLinkedFactsSection(
2496
+ heading: string,
2497
+ facts: ReadonlyArray<{ label: string; sourcePath: string }>,
2498
+ ): string {
2499
+ if (facts.length === 0) return `${heading}\n\n${EMPTY_SECTION_MARKER}`
2500
+ const rows = facts.map((fact) => `| ${fact.label} | ${renderSourceLink(fact.sourcePath)} |`)
2501
+ return [heading, '', '| ID / path | Source |', '|---|---|', ...rows].join('\n')
2502
+ }
2503
+
867
2504
  function renderHostTokensSection(hostTokens: ModuleHostTokens): string {
868
2505
  const entityIdsLine = hostTokens.entityIds.length > 0 ? hostTokens.entityIds.join(' · ') : EMPTY_SECTION_MARKER
869
2506
  const tableIdsLine = hostTokens.tableIds.length > 0 ? hostTokens.tableIds.join(' · ') : EMPTY_SECTION_MARKER
870
2507
  return ['## Host extension points', '', `- Entity IDs: ${entityIdsLine}`, `- Table IDs: ${tableIdsLine}`].join('\n')
871
2508
  }
872
2509
 
873
- export function renderModuleFactsMarkdown(facts: ModuleFacts): string {
2510
+ function renderExtensionHostContext(host: ModuleExtensionSurfaceFacts['hosts'][number]): string {
2511
+ return host.contextContract ?? host.runtimeContract ?? host.scopeContract ?? '—'
2512
+ }
2513
+
2514
+ function renderExtensionHostSource(host: ModuleExtensionHostFact): string {
2515
+ if (host.source.kind === 'fact-ref') return `${host.source.factSection}:${host.source.factKey}`
2516
+ return renderSourceRefLink({ sourcePath: host.source.path })
2517
+ }
2518
+
2519
+ function renderExtensionHostsSection(extensionSurfaces: ModuleExtensionSurfaceFacts): string {
2520
+ const boundHosts = extensionSurfaces.hosts.filter((host) => host.bound)
2521
+ if (boundHosts.length === 0) return `## UMES hosts\n\n${EMPTY_SECTION_MARKER}`
2522
+ const rows = boundHosts.map((host) =>
2523
+ `| ${host.id} | ${host.family} | ${host.capabilities.join(', ') || '—'} | ${renderExtensionHostContext(host)} | ${host.stability.toUpperCase()} | ${renderExtensionHostSource(host)} |`,
2524
+ )
2525
+ return [
2526
+ '## UMES hosts',
2527
+ '',
2528
+ '| ID / pattern | Family | Supports | Context | Stability | Source |',
2529
+ '|---|---|---|---|---|---|',
2530
+ ...rows,
2531
+ ].join('\n')
2532
+ }
2533
+
2534
+ function compactContributionDetails(contribution: ModuleExtensionContributionFact): string {
2535
+ const details = contribution.details as unknown as Record<string, unknown>
2536
+ return Object.keys(details).sort((left, right) => left.localeCompare(right)).flatMap((key) => {
2537
+ const value = details[key]
2538
+ if (value === undefined) return []
2539
+ if (Array.isArray(value)) return [`${key}=${value.join(',') || 'none'}`]
2540
+ if (value && typeof value === 'object') {
2541
+ const nested = Object.keys(value as Record<string, unknown>).sort((left, right) => left.localeCompare(right)).map((nestedKey) => {
2542
+ const nestedValue = (value as Record<string, unknown>)[nestedKey]
2543
+ return `${nestedKey}:${Array.isArray(nestedValue) ? nestedValue.join(',') : String(nestedValue)}`
2544
+ })
2545
+ return [`${key}={${nested.join(';')}}`]
2546
+ }
2547
+ return [`${key}=${String(value)}`]
2548
+ }).join('; ')
2549
+ }
2550
+
2551
+ function renderExtensionContributionsSection(extensionSurfaces: ModuleExtensionSurfaceFacts): string {
2552
+ if (extensionSurfaces.contributions.length === 0) return `## UMES contributions\n\n${EMPTY_SECTION_MARKER}`
2553
+ const rows = extensionSurfaces.contributions.map((contribution) => {
2554
+ const targets = contribution.targets.map((entry) => entry.id).join(', ')
2555
+ const resolution = contribution.targets.map((entry) => entry.resolution).join(', ')
2556
+ const phases = [...(contribution.phases ?? []), ...(contribution.operations ?? [])].join(', ') || '—'
2557
+ const source = renderSourceRefLink({ sourcePath: contribution.source.path })
2558
+ return `| ${contribution.id} | ${contribution.kind} | ${targets || '—'} | ${phases} | ${compactContributionDetails(contribution)} | ${resolution || '—'} | ${source} |`
2559
+ })
2560
+ return [
2561
+ '## UMES contributions',
2562
+ '',
2563
+ '| ID | Kind | Target | Phase / operations | Contract | Resolution | Source |',
2564
+ '|---|---|---|---|---|---|---|',
2565
+ ...rows,
2566
+ ].join('\n')
2567
+ }
2568
+
2569
+ function renderExtensionDiagnosticsSection(extensionSurfaces: ModuleExtensionSurfaceFacts): string {
2570
+ const unbound = extensionSurfaces.hosts.filter((host) => !host.bound)
2571
+ if (unbound.length === 0 && extensionSurfaces.unresolved.length === 0) return ''
2572
+ const diagnostics = [
2573
+ ...unbound.map((host) => `- unbound-helper: ${host.id}`),
2574
+ ...extensionSurfaces.unresolved.map((entry) => `- ${entry.reason}: ${entry.key} (${entry.source.path})`),
2575
+ ]
2576
+ return ['## UMES diagnostics', '', ...diagnostics].join('\n')
2577
+ }
2578
+
2579
+ function renderExtensionTargetRef(target: ModuleExtensionTargetRef): string {
2580
+ const method = target.method ? ` ${target.method}` : ''
2581
+ const owner = target.moduleId ? ` @${target.moduleId}` : ''
2582
+ return `${target.kind}:${target.id}${method}${owner}`
2583
+ }
2584
+
2585
+ function renderActivationBindingsSection(extensionSurfaces: ModuleExtensionSurfaceFacts): string {
2586
+ const activations = extensionSurfaces.activations ?? []
2587
+ if (activations.length === 0) return `## Active extension bindings\n\n${EMPTY_SECTION_MARKER}`
2588
+ const rows = activations.map((activation) => {
2589
+ const phases = activation.phases && activation.phases.length > 0 ? activation.phases.join(', ') : '—'
2590
+ const source = activation.source ? renderSourceRefLink(activation.source) : '—'
2591
+ const bridge = activation.bridge ? `${activation.bridge.factSection}:${activation.bridge.factKey}` : '—'
2592
+ return `| ${activation.id} | ${activation.kind} | ${renderExtensionTargetRef(activation.host)} | ${activation.contributionKinds.join(', ') || '—'} | ${phases} | ${bridge} | ${source} |`
2593
+ })
2594
+ return [
2595
+ '## Active extension bindings',
2596
+ '',
2597
+ '| Activation | Kind | Host | Contribution kinds | Phases | Bridge | Source |',
2598
+ '|---|---|---|---|---|---|---|',
2599
+ ...rows,
2600
+ ].join('\n')
2601
+ }
2602
+
2603
+ function renderIncomingContributionsSection(extensionSurfaces: ModuleExtensionSurfaceFacts): string {
2604
+ const incoming = extensionSurfaces.incoming ?? []
2605
+ if (incoming.length === 0) return `## Incoming installed contributions\n\n${EMPTY_SECTION_MARKER}`
2606
+ const rows = incoming.map((entry) => {
2607
+ const activation = entry.activationId ?? '—'
2608
+ const source = entry.source ? renderSourceRefLink(entry.source) : '—'
2609
+ return `| ${entry.contributorModuleId} | ${entry.contributionKind} | ${renderExtensionTargetRef(entry.target)} | ${entry.resolution} | ${activation} | ${entry.contributionId} · ${source} |`
2610
+ })
2611
+ return [
2612
+ '## Incoming installed contributions',
2613
+ '',
2614
+ '| Contributor | Kind | Target | Resolution | Activation | Contribution · Source |',
2615
+ '|---|---|---|---|---|---|',
2616
+ ...rows,
2617
+ ].join('\n')
2618
+ }
2619
+
2620
+ function renderContributionResolutionsSection(
2621
+ extensionSurfaces: ModuleExtensionSurfaceFacts,
2622
+ lookup: FactSourceLookup,
2623
+ ): string {
2624
+ const resolutions = extensionSurfaces.contributionResolutions ?? []
2625
+ if (resolutions.length === 0) return `## Contribution resolutions\n\n${EMPTY_SECTION_MARKER}`
2626
+ const rows = resolutions.map((resolution) => {
2627
+ const activations = resolution.activationIds.length > 0 ? resolution.activationIds.join(', ') : '—'
2628
+ const source = renderLookedUpSource(lookup, 'extension-contribution', resolution.contributionId)
2629
+ return `| ${resolution.contributionId} | ${renderExtensionTargetRef(resolution.target)} | ${resolution.resolution} | ${activations} | ${source} |`
2630
+ })
2631
+ return [
2632
+ '## Contribution resolutions',
2633
+ '',
2634
+ '| Contribution | Target | Resolution | Activations | Source |',
2635
+ '|---|---|---|---|---|',
2636
+ ...rows,
2637
+ ].join('\n')
2638
+ }
2639
+
2640
+ function renderOverrideTargetPath(target: ModuleOverrideTarget): string {
2641
+ let display = 'overrides'
2642
+ for (let index = 0; index < target.path.length; index += 1) {
2643
+ const segment = target.path[index]
2644
+ const isKeyedTerminal = target.key !== undefined && index === target.path.length - 1
2645
+ display += isKeyedTerminal ? `[${JSON.stringify(segment)}]` : `.${segment}`
2646
+ }
2647
+ return display
2648
+ }
2649
+
2650
+ function renderOverrideTargetsSection(targets: readonly ModuleOverrideTarget[] | undefined): string {
2651
+ if (!targets || targets.length === 0) return `## Exact override targets\n\n${EMPTY_SECTION_MARKER}`
2652
+ const header = '| Domain | Path / key | Supported modes | Referenced fact | Source |'
2653
+ const divider = '|---|---|---|---|---|'
2654
+ const rows = targets.map((target) => {
2655
+ const path = `\`${renderOverrideTargetPath(target)}\``
2656
+ const modes = target.modes.join(', ') || '—'
2657
+ const fact = `${target.factRef.factSection}:${target.factRef.factKey}`
2658
+ return `| ${target.domain} | ${path} | ${modes} | ${fact} | ${renderSourceRefLink(target.source)} |`
2659
+ })
2660
+ return ['## Exact override targets', '', header, divider, ...rows].join('\n')
2661
+ }
2662
+
2663
+ export interface ReferenceProjectionFingerprints {
2664
+ sourceFingerprint: string
2665
+ taxonomyFingerprint: string
2666
+ }
2667
+
2668
+ /**
2669
+ * Header for a `local-reference` projection. It never claims a package version — the
2670
+ * module is app-local — and it states in the document itself that the activations and
2671
+ * exact override targets below describe behavior only after the module is opted in,
2672
+ * because the artifact is generated while the module is disabled.
2673
+ */
2674
+ function renderReferenceProjectionHeader(
2675
+ facts: ModuleFacts,
2676
+ fingerprints: ReferenceProjectionFingerprints,
2677
+ ): string[] {
2678
+ return [
2679
+ `# ${facts.module} — module facts (generated reference projection, do not edit)`,
2680
+ `<!-- generated from local reference ${facts.sourceRoot}; source-fingerprint ${fingerprints.sourceFingerprint}; taxonomy-fingerprint ${fingerprints.taxonomyFingerprint} — R1 staleness stamp -->`,
2681
+ `> Reference projection: \`${facts.module}\` is not selected by the current runtime. Its activations and exact override targets describe behavior only after opt-in in \`src/modules.ts\`.`,
2682
+ `Source root: ${renderSourceLink(facts.sourceRoot)}`,
2683
+ ]
2684
+ }
2685
+
2686
+ export function renderModuleFactsMarkdown(
2687
+ facts: ModuleFacts,
2688
+ referenceProjection?: ReferenceProjectionFingerprints,
2689
+ ): string {
2690
+ const extensionSurfaces = facts.extensionSurfaces ?? { hosts: [], contributions: [], unresolved: [] }
2691
+ const lookup = buildFactSourceLookup(facts)
2692
+ const header = facts.sourceKind === 'local-reference' && referenceProjection
2693
+ ? renderReferenceProjectionHeader(facts, referenceProjection)
2694
+ : [
2695
+ `# ${facts.module} — module facts (generated, do not edit)`,
2696
+ renderVersionStamp(facts.coreVersion, facts.sourcePackage, facts.sourceVersion),
2697
+ `Source root: ${renderSourceLink(facts.sourceRoot)}`,
2698
+ ]
874
2699
  const sections = [
875
- `# ${facts.module} — module facts (generated, do not edit)`,
876
- renderVersionStamp(facts.coreVersion),
2700
+ ...header,
877
2701
  '',
878
- renderEntitiesSection(facts.entities),
2702
+ renderEntitiesSection(facts.entities, lookup),
879
2703
  '',
880
- renderEventsSection(facts.events),
2704
+ renderEventsSection(facts.events, lookup),
881
2705
  '',
882
- renderInlineListSection(`## ACL features (${facts.aclFeatures.length})`, facts.aclFeatures),
2706
+ renderSourceLinkedListSection(
2707
+ `## ACL features (${facts.aclFeatures.length})`,
2708
+ facts.aclFeatures,
2709
+ lookup,
2710
+ 'acl-feature',
2711
+ 'Feature',
2712
+ ),
883
2713
  '',
884
2714
  renderApiRoutesSection(facts.apiRoutes),
885
2715
  '',
886
- renderInlineListSection('## DI service tokens', facts.diTokens),
2716
+ renderLinkedFactsSection('## Backend pages', facts.backendPages.map((page) => ({ label: page.path, sourcePath: page.sourcePath }))),
2717
+ '',
2718
+ renderLinkedFactsSection('## Frontend pages', facts.frontendPages.map((page) => ({ label: page.path, sourcePath: page.sourcePath }))),
887
2719
  '',
888
- renderInlineListSection('## Search entities', facts.searchEntities),
2720
+ renderSourceLinkedListSection('## DI service tokens', facts.diTokens, lookup, 'di-registration', 'Token'),
2721
+ '',
2722
+ renderSourceLinkedListSection('## Search entities', facts.searchEntities, lookup, 'search', 'Entity ID'),
889
2723
  '',
890
2724
  renderHostTokensSection(facts.hostTokens),
891
2725
  '',
892
- renderInlineListSection('## Notifications', facts.notifications),
2726
+ renderExtensionHostsSection(extensionSurfaces),
2727
+ '',
2728
+ renderExtensionContributionsSection(extensionSurfaces),
2729
+ '',
2730
+ renderActivationBindingsSection(extensionSurfaces),
893
2731
  '',
894
- renderInlineListSection('## CLI', facts.cli),
2732
+ renderIncomingContributionsSection(extensionSurfaces),
2733
+ '',
2734
+ renderContributionResolutionsSection(extensionSurfaces, lookup),
2735
+ '',
2736
+ renderExtensionDiagnosticsSection(extensionSurfaces),
2737
+ '',
2738
+ renderSourceLinkedListSection('## Notifications', facts.notifications, lookup, 'notification', 'Notification ID'),
2739
+ '',
2740
+ renderLinkedFactsSection('## CLI commands', facts.cliCommands.map((command) => ({ label: command.command, sourcePath: command.sourcePath }))),
2741
+ '',
2742
+ renderLinkedFactsSection('## AI tools / MCP capabilities', facts.aiTools.map((tool) => ({ label: tool.name, sourcePath: tool.sourcePath }))),
2743
+ '',
2744
+ renderLinkedFactsSection('## AI agents', facts.aiAgents.map((agent) => ({ label: agent.id, sourcePath: agent.sourcePath }))),
2745
+ '',
2746
+ renderOverrideTargetsSection(facts.overrideTargets),
895
2747
  '',
896
2748
  ]
2749
+ const owned = facts.ownedContracts ?? {}
2750
+ const ownedSections = [
2751
+ renderOwnedContractSection('## Owned contract — module metadata', owned['module-metadata']),
2752
+ renderOwnedContractSection('## Domain commands', owned.command),
2753
+ renderOwnedContractSection('## Workers', owned.worker),
2754
+ renderOwnedContractSection('## Page middleware', owned['page-middleware']),
2755
+ renderOwnedContractSection('## Setup', owned.setup),
2756
+ renderOwnedContractSection('## Encryption', owned.encryption),
2757
+ renderDiRegistrationsSection(owned['di-registration']),
2758
+ renderOwnedContractSection('## Custom entities', owned['custom-entity']),
2759
+ renderOwnedContractSection('## AI extensions', owned['ai-extension']),
2760
+ renderOwnedContractSection('## Generator plugins', owned['generator-plugin']),
2761
+ ].filter((section) => section.length > 0)
2762
+ for (const section of ownedSections) {
2763
+ sections.push(section, '')
2764
+ }
897
2765
  return sections.join('\n')
898
2766
  }
899
2767
 
@@ -902,8 +2770,18 @@ export function toModuleFactsJsonEntry(facts: ModuleFacts): ModuleFactsJsonEntry
902
2770
  title: facts.title,
903
2771
  description: facts.description,
904
2772
  coreVersion: facts.coreVersion,
2773
+ sourcePackage: facts.sourcePackage,
2774
+ sourceVersion: facts.sourceVersion,
2775
+ sourceRoot: facts.sourceRoot,
2776
+ ...(facts.sourceKind === 'local-reference' ? { sourceKind: facts.sourceKind } : {}),
905
2777
  entities: facts.entities,
906
- events: facts.events.map((event) => ({ id: event.id, category: event.category, entity: event.entity })),
2778
+ events: facts.events.map((event) => ({
2779
+ id: event.id,
2780
+ category: event.category,
2781
+ entity: event.entity,
2782
+ ...(event.clientBroadcast !== undefined ? { clientBroadcast: event.clientBroadcast } : {}),
2783
+ ...(event.portalBroadcast !== undefined ? { portalBroadcast: event.portalBroadcast } : {}),
2784
+ })),
907
2785
  aclFeatures: facts.aclFeatures,
908
2786
  apiRoutes: facts.apiRoutes,
909
2787
  diTokens: facts.diTokens,
@@ -911,6 +2789,25 @@ export function toModuleFactsJsonEntry(facts: ModuleFacts): ModuleFactsJsonEntry
911
2789
  hostTokens: facts.hostTokens,
912
2790
  notifications: facts.notifications,
913
2791
  cli: facts.cli,
2792
+ backendPages: facts.backendPages,
2793
+ frontendPages: facts.frontendPages,
2794
+ cliCommands: facts.cliCommands,
2795
+ aiTools: facts.aiTools,
2796
+ aiAgents: facts.aiAgents,
2797
+ ...(facts.extensionSurfaces ? { extensionSurfaces: facts.extensionSurfaces } : {}),
2798
+ ...(facts.factSources && facts.factSources.length > 0 ? { factSources: facts.factSources } : {}),
2799
+ ...(facts.ownedContracts && Object.keys(facts.ownedContracts).length > 0
2800
+ ? { ownedContracts: facts.ownedContracts }
2801
+ : {}),
2802
+ ...(facts.factDiagnostics && facts.factDiagnostics.length > 0
2803
+ ? { factDiagnostics: facts.factDiagnostics }
2804
+ : {}),
2805
+ ...(facts.overrideTargets && facts.overrideTargets.length > 0
2806
+ ? { overrideTargets: facts.overrideTargets }
2807
+ : {}),
2808
+ ...(facts.overrideTargetDiagnostics && facts.overrideTargetDiagnostics.length > 0
2809
+ ? { overrideTargetDiagnostics: facts.overrideTargetDiagnostics }
2810
+ : {}),
914
2811
  }
915
2812
  }
916
2813
 
@@ -928,6 +2825,265 @@ export function renderModuleFactsJson(factsByModule: Record<string, ModuleFacts>
928
2825
  return `${JSON.stringify(buildModuleFactsJsonObject(factsByModule), null, 2)}\n`
929
2826
  }
930
2827
 
2828
+ /**
2829
+ * The normal package sidecar (`module-facts.json`) and the enabled-filtered package
2830
+ * markdown subset carry package-provided modules only. An app-local module reaching
2831
+ * either output would publish `bound` activations and exact override targets for code
2832
+ * the runtime does not select, so it fails the build instead of shipping.
2833
+ */
2834
+ export function assertPackageModuleFactsOnly(factsByModule: Record<string, ModuleFacts>): void {
2835
+ const offenders = Object.values(factsByModule)
2836
+ .filter((facts) => facts.sourceKind === 'local-reference' || !facts.sourceRoot.startsWith('node_modules/'))
2837
+ .map((facts) => `${facts.module} (${facts.sourceRoot})`)
2838
+ .sort((left, right) => left.localeCompare(right))
2839
+ if (offenders.length > 0) {
2840
+ throw new Error(
2841
+ `[module-facts] normal package output must not contain app-local modules: ${offenders.join(', ')}`,
2842
+ )
2843
+ }
2844
+ }
2845
+
2846
+ function sha256Hex(value: string | Buffer): string {
2847
+ return createHash('sha256').update(value).digest('hex')
2848
+ }
2849
+
2850
+ /**
2851
+ * Canonical form used by both reference fingerprints: object keys are recursively sorted
2852
+ * and arrays are sorted by their own canonical text. Every array a fact entry emits is
2853
+ * set-valued (capabilities, phases, operations, modes, per-section fact lists), so a
2854
+ * single ordering rule keeps the fingerprint stable against extractor iteration order
2855
+ * without inventing per-field exceptions.
2856
+ */
2857
+ function canonicalizeFingerprintValue(value: unknown): unknown {
2858
+ if (Array.isArray(value)) {
2859
+ return value
2860
+ .map((item) => {
2861
+ const canonical = canonicalizeFingerprintValue(item)
2862
+ return { canonical, text: JSON.stringify(canonical) ?? '' }
2863
+ })
2864
+ .sort((left, right) => (left.text < right.text ? -1 : left.text > right.text ? 1 : 0))
2865
+ .map((item) => item.canonical)
2866
+ }
2867
+ if (value !== null && typeof value === 'object') {
2868
+ const record = value as Record<string, unknown>
2869
+ const result: Record<string, unknown> = {}
2870
+ for (const key of Object.keys(record).sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))) {
2871
+ if (record[key] === undefined) continue
2872
+ result[key] = canonicalizeFingerprintValue(record[key])
2873
+ }
2874
+ return result
2875
+ }
2876
+ return value
2877
+ }
2878
+
2879
+ function canonicalFingerprintJson(value: unknown): string {
2880
+ return JSON.stringify(canonicalizeFingerprintValue(value))
2881
+ }
2882
+
2883
+ const REFERENCE_FINGERPRINT_SKIPPED_DIRECTORIES = new Set(['__tests__', '__integration__', 'node_modules'])
2884
+
2885
+ /**
2886
+ * SHA-256 over the canonical JSON of `{ path, sha256 }` records sorted by path, where
2887
+ * `path` is the portable app-relative path so the value is identical across the
2888
+ * authoring, template, and emitted roots. Test-only directories are excluded because the
2889
+ * scaffold does not emit them, so including them would make the emitted app's recomputed
2890
+ * fingerprint disagree with the one built from the template.
2891
+ */
2892
+ export function computeModuleSourceFingerprint(moduleRoot: string, portableSourceRoot: string): string {
2893
+ assertPortableSourceRoot(path.basename(portableSourceRoot), portableSourceRoot)
2894
+ const records: { path: string; sha256: string }[] = []
2895
+ const walk = (directory: string): void => {
2896
+ const entries = fs.readdirSync(directory, { withFileTypes: true })
2897
+ for (const entry of entries) {
2898
+ const fullPath = path.join(directory, entry.name)
2899
+ if (entry.isDirectory()) {
2900
+ if (REFERENCE_FINGERPRINT_SKIPPED_DIRECTORIES.has(entry.name)) continue
2901
+ walk(fullPath)
2902
+ continue
2903
+ }
2904
+ if (!entry.isFile()) continue
2905
+ records.push({
2906
+ path: toPortableSourcePath(moduleRoot, portableSourceRoot, fullPath),
2907
+ sha256: sha256Hex(fs.readFileSync(fullPath)),
2908
+ })
2909
+ }
2910
+ }
2911
+ walk(moduleRoot)
2912
+ records.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
2913
+ return sha256Hex(canonicalFingerprintJson(records))
2914
+ }
2915
+
2916
+ /**
2917
+ * Provenance and release metadata are not taxonomy: a version bump must not invalidate
2918
+ * the classification fingerprint. Every remaining section of the emitted entry is a
2919
+ * classified set and contributes.
2920
+ */
2921
+ const TAXONOMY_EXCLUDED_SECTIONS = new Set([
2922
+ 'title',
2923
+ 'description',
2924
+ 'coreVersion',
2925
+ 'sourcePackage',
2926
+ 'sourceVersion',
2927
+ 'sourceRoot',
2928
+ 'sourceKind',
2929
+ ])
2930
+
2931
+ function collectTaxonomyRecords(
2932
+ setName: string,
2933
+ value: unknown,
2934
+ records: { setName: string; value: unknown }[],
2935
+ ): void {
2936
+ if (value === undefined) return
2937
+ if (Array.isArray(value)) {
2938
+ for (const item of value) records.push({ setName, value: canonicalizeFingerprintValue(item) })
2939
+ return
2940
+ }
2941
+ if (value !== null && typeof value === 'object') {
2942
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
2943
+ collectTaxonomyRecords(`${setName}.${key}`, child, records)
2944
+ }
2945
+ return
2946
+ }
2947
+ records.push({ setName, value })
2948
+ }
2949
+
2950
+ /**
2951
+ * SHA-256 over the canonical JSON of `{ setName, value }` records sorted by set name and
2952
+ * canonicalized value. Each record carries a complete literal or structured entry — key,
2953
+ * suffix, capabilities, `bound`, phases, operations included — so no delimiter-joined
2954
+ * tuple can collapse two distinct classifications into one fingerprint input.
2955
+ */
2956
+ export function computeModuleTaxonomyFingerprint(entry: ModuleFactsJsonEntry): string {
2957
+ const records: { setName: string; value: unknown }[] = []
2958
+ for (const [setName, value] of Object.entries(entry as unknown as Record<string, unknown>)) {
2959
+ if (TAXONOMY_EXCLUDED_SECTIONS.has(setName)) continue
2960
+ collectTaxonomyRecords(setName, value, records)
2961
+ }
2962
+ const decorated = records
2963
+ .map((record) => ({ record, text: JSON.stringify(record.value) ?? '' }))
2964
+ .sort((left, right) =>
2965
+ left.record.setName < right.record.setName
2966
+ ? -1
2967
+ : left.record.setName > right.record.setName
2968
+ ? 1
2969
+ : left.text < right.text
2970
+ ? -1
2971
+ : left.text > right.text
2972
+ ? 1
2973
+ : 0,
2974
+ )
2975
+ return sha256Hex(JSON.stringify(decorated.map((item) => item.record)))
2976
+ }
2977
+
2978
+ export interface ReferenceModuleFactsEntry {
2979
+ moduleId: string
2980
+ projectionKind: 'activated-reference'
2981
+ sourceKind: 'local-reference'
2982
+ runtimeSelected: false
2983
+ sourceFingerprint: string
2984
+ taxonomyFingerprint: string
2985
+ facts: ModuleFactsJsonEntry
2986
+ }
2987
+
2988
+ export type ReferenceModuleFactsJson = Record<string, ReferenceModuleFactsEntry>
2989
+
2990
+ export function renderReferenceModuleFactsJson(bundle: ReferenceModuleFactsJson): string {
2991
+ const ordered: ReferenceModuleFactsJson = {}
2992
+ for (const moduleId of Object.keys(bundle).sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))) {
2993
+ ordered[moduleId] = bundle[moduleId]
2994
+ }
2995
+ return `${JSON.stringify(ordered, null, 2)}\n`
2996
+ }
2997
+
2998
+ export interface ExtractLocalReferenceModuleFactsOptions {
2999
+ /** Package-provided sources, exactly as the normal batch receives them. */
3000
+ packageSources: readonly ModuleFactSource[]
3001
+ /** The app-local module projected into the reference bundle. */
3002
+ reference: ModuleFactSource
3003
+ registryPath?: string | null
3004
+ registrySource?: string | null
3005
+ coreVersion?: string | null
3006
+ }
3007
+
3008
+ export interface LocalReferenceModuleFactsResult {
3009
+ entry: ReferenceModuleFactsEntry
3010
+ markdown: string
3011
+ warnings: string[]
3012
+ /**
3013
+ * First-party targets the activated projection could not correlate. They also remain in
3014
+ * the emitted `facts.extensionSurfaces.unresolved`, so the artifact states its own gaps
3015
+ * rather than presenting a partial surface as complete.
3016
+ */
3017
+ unresolvedTargets: string[]
3018
+ }
3019
+
3020
+ /**
3021
+ * Produces the reference projection of an app-local module from an explicitly activated,
3022
+ * disposable selection context: the local source is appended to the package sources and
3023
+ * the whole set runs through the same `extractAllModuleFacts` selection and correlation
3024
+ * batch, so incoming contributions and optional targets resolve exactly as they would
3025
+ * after opt-in. Merging an independently extracted object into a finished batch is
3026
+ * forbidden — it would silently drop both resolutions. The batch is disposable: the
3027
+ * caller keeps only this module's projection and never writes the rest, which is why the
3028
+ * normal package outputs stay byte-identical.
3029
+ */
3030
+ export function extractLocalReferenceModuleFacts(
3031
+ options: ExtractLocalReferenceModuleFactsOptions,
3032
+ ): LocalReferenceModuleFactsResult {
3033
+ const { reference } = options
3034
+ if (reference.sourceKind !== 'local-reference') {
3035
+ throw new Error(
3036
+ `[module-facts] reference source "${reference.moduleId}" must declare sourceKind "local-reference"`,
3037
+ )
3038
+ }
3039
+ const portableSourceRoot = reference.portableSourceRoot
3040
+ if (!portableSourceRoot) {
3041
+ throw new Error(`[module-facts] reference source "${reference.moduleId}" must declare portableSourceRoot`)
3042
+ }
3043
+ assertPortableSourceRoot(reference.moduleId, portableSourceRoot)
3044
+
3045
+ const sources = appendLocalReferenceModuleSource(options.packageSources, reference)
3046
+ const activated = extractAllModuleFacts({
3047
+ sources,
3048
+ registryPath: options.registryPath ?? null,
3049
+ registrySource: options.registrySource ?? null,
3050
+ coreVersion: options.coreVersion ?? null,
3051
+ // The disposable projection collects rather than throws: an app-local module can name
3052
+ // a target whose classification is still missing from the shared extension reader, and
3053
+ // aborting there would delete the whole reference bundle instead of publishing the
3054
+ // exact gap. Normal package output keeps the strict default.
3055
+ unresolvedFirstPartyTargets: 'collect',
3056
+ })
3057
+ const facts = activated.factsByModule[reference.moduleId]
3058
+ if (!facts) {
3059
+ throw new Error(`[module-facts] reference module "${reference.moduleId}" produced no facts`)
3060
+ }
3061
+ if (facts.sourceRoot !== portableSourceRoot) {
3062
+ throw new Error(
3063
+ `[module-facts] reference module "${reference.moduleId}" emitted non-portable source root "${facts.sourceRoot}"`,
3064
+ )
3065
+ }
3066
+
3067
+ const jsonEntry = toModuleFactsJsonEntry(facts)
3068
+ const fingerprints: ReferenceProjectionFingerprints = {
3069
+ sourceFingerprint: computeModuleSourceFingerprint(reference.moduleRoot, portableSourceRoot),
3070
+ taxonomyFingerprint: computeModuleTaxonomyFingerprint(jsonEntry),
3071
+ }
3072
+ return {
3073
+ entry: {
3074
+ moduleId: reference.moduleId,
3075
+ projectionKind: 'activated-reference',
3076
+ sourceKind: 'local-reference',
3077
+ runtimeSelected: false,
3078
+ ...fingerprints,
3079
+ facts: jsonEntry,
3080
+ },
3081
+ markdown: renderModuleFactsMarkdown(facts, fingerprints),
3082
+ warnings: activated.warnings,
3083
+ unresolvedTargets: activated.unresolvedFirstPartyTargets,
3084
+ }
3085
+ }
3086
+
931
3087
  export interface ExtractAllModuleFactsOptions {
932
3088
  /**
933
3089
  * Discovered module sources (auto-discovery path). When provided, each entry's
@@ -941,15 +3097,46 @@ export interface ExtractAllModuleFactsOptions {
941
3097
  coreVersion?: string | null
942
3098
  /** @deprecated Legacy explicit module-id list; only consulted when `sources` is absent. */
943
3099
  moduleIds?: readonly string[]
3100
+ /**
3101
+ * `throw` (the default, and the only value any normal package output uses) aborts the
3102
+ * batch when a first-party extension target does not correlate. `collect` keeps the
3103
+ * unresolved entries in `extensionSurfaces.unresolved` and reports them on the result
3104
+ * so a disposable projection can publish the gap instead of silently dropping it.
3105
+ */
3106
+ unresolvedFirstPartyTargets?: 'throw' | 'collect'
944
3107
  }
945
3108
 
946
3109
  export interface ExtractAllModuleFactsResult {
947
3110
  factsByModule: Record<string, ModuleFacts>
948
3111
  markdownByModule: Record<string, string>
949
3112
  warnings: string[]
3113
+ frameworkMarkdown: string
3114
+ /** Always empty under the default `throw` policy, which aborts before returning. */
3115
+ unresolvedFirstPartyTargets: string[]
3116
+ }
3117
+
3118
+ /**
3119
+ * `<moduleId>:<contributionId>:<targetId>` for every first-party contribution target that
3120
+ * did not correlate — the same identity {@link assertNoUnresolvedExtensionTargets} throws
3121
+ * with, so a collected batch reports exactly what a strict batch would have refused.
3122
+ */
3123
+ export function collectUnresolvedFirstPartyTargets(
3124
+ surfacesByModule: Readonly<Record<string, ModuleExtensionSurfaceFacts>>,
3125
+ ): string[] {
3126
+ return Object.entries(surfacesByModule)
3127
+ .flatMap(([moduleId, surface]) =>
3128
+ surface.unresolved
3129
+ .filter((entry) => entry.reason === 'unresolved-first-party-target')
3130
+ .map((entry) => `${moduleId}:${entry.key}`),
3131
+ )
3132
+ .sort((left, right) => left.localeCompare(right))
950
3133
  }
951
3134
 
952
3135
  export function extractAllModuleFacts(options: ExtractAllModuleFactsOptions): ExtractAllModuleFactsResult {
3136
+ return withModuleExtensionFactExtractionCache(() => extractAllModuleFactsWithCache(options))
3137
+ }
3138
+
3139
+ function extractAllModuleFactsWithCache(options: ExtractAllModuleFactsOptions): ExtractAllModuleFactsResult {
953
3140
  const sources: ModuleFactSource[] = options.sources
954
3141
  ? [...options.sources]
955
3142
  : (options.coreSrcRoot
@@ -967,12 +3154,74 @@ export function extractAllModuleFacts(options: ExtractAllModuleFactsOptions): Ex
967
3154
  moduleId: source.moduleId,
968
3155
  moduleRoot: source.moduleRoot,
969
3156
  coreVersion: options.coreVersion ?? null,
3157
+ sourcePackage: source.from ?? null,
3158
+ sourceVersion: source.packageVersion ?? null,
970
3159
  registryPath: options.registryPath ?? null,
971
3160
  registrySource: options.registrySource ?? null,
3161
+ ...(source.portableSourceRoot ? { portableSourceRoot: source.portableSourceRoot } : {}),
3162
+ ...(source.sourceKind ? { sourceKind: source.sourceKind } : {}),
972
3163
  })
973
3164
  factsByModule[source.moduleId] = facts
974
- markdownByModule[source.moduleId] = renderModuleFactsMarkdown(facts)
975
3165
  warnings.push(...facts.warnings)
976
3166
  }
977
- return { factsByModule, markdownByModule, warnings }
3167
+ const surfacesByModule = Object.fromEntries(
3168
+ Object.entries(factsByModule).map(([moduleId, facts]) => [
3169
+ moduleId,
3170
+ facts.extensionSurfaces ?? { hosts: [], contributions: [], unresolved: [] },
3171
+ ]),
3172
+ )
3173
+ const correlated = correlateModuleExtensionFacts({
3174
+ surfacesByModule,
3175
+ entityIds: new Set(Object.values(factsByModule).flatMap((facts) => facts.entities.map((entity) => entity.id))),
3176
+ eventIds: new Set(Object.values(factsByModule).flatMap((facts) => facts.events.map((event) => event.id))),
3177
+ apiRoutes: new Set([
3178
+ ...Object.values(factsByModule).flatMap((facts) => facts.apiRoutes.map((route) => route.path)),
3179
+ ...sources.flatMap((source) => extractKnownApiRouteIds(source.moduleId, source.moduleRoot)),
3180
+ ]),
3181
+ commandIds: new Set(sources.flatMap((source) => extractKnownCommandIds(source.moduleId, source.moduleRoot))),
3182
+ })
3183
+ const unresolvedFirstPartyTargets = collectUnresolvedFirstPartyTargets(correlated)
3184
+ if ((options.unresolvedFirstPartyTargets ?? 'throw') === 'throw') assertNoUnresolvedExtensionTargets(correlated)
3185
+ const apiRouteOwners = new Map<string, {
3186
+ moduleId: string
3187
+ source: ModuleFactSourceRef
3188
+ bridges: ApiRouteInterceptorBridge[]
3189
+ }>()
3190
+ const commandOwners = new Map<string, { moduleId: string; source: ModuleFactSourceRef }>()
3191
+ for (const source of [...sources].sort((left, right) => left.moduleId.localeCompare(right.moduleId))) {
3192
+ const facts = factsByModule[source.moduleId]
3193
+ const sourceRoot = facts ? facts.sourceRoot : source.moduleId
3194
+ const owners = extractActivationTargetOwners({
3195
+ moduleId: source.moduleId,
3196
+ moduleRoot: source.moduleRoot,
3197
+ sourceRoot,
3198
+ })
3199
+ for (const route of owners.apiRoutes) {
3200
+ if (!apiRouteOwners.has(route.id)) {
3201
+ apiRouteOwners.set(route.id, { moduleId: source.moduleId, source: route.source, bridges: route.bridges })
3202
+ }
3203
+ }
3204
+ for (const command of owners.commands) {
3205
+ if (!commandOwners.has(command.id)) commandOwners.set(command.id, { moduleId: source.moduleId, source: command.source })
3206
+ }
3207
+ }
3208
+ const withIncoming = correlateIncomingExtensions({
3209
+ surfacesByModule: correlated,
3210
+ apiRouteOwners,
3211
+ commandOwners,
3212
+ })
3213
+ for (const moduleId of Object.keys(factsByModule).sort((left, right) => left.localeCompare(right))) {
3214
+ factsByModule[moduleId].extensionSurfaces = withIncoming[moduleId]
3215
+ markdownByModule[moduleId] = renderModuleFactsMarkdown(factsByModule[moduleId])
3216
+ warnings.push(...correlated[moduleId].unresolved.map((entry) =>
3217
+ `[module-facts] ${moduleId} ${entry.reason}: ${entry.key} (${entry.source.path})`,
3218
+ ))
3219
+ }
3220
+ return {
3221
+ factsByModule,
3222
+ markdownByModule,
3223
+ warnings,
3224
+ frameworkMarkdown: renderFrameworkExtensionPointsMarkdown(),
3225
+ unresolvedFirstPartyTargets,
3226
+ }
978
3227
  }