@open-mercato/cli 0.6.7 → 0.6.8-canary.320.1.bf889f49e4

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 (335) hide show
  1. package/.turbo/turbo-build.log +77 -4
  2. package/AGENTS.md +6 -1
  3. package/build.mjs +68 -11
  4. package/dist/agentic/claude-code/hooks/entity-migration-check.ts +32 -88
  5. package/dist/agentic/codex/enforcement-rules.md +2 -16
  6. package/dist/agentic/cursor/hooks/entity-migration-check.mjs +19 -50
  7. package/dist/agentic/cursor/rules/entity-guard.mdc +9 -21
  8. package/dist/agentic/cursor/rules/generated-guard.mdc +3 -5
  9. package/dist/agentic/cursor/rules/open-mercato.mdc +3 -29
  10. package/dist/agentic/guides/ai-workflows.md +76 -0
  11. package/dist/agentic/guides/architecture.md +107 -0
  12. package/dist/agentic/guides/backend-ui.md +86 -0
  13. package/dist/agentic/guides/contracts.md +92 -0
  14. package/dist/agentic/guides/extensions.md +121 -0
  15. package/dist/agentic/guides/framework-extension-points.md +36 -0
  16. package/dist/agentic/guides/integrations.md +81 -0
  17. package/dist/agentic/guides/module-facts.json +116757 -3951
  18. package/dist/agentic/guides/module-system.md +12 -127
  19. package/dist/agentic/guides/modules/ai_assistant.md +227 -28
  20. package/dist/agentic/guides/modules/api_docs.md +58 -2
  21. package/dist/agentic/guides/modules/api_keys.md +92 -7
  22. package/dist/agentic/guides/modules/attachments.md +119 -14
  23. package/dist/agentic/guides/modules/audit_logs.md +131 -10
  24. package/dist/agentic/guides/modules/auth.md +276 -33
  25. package/dist/agentic/guides/modules/business_rules.md +149 -14
  26. package/dist/agentic/guides/modules/catalog.md +482 -43
  27. package/dist/agentic/guides/modules/channel_gmail.md +82 -3
  28. package/dist/agentic/guides/modules/channel_imap.md +82 -3
  29. package/dist/agentic/guides/modules/checkout.md +305 -27
  30. package/dist/agentic/guides/modules/communication_channels.md +315 -33
  31. package/dist/agentic/guides/modules/configs.md +104 -8
  32. package/dist/agentic/guides/modules/content.md +57 -2
  33. package/dist/agentic/guides/modules/currencies.md +152 -17
  34. package/dist/agentic/guides/modules/customer_accounts.md +387 -45
  35. package/dist/agentic/guides/modules/customers.md +746 -85
  36. package/dist/agentic/guides/modules/dashboards.md +155 -10
  37. package/dist/agentic/guides/modules/data_sync.md +149 -16
  38. package/dist/agentic/guides/modules/design_system.md +102 -0
  39. package/dist/agentic/guides/modules/dictionaries.md +107 -12
  40. package/dist/agentic/guides/modules/directory.md +148 -15
  41. package/dist/agentic/guides/modules/entities.md +145 -13
  42. package/dist/agentic/guides/modules/events.md +59 -2
  43. package/dist/agentic/guides/modules/feature_toggles.md +119 -9
  44. package/dist/agentic/guides/modules/gateway_stripe.md +92 -4
  45. package/dist/agentic/guides/modules/generators.md +45 -2
  46. package/dist/agentic/guides/modules/inbox_ops.md +207 -27
  47. package/dist/agentic/guides/modules/integrations.md +149 -16
  48. package/dist/agentic/guides/modules/messages.md +223 -24
  49. package/dist/agentic/guides/modules/notifications.md +96 -8
  50. package/dist/agentic/guides/modules/onboarding.md +77 -6
  51. package/dist/agentic/guides/modules/payment_gateways.md +168 -21
  52. package/dist/agentic/guides/modules/perspectives.md +77 -7
  53. package/dist/agentic/guides/modules/planner.md +136 -18
  54. package/dist/agentic/guides/modules/portal.md +93 -2
  55. package/dist/agentic/guides/modules/progress.md +104 -14
  56. package/dist/agentic/guides/modules/query_index.md +154 -15
  57. package/dist/agentic/guides/modules/record_locks.md +206 -21
  58. package/dist/agentic/guides/modules/resources.md +197 -30
  59. package/dist/agentic/guides/modules/sales.md +687 -84
  60. package/dist/agentic/guides/modules/scheduler.md +133 -14
  61. package/dist/agentic/guides/modules/search.md +170 -6
  62. package/dist/agentic/guides/modules/security.md +294 -35
  63. package/dist/agentic/guides/modules/shipping_carriers.md +134 -18
  64. package/dist/agentic/guides/modules/sso.md +151 -26
  65. package/dist/agentic/guides/modules/staff.md +372 -57
  66. package/dist/agentic/guides/modules/storage_s3.md +95 -5
  67. package/dist/agentic/guides/modules/sync_akeneo.md +85 -3
  68. package/dist/agentic/guides/modules/sync_excel.md +75 -6
  69. package/dist/agentic/guides/modules/system_status_overlays.md +60 -2
  70. package/dist/agentic/guides/modules/translations.md +104 -10
  71. package/dist/agentic/guides/modules/webhooks.md +203 -24
  72. package/dist/agentic/guides/modules/wms.md +469 -47
  73. package/dist/agentic/guides/modules/workflows.md +302 -43
  74. package/dist/agentic/guides/reference-module-facts.json +6942 -0
  75. package/dist/agentic/guides/reference-modules/example.md +462 -0
  76. package/dist/agentic/guides/spec-delivery.md +56 -0
  77. package/dist/agentic/guides/testing-debugging.md +80 -0
  78. package/dist/agentic/guides/upstream/AGENTS.md +300 -0
  79. package/dist/agentic/guides/upstream/BACKWARD_COMPATIBILITY.md +334 -0
  80. package/dist/agentic/guides/upstream/manifest.json +8 -0
  81. package/dist/agentic/shared/AGENTS.md.template +120 -311
  82. package/dist/agentic/shared/ai/agentic.config.json +2 -0
  83. package/dist/agentic/shared/ai/harness/README.md +50 -0
  84. package/dist/agentic/shared/ai/harness/RELEASE.md +59 -0
  85. package/dist/agentic/shared/ai/harness/cases.json +20596 -0
  86. package/dist/agentic/shared/ai/harness/cases.schema.json +196 -0
  87. package/dist/agentic/shared/ai/harness/design-system-inventory.json +6326 -0
  88. package/dist/agentic/shared/ai/harness/fixtures/index.json +51 -0
  89. package/dist/agentic/shared/ai/harness/fixtures/seeds.json +166 -0
  90. package/dist/agentic/shared/ai/harness/generated-code-review-policy.md +21 -0
  91. package/dist/agentic/shared/ai/harness/generated-code-review-response.schema.json +96 -0
  92. package/dist/agentic/shared/ai/harness/generated-code-review-result.schema.json +172 -0
  93. package/dist/agentic/shared/ai/harness/knowledge-change.schema.json +228 -0
  94. package/dist/agentic/shared/ai/harness/release-matrix.json +163 -0
  95. package/dist/agentic/shared/ai/harness/release-result.schema.json +219 -0
  96. package/dist/agentic/shared/ai/harness/result.schema.json +136 -0
  97. package/dist/agentic/shared/ai/harness/routing-response.schema.json +22 -0
  98. package/dist/agentic/shared/ai/harness/source-link-inventory.json +1706 -0
  99. package/dist/agentic/shared/ai/harness/target-validation-result.schema.json +73 -0
  100. package/dist/agentic/shared/ai/harness/validators.json +82 -0
  101. package/dist/agentic/shared/ai/harness/writable-ast-oracles.mjs +2121 -0
  102. package/dist/agentic/shared/ai/harness/writable-behavior-oracles.mjs +1096 -0
  103. package/dist/agentic/shared/ai/harness/writable-spec-oracles.mjs +602 -0
  104. package/dist/agentic/shared/ai/lessons/_template.md +16 -0
  105. package/dist/agentic/shared/ai/lessons.md +26 -3
  106. package/dist/agentic/shared/ai/review-checklist.md +36 -0
  107. package/dist/agentic/shared/ai/skills/om-auto-implement-spec/SKILL.md +21 -0
  108. package/dist/agentic/shared/ai/skills/om-auto-review-pr/SKILL.md +4 -0
  109. package/dist/agentic/shared/ai/skills/om-auto-upgrade-0.6.6-to-0.6.7/SKILL.md +98 -0
  110. package/dist/agentic/shared/ai/skills/om-backend-ui-design/SKILL.md +21 -241
  111. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/crud-surfaces.md +31 -0
  112. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/frontend-and-design-system.md +53 -0
  113. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/page-and-navigation.md +15 -0
  114. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/quality-states.md +11 -0
  115. package/dist/agentic/shared/ai/skills/om-build-workflow/SKILL.md +30 -0
  116. package/dist/agentic/shared/ai/skills/om-build-workflow/references/activity-contracts.md +12 -0
  117. package/dist/agentic/shared/ai/skills/om-build-workflow/references/durability-and-progress.md +13 -0
  118. package/dist/agentic/shared/ai/skills/om-build-workflow/references/workflow-design.md +14 -0
  119. package/dist/agentic/shared/ai/skills/om-create-ai-agent/SKILL.md +28 -0
  120. package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/attachments-and-overrides.md +12 -0
  121. package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/module-agents-and-tools.md +22 -0
  122. package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/orchestrator-agents.md +12 -0
  123. package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/surface-selector.md +19 -0
  124. package/dist/agentic/shared/ai/skills/om-data-model-design/SKILL.md +17 -601
  125. package/dist/agentic/shared/ai/skills/om-data-model-design/references/integrity-and-concurrency.md +21 -0
  126. package/dist/agentic/shared/ai/skills/om-data-model-design/references/migration-workflow.md +15 -0
  127. package/dist/agentic/shared/ai/skills/om-data-model-design/references/schema-design.md +16 -0
  128. package/dist/agentic/shared/ai/skills/om-data-model-design/references/sensitive-data.md +10 -0
  129. package/dist/agentic/shared/ai/skills/om-eject-and-customize/SKILL.md +15 -310
  130. package/dist/agentic/shared/ai/skills/om-eject-and-customize/references/decision-and-procedure.md +19 -0
  131. package/dist/agentic/shared/ai/skills/om-evolve-harness/SKILL.md +31 -0
  132. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-template.md +100 -0
  133. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-workflow.md +16 -0
  134. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/knowledge-change.md +133 -0
  135. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/owner-selection.md +17 -0
  136. package/dist/agentic/shared/ai/skills/om-framework-context/SKILL.md +25 -0
  137. package/dist/agentic/shared/ai/skills/om-framework-context/references/resolver-procedure.md +11 -0
  138. package/dist/agentic/shared/ai/skills/om-framework-context/references/skew-and-escalation.md +13 -0
  139. package/dist/agentic/shared/ai/skills/om-help/SKILL.md +15 -97
  140. package/dist/agentic/shared/ai/skills/om-help/references/delivery-workflows.md +18 -0
  141. package/dist/agentic/shared/ai/skills/om-help/references/task-families.md +20 -0
  142. package/dist/agentic/shared/ai/skills/om-implement-spec/SKILL.md +33 -152
  143. package/dist/agentic/shared/ai/skills/om-implement-spec/references/phases-and-gates.md +33 -0
  144. package/dist/agentic/shared/ai/skills/om-implement-spec/references/planning-and-progress.md +40 -0
  145. package/dist/agentic/shared/ai/skills/om-implement-spec/references/report-templates.md +27 -0
  146. package/dist/agentic/shared/ai/skills/om-implement-spec/references/spec-resolution.md +24 -0
  147. package/dist/agentic/shared/ai/skills/om-integration-builder/SKILL.md +25 -708
  148. package/dist/agentic/shared/ai/skills/om-integration-builder/references/package-and-activation.md +16 -0
  149. package/dist/agentic/shared/ai/skills/om-integration-builder/references/provider-families.md +25 -0
  150. package/dist/agentic/shared/ai/skills/om-integration-builder/references/security-and-reliability.md +15 -0
  151. package/dist/agentic/shared/ai/skills/om-integration-builder/references/sync-and-files.md +12 -0
  152. package/dist/agentic/shared/ai/skills/om-judge-agent-session/SKILL.md +34 -0
  153. package/dist/agentic/shared/ai/skills/om-judge-agent-session/agents/openai.yaml +4 -0
  154. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/agentic-setup.md +11 -0
  155. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/input-normalization.md +31 -0
  156. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/judge-workflow.md +39 -0
  157. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/report-template.md +43 -0
  158. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/rules.md +11 -0
  159. package/dist/agentic/shared/ai/skills/om-module-scaffold/SKILL.md +20 -820
  160. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/api-and-domain.md +32 -0
  161. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/business-one-shot-blueprints.md +172 -0
  162. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/data-and-migrations.md +14 -0
  163. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/discovery-surface-catalog.md +83 -0
  164. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/module-surfaces.md +40 -0
  165. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/planning.md +11 -0
  166. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/runtime-cache-and-queues.md +22 -0
  167. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/verification.md +13 -0
  168. package/dist/agentic/shared/ai/skills/om-share-this-session/SKILL.md +41 -0
  169. package/dist/agentic/shared/ai/skills/om-share-this-session/references/agentic-setup.md +19 -0
  170. package/dist/agentic/shared/ai/skills/om-share-this-session/references/bundle-preparation.md +44 -0
  171. package/dist/agentic/shared/ai/skills/om-share-this-session/references/consent-and-review.md +39 -0
  172. package/dist/agentic/shared/ai/skills/om-share-this-session/references/publication.md +20 -0
  173. package/dist/agentic/shared/ai/skills/om-share-this-session/references/report-templates.md +57 -0
  174. package/dist/agentic/shared/ai/skills/om-share-this-session/references/rules.md +11 -0
  175. package/dist/agentic/shared/ai/skills/om-share-this-session/scripts/prepare-share-bundle.mjs +486 -0
  176. package/dist/agentic/shared/ai/skills/om-system-extension/SKILL.md +20 -843
  177. package/dist/agentic/shared/ai/skills/om-system-extension/references/extension-branches.md +42 -0
  178. package/dist/agentic/shared/ai/skills/om-system-extension/references/mechanism-selector.md +27 -0
  179. package/dist/agentic/shared/ai/skills/om-system-extension/references/read-write-roundtrip.md +13 -0
  180. package/dist/agentic/shared/ai/skills/om-system-extension/references/unified-overrides.md +43 -0
  181. package/dist/agentic/shared/ai/skills/om-system-extension/references/verification.md +11 -0
  182. package/dist/agentic/shared/ai/skills/om-trim-unused-modules/SKILL.md +15 -63
  183. package/dist/agentic/shared/ai/skills/om-trim-unused-modules/references/dependency-and-redirect.md +15 -0
  184. package/dist/agentic/shared/ai/skills/om-troubleshooter/SKILL.md +17 -467
  185. package/dist/agentic/shared/ai/skills/om-troubleshooter/references/diagnosis-map.md +29 -0
  186. package/dist/agentic/shared/ai/skills/om-troubleshooter/references/regression-oracles.md +14 -0
  187. package/dist/agentic/shared/ai/skills/tiers.json +143 -21
  188. package/dist/agentic/shared/ai/skills/tiers.schema.json +52 -40
  189. package/dist/agentic/shared/ai/specs/2026-08-06-reference-module-activation.md +248 -0
  190. package/dist/agentic/shared/ai/specs/README.md +4 -3
  191. package/dist/agentic/shared/ai/specs/SPEC-000-template.md +226 -10
  192. package/dist/agentic/shared/ai/trackers/github.md +108 -4
  193. package/dist/agentic/shared/scripts/agent-harness-tool-server.mjs +224 -0
  194. package/dist/agentic/shared/scripts/check-lessons.mjs +178 -0
  195. package/dist/agentic/shared/scripts/evaluate-agent-harness.mjs +3812 -0
  196. package/dist/agentic/shared/scripts/execution-sandbox.mjs +329 -0
  197. package/dist/agentic/shared/scripts/framework-context.mjs +888 -0
  198. package/dist/agentic/shared/scripts/install-skills.mjs +1029 -0
  199. package/dist/agentic/shared/scripts/install-skills.sh +4 -694
  200. package/dist/agentic/shared/scripts/prepare-agent-harness-fixture.mjs +222 -0
  201. package/dist/agentic/shared/scripts/run-agent-harness-release.mjs +1774 -0
  202. package/dist/agentic/shared/scripts/validate-knowledge-change.mjs +1172 -0
  203. package/dist/bin.js +32 -34
  204. package/dist/bin.js.map +2 -2
  205. package/dist/lib/__integration__/TC-INT-008.spec.js +88 -8
  206. package/dist/lib/__integration__/TC-INT-008.spec.js.map +2 -2
  207. package/dist/lib/agentic-init.js +13 -3
  208. package/dist/lib/agentic-init.js.map +2 -2
  209. package/dist/lib/agentic-setup.js +434 -186
  210. package/dist/lib/agentic-setup.js.map +2 -2
  211. package/dist/lib/cli-bootstrap-mode.js +32 -0
  212. package/dist/lib/cli-bootstrap-mode.js.map +7 -0
  213. package/dist/lib/dev-supervisor-manifest.js +105 -0
  214. package/dist/lib/dev-supervisor-manifest.js.map +7 -0
  215. package/dist/lib/generate-watch-events.js +15 -0
  216. package/dist/lib/generate-watch-events.js.map +2 -2
  217. package/dist/lib/generators/entity-ids.js +11 -24
  218. package/dist/lib/generators/entity-ids.js.map +2 -2
  219. package/dist/lib/generators/extensions/injection-widgets.js +20 -0
  220. package/dist/lib/generators/extensions/injection-widgets.js.map +2 -2
  221. package/dist/lib/generators/module-di.js +10 -23
  222. package/dist/lib/generators/module-di.js.map +2 -2
  223. package/dist/lib/generators/module-entities.js +10 -23
  224. package/dist/lib/generators/module-entities.js.map +2 -2
  225. package/dist/lib/generators/module-extension-facts.js +1882 -0
  226. package/dist/lib/generators/module-extension-facts.js.map +7 -0
  227. package/dist/lib/generators/module-fact-sources.js +70 -0
  228. package/dist/lib/generators/module-fact-sources.js.map +7 -0
  229. package/dist/lib/generators/module-facts-discovery.js +64 -7
  230. package/dist/lib/generators/module-facts-discovery.js.map +2 -2
  231. package/dist/lib/generators/module-facts.js +1913 -52
  232. package/dist/lib/generators/module-facts.js.map +3 -3
  233. package/dist/lib/generators/module-override-targets.js +712 -0
  234. package/dist/lib/generators/module-override-targets.js.map +7 -0
  235. package/dist/lib/generators/module-registry.js +405 -36
  236. package/dist/lib/generators/module-registry.js.map +2 -2
  237. package/dist/lib/generators/route-manifest-shards.js +170 -0
  238. package/dist/lib/generators/route-manifest-shards.js.map +7 -0
  239. package/dist/lib/in-process-generate-watcher.js +12 -3
  240. package/dist/lib/in-process-generate-watcher.js.map +2 -2
  241. package/dist/lib/load-env.js +24 -0
  242. package/dist/lib/load-env.js.map +7 -0
  243. package/dist/lib/queue-worker-supervisor.js.map +2 -2
  244. package/dist/lib/resolver.js +28 -3
  245. package/dist/lib/resolver.js.map +2 -2
  246. package/dist/lib/telemetry-init.js +358 -0
  247. package/dist/lib/telemetry-init.js.map +7 -0
  248. package/dist/lib/testing/integration.js +5 -2
  249. package/dist/lib/testing/integration.js.map +2 -2
  250. package/dist/lib/utils.js +15 -8
  251. package/dist/lib/utils.js.map +2 -2
  252. package/dist/mercato.js +35 -9
  253. package/dist/mercato.js.map +2 -2
  254. package/package.json +13 -11
  255. package/src/__tests__/mercato.test.ts +144 -0
  256. package/src/bin.ts +60 -42
  257. package/src/lib/__integration__/TC-INT-008.spec.ts +107 -9
  258. package/src/lib/__tests__/agentic-init.test.ts +41 -5
  259. package/src/lib/__tests__/agentic-setup.ownership.test.ts +386 -0
  260. package/src/lib/__tests__/cli-bootstrap-mode.test.ts +22 -0
  261. package/src/lib/__tests__/content-stable-writes.test.ts +74 -0
  262. package/src/lib/__tests__/dev-supervisor-manifest.test.ts +131 -0
  263. package/src/lib/__tests__/generate-watch-events.test.ts +76 -0
  264. package/src/lib/__tests__/in-process-generate-watcher.test.ts +52 -1
  265. package/src/lib/__tests__/injection-widget-id-validation.test.ts +184 -0
  266. package/src/lib/__tests__/load-env.test.ts +65 -0
  267. package/src/lib/__tests__/queue-worker-supervisor.test.ts +33 -0
  268. package/src/lib/__tests__/resolver.enterprise.test.ts +50 -0
  269. package/src/lib/__tests__/telemetry-init.test.ts +298 -0
  270. package/src/lib/agentic-init.ts +15 -3
  271. package/src/lib/agentic-setup.ts +573 -222
  272. package/src/lib/cli-bootstrap-mode.ts +31 -0
  273. package/src/lib/dev-supervisor-manifest.ts +142 -0
  274. package/src/lib/generate-watch-events.ts +14 -0
  275. package/src/lib/generators/__tests__/activation-adapter-coverage.test.ts +92 -0
  276. package/src/lib/generators/__tests__/entity-ids.test.ts +32 -0
  277. package/src/lib/generators/__tests__/helpers/override-target-diagnostic-fixtures.ts +150 -0
  278. package/src/lib/generators/__tests__/module-extension-activations.test.ts +762 -0
  279. package/src/lib/generators/__tests__/module-extension-facts.test.ts +603 -0
  280. package/src/lib/generators/__tests__/module-facts.bc-guard.test.ts +139 -2
  281. package/src/lib/generators/__tests__/module-facts.computed-di-token.test.ts +88 -0
  282. package/src/lib/generators/__tests__/module-facts.customers.fixture.test.ts +1 -0
  283. package/src/lib/generators/__tests__/module-facts.discovery.test.ts +54 -10
  284. package/src/lib/generators/__tests__/module-facts.example-fact-coverage.test.ts +414 -0
  285. package/src/lib/generators/__tests__/module-facts.extension-hosts.test.ts +108 -0
  286. package/src/lib/generators/__tests__/module-facts.local-reference.test.ts +393 -0
  287. package/src/lib/generators/__tests__/module-facts.owned-contracts.test.ts +561 -0
  288. package/src/lib/generators/__tests__/module-facts.search-entity-id.test.ts +151 -0
  289. package/src/lib/generators/__tests__/module-facts.source-links.test.ts +160 -0
  290. package/src/lib/generators/__tests__/module-override-targets.diagnostic-wiring.test.ts +92 -0
  291. package/src/lib/generators/__tests__/module-override-targets.roundtrip.test.ts +149 -0
  292. package/src/lib/generators/__tests__/module-override-targets.test.ts +328 -0
  293. package/src/lib/generators/__tests__/module-override-targets.unknown-mode.test.ts +117 -0
  294. package/src/lib/generators/__tests__/module-subset.test.ts +266 -0
  295. package/src/lib/generators/__tests__/output-snapshots.test.ts +3 -3
  296. package/src/lib/generators/__tests__/route-manifest-shards.test.ts +62 -0
  297. package/src/lib/generators/entity-ids.ts +10 -26
  298. package/src/lib/generators/extensions/injection-widgets.ts +51 -0
  299. package/src/lib/generators/module-di.ts +9 -25
  300. package/src/lib/generators/module-entities.ts +9 -25
  301. package/src/lib/generators/module-extension-facts.ts +2472 -0
  302. package/src/lib/generators/module-fact-sources.ts +108 -0
  303. package/src/lib/generators/module-facts-discovery.ts +104 -7
  304. package/src/lib/generators/module-facts.ts +2747 -138
  305. package/src/lib/generators/module-override-targets.ts +907 -0
  306. package/src/lib/generators/module-registry.ts +448 -35
  307. package/src/lib/generators/route-manifest-shards.ts +146 -0
  308. package/src/lib/in-process-generate-watcher.ts +13 -4
  309. package/src/lib/load-env.ts +30 -0
  310. package/src/lib/queue-worker-supervisor.ts +7 -2
  311. package/src/lib/resolver.ts +34 -3
  312. package/src/lib/telemetry-init.ts +461 -0
  313. package/src/lib/testing/__tests__/integration.test.ts +4 -0
  314. package/src/lib/testing/integration.ts +5 -2
  315. package/src/lib/utils.ts +24 -16
  316. package/src/mercato.ts +46 -9
  317. package/dist/agentic/guides/cache.md +0 -45
  318. package/dist/agentic/guides/core.md +0 -453
  319. package/dist/agentic/guides/events.md +0 -117
  320. package/dist/agentic/guides/queue.md +0 -53
  321. package/dist/agentic/guides/search.md +0 -115
  322. package/dist/agentic/guides/shared.md +0 -108
  323. package/dist/agentic/guides/ui.md +0 -347
  324. package/dist/agentic/shared/ai/skills/om-auto-continue-pr-loop/SKILL.md +0 -27
  325. package/dist/agentic/shared/ai/skills/om-auto-create-pr-loop/SKILL.md +0 -27
  326. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/ui-components.md +0 -260
  327. package/dist/agentic/shared/ai/skills/om-data-model-design/references/mikro-orm-cheatsheet.md +0 -146
  328. package/dist/agentic/shared/ai/skills/om-help/references/skills-catalog.md +0 -84
  329. package/dist/agentic/shared/ai/skills/om-help/references/workflow-sequences.md +0 -193
  330. package/dist/agentic/shared/ai/skills/om-integration-builder/STANDALONE.md +0 -34
  331. package/dist/agentic/shared/ai/skills/om-integration-builder/references/adapter-contracts.md +0 -762
  332. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/naming-conventions.md +0 -50
  333. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/navigation-patterns.md +0 -97
  334. package/dist/agentic/shared/ai/skills/om-system-extension/references/extension-contracts.md +0 -271
  335. package/dist/agentic/shared/ai/skills/om-troubleshooter/references/diagnostic-commands.md +0 -101
@@ -1,7 +1,39 @@
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
+ ALL_OVERRIDE_DOMAINS,
28
+ MODULE_OVERRIDE_MODES,
29
+ MODULE_OVERRIDE_TARGET_DIAGNOSTIC_CODES,
30
+ MODULE_OVERRIDE_TARGET_NOTES,
31
+ collectModuleOverrideTargets,
32
+ type ModuleOverrideTarget,
33
+ type ModuleOverrideTargetDiagnostic,
34
+ } from './module-override-targets'
35
+ import { buildFactSourceLookup, type FactSourceLookup } from './module-fact-sources'
36
+ import { appendLocalReferenceModuleSource } from './module-facts-discovery'
5
37
 
6
38
  export interface ModuleEntityFact {
7
39
  id: string
@@ -21,6 +53,49 @@ export interface ModuleApiRouteFact {
21
53
  path: string
22
54
  methods: string[]
23
55
  auth: Record<string, ApiRouteAuthRule>
56
+ sourcePath: string | null
57
+ }
58
+
59
+ /**
60
+ * Statically declared page metadata that affects extensibility or access, using the
61
+ * runtime `PageMetadata` contract keys and the same alias precedence as
62
+ * `resolvePageRouteMetadata` (`pageGroup ?? group`, `pageGroupKey ?? groupKey`,
63
+ * `pageOrder ?? order`). Executable guards, icons, titles and translation results are
64
+ * never serialized.
65
+ */
66
+ export interface ModulePageMetadata {
67
+ requireAuth?: boolean
68
+ requireCustomerAuth?: boolean
69
+ requireFeatures?: string[]
70
+ requireCustomerFeatures?: string[]
71
+ navHidden?: boolean
72
+ navGroup?: string
73
+ navGroupKey?: string
74
+ navOrder?: number
75
+ pageContext?: 'main' | 'admin' | 'settings' | 'profile'
76
+ }
77
+
78
+ export interface ModulePageFact {
79
+ path: string
80
+ sourcePath: string
81
+ /** Companion metadata file (`page.meta.*` / `meta.*`) when runtime reads metadata from one. */
82
+ metadataSourcePath?: string
83
+ metadata?: ModulePageMetadata
84
+ }
85
+
86
+ export interface ModuleCliCommandFact {
87
+ command: string
88
+ sourcePath: string
89
+ }
90
+
91
+ export interface ModuleAiToolFact {
92
+ name: string
93
+ sourcePath: string
94
+ }
95
+
96
+ export interface ModuleAiAgentFact {
97
+ id: string
98
+ sourcePath: string
24
99
  }
25
100
 
26
101
  export interface ModuleEventFact {
@@ -28,6 +103,8 @@ export interface ModuleEventFact {
28
103
  label?: string
29
104
  category: string | null
30
105
  entity: string | null
106
+ clientBroadcast?: boolean
107
+ portalBroadcast?: boolean
31
108
  }
32
109
 
33
110
  export interface ModuleHostTokens {
@@ -35,11 +112,139 @@ export interface ModuleHostTokens {
35
112
  tableIds: string[]
36
113
  }
37
114
 
115
+ export type ModuleFactSourceRef = {
116
+ sourcePath: string
117
+ exportName?: string
118
+ line?: number
119
+ }
120
+
121
+ export type ModuleFactRef = {
122
+ factSection: string
123
+ factKey: string
124
+ }
125
+
126
+ /**
127
+ * Closed provenance/contract sets, published as `as const` ledgers whose types
128
+ * derive from them. The `factCoverage` ledger enumerates these at runtime, so a
129
+ * kind added here without a ledger row fails the coverage contract test rather
130
+ * than silently joining the public surface unclassified.
131
+ */
132
+ export const MODULE_FACT_SOURCE_KINDS = [
133
+ 'module-metadata',
134
+ 'entity',
135
+ 'event',
136
+ 'acl-feature',
137
+ 'api-route',
138
+ 'di-registration',
139
+ 'search',
140
+ 'vector',
141
+ 'notification',
142
+ 'cli-command',
143
+ 'backend-page',
144
+ 'frontend-page',
145
+ 'ai-tool',
146
+ 'ai-agent',
147
+ 'ai-extension',
148
+ 'command',
149
+ 'subscriber',
150
+ 'worker',
151
+ 'page-middleware',
152
+ 'setup',
153
+ 'encryption',
154
+ 'custom-entity',
155
+ 'integration',
156
+ 'generator-plugin',
157
+ 'extension-host',
158
+ 'extension-contribution',
159
+ ] as const
160
+
161
+ export type ModuleFactSourceKind = (typeof MODULE_FACT_SOURCE_KINDS)[number]
162
+
163
+ export const MODULE_OWNED_CONTRACT_KINDS = [
164
+ 'module-metadata',
165
+ 'command',
166
+ 'worker',
167
+ 'page-middleware',
168
+ 'setup',
169
+ 'encryption',
170
+ 'di-registration',
171
+ 'custom-entity',
172
+ 'ai-extension',
173
+ 'generator-plugin',
174
+ ] as const
175
+
176
+ export type ModuleOwnedContractKind = (typeof MODULE_OWNED_CONTRACT_KINDS)[number]
177
+
178
+ export type ModuleFactSafeScalar = string | number | boolean | null
179
+
180
+ export type ModuleOwnedContractFact = {
181
+ kind: ModuleOwnedContractKind
182
+ id: string
183
+ source: ModuleFactSourceRef
184
+ metadata?: Record<string, ModuleFactSafeScalar | ModuleFactSafeScalar[]>
185
+ }
186
+
187
+ export type ModuleOwnedContracts = Partial<
188
+ Record<ModuleOwnedContractKind, ModuleOwnedContractFact[]>
189
+ >
190
+
191
+ /**
192
+ * Provenance pointer into a fact section that already serializes the declaration site.
193
+ * `factKey` is omitted when it equals the owning entry's `id`, which is the common case.
194
+ */
195
+ export type ModuleFactIndexRef = {
196
+ factSection: string
197
+ factKey?: string
198
+ }
199
+
200
+ /**
201
+ * One provenance entry per proven `(kind, id)` in the module. Exactly one of `source`
202
+ * (the declaration site, for facts that have no other home in the JSON) or `factRef`
203
+ * (a deterministic pointer into a fact section that already carries the source inline)
204
+ * is present, so a consumer resolves any fact's origin through this single index.
205
+ */
206
+ export type ModuleFactSourceEntry = {
207
+ kind: ModuleFactSourceKind
208
+ id: string
209
+ source?: ModuleFactSourceRef
210
+ factRef?: ModuleFactIndexRef
211
+ }
212
+
213
+ export type ModuleFactDiagnostic = {
214
+ code: 'duplicate-source' | 'unresolved-static-contract'
215
+ kind: ModuleFactSourceKind
216
+ id: string
217
+ source?: ModuleFactSourceRef
218
+ }
219
+
220
+ export type ModuleDiRegistrationFact = {
221
+ token: string
222
+ registrationKind: 'function' | 'class' | 'value' | 'alias'
223
+ providerSymbol?: string
224
+ lifetime?: 'singleton' | 'scoped' | 'transient'
225
+ injectionMode?: 'classic' | 'proxy'
226
+ source: ModuleFactSourceRef
227
+ }
228
+
229
+ /**
230
+ * How the extracted module reached the batch. `package` is the implicit default for
231
+ * every installed `@open-mercato/*` module and never appears in emitted output, so the
232
+ * package sidecar contract stays byte-identical. `local-reference` marks an app-local
233
+ * module projected into the explicit reference bundle; it is an independent discriminator
234
+ * from `sourcePackage`, which keeps its legacy "null means core package" meaning.
235
+ */
236
+ export type ModuleFactProjectionSourceKind = 'package' | 'local-reference'
237
+
38
238
  export interface ModuleFacts {
39
239
  module: string
40
240
  title: string | null
41
241
  description: string | null
42
242
  coreVersion: string | null
243
+ sourcePackage: string | null
244
+ sourceVersion: string | null
245
+ sourceRoot: string
246
+ /** Present only for `local-reference` projections; package modules omit it entirely. */
247
+ sourceKind?: ModuleFactProjectionSourceKind
43
248
  entities: ModuleEntityFact[]
44
249
  events: ModuleEventFact[]
45
250
  aclFeatures: string[]
@@ -49,6 +254,17 @@ export interface ModuleFacts {
49
254
  hostTokens: ModuleHostTokens
50
255
  notifications: string[]
51
256
  cli: string[]
257
+ backendPages: ModulePageFact[]
258
+ frontendPages: ModulePageFact[]
259
+ cliCommands: ModuleCliCommandFact[]
260
+ aiTools: ModuleAiToolFact[]
261
+ aiAgents: ModuleAiAgentFact[]
262
+ extensionSurfaces?: ModuleExtensionSurfaceFacts
263
+ factSources?: ModuleFactSourceEntry[]
264
+ ownedContracts?: ModuleOwnedContracts
265
+ factDiagnostics?: ModuleFactDiagnostic[]
266
+ overrideTargets?: ModuleOverrideTarget[]
267
+ overrideTargetDiagnostics?: ModuleOverrideTargetDiagnostic[]
52
268
  warnings: string[]
53
269
  }
54
270
 
@@ -64,8 +280,17 @@ export interface ExtractModuleFactsOptions {
64
280
  /** Explicit module source directory. When set it overrides `coreSrcRoot + moduleId`. */
65
281
  moduleRoot?: string
66
282
  coreVersion?: string | null
283
+ sourcePackage?: string | null
284
+ sourceVersion?: string | null
67
285
  registryPath?: string | null
68
286
  registrySource?: string | null
287
+ /**
288
+ * App-root-relative POSIX root written into every emitted source path. Overrides the
289
+ * default `node_modules/<package>/src/modules/<moduleId>` layout so an app-local module
290
+ * emits `src/modules/<moduleId>` instead of a package path it does not live in.
291
+ */
292
+ portableSourceRoot?: string
293
+ sourceKind?: ModuleFactProjectionSourceKind
69
294
  }
70
295
 
71
296
  /** A discovered module and the source directory its facts are extracted from. */
@@ -73,6 +298,9 @@ export interface ModuleFactSource {
73
298
  moduleId: string
74
299
  moduleRoot: string
75
300
  from?: string
301
+ packageVersion?: string | null
302
+ portableSourceRoot?: string
303
+ sourceKind?: ModuleFactProjectionSourceKind
76
304
  }
77
305
 
78
306
  function readSourceFile(filePath: string): ts.SourceFile | null {
@@ -153,6 +381,49 @@ function classHasUpdatedAtColumn(node: ts.ClassDeclaration): boolean {
153
381
  return false
154
382
  }
155
383
 
384
+ function readStaticAccessPath(candidate: ts.Expression): string[] | null {
385
+ const access = unwrapExpression(candidate)
386
+ if (ts.isIdentifier(access)) return [access.text]
387
+ if (ts.isPropertyAccessExpression(access)) {
388
+ const parent = readStaticAccessPath(access.expression)
389
+ return parent ? [...parent, access.name.text] : null
390
+ }
391
+ if (ts.isElementAccessExpression(access) && access.argumentExpression) {
392
+ const parent = readStaticAccessPath(access.expression)
393
+ const key = unwrapExpression(access.argumentExpression)
394
+ return parent && ts.isStringLiteralLike(key) ? [...parent, key.text] : null
395
+ }
396
+ return null
397
+ }
398
+
399
+ /**
400
+ * Resolves an entity-id expression to its literal `module:entity` value.
401
+ *
402
+ * Modules reference entity ids three ways and all three are statically knowable:
403
+ * a string literal, `E.<module>.<entity>` from the generated entity-id registry
404
+ * (`entity-ids.ts` emits `E[module][entity] = '<module>:<entity>'`), and — when
405
+ * `initializers` is supplied — a same-file `const ENTITY_ID = 'module:entity'`.
406
+ * Anything else stays undefined: a guessed id is worse than a missing one.
407
+ */
408
+ function resolveEntityIdReference(
409
+ expression: ts.Expression,
410
+ initializers?: Map<string, ts.Expression>,
411
+ visitedIdentifiers: Set<string> = new Set(),
412
+ ): string | undefined {
413
+ const current = unwrapExpression(expression)
414
+ if (ts.isStringLiteralLike(current)) return current.text
415
+ const accessPath = readStaticAccessPath(current)
416
+ if (accessPath?.length === 3 && accessPath[0] === 'E') {
417
+ return `${accessPath[1]}:${accessPath[2]}`
418
+ }
419
+ if (initializers && ts.isIdentifier(current) && !visitedIdentifiers.has(current.text)) {
420
+ visitedIdentifiers.add(current.text)
421
+ const declaration = initializers.get(current.text)
422
+ if (declaration) return resolveEntityIdReference(declaration, initializers, visitedIdentifiers)
423
+ }
424
+ return undefined
425
+ }
426
+
156
427
  function collectCustomFieldEntityIds(ceFilePath: string | null): Set<string> {
157
428
  const result = new Set<string>()
158
429
  if (!ceFilePath) return result
@@ -161,8 +432,11 @@ function collectCustomFieldEntityIds(ceFilePath: string | null): Set<string> {
161
432
 
162
433
  const visit = (node: ts.Node): void => {
163
434
  if (ts.isObjectLiteralExpression(node)) {
164
- const id = readStringPropertyInitializer(node, 'id')
165
- if (id && id.includes(':')) result.add(id)
435
+ const initializer = getObjectPropertyInitializer(node, 'id')
436
+ if (initializer) {
437
+ const id = resolveEntityIdReference(initializer)
438
+ if (id && id.includes(':')) result.add(id)
439
+ }
166
440
  }
167
441
  node.forEachChild(visit)
168
442
  }
@@ -222,9 +496,38 @@ function getPropertyName(property: ts.ObjectLiteralElementLike): string | undefi
222
496
  if (!name) return undefined
223
497
  if (ts.isIdentifier(name)) return name.text
224
498
  if (ts.isStringLiteralLike(name)) return name.text
499
+ // A computed key whose expression is a string literal or a same-file `const NAME = 'token'`
500
+ // is still a statically known token. Without this, `{ [SERVICE_TOKEN]: asFunction(...) }` —
501
+ // the idiomatic, type-safe way to register a DI token from an exported const — yielded
502
+ // undefined and the registration vanished from module facts with NO diagnostic, because the
503
+ // unresolved-token path is only reached for a NAMED token whose Awilix kind is unrecognised.
504
+ if (ts.isComputedPropertyName(name)) return resolveComputedPropertyKey(name.expression)
225
505
  return undefined
226
506
  }
227
507
 
508
+ /** Resolves a computed key to its literal token when the value is statically knowable. */
509
+ function resolveComputedPropertyKey(expression: ts.Expression): string | undefined {
510
+ if (ts.isStringLiteralLike(expression)) return expression.text
511
+ // `X as const` / `X satisfies Y` wrappers.
512
+ if (ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression)) {
513
+ return resolveComputedPropertyKey(expression.expression)
514
+ }
515
+ if (ts.isParenthesizedExpression(expression)) return resolveComputedPropertyKey(expression.expression)
516
+ if (!ts.isIdentifier(expression)) return undefined
517
+ const sourceFile = expression.getSourceFile()
518
+ if (!sourceFile) return undefined
519
+ let resolved: string | undefined
520
+ const visit = (node: ts.Node): void => {
521
+ if (resolved) return
522
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === expression.text) {
523
+ if (node.initializer) resolved = resolveComputedPropertyKey(node.initializer)
524
+ }
525
+ node.forEachChild(visit)
526
+ }
527
+ sourceFile.forEachChild(visit)
528
+ return resolved
529
+ }
530
+
228
531
  function getObjectPropertyInitializer(
229
532
  objectLiteral: ts.ObjectLiteralExpression,
230
533
  propertyName: string,
@@ -351,7 +654,11 @@ function extractEvents(eventsFilePath: string | null): ModuleEventFact[] {
351
654
  category: category ?? null,
352
655
  entity: entity ?? null,
353
656
  }
657
+ const clientBroadcast = readBooleanPropertyInitializer(element, 'clientBroadcast')
658
+ const portalBroadcast = readBooleanPropertyInitializer(element, 'portalBroadcast')
354
659
  if (label !== undefined) fact.label = label
660
+ if (clientBroadcast !== undefined) fact.clientBroadcast = clientBroadcast
661
+ if (portalBroadcast !== undefined) fact.portalBroadcast = portalBroadcast
355
662
  facts.push(fact)
356
663
  }
357
664
 
@@ -459,13 +766,14 @@ function parseApiRouteEntry(entryLiteral: ts.ObjectLiteralExpression): ModuleApi
459
766
  }
460
767
  }
461
768
 
462
- return { path: routePath, methods, auth }
769
+ return { path: routePath, methods, auth, sourcePath: null }
463
770
  }
464
771
 
465
772
  function extractApiRoutes(
466
773
  moduleId: string,
467
774
  registrySource: string | null,
468
775
  registryDescription: string,
776
+ routeSourcePaths: ReadonlyMap<string, string>,
469
777
  warnings: string[],
470
778
  ): ModuleApiRouteFact[] {
471
779
  if (registrySource == null) {
@@ -493,7 +801,7 @@ function extractApiRoutes(
493
801
  const route = parseApiRouteEntry(element)
494
802
  if (route && !seenPaths.has(route.path)) {
495
803
  seenPaths.add(route.path)
496
- routes.push(route)
804
+ routes.push({ ...route, sourcePath: routeSourcePaths.get(route.path) ?? null })
497
805
  }
498
806
  }
499
807
  }
@@ -581,15 +889,29 @@ function extractSearchEntities(searchFilePath: string | null, warnings: string[]
581
889
  return []
582
890
  }
583
891
 
892
+ // `entityId` is rarely a bare string literal: modules reference the entity through
893
+ // `E.<module>.<entity>` or a same-file `const ENTITY_ID`. Reading only string literals
894
+ // dropped every such entry with NO diagnostic, so a module shipping `search.ts` scored
895
+ // zero search facts while looking healthy. Unresolvable entries now warn instead.
896
+ const initializers = buildVariableInitializerMap(sourceFile)
584
897
  const entityIds: string[] = []
585
898
  const seen = new Set<string>()
586
899
  for (const element of entitiesArray.elements) {
587
900
  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)
901
+ const initializer = getObjectPropertyInitializer(element, 'entityId')
902
+ const location = `${searchFilePath}:${nodeLine(sourceFile, element)}`
903
+ if (!initializer) {
904
+ warnings.push(`[module-facts] searchConfig entity declares no entityId: ${location}`)
905
+ continue
906
+ }
907
+ const entityId = resolveEntityIdReference(initializer, initializers)
908
+ if (!entityId) {
909
+ warnings.push(`[module-facts] searchConfig entityId is not statically resolvable: ${location}`)
910
+ continue
592
911
  }
912
+ if (seen.has(entityId)) continue
913
+ seen.add(entityId)
914
+ entityIds.push(entityId)
593
915
  }
594
916
  return entityIds
595
917
  }
@@ -666,36 +988,261 @@ function extractCli(cliFilePath: string | null, warnings: string[]): string[] {
666
988
  function listSourceFilesRecursive(directory: string): string[] {
667
989
  const files: string[] = []
668
990
  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
669
- if (entry.name === '__tests__' || entry.name === 'node_modules') continue
991
+ if (entry.name === '__tests__' || entry.name === '__mocks__' || entry.name === 'node_modules') continue
670
992
  const fullPath = path.join(directory, entry.name)
671
993
  if (entry.isDirectory()) {
672
994
  files.push(...listSourceFilesRecursive(fullPath))
673
- } else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
995
+ } else if (
996
+ /\.tsx?$/.test(entry.name)
997
+ && !entry.name.endsWith('.d.ts')
998
+ && !/\.(?:test|spec)\.tsx?$/.test(entry.name)
999
+ ) {
674
1000
  files.push(fullPath)
675
1001
  }
676
1002
  }
677
1003
  return files
678
1004
  }
679
1005
 
680
- function extractTableIds(backendDir: string): string[] {
681
- if (!fs.existsSync(backendDir)) return []
1006
+ function toPortableSourceRoot(moduleId: string, sourcePackage: string | null): string {
1007
+ return path.posix.join('node_modules', sourcePackage ?? '@open-mercato/core', 'src', 'modules', moduleId)
1008
+ }
1009
+
1010
+ /**
1011
+ * An explicit portable root must stay app-root-relative and POSIX-shaped: every emitted
1012
+ * fact path and markdown link is resolved against the generated app root, so an absolute
1013
+ * path, a Windows separator, or a traversal segment would emit an unclickable or
1014
+ * machine-specific reference into a distributed artifact.
1015
+ */
1016
+ export function assertPortableSourceRoot(moduleId: string, portableSourceRoot: string): string {
1017
+ const invalid = portableSourceRoot.length === 0
1018
+ || path.posix.isAbsolute(portableSourceRoot)
1019
+ || /^[A-Za-z]:/.test(portableSourceRoot)
1020
+ || portableSourceRoot.includes('\\')
1021
+ || portableSourceRoot.split('/').includes('..')
1022
+ if (invalid) {
1023
+ throw new Error(
1024
+ `[module-facts] portableSourceRoot for module "${moduleId}" must be a relative POSIX app path, received "${portableSourceRoot}"`,
1025
+ )
1026
+ }
1027
+ return portableSourceRoot
1028
+ }
1029
+
1030
+ function toPortableSourcePath(moduleRoot: string, sourceRoot: string, filePath: string): string {
1031
+ const relativePath = path.relative(moduleRoot, filePath).split(path.sep).join('/')
1032
+ return path.posix.join(sourceRoot, relativePath)
1033
+ }
1034
+
1035
+ function sourceHasDefaultExport(sourceFile: ts.SourceFile): boolean {
1036
+ return sourceFile.statements.some((statement) => {
1037
+ if (ts.isExportAssignment(statement) && !statement.isExportEquals) return true
1038
+ if (!ts.isFunctionDeclaration(statement) && !ts.isClassDeclaration(statement)) return false
1039
+ return statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) ?? false
1040
+ })
1041
+ }
1042
+
1043
+ function readRouteMetadataPath(sourceFile: ts.SourceFile): string | null {
1044
+ const metadata = findObjectLiteralDeclaration(sourceFile, 'metadata')
1045
+ return metadata ? readStringPropertyInitializer(metadata, 'path') ?? null : null
1046
+ }
1047
+
1048
+ function extractApiRouteSourcePaths(
1049
+ moduleId: string,
1050
+ moduleRoot: string,
1051
+ sourceRoot: string,
1052
+ ): Map<string, string> {
1053
+ const apiRoot = path.join(moduleRoot, 'api')
1054
+ const sources = new Map<string, string>()
1055
+ if (!fs.existsSync(apiRoot)) return sources
1056
+ const methodDirectories = new Set(['get', 'post', 'put', 'patch', 'delete'])
1057
+
1058
+ for (const filePath of listSourceFilesRecursive(apiRoot)) {
1059
+ const relativePath = path.relative(apiRoot, filePath).split(path.sep).join('/')
1060
+ const segments = relativePath.split('/')
1061
+ const fileName = segments.pop() as string
1062
+ const fileStem = fileName.replace(/\.tsx?$/, '')
1063
+ let routeSegments: string[]
1064
+ if (fileStem === 'route') {
1065
+ routeSegments = segments
1066
+ } else if (segments[0] && methodDirectories.has(segments[0].toLowerCase())) {
1067
+ routeSegments = [...segments.slice(1), fileStem]
1068
+ } else {
1069
+ routeSegments = [...segments, fileStem]
1070
+ }
1071
+ const defaultPath = `/${[moduleId, ...routeSegments].filter(Boolean).join('/')}`
1072
+ const sourceFile = readSourceFile(filePath)
1073
+ const routePath = sourceFile ? readRouteMetadataPath(sourceFile) ?? defaultPath : defaultPath
1074
+ sources.set(routePath, toPortableSourcePath(moduleRoot, sourceRoot, filePath))
1075
+ }
1076
+ return sources
1077
+ }
1078
+
1079
+ function extractModulePages(
1080
+ moduleId: string,
1081
+ moduleRoot: string,
1082
+ sourceRoot: string,
1083
+ surface: 'backend' | 'frontend',
1084
+ ): ModulePageFact[] {
1085
+ const pageRoot = path.join(moduleRoot, surface)
1086
+ if (!fs.existsSync(pageRoot)) return []
1087
+ const pages: ModulePageFact[] = []
1088
+ const seen = new Set<string>()
1089
+
1090
+ for (const filePath of listSourceFilesRecursive(pageRoot)) {
1091
+ if (!filePath.endsWith('.tsx')) continue
1092
+ const relativePath = path.relative(pageRoot, filePath).split(path.sep).join('/')
1093
+ const segments = relativePath.split('/')
1094
+ const fileName = segments.pop() as string
1095
+ const fileStem = fileName.replace(/\.tsx$/, '')
1096
+ const isModernPage = fileStem === 'page'
1097
+ if (!isModernPage && (fileStem === 'meta' || fileStem.endsWith('.meta') || /^[A-Z]/.test(fileStem))) continue
1098
+ const sourceFile = readSourceFile(filePath)
1099
+ if (!sourceFile || !sourceHasDefaultExport(sourceFile)) continue
1100
+ const routeSegments = isModernPage ? segments : [...segments, fileStem]
1101
+ const routePath = surface === 'frontend'
1102
+ ? `/${routeSegments.filter(Boolean).join('/')}`
1103
+ : isModernPage
1104
+ ? `/backend/${routeSegments.join('/') || moduleId}`
1105
+ : `/backend/${routeSegments[0] === moduleId
1106
+ ? routeSegments.filter(Boolean).join('/')
1107
+ : [moduleId, ...routeSegments].filter(Boolean).join('/')}`
1108
+ if (seen.has(routePath)) continue
1109
+ seen.add(routePath)
1110
+ const metadataFilePath = resolvePageMetadataFile(path.dirname(filePath))
1111
+ const metadataSourceFile = metadataFilePath ? readSourceFile(metadataFilePath) : sourceFile
1112
+ const metadata = metadataSourceFile ? readPageMetadata(metadataSourceFile) : undefined
1113
+ pages.push({
1114
+ path: routePath,
1115
+ sourcePath: toPortableSourcePath(moduleRoot, sourceRoot, filePath),
1116
+ ...(metadataFilePath
1117
+ ? { metadataSourcePath: toPortableSourcePath(moduleRoot, sourceRoot, metadataFilePath) }
1118
+ : {}),
1119
+ ...(metadata ? { metadata } : {}),
1120
+ })
1121
+ }
1122
+ return pages.sort((left, right) => left.path.localeCompare(right.path))
1123
+ }
1124
+
1125
+ function findAncestorVariableDeclaration(node: ts.Node): ts.VariableDeclaration | null {
1126
+ let current: ts.Node | undefined = node.parent
1127
+ while (current) {
1128
+ if (ts.isVariableDeclaration(current)) return current
1129
+ current = current.parent
1130
+ }
1131
+ return null
1132
+ }
1133
+
1134
+ function findAncestorCallExpression(node: ts.Node): ts.CallExpression | null {
1135
+ let current: ts.Node | undefined = node.parent
1136
+ while (current) {
1137
+ if (ts.isCallExpression(current)) return current
1138
+ if (ts.isVariableDeclaration(current) || ts.isStatement(current)) return null
1139
+ current = current.parent
1140
+ }
1141
+ return null
1142
+ }
1143
+
1144
+ function extractNamedAiFacts(
1145
+ filePaths: readonly string[],
1146
+ moduleRoot: string,
1147
+ sourceRoot: string,
1148
+ propertyName: 'name' | 'id',
1149
+ ): Array<{ value: string; sourcePath: string }> {
1150
+ const facts: Array<{ value: string; sourcePath: string }> = []
1151
+ const seen = new Set<string>()
1152
+ for (const filePath of filePaths) {
1153
+ const sourceFile = readSourceFile(filePath)
1154
+ if (!sourceFile) continue
1155
+ const initializers = buildVariableInitializerMap(sourceFile)
1156
+ const readDefinitionValue = (objectLiteral: ts.ObjectLiteralExpression): string | undefined => {
1157
+ const initializer = getObjectPropertyInitializer(objectLiteral, propertyName)
1158
+ if (!initializer) return undefined
1159
+ const resolved = unwrapExpression(initializer)
1160
+ if (ts.isStringLiteralLike(resolved)) return resolved.text
1161
+ if (ts.isIdentifier(resolved)) {
1162
+ const declarationInitializer = initializers.get(resolved.text)
1163
+ if (declarationInitializer) {
1164
+ const declarationValue = unwrapExpression(declarationInitializer)
1165
+ if (ts.isStringLiteralLike(declarationValue)) return declarationValue.text
1166
+ }
1167
+ }
1168
+ return undefined
1169
+ }
1170
+ const visit = (node: ts.Node): void => {
1171
+ if (ts.isObjectLiteralExpression(node)) {
1172
+ const value = readDefinitionValue(node)
1173
+ if (value && !seen.has(value)) {
1174
+ const declaration = findAncestorVariableDeclaration(node)
1175
+ const declarationName = declaration && ts.isIdentifier(declaration.name) ? declaration.name.text : ''
1176
+ const declarationType = declaration?.type?.getText(sourceFile) ?? ''
1177
+ const call = findAncestorCallExpression(node)
1178
+ const callee = call?.expression.getText(sourceFile) ?? ''
1179
+ const isDefinition = propertyName === 'name'
1180
+ ? /AiTool/.test(declarationType) || /tool$/i.test(declarationName) || /AiTool/.test(callee)
1181
+ : /AiAgentDefinition/.test(declarationType) || /agent$/i.test(declarationName) || /defineAiAgent/.test(callee)
1182
+ if (isDefinition) {
1183
+ seen.add(value)
1184
+ facts.push({
1185
+ value,
1186
+ sourcePath: toPortableSourcePath(moduleRoot, sourceRoot, filePath),
1187
+ })
1188
+ }
1189
+ }
1190
+ }
1191
+ node.forEachChild(visit)
1192
+ }
1193
+ sourceFile.forEachChild(visit)
1194
+ }
1195
+ return facts.sort((left, right) => left.value.localeCompare(right.value))
1196
+ }
1197
+
1198
+ function extractAiTools(moduleRoot: string, sourceRoot: string): ModuleAiToolFact[] {
1199
+ const files = new Set<string>()
1200
+ const rootFile = resolveConventionFile(moduleRoot, 'ai-tools')
1201
+ if (rootFile) files.add(rootFile)
1202
+ const toolsDirectory = path.join(moduleRoot, 'ai-tools')
1203
+ if (fs.existsSync(toolsDirectory)) {
1204
+ for (const filePath of listSourceFilesRecursive(toolsDirectory)) files.add(filePath)
1205
+ }
1206
+ return extractNamedAiFacts([...files], moduleRoot, sourceRoot, 'name')
1207
+ .map((fact) => ({ name: fact.value, sourcePath: fact.sourcePath }))
1208
+ }
1209
+
1210
+ function extractAiAgents(moduleRoot: string, sourceRoot: string): ModuleAiAgentFact[] {
1211
+ const agentsFile = resolveConventionFile(moduleRoot, 'ai-agents')
1212
+ if (!agentsFile) return []
1213
+ return extractNamedAiFacts([agentsFile], moduleRoot, sourceRoot, 'id')
1214
+ .map((fact) => ({ id: fact.value, sourcePath: fact.sourcePath }))
1215
+ }
1216
+
1217
+ function extractTableIds(moduleRoot: string): string[] {
1218
+ if (!fs.existsSync(moduleRoot)) return []
682
1219
 
683
1220
  const tableIds: string[] = []
684
1221
  const seen = new Set<string>()
685
- for (const filePath of listSourceFilesRecursive(backendDir)) {
1222
+ const collectLiteralValues = (expression: ts.Expression): string[] => {
1223
+ const current = unwrapExpression(expression)
1224
+ if (ts.isStringLiteralLike(current)) return [current.text]
1225
+ if (ts.isConditionalExpression(current)) {
1226
+ return [
1227
+ ...collectLiteralValues(current.whenTrue),
1228
+ ...collectLiteralValues(current.whenFalse),
1229
+ ]
1230
+ }
1231
+ return []
1232
+ }
1233
+
1234
+ for (const filePath of listSourceFilesRecursive(moduleRoot)) {
686
1235
  const sourceFile = readSourceFile(filePath)
687
1236
  if (!sourceFile) continue
688
1237
  const visit = (node: ts.Node): void => {
689
1238
  if (ts.isPropertyAssignment(node)) {
690
1239
  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)
1240
+ if (propertyName === 'tableId' || propertyName === 'extensionTableId') {
1241
+ for (const value of collectLiteralValues(node.initializer)) {
1242
+ if (value && !seen.has(value)) {
1243
+ seen.add(value)
1244
+ tableIds.push(value)
1245
+ }
699
1246
  }
700
1247
  }
701
1248
  }
@@ -723,130 +1270,1227 @@ function extractModuleMeta(indexFilePath: string | null): { title: string | null
723
1270
  }
724
1271
  }
725
1272
 
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
- }
1273
+ function nodeLine(sourceFile: ts.SourceFile, node: ts.Node): number {
1274
+ return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1
1275
+ }
733
1276
 
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')
1277
+ function readPropertyNameText(name: ts.PropertyName): string | undefined {
1278
+ if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) return name.text
1279
+ return undefined
1280
+ }
747
1281
 
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)
1282
+ function readMethodBearingPropertyNames(objectLiteral: ts.ObjectLiteralExpression): string[] {
1283
+ const names: string[] = []
1284
+ for (const property of objectLiteral.properties) {
1285
+ if (ts.isMethodDeclaration(property)) {
1286
+ const name = readPropertyNameText(property.name)
1287
+ if (name) names.push(name)
1288
+ continue
1289
+ }
1290
+ if (ts.isPropertyAssignment(property)) {
1291
+ const name = readPropertyNameText(property.name)
1292
+ if (!name) continue
1293
+ const initializer = unwrapExpression(property.initializer)
1294
+ if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) names.push(name)
1295
+ }
1296
+ }
1297
+ return names.sort((left, right) => left.localeCompare(right))
1298
+ }
754
1299
 
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),
1300
+ function readObjectKeyNames(objectLiteral: ts.ObjectLiteralExpression): string[] {
1301
+ const names: string[] = []
1302
+ for (const property of objectLiteral.properties) {
1303
+ const name = getPropertyName(property)
1304
+ if (name) names.push(name)
764
1305
  }
1306
+ return names.sort((left, right) => left.localeCompare(right))
1307
+ }
765
1308
 
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,
1309
+ function mapDiRegistrationKind(base: string | null): ModuleDiRegistrationFact['registrationKind'] | null {
1310
+ switch (base) {
1311
+ case 'asFunction':
1312
+ return 'function'
1313
+ case 'asClass':
1314
+ return 'class'
1315
+ case 'asValue':
1316
+ return 'value'
1317
+ case 'aliasTo':
1318
+ return 'alias'
1319
+ default:
1320
+ return null
781
1321
  }
782
1322
  }
783
1323
 
784
- export interface ModuleFactsJsonEvent {
785
- id: string
786
- category: string | null
787
- entity: string | null
1324
+ function readDiChainMetadata(initializer: ts.Expression): {
1325
+ lifetime?: ModuleDiRegistrationFact['lifetime']
1326
+ injectionMode?: ModuleDiRegistrationFact['injectionMode']
1327
+ } {
1328
+ const result: {
1329
+ lifetime?: ModuleDiRegistrationFact['lifetime']
1330
+ injectionMode?: ModuleDiRegistrationFact['injectionMode']
1331
+ } = {}
1332
+ let current: ts.Expression = unwrapExpression(initializer)
1333
+ while (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) {
1334
+ const method = current.expression.name.text
1335
+ if ((method === 'singleton' || method === 'scoped' || method === 'transient') && !result.lifetime) {
1336
+ result.lifetime = method
1337
+ } else if (method === 'proxy' && !result.injectionMode) {
1338
+ result.injectionMode = 'proxy'
1339
+ } else if (method === 'classic' && !result.injectionMode) {
1340
+ result.injectionMode = 'classic'
1341
+ }
1342
+ current = unwrapExpression(current.expression.expression)
1343
+ }
1344
+ return result
788
1345
  }
789
1346
 
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[]
1347
+ function readDiProviderSymbol(
1348
+ initializer: ts.Expression,
1349
+ kind: ModuleDiRegistrationFact['registrationKind'],
1350
+ ): string | undefined {
1351
+ let current: ts.Expression = unwrapExpression(initializer)
1352
+ while (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) {
1353
+ current = unwrapExpression(current.expression.expression)
1354
+ }
1355
+ if (!ts.isCallExpression(current)) return undefined
1356
+ const argument = current.arguments[0]
1357
+ if (!argument) return undefined
1358
+ if (kind === 'value') return undefined
1359
+ if (kind === 'alias' && ts.isStringLiteralLike(argument)) return argument.text
1360
+ if ((kind === 'function' || kind === 'class') && ts.isIdentifier(argument)) return argument.text
1361
+ return undefined
803
1362
  }
804
1363
 
805
- const EMPTY_SECTION_MARKER = '_none_'
1364
+ function extractDiRegistrationFacts(
1365
+ diFilePath: string | null,
1366
+ sourcePath: string,
1367
+ ): { facts: ModuleDiRegistrationFact[]; unresolvedTokens: string[] } {
1368
+ if (!diFilePath) return { facts: [], unresolvedTokens: [] }
1369
+ const sourceFile = readSourceFile(diFilePath)
1370
+ if (!sourceFile) return { facts: [], unresolvedTokens: [] }
806
1371
 
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 -->`
1372
+ const facts: ModuleDiRegistrationFact[] = []
1373
+ const unresolvedTokens: string[] = []
1374
+ const visit = (node: ts.Node): void => {
1375
+ if (
1376
+ ts.isCallExpression(node) &&
1377
+ ts.isPropertyAccessExpression(node.expression) &&
1378
+ node.expression.name.text === 'register'
1379
+ ) {
1380
+ const argument = node.arguments[0]
1381
+ if (argument && ts.isObjectLiteralExpression(argument)) {
1382
+ for (const property of argument.properties) {
1383
+ if (!ts.isPropertyAssignment(property)) continue
1384
+ const token = getPropertyName(property)
1385
+ if (!token) continue
1386
+ const kind = mapDiRegistrationKind(detectAwilixRegistrationKind(property.initializer))
1387
+ if (!kind) {
1388
+ if (!unresolvedTokens.includes(token)) unresolvedTokens.push(token)
1389
+ continue
1390
+ }
1391
+ const chain = readDiChainMetadata(property.initializer)
1392
+ const providerSymbol = readDiProviderSymbol(property.initializer, kind)
1393
+ facts.push({
1394
+ token,
1395
+ registrationKind: kind,
1396
+ ...(providerSymbol ? { providerSymbol } : {}),
1397
+ ...(chain.lifetime ? { lifetime: chain.lifetime } : {}),
1398
+ ...(chain.injectionMode ? { injectionMode: chain.injectionMode } : {}),
1399
+ source: { sourcePath, exportName: token, line: nodeLine(sourceFile, property) },
1400
+ })
1401
+ }
1402
+ }
1403
+ }
1404
+ node.forEachChild(visit)
1405
+ }
1406
+ sourceFile.forEachChild(visit)
1407
+ return { facts, unresolvedTokens }
810
1408
  }
811
1409
 
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')
1410
+ function deriveLegacyDiTokens(facts: readonly ModuleDiRegistrationFact[]): string[] {
1411
+ const tokens: string[] = []
1412
+ const seen = new Set<string>()
1413
+ for (const fact of facts) {
1414
+ if (fact.registrationKind !== 'function' && fact.registrationKind !== 'class') continue
1415
+ if (seen.has(fact.token)) continue
1416
+ seen.add(fact.token)
1417
+ tokens.push(fact.token)
1418
+ }
1419
+ return tokens
821
1420
  }
822
1421
 
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')
1422
+ function dedupeOwnedContractFacts(facts: readonly ModuleOwnedContractFact[]): ModuleOwnedContractFact[] {
1423
+ const byId = new Map<string, ModuleOwnedContractFact>()
1424
+ for (const fact of facts) {
1425
+ const existing = byId.get(fact.id)
1426
+ if (!existing) {
1427
+ byId.set(fact.id, fact)
1428
+ continue
1429
+ }
1430
+ if (fact.source.sourcePath.localeCompare(existing.source.sourcePath) < 0) byId.set(fact.id, fact)
1431
+ }
1432
+ return [...byId.values()].sort((left, right) => left.id.localeCompare(right.id))
830
1433
  }
831
1434
 
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(' · ')}`
1435
+ const PAGE_CONTEXT_VALUES = new Set(['main', 'admin', 'settings', 'profile'])
1436
+
1437
+ function readAliasedStringProperty(
1438
+ metadata: ts.ObjectLiteralExpression,
1439
+ preferred: string,
1440
+ fallback: string,
1441
+ ): string | undefined {
1442
+ return readStringPropertyInitializer(metadata, preferred) ?? readStringPropertyInitializer(metadata, fallback)
835
1443
  }
836
1444
 
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'
1445
+ function readNumericProperty(metadata: ts.ObjectLiteralExpression, propertyName: string): number | undefined {
1446
+ const initializer = getObjectPropertyInitializer(metadata, propertyName)
1447
+ if (!initializer || !ts.isNumericLiteral(initializer)) return undefined
1448
+ return Number(initializer.text)
843
1449
  }
844
1450
 
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])
1451
+ function readPageMetadata(sourceFile: ts.SourceFile): ModulePageMetadata | undefined {
1452
+ const metadata = findObjectLiteralDeclaration(sourceFile, 'metadata')
1453
+ if (!metadata) return undefined
1454
+ const result: ModulePageMetadata = {}
1455
+ const requireAuth = readBooleanPropertyInitializer(metadata, 'requireAuth')
1456
+ if (requireAuth !== undefined) result.requireAuth = requireAuth
1457
+ const requireCustomerAuth = readBooleanPropertyInitializer(metadata, 'requireCustomerAuth')
1458
+ if (requireCustomerAuth !== undefined) result.requireCustomerAuth = requireCustomerAuth
1459
+ const requireFeatures = readStringArrayPropertyInitializer(metadata, 'requireFeatures')
1460
+ if (requireFeatures && requireFeatures.length > 0) result.requireFeatures = requireFeatures
1461
+ const requireCustomerFeatures = readStringArrayPropertyInitializer(metadata, 'requireCustomerFeatures')
1462
+ if (requireCustomerFeatures && requireCustomerFeatures.length > 0) {
1463
+ result.requireCustomerFeatures = requireCustomerFeatures
1464
+ }
1465
+ const navHidden = readBooleanPropertyInitializer(metadata, 'navHidden')
1466
+ if (navHidden !== undefined) result.navHidden = navHidden
1467
+ const navGroup = readAliasedStringProperty(metadata, 'pageGroup', 'group')
1468
+ if (navGroup) result.navGroup = navGroup
1469
+ const navGroupKey = readAliasedStringProperty(metadata, 'pageGroupKey', 'groupKey')
1470
+ if (navGroupKey) result.navGroupKey = navGroupKey
1471
+ const navOrder = readNumericProperty(metadata, 'pageOrder') ?? readNumericProperty(metadata, 'order')
1472
+ if (navOrder !== undefined) result.navOrder = navOrder
1473
+ const pageContext = readStringPropertyInitializer(metadata, 'pageContext')
1474
+ if (pageContext && PAGE_CONTEXT_VALUES.has(pageContext)) {
1475
+ result.pageContext = pageContext as ModulePageMetadata['pageContext']
1476
+ }
1477
+ return Object.keys(result).length > 0 ? result : undefined
1478
+ }
1479
+
1480
+ /**
1481
+ * Mirrors the runtime companion-file convention: a page's metadata comes from
1482
+ * `page.meta.*` (or `meta.*`) in the page directory when one exists, and only
1483
+ * otherwise from the page component itself.
1484
+ */
1485
+ function resolvePageMetadataFile(pageDirectory: string): string | null {
1486
+ for (const baseName of ['page.meta', 'meta']) {
1487
+ for (const extension of MODULE_CODE_EXTENSIONS) {
1488
+ const candidate = path.join(pageDirectory, `${baseName}${extension}`)
1489
+ if (fs.existsSync(candidate)) return candidate
1490
+ }
1491
+ }
1492
+ return null
1493
+ }
1494
+
1495
+ function extractModuleMetadataContract(
1496
+ indexFilePath: string | null,
1497
+ moduleId: string,
1498
+ sourcePath: string | null,
1499
+ ): ModuleOwnedContractFact | null {
1500
+ if (!indexFilePath || !sourcePath) return null
1501
+ const sourceFile = readSourceFile(indexFilePath)
1502
+ if (!sourceFile) return null
1503
+ const metadata = findObjectLiteralDeclaration(sourceFile, 'metadata')
1504
+ if (!metadata) return null
1505
+ const facts: Record<string, ModuleFactSafeScalar | ModuleFactSafeScalar[]> = {}
1506
+ const name = readStringPropertyInitializer(metadata, 'name')
1507
+ if (name) facts.name = name
1508
+ const version = readStringPropertyInitializer(metadata, 'version')
1509
+ if (version) facts.version = version
1510
+ const requires = readStringArrayPropertyInitializer(metadata, 'requires')
1511
+ if (requires && requires.length > 0) facts.requires = requires
1512
+ const ejectable = readBooleanPropertyInitializer(metadata, 'ejectable')
1513
+ if (ejectable !== undefined) facts.ejectable = ejectable
1514
+ return {
1515
+ kind: 'module-metadata',
1516
+ id: moduleId,
1517
+ source: { sourcePath, exportName: 'metadata', line: nodeLine(sourceFile, metadata) },
1518
+ ...(Object.keys(facts).length > 0 ? { metadata: facts } : {}),
1519
+ }
1520
+ }
1521
+
1522
+ function extractCommandContractCandidates(
1523
+ moduleId: string,
1524
+ moduleRoot: string,
1525
+ sourceRoot: string,
1526
+ ): Array<{ id: string; sourcePath: string }> {
1527
+ const commandsRoot = path.join(moduleRoot, 'commands')
1528
+ if (!fs.existsSync(commandsRoot)) return []
1529
+ const candidates: Array<{ id: string; sourcePath: string }> = []
1530
+ for (const filePath of listSourceFilesRecursive(commandsRoot)) {
1531
+ if (path.basename(filePath).replace(/\.tsx?$/, '') === 'interceptors') continue
1532
+ const sourcePath = toPortableSourcePath(moduleRoot, sourceRoot, filePath)
1533
+ for (const id of extractCommandIdsFromSource(filePath)) candidates.push({ id, sourcePath })
1534
+ }
1535
+ return candidates
1536
+ }
1537
+
1538
+ /**
1539
+ * Runtime worker id: `metadata.id` when declared, otherwise the path-derived
1540
+ * `<moduleId>:workers:<...segments>:<fileStem>` that `discoverWorkers` builds.
1541
+ */
1542
+ function deriveWorkerId(moduleId: string, workersRoot: string, filePath: string): string {
1543
+ const relativePath = path.relative(workersRoot, filePath).split(path.sep).join('/')
1544
+ const segments = relativePath.split('/')
1545
+ const fileName = segments.pop() as string
1546
+ const fileStem = fileName.replace(/\.tsx?$/, '')
1547
+ return [moduleId, 'workers', ...segments, fileStem].filter(Boolean).join(':')
1548
+ }
1549
+
1550
+ /** Resolves a string property directly or through a local `const` in the same file. */
1551
+ function readResolvedStringProperty(
1552
+ objectLiteral: ts.ObjectLiteralExpression,
1553
+ propertyName: string,
1554
+ initializers: Map<string, ts.Expression>,
1555
+ ): string | undefined {
1556
+ const initializer = getObjectPropertyInitializer(objectLiteral, propertyName)
1557
+ if (!initializer) return undefined
1558
+ const resolved = unwrapExpression(initializer)
1559
+ if (ts.isStringLiteralLike(resolved)) return resolved.text
1560
+ if (ts.isIdentifier(resolved)) {
1561
+ const declaration = initializers.get(resolved.text)
1562
+ if (declaration) {
1563
+ const declarationValue = unwrapExpression(declaration)
1564
+ if (ts.isStringLiteralLike(declarationValue)) return declarationValue.text
1565
+ }
1566
+ }
1567
+ return undefined
1568
+ }
1569
+
1570
+ function extractWorkerContracts(
1571
+ moduleId: string,
1572
+ moduleRoot: string,
1573
+ sourceRoot: string,
1574
+ ): { facts: ModuleOwnedContractFact[]; unresolved: ModuleFactDiagnostic[] } {
1575
+ const workersRoot = path.join(moduleRoot, 'workers')
1576
+ if (!fs.existsSync(workersRoot)) return { facts: [], unresolved: [] }
1577
+ const facts: ModuleOwnedContractFact[] = []
1578
+ const unresolved: ModuleFactDiagnostic[] = []
1579
+ for (const filePath of listSourceFilesRecursive(workersRoot)) {
1580
+ const sourceFile = readSourceFile(filePath)
1581
+ if (!sourceFile) continue
1582
+ const metadata = findObjectLiteralDeclaration(sourceFile, 'metadata')
1583
+ if (!metadata) continue
1584
+ const sourcePath = toPortableSourcePath(moduleRoot, sourceRoot, filePath)
1585
+ const source = { sourcePath, exportName: 'metadata', line: nodeLine(sourceFile, metadata) }
1586
+ const initializers = buildVariableInitializerMap(sourceFile)
1587
+ const workerId = readResolvedStringProperty(metadata, 'id', initializers)
1588
+ ?? deriveWorkerId(moduleId, workersRoot, filePath)
1589
+
1590
+ // Runtime skips any worker without a queue, so a worker missing the required
1591
+ // property is not a contract; a present-but-dynamic queue is reported instead.
1592
+ const hasQueueProperty = getObjectPropertyInitializer(metadata, 'queue') !== undefined
1593
+ const queue = readResolvedStringProperty(metadata, 'queue', initializers)
1594
+ if (!hasQueueProperty) {
1595
+ unresolved.push({ code: 'unresolved-static-contract', kind: 'worker', id: workerId, source })
1596
+ continue
1597
+ }
1598
+
1599
+ const workerMetadata: Record<string, ModuleFactSafeScalar | ModuleFactSafeScalar[]> = {}
1600
+ if (queue) workerMetadata.queue = queue
1601
+ else unresolved.push({ code: 'unresolved-static-contract', kind: 'worker', id: workerId, source })
1602
+ const name = readResolvedStringProperty(metadata, 'name', initializers)
1603
+ if (name) workerMetadata.name = name
1604
+ const concurrencyInitializer = getObjectPropertyInitializer(metadata, 'concurrency')
1605
+ if (concurrencyInitializer && ts.isNumericLiteral(concurrencyInitializer)) {
1606
+ workerMetadata.concurrency = Number(concurrencyInitializer.text)
1607
+ }
1608
+ facts.push({
1609
+ kind: 'worker',
1610
+ id: workerId,
1611
+ source,
1612
+ ...(Object.keys(workerMetadata).length > 0 ? { metadata: workerMetadata } : {}),
1613
+ })
1614
+ }
1615
+ return { facts: dedupeOwnedContractFacts(facts), unresolved }
1616
+ }
1617
+
1618
+ function extractPageMiddlewareContracts(
1619
+ moduleRoot: string,
1620
+ sourceRoot: string,
1621
+ ): { facts: ModuleOwnedContractFact[]; unresolved: ModuleFactDiagnostic[] } {
1622
+ const facts: ModuleOwnedContractFact[] = []
1623
+ const unresolved: ModuleFactDiagnostic[] = []
1624
+ for (const surface of ['backend', 'frontend'] as const) {
1625
+ const filePath = resolveConventionFile(path.join(moduleRoot, surface), 'middleware')
1626
+ if (!filePath) continue
1627
+ const sourceFile = readSourceFile(filePath)
1628
+ if (!sourceFile) continue
1629
+ const sourcePath = toPortableSourcePath(moduleRoot, sourceRoot, filePath)
1630
+ const middlewareArray =
1631
+ findArrayLiteralDeclaration(sourceFile, 'middleware') ??
1632
+ (() => {
1633
+ const defaultExport = findDefaultExportExpression(sourceFile)
1634
+ return defaultExport ? unwrapArrayLiteral(defaultExport) : null
1635
+ })()
1636
+ if (!middlewareArray) continue
1637
+ let index = 0
1638
+ for (const element of middlewareArray.elements) {
1639
+ if (!ts.isObjectLiteralExpression(element)) continue
1640
+ const middlewareId = readStringPropertyInitializer(element, 'id')
1641
+ if (!middlewareId) {
1642
+ unresolved.push({
1643
+ code: 'unresolved-static-contract',
1644
+ kind: 'page-middleware',
1645
+ id: `${sourcePath}#${index}`,
1646
+ source: { sourcePath, line: nodeLine(sourceFile, element) },
1647
+ })
1648
+ index += 1
1649
+ continue
1650
+ }
1651
+ const metadata: Record<string, ModuleFactSafeScalar | ModuleFactSafeScalar[]> = { surface }
1652
+ const mode = readStringPropertyInitializer(element, 'mode')
1653
+ if (mode) metadata.mode = mode
1654
+ const priorityInitializer = getObjectPropertyInitializer(element, 'priority')
1655
+ if (priorityInitializer && ts.isNumericLiteral(priorityInitializer)) {
1656
+ metadata.priority = Number(priorityInitializer.text)
1657
+ }
1658
+ facts.push({
1659
+ kind: 'page-middleware',
1660
+ id: middlewareId,
1661
+ source: { sourcePath, exportName: 'middleware', line: nodeLine(sourceFile, element) },
1662
+ metadata,
1663
+ })
1664
+ index += 1
1665
+ }
1666
+ }
1667
+ return { facts: dedupeOwnedContractFacts(facts), unresolved }
1668
+ }
1669
+
1670
+ function extractSetupContract(
1671
+ moduleId: string,
1672
+ setupFilePath: string | null,
1673
+ sourcePath: string | null,
1674
+ ): ModuleOwnedContractFact | null {
1675
+ if (!setupFilePath || !sourcePath) return null
1676
+ const sourceFile = readSourceFile(setupFilePath)
1677
+ if (!sourceFile) return null
1678
+ const setupObject =
1679
+ findObjectLiteralDeclaration(sourceFile, 'setup') ??
1680
+ (() => {
1681
+ const defaultExport = findDefaultExportExpression(sourceFile)
1682
+ if (!defaultExport) return null
1683
+ const unwrapped = unwrapExpression(defaultExport)
1684
+ return ts.isObjectLiteralExpression(unwrapped) ? unwrapped : null
1685
+ })()
1686
+ if (!setupObject) return null
1687
+ const hooks = readMethodBearingPropertyNames(setupObject)
1688
+ const metadata: Record<string, ModuleFactSafeScalar | ModuleFactSafeScalar[]> = {}
1689
+ if (hooks.length > 0) metadata.hooks = hooks
1690
+ const roleFeatures = getObjectPropertyInitializer(setupObject, 'defaultRoleFeatures')
1691
+ if (roleFeatures && ts.isObjectLiteralExpression(roleFeatures)) {
1692
+ const roles = readObjectKeyNames(roleFeatures)
1693
+ if (roles.length > 0) metadata.roles = roles
1694
+ }
1695
+ const customerRoleFeatures = getObjectPropertyInitializer(setupObject, 'defaultCustomerRoleFeatures')
1696
+ if (customerRoleFeatures && ts.isObjectLiteralExpression(customerRoleFeatures)) {
1697
+ const customerRoles = readObjectKeyNames(customerRoleFeatures)
1698
+ if (customerRoles.length > 0) metadata.customerRoles = customerRoles
1699
+ }
1700
+ return {
1701
+ kind: 'setup',
1702
+ id: `${moduleId}:setup`,
1703
+ source: { sourcePath, exportName: 'setup', line: nodeLine(sourceFile, setupObject) },
1704
+ ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
1705
+ }
1706
+ }
1707
+
1708
+ function extractEncryptionContracts(
1709
+ encryptionFilePath: string | null,
1710
+ sourcePath: string | null,
1711
+ ): ModuleOwnedContractFact[] {
1712
+ if (!encryptionFilePath || !sourcePath) return []
1713
+ const sourceFile = readSourceFile(encryptionFilePath)
1714
+ if (!sourceFile) return []
1715
+ const mapsArray =
1716
+ findArrayLiteralDeclaration(sourceFile, 'defaultEncryptionMaps') ??
1717
+ findArrayLiteralDeclaration(sourceFile, 'encryptionMaps')
1718
+ if (!mapsArray) return []
1719
+ const facts: ModuleOwnedContractFact[] = []
1720
+ for (const element of mapsArray.elements) {
1721
+ if (!ts.isObjectLiteralExpression(element)) continue
1722
+ const entityId = readStringPropertyInitializer(element, 'entityId')
1723
+ if (!entityId) continue
1724
+ const fieldsInitializer = getObjectPropertyInitializer(element, 'fields')
1725
+ const fields: string[] = []
1726
+ if (fieldsInitializer) {
1727
+ const fieldsArray = unwrapArrayLiteral(fieldsInitializer)
1728
+ if (fieldsArray) {
1729
+ for (const fieldElement of fieldsArray.elements) {
1730
+ if (!ts.isObjectLiteralExpression(fieldElement)) continue
1731
+ const field = readStringPropertyInitializer(fieldElement, 'field')
1732
+ if (field) fields.push(field)
1733
+ }
1734
+ }
1735
+ }
1736
+ facts.push({
1737
+ kind: 'encryption',
1738
+ id: entityId,
1739
+ source: { sourcePath, exportName: 'defaultEncryptionMaps', line: nodeLine(sourceFile, element) },
1740
+ ...(fields.length > 0 ? { metadata: { fields: fields.sort((left, right) => left.localeCompare(right)) } } : {}),
1741
+ })
1742
+ }
1743
+ return dedupeOwnedContractFacts(facts)
1744
+ }
1745
+
1746
+ function extractCustomEntityContracts(
1747
+ ceFilePath: string | null,
1748
+ sourcePath: string | null,
1749
+ ): ModuleOwnedContractFact[] {
1750
+ if (!ceFilePath || !sourcePath) return []
1751
+ const entityIds = [...collectCustomFieldEntityIds(ceFilePath)].sort((left, right) => left.localeCompare(right))
1752
+ return entityIds.map((id) => ({
1753
+ kind: 'custom-entity' as const,
1754
+ id,
1755
+ source: { sourcePath, exportName: 'default' },
1756
+ }))
1757
+ }
1758
+
1759
+ /**
1760
+ * Keyed AI override exports. Agent *extensions* (`aiAgentExtensions`) patch an agent
1761
+ * additively and belong to the `ai.extensions` array; these two replace or disable an
1762
+ * entry by key and map to exact `ai.agents.<id>` / `ai.tools.<id>` override targets.
1763
+ */
1764
+ const AI_OVERRIDE_EXPORTS = [
1765
+ { exportName: 'aiAgentOverrides', target: 'agent' },
1766
+ { exportName: 'aiToolOverrides', target: 'tool' },
1767
+ ] as const
1768
+
1769
+ function extractAiExtensionContracts(
1770
+ moduleRoot: string,
1771
+ sourceRoot: string,
1772
+ ): ModuleOwnedContractFact[] {
1773
+ const facts: ModuleOwnedContractFact[] = []
1774
+ const sources: Array<{ basename: string; relativeDir: string }> = [
1775
+ { basename: 'ai-agents', relativeDir: '.' },
1776
+ { basename: 'ai-tools', relativeDir: '.' },
1777
+ { basename: 'ai-agents.extensions', relativeDir: '.' },
1778
+ { basename: 'ai-tools.extensions', relativeDir: '.' },
1779
+ ]
1780
+ for (const entry of sources) {
1781
+ const filePath = resolveConventionFile(path.join(moduleRoot, entry.relativeDir), entry.basename)
1782
+ if (!filePath) continue
1783
+ const sourceFile = readSourceFile(filePath)
1784
+ if (!sourceFile) continue
1785
+ const sourcePath = toPortableSourcePath(moduleRoot, sourceRoot, filePath)
1786
+ const extensionsArray = findArrayLiteralDeclaration(sourceFile, 'aiAgentExtensions')
1787
+ if (extensionsArray) {
1788
+ for (const element of extensionsArray.elements) {
1789
+ const objectLiteral = ts.isObjectLiteralExpression(element)
1790
+ ? element
1791
+ : ts.isCallExpression(element) && element.arguments[0] && ts.isObjectLiteralExpression(element.arguments[0])
1792
+ ? element.arguments[0]
1793
+ : null
1794
+ if (!objectLiteral) continue
1795
+ const targetAgentId = readStringPropertyInitializer(objectLiteral, 'targetAgentId')
1796
+ if (!targetAgentId) continue
1797
+ facts.push({
1798
+ kind: 'ai-extension',
1799
+ id: `agent-extension:${targetAgentId}`,
1800
+ source: { sourcePath, exportName: 'aiAgentExtensions', line: nodeLine(sourceFile, objectLiteral) },
1801
+ metadata: { target: 'agent', mode: 'extension', targetAgentId },
1802
+ })
1803
+ }
1804
+ }
1805
+ // `aiAgentOverrides` / `aiToolOverrides` are keyed replace-or-disable maps; only
1806
+ // their keys are contract facts. Values (agent/tool definitions, `null` disables)
1807
+ // are never read or serialized.
1808
+ for (const { exportName, target } of AI_OVERRIDE_EXPORTS) {
1809
+ const overridesObject = findObjectLiteralDeclaration(sourceFile, exportName)
1810
+ if (!overridesObject) continue
1811
+ for (const property of overridesObject.properties) {
1812
+ const overrideId = getPropertyName(property)
1813
+ if (!overrideId) continue
1814
+ facts.push({
1815
+ kind: 'ai-extension',
1816
+ id: `${target}-override:${overrideId}`,
1817
+ source: { sourcePath, exportName, line: nodeLine(sourceFile, property) },
1818
+ metadata: { target, mode: 'override', overrideKey: overrideId },
1819
+ })
1820
+ }
1821
+ }
1822
+ }
1823
+ return dedupeOwnedContractFacts(facts)
1824
+ }
1825
+
1826
+ function extractGeneratorPluginContracts(
1827
+ moduleRoot: string,
1828
+ sourceRoot: string,
1829
+ ): { facts: ModuleOwnedContractFact[]; unresolved: ModuleFactDiagnostic[] } {
1830
+ const filePath = resolveConventionFile(moduleRoot, 'generators')
1831
+ if (!filePath) return { facts: [], unresolved: [] }
1832
+ const sourceFile = readSourceFile(filePath)
1833
+ if (!sourceFile) return { facts: [], unresolved: [] }
1834
+ const pluginsArray = findArrayLiteralDeclaration(sourceFile, 'generatorPlugins')
1835
+ if (!pluginsArray) return { facts: [], unresolved: [] }
1836
+ const sourcePath = toPortableSourcePath(moduleRoot, sourceRoot, filePath)
1837
+ const initializers = buildVariableInitializerMap(sourceFile)
1838
+ const facts: ModuleOwnedContractFact[] = []
1839
+ const unresolved: ModuleFactDiagnostic[] = []
1840
+ let index = 0
1841
+ for (const element of pluginsArray.elements) {
1842
+ const objectLiteral = resolveToObjectLiteral(element, initializers)
1843
+ if (!objectLiteral) {
1844
+ index += 1
1845
+ continue
1846
+ }
1847
+ const pluginId = readStringPropertyInitializer(objectLiteral, 'id')
1848
+ if (!pluginId) {
1849
+ unresolved.push({
1850
+ code: 'unresolved-static-contract',
1851
+ kind: 'generator-plugin',
1852
+ id: `${sourcePath}#${index}`,
1853
+ source: { sourcePath, exportName: 'generatorPlugins', line: nodeLine(sourceFile, objectLiteral) },
1854
+ })
1855
+ index += 1
1856
+ continue
1857
+ }
1858
+ const metadata: Record<string, ModuleFactSafeScalar | ModuleFactSafeScalar[]> = {}
1859
+ const conventionFile = readStringPropertyInitializer(objectLiteral, 'conventionFile')
1860
+ if (conventionFile) metadata.conventionFile = conventionFile
1861
+ const outputFileName = readStringPropertyInitializer(objectLiteral, 'outputFileName')
1862
+ if (outputFileName) metadata.outputFileName = outputFileName
1863
+ facts.push({
1864
+ kind: 'generator-plugin',
1865
+ id: pluginId,
1866
+ source: { sourcePath, exportName: 'generatorPlugins', line: nodeLine(sourceFile, objectLiteral) },
1867
+ ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
1868
+ })
1869
+ index += 1
1870
+ }
1871
+ return { facts: dedupeOwnedContractFacts(facts), unresolved }
1872
+ }
1873
+
1874
+ function compareSourceRefs(left: ModuleFactSourceRef, right: ModuleFactSourceRef): number {
1875
+ return (
1876
+ left.sourcePath.localeCompare(right.sourcePath) ||
1877
+ (left.line ?? 0) - (right.line ?? 0) ||
1878
+ (left.exportName ?? '').localeCompare(right.exportName ?? '')
1879
+ )
1880
+ }
1881
+
1882
+ function sameSourceRef(left: ModuleFactSourceRef, right: ModuleFactSourceRef): boolean {
1883
+ return (
1884
+ left.sourcePath === right.sourcePath &&
1885
+ (left.line ?? null) === (right.line ?? null) &&
1886
+ (left.exportName ?? null) === (right.exportName ?? null)
1887
+ )
1888
+ }
1889
+
1890
+ // Every proven `(kind, id)` reaches `factSources`. Facts whose declaration site is already
1891
+ // serialized inline (pages, CLI commands, routes, AI tools/agents, reused contributions) and
1892
+ // the owned families (which carry `source` inside `ownedContracts`) are emitted as a typed
1893
+ // `factRef` pointer instead of a duplicated source ref, so the index stays uniform without
1894
+ // copying provenance payloads past the determinism byte guard. Duplicate detection runs over
1895
+ // ALL candidates regardless of which projection they take.
1896
+ type ProvenanceCandidate = {
1897
+ kind: ModuleFactSourceKind
1898
+ id: string
1899
+ source?: ModuleFactSourceRef
1900
+ factRef?: ModuleFactRef
1901
+ }
1902
+
1903
+ function provenanceCandidateOrderKey(candidate: ProvenanceCandidate): string {
1904
+ if (candidate.source) {
1905
+ return JSON.stringify([
1906
+ 0,
1907
+ candidate.source.sourcePath,
1908
+ candidate.source.line ?? 0,
1909
+ candidate.source.exportName ?? '',
1910
+ ])
1911
+ }
1912
+ return JSON.stringify([1, candidate.factRef?.factSection ?? '', candidate.factRef?.factKey ?? ''])
1913
+ }
1914
+
1915
+ function buildProvenanceIndex(candidates: readonly ProvenanceCandidate[]): {
1916
+ factSources: ModuleFactSourceEntry[]
1917
+ diagnostics: ModuleFactDiagnostic[]
1918
+ } {
1919
+ const grouped = new Map<string, ProvenanceCandidate[]>()
1920
+ for (const candidate of candidates) {
1921
+ const key = JSON.stringify([candidate.kind, candidate.id])
1922
+ const group = grouped.get(key) ?? []
1923
+ group.push(candidate)
1924
+ grouped.set(key, group)
1925
+ }
1926
+ // Keep the provenance index compact: sourcePath is the provenance and (kind,id) the
1927
+ // stable identity, so exportName and line (recoverable from source) are dropped here.
1928
+ // Owned families retain the richer source ref inside `ownedContracts`.
1929
+ const factSources: ModuleFactSourceEntry[] = []
1930
+ const diagnostics: ModuleFactDiagnostic[] = []
1931
+ for (const group of grouped.values()) {
1932
+ const sorted = [...group].sort(
1933
+ (left, right) => provenanceCandidateOrderKey(left).localeCompare(provenanceCandidateOrderKey(right)),
1934
+ )
1935
+ const canonical = sorted[0]
1936
+ factSources.push(
1937
+ canonical.factRef
1938
+ ? {
1939
+ kind: canonical.kind,
1940
+ id: canonical.id,
1941
+ factRef: {
1942
+ factSection: canonical.factRef.factSection,
1943
+ ...(canonical.factRef.factKey === canonical.id
1944
+ ? {}
1945
+ : { factKey: canonical.factRef.factKey }),
1946
+ },
1947
+ }
1948
+ : {
1949
+ kind: canonical.kind,
1950
+ id: canonical.id,
1951
+ source: { sourcePath: (canonical.source as ModuleFactSourceRef).sourcePath },
1952
+ },
1953
+ )
1954
+ const canonicalKey = provenanceCandidateOrderKey(canonical)
1955
+ for (const rejected of sorted.slice(1)) {
1956
+ if (provenanceCandidateOrderKey(rejected) === canonicalKey) continue
1957
+ diagnostics.push({
1958
+ code: 'duplicate-source',
1959
+ kind: rejected.kind,
1960
+ id: rejected.id,
1961
+ ...(rejected.source ? { source: rejected.source } : {}),
1962
+ })
1963
+ }
1964
+ }
1965
+ factSources.sort(
1966
+ (left, right) => left.kind.localeCompare(right.kind) || left.id.localeCompare(right.id),
1967
+ )
1968
+ diagnostics.sort(
1969
+ (left, right) =>
1970
+ left.code.localeCompare(right.code) ||
1971
+ left.kind.localeCompare(right.kind) ||
1972
+ left.id.localeCompare(right.id) ||
1973
+ compareSourceRefs(left.source ?? { sourcePath: '' }, right.source ?? { sourcePath: '' }),
1974
+ )
1975
+ return { factSources, diagnostics }
1976
+ }
1977
+
1978
+ function buildOwnedContracts(
1979
+ families: ReadonlyArray<[ModuleOwnedContractKind, ModuleOwnedContractFact[]]>,
1980
+ ): ModuleOwnedContracts | undefined {
1981
+ const result: ModuleOwnedContracts = {}
1982
+ for (const [kind, facts] of families) {
1983
+ if (facts.length === 0) continue
1984
+ // Drop the (constant/redundant) exportName from the emitted source ref; the line
1985
+ // anchor plus sourcePath already pin the declaration and keep the JSON compact.
1986
+ result[kind] = facts.map((fact) => ({
1987
+ ...fact,
1988
+ source: {
1989
+ sourcePath: fact.source.sourcePath,
1990
+ ...(fact.source.line ? { line: fact.source.line } : {}),
1991
+ },
1992
+ }))
1993
+ }
1994
+ return Object.keys(result).length > 0 ? result : undefined
1995
+ }
1996
+
1997
+ const CONTRIBUTION_KIND_TO_SOURCE_KIND: Partial<Record<string, ModuleFactSourceKind>> = {
1998
+ subscriber: 'subscriber',
1999
+ }
2000
+
2001
+ const REGISTRY_TO_SOURCE_KIND: Partial<Record<string, ModuleFactSourceKind>> = {
2002
+ vector: 'vector',
2003
+ integration: 'integration',
2004
+ payment: 'integration',
2005
+ shipping: 'integration',
2006
+ currency: 'integration',
2007
+ }
2008
+
2009
+ export function extractModuleFacts(options: ExtractModuleFactsOptions): ModuleFacts {
2010
+ const { moduleId, coreVersion = null, sourcePackage = null, sourceVersion = null } = options
2011
+ const moduleRoot = options.moduleRoot
2012
+ ?? (options.coreSrcRoot ? path.join(options.coreSrcRoot, moduleId) : null)
2013
+ if (!moduleRoot) {
2014
+ throw new Error(`[internal] extractModuleFacts requires moduleRoot or coreSrcRoot for module "${moduleId}"`)
2015
+ }
2016
+ const sourceRoot = options.portableSourceRoot
2017
+ ? assertPortableSourceRoot(moduleId, options.portableSourceRoot)
2018
+ : toPortableSourceRoot(moduleId, sourcePackage)
2019
+
2020
+ const entitiesFilePath =
2021
+ resolveConventionFile(path.join(moduleRoot, 'data'), 'entities') ??
2022
+ resolveConventionFile(path.join(moduleRoot, 'db'), 'entities') ??
2023
+ resolveConventionFile(path.join(moduleRoot, 'data'), 'schema')
2024
+ const ceFilePath = resolveConventionFile(moduleRoot, 'ce')
2025
+ const eventsFilePath = resolveConventionFile(moduleRoot, 'events')
2026
+ const aclFilePath = resolveConventionFile(moduleRoot, 'acl')
2027
+ const diFilePath = resolveConventionFile(moduleRoot, 'di')
2028
+ const searchFilePath = resolveConventionFile(moduleRoot, 'search')
2029
+ const notificationsFilePath = resolveConventionFile(moduleRoot, 'notifications')
2030
+ const cliFilePath = resolveConventionFile(moduleRoot, 'cli')
2031
+ const indexFilePath = resolveConventionFile(moduleRoot, 'index')
2032
+ const setupFilePath = resolveConventionFile(moduleRoot, 'setup')
2033
+ const encryptionFilePath = resolveConventionFile(moduleRoot, 'encryption')
2034
+
2035
+ const portableOf = (filePath: string | null): string | null =>
2036
+ filePath ? toPortableSourcePath(moduleRoot, sourceRoot, filePath) : null
2037
+
2038
+ const warnings: string[] = []
2039
+ const { title, description } = extractModuleMeta(indexFilePath)
2040
+ const customFieldEntityIds = collectCustomFieldEntityIds(ceFilePath)
2041
+ const entities = extractEntities(moduleId, entitiesFilePath, customFieldEntityIds)
2042
+ const events = extractEvents(eventsFilePath)
2043
+ const aclFeatures = extractAclFeatures(aclFilePath)
2044
+
2045
+ const { source: registrySource, description: registryDescription } = resolveRegistrySource(options)
2046
+ const apiRouteSourcePaths = extractApiRouteSourcePaths(moduleId, moduleRoot, sourceRoot)
2047
+ const apiRoutes = extractApiRoutes(moduleId, registrySource, registryDescription, apiRouteSourcePaths, warnings)
2048
+ const diRegistrations = extractDiRegistrationFacts(diFilePath, portableOf(diFilePath) ?? '')
2049
+ const diTokens = deriveLegacyDiTokens(diRegistrations.facts)
2050
+ for (const legacyToken of extractDiTokens(diFilePath)) {
2051
+ if (!diTokens.includes(legacyToken)) diTokens.push(legacyToken)
2052
+ }
2053
+ const searchEntities = extractSearchEntities(searchFilePath, warnings)
2054
+ const notifications = extractNotifications(notificationsFilePath, warnings)
2055
+ const cli = extractCli(cliFilePath, warnings)
2056
+ const cliCommands = cliFilePath
2057
+ ? cli.map((command) => ({
2058
+ command,
2059
+ sourcePath: toPortableSourcePath(moduleRoot, sourceRoot, cliFilePath),
2060
+ }))
2061
+ : []
2062
+ const backendPages = extractModulePages(moduleId, moduleRoot, sourceRoot, 'backend')
2063
+ const frontendPages = extractModulePages(moduleId, moduleRoot, sourceRoot, 'frontend')
2064
+ const aiTools = extractAiTools(moduleRoot, sourceRoot)
2065
+ const aiAgents = extractAiAgents(moduleRoot, sourceRoot)
2066
+ const hostTokens: ModuleHostTokens = {
2067
+ entityIds: extractHostEntityIds(entities),
2068
+ tableIds: extractTableIds(moduleRoot),
2069
+ }
2070
+ const extensionSurfaces = extractModuleExtensionFacts({
2071
+ moduleId,
2072
+ moduleRoot,
2073
+ sourceRoot,
2074
+ entities,
2075
+ events,
2076
+ apiRoutes,
2077
+ searchEntities,
2078
+ notifications,
2079
+ aiTools,
2080
+ aiAgents,
2081
+ })
2082
+
2083
+ const indexSourcePath = portableOf(indexFilePath)
2084
+ const diSourcePath = portableOf(diFilePath)
2085
+ const moduleMetadataFact = extractModuleMetadataContract(indexFilePath, moduleId, indexSourcePath)
2086
+ const commandCandidates = extractCommandContractCandidates(moduleId, moduleRoot, sourceRoot)
2087
+ const commandFacts = dedupeOwnedContractFacts(
2088
+ commandCandidates.map((candidate) => ({
2089
+ kind: 'command' as const,
2090
+ id: candidate.id,
2091
+ source: { sourcePath: candidate.sourcePath },
2092
+ })),
2093
+ )
2094
+ const workers = extractWorkerContracts(moduleId, moduleRoot, sourceRoot)
2095
+ const pageMiddleware = extractPageMiddlewareContracts(moduleRoot, sourceRoot)
2096
+ const setupFact = extractSetupContract(moduleId, setupFilePath, portableOf(setupFilePath))
2097
+ const encryptionFacts = extractEncryptionContracts(encryptionFilePath, portableOf(encryptionFilePath))
2098
+ const customEntityFacts = extractCustomEntityContracts(ceFilePath, portableOf(ceFilePath))
2099
+ const aiExtensionFacts = extractAiExtensionContracts(moduleRoot, sourceRoot)
2100
+ const generatorPlugins = extractGeneratorPluginContracts(moduleRoot, sourceRoot)
2101
+ const diContractFacts = dedupeOwnedContractFacts(
2102
+ diRegistrations.facts.map((fact) => ({
2103
+ kind: 'di-registration' as const,
2104
+ id: fact.token,
2105
+ source: fact.source,
2106
+ metadata: {
2107
+ registrationKind: fact.registrationKind,
2108
+ ...(fact.providerSymbol ? { providerSymbol: fact.providerSymbol } : {}),
2109
+ ...(fact.lifetime ? { lifetime: fact.lifetime } : {}),
2110
+ ...(fact.injectionMode ? { injectionMode: fact.injectionMode } : {}),
2111
+ },
2112
+ })),
2113
+ )
2114
+
2115
+ const ownedContracts = buildOwnedContracts([
2116
+ ['module-metadata', moduleMetadataFact ? [moduleMetadataFact] : []],
2117
+ ['command', commandFacts],
2118
+ ['worker', workers.facts],
2119
+ ['page-middleware', pageMiddleware.facts],
2120
+ ['setup', setupFact ? [setupFact] : []],
2121
+ ['encryption', encryptionFacts],
2122
+ ['di-registration', diContractFacts],
2123
+ ['custom-entity', customEntityFacts],
2124
+ ['ai-extension', aiExtensionFacts],
2125
+ ['generator-plugin', generatorPlugins.facts],
2126
+ ])
2127
+
2128
+ const provenanceCandidates: ProvenanceCandidate[] = []
2129
+ const pushSource = (kind: ModuleFactSourceKind, id: string, source: ModuleFactSourceRef): void => {
2130
+ provenanceCandidates.push({ kind, id, source })
2131
+ }
2132
+ const pushRef = (
2133
+ kind: ModuleFactSourceKind,
2134
+ id: string,
2135
+ source: ModuleFactSourceRef,
2136
+ factSection: string,
2137
+ factKey: string,
2138
+ ): void => {
2139
+ provenanceCandidates.push({ kind, id, source, factRef: { factSection, factKey } })
2140
+ }
2141
+ const pushOwnedRefs = (kind: ModuleOwnedContractKind, facts: readonly ModuleOwnedContractFact[]): void => {
2142
+ for (const fact of facts) pushRef(kind, fact.id, fact.source, `ownedContracts.${kind}`, fact.id)
2143
+ }
2144
+ if (moduleMetadataFact) {
2145
+ pushRef(
2146
+ 'module-metadata',
2147
+ moduleId,
2148
+ moduleMetadataFact.source,
2149
+ 'ownedContracts.module-metadata',
2150
+ moduleMetadataFact.id,
2151
+ )
2152
+ }
2153
+ if (entitiesFilePath) {
2154
+ const entitiesSourcePath = portableOf(entitiesFilePath) as string
2155
+ for (const entity of entities) {
2156
+ pushSource('entity', entity.id, { sourcePath: entitiesSourcePath, exportName: entity.class })
2157
+ }
2158
+ }
2159
+ if (eventsFilePath) {
2160
+ const eventsSourcePath = portableOf(eventsFilePath) as string
2161
+ for (const event of events) pushSource('event', event.id, { sourcePath: eventsSourcePath, exportName: 'events' })
2162
+ }
2163
+ if (aclFilePath) {
2164
+ const aclSourcePath = portableOf(aclFilePath) as string
2165
+ for (const feature of aclFeatures) pushSource('acl-feature', feature, { sourcePath: aclSourcePath, exportName: 'features' })
2166
+ }
2167
+ for (const route of apiRoutes) {
2168
+ if (route.sourcePath) {
2169
+ pushRef('api-route', route.path, { sourcePath: route.sourcePath }, 'apiRoutes', route.path)
2170
+ }
2171
+ }
2172
+ if (searchFilePath) {
2173
+ const searchSourcePath = portableOf(searchFilePath) as string
2174
+ for (const entityId of searchEntities) pushSource('search', entityId, { sourcePath: searchSourcePath, exportName: 'searchConfig' })
2175
+ }
2176
+ if (notificationsFilePath) {
2177
+ const notificationsSourcePath = portableOf(notificationsFilePath) as string
2178
+ for (const id of notifications) pushSource('notification', id, { sourcePath: notificationsSourcePath })
2179
+ }
2180
+ for (const command of cliCommands) {
2181
+ pushRef('cli-command', command.command, { sourcePath: command.sourcePath }, 'cliCommands', command.command)
2182
+ }
2183
+ for (const page of backendPages) {
2184
+ pushRef('backend-page', page.path, { sourcePath: page.sourcePath }, 'backendPages', page.path)
2185
+ }
2186
+ for (const page of frontendPages) {
2187
+ pushRef('frontend-page', page.path, { sourcePath: page.sourcePath }, 'frontendPages', page.path)
2188
+ }
2189
+ for (const tool of aiTools) pushRef('ai-tool', tool.name, { sourcePath: tool.sourcePath }, 'aiTools', tool.name)
2190
+ for (const agent of aiAgents) pushRef('ai-agent', agent.id, { sourcePath: agent.sourcePath }, 'aiAgents', agent.id)
2191
+ for (const candidate of commandCandidates) {
2192
+ pushRef('command', candidate.id, { sourcePath: candidate.sourcePath }, 'ownedContracts.command', candidate.id)
2193
+ }
2194
+ pushOwnedRefs('worker', workers.facts)
2195
+ pushOwnedRefs('page-middleware', pageMiddleware.facts)
2196
+ if (setupFact) pushOwnedRefs('setup', [setupFact])
2197
+ pushOwnedRefs('encryption', encryptionFacts)
2198
+ for (const fact of diRegistrations.facts) {
2199
+ pushRef('di-registration', fact.token, fact.source, 'ownedContracts.di-registration', fact.token)
2200
+ }
2201
+ pushOwnedRefs('custom-entity', customEntityFacts)
2202
+ pushOwnedRefs('ai-extension', aiExtensionFacts)
2203
+ pushOwnedRefs('generator-plugin', generatorPlugins.facts)
2204
+ const contributionRef = (contributionId: string): { factSection: string; factKey: string } => ({
2205
+ factSection: 'extensionSurfaces.contributions',
2206
+ factKey: contributionId,
2207
+ })
2208
+ for (const contribution of extensionSurfaces.contributions) {
2209
+ const contributionSource: ModuleFactSourceRef = {
2210
+ sourcePath: contribution.source.path,
2211
+ ...(contribution.source.symbol ? { exportName: contribution.source.symbol } : {}),
2212
+ }
2213
+ const ref = contributionRef(contribution.id)
2214
+ if (contribution.kind === 'subscriber') {
2215
+ pushRef('subscriber', contribution.id, contributionSource, ref.factSection, ref.factKey)
2216
+ continue
2217
+ }
2218
+ if (contribution.kind === 'specialized-registry') {
2219
+ const details = contribution.details as { registry?: string; registryId?: string }
2220
+ const sourceKind = details.registry ? REGISTRY_TO_SOURCE_KIND[details.registry] : undefined
2221
+ if (sourceKind) {
2222
+ pushRef(
2223
+ sourceKind,
2224
+ details.registryId ?? contribution.id,
2225
+ contributionSource,
2226
+ ref.factSection,
2227
+ ref.factKey,
2228
+ )
2229
+ }
2230
+ continue
2231
+ }
2232
+ const mapped = CONTRIBUTION_KIND_TO_SOURCE_KIND[contribution.kind]
2233
+ if (mapped) pushRef(mapped, contribution.id, contributionSource, ref.factSection, ref.factKey)
2234
+ }
2235
+ // extension-host / extension-contribution provenance is emitted as typed `factRef` pointers
2236
+ // rather than duplicated source refs: those facts already carry their own `source` inside
2237
+ // `extensionSurfaces.{hosts,contributions}`, and copying every declared host spot (hundreds
2238
+ // per large module) would push the shared JSON past the determinism byte guard asserted in
2239
+ // `module-facts.bc-guard.test.ts`.
2240
+ // Hosts are indexed by their registry `key`: `id` repeats across families (an entity id is
2241
+ // also an api-entity id), while `key` is unique inside a module.
2242
+ for (const host of extensionSurfaces.hosts) {
2243
+ provenanceCandidates.push({
2244
+ kind: 'extension-host',
2245
+ id: host.key,
2246
+ ...(host.source.kind === 'fact-ref' ? {} : { source: { sourcePath: host.source.path } }),
2247
+ factRef: { factSection: 'extensionSurfaces.hosts', factKey: host.key },
2248
+ })
2249
+ }
2250
+ for (const contribution of extensionSurfaces.contributions) {
2251
+ pushRef(
2252
+ 'extension-contribution',
2253
+ contribution.id,
2254
+ { sourcePath: contribution.source.path },
2255
+ 'extensionSurfaces.contributions',
2256
+ contribution.id,
2257
+ )
2258
+ }
2259
+
2260
+ const provenance = buildProvenanceIndex(provenanceCandidates)
2261
+ const factDiagnostics = [
2262
+ ...provenance.diagnostics,
2263
+ ...workers.unresolved,
2264
+ ...pageMiddleware.unresolved,
2265
+ ...generatorPlugins.unresolved,
2266
+ ...diRegistrations.unresolvedTokens.map((token): ModuleFactDiagnostic => ({
2267
+ code: 'unresolved-static-contract',
2268
+ kind: 'di-registration',
2269
+ id: token,
2270
+ ...(diSourcePath ? { source: { sourcePath: diSourcePath, exportName: token } } : {}),
2271
+ })),
2272
+ ].sort(
2273
+ (left, right) =>
2274
+ left.code.localeCompare(right.code) ||
2275
+ left.kind.localeCompare(right.kind) ||
2276
+ left.id.localeCompare(right.id),
2277
+ )
2278
+
2279
+ const facts: ModuleFacts = {
2280
+ module: moduleId,
2281
+ title,
2282
+ description,
2283
+ coreVersion,
2284
+ sourcePackage,
2285
+ sourceVersion,
2286
+ sourceRoot,
2287
+ ...(options.sourceKind === 'local-reference' ? { sourceKind: options.sourceKind } : {}),
2288
+ entities,
2289
+ events,
2290
+ aclFeatures,
2291
+ apiRoutes,
2292
+ diTokens,
2293
+ searchEntities,
2294
+ hostTokens,
2295
+ notifications,
2296
+ cli,
2297
+ backendPages,
2298
+ frontendPages,
2299
+ cliCommands,
2300
+ aiTools,
2301
+ aiAgents,
2302
+ extensionSurfaces,
2303
+ ...(provenance.factSources.length > 0 ? { factSources: provenance.factSources } : {}),
2304
+ ...(ownedContracts ? { ownedContracts } : {}),
2305
+ ...(factDiagnostics.length > 0 ? { factDiagnostics } : {}),
2306
+ warnings,
2307
+ }
2308
+
2309
+ // Exact unified override targets (spec 2026-08-02-module-facts-exact-override-targets).
2310
+ // Adapters consume the fully-populated owned facts + contributions above.
2311
+ const overrides = collectModuleOverrideTargets(facts, buildFactSourceLookup(facts))
2312
+ if (overrides.overrideTargets.length > 0) facts.overrideTargets = overrides.overrideTargets
2313
+ if (overrides.overrideTargetDiagnostics.length > 0) {
2314
+ facts.overrideTargetDiagnostics = overrides.overrideTargetDiagnostics
2315
+ }
2316
+ return facts
2317
+ }
2318
+
2319
+ export interface ModuleFactsJsonEvent {
2320
+ id: string
2321
+ category: string | null
2322
+ entity: string | null
2323
+ clientBroadcast?: boolean
2324
+ portalBroadcast?: boolean
2325
+ }
2326
+
2327
+ export interface ModuleFactsJsonEntry {
2328
+ title: string | null
2329
+ description: string | null
2330
+ coreVersion: string | null
2331
+ sourcePackage: string | null
2332
+ sourceVersion: string | null
2333
+ sourceRoot: string
2334
+ /** Present only for `local-reference` projections; package sidecar entries omit it. */
2335
+ sourceKind?: ModuleFactProjectionSourceKind
2336
+ entities: ModuleEntityFact[]
2337
+ events: ModuleFactsJsonEvent[]
2338
+ aclFeatures: string[]
2339
+ apiRoutes: ModuleApiRouteFact[]
2340
+ diTokens: string[]
2341
+ searchEntities: string[]
2342
+ hostTokens: ModuleHostTokens
2343
+ notifications: string[]
2344
+ cli: string[]
2345
+ backendPages: ModulePageFact[]
2346
+ frontendPages: ModulePageFact[]
2347
+ cliCommands: ModuleCliCommandFact[]
2348
+ aiTools: ModuleAiToolFact[]
2349
+ aiAgents: ModuleAiAgentFact[]
2350
+ extensionSurfaces?: ModuleExtensionSurfaceFacts
2351
+ factSources?: ModuleFactSourceEntry[]
2352
+ ownedContracts?: ModuleOwnedContracts
2353
+ factDiagnostics?: ModuleFactDiagnostic[]
2354
+ overrideTargets?: ModuleOverrideTarget[]
2355
+ overrideTargetDiagnostics?: ModuleOverrideTargetDiagnostic[]
2356
+ }
2357
+
2358
+ const EMPTY_SECTION_MARKER = '_none_'
2359
+
2360
+ function renderVersionStamp(
2361
+ coreVersion: string | null,
2362
+ sourcePackage: string | null,
2363
+ sourceVersion: string | null,
2364
+ ): string {
2365
+ if (sourcePackage) {
2366
+ return `<!-- generated from ${sourcePackage} ${sourceVersion || '<unknown>'}; core ${coreVersion || '<unknown>'} — R1 staleness stamp -->`
2367
+ }
2368
+ const version = coreVersion && coreVersion.length > 0 ? coreVersion : '<unknown>'
2369
+ return `<!-- generated from @open-mercato/core ${version} — R1 staleness stamp -->`
2370
+ }
2371
+
2372
+ function renderEntitiesSection(entities: ModuleEntityFact[], lookup: FactSourceLookup): string {
2373
+ if (entities.length === 0) return `## Entities\n\n${EMPTY_SECTION_MARKER}`
2374
+ const header = '| Entity ID | Class | Table | Editable | CustomFields | Source |'
2375
+ const divider = '|---|---|---|---|---|---|'
2376
+ const rows = entities.map(
2377
+ (entity) =>
2378
+ `| ${entity.id} | ${entity.class} | ${entity.table} | ${entity.editable ? 'yes' : 'no'} | ${entity.customFields ? 'yes' : 'no'} | ${renderLookedUpSource(lookup, 'entity', entity.id)} |`,
2379
+ )
2380
+ return ['## Entities', '', header, divider, ...rows].join('\n')
2381
+ }
2382
+
2383
+ function renderEventsSection(events: ModuleEventFact[], lookup: FactSourceLookup): string {
2384
+ const heading = `## Events (${events.length})`
2385
+ if (events.length === 0) return `${heading}\n\n${EMPTY_SECTION_MARKER}`
2386
+ const header = '| ID | Category | Entity | Browser transport | Source |'
2387
+ const divider = '|---|---|---|---|---|'
2388
+ const rows = events.map((event) => {
2389
+ const transports = [event.clientBroadcast ? 'client' : null, event.portalBroadcast ? 'portal' : null]
2390
+ .filter((value): value is string => value !== null)
2391
+ return `| ${event.id} | ${event.category ?? '—'} | ${event.entity ?? '—'} | ${transports.join(', ') || '—'} | ${renderLookedUpSource(lookup, 'event', event.id)} |`
2392
+ })
2393
+ return [heading, '', header, divider, ...rows].join('\n')
2394
+ }
2395
+
2396
+ function renderInlineListSection(heading: string, values: string[]): string {
2397
+ if (values.length === 0) return `${heading}\n\n${EMPTY_SECTION_MARKER}`
2398
+ return `${heading}\n\n${values.join(' · ')}`
2399
+ }
2400
+
2401
+ function renderSourceLinkedListSection(
2402
+ heading: string,
2403
+ values: readonly string[],
2404
+ lookup: FactSourceLookup,
2405
+ kind: ModuleFactSourceKind,
2406
+ idColumnLabel: string,
2407
+ ): string {
2408
+ if (values.length === 0) return `${heading}\n\n${EMPTY_SECTION_MARKER}`
2409
+ const rows = values.map((value) => `| ${value} | ${renderLookedUpSource(lookup, kind, value)} |`)
2410
+ return [heading, '', `| ${idColumnLabel} | Source |`, '|---|---|', ...rows].join('\n')
2411
+ }
2412
+
2413
+ /**
2414
+ * A generated fact only earns a markdown link when it names an exact module code
2415
+ * file. Directory-valued provenance (the module source root, framework hosts that
2416
+ * only know their owning package folder) renders as plain text: a link to a folder
2417
+ * resolves to nothing in an editor or on the repository browser, so emitting one
2418
+ * costs a consumer a dead click instead of taking them to the declaration site.
2419
+ */
2420
+ export function isExactSourceFilePath(sourcePath: string): boolean {
2421
+ return MODULE_CODE_EXTENSIONS.some((extension) => sourcePath.endsWith(extension))
2422
+ }
2423
+
2424
+ function renderSourceLink(sourcePath: string): string {
2425
+ if (!isExactSourceFilePath(sourcePath)) return sourcePath
2426
+ return `[${sourcePath}](../../../${sourcePath})`
2427
+ }
2428
+
2429
+ function renderSourceRefLink(source: ModuleFactSourceRef): string {
2430
+ const anchor = source.line ? `#L${source.line}` : ''
2431
+ const label = source.line ? `${source.sourcePath}:${source.line}` : source.sourcePath
2432
+ if (!isExactSourceFilePath(source.sourcePath)) return label
2433
+ return `[${label}](../../../${source.sourcePath}${anchor})`
2434
+ }
2435
+
2436
+
2437
+ function renderLookedUpSource(lookup: FactSourceLookup, kind: ModuleFactSourceKind, id: string): string {
2438
+ const source = lookup(kind, id)
2439
+ return source ? renderSourceRefLink(source) : '—'
2440
+ }
2441
+
2442
+ function renderSafeScalar(value: ModuleFactSafeScalar | ModuleFactSafeScalar[] | undefined): string {
2443
+ if (value === undefined || value === null) return '—'
2444
+ if (Array.isArray(value)) return value.length > 0 ? value.map((entry) => String(entry)).join(', ') : '—'
2445
+ return String(value)
2446
+ }
2447
+
2448
+ function renderOwnedContractMetadata(metadata: ModuleOwnedContractFact['metadata']): string {
2449
+ if (!metadata) return '—'
2450
+ const keys = Object.keys(metadata).sort((left, right) => left.localeCompare(right))
2451
+ if (keys.length === 0) return '—'
2452
+ return keys.map((key) => `${key}=${renderSafeScalar(metadata[key])}`).join('; ')
2453
+ }
2454
+
2455
+ function renderOwnedContractSection(
2456
+ heading: string,
2457
+ facts: readonly ModuleOwnedContractFact[] | undefined,
2458
+ ): string {
2459
+ if (!facts || facts.length === 0) return ''
2460
+ const rows = facts.map(
2461
+ (fact) => `| ${fact.id} | ${renderOwnedContractMetadata(fact.metadata)} | ${renderSourceRefLink(fact.source)} |`,
2462
+ )
2463
+ return [heading, '', '| ID | Metadata | Source |', '|---|---|---|', ...rows].join('\n')
2464
+ }
2465
+
2466
+ function renderDiRegistrationsSection(facts: readonly ModuleOwnedContractFact[] | undefined): string {
2467
+ if (!facts || facts.length === 0) return ''
2468
+ const rows = facts.map((fact) => {
2469
+ const metadata = fact.metadata ?? {}
2470
+ return `| ${fact.id} | ${renderSafeScalar(metadata.registrationKind)} | ${renderSafeScalar(metadata.lifetime)} | ${renderSafeScalar(metadata.injectionMode)} | ${renderSafeScalar(metadata.providerSymbol)} | ${renderSourceRefLink(fact.source)} |`
2471
+ })
2472
+ return [
2473
+ '## DI registrations (rich)',
2474
+ '',
2475
+ '| Token | Kind | Lifetime | Injection | Provider | Source |',
2476
+ '|---|---|---|---|---|---|',
2477
+ ...rows,
2478
+ ].join('\n')
2479
+ }
2480
+
2481
+ function describeAuthRule(rule: ApiRouteAuthRule | undefined): string {
2482
+ if (!rule) return 'public'
2483
+ if (rule.requireFeatures && rule.requireFeatures.length > 0) return rule.requireFeatures.join(', ')
2484
+ if (rule.requireRoles && rule.requireRoles.length > 0) return rule.requireRoles.join(', ')
2485
+ if (rule.requireAuth) return 'auth'
2486
+ return 'public'
2487
+ }
2488
+
2489
+ function renderApiRouteAuthCell(route: ModuleApiRouteFact): string {
2490
+ if (route.methods.length === 0) return '—'
2491
+ const groups: Array<{ label: string; methods: string[] }> = []
2492
+ for (const method of route.methods) {
2493
+ const label = describeAuthRule(route.auth[method])
850
2494
  const existing = groups.find((group) => group.label === label)
851
2495
  if (existing) existing.methods.push(method)
852
2496
  else groups.push({ label, methods: [method] })
@@ -856,44 +2500,284 @@ function renderApiRouteAuthCell(route: ModuleApiRouteFact): string {
856
2500
 
857
2501
  function renderApiRoutesSection(routes: ModuleApiRouteFact[]): string {
858
2502
  if (routes.length === 0) return `## API routes\n\n${EMPTY_SECTION_MARKER}`
859
- const header = '| Path | Methods | Auth (per-method requireFeatures) |'
860
- const divider = '|---|---|---|'
2503
+ const header = '| Path | Methods | Auth (per-method requireFeatures) | Source |'
2504
+ const divider = '|---|---|---|---|'
861
2505
  const rows = routes.map(
862
- (route) => `| ${route.path} | ${route.methods.join(' ')} | ${renderApiRouteAuthCell(route)} |`,
2506
+ (route) => `| ${route.path} | ${route.methods.join(' ')} | ${renderApiRouteAuthCell(route)} | ${route.sourcePath ? renderSourceLink(route.sourcePath) : '—'} |`,
863
2507
  )
864
2508
  return ['## API routes', '', header, divider, ...rows].join('\n')
865
2509
  }
866
2510
 
2511
+ function renderLinkedFactsSection(
2512
+ heading: string,
2513
+ facts: ReadonlyArray<{ label: string; sourcePath: string }>,
2514
+ ): string {
2515
+ if (facts.length === 0) return `${heading}\n\n${EMPTY_SECTION_MARKER}`
2516
+ const rows = facts.map((fact) => `| ${fact.label} | ${renderSourceLink(fact.sourcePath)} |`)
2517
+ return [heading, '', '| ID / path | Source |', '|---|---|', ...rows].join('\n')
2518
+ }
2519
+
867
2520
  function renderHostTokensSection(hostTokens: ModuleHostTokens): string {
868
2521
  const entityIdsLine = hostTokens.entityIds.length > 0 ? hostTokens.entityIds.join(' · ') : EMPTY_SECTION_MARKER
869
2522
  const tableIdsLine = hostTokens.tableIds.length > 0 ? hostTokens.tableIds.join(' · ') : EMPTY_SECTION_MARKER
870
2523
  return ['## Host extension points', '', `- Entity IDs: ${entityIdsLine}`, `- Table IDs: ${tableIdsLine}`].join('\n')
871
2524
  }
872
2525
 
873
- export function renderModuleFactsMarkdown(facts: ModuleFacts): string {
2526
+ function renderExtensionHostContext(host: ModuleExtensionSurfaceFacts['hosts'][number]): string {
2527
+ return host.contextContract ?? host.runtimeContract ?? host.scopeContract ?? '—'
2528
+ }
2529
+
2530
+ function renderExtensionHostSource(host: ModuleExtensionHostFact): string {
2531
+ if (host.source.kind === 'fact-ref') return `${host.source.factSection}:${host.source.factKey}`
2532
+ return renderSourceRefLink({ sourcePath: host.source.path })
2533
+ }
2534
+
2535
+ function renderExtensionHostsSection(extensionSurfaces: ModuleExtensionSurfaceFacts): string {
2536
+ const boundHosts = extensionSurfaces.hosts.filter((host) => host.bound)
2537
+ if (boundHosts.length === 0) return `## UMES hosts\n\n${EMPTY_SECTION_MARKER}`
2538
+ const rows = boundHosts.map((host) =>
2539
+ `| ${host.id} | ${host.family} | ${host.capabilities.join(', ') || '—'} | ${renderExtensionHostContext(host)} | ${host.stability.toUpperCase()} | ${renderExtensionHostSource(host)} |`,
2540
+ )
2541
+ return [
2542
+ '## UMES hosts',
2543
+ '',
2544
+ '| ID / pattern | Family | Supports | Context | Stability | Source |',
2545
+ '|---|---|---|---|---|---|',
2546
+ ...rows,
2547
+ ].join('\n')
2548
+ }
2549
+
2550
+ function compactContributionDetails(contribution: ModuleExtensionContributionFact): string {
2551
+ const details = contribution.details as unknown as Record<string, unknown>
2552
+ return Object.keys(details).sort((left, right) => left.localeCompare(right)).flatMap((key) => {
2553
+ const value = details[key]
2554
+ if (value === undefined) return []
2555
+ if (Array.isArray(value)) return [`${key}=${value.join(',') || 'none'}`]
2556
+ if (value && typeof value === 'object') {
2557
+ const nested = Object.keys(value as Record<string, unknown>).sort((left, right) => left.localeCompare(right)).map((nestedKey) => {
2558
+ const nestedValue = (value as Record<string, unknown>)[nestedKey]
2559
+ return `${nestedKey}:${Array.isArray(nestedValue) ? nestedValue.join(',') : String(nestedValue)}`
2560
+ })
2561
+ return [`${key}={${nested.join(';')}}`]
2562
+ }
2563
+ return [`${key}=${String(value)}`]
2564
+ }).join('; ')
2565
+ }
2566
+
2567
+ function renderExtensionContributionsSection(extensionSurfaces: ModuleExtensionSurfaceFacts): string {
2568
+ if (extensionSurfaces.contributions.length === 0) return `## UMES contributions\n\n${EMPTY_SECTION_MARKER}`
2569
+ const rows = extensionSurfaces.contributions.map((contribution) => {
2570
+ const targets = contribution.targets.map((entry) => entry.id).join(', ')
2571
+ const resolution = contribution.targets.map((entry) => entry.resolution).join(', ')
2572
+ const phases = [...(contribution.phases ?? []), ...(contribution.operations ?? [])].join(', ') || '—'
2573
+ const source = renderSourceRefLink({ sourcePath: contribution.source.path })
2574
+ return `| ${contribution.id} | ${contribution.kind} | ${targets || '—'} | ${phases} | ${compactContributionDetails(contribution)} | ${resolution || '—'} | ${source} |`
2575
+ })
2576
+ return [
2577
+ '## UMES contributions',
2578
+ '',
2579
+ '| ID | Kind | Target | Phase / operations | Contract | Resolution | Source |',
2580
+ '|---|---|---|---|---|---|---|',
2581
+ ...rows,
2582
+ ].join('\n')
2583
+ }
2584
+
2585
+ function renderExtensionDiagnosticsSection(extensionSurfaces: ModuleExtensionSurfaceFacts): string {
2586
+ const unbound = extensionSurfaces.hosts.filter((host) => !host.bound)
2587
+ if (unbound.length === 0 && extensionSurfaces.unresolved.length === 0) return ''
2588
+ const diagnostics = [
2589
+ ...unbound.map((host) => `- unbound-helper: ${host.id}`),
2590
+ ...extensionSurfaces.unresolved.map((entry) => `- ${entry.reason}: ${entry.key} (${entry.source.path})`),
2591
+ ]
2592
+ return ['## UMES diagnostics', '', ...diagnostics].join('\n')
2593
+ }
2594
+
2595
+ function renderExtensionTargetRef(target: ModuleExtensionTargetRef): string {
2596
+ const method = target.method ? ` ${target.method}` : ''
2597
+ const owner = target.moduleId ? ` @${target.moduleId}` : ''
2598
+ return `${target.kind}:${target.id}${method}${owner}`
2599
+ }
2600
+
2601
+ function renderActivationBindingsSection(extensionSurfaces: ModuleExtensionSurfaceFacts): string {
2602
+ const activations = extensionSurfaces.activations ?? []
2603
+ if (activations.length === 0) return `## Active extension bindings\n\n${EMPTY_SECTION_MARKER}`
2604
+ const rows = activations.map((activation) => {
2605
+ const phases = activation.phases && activation.phases.length > 0 ? activation.phases.join(', ') : '—'
2606
+ const source = activation.source ? renderSourceRefLink(activation.source) : '—'
2607
+ const bridge = activation.bridge ? `${activation.bridge.factSection}:${activation.bridge.factKey}` : '—'
2608
+ return `| ${activation.id} | ${activation.kind} | ${renderExtensionTargetRef(activation.host)} | ${activation.contributionKinds.join(', ') || '—'} | ${phases} | ${bridge} | ${source} |`
2609
+ })
2610
+ return [
2611
+ '## Active extension bindings',
2612
+ '',
2613
+ '| Activation | Kind | Host | Contribution kinds | Phases | Bridge | Source |',
2614
+ '|---|---|---|---|---|---|---|',
2615
+ ...rows,
2616
+ ].join('\n')
2617
+ }
2618
+
2619
+ function renderIncomingContributionsSection(extensionSurfaces: ModuleExtensionSurfaceFacts): string {
2620
+ const incoming = extensionSurfaces.incoming ?? []
2621
+ if (incoming.length === 0) return `## Incoming installed contributions\n\n${EMPTY_SECTION_MARKER}`
2622
+ const rows = incoming.map((entry) => {
2623
+ const activation = entry.activationId ?? '—'
2624
+ const source = entry.source ? renderSourceRefLink(entry.source) : '—'
2625
+ return `| ${entry.contributorModuleId} | ${entry.contributionKind} | ${renderExtensionTargetRef(entry.target)} | ${entry.resolution} | ${activation} | ${entry.contributionId} · ${source} |`
2626
+ })
2627
+ return [
2628
+ '## Incoming installed contributions',
2629
+ '',
2630
+ '| Contributor | Kind | Target | Resolution | Activation | Contribution · Source |',
2631
+ '|---|---|---|---|---|---|',
2632
+ ...rows,
2633
+ ].join('\n')
2634
+ }
2635
+
2636
+ function renderContributionResolutionsSection(
2637
+ extensionSurfaces: ModuleExtensionSurfaceFacts,
2638
+ lookup: FactSourceLookup,
2639
+ ): string {
2640
+ const resolutions = extensionSurfaces.contributionResolutions ?? []
2641
+ if (resolutions.length === 0) return `## Contribution resolutions\n\n${EMPTY_SECTION_MARKER}`
2642
+ const rows = resolutions.map((resolution) => {
2643
+ const activations = resolution.activationIds.length > 0 ? resolution.activationIds.join(', ') : '—'
2644
+ const source = renderLookedUpSource(lookup, 'extension-contribution', resolution.contributionId)
2645
+ return `| ${resolution.contributionId} | ${renderExtensionTargetRef(resolution.target)} | ${resolution.resolution} | ${activations} | ${source} |`
2646
+ })
2647
+ return [
2648
+ '## Contribution resolutions',
2649
+ '',
2650
+ '| Contribution | Target | Resolution | Activations | Source |',
2651
+ '|---|---|---|---|---|',
2652
+ ...rows,
2653
+ ].join('\n')
2654
+ }
2655
+
2656
+ function renderOverrideTargetPath(target: ModuleOverrideTarget): string {
2657
+ let display = 'overrides'
2658
+ for (let index = 0; index < target.path.length; index += 1) {
2659
+ const segment = target.path[index]
2660
+ const isKeyedTerminal = target.key !== undefined && index === target.path.length - 1
2661
+ display += isKeyedTerminal ? `[${JSON.stringify(segment)}]` : `.${segment}`
2662
+ }
2663
+ return display
2664
+ }
2665
+
2666
+ function renderOverrideTargetsSection(targets: readonly ModuleOverrideTarget[] | undefined): string {
2667
+ if (!targets || targets.length === 0) return `## Exact override targets\n\n${EMPTY_SECTION_MARKER}`
2668
+ const header = '| Domain | Path / key | Supported modes | Referenced fact | Source |'
2669
+ const divider = '|---|---|---|---|---|'
2670
+ const rows = targets.map((target) => {
2671
+ const path = `\`${renderOverrideTargetPath(target)}\``
2672
+ const modes = target.modes.join(', ') || '—'
2673
+ const fact = `${target.factRef.factSection}:${target.factRef.factKey}`
2674
+ return `| ${target.domain} | ${path} | ${modes} | ${fact} | ${renderSourceRefLink(target.source)} |`
2675
+ })
2676
+ return ['## Exact override targets', '', header, divider, ...rows].join('\n')
2677
+ }
2678
+
2679
+ export interface ReferenceProjectionFingerprints {
2680
+ sourceFingerprint: string
2681
+ taxonomyFingerprint: string
2682
+ }
2683
+
2684
+ /**
2685
+ * Header for a `local-reference` projection. It never claims a package version — the
2686
+ * module is app-local — and it states in the document itself that the activations and
2687
+ * exact override targets below describe behavior only after the module is opted in,
2688
+ * because the artifact is generated while the module is disabled.
2689
+ */
2690
+ function renderReferenceProjectionHeader(
2691
+ facts: ModuleFacts,
2692
+ fingerprints: ReferenceProjectionFingerprints,
2693
+ ): string[] {
2694
+ return [
2695
+ `# ${facts.module} — module facts (generated reference projection, do not edit)`,
2696
+ `<!-- generated from local reference ${facts.sourceRoot}; source-fingerprint ${fingerprints.sourceFingerprint}; taxonomy-fingerprint ${fingerprints.taxonomyFingerprint} — R1 staleness stamp -->`,
2697
+ `> 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\`.`,
2698
+ `Source root: ${renderSourceLink(facts.sourceRoot)}`,
2699
+ ]
2700
+ }
2701
+
2702
+ export function renderModuleFactsMarkdown(
2703
+ facts: ModuleFacts,
2704
+ referenceProjection?: ReferenceProjectionFingerprints,
2705
+ ): string {
2706
+ const extensionSurfaces = facts.extensionSurfaces ?? { hosts: [], contributions: [], unresolved: [] }
2707
+ const lookup = buildFactSourceLookup(facts)
2708
+ const header = facts.sourceKind === 'local-reference' && referenceProjection
2709
+ ? renderReferenceProjectionHeader(facts, referenceProjection)
2710
+ : [
2711
+ `# ${facts.module} — module facts (generated, do not edit)`,
2712
+ renderVersionStamp(facts.coreVersion, facts.sourcePackage, facts.sourceVersion),
2713
+ `Source root: ${renderSourceLink(facts.sourceRoot)}`,
2714
+ ]
874
2715
  const sections = [
875
- `# ${facts.module} — module facts (generated, do not edit)`,
876
- renderVersionStamp(facts.coreVersion),
2716
+ ...header,
877
2717
  '',
878
- renderEntitiesSection(facts.entities),
2718
+ renderEntitiesSection(facts.entities, lookup),
879
2719
  '',
880
- renderEventsSection(facts.events),
2720
+ renderEventsSection(facts.events, lookup),
881
2721
  '',
882
- renderInlineListSection(`## ACL features (${facts.aclFeatures.length})`, facts.aclFeatures),
2722
+ renderSourceLinkedListSection(
2723
+ `## ACL features (${facts.aclFeatures.length})`,
2724
+ facts.aclFeatures,
2725
+ lookup,
2726
+ 'acl-feature',
2727
+ 'Feature',
2728
+ ),
883
2729
  '',
884
2730
  renderApiRoutesSection(facts.apiRoutes),
885
2731
  '',
886
- renderInlineListSection('## DI service tokens', facts.diTokens),
2732
+ renderLinkedFactsSection('## Backend pages', facts.backendPages.map((page) => ({ label: page.path, sourcePath: page.sourcePath }))),
887
2733
  '',
888
- renderInlineListSection('## Search entities', facts.searchEntities),
2734
+ renderLinkedFactsSection('## Frontend pages', facts.frontendPages.map((page) => ({ label: page.path, sourcePath: page.sourcePath }))),
2735
+ '',
2736
+ renderSourceLinkedListSection('## DI service tokens', facts.diTokens, lookup, 'di-registration', 'Token'),
2737
+ '',
2738
+ renderSourceLinkedListSection('## Search entities', facts.searchEntities, lookup, 'search', 'Entity ID'),
889
2739
  '',
890
2740
  renderHostTokensSection(facts.hostTokens),
891
2741
  '',
892
- renderInlineListSection('## Notifications', facts.notifications),
2742
+ renderExtensionHostsSection(extensionSurfaces),
2743
+ '',
2744
+ renderExtensionContributionsSection(extensionSurfaces),
2745
+ '',
2746
+ renderActivationBindingsSection(extensionSurfaces),
2747
+ '',
2748
+ renderIncomingContributionsSection(extensionSurfaces),
2749
+ '',
2750
+ renderContributionResolutionsSection(extensionSurfaces, lookup),
2751
+ '',
2752
+ renderExtensionDiagnosticsSection(extensionSurfaces),
2753
+ '',
2754
+ renderSourceLinkedListSection('## Notifications', facts.notifications, lookup, 'notification', 'Notification ID'),
2755
+ '',
2756
+ renderLinkedFactsSection('## CLI commands', facts.cliCommands.map((command) => ({ label: command.command, sourcePath: command.sourcePath }))),
2757
+ '',
2758
+ renderLinkedFactsSection('## AI tools / MCP capabilities', facts.aiTools.map((tool) => ({ label: tool.name, sourcePath: tool.sourcePath }))),
893
2759
  '',
894
- renderInlineListSection('## CLI', facts.cli),
2760
+ renderLinkedFactsSection('## AI agents', facts.aiAgents.map((agent) => ({ label: agent.id, sourcePath: agent.sourcePath }))),
2761
+ '',
2762
+ renderOverrideTargetsSection(facts.overrideTargets),
895
2763
  '',
896
2764
  ]
2765
+ const owned = facts.ownedContracts ?? {}
2766
+ const ownedSections = [
2767
+ renderOwnedContractSection('## Owned contract — module metadata', owned['module-metadata']),
2768
+ renderOwnedContractSection('## Domain commands', owned.command),
2769
+ renderOwnedContractSection('## Workers', owned.worker),
2770
+ renderOwnedContractSection('## Page middleware', owned['page-middleware']),
2771
+ renderOwnedContractSection('## Setup', owned.setup),
2772
+ renderOwnedContractSection('## Encryption', owned.encryption),
2773
+ renderDiRegistrationsSection(owned['di-registration']),
2774
+ renderOwnedContractSection('## Custom entities', owned['custom-entity']),
2775
+ renderOwnedContractSection('## AI extensions', owned['ai-extension']),
2776
+ renderOwnedContractSection('## Generator plugins', owned['generator-plugin']),
2777
+ ].filter((section) => section.length > 0)
2778
+ for (const section of ownedSections) {
2779
+ sections.push(section, '')
2780
+ }
897
2781
  return sections.join('\n')
898
2782
  }
899
2783
 
@@ -902,8 +2786,18 @@ export function toModuleFactsJsonEntry(facts: ModuleFacts): ModuleFactsJsonEntry
902
2786
  title: facts.title,
903
2787
  description: facts.description,
904
2788
  coreVersion: facts.coreVersion,
2789
+ sourcePackage: facts.sourcePackage,
2790
+ sourceVersion: facts.sourceVersion,
2791
+ sourceRoot: facts.sourceRoot,
2792
+ ...(facts.sourceKind === 'local-reference' ? { sourceKind: facts.sourceKind } : {}),
905
2793
  entities: facts.entities,
906
- events: facts.events.map((event) => ({ id: event.id, category: event.category, entity: event.entity })),
2794
+ events: facts.events.map((event) => ({
2795
+ id: event.id,
2796
+ category: event.category,
2797
+ entity: event.entity,
2798
+ ...(event.clientBroadcast !== undefined ? { clientBroadcast: event.clientBroadcast } : {}),
2799
+ ...(event.portalBroadcast !== undefined ? { portalBroadcast: event.portalBroadcast } : {}),
2800
+ })),
907
2801
  aclFeatures: facts.aclFeatures,
908
2802
  apiRoutes: facts.apiRoutes,
909
2803
  diTokens: facts.diTokens,
@@ -911,6 +2805,25 @@ export function toModuleFactsJsonEntry(facts: ModuleFacts): ModuleFactsJsonEntry
911
2805
  hostTokens: facts.hostTokens,
912
2806
  notifications: facts.notifications,
913
2807
  cli: facts.cli,
2808
+ backendPages: facts.backendPages,
2809
+ frontendPages: facts.frontendPages,
2810
+ cliCommands: facts.cliCommands,
2811
+ aiTools: facts.aiTools,
2812
+ aiAgents: facts.aiAgents,
2813
+ ...(facts.extensionSurfaces ? { extensionSurfaces: facts.extensionSurfaces } : {}),
2814
+ ...(facts.factSources && facts.factSources.length > 0 ? { factSources: facts.factSources } : {}),
2815
+ ...(facts.ownedContracts && Object.keys(facts.ownedContracts).length > 0
2816
+ ? { ownedContracts: facts.ownedContracts }
2817
+ : {}),
2818
+ ...(facts.factDiagnostics && facts.factDiagnostics.length > 0
2819
+ ? { factDiagnostics: facts.factDiagnostics }
2820
+ : {}),
2821
+ ...(facts.overrideTargets && facts.overrideTargets.length > 0
2822
+ ? { overrideTargets: facts.overrideTargets }
2823
+ : {}),
2824
+ ...(facts.overrideTargetDiagnostics && facts.overrideTargetDiagnostics.length > 0
2825
+ ? { overrideTargetDiagnostics: facts.overrideTargetDiagnostics }
2826
+ : {}),
914
2827
  }
915
2828
  }
916
2829
 
@@ -928,6 +2841,273 @@ export function renderModuleFactsJson(factsByModule: Record<string, ModuleFacts>
928
2841
  return `${JSON.stringify(buildModuleFactsJsonObject(factsByModule), null, 2)}\n`
929
2842
  }
930
2843
 
2844
+ /**
2845
+ * The normal package sidecar (`module-facts.json`) and the enabled-filtered package
2846
+ * markdown subset carry package-provided modules only. An app-local module reaching
2847
+ * either output would publish `bound` activations and exact override targets for code
2848
+ * the runtime does not select, so it fails the build instead of shipping.
2849
+ */
2850
+ export function assertPackageModuleFactsOnly(factsByModule: Record<string, ModuleFacts>): void {
2851
+ const offenders = Object.values(factsByModule)
2852
+ .filter((facts) => facts.sourceKind === 'local-reference' || !facts.sourceRoot.startsWith('node_modules/'))
2853
+ .map((facts) => `${facts.module} (${facts.sourceRoot})`)
2854
+ .sort((left, right) => left.localeCompare(right))
2855
+ if (offenders.length > 0) {
2856
+ throw new Error(
2857
+ `[module-facts] normal package output must not contain app-local modules: ${offenders.join(', ')}`,
2858
+ )
2859
+ }
2860
+ }
2861
+
2862
+ function sha256Hex(value: string | Buffer): string {
2863
+ return createHash('sha256').update(value).digest('hex')
2864
+ }
2865
+
2866
+ /**
2867
+ * Canonical form used by both reference fingerprints: object keys are recursively sorted
2868
+ * and arrays are sorted by their own canonical text. Every array a fact entry emits is
2869
+ * set-valued (capabilities, phases, operations, modes, per-section fact lists), so a
2870
+ * single ordering rule keeps the fingerprint stable against extractor iteration order
2871
+ * without inventing per-field exceptions.
2872
+ */
2873
+ function canonicalizeFingerprintValue(value: unknown): unknown {
2874
+ if (Array.isArray(value)) {
2875
+ return value
2876
+ .map((item) => {
2877
+ const canonical = canonicalizeFingerprintValue(item)
2878
+ return { canonical, text: JSON.stringify(canonical) ?? '' }
2879
+ })
2880
+ .sort((left, right) => (left.text < right.text ? -1 : left.text > right.text ? 1 : 0))
2881
+ .map((item) => item.canonical)
2882
+ }
2883
+ if (value !== null && typeof value === 'object') {
2884
+ const record = value as Record<string, unknown>
2885
+ const result: Record<string, unknown> = {}
2886
+ for (const key of Object.keys(record).sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))) {
2887
+ if (record[key] === undefined) continue
2888
+ result[key] = canonicalizeFingerprintValue(record[key])
2889
+ }
2890
+ return result
2891
+ }
2892
+ return value
2893
+ }
2894
+
2895
+ function canonicalFingerprintJson(value: unknown): string {
2896
+ return JSON.stringify(canonicalizeFingerprintValue(value))
2897
+ }
2898
+
2899
+ const REFERENCE_FINGERPRINT_SKIPPED_DIRECTORIES = new Set(['__tests__', '__integration__', 'node_modules'])
2900
+
2901
+ /**
2902
+ * SHA-256 over the canonical JSON of `{ path, sha256 }` records sorted by path, where
2903
+ * `path` is the portable app-relative path so the value is identical across the
2904
+ * authoring, template, and emitted roots. Test-only directories are excluded because the
2905
+ * scaffold does not emit them, so including them would make the emitted app's recomputed
2906
+ * fingerprint disagree with the one built from the template.
2907
+ */
2908
+ export function computeModuleSourceFingerprint(moduleRoot: string, portableSourceRoot: string): string {
2909
+ assertPortableSourceRoot(path.basename(portableSourceRoot), portableSourceRoot)
2910
+ const records: { path: string; sha256: string }[] = []
2911
+ const walk = (directory: string): void => {
2912
+ const entries = fs.readdirSync(directory, { withFileTypes: true })
2913
+ for (const entry of entries) {
2914
+ const fullPath = path.join(directory, entry.name)
2915
+ if (entry.isDirectory()) {
2916
+ if (REFERENCE_FINGERPRINT_SKIPPED_DIRECTORIES.has(entry.name)) continue
2917
+ walk(fullPath)
2918
+ continue
2919
+ }
2920
+ if (!entry.isFile()) continue
2921
+ records.push({
2922
+ path: toPortableSourcePath(moduleRoot, portableSourceRoot, fullPath),
2923
+ sha256: sha256Hex(fs.readFileSync(fullPath)),
2924
+ })
2925
+ }
2926
+ }
2927
+ walk(moduleRoot)
2928
+ records.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
2929
+ return sha256Hex(canonicalFingerprintJson(records))
2930
+ }
2931
+
2932
+ /**
2933
+ * Provenance and release metadata are not taxonomy: a version bump must not invalidate
2934
+ * the classification fingerprint. Every remaining section of the emitted entry is a
2935
+ * classified set and contributes.
2936
+ */
2937
+ const TAXONOMY_EXCLUDED_SECTIONS = new Set([
2938
+ 'title',
2939
+ 'description',
2940
+ 'coreVersion',
2941
+ 'sourcePackage',
2942
+ 'sourceVersion',
2943
+ 'sourceRoot',
2944
+ 'sourceKind',
2945
+ ])
2946
+
2947
+ function collectTaxonomyRecords(
2948
+ setName: string,
2949
+ value: unknown,
2950
+ records: { setName: string; value: unknown }[],
2951
+ ): void {
2952
+ if (value === undefined) return
2953
+ if (Array.isArray(value)) {
2954
+ for (const item of value) records.push({ setName, value: canonicalizeFingerprintValue(item) })
2955
+ return
2956
+ }
2957
+ if (value !== null && typeof value === 'object') {
2958
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
2959
+ collectTaxonomyRecords(`${setName}.${key}`, child, records)
2960
+ }
2961
+ return
2962
+ }
2963
+ records.push({ setName, value })
2964
+ }
2965
+
2966
+ /**
2967
+ * SHA-256 over the canonical JSON of `{ setName, value }` records sorted by set name and
2968
+ * canonicalized value. Each record carries a complete literal or structured entry — key,
2969
+ * suffix, capabilities, `bound`, phases, operations included — so no delimiter-joined
2970
+ * tuple can collapse two distinct classifications into one fingerprint input.
2971
+ */
2972
+ export function computeModuleTaxonomyFingerprint(entry: ModuleFactsJsonEntry): string {
2973
+ const records: { setName: string; value: unknown }[] = []
2974
+ for (const [setName, value] of Object.entries(entry as unknown as Record<string, unknown>)) {
2975
+ if (TAXONOMY_EXCLUDED_SECTIONS.has(setName)) continue
2976
+ collectTaxonomyRecords(setName, value, records)
2977
+ }
2978
+ const decorated = records
2979
+ .map((record) => ({ record, text: JSON.stringify(record.value) ?? '' }))
2980
+ .sort((left, right) =>
2981
+ left.record.setName < right.record.setName
2982
+ ? -1
2983
+ : left.record.setName > right.record.setName
2984
+ ? 1
2985
+ : left.text < right.text
2986
+ ? -1
2987
+ : left.text > right.text
2988
+ ? 1
2989
+ : 0,
2990
+ )
2991
+ return sha256Hex(JSON.stringify(decorated.map((item) => item.record)))
2992
+ }
2993
+
2994
+ export interface ReferenceModuleFactsEntry {
2995
+ moduleId: string
2996
+ projectionKind: 'activated-reference'
2997
+ sourceKind: 'local-reference'
2998
+ runtimeSelected: false
2999
+ sourceFingerprint: string
3000
+ taxonomyFingerprint: string
3001
+ /**
3002
+ * The enum-derived classification of every taxonomy `facts` is expressed in, built
3003
+ * by the same generation run. It travels with the projection so a reader of the
3004
+ * emitted sheet can tell a value the example really emits from one that is only
3005
+ * reserved, pending, or reachable through a negative fixture.
3006
+ */
3007
+ factCoverage: ModuleFactCoverageFamily[]
3008
+ facts: ModuleFactsJsonEntry
3009
+ }
3010
+
3011
+ export type ReferenceModuleFactsJson = Record<string, ReferenceModuleFactsEntry>
3012
+
3013
+ export function renderReferenceModuleFactsJson(bundle: ReferenceModuleFactsJson): string {
3014
+ const ordered: ReferenceModuleFactsJson = {}
3015
+ for (const moduleId of Object.keys(bundle).sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))) {
3016
+ ordered[moduleId] = bundle[moduleId]
3017
+ }
3018
+ return `${JSON.stringify(ordered, null, 2)}\n`
3019
+ }
3020
+
3021
+ export interface ExtractLocalReferenceModuleFactsOptions {
3022
+ /** Package-provided sources, exactly as the normal batch receives them. */
3023
+ packageSources: readonly ModuleFactSource[]
3024
+ /** The app-local module projected into the reference bundle. */
3025
+ reference: ModuleFactSource
3026
+ registryPath?: string | null
3027
+ registrySource?: string | null
3028
+ coreVersion?: string | null
3029
+ }
3030
+
3031
+ export interface LocalReferenceModuleFactsResult {
3032
+ entry: ReferenceModuleFactsEntry
3033
+ markdown: string
3034
+ warnings: string[]
3035
+ /**
3036
+ * First-party targets the activated projection could not correlate. They also remain in
3037
+ * the emitted `facts.extensionSurfaces.unresolved`, so the artifact states its own gaps
3038
+ * rather than presenting a partial surface as complete.
3039
+ */
3040
+ unresolvedTargets: string[]
3041
+ }
3042
+
3043
+ /**
3044
+ * Produces the reference projection of an app-local module from an explicitly activated,
3045
+ * disposable selection context: the local source is appended to the package sources and
3046
+ * the whole set runs through the same `extractAllModuleFacts` selection and correlation
3047
+ * batch, so incoming contributions and optional targets resolve exactly as they would
3048
+ * after opt-in. Merging an independently extracted object into a finished batch is
3049
+ * forbidden — it would silently drop both resolutions. The batch is disposable: the
3050
+ * caller keeps only this module's projection and never writes the rest, which is why the
3051
+ * normal package outputs stay byte-identical.
3052
+ */
3053
+ export function extractLocalReferenceModuleFacts(
3054
+ options: ExtractLocalReferenceModuleFactsOptions,
3055
+ ): LocalReferenceModuleFactsResult {
3056
+ const { reference } = options
3057
+ if (reference.sourceKind !== 'local-reference') {
3058
+ throw new Error(
3059
+ `[module-facts] reference source "${reference.moduleId}" must declare sourceKind "local-reference"`,
3060
+ )
3061
+ }
3062
+ const portableSourceRoot = reference.portableSourceRoot
3063
+ if (!portableSourceRoot) {
3064
+ throw new Error(`[module-facts] reference source "${reference.moduleId}" must declare portableSourceRoot`)
3065
+ }
3066
+ assertPortableSourceRoot(reference.moduleId, portableSourceRoot)
3067
+
3068
+ const sources = appendLocalReferenceModuleSource(options.packageSources, reference)
3069
+ const activated = extractAllModuleFacts({
3070
+ sources,
3071
+ registryPath: options.registryPath ?? null,
3072
+ registrySource: options.registrySource ?? null,
3073
+ coreVersion: options.coreVersion ?? null,
3074
+ // The disposable projection collects rather than throws: an app-local module can name
3075
+ // a target whose classification is still missing from the shared extension reader, and
3076
+ // aborting there would delete the whole reference bundle instead of publishing the
3077
+ // exact gap. Normal package output keeps the strict default.
3078
+ unresolvedFirstPartyTargets: 'collect',
3079
+ })
3080
+ const facts = activated.factsByModule[reference.moduleId]
3081
+ if (!facts) {
3082
+ throw new Error(`[module-facts] reference module "${reference.moduleId}" produced no facts`)
3083
+ }
3084
+ if (facts.sourceRoot !== portableSourceRoot) {
3085
+ throw new Error(
3086
+ `[module-facts] reference module "${reference.moduleId}" emitted non-portable source root "${facts.sourceRoot}"`,
3087
+ )
3088
+ }
3089
+
3090
+ const jsonEntry = toModuleFactsJsonEntry(facts)
3091
+ const fingerprints: ReferenceProjectionFingerprints = {
3092
+ sourceFingerprint: computeModuleSourceFingerprint(reference.moduleRoot, portableSourceRoot),
3093
+ taxonomyFingerprint: computeModuleTaxonomyFingerprint(jsonEntry),
3094
+ }
3095
+ return {
3096
+ entry: {
3097
+ moduleId: reference.moduleId,
3098
+ projectionKind: 'activated-reference',
3099
+ sourceKind: 'local-reference',
3100
+ runtimeSelected: false,
3101
+ ...fingerprints,
3102
+ factCoverage: activated.factCoverage,
3103
+ facts: jsonEntry,
3104
+ },
3105
+ markdown: renderModuleFactsMarkdown(facts, fingerprints),
3106
+ warnings: activated.warnings,
3107
+ unresolvedTargets: activated.unresolvedFirstPartyTargets,
3108
+ }
3109
+ }
3110
+
931
3111
  export interface ExtractAllModuleFactsOptions {
932
3112
  /**
933
3113
  * Discovered module sources (auto-discovery path). When provided, each entry's
@@ -941,15 +3121,53 @@ export interface ExtractAllModuleFactsOptions {
941
3121
  coreVersion?: string | null
942
3122
  /** @deprecated Legacy explicit module-id list; only consulted when `sources` is absent. */
943
3123
  moduleIds?: readonly string[]
3124
+ /**
3125
+ * `throw` (the default, and the only value any normal package output uses) aborts the
3126
+ * batch when a first-party extension target does not correlate. `collect` keeps the
3127
+ * unresolved entries in `extensionSurfaces.unresolved` and reports them on the result
3128
+ * so a disposable projection can publish the gap instead of silently dropping it.
3129
+ */
3130
+ unresolvedFirstPartyTargets?: 'throw' | 'collect'
944
3131
  }
945
3132
 
946
3133
  export interface ExtractAllModuleFactsResult {
947
3134
  factsByModule: Record<string, ModuleFacts>
948
3135
  markdownByModule: Record<string, string>
949
3136
  warnings: string[]
3137
+ frameworkMarkdown: string
3138
+ /** Always empty under the default `throw` policy, which aborts before returning. */
3139
+ unresolvedFirstPartyTargets: string[]
3140
+ /**
3141
+ * The enum-derived coverage ledger for the taxonomies these facts are expressed
3142
+ * in, built during generation so an enum value with no classification aborts the
3143
+ * run instead of shipping an unclassified public value.
3144
+ */
3145
+ factCoverage: ModuleFactCoverageFamily[]
3146
+ }
3147
+
3148
+ /**
3149
+ * `<moduleId>:<contributionId>:<targetId>` for every first-party contribution target that
3150
+ * did not correlate — the same identity {@link assertNoUnresolvedExtensionTargets} throws
3151
+ * with, so a collected batch reports exactly what a strict batch would have refused.
3152
+ */
3153
+ export function collectUnresolvedFirstPartyTargets(
3154
+ surfacesByModule: Readonly<Record<string, ModuleExtensionSurfaceFacts>>,
3155
+ ): string[] {
3156
+ return Object.entries(surfacesByModule)
3157
+ .flatMap(([moduleId, surface]) =>
3158
+ surface.unresolved
3159
+ .filter((entry) => entry.reason === 'unresolved-first-party-target')
3160
+ .map((entry) => `${moduleId}:${entry.key}`),
3161
+ )
3162
+ .sort((left, right) => left.localeCompare(right))
950
3163
  }
951
3164
 
952
3165
  export function extractAllModuleFacts(options: ExtractAllModuleFactsOptions): ExtractAllModuleFactsResult {
3166
+ return withModuleExtensionFactExtractionCache(() => extractAllModuleFactsWithCache(options))
3167
+ }
3168
+
3169
+ function extractAllModuleFactsWithCache(options: ExtractAllModuleFactsOptions): ExtractAllModuleFactsResult {
3170
+ const factCoverage = buildModuleFactCoverageLedger()
953
3171
  const sources: ModuleFactSource[] = options.sources
954
3172
  ? [...options.sources]
955
3173
  : (options.coreSrcRoot
@@ -967,12 +3185,403 @@ export function extractAllModuleFacts(options: ExtractAllModuleFactsOptions): Ex
967
3185
  moduleId: source.moduleId,
968
3186
  moduleRoot: source.moduleRoot,
969
3187
  coreVersion: options.coreVersion ?? null,
3188
+ sourcePackage: source.from ?? null,
3189
+ sourceVersion: source.packageVersion ?? null,
970
3190
  registryPath: options.registryPath ?? null,
971
3191
  registrySource: options.registrySource ?? null,
3192
+ ...(source.portableSourceRoot ? { portableSourceRoot: source.portableSourceRoot } : {}),
3193
+ ...(source.sourceKind ? { sourceKind: source.sourceKind } : {}),
972
3194
  })
973
3195
  factsByModule[source.moduleId] = facts
974
- markdownByModule[source.moduleId] = renderModuleFactsMarkdown(facts)
975
3196
  warnings.push(...facts.warnings)
976
3197
  }
977
- return { factsByModule, markdownByModule, warnings }
3198
+ const surfacesByModule = Object.fromEntries(
3199
+ Object.entries(factsByModule).map(([moduleId, facts]) => [
3200
+ moduleId,
3201
+ facts.extensionSurfaces ?? { hosts: [], contributions: [], unresolved: [] },
3202
+ ]),
3203
+ )
3204
+ const correlated = correlateModuleExtensionFacts({
3205
+ surfacesByModule,
3206
+ entityIds: new Set(Object.values(factsByModule).flatMap((facts) => facts.entities.map((entity) => entity.id))),
3207
+ eventIds: new Set(Object.values(factsByModule).flatMap((facts) => facts.events.map((event) => event.id))),
3208
+ apiRoutes: new Set([
3209
+ ...Object.values(factsByModule).flatMap((facts) => facts.apiRoutes.map((route) => route.path)),
3210
+ ...sources.flatMap((source) => extractKnownApiRouteIds(source.moduleId, source.moduleRoot)),
3211
+ ]),
3212
+ commandIds: new Set(sources.flatMap((source) => extractKnownCommandIds(source.moduleId, source.moduleRoot))),
3213
+ })
3214
+ const unresolvedFirstPartyTargets = collectUnresolvedFirstPartyTargets(correlated)
3215
+ if ((options.unresolvedFirstPartyTargets ?? 'throw') === 'throw') assertNoUnresolvedExtensionTargets(correlated)
3216
+ const apiRouteOwners = new Map<string, {
3217
+ moduleId: string
3218
+ source: ModuleFactSourceRef
3219
+ bridges: ApiRouteInterceptorBridge[]
3220
+ }>()
3221
+ const commandOwners = new Map<string, { moduleId: string; source: ModuleFactSourceRef }>()
3222
+ for (const source of [...sources].sort((left, right) => left.moduleId.localeCompare(right.moduleId))) {
3223
+ const facts = factsByModule[source.moduleId]
3224
+ const sourceRoot = facts ? facts.sourceRoot : source.moduleId
3225
+ const owners = extractActivationTargetOwners({
3226
+ moduleId: source.moduleId,
3227
+ moduleRoot: source.moduleRoot,
3228
+ sourceRoot,
3229
+ })
3230
+ for (const route of owners.apiRoutes) {
3231
+ if (!apiRouteOwners.has(route.id)) {
3232
+ apiRouteOwners.set(route.id, { moduleId: source.moduleId, source: route.source, bridges: route.bridges })
3233
+ }
3234
+ }
3235
+ for (const command of owners.commands) {
3236
+ if (!commandOwners.has(command.id)) commandOwners.set(command.id, { moduleId: source.moduleId, source: command.source })
3237
+ }
3238
+ }
3239
+ const withIncoming = correlateIncomingExtensions({
3240
+ surfacesByModule: correlated,
3241
+ apiRouteOwners,
3242
+ commandOwners,
3243
+ })
3244
+ for (const moduleId of Object.keys(factsByModule).sort((left, right) => left.localeCompare(right))) {
3245
+ factsByModule[moduleId].extensionSurfaces = withIncoming[moduleId]
3246
+ markdownByModule[moduleId] = renderModuleFactsMarkdown(factsByModule[moduleId])
3247
+ warnings.push(...correlated[moduleId].unresolved.map((entry) =>
3248
+ `[module-facts] ${moduleId} ${entry.reason}: ${entry.key} (${entry.source.path})`,
3249
+ ))
3250
+ }
3251
+ return {
3252
+ factsByModule,
3253
+ markdownByModule,
3254
+ warnings,
3255
+ frameworkMarkdown: renderFrameworkExtensionPointsMarkdown(),
3256
+ unresolvedFirstPartyTargets,
3257
+ factCoverage,
3258
+ }
3259
+ }
3260
+
3261
+ /**
3262
+ * Enum-derived `factCoverage` ledger — spec
3263
+ * `.ai/specs/2026-07-31-standalone-canonical-example-module.md`,
3264
+ * § PR #4883 Module-Fact and Extension-Topology Contract.
3265
+ *
3266
+ * Every finite public set is enumerated from the `as const` ledger that also derives
3267
+ * its type, so an added enum value has no ledger row and fails
3268
+ * {@link buildModuleFactCoverageLedger} instead of joining the public surface
3269
+ * unclassified. The purpose of the ledger is to make a *silent zero* impossible: a
3270
+ * family cannot claim canonical coverage while the real extractor publishes no
3271
+ * contribution for it.
3272
+ */
3273
+ export type ModuleFactCoverageStatus =
3274
+ /** The canonical example really emits it; a real extraction proves a non-zero count. */
3275
+ | 'emitted-example'
3276
+ /** A framework/app-level setting that never becomes a module contribution. */
3277
+ | 'framework-only'
3278
+ /** Described by the framework catalog only; the example contributes nothing for it. */
3279
+ | 'catalog-only'
3280
+ /** In the closed public set, but no generator code path emits it today. */
3281
+ | 'currently-unbound'
3282
+ /**
3283
+ * Required to be `emitted-example` by the spec but NOT emitted yet. The status
3284
+ * itself states the gap, so a machine reader keying on `status` alone is never
3285
+ * told the example emits something it does not.
3286
+ */
3287
+ | 'pending-emission'
3288
+ /** Only a deliberately broken fixture produces it; canonical output emits none. */
3289
+ | 'negative-fixture'
3290
+
3291
+ export type ModuleFactCoverageRow = {
3292
+ value: string
3293
+ status: ModuleFactCoverageStatus
3294
+ note: string
3295
+ /**
3296
+ * Names the slice that owns closing a `pending-emission` gap. A pending row is
3297
+ * asserted to have a *zero* real count, so it can never be mistaken for proven
3298
+ * coverage and cannot go stale once the gap closes.
3299
+ */
3300
+ pendingEmission?: string
3301
+ /**
3302
+ * Set when the value is extractable only from the generated module registry rather
3303
+ * than from module source, so a source-only extraction legitimately reports zero.
3304
+ *
3305
+ * This is the only way a row skips the non-zero proof, so the flag is proven rather
3306
+ * than trusted: `module-facts.example-fact-coverage.test.ts` asserts every row
3307
+ * carrying it counts zero from a source-only extraction of the example AND non-zero
3308
+ * once the same extraction is handed a module registry.
3309
+ */
3310
+ requiresGeneratedRegistry?: true
3311
+ }
3312
+
3313
+ export type ModuleFactCoverageFamily = {
3314
+ family: string
3315
+ enumSource: string
3316
+ rows: ModuleFactCoverageRow[]
3317
+ }
3318
+
3319
+ const FACT_SOURCE_KIND_COVERAGE: Record<ModuleFactSourceKind, ModuleFactCoverageRow> = {
3320
+ 'module-metadata': { value: 'module-metadata', status: 'emitted-example', note: 'example/index.ts ModuleInfo export' },
3321
+ entity: { value: 'entity', status: 'emitted-example', note: 'example/data/entities.ts ORM entities' },
3322
+ event: { value: 'event', status: 'emitted-example', note: 'example/events.ts typed event definitions' },
3323
+ 'acl-feature': { value: 'acl-feature', status: 'emitted-example', note: 'example/acl.ts RBAC features' },
3324
+ 'api-route': {
3325
+ value: 'api-route',
3326
+ status: 'emitted-example',
3327
+ note: 'example/api/**/route.ts; route facts are read from the generated module registry, not from module source',
3328
+ requiresGeneratedRegistry: true,
3329
+ },
3330
+ 'di-registration': { value: 'di-registration', status: 'emitted-example', note: 'example/di.ts Awilix container.register' },
3331
+ search: { value: 'search', status: 'emitted-example', note: 'example/search.ts indexed entity declaration' },
3332
+ vector: {
3333
+ value: 'vector',
3334
+ status: 'pending-emission',
3335
+ note: 'REQUIRED: a vector-search identity for the Todo entity; the example declares none today',
3336
+ pendingEmission: 'slice-H-missing-example-surfaces',
3337
+ },
3338
+ notification: { value: 'notification', status: 'emitted-example', note: 'example/notifications.ts notification types' },
3339
+ 'cli-command': { value: 'cli-command', status: 'emitted-example', note: 'example/cli.ts module commands' },
3340
+ 'backend-page': { value: 'backend-page', status: 'emitted-example', note: 'example/backend/**/page.tsx admin pages' },
3341
+ 'frontend-page': { value: 'frontend-page', status: 'emitted-example', note: 'example/frontend/**/page.tsx portal pages' },
3342
+ 'ai-tool': { value: 'ai-tool', status: 'emitted-example', note: 'example/ai-tools.ts defineAiTool declarations' },
3343
+ 'ai-agent': { value: 'ai-agent', status: 'emitted-example', note: 'example/ai-agents.ts defineAiAgent declarations' },
3344
+ 'ai-extension': { value: 'ai-extension', status: 'emitted-example', note: 'example AI agent/tool override and extension records' },
3345
+ command: { value: 'command', status: 'emitted-example', note: 'example/commands/todos.ts command handlers' },
3346
+ subscriber: { value: 'subscriber', status: 'emitted-example', note: 'example/subscribers/*.ts event subscribers' },
3347
+ worker: { value: 'worker', status: 'emitted-example', note: 'example/workers/*.ts queue workers' },
3348
+ 'page-middleware': { value: 'page-middleware', status: 'emitted-example', note: 'example/backend/middleware.ts page guard' },
3349
+ setup: { value: 'setup', status: 'emitted-example', note: 'example/setup.ts tenant setup hooks' },
3350
+ encryption: { value: 'encryption', status: 'emitted-example', note: 'example/encryption.ts field encryption map' },
3351
+ 'custom-entity': { value: 'custom-entity', status: 'emitted-example', note: 'example/ce.ts custom entity and field DSL' },
3352
+ integration: { value: 'integration', status: 'emitted-example', note: 'example/lib/mock-*-adapter.ts integration registrations' },
3353
+ 'generator-plugin': {
3354
+ value: 'generator-plugin',
3355
+ status: 'pending-emission',
3356
+ note: 'REQUIRED: an example/generators.ts GeneratorPlugin declaration; the file does not exist yet',
3357
+ pendingEmission: 'slice-H-missing-example-surfaces',
3358
+ },
3359
+ 'extension-host': { value: 'extension-host', status: 'emitted-example', note: 'example/extension-points.ts declared hosts' },
3360
+ 'extension-contribution': {
3361
+ value: 'extension-contribution',
3362
+ status: 'emitted-example',
3363
+ note: 'example widget, DataTable, CrudForm, interceptor and enricher contributions',
3364
+ },
3365
+ }
3366
+
3367
+ const OWNED_CONTRACT_KIND_COVERAGE: Record<ModuleOwnedContractKind, ModuleFactCoverageRow> = {
3368
+ 'module-metadata': { value: 'module-metadata', status: 'emitted-example', note: 'ModuleInfo owned contract' },
3369
+ command: { value: 'command', status: 'emitted-example', note: 'example/commands/todos.ts' },
3370
+ worker: { value: 'worker', status: 'emitted-example', note: 'example/workers/*.ts' },
3371
+ 'page-middleware': { value: 'page-middleware', status: 'emitted-example', note: 'example/backend/middleware.ts' },
3372
+ setup: { value: 'setup', status: 'emitted-example', note: 'example/setup.ts' },
3373
+ encryption: { value: 'encryption', status: 'emitted-example', note: 'example/encryption.ts' },
3374
+ 'di-registration': {
3375
+ value: 'di-registration',
3376
+ status: 'emitted-example',
3377
+ note: 'example/di.ts scoped Todo summary cache service consumed by a route',
3378
+ },
3379
+ 'custom-entity': { value: 'custom-entity', status: 'emitted-example', note: 'example/ce.ts' },
3380
+ 'ai-extension': { value: 'ai-extension', status: 'emitted-example', note: 'example AI override/extension record' },
3381
+ 'generator-plugin': {
3382
+ value: 'generator-plugin',
3383
+ status: 'pending-emission',
3384
+ note: 'REQUIRED: an example/generators.ts GeneratorPlugin declaration; the file does not exist yet',
3385
+ pendingEmission: 'slice-H-missing-example-surfaces',
3386
+ },
3387
+ }
3388
+
3389
+ const OVERRIDE_DOMAIN_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3390
+ ai: { value: 'ai', status: 'emitted-example', note: 'ai.agents, ai.tools and ai.extensions targets' },
3391
+ routes: { value: 'routes', status: 'emitted-example', note: 'routes.api and routes.pages targets' },
3392
+ events: { value: 'events', status: 'emitted-example', note: 'events.subscribers targets' },
3393
+ workers: { value: 'workers', status: 'emitted-example', note: 'workers targets' },
3394
+ widgets: { value: 'widgets', status: 'emitted-example', note: 'widgets.injection, .components and .dashboard targets' },
3395
+ notifications: { value: 'notifications', status: 'emitted-example', note: 'notifications.types and notifications.handlers targets' },
3396
+ interceptors: { value: 'interceptors', status: 'emitted-example', note: 'API interceptor targets' },
3397
+ commandInterceptors: { value: 'commandInterceptors', status: 'emitted-example', note: 'command interceptor targets' },
3398
+ enrichers: { value: 'enrichers', status: 'emitted-example', note: 'response enricher targets' },
3399
+ guards: { value: 'guards', status: 'emitted-example', note: 'page-middleware guard targets' },
3400
+ cli: { value: 'cli', status: 'emitted-example', note: 'CLI command targets' },
3401
+ setup: { value: 'setup', status: 'emitted-example', note: 'setup hook and default-role targets' },
3402
+ acl: { value: 'acl', status: 'emitted-example', note: 'acl.features targets' },
3403
+ di: { value: 'di', status: 'emitted-example', note: 'DI token targets' },
3404
+ encryption: { value: 'encryption', status: 'emitted-example', note: 'encryption.maps targets' },
3405
+ nav: {
3406
+ value: 'nav',
3407
+ status: 'framework-only',
3408
+ note: 'nav.groupOrder is an app-level framework setting; no module target is ever emitted for it',
3409
+ },
3410
+ }
3411
+
3412
+ const OVERRIDE_MODE_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3413
+ 'disable-replace': {
3414
+ value: 'disable-replace',
3415
+ status: 'emitted-example',
3416
+ note: 'default mode for most example override hosts',
3417
+ },
3418
+ replace: { value: 'replace', status: 'emitted-example', note: 'the setup host replaces wholesale' },
3419
+ additive: { value: 'additive', status: 'emitted-example', note: 'the ai.extensions host merges additively' },
3420
+ }
3421
+
3422
+ const OVERRIDE_NOTE_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3423
+ 'safe-metadata-only': {
3424
+ value: 'safe-metadata-only',
3425
+ status: 'emitted-example',
3426
+ note: 'emitted on the metadata-only override target',
3427
+ },
3428
+ 'page-middleware-not-mutation-guard': {
3429
+ value: 'page-middleware-not-mutation-guard',
3430
+ status: 'emitted-example',
3431
+ note: 'emitted on the guards target so a page guard is not read as a mutation guard',
3432
+ },
3433
+ }
3434
+
3435
+ /**
3436
+ * Every `negative-fixture` row here names a code that a real fixture in
3437
+ * `module-override-targets.diagnostic-wiring.test.ts` drives out of
3438
+ * `collectModuleOverrideTargets`; the coverage test asserts that fixture set matches
3439
+ * these rows exactly, so a row can never claim a fixture that does not exist.
3440
+ */
3441
+ const OVERRIDE_DIAGNOSTIC_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3442
+ 'missing-owned-fact': {
3443
+ value: 'missing-owned-fact',
3444
+ status: 'currently-unbound',
3445
+ note: 'reserved by the spec\'s closed diagnostic set; no adapter emits it today, so not even a broken fixture can produce it',
3446
+ },
3447
+ 'missing-source': {
3448
+ value: 'missing-source',
3449
+ status: 'negative-fixture',
3450
+ note: 'a fact whose provenance cannot be proven yields this and no target; activated canonical output emits none',
3451
+ },
3452
+ 'unsupported-dynamic-key': {
3453
+ value: 'unsupported-dynamic-key',
3454
+ status: 'negative-fixture',
3455
+ note: 'a key the runtime cannot address yields this and no target; activated canonical output emits none',
3456
+ },
3457
+ 'unknown-framework-domain': {
3458
+ value: 'unknown-framework-domain',
3459
+ status: 'negative-fixture',
3460
+ note: 'the framework catalog does not describe the dotted host at all',
3461
+ },
3462
+ 'unknown-framework-mode': {
3463
+ value: 'unknown-framework-mode',
3464
+ status: 'negative-fixture',
3465
+ note: 'the framework catalog describes the host but names an operation that is not a public mode',
3466
+ },
3467
+ }
3468
+
3469
+ /**
3470
+ * One family of the ledger. Both directions are enforced: an enumerated value with
3471
+ * no row, and a row classifying a value the enum no longer carries, each throw — so
3472
+ * neither an unclassified new value nor a stale row for a removed one can ship.
3473
+ */
3474
+ export function buildCoverageFamily(
3475
+ family: string,
3476
+ enumSource: string,
3477
+ values: readonly string[],
3478
+ coverage: Readonly<Record<string, ModuleFactCoverageRow>>,
3479
+ ): ModuleFactCoverageFamily {
3480
+ const rows: ModuleFactCoverageRow[] = []
3481
+ const missing: string[] = []
3482
+ for (const value of values) {
3483
+ const row = coverage[value]
3484
+ if (!row) {
3485
+ missing.push(value)
3486
+ continue
3487
+ }
3488
+ rows.push(row)
3489
+ }
3490
+ if (missing.length > 0) {
3491
+ throw new Error(
3492
+ `[module-facts] factCoverage family "${family}" has no row for: ${missing.join(', ')}. ` +
3493
+ 'Add an explicit classification instead of leaving the value unclassified.',
3494
+ )
3495
+ }
3496
+ const extra = Object.keys(coverage).filter((value) => !values.includes(value))
3497
+ if (extra.length > 0) {
3498
+ throw new Error(
3499
+ `[module-facts] factCoverage family "${family}" classifies values absent from the enum: ${extra.join(', ')}.`,
3500
+ )
3501
+ }
3502
+ return { family, enumSource, rows }
3503
+ }
3504
+
3505
+ /**
3506
+ * Builds the whole ledger, throwing when an enumerated value lacks a row or a row
3507
+ * classifies a value the enum no longer carries. {@link extractAllModuleFacts} calls
3508
+ * it on every run, so either violation fails fact generation — not only the unit
3509
+ * test — and the built ledger is returned on the generation result so the published
3510
+ * mirrors have a single generated authority to be checked against.
3511
+ */
3512
+ export function buildModuleFactCoverageLedger(): ModuleFactCoverageFamily[] {
3513
+ return [
3514
+ buildCoverageFamily(
3515
+ 'ModuleFactSourceKind',
3516
+ 'packages/cli/src/lib/generators/module-facts.ts#MODULE_FACT_SOURCE_KINDS',
3517
+ MODULE_FACT_SOURCE_KINDS,
3518
+ FACT_SOURCE_KIND_COVERAGE,
3519
+ ),
3520
+ buildCoverageFamily(
3521
+ 'ModuleOwnedContractKind',
3522
+ 'packages/cli/src/lib/generators/module-facts.ts#MODULE_OWNED_CONTRACT_KINDS',
3523
+ MODULE_OWNED_CONTRACT_KINDS,
3524
+ OWNED_CONTRACT_KIND_COVERAGE,
3525
+ ),
3526
+ buildCoverageFamily(
3527
+ 'ModuleOverrideDomain',
3528
+ 'packages/cli/src/lib/generators/module-override-targets.ts#ALL_OVERRIDE_DOMAINS',
3529
+ ALL_OVERRIDE_DOMAINS,
3530
+ OVERRIDE_DOMAIN_COVERAGE,
3531
+ ),
3532
+ buildCoverageFamily(
3533
+ 'ModuleOverrideMode',
3534
+ 'packages/cli/src/lib/generators/module-override-targets.ts#MODULE_OVERRIDE_MODES',
3535
+ MODULE_OVERRIDE_MODES,
3536
+ OVERRIDE_MODE_COVERAGE,
3537
+ ),
3538
+ buildCoverageFamily(
3539
+ 'ModuleOverrideTargetNote',
3540
+ 'packages/cli/src/lib/generators/module-override-targets.ts#MODULE_OVERRIDE_TARGET_NOTES',
3541
+ MODULE_OVERRIDE_TARGET_NOTES,
3542
+ OVERRIDE_NOTE_COVERAGE,
3543
+ ),
3544
+ buildCoverageFamily(
3545
+ 'ModuleOverrideTargetDiagnosticCode',
3546
+ 'packages/cli/src/lib/generators/module-override-targets.ts#MODULE_OVERRIDE_TARGET_DIAGNOSTIC_CODES',
3547
+ MODULE_OVERRIDE_TARGET_DIAGNOSTIC_CODES,
3548
+ OVERRIDE_DIAGNOSTIC_COVERAGE,
3549
+ ),
3550
+ ]
3551
+ }
3552
+
3553
+ /**
3554
+ * Real per-value counts for every ledger family, read from an extracted module's
3555
+ * facts. This is the anti-silent-zero oracle: a ledger claim is checked against what
3556
+ * the extractor actually published, never against a hand-maintained inventory row.
3557
+ */
3558
+ export function countModuleFactCoverage(facts: ModuleFacts): Record<string, Record<string, number>> {
3559
+ const factSourceKind: Record<string, number> = {}
3560
+ for (const entry of facts.factSources ?? []) {
3561
+ factSourceKind[entry.kind] = (factSourceKind[entry.kind] ?? 0) + 1
3562
+ }
3563
+ const ownedContractKind: Record<string, number> = {}
3564
+ for (const [kind, list] of Object.entries(facts.ownedContracts ?? {})) {
3565
+ ownedContractKind[kind] = (list ?? []).length
3566
+ }
3567
+ const overrideDomain: Record<string, number> = {}
3568
+ const overrideMode: Record<string, number> = {}
3569
+ const overrideNote: Record<string, number> = {}
3570
+ for (const target of facts.overrideTargets ?? []) {
3571
+ overrideDomain[target.domain] = (overrideDomain[target.domain] ?? 0) + 1
3572
+ for (const mode of target.modes) overrideMode[mode] = (overrideMode[mode] ?? 0) + 1
3573
+ for (const note of target.notes ?? []) overrideNote[note] = (overrideNote[note] ?? 0) + 1
3574
+ }
3575
+ const overrideDiagnosticCode: Record<string, number> = {}
3576
+ for (const diagnostic of facts.overrideTargetDiagnostics ?? []) {
3577
+ overrideDiagnosticCode[diagnostic.code] = (overrideDiagnosticCode[diagnostic.code] ?? 0) + 1
3578
+ }
3579
+ return {
3580
+ ModuleFactSourceKind: factSourceKind,
3581
+ ModuleOwnedContractKind: ownedContractKind,
3582
+ ModuleOverrideDomain: overrideDomain,
3583
+ ModuleOverrideMode: overrideMode,
3584
+ ModuleOverrideTargetNote: overrideNote,
3585
+ ModuleOverrideTargetDiagnosticCode: overrideDiagnosticCode,
3586
+ }
978
3587
  }