@open-mercato/cli 0.6.8-develop.6930.1.1e5976efc3 → 0.6.8-develop.6940.1.177ea30c6e

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 (168) hide show
  1. package/.turbo/turbo-build.log +65 -1
  2. package/build.mjs +57 -7
  3. package/dist/agentic/guides/backend-ui.md +7 -1
  4. package/dist/agentic/guides/module-facts.json +565 -224
  5. package/dist/agentic/guides/module-facts.v2.json +122933 -0
  6. package/dist/agentic/guides/modules/ai_assistant.md +2 -2
  7. package/dist/agentic/guides/modules/api_docs.md +2 -2
  8. package/dist/agentic/guides/modules/api_keys.md +2 -2
  9. package/dist/agentic/guides/modules/attachments.md +2 -2
  10. package/dist/agentic/guides/modules/audit_logs.md +2 -2
  11. package/dist/agentic/guides/modules/auth.md +2 -2
  12. package/dist/agentic/guides/modules/business_rules.md +2 -2
  13. package/dist/agentic/guides/modules/catalog.md +11 -4
  14. package/dist/agentic/guides/modules/channel_gmail.md +2 -2
  15. package/dist/agentic/guides/modules/channel_imap.md +2 -2
  16. package/dist/agentic/guides/modules/checkout.md +8 -2
  17. package/dist/agentic/guides/modules/communication_channels.md +6 -6
  18. package/dist/agentic/guides/modules/configs.md +2 -2
  19. package/dist/agentic/guides/modules/content.md +2 -2
  20. package/dist/agentic/guides/modules/currencies.md +2 -2
  21. package/dist/agentic/guides/modules/customer_accounts.md +2 -2
  22. package/dist/agentic/guides/modules/customers.md +8 -8
  23. package/dist/agentic/guides/modules/dashboards.md +12 -12
  24. package/dist/agentic/guides/modules/data_sync.md +4 -4
  25. package/dist/agentic/guides/modules/design_system.md +2 -2
  26. package/dist/agentic/guides/modules/dictionaries.md +2 -2
  27. package/dist/agentic/guides/modules/directory.md +2 -2
  28. package/dist/agentic/guides/modules/entities.md +3 -3
  29. package/dist/agentic/guides/modules/eudr.md +3 -3
  30. package/dist/agentic/guides/modules/events.md +2 -2
  31. package/dist/agentic/guides/modules/feature_toggles.md +2 -2
  32. package/dist/agentic/guides/modules/gateway_stripe.md +2 -2
  33. package/dist/agentic/guides/modules/generators.md +2 -2
  34. package/dist/agentic/guides/modules/inbox_ops.md +2 -2
  35. package/dist/agentic/guides/modules/integrations.md +5 -2
  36. package/dist/agentic/guides/modules/messages.md +2 -2
  37. package/dist/agentic/guides/modules/notifications.md +2 -2
  38. package/dist/agentic/guides/modules/onboarding.md +2 -2
  39. package/dist/agentic/guides/modules/payment_gateways.md +7 -3
  40. package/dist/agentic/guides/modules/perspectives.md +2 -2
  41. package/dist/agentic/guides/modules/planner.md +2 -2
  42. package/dist/agentic/guides/modules/portal.md +2 -2
  43. package/dist/agentic/guides/modules/progress.md +2 -2
  44. package/dist/agentic/guides/modules/query_index.md +2 -2
  45. package/dist/agentic/guides/modules/record_locks.md +3 -3
  46. package/dist/agentic/guides/modules/resources.md +2 -2
  47. package/dist/agentic/guides/modules/sales.md +13 -4
  48. package/dist/agentic/guides/modules/scheduler.md +2 -2
  49. package/dist/agentic/guides/modules/search.md +4 -4
  50. package/dist/agentic/guides/modules/security.md +3 -3
  51. package/dist/agentic/guides/modules/shipping_carriers.md +2 -2
  52. package/dist/agentic/guides/modules/sso.md +2 -2
  53. package/dist/agentic/guides/modules/staff.md +7 -4
  54. package/dist/agentic/guides/modules/storage_s3.md +2 -2
  55. package/dist/agentic/guides/modules/sync_akeneo.md +4 -4
  56. package/dist/agentic/guides/modules/sync_excel.md +2 -2
  57. package/dist/agentic/guides/modules/system_status_overlays.md +2 -2
  58. package/dist/agentic/guides/modules/translations.md +2 -2
  59. package/dist/agentic/guides/modules/webhooks.md +2 -2
  60. package/dist/agentic/guides/modules/wms.md +31 -4
  61. package/dist/agentic/guides/modules/workflows.md +2 -2
  62. package/dist/agentic/guides/reference-module-facts.json +8335 -0
  63. package/dist/agentic/guides/reference-modules/example.md +489 -0
  64. package/dist/agentic/guides/spec-delivery.md +34 -0
  65. package/dist/agentic/guides/testing-debugging.md +2 -1
  66. package/dist/agentic/guides/upstream/BACKWARD_COMPATIBILITY.md +3 -2
  67. package/dist/agentic/guides/upstream/manifest.json +2 -2
  68. package/dist/agentic/shared/AGENTS.md.template +10 -14
  69. package/dist/agentic/shared/ai/harness/README.md +4 -4
  70. package/dist/agentic/shared/ai/harness/RELEASE.md +4 -4
  71. package/dist/agentic/shared/ai/harness/cases.json +11350 -5088
  72. package/dist/agentic/shared/ai/harness/cases.schema.json +70 -7
  73. package/dist/agentic/shared/ai/harness/design-system-inventory.json +6697 -0
  74. package/dist/agentic/shared/ai/harness/fixtures/index.json +3 -1
  75. package/dist/agentic/shared/ai/harness/fixtures/seeds.json +4 -0
  76. package/dist/agentic/shared/ai/harness/knowledge-change.schema.json +228 -0
  77. package/dist/agentic/shared/ai/harness/release-matrix.json +9 -4
  78. package/dist/agentic/shared/ai/harness/result.schema.json +60 -0
  79. package/dist/agentic/shared/ai/harness/routing-response.schema.json +10 -0
  80. package/dist/agentic/shared/ai/harness/source-link-inventory.json +3231 -0
  81. package/dist/agentic/shared/ai/harness/validators.json +8 -4
  82. package/dist/agentic/shared/ai/harness/writable-ast-oracles.mjs +5 -2
  83. package/dist/agentic/shared/ai/harness/writable-behavior-oracles.mjs +3 -0
  84. package/dist/agentic/shared/ai/harness/writable-spec-oracles.mjs +879 -0
  85. package/dist/agentic/shared/ai/skills/om-auto-upgrade-0.6.7-to-0.7.0/SKILL.md +102 -0
  86. package/dist/agentic/shared/ai/skills/om-backend-ui-design/SKILL.md +1 -0
  87. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/crud-surfaces.md +15 -0
  88. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/frontend-and-design-system.md +6 -0
  89. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/page-and-navigation.md +4 -0
  90. package/dist/agentic/shared/ai/skills/om-data-model-design/SKILL.md +1 -0
  91. package/dist/agentic/shared/ai/skills/om-data-model-design/references/integrity-and-concurrency.md +2 -0
  92. package/dist/agentic/shared/ai/skills/om-data-model-design/references/migration-workflow.md +2 -0
  93. package/dist/agentic/shared/ai/skills/om-data-model-design/references/schema-design.md +3 -1
  94. package/dist/agentic/shared/ai/skills/om-eject-and-customize/SKILL.md +1 -0
  95. package/dist/agentic/shared/ai/skills/om-eject-and-customize/references/decision-and-procedure.md +2 -0
  96. package/dist/agentic/shared/ai/skills/om-evolve-harness/SKILL.md +2 -0
  97. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-template.md +3 -3
  98. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-workflow.md +1 -1
  99. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/knowledge-change.md +133 -0
  100. package/dist/agentic/shared/ai/skills/om-integration-builder/SKILL.md +1 -0
  101. package/dist/agentic/shared/ai/skills/om-integration-builder/references/provider-families.md +11 -0
  102. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/judge-workflow.md +1 -1
  103. package/dist/agentic/shared/ai/skills/om-module-scaffold/SKILL.md +2 -2
  104. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/api-and-domain.md +14 -1
  105. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/data-and-migrations.md +2 -0
  106. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/module-surfaces.md +19 -0
  107. package/dist/agentic/shared/ai/skills/om-share-this-session/scripts/prepare-share-bundle.mjs +1 -4
  108. package/dist/agentic/shared/ai/skills/om-system-extension/SKILL.md +1 -0
  109. package/dist/agentic/shared/ai/skills/om-system-extension/references/extension-branches.md +24 -0
  110. package/dist/agentic/shared/ai/skills/om-system-extension/references/unified-overrides.md +3 -1
  111. package/dist/agentic/shared/ai/skills/om-troubleshooter/SKILL.md +1 -0
  112. package/dist/agentic/shared/ai/skills/om-troubleshooter/references/diagnosis-map.md +13 -0
  113. package/dist/agentic/shared/ai/skills/tiers.json +2 -1
  114. package/dist/agentic/shared/ai/specs/2026-08-06-reference-module-activation.md +248 -0
  115. package/dist/agentic/shared/scripts/agent-harness-tool-server.mjs +39 -2
  116. package/dist/agentic/shared/scripts/design-source-contract.mjs +108 -0
  117. package/dist/agentic/shared/scripts/evaluate-agent-harness.mjs +935 -28
  118. package/dist/agentic/shared/scripts/framework-context.mjs +4 -1
  119. package/dist/agentic/shared/scripts/run-agent-harness-release.mjs +1 -1
  120. package/dist/agentic/shared/scripts/validate-knowledge-change.mjs +1511 -0
  121. package/dist/lib/__integration__/TC-INT-008.spec.js +1 -0
  122. package/dist/lib/__integration__/TC-INT-008.spec.js.map +2 -2
  123. package/dist/lib/agentic-setup.js +9 -1
  124. package/dist/lib/agentic-setup.js.map +2 -2
  125. package/dist/lib/generators/module-extension-facts.js +93 -26
  126. package/dist/lib/generators/module-extension-facts.js.map +3 -3
  127. package/dist/lib/generators/module-facts-discovery.js +23 -0
  128. package/dist/lib/generators/module-facts-discovery.js.map +2 -2
  129. package/dist/lib/generators/module-facts.js +969 -41
  130. package/dist/lib/generators/module-facts.js.map +2 -2
  131. package/dist/lib/generators/module-override-targets.js +35 -11
  132. package/dist/lib/generators/module-override-targets.js.map +2 -2
  133. package/dist/lib/generators/module-registry.js +105 -2
  134. package/dist/lib/generators/module-registry.js.map +2 -2
  135. package/dist/lib/resolver.js +5 -0
  136. package/dist/lib/resolver.js.map +2 -2
  137. package/dist/lib/testing/integration-discovery.js +34 -0
  138. package/dist/lib/testing/integration-discovery.js.map +2 -2
  139. package/dist/lib/testing/integration.js +10 -2
  140. package/dist/lib/testing/integration.js.map +2 -2
  141. package/dist/mercato.js +4 -4
  142. package/dist/mercato.js.map +2 -2
  143. package/package.json +6 -6
  144. package/src/lib/__integration__/TC-INT-008.spec.ts +4 -2
  145. package/src/lib/__tests__/resolve-environment.test.ts +18 -0
  146. package/src/lib/agentic-setup.ts +10 -2
  147. package/src/lib/generators/__tests__/helpers/override-target-diagnostic-fixtures.ts +150 -0
  148. package/src/lib/generators/__tests__/module-extension-facts.test.ts +290 -0
  149. package/src/lib/generators/__tests__/module-facts.bc-guard.test.ts +72 -7
  150. package/src/lib/generators/__tests__/module-facts.computed-di-token.test.ts +88 -0
  151. package/src/lib/generators/__tests__/module-facts.example-fact-coverage.test.ts +502 -0
  152. package/src/lib/generators/__tests__/module-facts.local-reference.test.ts +414 -0
  153. package/src/lib/generators/__tests__/module-facts.search-entity-id.test.ts +151 -0
  154. package/src/lib/generators/__tests__/module-facts.source-links.test.ts +42 -1
  155. package/src/lib/generators/__tests__/module-override-targets.diagnostic-wiring.test.ts +92 -0
  156. package/src/lib/generators/__tests__/module-override-targets.test.ts +24 -0
  157. package/src/lib/generators/__tests__/module-override-targets.unknown-mode.test.ts +117 -0
  158. package/src/lib/generators/__tests__/module-subset.test.ts +60 -0
  159. package/src/lib/generators/module-extension-facts.ts +181 -25
  160. package/src/lib/generators/module-facts-discovery.ts +46 -0
  161. package/src/lib/generators/module-facts.ts +1330 -86
  162. package/src/lib/generators/module-override-targets.ts +70 -26
  163. package/src/lib/generators/module-registry.ts +135 -2
  164. package/src/lib/resolver.ts +6 -1
  165. package/src/lib/testing/__tests__/integration-discovery.test.ts +35 -0
  166. package/src/lib/testing/integration-discovery.ts +50 -0
  167. package/src/lib/testing/integration.ts +10 -2
  168. package/src/mercato.ts +3 -3
@@ -1,11 +1,28 @@
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'
4
- import type {
5
- ModuleExtensionContributionFact,
6
- ModuleExtensionHostFact,
7
- ModuleExtensionSurfaceFacts,
8
- ModuleExtensionTargetRef,
5
+ import {
6
+ COMPONENT_OVERRIDE_MODES,
7
+ CRUD_FORM_EXTENSION_SURFACE_KEYS,
8
+ CRUD_FORM_LIFECYCLE_PHASES,
9
+ CRUD_FORM_OPERATIONS,
10
+ DATA_TABLE_EXTENSION_SURFACE_KEYS,
11
+ EXTENSION_HOST_ACTIVATIONS,
12
+ EXTENSION_HOST_CAPABILITIES,
13
+ EXTENSION_HOST_FAMILIES,
14
+ MODULE_EXTENSION_ACTIVATION_KINDS,
15
+ MODULE_EXTENSION_CONTRIBUTION_KINDS,
16
+ MODULE_EXTENSION_HOST_RESOLUTIONS,
17
+ MODULE_EXTENSION_RESOLUTIONS,
18
+ MODULE_EXTENSION_TARGET_KINDS,
19
+ MODULE_EXTENSION_TARGET_RESOLUTIONS,
20
+ MODULE_EXTENSION_UNRESOLVED_REASONS,
21
+ MODULE_SPECIALIZED_REGISTRIES,
22
+ type ModuleExtensionContributionFact,
23
+ type ModuleExtensionHostFact,
24
+ type ModuleExtensionSurfaceFacts,
25
+ type ModuleExtensionTargetRef,
9
26
  } from '@open-mercato/shared/modules/widgets/extension-points'
10
27
  import { toSnake } from '../utils'
11
28
  import { extractCommandIdsFromSource } from './module-registry'
@@ -23,11 +40,16 @@ import {
23
40
  withModuleExtensionFactExtractionCache,
24
41
  } from './module-extension-facts'
25
42
  import {
43
+ ALL_OVERRIDE_DOMAINS,
44
+ MODULE_OVERRIDE_MODES,
45
+ MODULE_OVERRIDE_TARGET_DIAGNOSTIC_CODES,
46
+ MODULE_OVERRIDE_TARGET_NOTES,
26
47
  collectModuleOverrideTargets,
27
48
  type ModuleOverrideTarget,
28
49
  type ModuleOverrideTargetDiagnostic,
29
50
  } from './module-override-targets'
30
51
  import { buildFactSourceLookup, type FactSourceLookup } from './module-fact-sources'
52
+ import { appendLocalReferenceModuleSource } from './module-facts-discovery'
31
53
 
32
54
  export interface ModuleEntityFact {
33
55
  id: string
@@ -117,45 +139,57 @@ export type ModuleFactRef = {
117
139
  factKey: string
118
140
  }
119
141
 
120
- export type ModuleFactSourceKind =
121
- | 'module-metadata'
122
- | 'entity'
123
- | 'event'
124
- | 'acl-feature'
125
- | 'api-route'
126
- | 'di-registration'
127
- | 'search'
128
- | 'vector'
129
- | 'notification'
130
- | 'cli-command'
131
- | 'backend-page'
132
- | 'frontend-page'
133
- | 'ai-tool'
134
- | 'ai-agent'
135
- | 'ai-extension'
136
- | 'command'
137
- | 'subscriber'
138
- | 'worker'
139
- | 'page-middleware'
140
- | 'setup'
141
- | 'encryption'
142
- | 'custom-entity'
143
- | 'integration'
144
- | 'generator-plugin'
145
- | 'extension-host'
146
- | 'extension-contribution'
147
-
148
- export type ModuleOwnedContractKind =
149
- | 'module-metadata'
150
- | 'command'
151
- | 'worker'
152
- | 'page-middleware'
153
- | 'setup'
154
- | 'encryption'
155
- | 'di-registration'
156
- | 'custom-entity'
157
- | 'ai-extension'
158
- | 'generator-plugin'
142
+ /**
143
+ * Closed provenance/contract sets, published as `as const` ledgers whose types
144
+ * derive from them. The `factCoverage` ledger enumerates these at runtime, so a
145
+ * kind added here without a ledger row fails the coverage contract test rather
146
+ * than silently joining the public surface unclassified.
147
+ */
148
+ export const MODULE_FACT_SOURCE_KINDS = [
149
+ 'module-metadata',
150
+ 'entity',
151
+ 'event',
152
+ 'acl-feature',
153
+ 'api-route',
154
+ 'di-registration',
155
+ 'search',
156
+ 'vector',
157
+ 'notification',
158
+ 'cli-command',
159
+ 'backend-page',
160
+ 'frontend-page',
161
+ 'ai-tool',
162
+ 'ai-agent',
163
+ 'ai-extension',
164
+ 'command',
165
+ 'subscriber',
166
+ 'worker',
167
+ 'page-middleware',
168
+ 'setup',
169
+ 'encryption',
170
+ 'custom-entity',
171
+ 'integration',
172
+ 'generator-plugin',
173
+ 'extension-host',
174
+ 'extension-contribution',
175
+ ] as const
176
+
177
+ export type ModuleFactSourceKind = (typeof MODULE_FACT_SOURCE_KINDS)[number]
178
+
179
+ export const MODULE_OWNED_CONTRACT_KINDS = [
180
+ 'module-metadata',
181
+ 'command',
182
+ 'worker',
183
+ 'page-middleware',
184
+ 'setup',
185
+ 'encryption',
186
+ 'di-registration',
187
+ 'custom-entity',
188
+ 'ai-extension',
189
+ 'generator-plugin',
190
+ ] as const
191
+
192
+ export type ModuleOwnedContractKind = (typeof MODULE_OWNED_CONTRACT_KINDS)[number]
159
193
 
160
194
  export type ModuleFactSafeScalar = string | number | boolean | null
161
195
 
@@ -192,8 +226,12 @@ export type ModuleFactSourceEntry = {
192
226
  factRef?: ModuleFactIndexRef
193
227
  }
194
228
 
229
+ export const MODULE_FACT_DIAGNOSTIC_CODES = ['duplicate-source', 'unresolved-static-contract'] as const
230
+
231
+ export type ModuleFactDiagnosticCode = (typeof MODULE_FACT_DIAGNOSTIC_CODES)[number]
232
+
195
233
  export type ModuleFactDiagnostic = {
196
- code: 'duplicate-source' | 'unresolved-static-contract'
234
+ code: ModuleFactDiagnosticCode
197
235
  kind: ModuleFactSourceKind
198
236
  id: string
199
237
  source?: ModuleFactSourceRef
@@ -208,6 +246,15 @@ export type ModuleDiRegistrationFact = {
208
246
  source: ModuleFactSourceRef
209
247
  }
210
248
 
249
+ /**
250
+ * How the extracted module reached the batch. `package` is the implicit default for
251
+ * every installed `@open-mercato/*` module and never appears in emitted output, so the
252
+ * package sidecar contract stays byte-identical. `local-reference` marks an app-local
253
+ * module projected into the explicit reference bundle; it is an independent discriminator
254
+ * from `sourcePackage`, which keeps its legacy "null means core package" meaning.
255
+ */
256
+ export type ModuleFactProjectionSourceKind = 'package' | 'local-reference'
257
+
211
258
  export interface ModuleFacts {
212
259
  module: string
213
260
  title: string | null
@@ -216,6 +263,8 @@ export interface ModuleFacts {
216
263
  sourcePackage: string | null
217
264
  sourceVersion: string | null
218
265
  sourceRoot: string
266
+ /** Present only for `local-reference` projections; package modules omit it entirely. */
267
+ sourceKind?: ModuleFactProjectionSourceKind
219
268
  entities: ModuleEntityFact[]
220
269
  events: ModuleEventFact[]
221
270
  aclFeatures: string[]
@@ -255,6 +304,15 @@ export interface ExtractModuleFactsOptions {
255
304
  sourceVersion?: string | null
256
305
  registryPath?: string | null
257
306
  registrySource?: string | null
307
+ /**
308
+ * App-root-relative POSIX root written into every emitted source path. Overrides the
309
+ * default `node_modules/<package>/src/modules/<moduleId>` layout so an app-local module
310
+ * emits `src/modules/<moduleId>` instead of a package path it does not live in.
311
+ */
312
+ portableSourceRoot?: string
313
+ sourceKind?: ModuleFactProjectionSourceKind
314
+ /** Generated-facts compatibility projection. Omitted means the corrected v2 contract. */
315
+ factsContractVersion?: 1 | 2
258
316
  }
259
317
 
260
318
  /** A discovered module and the source directory its facts are extracted from. */
@@ -263,6 +321,8 @@ export interface ModuleFactSource {
263
321
  moduleRoot: string
264
322
  from?: string
265
323
  packageVersion?: string | null
324
+ portableSourceRoot?: string
325
+ sourceKind?: ModuleFactProjectionSourceKind
266
326
  }
267
327
 
268
328
  function readSourceFile(filePath: string): ts.SourceFile | null {
@@ -343,46 +403,61 @@ function classHasUpdatedAtColumn(node: ts.ClassDeclaration): boolean {
343
403
  return false
344
404
  }
345
405
 
406
+ function readStaticAccessPath(candidate: ts.Expression): string[] | null {
407
+ const access = unwrapExpression(candidate)
408
+ if (ts.isIdentifier(access)) return [access.text]
409
+ if (ts.isPropertyAccessExpression(access)) {
410
+ const parent = readStaticAccessPath(access.expression)
411
+ return parent ? [...parent, access.name.text] : null
412
+ }
413
+ if (ts.isElementAccessExpression(access) && access.argumentExpression) {
414
+ const parent = readStaticAccessPath(access.expression)
415
+ const key = unwrapExpression(access.argumentExpression)
416
+ return parent && ts.isStringLiteralLike(key) ? [...parent, key.text] : null
417
+ }
418
+ return null
419
+ }
420
+
421
+ /**
422
+ * Resolves an entity-id expression to its literal `module:entity` value.
423
+ *
424
+ * Modules reference entity ids three ways and all three are statically knowable:
425
+ * a string literal, `E.<module>.<entity>` from the generated entity-id registry
426
+ * (`entity-ids.ts` emits `E[module][entity] = '<module>:<entity>'`), and — when
427
+ * `initializers` is supplied — a same-file `const ENTITY_ID = 'module:entity'`.
428
+ * Anything else stays undefined: a guessed id is worse than a missing one.
429
+ */
430
+ function resolveEntityIdReference(
431
+ expression: ts.Expression,
432
+ initializers?: Map<string, ts.Expression>,
433
+ visitedIdentifiers: Set<string> = new Set(),
434
+ ): string | undefined {
435
+ const current = unwrapExpression(expression)
436
+ if (ts.isStringLiteralLike(current)) return current.text
437
+ const accessPath = readStaticAccessPath(current)
438
+ if (accessPath?.length === 3 && accessPath[0] === 'E') {
439
+ return `${accessPath[1]}:${accessPath[2]}`
440
+ }
441
+ if (initializers && ts.isIdentifier(current) && !visitedIdentifiers.has(current.text)) {
442
+ visitedIdentifiers.add(current.text)
443
+ const declaration = initializers.get(current.text)
444
+ if (declaration) return resolveEntityIdReference(declaration, initializers, visitedIdentifiers)
445
+ }
446
+ return undefined
447
+ }
448
+
346
449
  function collectCustomFieldEntityIds(ceFilePath: string | null): Set<string> {
347
450
  const result = new Set<string>()
348
451
  if (!ceFilePath) return result
349
452
  const sourceFile = readSourceFile(ceFilePath)
350
453
  if (!sourceFile) return result
351
454
 
352
- const readEntityId = (expression: ts.Expression): string | undefined => {
353
- const current = unwrapExpression(expression)
354
- if (ts.isStringLiteralLike(current)) {
355
- return current.text.includes(':') ? current.text : undefined
356
- }
357
-
358
- const readAccessPath = (candidate: ts.Expression): string[] | null => {
359
- const access = unwrapExpression(candidate)
360
- if (ts.isIdentifier(access)) return [access.text]
361
- if (ts.isPropertyAccessExpression(access)) {
362
- const parent = readAccessPath(access.expression)
363
- return parent ? [...parent, access.name.text] : null
364
- }
365
- if (ts.isElementAccessExpression(access) && access.argumentExpression) {
366
- const parent = readAccessPath(access.expression)
367
- const key = unwrapExpression(access.argumentExpression)
368
- return parent && ts.isStringLiteralLike(key) ? [...parent, key.text] : null
369
- }
370
- return null
371
- }
372
-
373
- const accessPath = readAccessPath(current)
374
- if (accessPath?.length === 3 && accessPath[0] === 'E') {
375
- return `${accessPath[1]}:${accessPath[2]}`
376
- }
377
- return undefined
378
- }
379
-
380
455
  const visit = (node: ts.Node): void => {
381
456
  if (ts.isObjectLiteralExpression(node)) {
382
457
  const initializer = getObjectPropertyInitializer(node, 'id')
383
458
  if (initializer) {
384
- const id = readEntityId(initializer)
385
- if (id) result.add(id)
459
+ const id = resolveEntityIdReference(initializer)
460
+ if (id && id.includes(':')) result.add(id)
386
461
  }
387
462
  }
388
463
  node.forEachChild(visit)
@@ -443,9 +518,38 @@ function getPropertyName(property: ts.ObjectLiteralElementLike): string | undefi
443
518
  if (!name) return undefined
444
519
  if (ts.isIdentifier(name)) return name.text
445
520
  if (ts.isStringLiteralLike(name)) return name.text
521
+ // A computed key whose expression is a string literal or a same-file `const NAME = 'token'`
522
+ // is still a statically known token. Without this, `{ [SERVICE_TOKEN]: asFunction(...) }` —
523
+ // the idiomatic, type-safe way to register a DI token from an exported const — yielded
524
+ // undefined and the registration vanished from module facts with NO diagnostic, because the
525
+ // unresolved-token path is only reached for a NAMED token whose Awilix kind is unrecognised.
526
+ if (ts.isComputedPropertyName(name)) return resolveComputedPropertyKey(name.expression)
446
527
  return undefined
447
528
  }
448
529
 
530
+ /** Resolves a computed key to its literal token when the value is statically knowable. */
531
+ function resolveComputedPropertyKey(expression: ts.Expression): string | undefined {
532
+ if (ts.isStringLiteralLike(expression)) return expression.text
533
+ // `X as const` / `X satisfies Y` wrappers.
534
+ if (ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression)) {
535
+ return resolveComputedPropertyKey(expression.expression)
536
+ }
537
+ if (ts.isParenthesizedExpression(expression)) return resolveComputedPropertyKey(expression.expression)
538
+ if (!ts.isIdentifier(expression)) return undefined
539
+ const sourceFile = expression.getSourceFile()
540
+ if (!sourceFile) return undefined
541
+ let resolved: string | undefined
542
+ const visit = (node: ts.Node): void => {
543
+ if (resolved) return
544
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === expression.text) {
545
+ if (node.initializer) resolved = resolveComputedPropertyKey(node.initializer)
546
+ }
547
+ node.forEachChild(visit)
548
+ }
549
+ sourceFile.forEachChild(visit)
550
+ return resolved
551
+ }
552
+
449
553
  function getObjectPropertyInitializer(
450
554
  objectLiteral: ts.ObjectLiteralExpression,
451
555
  propertyName: string,
@@ -807,15 +911,29 @@ function extractSearchEntities(searchFilePath: string | null, warnings: string[]
807
911
  return []
808
912
  }
809
913
 
914
+ // `entityId` is rarely a bare string literal: modules reference the entity through
915
+ // `E.<module>.<entity>` or a same-file `const ENTITY_ID`. Reading only string literals
916
+ // dropped every such entry with NO diagnostic, so a module shipping `search.ts` scored
917
+ // zero search facts while looking healthy. Unresolvable entries now warn instead.
918
+ const initializers = buildVariableInitializerMap(sourceFile)
810
919
  const entityIds: string[] = []
811
920
  const seen = new Set<string>()
812
921
  for (const element of entitiesArray.elements) {
813
922
  if (!ts.isObjectLiteralExpression(element)) continue
814
- const entityId = readStringPropertyInitializer(element, 'entityId')
815
- if (entityId && !seen.has(entityId)) {
816
- seen.add(entityId)
817
- entityIds.push(entityId)
923
+ const initializer = getObjectPropertyInitializer(element, 'entityId')
924
+ const location = `${searchFilePath}:${nodeLine(sourceFile, element)}`
925
+ if (!initializer) {
926
+ warnings.push(`[module-facts] searchConfig entity declares no entityId: ${location}`)
927
+ continue
928
+ }
929
+ const entityId = resolveEntityIdReference(initializer, initializers)
930
+ if (!entityId) {
931
+ warnings.push(`[module-facts] searchConfig entityId is not statically resolvable: ${location}`)
932
+ continue
818
933
  }
934
+ if (seen.has(entityId)) continue
935
+ seen.add(entityId)
936
+ entityIds.push(entityId)
819
937
  }
820
938
  return entityIds
821
939
  }
@@ -911,6 +1029,26 @@ function toPortableSourceRoot(moduleId: string, sourcePackage: string | null): s
911
1029
  return path.posix.join('node_modules', sourcePackage ?? '@open-mercato/core', 'src', 'modules', moduleId)
912
1030
  }
913
1031
 
1032
+ /**
1033
+ * An explicit portable root must stay app-root-relative and POSIX-shaped: every emitted
1034
+ * fact path and markdown link is resolved against the generated app root, so an absolute
1035
+ * path, a Windows separator, or a traversal segment would emit an unclickable or
1036
+ * machine-specific reference into a distributed artifact.
1037
+ */
1038
+ export function assertPortableSourceRoot(moduleId: string, portableSourceRoot: string): string {
1039
+ const invalid = portableSourceRoot.length === 0
1040
+ || path.posix.isAbsolute(portableSourceRoot)
1041
+ || /^[A-Za-z]:/.test(portableSourceRoot)
1042
+ || portableSourceRoot.includes('\\')
1043
+ || portableSourceRoot.split('/').includes('..')
1044
+ if (invalid) {
1045
+ throw new Error(
1046
+ `[module-facts] portableSourceRoot for module "${moduleId}" must be a relative POSIX app path, received "${portableSourceRoot}"`,
1047
+ )
1048
+ }
1049
+ return portableSourceRoot
1050
+ }
1051
+
914
1052
  function toPortableSourcePath(moduleRoot: string, sourceRoot: string, filePath: string): string {
915
1053
  const relativePath = path.relative(moduleRoot, filePath).split(path.sep).join('/')
916
1054
  return path.posix.join(sourceRoot, relativePath)
@@ -1451,6 +1589,42 @@ function readResolvedStringProperty(
1451
1589
  return undefined
1452
1590
  }
1453
1591
 
1592
+ function readImportedStringIdentifier(
1593
+ sourceFile: ts.SourceFile,
1594
+ sourcePath: string,
1595
+ identifier: string,
1596
+ ): string | undefined {
1597
+ for (const statement of sourceFile.statements) {
1598
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteralLike(statement.moduleSpecifier)) continue
1599
+ const importClause = statement.importClause
1600
+ const bindings = importClause?.namedBindings
1601
+ if (!bindings || !ts.isNamedImports(bindings)) continue
1602
+ const imported = bindings.elements.find((element) => element.name.text === identifier)
1603
+ if (!imported) continue
1604
+ const request = statement.moduleSpecifier.text
1605
+ if (!request.startsWith('.')) return undefined
1606
+ const basePath = path.resolve(path.dirname(sourcePath), request)
1607
+ const candidates = [
1608
+ `${basePath}.ts`,
1609
+ `${basePath}.tsx`,
1610
+ path.join(basePath, 'index.ts'),
1611
+ path.join(basePath, 'index.tsx'),
1612
+ ]
1613
+ const importedPath = candidates.find((candidate) => fs.existsSync(candidate))
1614
+ if (!importedPath) return undefined
1615
+ const importedSource = readSourceFile(importedPath)
1616
+ if (!importedSource) return undefined
1617
+ const exportName = imported.propertyName?.text ?? imported.name.text
1618
+ const declaration = importedSource.statements
1619
+ .filter(ts.isVariableStatement)
1620
+ .flatMap((variable) => [...variable.declarationList.declarations])
1621
+ .find((candidate) => ts.isIdentifier(candidate.name) && candidate.name.text === exportName)
1622
+ const initializer = declaration?.initializer ? unwrapExpression(declaration.initializer) : null
1623
+ return initializer && ts.isStringLiteralLike(initializer) ? initializer.text : undefined
1624
+ }
1625
+ return undefined
1626
+ }
1627
+
1454
1628
  function extractWorkerContracts(
1455
1629
  moduleId: string,
1456
1630
  moduleRoot: string,
@@ -1474,7 +1648,11 @@ function extractWorkerContracts(
1474
1648
  // Runtime skips any worker without a queue, so a worker missing the required
1475
1649
  // property is not a contract; a present-but-dynamic queue is reported instead.
1476
1650
  const hasQueueProperty = getObjectPropertyInitializer(metadata, 'queue') !== undefined
1651
+ const queueInitializer = getObjectPropertyInitializer(metadata, 'queue')
1477
1652
  const queue = readResolvedStringProperty(metadata, 'queue', initializers)
1653
+ ?? (queueInitializer && ts.isIdentifier(unwrapExpression(queueInitializer))
1654
+ ? readImportedStringIdentifier(sourceFile, filePath, (unwrapExpression(queueInitializer) as ts.Identifier).text)
1655
+ : undefined)
1478
1656
  if (!hasQueueProperty) {
1479
1657
  unresolved.push({ code: 'unresolved-static-contract', kind: 'worker', id: workerId, source })
1480
1658
  continue
@@ -1897,7 +2075,9 @@ export function extractModuleFacts(options: ExtractModuleFactsOptions): ModuleFa
1897
2075
  if (!moduleRoot) {
1898
2076
  throw new Error(`[internal] extractModuleFacts requires moduleRoot or coreSrcRoot for module "${moduleId}"`)
1899
2077
  }
1900
- const sourceRoot = toPortableSourceRoot(moduleId, sourcePackage)
2078
+ const sourceRoot = options.portableSourceRoot
2079
+ ? assertPortableSourceRoot(moduleId, options.portableSourceRoot)
2080
+ : toPortableSourceRoot(moduleId, sourcePackage)
1901
2081
 
1902
2082
  const entitiesFilePath =
1903
2083
  resolveConventionFile(path.join(moduleRoot, 'data'), 'entities') ??
@@ -1960,6 +2140,7 @@ export function extractModuleFacts(options: ExtractModuleFactsOptions): ModuleFa
1960
2140
  notifications,
1961
2141
  aiTools,
1962
2142
  aiAgents,
2143
+ factsContractVersion: options.factsContractVersion ?? 2,
1963
2144
  })
1964
2145
 
1965
2146
  const indexSourcePath = portableOf(indexFilePath)
@@ -2166,6 +2347,7 @@ export function extractModuleFacts(options: ExtractModuleFactsOptions): ModuleFa
2166
2347
  sourcePackage,
2167
2348
  sourceVersion,
2168
2349
  sourceRoot,
2350
+ ...(options.sourceKind === 'local-reference' ? { sourceKind: options.sourceKind } : {}),
2169
2351
  entities,
2170
2352
  events,
2171
2353
  aclFeatures,
@@ -2212,6 +2394,8 @@ export interface ModuleFactsJsonEntry {
2212
2394
  sourcePackage: string | null
2213
2395
  sourceVersion: string | null
2214
2396
  sourceRoot: string
2397
+ /** Present only for `local-reference` projections; package sidecar entries omit it. */
2398
+ sourceKind?: ModuleFactProjectionSourceKind
2215
2399
  entities: ModuleEntityFact[]
2216
2400
  events: ModuleFactsJsonEvent[]
2217
2401
  aclFeatures: string[]
@@ -2289,13 +2473,26 @@ function renderSourceLinkedListSection(
2289
2473
  return [heading, '', `| ${idColumnLabel} | Source |`, '|---|---|', ...rows].join('\n')
2290
2474
  }
2291
2475
 
2476
+ /**
2477
+ * A generated fact only earns a markdown link when it names an exact module code
2478
+ * file. Directory-valued provenance (the module source root, framework hosts that
2479
+ * only know their owning package folder) renders as plain text: a link to a folder
2480
+ * resolves to nothing in an editor or on the repository browser, so emitting one
2481
+ * costs a consumer a dead click instead of taking them to the declaration site.
2482
+ */
2483
+ export function isExactSourceFilePath(sourcePath: string): boolean {
2484
+ return MODULE_CODE_EXTENSIONS.some((extension) => sourcePath.endsWith(extension))
2485
+ }
2486
+
2292
2487
  function renderSourceLink(sourcePath: string): string {
2488
+ if (!isExactSourceFilePath(sourcePath)) return sourcePath
2293
2489
  return `[${sourcePath}](../../../${sourcePath})`
2294
2490
  }
2295
2491
 
2296
2492
  function renderSourceRefLink(source: ModuleFactSourceRef): string {
2297
2493
  const anchor = source.line ? `#L${source.line}` : ''
2298
2494
  const label = source.line ? `${source.sourcePath}:${source.line}` : source.sourcePath
2495
+ if (!isExactSourceFilePath(source.sourcePath)) return label
2299
2496
  return `[${label}](../../../${source.sourcePath}${anchor})`
2300
2497
  }
2301
2498
 
@@ -2542,13 +2739,44 @@ function renderOverrideTargetsSection(targets: readonly ModuleOverrideTarget[] |
2542
2739
  return ['## Exact override targets', '', header, divider, ...rows].join('\n')
2543
2740
  }
2544
2741
 
2545
- export function renderModuleFactsMarkdown(facts: ModuleFacts): string {
2742
+ export interface ReferenceProjectionFingerprints {
2743
+ sourceFingerprint: string
2744
+ taxonomyFingerprint: string
2745
+ }
2746
+
2747
+ /**
2748
+ * Header for a `local-reference` projection. It never claims a package version — the
2749
+ * module is app-local — and it states in the document itself that the activations and
2750
+ * exact override targets below describe behavior only after the module is opted in,
2751
+ * because the artifact is generated while the module is disabled.
2752
+ */
2753
+ function renderReferenceProjectionHeader(
2754
+ facts: ModuleFacts,
2755
+ fingerprints: ReferenceProjectionFingerprints,
2756
+ ): string[] {
2757
+ return [
2758
+ `# ${facts.module} — module facts (generated reference projection, do not edit)`,
2759
+ `<!-- generated from local reference ${facts.sourceRoot}; source-fingerprint ${fingerprints.sourceFingerprint}; taxonomy-fingerprint ${fingerprints.taxonomyFingerprint} — R1 staleness stamp -->`,
2760
+ `> 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\`.`,
2761
+ `Source root: ${renderSourceLink(facts.sourceRoot)}`,
2762
+ ]
2763
+ }
2764
+
2765
+ export function renderModuleFactsMarkdown(
2766
+ facts: ModuleFacts,
2767
+ referenceProjection?: ReferenceProjectionFingerprints,
2768
+ ): string {
2546
2769
  const extensionSurfaces = facts.extensionSurfaces ?? { hosts: [], contributions: [], unresolved: [] }
2547
2770
  const lookup = buildFactSourceLookup(facts)
2771
+ const header = facts.sourceKind === 'local-reference' && referenceProjection
2772
+ ? renderReferenceProjectionHeader(facts, referenceProjection)
2773
+ : [
2774
+ `# ${facts.module} — module facts (generated, do not edit)`,
2775
+ renderVersionStamp(facts.coreVersion, facts.sourcePackage, facts.sourceVersion),
2776
+ `Source root: ${renderSourceLink(facts.sourceRoot)}`,
2777
+ ]
2548
2778
  const sections = [
2549
- `# ${facts.module} — module facts (generated, do not edit)`,
2550
- renderVersionStamp(facts.coreVersion, facts.sourcePackage, facts.sourceVersion),
2551
- `Source root: ${renderSourceLink(facts.sourceRoot)}`,
2779
+ ...header,
2552
2780
  '',
2553
2781
  renderEntitiesSection(facts.entities, lookup),
2554
2782
  '',
@@ -2624,6 +2852,7 @@ export function toModuleFactsJsonEntry(facts: ModuleFacts): ModuleFactsJsonEntry
2624
2852
  sourcePackage: facts.sourcePackage,
2625
2853
  sourceVersion: facts.sourceVersion,
2626
2854
  sourceRoot: facts.sourceRoot,
2855
+ ...(facts.sourceKind === 'local-reference' ? { sourceKind: facts.sourceKind } : {}),
2627
2856
  entities: facts.entities,
2628
2857
  events: facts.events.map((event) => ({
2629
2858
  id: event.id,
@@ -2675,6 +2904,274 @@ export function renderModuleFactsJson(factsByModule: Record<string, ModuleFacts>
2675
2904
  return `${JSON.stringify(buildModuleFactsJsonObject(factsByModule), null, 2)}\n`
2676
2905
  }
2677
2906
 
2907
+ /**
2908
+ * The normal package sidecar (`module-facts.json`) and the enabled-filtered package
2909
+ * markdown subset carry package-provided modules only. An app-local module reaching
2910
+ * either output would publish `bound` activations and exact override targets for code
2911
+ * the runtime does not select, so it fails the build instead of shipping.
2912
+ */
2913
+ export function assertPackageModuleFactsOnly(factsByModule: Record<string, ModuleFacts>): void {
2914
+ const offenders = Object.values(factsByModule)
2915
+ .filter((facts) => facts.sourceKind === 'local-reference' || !facts.sourceRoot.startsWith('node_modules/'))
2916
+ .map((facts) => `${facts.module} (${facts.sourceRoot})`)
2917
+ .sort((left, right) => left.localeCompare(right))
2918
+ if (offenders.length > 0) {
2919
+ throw new Error(
2920
+ `[module-facts] normal package output must not contain app-local modules: ${offenders.join(', ')}`,
2921
+ )
2922
+ }
2923
+ }
2924
+
2925
+ function sha256Hex(value: string | Buffer): string {
2926
+ return createHash('sha256').update(value).digest('hex')
2927
+ }
2928
+
2929
+ /**
2930
+ * Canonical form used by both reference fingerprints: object keys are recursively sorted
2931
+ * and set-valued arrays are sorted by their own canonical text. Structured `path` arrays
2932
+ * are ordered tuples whose segment order is part of the public target identity, so they
2933
+ * retain their declared order.
2934
+ */
2935
+ function canonicalizeFingerprintValue(value: unknown, propertyName?: string): unknown {
2936
+ if (Array.isArray(value)) {
2937
+ const items = value
2938
+ .map((item) => {
2939
+ const canonical = canonicalizeFingerprintValue(item)
2940
+ return { canonical, text: JSON.stringify(canonical) ?? '' }
2941
+ })
2942
+ if (propertyName === 'path') return items.map((item) => item.canonical)
2943
+ return items
2944
+ .sort((left, right) => (left.text < right.text ? -1 : left.text > right.text ? 1 : 0))
2945
+ .map((item) => item.canonical)
2946
+ }
2947
+ if (value !== null && typeof value === 'object') {
2948
+ const record = value as Record<string, unknown>
2949
+ const result: Record<string, unknown> = {}
2950
+ for (const key of Object.keys(record).sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))) {
2951
+ if (record[key] === undefined) continue
2952
+ result[key] = canonicalizeFingerprintValue(record[key], key)
2953
+ }
2954
+ return result
2955
+ }
2956
+ return value
2957
+ }
2958
+
2959
+ function canonicalFingerprintJson(value: unknown): string {
2960
+ return JSON.stringify(canonicalizeFingerprintValue(value))
2961
+ }
2962
+
2963
+ const REFERENCE_FINGERPRINT_SKIPPED_DIRECTORIES = new Set(['__tests__', '__integration__', 'node_modules'])
2964
+
2965
+ /**
2966
+ * SHA-256 over the canonical JSON of `{ path, sha256 }` records sorted by path, where
2967
+ * `path` is the portable app-relative path so the value is identical across the
2968
+ * authoring, template, and emitted roots. Test-only directories are excluded because the
2969
+ * scaffold does not emit them, so including them would make the emitted app's recomputed
2970
+ * fingerprint disagree with the one built from the template.
2971
+ */
2972
+ export function computeModuleSourceFingerprint(moduleRoot: string, portableSourceRoot: string): string {
2973
+ assertPortableSourceRoot(path.basename(portableSourceRoot), portableSourceRoot)
2974
+ const records: { path: string; sha256: string }[] = []
2975
+ const walk = (directory: string): void => {
2976
+ const entries = fs.readdirSync(directory, { withFileTypes: true })
2977
+ for (const entry of entries) {
2978
+ const fullPath = path.join(directory, entry.name)
2979
+ if (entry.isDirectory()) {
2980
+ if (REFERENCE_FINGERPRINT_SKIPPED_DIRECTORIES.has(entry.name)) continue
2981
+ walk(fullPath)
2982
+ continue
2983
+ }
2984
+ if (!entry.isFile()) continue
2985
+ records.push({
2986
+ path: toPortableSourcePath(moduleRoot, portableSourceRoot, fullPath),
2987
+ sha256: sha256Hex(fs.readFileSync(fullPath)),
2988
+ })
2989
+ }
2990
+ }
2991
+ walk(moduleRoot)
2992
+ records.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
2993
+ return sha256Hex(canonicalFingerprintJson(records))
2994
+ }
2995
+
2996
+ /**
2997
+ * Provenance and release metadata are not taxonomy: a version bump must not invalidate
2998
+ * the classification fingerprint. Every remaining section of the emitted entry is a
2999
+ * classified set and contributes.
3000
+ */
3001
+ const TAXONOMY_EXCLUDED_SECTIONS = new Set([
3002
+ 'title',
3003
+ 'description',
3004
+ 'coreVersion',
3005
+ 'sourcePackage',
3006
+ 'sourceVersion',
3007
+ 'sourceRoot',
3008
+ 'sourceKind',
3009
+ ])
3010
+
3011
+ function collectTaxonomyRecords(
3012
+ setName: string,
3013
+ value: unknown,
3014
+ records: { setName: string; value: unknown }[],
3015
+ ): void {
3016
+ if (value === undefined) return
3017
+ if (Array.isArray(value)) {
3018
+ for (const item of value) records.push({ setName, value: canonicalizeFingerprintValue(item) })
3019
+ return
3020
+ }
3021
+ if (value !== null && typeof value === 'object') {
3022
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
3023
+ collectTaxonomyRecords(`${setName}.${key}`, child, records)
3024
+ }
3025
+ return
3026
+ }
3027
+ records.push({ setName, value })
3028
+ }
3029
+
3030
+ /**
3031
+ * SHA-256 over the canonical JSON of `{ setName, value }` records sorted by set name and
3032
+ * canonicalized value. Each record carries a complete literal or structured entry — key,
3033
+ * suffix, capabilities, `bound`, phases, operations included — so no delimiter-joined
3034
+ * tuple can collapse two distinct classifications into one fingerprint input.
3035
+ */
3036
+ export function computeModuleTaxonomyFingerprint(entry: ModuleFactsJsonEntry): string {
3037
+ const records: { setName: string; value: unknown }[] = []
3038
+ for (const [setName, value] of Object.entries(entry as unknown as Record<string, unknown>)) {
3039
+ if (TAXONOMY_EXCLUDED_SECTIONS.has(setName)) continue
3040
+ collectTaxonomyRecords(setName, value, records)
3041
+ }
3042
+ const decorated = records
3043
+ .map((record) => ({ record, text: JSON.stringify(record.value) ?? '' }))
3044
+ .sort((left, right) =>
3045
+ left.record.setName < right.record.setName
3046
+ ? -1
3047
+ : left.record.setName > right.record.setName
3048
+ ? 1
3049
+ : left.text < right.text
3050
+ ? -1
3051
+ : left.text > right.text
3052
+ ? 1
3053
+ : 0,
3054
+ )
3055
+ return sha256Hex(JSON.stringify(decorated.map((item) => item.record)))
3056
+ }
3057
+
3058
+ export interface ReferenceModuleFactsEntry {
3059
+ moduleId: string
3060
+ projectionKind: 'activated-reference'
3061
+ sourceKind: 'local-reference'
3062
+ runtimeSelected: false
3063
+ sourceFingerprint: string
3064
+ taxonomyFingerprint: string
3065
+ /**
3066
+ * The enum-derived classification of every taxonomy `facts` is expressed in, built
3067
+ * by the same generation run. It travels with the projection so a reader of the
3068
+ * emitted sheet can tell a value the example really emits from one that is only
3069
+ * reserved, pending, or reachable through a negative fixture.
3070
+ */
3071
+ factCoverage: ModuleFactCoverageFamily[]
3072
+ facts: ModuleFactsJsonEntry
3073
+ }
3074
+
3075
+ export type ReferenceModuleFactsJson = Record<string, ReferenceModuleFactsEntry>
3076
+
3077
+ export function renderReferenceModuleFactsJson(bundle: ReferenceModuleFactsJson): string {
3078
+ const ordered: ReferenceModuleFactsJson = {}
3079
+ for (const moduleId of Object.keys(bundle).sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))) {
3080
+ ordered[moduleId] = bundle[moduleId]
3081
+ }
3082
+ return `${JSON.stringify(ordered, null, 2)}\n`
3083
+ }
3084
+
3085
+ export interface ExtractLocalReferenceModuleFactsOptions {
3086
+ /** Package-provided sources, exactly as the normal batch receives them. */
3087
+ packageSources: readonly ModuleFactSource[]
3088
+ /** The app-local module projected into the reference bundle. */
3089
+ reference: ModuleFactSource
3090
+ registryPath?: string | null
3091
+ registrySource?: string | null
3092
+ coreVersion?: string | null
3093
+ }
3094
+
3095
+ export interface LocalReferenceModuleFactsResult {
3096
+ entry: ReferenceModuleFactsEntry
3097
+ markdown: string
3098
+ warnings: string[]
3099
+ /**
3100
+ * First-party targets the activated projection could not correlate. They also remain in
3101
+ * the emitted `facts.extensionSurfaces.unresolved`, so the artifact states its own gaps
3102
+ * rather than presenting a partial surface as complete.
3103
+ */
3104
+ unresolvedTargets: string[]
3105
+ }
3106
+
3107
+ /**
3108
+ * Produces the reference projection of an app-local module from an explicitly activated,
3109
+ * disposable selection context: the local source is appended to the package sources and
3110
+ * the whole set runs through the same `extractAllModuleFacts` selection and correlation
3111
+ * batch, so incoming contributions and optional targets resolve exactly as they would
3112
+ * after opt-in. Merging an independently extracted object into a finished batch is
3113
+ * forbidden — it would silently drop both resolutions. The batch is disposable: the
3114
+ * caller keeps only this module's projection and never writes the rest, which is why the
3115
+ * normal package outputs stay byte-identical.
3116
+ */
3117
+ export function extractLocalReferenceModuleFacts(
3118
+ options: ExtractLocalReferenceModuleFactsOptions,
3119
+ ): LocalReferenceModuleFactsResult {
3120
+ const { reference } = options
3121
+ if (reference.sourceKind !== 'local-reference') {
3122
+ throw new Error(
3123
+ `[module-facts] reference source "${reference.moduleId}" must declare sourceKind "local-reference"`,
3124
+ )
3125
+ }
3126
+ const portableSourceRoot = reference.portableSourceRoot
3127
+ if (!portableSourceRoot) {
3128
+ throw new Error(`[module-facts] reference source "${reference.moduleId}" must declare portableSourceRoot`)
3129
+ }
3130
+ assertPortableSourceRoot(reference.moduleId, portableSourceRoot)
3131
+
3132
+ const sources = appendLocalReferenceModuleSource(options.packageSources, reference)
3133
+ const activated = extractAllModuleFacts({
3134
+ sources,
3135
+ registryPath: options.registryPath ?? null,
3136
+ registrySource: options.registrySource ?? null,
3137
+ coreVersion: options.coreVersion ?? null,
3138
+ // The disposable projection collects rather than throws: an app-local module can name
3139
+ // a target whose classification is still missing from the shared extension reader, and
3140
+ // aborting there would delete the whole reference bundle instead of publishing the
3141
+ // exact gap. Normal package output keeps the strict default.
3142
+ unresolvedFirstPartyTargets: 'collect',
3143
+ })
3144
+ const facts = activated.factsByModule[reference.moduleId]
3145
+ if (!facts) {
3146
+ throw new Error(`[module-facts] reference module "${reference.moduleId}" produced no facts`)
3147
+ }
3148
+ if (facts.sourceRoot !== portableSourceRoot) {
3149
+ throw new Error(
3150
+ `[module-facts] reference module "${reference.moduleId}" emitted non-portable source root "${facts.sourceRoot}"`,
3151
+ )
3152
+ }
3153
+
3154
+ const jsonEntry = toModuleFactsJsonEntry(facts)
3155
+ const fingerprints: ReferenceProjectionFingerprints = {
3156
+ sourceFingerprint: computeModuleSourceFingerprint(reference.moduleRoot, portableSourceRoot),
3157
+ taxonomyFingerprint: computeModuleTaxonomyFingerprint(jsonEntry),
3158
+ }
3159
+ return {
3160
+ entry: {
3161
+ moduleId: reference.moduleId,
3162
+ projectionKind: 'activated-reference',
3163
+ sourceKind: 'local-reference',
3164
+ runtimeSelected: false,
3165
+ ...fingerprints,
3166
+ factCoverage: activated.factCoverage ?? buildModuleFactCoverageLedger(),
3167
+ facts: jsonEntry,
3168
+ },
3169
+ markdown: renderModuleFactsMarkdown(facts, fingerprints),
3170
+ warnings: activated.warnings,
3171
+ unresolvedTargets: activated.unresolvedFirstPartyTargets ?? [],
3172
+ }
3173
+ }
3174
+
2678
3175
  export interface ExtractAllModuleFactsOptions {
2679
3176
  /**
2680
3177
  * Discovered module sources (auto-discovery path). When provided, each entry's
@@ -2688,6 +3185,15 @@ export interface ExtractAllModuleFactsOptions {
2688
3185
  coreVersion?: string | null
2689
3186
  /** @deprecated Legacy explicit module-id list; only consulted when `sources` is absent. */
2690
3187
  moduleIds?: readonly string[]
3188
+ /**
3189
+ * `throw` (the default, and the only value any normal package output uses) aborts the
3190
+ * batch when a first-party extension target does not correlate. `collect` keeps the
3191
+ * unresolved entries in `extensionSurfaces.unresolved` and reports them on the result
3192
+ * so a disposable projection can publish the gap instead of silently dropping it.
3193
+ */
3194
+ unresolvedFirstPartyTargets?: 'throw' | 'collect'
3195
+ /** Generated-facts compatibility projection. Omitted means the corrected v2 contract. */
3196
+ factsContractVersion?: 1 | 2
2691
3197
  }
2692
3198
 
2693
3199
  export interface ExtractAllModuleFactsResult {
@@ -2695,6 +3201,31 @@ export interface ExtractAllModuleFactsResult {
2695
3201
  markdownByModule: Record<string, string>
2696
3202
  warnings: string[]
2697
3203
  frameworkMarkdown: string
3204
+ /** Always empty under the default `throw` policy, which aborts before returning. */
3205
+ unresolvedFirstPartyTargets?: string[]
3206
+ /**
3207
+ * The enum-derived coverage ledger for the taxonomies these facts are expressed
3208
+ * in, built during generation so an enum value with no classification aborts the
3209
+ * run instead of shipping an unclassified public value.
3210
+ */
3211
+ factCoverage?: ModuleFactCoverageFamily[]
3212
+ }
3213
+
3214
+ /**
3215
+ * `<moduleId>:<contributionId>:<targetId>` for every first-party contribution target that
3216
+ * did not correlate — the same identity {@link assertNoUnresolvedExtensionTargets} throws
3217
+ * with, so a collected batch reports exactly what a strict batch would have refused.
3218
+ */
3219
+ export function collectUnresolvedFirstPartyTargets(
3220
+ surfacesByModule: Readonly<Record<string, ModuleExtensionSurfaceFacts>>,
3221
+ ): string[] {
3222
+ return Object.entries(surfacesByModule)
3223
+ .flatMap(([moduleId, surface]) =>
3224
+ surface.unresolved
3225
+ .filter((entry) => entry.reason === 'unresolved-first-party-target')
3226
+ .map((entry) => `${moduleId}:${entry.key}`),
3227
+ )
3228
+ .sort((left, right) => left.localeCompare(right))
2698
3229
  }
2699
3230
 
2700
3231
  export function extractAllModuleFacts(options: ExtractAllModuleFactsOptions): ExtractAllModuleFactsResult {
@@ -2702,6 +3233,7 @@ export function extractAllModuleFacts(options: ExtractAllModuleFactsOptions): Ex
2702
3233
  }
2703
3234
 
2704
3235
  function extractAllModuleFactsWithCache(options: ExtractAllModuleFactsOptions): ExtractAllModuleFactsResult {
3236
+ const factCoverage = buildModuleFactCoverageLedger()
2705
3237
  const sources: ModuleFactSource[] = options.sources
2706
3238
  ? [...options.sources]
2707
3239
  : (options.coreSrcRoot
@@ -2723,6 +3255,9 @@ function extractAllModuleFactsWithCache(options: ExtractAllModuleFactsOptions):
2723
3255
  sourceVersion: source.packageVersion ?? null,
2724
3256
  registryPath: options.registryPath ?? null,
2725
3257
  registrySource: options.registrySource ?? null,
3258
+ ...(source.portableSourceRoot ? { portableSourceRoot: source.portableSourceRoot } : {}),
3259
+ ...(source.sourceKind ? { sourceKind: source.sourceKind } : {}),
3260
+ factsContractVersion: options.factsContractVersion ?? 2,
2726
3261
  })
2727
3262
  factsByModule[source.moduleId] = facts
2728
3263
  warnings.push(...facts.warnings)
@@ -2742,8 +3277,10 @@ function extractAllModuleFactsWithCache(options: ExtractAllModuleFactsOptions):
2742
3277
  ...sources.flatMap((source) => extractKnownApiRouteIds(source.moduleId, source.moduleRoot)),
2743
3278
  ]),
2744
3279
  commandIds: new Set(sources.flatMap((source) => extractKnownCommandIds(source.moduleId, source.moduleRoot))),
3280
+ factsContractVersion: options.factsContractVersion ?? 2,
2745
3281
  })
2746
- assertNoUnresolvedExtensionTargets(correlated)
3282
+ const unresolvedFirstPartyTargets = collectUnresolvedFirstPartyTargets(correlated)
3283
+ if ((options.unresolvedFirstPartyTargets ?? 'throw') === 'throw') assertNoUnresolvedExtensionTargets(correlated)
2747
3284
  const apiRouteOwners = new Map<string, {
2748
3285
  moduleId: string
2749
3286
  source: ModuleFactSourceRef
@@ -2779,5 +3316,712 @@ function extractAllModuleFactsWithCache(options: ExtractAllModuleFactsOptions):
2779
3316
  `[module-facts] ${moduleId} ${entry.reason}: ${entry.key} (${entry.source.path})`,
2780
3317
  ))
2781
3318
  }
2782
- return { factsByModule, markdownByModule, warnings, frameworkMarkdown: renderFrameworkExtensionPointsMarkdown() }
3319
+ return {
3320
+ factsByModule,
3321
+ markdownByModule,
3322
+ warnings,
3323
+ frameworkMarkdown: renderFrameworkExtensionPointsMarkdown(),
3324
+ unresolvedFirstPartyTargets,
3325
+ factCoverage,
3326
+ }
3327
+ }
3328
+
3329
+ /**
3330
+ * Enum-derived `factCoverage` ledger — spec
3331
+ * `.ai/specs/2026-07-31-standalone-canonical-example-module.md`,
3332
+ * § PR #4883 Module-Fact and Extension-Topology Contract.
3333
+ *
3334
+ * Every finite public set is enumerated from the `as const` ledger that also derives
3335
+ * its type, so an added enum value has no ledger row and fails
3336
+ * {@link buildModuleFactCoverageLedger} instead of joining the public surface
3337
+ * unclassified. The purpose of the ledger is to make a *silent zero* impossible: a
3338
+ * family cannot claim canonical coverage while the real extractor publishes no
3339
+ * contribution for it.
3340
+ */
3341
+ export type ModuleFactCoverageStatus =
3342
+ /** The canonical example really emits it; a real extraction proves a non-zero count. */
3343
+ | 'emitted-example'
3344
+ /** A framework/app-level setting that never becomes a module contribution. */
3345
+ | 'framework-only'
3346
+ /** Described by the framework catalog only; the example contributes nothing for it. */
3347
+ | 'catalog-only'
3348
+ /** In the closed public set, but no generator code path emits it today. */
3349
+ | 'currently-unbound'
3350
+ /**
3351
+ * Required to be `emitted-example` by the spec but NOT emitted yet. The status
3352
+ * itself states the gap, so a machine reader keying on `status` alone is never
3353
+ * told the example emits something it does not.
3354
+ */
3355
+ | 'pending-emission'
3356
+ /** Only a deliberately broken fixture produces it; canonical output emits none. */
3357
+ | 'negative-fixture'
3358
+
3359
+ export type ModuleFactCoverageRow = {
3360
+ value: string
3361
+ status: ModuleFactCoverageStatus
3362
+ note: string
3363
+ /**
3364
+ * Names the slice that owns closing a `pending-emission` gap. A pending row is
3365
+ * asserted to have a *zero* real count, so it can never be mistaken for proven
3366
+ * coverage and cannot go stale once the gap closes.
3367
+ */
3368
+ pendingEmission?: string
3369
+ /**
3370
+ * Set when the value is extractable only from the generated module registry rather
3371
+ * than from module source, so a source-only extraction legitimately reports zero.
3372
+ *
3373
+ * This is the only way a row skips the non-zero proof, so the flag is proven rather
3374
+ * than trusted: `module-facts.example-fact-coverage.test.ts` asserts every row
3375
+ * carrying it counts zero from a source-only extraction of the example AND non-zero
3376
+ * once the same extraction is handed a module registry.
3377
+ */
3378
+ requiresGeneratedRegistry?: true
3379
+ }
3380
+
3381
+ export type ModuleFactCoverageFamily = {
3382
+ family: string
3383
+ enumSource: string
3384
+ rows: ModuleFactCoverageRow[]
3385
+ }
3386
+
3387
+ const FACT_SOURCE_KIND_COVERAGE: Record<ModuleFactSourceKind, ModuleFactCoverageRow> = {
3388
+ 'module-metadata': { value: 'module-metadata', status: 'emitted-example', note: 'example/index.ts ModuleInfo export' },
3389
+ entity: { value: 'entity', status: 'emitted-example', note: 'example/data/entities.ts ORM entities' },
3390
+ event: { value: 'event', status: 'emitted-example', note: 'example/events.ts typed event definitions' },
3391
+ 'acl-feature': { value: 'acl-feature', status: 'emitted-example', note: 'example/acl.ts RBAC features' },
3392
+ 'api-route': {
3393
+ value: 'api-route',
3394
+ status: 'emitted-example',
3395
+ note: 'example/api/**/route.ts; route facts are read from the generated module registry, not from module source',
3396
+ requiresGeneratedRegistry: true,
3397
+ },
3398
+ 'di-registration': { value: 'di-registration', status: 'emitted-example', note: 'example/di.ts Awilix container.register' },
3399
+ search: { value: 'search', status: 'emitted-example', note: 'example/search.ts indexed entity declaration' },
3400
+ vector: {
3401
+ value: 'vector',
3402
+ status: 'emitted-example',
3403
+ note: 'example/vector.ts deterministic Todo vector declaration',
3404
+ },
3405
+ notification: { value: 'notification', status: 'emitted-example', note: 'example/notifications.ts notification types' },
3406
+ 'cli-command': { value: 'cli-command', status: 'emitted-example', note: 'example/cli.ts module commands' },
3407
+ 'backend-page': { value: 'backend-page', status: 'emitted-example', note: 'example/backend/**/page.tsx admin pages' },
3408
+ 'frontend-page': { value: 'frontend-page', status: 'emitted-example', note: 'example/frontend/**/page.tsx portal pages' },
3409
+ 'ai-tool': { value: 'ai-tool', status: 'emitted-example', note: 'example/ai-tools.ts defineAiTool declarations' },
3410
+ 'ai-agent': { value: 'ai-agent', status: 'emitted-example', note: 'example/ai-agents.ts defineAiAgent declarations' },
3411
+ 'ai-extension': { value: 'ai-extension', status: 'emitted-example', note: 'example AI agent/tool override and extension records' },
3412
+ command: { value: 'command', status: 'emitted-example', note: 'example/commands/todos.ts command handlers' },
3413
+ subscriber: { value: 'subscriber', status: 'emitted-example', note: 'example/subscribers/*.ts event subscribers' },
3414
+ worker: { value: 'worker', status: 'emitted-example', note: 'example/workers/*.ts queue workers' },
3415
+ 'page-middleware': { value: 'page-middleware', status: 'emitted-example', note: 'example/backend/middleware.ts page guard' },
3416
+ setup: { value: 'setup', status: 'emitted-example', note: 'example/setup.ts tenant setup hooks' },
3417
+ encryption: { value: 'encryption', status: 'emitted-example', note: 'example/encryption.ts field encryption map' },
3418
+ 'custom-entity': { value: 'custom-entity', status: 'emitted-example', note: 'example/ce.ts custom entity and field DSL' },
3419
+ integration: { value: 'integration', status: 'emitted-example', note: 'example/lib/mock-*-adapter.ts integration registrations' },
3420
+ 'generator-plugin': {
3421
+ value: 'generator-plugin',
3422
+ status: 'emitted-example',
3423
+ note: 'example/generators.ts deterministic reference-index generator plugin',
3424
+ },
3425
+ 'extension-host': { value: 'extension-host', status: 'emitted-example', note: 'example/extension-points.ts declared hosts' },
3426
+ 'extension-contribution': {
3427
+ value: 'extension-contribution',
3428
+ status: 'emitted-example',
3429
+ note: 'example widget, DataTable, CrudForm, interceptor and enricher contributions',
3430
+ },
3431
+ }
3432
+
3433
+ const OWNED_CONTRACT_KIND_COVERAGE: Record<ModuleOwnedContractKind, ModuleFactCoverageRow> = {
3434
+ 'module-metadata': { value: 'module-metadata', status: 'emitted-example', note: 'ModuleInfo owned contract' },
3435
+ command: { value: 'command', status: 'emitted-example', note: 'example/commands/todos.ts' },
3436
+ worker: { value: 'worker', status: 'emitted-example', note: 'example/workers/*.ts' },
3437
+ 'page-middleware': { value: 'page-middleware', status: 'emitted-example', note: 'example/backend/middleware.ts' },
3438
+ setup: { value: 'setup', status: 'emitted-example', note: 'example/setup.ts' },
3439
+ encryption: { value: 'encryption', status: 'emitted-example', note: 'example/encryption.ts' },
3440
+ 'di-registration': {
3441
+ value: 'di-registration',
3442
+ status: 'emitted-example',
3443
+ note: 'example/di.ts scoped Todo summary cache service consumed by a route',
3444
+ },
3445
+ 'custom-entity': { value: 'custom-entity', status: 'emitted-example', note: 'example/ce.ts' },
3446
+ 'ai-extension': { value: 'ai-extension', status: 'emitted-example', note: 'example AI override/extension record' },
3447
+ 'generator-plugin': {
3448
+ value: 'generator-plugin',
3449
+ status: 'emitted-example',
3450
+ note: 'example/generators.ts deterministic reference-index generator plugin',
3451
+ },
3452
+ }
3453
+
3454
+ const OVERRIDE_DOMAIN_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3455
+ ai: { value: 'ai', status: 'emitted-example', note: 'ai.agents, ai.tools and ai.extensions targets' },
3456
+ routes: { value: 'routes', status: 'emitted-example', note: 'routes.api and routes.pages targets' },
3457
+ events: { value: 'events', status: 'emitted-example', note: 'events.subscribers targets' },
3458
+ workers: { value: 'workers', status: 'emitted-example', note: 'workers targets' },
3459
+ widgets: { value: 'widgets', status: 'emitted-example', note: 'widgets.injection, .components and .dashboard targets' },
3460
+ notifications: { value: 'notifications', status: 'emitted-example', note: 'notifications.types and notifications.handlers targets' },
3461
+ interceptors: { value: 'interceptors', status: 'emitted-example', note: 'API interceptor targets' },
3462
+ commandInterceptors: { value: 'commandInterceptors', status: 'emitted-example', note: 'command interceptor targets' },
3463
+ enrichers: { value: 'enrichers', status: 'emitted-example', note: 'response enricher targets' },
3464
+ guards: { value: 'guards', status: 'emitted-example', note: 'page-middleware guard targets' },
3465
+ cli: { value: 'cli', status: 'emitted-example', note: 'CLI command targets' },
3466
+ setup: { value: 'setup', status: 'emitted-example', note: 'setup hook and default-role targets' },
3467
+ acl: { value: 'acl', status: 'emitted-example', note: 'acl.features targets' },
3468
+ di: { value: 'di', status: 'emitted-example', note: 'DI token targets' },
3469
+ encryption: { value: 'encryption', status: 'emitted-example', note: 'encryption.maps targets' },
3470
+ nav: {
3471
+ value: 'nav',
3472
+ status: 'framework-only',
3473
+ note: 'nav.groupOrder is an app-level framework setting; no module target is ever emitted for it',
3474
+ },
3475
+ }
3476
+
3477
+ const OVERRIDE_MODE_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3478
+ 'disable-replace': {
3479
+ value: 'disable-replace',
3480
+ status: 'emitted-example',
3481
+ note: 'default mode for most example override hosts',
3482
+ },
3483
+ replace: { value: 'replace', status: 'emitted-example', note: 'the setup host replaces wholesale' },
3484
+ additive: { value: 'additive', status: 'emitted-example', note: 'the ai.extensions host merges additively' },
3485
+ }
3486
+
3487
+ const OVERRIDE_NOTE_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3488
+ 'safe-metadata-only': {
3489
+ value: 'safe-metadata-only',
3490
+ status: 'emitted-example',
3491
+ note: 'emitted on the metadata-only override target',
3492
+ },
3493
+ 'page-middleware-not-mutation-guard': {
3494
+ value: 'page-middleware-not-mutation-guard',
3495
+ status: 'emitted-example',
3496
+ note: 'emitted on the guards target so a page guard is not read as a mutation guard',
3497
+ },
3498
+ }
3499
+
3500
+ /**
3501
+ * Every `negative-fixture` row here names a code that a real fixture in
3502
+ * `module-override-targets.diagnostic-wiring.test.ts` drives out of
3503
+ * `collectModuleOverrideTargets`; the coverage test asserts that fixture set matches
3504
+ * these rows exactly, so a row can never claim a fixture that does not exist.
3505
+ */
3506
+ const OVERRIDE_DIAGNOSTIC_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3507
+ 'missing-owned-fact': {
3508
+ value: 'missing-owned-fact',
3509
+ status: 'currently-unbound',
3510
+ note: 'reserved by the spec\'s closed diagnostic set; no adapter emits it today, so not even a broken fixture can produce it',
3511
+ },
3512
+ 'missing-source': {
3513
+ value: 'missing-source',
3514
+ status: 'negative-fixture',
3515
+ note: 'a fact whose provenance cannot be proven yields this and no target; activated canonical output emits none',
3516
+ },
3517
+ 'unsupported-dynamic-key': {
3518
+ value: 'unsupported-dynamic-key',
3519
+ status: 'negative-fixture',
3520
+ note: 'a key the runtime cannot address yields this and no target; activated canonical output emits none',
3521
+ },
3522
+ 'unknown-framework-domain': {
3523
+ value: 'unknown-framework-domain',
3524
+ status: 'negative-fixture',
3525
+ note: 'the framework catalog does not describe the dotted host at all',
3526
+ },
3527
+ 'unknown-framework-mode': {
3528
+ value: 'unknown-framework-mode',
3529
+ status: 'negative-fixture',
3530
+ note: 'the framework catalog describes the host but names an operation that is not a public mode',
3531
+ },
3532
+ }
3533
+
3534
+ const CONTRIBUTION_KIND_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3535
+ widget: { value: 'widget', status: 'emitted-example', note: 'example widget injection and dashboard registrations' },
3536
+ 'data-table': { value: 'data-table', status: 'emitted-example', note: 'example DataTable extension entries' },
3537
+ 'crud-form': { value: 'crud-form', status: 'emitted-example', note: 'example CrudForm field and lifecycle entries' },
3538
+ 'component-override': { value: 'component-override', status: 'emitted-example', note: 'example replace, wrapper and props component entries' },
3539
+ 'response-enricher': { value: 'response-enricher', status: 'emitted-example', note: 'example customer priority response enricher' },
3540
+ 'api-interceptor': { value: 'api-interceptor', status: 'emitted-example', note: 'example CRUD API interceptor entries' },
3541
+ 'command-interceptor': { value: 'command-interceptor', status: 'emitted-example', note: 'example command interceptor entries' },
3542
+ 'mutation-guard': { value: 'mutation-guard', status: 'emitted-example', note: 'example Todo mutation guard' },
3543
+ 'entity-extension': { value: 'entity-extension', status: 'emitted-example', note: 'example customer entity extension' },
3544
+ subscriber: { value: 'subscriber', status: 'emitted-example', note: 'example synchronous and asynchronous subscribers' },
3545
+ 'browser-reaction': { value: 'browser-reaction', status: 'emitted-example', note: 'example client, portal and notification reactions' },
3546
+ 'specialized-registry': { value: 'specialized-registry', status: 'emitted-example', note: 'example notification, search, AI and mock adapter registries' },
3547
+ 'module-override': { value: 'module-override', status: 'catalog-only', note: 'app-owned configuration is not an outgoing module contribution' },
3548
+ }
3549
+
3550
+ const ACTIVATION_KIND_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3551
+ 'crud-response-enricher': { value: 'crud-response-enricher', status: 'currently-unbound', note: 'example contributes a response enricher but owns no CRUD response call site for its customer target' },
3552
+ 'query-enricher': { value: 'query-enricher', status: 'currently-unbound', note: 'example contributes a query enricher but owns no query-engine call site for its customer target' },
3553
+ 'mutation-guard': { value: 'mutation-guard', status: 'emitted-example', note: 'Todo mutation-guard route bridge' },
3554
+ 'api-interceptor-bridge': { value: 'api-interceptor-bridge', status: 'emitted-example', note: 'CRUD route interceptor pipeline' },
3555
+ 'command-interceptor-bridge': { value: 'command-interceptor-bridge', status: 'emitted-example', note: 'exact Todo command bus target' },
3556
+ 'widget-injection-consumer': { value: 'widget-injection-consumer', status: 'emitted-example', note: 'bound injection host consumer' },
3557
+ 'component-extension-consumer': { value: 'component-extension-consumer', status: 'emitted-example', note: 'bound component handle consumer' },
3558
+ 'dashboard-host-consumer': { value: 'dashboard-host-consumer', status: 'emitted-example', note: 'framework dashboard registry consumer' },
3559
+ }
3560
+
3561
+ const HOST_FAMILY_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3562
+ generic: { value: 'generic', status: 'emitted-example', note: 'example headless injection host' },
3563
+ menu: { value: 'menu', status: 'framework-only', note: 'framework menu hosts consumed by example widgets' },
3564
+ 'data-table': { value: 'data-table', status: 'emitted-example', note: 'example Todo DataTable host' },
3565
+ 'crud-form': { value: 'crud-form', status: 'emitted-example', note: 'example Todo CrudForm host' },
3566
+ detail: { value: 'detail', status: 'framework-only', note: 'framework detail hosts consumed by example widgets' },
3567
+ 'portal-page': { value: 'portal-page', status: 'framework-only', note: 'framework portal host consumed by example widgets' },
3568
+ 'component-handle': { value: 'component-handle', status: 'emitted-example', note: 'example replaceable component host' },
3569
+ entity: { value: 'entity', status: 'emitted-example', note: 'example entity fact-reference hosts' },
3570
+ 'api-route': { value: 'api-route', status: 'framework-only', note: 'API route hosts are owned by the target module and consumed by example interceptors' },
3571
+ command: { value: 'command', status: 'framework-only', note: 'command hosts are owned by the target module and consumed by example interceptors' },
3572
+ event: { value: 'event', status: 'emitted-example', note: 'example event fact-reference hosts' },
3573
+ 'query-lifecycle': { value: 'query-lifecycle', status: 'emitted-example', note: 'example Todo query lifecycle hosts' },
3574
+ dashboard: { value: 'dashboard', status: 'framework-only', note: 'framework dashboard host consumed by example widgets' },
3575
+ notification: { value: 'notification', status: 'framework-only', note: 'framework notification host consumed by the example registry' },
3576
+ integration: { value: 'integration', status: 'framework-only', note: 'framework integration detail host consumed by example widgets' },
3577
+ 'specialized-registry': { value: 'specialized-registry', status: 'framework-only', note: 'framework-owned specialist registries accept example entries' },
3578
+ 'module-override': { value: 'module-override', status: 'framework-only', note: 'framework override catalog owns these hosts' },
3579
+ }
3580
+
3581
+ const HOST_CAPABILITY_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3582
+ 'render-widget': { value: 'render-widget', status: 'emitted-example', note: 'example render widget host and contributions' },
3583
+ 'headless-widget': { value: 'headless-widget', status: 'currently-unbound', note: 'injection-table facts do not yet distinguish headless widget payloads' },
3584
+ 'menu-item': { value: 'menu-item', status: 'currently-unbound', note: 'injection-table facts do not yet distinguish menu item payloads' },
3585
+ 'column-widget': { value: 'column-widget', status: 'emitted-example', note: 'example DataTable column host' },
3586
+ 'row-action': { value: 'row-action', status: 'emitted-example', note: 'example DataTable row-action host' },
3587
+ 'bulk-action': { value: 'bulk-action', status: 'emitted-example', note: 'example DataTable bulk-action host' },
3588
+ 'filter-widget': { value: 'filter-widget', status: 'emitted-example', note: 'example DataTable filter host' },
3589
+ 'toolbar-widget': { value: 'toolbar-widget', status: 'emitted-example', note: 'example DataTable toolbar host' },
3590
+ 'field-widget': { value: 'field-widget', status: 'emitted-example', note: 'example CrudForm field host' },
3591
+ 'lifecycle-handler': { value: 'lifecycle-handler', status: 'emitted-example', note: 'example CrudForm lifecycle host' },
3592
+ 'component-replacement': { value: 'component-replacement', status: 'emitted-example', note: 'example replaceable DataTable, CrudForm and component hosts' },
3593
+ 'response-enricher': { value: 'response-enricher', status: 'emitted-example', note: 'example entity enricher host' },
3594
+ 'query-enricher': { value: 'query-enricher', status: 'emitted-example', note: 'example query-enricher host' },
3595
+ 'api-interceptor': { value: 'api-interceptor', status: 'emitted-example', note: 'example API interceptor contribution and route bridge' },
3596
+ 'command-interceptor': { value: 'command-interceptor', status: 'emitted-example', note: 'example command interceptor and exact command owner' },
3597
+ 'mutation-guard': { value: 'mutation-guard', status: 'emitted-example', note: 'example entity mutation guard host' },
3598
+ 'entity-extension': { value: 'entity-extension', status: 'emitted-example', note: 'example entity extension host' },
3599
+ 'async-subscriber': { value: 'async-subscriber', status: 'emitted-example', note: 'example event subscriber host' },
3600
+ 'sync-subscriber': { value: 'sync-subscriber', status: 'emitted-example', note: 'example synchronous event subscriber host' },
3601
+ 'browser-client': { value: 'browser-client', status: 'emitted-example', note: 'example client-broadcast event host' },
3602
+ 'browser-portal': { value: 'browser-portal', status: 'emitted-example', note: 'example portal-broadcast event host' },
3603
+ 'registry-contribution': { value: 'registry-contribution', status: 'emitted-example', note: 'example specialized registry contributions' },
3604
+ 'module-override': { value: 'module-override', status: 'framework-only', note: 'framework override hosts; app configuration is not a contribution' },
3605
+ }
3606
+
3607
+ const HOST_ACTIVATION_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3608
+ always: { value: 'always', status: 'emitted-example', note: 'always-active example declarations' },
3609
+ 'host-opt-in': { value: 'host-opt-in', status: 'emitted-example', note: 'example entity extension and response-enricher hosts' },
3610
+ 'caller-opt-in': { value: 'caller-opt-in', status: 'emitted-example', note: 'example query lifecycle and query enricher' },
3611
+ 'feature-gated': { value: 'feature-gated', status: 'currently-unbound', note: 'feature requirements are emitted but are not a distinct activation value' },
3612
+ }
3613
+
3614
+ const TARGET_KIND_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3615
+ module: { value: 'module', status: 'emitted-example', note: 'example module-scoped registry target' },
3616
+ entity: { value: 'entity', status: 'emitted-example', note: 'example entity extension, enricher and guard targets' },
3617
+ 'api-route': { value: 'api-route', status: 'emitted-example', note: 'example API interceptor targets' },
3618
+ command: { value: 'command', status: 'emitted-example', note: 'example exact and wildcard command targets' },
3619
+ 'widget-spot': { value: 'widget-spot', status: 'emitted-example', note: 'example injection targets' },
3620
+ component: { value: 'component', status: 'emitted-example', note: 'example component override targets' },
3621
+ event: { value: 'event', status: 'emitted-example', note: 'example subscriber targets' },
3622
+ notification: { value: 'notification', status: 'emitted-example', note: 'example notification registry target' },
3623
+ wildcard: { value: 'wildcard', status: 'emitted-example', note: 'example wildcard subscriber and interceptor targets' },
3624
+ }
3625
+
3626
+ const HOST_RESOLUTION_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3627
+ exact: { value: 'exact', status: 'emitted-example', note: 'example exact declared hosts' },
3628
+ pattern: { value: 'pattern', status: 'emitted-example', note: 'example patterned host surfaces' },
3629
+ framework: { value: 'framework', status: 'framework-only', note: 'framework catalog host identity' },
3630
+ 'fact-ref': { value: 'fact-ref', status: 'emitted-example', note: 'example entity, event and query fact-reference hosts' },
3631
+ }
3632
+
3633
+ const TARGET_RESOLUTION_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3634
+ exact: { value: 'exact', status: 'emitted-example', note: 'example exact target correlation' },
3635
+ pattern: { value: 'pattern', status: 'emitted-example', note: 'example wildcard and patterned target correlation' },
3636
+ framework: { value: 'framework', status: 'emitted-example', note: 'example contribution to a framework host' },
3637
+ 'fact-ref': { value: 'fact-ref', status: 'emitted-example', note: 'example contribution to a generated fact owner' },
3638
+ 'optional-external': { value: 'optional-external', status: 'emitted-example', note: 'example optional module target remains fail-soft' },
3639
+ unresolved: { value: 'unresolved', status: 'negative-fixture', note: 'only malformed targets remain unresolved' },
3640
+ }
3641
+
3642
+ const TOPOLOGY_RESOLUTION_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3643
+ bound: { value: 'bound', status: 'emitted-example', note: 'example contribution reaches a verified runtime activation' },
3644
+ 'capability-only': { value: 'capability-only', status: 'emitted-example', note: 'declaration-backed capability without a separate caller activation' },
3645
+ 'optional-target-missing': { value: 'optional-target-missing', status: 'emitted-example', note: 'optional external target absent from the selected module set' },
3646
+ wildcard: { value: 'wildcard', status: 'emitted-example', note: 'wildcard contribution resolves against selected targets' },
3647
+ unresolved: { value: 'unresolved', status: 'negative-fixture', note: 'only malformed first-party targets remain unresolved' },
3648
+ }
3649
+
3650
+ const SPECIALIZED_REGISTRY_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3651
+ notification: { value: 'notification', status: 'emitted-example', note: 'example notification type registry' },
3652
+ integration: { value: 'integration', status: 'emitted-example', note: 'example credential-free integration bundle identity' },
3653
+ search: { value: 'search', status: 'emitted-example', note: 'example Todo search registry' },
3654
+ vector: { value: 'vector', status: 'emitted-example', note: 'example deterministic Todo vector registry' },
3655
+ ai: { value: 'ai', status: 'emitted-example', note: 'example AI tool and agent registries' },
3656
+ payment: { value: 'payment', status: 'emitted-example', note: 'example mock payment descriptors' },
3657
+ shipping: { value: 'shipping', status: 'emitted-example', note: 'example mock shipping registry identity' },
3658
+ currency: { value: 'currency', status: 'emitted-example', note: 'example deterministic currency provider identity' },
3659
+ workflow: { value: 'workflow', status: 'emitted-example', note: 'example event-triggered Todo workflow identity' },
3660
+ }
3661
+
3662
+ const COMPONENT_MODE_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3663
+ replace: { value: 'replace', status: 'emitted-example', note: 'example replacement component' },
3664
+ wrapper: { value: 'wrapper', status: 'emitted-example', note: 'example decorating wrapper component' },
3665
+ props: { value: 'props', status: 'emitted-example', note: 'example props-transform component' },
3666
+ }
3667
+
3668
+ const FACT_DIAGNOSTIC_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3669
+ 'duplicate-source': { value: 'duplicate-source', status: 'negative-fixture', note: 'duplicate provenance fixture; canonical output emits none' },
3670
+ 'unresolved-static-contract': { value: 'unresolved-static-contract', status: 'negative-fixture', note: 'dynamic contract fixture; canonical output emits none' },
3671
+ }
3672
+
3673
+ const UNRESOLVED_REASON_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3674
+ 'unclassified-binding': { value: 'unclassified-binding', status: 'negative-fixture', note: 'malformed extension declaration fixture' },
3675
+ 'unbound-declaration': { value: 'unbound-declaration', status: 'negative-fixture', note: 'declared host without a real caller fixture' },
3676
+ 'dynamic-without-pattern': { value: 'dynamic-without-pattern', status: 'negative-fixture', note: 'dynamic host without a declared pattern fixture' },
3677
+ 'unresolved-first-party-target': { value: 'unresolved-first-party-target', status: 'negative-fixture', note: 'broken first-party target fixture' },
3678
+ }
3679
+
3680
+ const DATA_TABLE_SURFACE_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3681
+ header: { value: 'header', status: 'emitted-example', note: 'bound Todo table header' },
3682
+ footer: { value: 'footer', status: 'emitted-example', note: 'bound Todo table footer' },
3683
+ toolbar: { value: 'toolbar', status: 'emitted-example', note: 'bound Todo table toolbar' },
3684
+ searchTrailing: { value: 'searchTrailing', status: 'emitted-example', note: 'bound Todo table search trailing surface' },
3685
+ columns: { value: 'columns', status: 'emitted-example', note: 'bound Todo table columns' },
3686
+ rowActions: { value: 'rowActions', status: 'emitted-example', note: 'bound Todo table row actions' },
3687
+ bulkActions: { value: 'bulkActions', status: 'emitted-example', note: 'bound Todo table bulk actions' },
3688
+ filters: { value: 'filters', status: 'emitted-example', note: 'bound Todo table filters' },
3689
+ replacement: { value: 'replacement', status: 'emitted-example', note: 'bound Todo table replacement' },
3690
+ emptyState: { value: 'emptyState', status: 'currently-unbound', note: 'catalog-declared DataTable surface has no runtime host' },
3691
+ }
3692
+
3693
+ const CRUD_FORM_SURFACE_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3694
+ base: { value: 'base', status: 'emitted-example', note: 'bound Todo form base' },
3695
+ header: { value: 'header', status: 'emitted-example', note: 'bound Todo form header' },
3696
+ fields: { value: 'fields', status: 'emitted-example', note: 'bound Todo form fields' },
3697
+ replacement: { value: 'replacement', status: 'emitted-example', note: 'bound Todo form replacement' },
3698
+ beforeFields: { value: 'beforeFields', status: 'currently-unbound', note: 'catalog-declared CrudForm surface has no runtime host' },
3699
+ afterFields: { value: 'afterFields', status: 'currently-unbound', note: 'catalog-declared CrudForm surface has no runtime host' },
3700
+ footer: { value: 'footer', status: 'currently-unbound', note: 'catalog-declared CrudForm surface has no runtime host' },
3701
+ sidebar: { value: 'sidebar', status: 'currently-unbound', note: 'catalog-declared CrudForm surface has no runtime host' },
3702
+ group: { value: 'group', status: 'currently-unbound', note: 'catalog-declared CrudForm surface has no runtime host' },
3703
+ fieldBefore: { value: 'fieldBefore', status: 'currently-unbound', note: 'catalog-declared CrudForm surface has no runtime host' },
3704
+ fieldAfter: { value: 'fieldAfter', status: 'currently-unbound', note: 'catalog-declared CrudForm surface has no runtime host' },
3705
+ }
3706
+
3707
+ const CRUD_FORM_LIFECYCLE_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3708
+ transformValidation: { value: 'transformValidation', status: 'emitted-example', note: 'example Todo form lifecycle transformValidation' },
3709
+ transformDisplayData: { value: 'transformDisplayData', status: 'emitted-example', note: 'example Todo form lifecycle transformDisplayData' },
3710
+ onBeforeNavigate: { value: 'onBeforeNavigate', status: 'emitted-example', note: 'example Todo form lifecycle onBeforeNavigate' },
3711
+ onAppEvent: { value: 'onAppEvent', status: 'emitted-example', note: 'example Todo form lifecycle onAppEvent' },
3712
+ onVisibilityChange: { value: 'onVisibilityChange', status: 'emitted-example', note: 'example Todo form lifecycle onVisibilityChange' },
3713
+ onBeforeDelete: { value: 'onBeforeDelete', status: 'emitted-example', note: 'example Todo form lifecycle onBeforeDelete' },
3714
+ onDelete: { value: 'onDelete', status: 'emitted-example', note: 'example Todo form lifecycle onDelete' },
3715
+ onAfterDelete: { value: 'onAfterDelete', status: 'emitted-example', note: 'example Todo form lifecycle onAfterDelete' },
3716
+ onDeleteError: { value: 'onDeleteError', status: 'emitted-example', note: 'example Todo form lifecycle onDeleteError' },
3717
+ onFieldChange: { value: 'onFieldChange', status: 'emitted-example', note: 'example Todo form lifecycle onFieldChange' },
3718
+ transformFormData: { value: 'transformFormData', status: 'emitted-example', note: 'example Todo form lifecycle transformFormData' },
3719
+ onBeforeSave: { value: 'onBeforeSave', status: 'emitted-example', note: 'example Todo form lifecycle onBeforeSave' },
3720
+ onSave: { value: 'onSave', status: 'emitted-example', note: 'example Todo form lifecycle onSave' },
3721
+ onAfterSave: { value: 'onAfterSave', status: 'emitted-example', note: 'example Todo form lifecycle onAfterSave' },
3722
+ }
3723
+
3724
+ const CRUD_FORM_OPERATION_COVERAGE: Readonly<Record<string, ModuleFactCoverageRow>> = {
3725
+ create: { value: 'create', status: 'emitted-example', note: 'example Todo create lifecycle' },
3726
+ update: { value: 'update', status: 'currently-unbound', note: 'example lifecycle contribution is currently registered for create only' },
3727
+ delete: { value: 'delete', status: 'currently-unbound', note: 'example lifecycle contribution is currently registered for create only' },
3728
+ }
3729
+
3730
+ /**
3731
+ * One family of the ledger. Both directions are enforced: an enumerated value with
3732
+ * no row, and a row classifying a value the enum no longer carries, each throw — so
3733
+ * neither an unclassified new value nor a stale row for a removed one can ship.
3734
+ */
3735
+ export function buildCoverageFamily(
3736
+ family: string,
3737
+ enumSource: string,
3738
+ values: readonly string[],
3739
+ coverage: Readonly<Record<string, ModuleFactCoverageRow>>,
3740
+ ): ModuleFactCoverageFamily {
3741
+ const rows: ModuleFactCoverageRow[] = []
3742
+ const missing: string[] = []
3743
+ for (const value of values) {
3744
+ const row = coverage[value]
3745
+ if (!row) {
3746
+ missing.push(value)
3747
+ continue
3748
+ }
3749
+ rows.push(row)
3750
+ }
3751
+ if (missing.length > 0) {
3752
+ throw new Error(
3753
+ `[module-facts] factCoverage family "${family}" has no row for: ${missing.join(', ')}. ` +
3754
+ 'Add an explicit classification instead of leaving the value unclassified.',
3755
+ )
3756
+ }
3757
+ const extra = Object.keys(coverage).filter((value) => !values.includes(value))
3758
+ if (extra.length > 0) {
3759
+ throw new Error(
3760
+ `[module-facts] factCoverage family "${family}" classifies values absent from the enum: ${extra.join(', ')}.`,
3761
+ )
3762
+ }
3763
+ return { family, enumSource, rows }
3764
+ }
3765
+
3766
+ /**
3767
+ * Builds the whole ledger, throwing when an enumerated value lacks a row or a row
3768
+ * classifies a value the enum no longer carries. {@link extractAllModuleFacts} calls
3769
+ * it on every run, so either violation fails fact generation — not only the unit
3770
+ * test — and the built ledger is returned on the generation result so the published
3771
+ * mirrors have a single generated authority to be checked against.
3772
+ */
3773
+ export function buildModuleFactCoverageLedger(): ModuleFactCoverageFamily[] {
3774
+ return [
3775
+ buildCoverageFamily(
3776
+ 'ModuleFactSourceKind',
3777
+ 'packages/cli/src/lib/generators/module-facts.ts#MODULE_FACT_SOURCE_KINDS',
3778
+ MODULE_FACT_SOURCE_KINDS,
3779
+ FACT_SOURCE_KIND_COVERAGE,
3780
+ ),
3781
+ buildCoverageFamily(
3782
+ 'ModuleOwnedContractKind',
3783
+ 'packages/cli/src/lib/generators/module-facts.ts#MODULE_OWNED_CONTRACT_KINDS',
3784
+ MODULE_OWNED_CONTRACT_KINDS,
3785
+ OWNED_CONTRACT_KIND_COVERAGE,
3786
+ ),
3787
+ buildCoverageFamily(
3788
+ 'ModuleOverrideDomain',
3789
+ 'packages/cli/src/lib/generators/module-override-targets.ts#ALL_OVERRIDE_DOMAINS',
3790
+ ALL_OVERRIDE_DOMAINS,
3791
+ OVERRIDE_DOMAIN_COVERAGE,
3792
+ ),
3793
+ buildCoverageFamily(
3794
+ 'ModuleOverrideMode',
3795
+ 'packages/cli/src/lib/generators/module-override-targets.ts#MODULE_OVERRIDE_MODES',
3796
+ MODULE_OVERRIDE_MODES,
3797
+ OVERRIDE_MODE_COVERAGE,
3798
+ ),
3799
+ buildCoverageFamily(
3800
+ 'ModuleOverrideTargetNote',
3801
+ 'packages/cli/src/lib/generators/module-override-targets.ts#MODULE_OVERRIDE_TARGET_NOTES',
3802
+ MODULE_OVERRIDE_TARGET_NOTES,
3803
+ OVERRIDE_NOTE_COVERAGE,
3804
+ ),
3805
+ buildCoverageFamily(
3806
+ 'ModuleOverrideTargetDiagnosticCode',
3807
+ 'packages/cli/src/lib/generators/module-override-targets.ts#MODULE_OVERRIDE_TARGET_DIAGNOSTIC_CODES',
3808
+ MODULE_OVERRIDE_TARGET_DIAGNOSTIC_CODES,
3809
+ OVERRIDE_DIAGNOSTIC_COVERAGE,
3810
+ ),
3811
+ buildCoverageFamily(
3812
+ 'ModuleExtensionContributionKind',
3813
+ 'packages/shared/src/modules/widgets/extension-points.ts#MODULE_EXTENSION_CONTRIBUTION_KINDS',
3814
+ MODULE_EXTENSION_CONTRIBUTION_KINDS,
3815
+ CONTRIBUTION_KIND_COVERAGE,
3816
+ ),
3817
+ buildCoverageFamily(
3818
+ 'ModuleExtensionActivationKind',
3819
+ 'packages/shared/src/modules/widgets/extension-points.ts#MODULE_EXTENSION_ACTIVATION_KINDS',
3820
+ MODULE_EXTENSION_ACTIVATION_KINDS,
3821
+ ACTIVATION_KIND_COVERAGE,
3822
+ ),
3823
+ buildCoverageFamily(
3824
+ 'ExtensionHostFamily',
3825
+ 'packages/shared/src/modules/widgets/extension-points.ts#EXTENSION_HOST_FAMILIES',
3826
+ EXTENSION_HOST_FAMILIES,
3827
+ HOST_FAMILY_COVERAGE,
3828
+ ),
3829
+ buildCoverageFamily(
3830
+ 'ExtensionHostCapability',
3831
+ 'packages/shared/src/modules/widgets/extension-points.ts#EXTENSION_HOST_CAPABILITIES',
3832
+ EXTENSION_HOST_CAPABILITIES,
3833
+ HOST_CAPABILITY_COVERAGE,
3834
+ ),
3835
+ buildCoverageFamily(
3836
+ 'ExtensionHostActivation',
3837
+ 'packages/shared/src/modules/widgets/extension-points.ts#EXTENSION_HOST_ACTIVATIONS',
3838
+ EXTENSION_HOST_ACTIVATIONS,
3839
+ HOST_ACTIVATION_COVERAGE,
3840
+ ),
3841
+ buildCoverageFamily(
3842
+ 'ModuleExtensionTargetKind',
3843
+ 'packages/shared/src/modules/widgets/extension-points.ts#MODULE_EXTENSION_TARGET_KINDS',
3844
+ MODULE_EXTENSION_TARGET_KINDS,
3845
+ TARGET_KIND_COVERAGE,
3846
+ ),
3847
+ buildCoverageFamily(
3848
+ 'ModuleExtensionHostResolution',
3849
+ 'packages/shared/src/modules/widgets/extension-points.ts#MODULE_EXTENSION_HOST_RESOLUTIONS',
3850
+ MODULE_EXTENSION_HOST_RESOLUTIONS,
3851
+ HOST_RESOLUTION_COVERAGE,
3852
+ ),
3853
+ buildCoverageFamily(
3854
+ 'ModuleExtensionTargetResolution',
3855
+ 'packages/shared/src/modules/widgets/extension-points.ts#MODULE_EXTENSION_TARGET_RESOLUTIONS',
3856
+ MODULE_EXTENSION_TARGET_RESOLUTIONS,
3857
+ TARGET_RESOLUTION_COVERAGE,
3858
+ ),
3859
+ buildCoverageFamily(
3860
+ 'ModuleExtensionResolution',
3861
+ 'packages/shared/src/modules/widgets/extension-points.ts#MODULE_EXTENSION_RESOLUTIONS',
3862
+ MODULE_EXTENSION_RESOLUTIONS,
3863
+ TOPOLOGY_RESOLUTION_COVERAGE,
3864
+ ),
3865
+ buildCoverageFamily(
3866
+ 'SpecializedRegistry',
3867
+ 'packages/shared/src/modules/widgets/extension-points.ts#MODULE_SPECIALIZED_REGISTRIES',
3868
+ MODULE_SPECIALIZED_REGISTRIES,
3869
+ SPECIALIZED_REGISTRY_COVERAGE,
3870
+ ),
3871
+ buildCoverageFamily(
3872
+ 'ComponentOverrideMode',
3873
+ 'packages/shared/src/modules/widgets/extension-points.ts#COMPONENT_OVERRIDE_MODES',
3874
+ COMPONENT_OVERRIDE_MODES,
3875
+ COMPONENT_MODE_COVERAGE,
3876
+ ),
3877
+ buildCoverageFamily(
3878
+ 'ModuleFactDiagnosticCode',
3879
+ 'packages/cli/src/lib/generators/module-facts.ts#MODULE_FACT_DIAGNOSTIC_CODES',
3880
+ MODULE_FACT_DIAGNOSTIC_CODES,
3881
+ FACT_DIAGNOSTIC_COVERAGE,
3882
+ ),
3883
+ buildCoverageFamily(
3884
+ 'ModuleExtensionUnresolvedReason',
3885
+ 'packages/shared/src/modules/widgets/extension-points.ts#MODULE_EXTENSION_UNRESOLVED_REASONS',
3886
+ MODULE_EXTENSION_UNRESOLVED_REASONS,
3887
+ UNRESOLVED_REASON_COVERAGE,
3888
+ ),
3889
+ buildCoverageFamily(
3890
+ 'DataTableExtensionSurface',
3891
+ 'packages/shared/src/modules/widgets/extension-points.ts#DATA_TABLE_EXTENSION_SURFACE_KEYS',
3892
+ DATA_TABLE_EXTENSION_SURFACE_KEYS,
3893
+ DATA_TABLE_SURFACE_COVERAGE,
3894
+ ),
3895
+ buildCoverageFamily(
3896
+ 'CrudFormExtensionSurface',
3897
+ 'packages/shared/src/modules/widgets/extension-points.ts#CRUD_FORM_EXTENSION_SURFACE_KEYS',
3898
+ CRUD_FORM_EXTENSION_SURFACE_KEYS,
3899
+ CRUD_FORM_SURFACE_COVERAGE,
3900
+ ),
3901
+ buildCoverageFamily(
3902
+ 'CrudFormLifecyclePhase',
3903
+ 'packages/shared/src/modules/widgets/extension-points.ts#CRUD_FORM_LIFECYCLE_PHASES',
3904
+ CRUD_FORM_LIFECYCLE_PHASES,
3905
+ CRUD_FORM_LIFECYCLE_COVERAGE,
3906
+ ),
3907
+ buildCoverageFamily(
3908
+ 'CrudFormOperation',
3909
+ 'packages/shared/src/modules/widgets/extension-points.ts#CRUD_FORM_OPERATIONS',
3910
+ CRUD_FORM_OPERATIONS,
3911
+ CRUD_FORM_OPERATION_COVERAGE,
3912
+ ),
3913
+ ]
3914
+ }
3915
+
3916
+ /**
3917
+ * Real per-value counts for every ledger family, read from an extracted module's
3918
+ * facts. This is the anti-silent-zero oracle: a ledger claim is checked against what
3919
+ * the extractor actually published, never against a hand-maintained inventory row.
3920
+ */
3921
+ export function countModuleFactCoverage(facts: ModuleFacts): Record<string, Record<string, number>> {
3922
+ const increment = (counts: Record<string, number>, value: string): void => {
3923
+ counts[value] = (counts[value] ?? 0) + 1
3924
+ }
3925
+ const factSourceKind: Record<string, number> = {}
3926
+ for (const entry of facts.factSources ?? []) {
3927
+ increment(factSourceKind, entry.kind)
3928
+ }
3929
+ const ownedContractKind: Record<string, number> = {}
3930
+ for (const [kind, list] of Object.entries(facts.ownedContracts ?? {})) {
3931
+ ownedContractKind[kind] = (list ?? []).length
3932
+ }
3933
+ const overrideDomain: Record<string, number> = {}
3934
+ const overrideMode: Record<string, number> = {}
3935
+ const overrideNote: Record<string, number> = {}
3936
+ for (const target of facts.overrideTargets ?? []) {
3937
+ overrideDomain[target.domain] = (overrideDomain[target.domain] ?? 0) + 1
3938
+ for (const mode of target.modes) overrideMode[mode] = (overrideMode[mode] ?? 0) + 1
3939
+ for (const note of target.notes ?? []) overrideNote[note] = (overrideNote[note] ?? 0) + 1
3940
+ }
3941
+ const overrideDiagnosticCode: Record<string, number> = {}
3942
+ for (const diagnostic of facts.overrideTargetDiagnostics ?? []) {
3943
+ increment(overrideDiagnosticCode, diagnostic.code)
3944
+ }
3945
+ const contributionKind: Record<string, number> = {}
3946
+ const activationKind: Record<string, number> = {}
3947
+ const hostFamily: Record<string, number> = {}
3948
+ const hostCapability: Record<string, number> = {}
3949
+ const hostActivation: Record<string, number> = {}
3950
+ const targetKind: Record<string, number> = {}
3951
+ const hostResolution: Record<string, number> = {}
3952
+ const targetResolution: Record<string, number> = {}
3953
+ const topologyResolution: Record<string, number> = {}
3954
+ const specializedRegistry: Record<string, number> = {}
3955
+ const componentMode: Record<string, number> = {}
3956
+ const factDiagnosticCode: Record<string, number> = {}
3957
+ const unresolvedReason: Record<string, number> = {}
3958
+ const dataTableSurface: Record<string, number> = {}
3959
+ const crudFormSurface: Record<string, number> = {}
3960
+ const crudFormLifecycle: Record<string, number> = {}
3961
+ const crudFormOperation: Record<string, number> = {}
3962
+ const surfaces = facts.extensionSurfaces
3963
+ for (const host of surfaces?.hosts ?? []) {
3964
+ increment(hostFamily, host.family)
3965
+ increment(hostResolution, host.resolution)
3966
+ increment(hostActivation, host.activation ?? 'always')
3967
+ for (const capability of host.capabilities) increment(hostCapability, capability)
3968
+ for (const phase of host.phases ?? []) increment(crudFormLifecycle, phase)
3969
+ for (const operation of host.operations ?? []) increment(crudFormOperation, operation)
3970
+ const [owner, surface] = host.key.split('.')
3971
+ if (owner === 'todosTable' && surface) increment(dataTableSurface, surface)
3972
+ if (owner === 'todoForm' && surface) increment(crudFormSurface, surface)
3973
+ }
3974
+ for (const contribution of surfaces?.contributions ?? []) {
3975
+ increment(contributionKind, contribution.kind)
3976
+ increment(hostActivation, contribution.activation ?? 'always')
3977
+ for (const target of contribution.targets) increment(targetResolution, target.resolution)
3978
+ for (const operation of contribution.operations ?? []) increment(crudFormOperation, operation)
3979
+ if (contribution.kind === 'specialized-registry') increment(specializedRegistry, contribution.details.registry)
3980
+ if (contribution.kind === 'component-override') increment(componentMode, contribution.details.mode)
3981
+ if (contribution.kind === 'widget') {
3982
+ if (contribution.details.payload === 'headless') increment(hostCapability, 'headless-widget')
3983
+ if (contribution.details.payload === 'menu') increment(hostCapability, 'menu-item')
3984
+ if (contribution.details.payload === 'dashboard' || contribution.details.payload === 'notification' || contribution.details.payload === 'integration') {
3985
+ increment(hostCapability, 'registry-contribution')
3986
+ }
3987
+ }
3988
+ if (contribution.kind === 'api-interceptor') increment(hostCapability, 'api-interceptor')
3989
+ if (contribution.kind === 'command-interceptor') increment(hostCapability, 'command-interceptor')
3990
+ }
3991
+ for (const activation of surfaces?.activations ?? []) increment(activationKind, activation.kind)
3992
+ for (const resolution of surfaces?.contributionResolutions ?? []) {
3993
+ increment(targetKind, resolution.target.kind)
3994
+ increment(topologyResolution, resolution.resolution)
3995
+ for (const id of resolution.activationIds) {
3996
+ const kind = MODULE_EXTENSION_ACTIVATION_KINDS.find((candidate) => id.endsWith(`:${candidate}`))
3997
+ if (kind) increment(activationKind, kind)
3998
+ }
3999
+ }
4000
+ for (const diagnostic of facts.factDiagnostics ?? []) increment(factDiagnosticCode, diagnostic.code)
4001
+ for (const unresolved of surfaces?.unresolved ?? []) increment(unresolvedReason, unresolved.reason)
4002
+ return {
4003
+ ModuleFactSourceKind: factSourceKind,
4004
+ ModuleOwnedContractKind: ownedContractKind,
4005
+ ModuleOverrideDomain: overrideDomain,
4006
+ ModuleOverrideMode: overrideMode,
4007
+ ModuleOverrideTargetNote: overrideNote,
4008
+ ModuleOverrideTargetDiagnosticCode: overrideDiagnosticCode,
4009
+ ModuleExtensionContributionKind: contributionKind,
4010
+ ModuleExtensionActivationKind: activationKind,
4011
+ ExtensionHostFamily: hostFamily,
4012
+ ExtensionHostCapability: hostCapability,
4013
+ ExtensionHostActivation: hostActivation,
4014
+ ModuleExtensionTargetKind: targetKind,
4015
+ ModuleExtensionHostResolution: hostResolution,
4016
+ ModuleExtensionTargetResolution: targetResolution,
4017
+ ModuleExtensionResolution: topologyResolution,
4018
+ SpecializedRegistry: specializedRegistry,
4019
+ ComponentOverrideMode: componentMode,
4020
+ ModuleFactDiagnosticCode: factDiagnosticCode,
4021
+ ModuleExtensionUnresolvedReason: unresolvedReason,
4022
+ DataTableExtensionSurface: dataTableSurface,
4023
+ CrudFormExtensionSurface: crudFormSurface,
4024
+ CrudFormLifecyclePhase: crudFormLifecycle,
4025
+ CrudFormOperation: crudFormOperation,
4026
+ }
2783
4027
  }