@open-mercato/cli 0.6.7-develop.6756.1.5300c41145 → 0.6.7-develop.6766.1.40c9011995

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 (79) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/build.mjs +2 -1
  3. package/dist/agentic/guides/extensions.md +20 -3
  4. package/dist/agentic/guides/framework-extension-points.md +36 -0
  5. package/dist/agentic/guides/module-facts.json +39708 -230
  6. package/dist/agentic/guides/modules/ai_assistant.md +49 -9
  7. package/dist/agentic/guides/modules/api_docs.md +11 -1
  8. package/dist/agentic/guides/modules/api_keys.md +24 -1
  9. package/dist/agentic/guides/modules/attachments.md +22 -6
  10. package/dist/agentic/guides/modules/audit_logs.md +35 -1
  11. package/dist/agentic/guides/modules/auth.md +79 -15
  12. package/dist/agentic/guides/modules/business_rules.md +36 -3
  13. package/dist/agentic/guides/modules/catalog.md +160 -22
  14. package/dist/agentic/guides/modules/channel_gmail.md +14 -1
  15. package/dist/agentic/guides/modules/channel_imap.md +14 -1
  16. package/dist/agentic/guides/modules/checkout.md +134 -19
  17. package/dist/agentic/guides/modules/communication_channels.md +110 -20
  18. package/dist/agentic/guides/modules/configs.md +15 -1
  19. package/dist/agentic/guides/modules/content.md +11 -1
  20. package/dist/agentic/guides/modules/currencies.md +50 -9
  21. package/dist/agentic/guides/modules/customer_accounts.md +151 -28
  22. package/dist/agentic/guides/modules/customers.md +299 -52
  23. package/dist/agentic/guides/modules/dashboards.md +30 -1
  24. package/dist/agentic/guides/modules/data_sync.md +40 -7
  25. package/dist/agentic/guides/modules/dictionaries.md +24 -6
  26. package/dist/agentic/guides/modules/directory.md +52 -9
  27. package/dist/agentic/guides/modules/entities.md +41 -1
  28. package/dist/agentic/guides/modules/events.md +11 -1
  29. package/dist/agentic/guides/modules/feature_toggles.md +15 -1
  30. package/dist/agentic/guides/modules/gateway_stripe.md +14 -1
  31. package/dist/agentic/guides/modules/generators.md +11 -1
  32. package/dist/agentic/guides/modules/inbox_ops.md +58 -16
  33. package/dist/agentic/guides/modules/integrations.md +30 -7
  34. package/dist/agentic/guides/modules/messages.md +68 -13
  35. package/dist/agentic/guides/modules/notifications.md +13 -1
  36. package/dist/agentic/guides/modules/onboarding.md +13 -1
  37. package/dist/agentic/guides/modules/payment_gateways.md +51 -12
  38. package/dist/agentic/guides/modules/perspectives.md +16 -1
  39. package/dist/agentic/guides/modules/planner.md +33 -9
  40. package/dist/agentic/guides/modules/portal.md +24 -1
  41. package/dist/agentic/guides/modules/progress.md +34 -9
  42. package/dist/agentic/guides/modules/query_index.md +56 -3
  43. package/dist/agentic/guides/modules/record_locks.md +60 -13
  44. package/dist/agentic/guides/modules/resources.md +79 -18
  45. package/dist/agentic/guides/modules/sales.md +238 -48
  46. package/dist/agentic/guides/modules/scheduler.md +23 -7
  47. package/dist/agentic/guides/modules/search.md +28 -3
  48. package/dist/agentic/guides/modules/security.md +106 -23
  49. package/dist/agentic/guides/modules/shipping_carriers.md +38 -10
  50. package/dist/agentic/guides/modules/sso.md +47 -15
  51. package/dist/agentic/guides/modules/staff.md +117 -38
  52. package/dist/agentic/guides/modules/storage_s3.md +13 -1
  53. package/dist/agentic/guides/modules/sync_akeneo.md +13 -1
  54. package/dist/agentic/guides/modules/sync_excel.md +15 -1
  55. package/dist/agentic/guides/modules/system_status_overlays.md +14 -1
  56. package/dist/agentic/guides/modules/translations.md +25 -5
  57. package/dist/agentic/guides/modules/webhooks.md +85 -15
  58. package/dist/agentic/guides/modules/wms.md +234 -31
  59. package/dist/agentic/guides/modules/workflows.md +114 -30
  60. package/dist/agentic/guides/upstream/BACKWARD_COMPATIBILITY.md +7 -0
  61. package/dist/agentic/guides/upstream/manifest.json +2 -2
  62. package/dist/agentic/shared/AGENTS.md.template +2 -2
  63. package/dist/agentic/shared/ai/harness/README.md +2 -0
  64. package/dist/agentic/shared/ai/harness/cases.json +80 -39
  65. package/dist/agentic/shared/ai/skills/om-system-extension/references/mechanism-selector.md +3 -1
  66. package/dist/agentic/shared/ai/skills/om-system-extension/references/unified-overrides.md +2 -1
  67. package/dist/agentic/shared/scripts/framework-context.mjs +3 -9
  68. package/dist/lib/generators/module-extension-facts.js +1185 -0
  69. package/dist/lib/generators/module-extension-facts.js.map +7 -0
  70. package/dist/lib/generators/module-facts.js +134 -7
  71. package/dist/lib/generators/module-facts.js.map +2 -2
  72. package/dist/lib/generators/module-registry.js +1 -0
  73. package/dist/lib/generators/module-registry.js.map +2 -2
  74. package/package.json +5 -5
  75. package/src/lib/generators/__tests__/module-extension-facts.test.ts +402 -0
  76. package/src/lib/generators/__tests__/module-facts.bc-guard.test.ts +39 -2
  77. package/src/lib/generators/module-extension-facts.ts +1428 -0
  78. package/src/lib/generators/module-facts.ts +143 -6
  79. package/src/lib/generators/module-registry.ts +1 -1
@@ -0,0 +1,1428 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import ts from 'typescript-js'
4
+ import {
5
+ CRUD_FORM_EXTENSION_SURFACES,
6
+ CRUD_FORM_LIFECYCLE_PHASES,
7
+ DATA_TABLE_EXTENSION_SURFACES,
8
+ } from '@open-mercato/shared/modules/widgets/extension-points'
9
+ import type {
10
+ ExtensionHostCapability,
11
+ ExtensionHostFamily,
12
+ ModuleExtensionContributionFact,
13
+ ModuleExtensionContributionBase,
14
+ ModuleExtensionHostFact,
15
+ ModuleExtensionSurfaceFacts,
16
+ ModuleExtensionTargetFact,
17
+ ModuleExtensionUnresolvedFact,
18
+ } from '@open-mercato/shared/modules/widgets/extension-points'
19
+ import { extractCommandIdsFromSource } from './module-registry'
20
+ import { scanModuleDir, SCAN_CONFIGS } from './scanner'
21
+
22
+ type StaticScalar = string | number | boolean | null
23
+ type StaticValue = StaticScalar | StaticValue[] | { [key: string]: StaticValue }
24
+ type StaticObject = { [key: string]: StaticValue }
25
+ type SpecializedRegistry = Extract<ModuleExtensionContributionFact, {
26
+ kind: 'specialized-registry'
27
+ }>['details']['registry']
28
+
29
+ export interface ExtensionFactEntityRef {
30
+ id: string
31
+ }
32
+
33
+ export interface ExtensionFactEventRef {
34
+ id: string
35
+ clientBroadcast?: boolean
36
+ portalBroadcast?: boolean
37
+ }
38
+
39
+ export interface ExtensionFactApiRouteRef {
40
+ path: string
41
+ methods: string[]
42
+ }
43
+
44
+ export interface ExtractModuleExtensionFactsOptions {
45
+ moduleId: string
46
+ moduleRoot: string
47
+ sourceRoot: string
48
+ entities: readonly ExtensionFactEntityRef[]
49
+ events: readonly ExtensionFactEventRef[]
50
+ apiRoutes: readonly ExtensionFactApiRouteRef[]
51
+ searchEntities: readonly string[]
52
+ notifications?: readonly string[]
53
+ aiTools?: ReadonlyArray<{ name: string; sourcePath: string }>
54
+ aiAgents?: ReadonlyArray<{ id: string; sourcePath: string }>
55
+ }
56
+
57
+ export interface CorrelateExtensionFactsOptions {
58
+ surfacesByModule: Readonly<Record<string, ModuleExtensionSurfaceFacts>>
59
+ entityIds: ReadonlySet<string>
60
+ eventIds: ReadonlySet<string>
61
+ apiRoutes: ReadonlySet<string>
62
+ commandIds?: ReadonlySet<string>
63
+ contributingModuleId?: string
64
+ }
65
+
66
+ type StaticContext = {
67
+ initializers: Map<string, ts.Expression | ts.FunctionDeclaration>
68
+ sourceFile: ts.SourceFile
69
+ resolving: Set<string>
70
+ }
71
+
72
+ const FRAMEWORK_PREFIXES = [
73
+ 'dashboard:',
74
+ 'menu:',
75
+ 'notification:',
76
+ 'integration:',
77
+ 'integrations.detail:',
78
+ 'system-status:',
79
+ 'topbar:',
80
+ ]
81
+
82
+ const FRAMEWORK_HOSTS: ModuleExtensionHostFact[] = [
83
+ frameworkHost('framework.backend.sidebar', 'menu:backend.sidebar', 'menu', ['menu-item']),
84
+ frameworkHost('framework.settings.sidebar', 'menu:settings.sidebar', 'menu', ['menu-item']),
85
+ frameworkHost('framework.profile.sidebar', 'menu:profile.sidebar', 'menu', ['menu-item']),
86
+ frameworkHost('framework.portal.sidebar', 'menu:portal.sidebar', 'menu', ['menu-item']),
87
+ frameworkHost('framework.dashboard', 'dashboard:*', 'dashboard', ['registry-contribution']),
88
+ frameworkHost('framework.notifications', 'notification:*', 'notification', ['registry-contribution']),
89
+ frameworkHost('framework.integration-detail', 'integrations.detail:{integrationId}', 'integration', ['render-widget']),
90
+ frameworkHost('framework.integration-detail-fallback', 'integrations.detail:tabs', 'integration', ['render-widget']),
91
+ frameworkHost('framework.backend-mutation', 'backend-mutation:global', 'generic', ['headless-widget']),
92
+ frameworkHost('framework.crud-form-wildcard', 'crud-form:*', 'crud-form', ['field-widget', 'lifecycle-handler']),
93
+ ]
94
+
95
+ function frameworkHost(
96
+ key: string,
97
+ id: string,
98
+ family: ExtensionHostFamily,
99
+ capabilities: ExtensionHostCapability[],
100
+ ): ModuleExtensionHostFact {
101
+ return {
102
+ key,
103
+ id,
104
+ resolution: id.includes('*') || id.includes('{') ? 'pattern' : 'framework',
105
+ family,
106
+ ownerModule: 'framework',
107
+ capabilities,
108
+ bound: true,
109
+ stability: 'frozen',
110
+ source: { kind: 'framework', path: 'packages/ui/src', symbol: key },
111
+ }
112
+ }
113
+
114
+ function frameworkOverrideHost(
115
+ key: string,
116
+ mode: 'disable-replace' | 'replace' | 'additive',
117
+ ): ModuleExtensionHostFact {
118
+ return {
119
+ key: `framework.module-override.${key}`,
120
+ id: `module-override:${key}`,
121
+ resolution: 'framework',
122
+ family: 'module-override',
123
+ ownerModule: 'framework',
124
+ capabilities: ['module-override'],
125
+ operations: [mode],
126
+ bound: true,
127
+ stability: 'stable',
128
+ source: {
129
+ kind: 'framework',
130
+ path: 'packages/shared/src/modules/overrides.ts',
131
+ symbol: `ModuleOverrides.${key}`,
132
+ },
133
+ }
134
+ }
135
+
136
+ const FRAMEWORK_OVERRIDE_HOSTS = [
137
+ frameworkOverrideHost('ai.agents', 'disable-replace'),
138
+ frameworkOverrideHost('ai.tools', 'disable-replace'),
139
+ frameworkOverrideHost('ai.extensions', 'additive'),
140
+ frameworkOverrideHost('routes.api', 'disable-replace'),
141
+ frameworkOverrideHost('routes.pages', 'disable-replace'),
142
+ frameworkOverrideHost('events.subscribers', 'disable-replace'),
143
+ frameworkOverrideHost('workers', 'disable-replace'),
144
+ frameworkOverrideHost('widgets.injection', 'disable-replace'),
145
+ frameworkOverrideHost('widgets.components', 'disable-replace'),
146
+ frameworkOverrideHost('widgets.dashboard', 'disable-replace'),
147
+ frameworkOverrideHost('notifications.types', 'disable-replace'),
148
+ frameworkOverrideHost('notifications.handlers', 'disable-replace'),
149
+ frameworkOverrideHost('interceptors', 'disable-replace'),
150
+ frameworkOverrideHost('commandInterceptors', 'disable-replace'),
151
+ frameworkOverrideHost('enrichers', 'disable-replace'),
152
+ frameworkOverrideHost('guards', 'disable-replace'),
153
+ frameworkOverrideHost('cli', 'disable-replace'),
154
+ frameworkOverrideHost('setup', 'replace'),
155
+ frameworkOverrideHost('acl.features', 'disable-replace'),
156
+ frameworkOverrideHost('di', 'disable-replace'),
157
+ frameworkOverrideHost('encryption.maps', 'disable-replace'),
158
+ frameworkOverrideHost('nav.groupOrder', 'additive'),
159
+ ] as const
160
+
161
+ function allFrameworkHosts(): ModuleExtensionHostFact[] {
162
+ return [...FRAMEWORK_HOSTS, ...FRAMEWORK_OVERRIDE_HOSTS]
163
+ }
164
+
165
+ function sourceFile(filePath: string): ts.SourceFile | null {
166
+ if (!fs.existsSync(filePath)) return null
167
+ const scriptKind = filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
168
+ return ts.createSourceFile(filePath, fs.readFileSync(filePath, 'utf8'), ts.ScriptTarget.ES2020, true, scriptKind)
169
+ }
170
+
171
+ function unwrap(expression: ts.Expression): ts.Expression {
172
+ let current = expression
173
+ while (
174
+ ts.isAsExpression(current)
175
+ || ts.isTypeAssertionExpression(current)
176
+ || ts.isParenthesizedExpression(current)
177
+ || ts.isSatisfiesExpression(current)
178
+ ) {
179
+ current = current.expression
180
+ }
181
+ return current
182
+ }
183
+
184
+ function propertyName(node: ts.PropertyName): string | null {
185
+ if (ts.isIdentifier(node) || ts.isStringLiteralLike(node) || ts.isNumericLiteral(node)) return node.text
186
+ return null
187
+ }
188
+
189
+ function buildStaticContext(file: ts.SourceFile): StaticContext {
190
+ const initializers = new Map<string, ts.Expression | ts.FunctionDeclaration>()
191
+ const visit = (node: ts.Node): void => {
192
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
193
+ initializers.set(node.name.text, node.initializer)
194
+ }
195
+ if (ts.isFunctionDeclaration(node) && node.name && node.body) {
196
+ initializers.set(node.name.text, node)
197
+ }
198
+ node.forEachChild(visit)
199
+ }
200
+ file.forEachChild(visit)
201
+ return { initializers, sourceFile: file, resolving: new Set() }
202
+ }
203
+
204
+ function entityRegistryValue(expression: ts.PropertyAccessExpression): string | null {
205
+ const parts: string[] = []
206
+ let current: ts.Expression = expression
207
+ while (ts.isPropertyAccessExpression(current)) {
208
+ parts.unshift(current.name.text)
209
+ current = current.expression
210
+ }
211
+ return ts.isIdentifier(current) && current.text === 'E' && parts.length === 2
212
+ ? `${parts[0]}:${parts[1]}`
213
+ : null
214
+ }
215
+
216
+ function staticTemplate(expression: ts.TemplateExpression, context: StaticContext): string | null {
217
+ let value = expression.head.text
218
+ for (const span of expression.templateSpans) {
219
+ const substitution = staticValue(span.expression, context)
220
+ if (typeof substitution !== 'string' && typeof substitution !== 'number') return null
221
+ value += `${substitution}${span.literal.text}`
222
+ }
223
+ return value
224
+ }
225
+
226
+ function staticObject(expression: ts.ObjectLiteralExpression, context: StaticContext): StaticObject {
227
+ const result: StaticObject = {}
228
+ for (const property of expression.properties) {
229
+ if (ts.isPropertyAssignment(property)) {
230
+ const name = propertyName(property.name)
231
+ if (!name) continue
232
+ const value = staticValue(property.initializer, context)
233
+ if (value !== undefined) result[name] = value
234
+ continue
235
+ }
236
+ if (ts.isShorthandPropertyAssignment(property)) {
237
+ const value = staticValue(property.name, context)
238
+ if (value !== undefined) result[property.name.text] = value
239
+ continue
240
+ }
241
+ if (ts.isMethodDeclaration(property)) {
242
+ const name = propertyName(property.name)
243
+ if (name) result[name] = true
244
+ continue
245
+ }
246
+ if (ts.isSpreadAssignment(property)) {
247
+ const value = staticValue(property.expression, context)
248
+ if (isStaticObject(value)) Object.assign(result, value)
249
+ }
250
+ }
251
+ return result
252
+ }
253
+
254
+ function staticValue(expression: ts.Expression, context: StaticContext): StaticValue | undefined {
255
+ const current = unwrap(expression)
256
+ if (ts.isStringLiteralLike(current) || ts.isNumericLiteral(current)) return current.text
257
+ if (current.kind === ts.SyntaxKind.TrueKeyword) return true
258
+ if (current.kind === ts.SyntaxKind.FalseKeyword) return false
259
+ if (current.kind === ts.SyntaxKind.NullKeyword) return null
260
+ if (ts.isNoSubstitutionTemplateLiteral(current)) return current.text
261
+ if (ts.isTemplateExpression(current)) return staticTemplate(current, context) ?? undefined
262
+ if (ts.isArrayLiteralExpression(current)) {
263
+ return current.elements.flatMap((element) => {
264
+ if (ts.isSpreadElement(element)) {
265
+ const spread = staticValue(element.expression, context)
266
+ return Array.isArray(spread) ? spread : []
267
+ }
268
+ const value = staticValue(element, context)
269
+ return value === undefined ? [] : [value]
270
+ })
271
+ }
272
+ if (ts.isObjectLiteralExpression(current)) return staticObject(current, context)
273
+ if (ts.isPropertyAccessExpression(current)) {
274
+ return entityRegistryValue(current) ?? undefined
275
+ }
276
+ if (ts.isIdentifier(current)) {
277
+ if (context.resolving.has(current.text)) return undefined
278
+ const initializer = context.initializers.get(current.text)
279
+ if (!initializer) return undefined
280
+ context.resolving.add(current.text)
281
+ const value = ts.isFunctionDeclaration(initializer) ? undefined : staticValue(initializer, context)
282
+ context.resolving.delete(current.text)
283
+ return value
284
+ }
285
+ if (ts.isCallExpression(current)) {
286
+ if (ts.isIdentifier(current.expression)) {
287
+ const callable = context.initializers.get(current.expression.text)
288
+ if (callable && (ts.isArrowFunction(callable) || ts.isFunctionExpression(callable) || ts.isFunctionDeclaration(callable))) {
289
+ const initializers = new Map(context.initializers)
290
+ callable.parameters.forEach((parameter, index) => {
291
+ if (ts.isIdentifier(parameter.name) && current.arguments[index]) {
292
+ initializers.set(parameter.name.text, current.arguments[index])
293
+ }
294
+ })
295
+ const childContext: StaticContext = {
296
+ initializers,
297
+ sourceFile: context.sourceFile,
298
+ resolving: new Set(context.resolving),
299
+ }
300
+ if (callable.body && ts.isExpression(callable.body)) return staticValue(callable.body, childContext)
301
+ const returnStatement = callable.body?.statements.find(ts.isReturnStatement)
302
+ if (returnStatement?.expression) return staticValue(returnStatement.expression, childContext)
303
+ }
304
+ }
305
+ const firstArgument = current.arguments[0]
306
+ if (firstArgument) {
307
+ const value = staticValue(firstArgument, context)
308
+ if (!isStaticObject(value) || !ts.isIdentifier(current.expression)) return value
309
+ if (current.expression.text === 'dataTableExtensionHost') return { family: 'data-table', ...value }
310
+ if (current.expression.text === 'crudFormExtensionHost') return { family: 'crud-form', ...value }
311
+ if (current.expression.text === 'componentExtensionHost') return { family: 'component-handle', ...value }
312
+ return value
313
+ }
314
+ }
315
+ if (ts.isConditionalExpression(current)) {
316
+ const left = staticValue(current.whenTrue, context)
317
+ const right = staticValue(current.whenFalse, context)
318
+ return left !== undefined && JSON.stringify(left) === JSON.stringify(right) ? left : undefined
319
+ }
320
+ return undefined
321
+ }
322
+
323
+ function isStaticObject(value: StaticValue | undefined): value is StaticObject {
324
+ return !!value && typeof value === 'object' && !Array.isArray(value)
325
+ }
326
+
327
+ function strings(value: StaticValue | undefined): string[] {
328
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
329
+ }
330
+
331
+ function stringValue(value: StaticValue | undefined): string | undefined {
332
+ return typeof value === 'string' ? value : undefined
333
+ }
334
+
335
+ function numberValue(value: StaticValue | undefined): number | undefined {
336
+ if (typeof value === 'number') return value
337
+ if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value)
338
+ return undefined
339
+ }
340
+
341
+ function booleanValue(value: StaticValue | undefined): boolean | undefined {
342
+ return typeof value === 'boolean' ? value : undefined
343
+ }
344
+
345
+ function portablePath(moduleRoot: string, sourceRoot: string, filePath: string): string {
346
+ return path.posix.join(sourceRoot, path.relative(moduleRoot, filePath).split(path.sep).join('/'))
347
+ }
348
+
349
+ function conventionPath(moduleRoot: string, relativePath: string): string | null {
350
+ const direct = path.join(moduleRoot, relativePath)
351
+ if (fs.existsSync(direct)) return direct
352
+ if (relativePath.endsWith('.ts')) {
353
+ const tsx = `${direct.slice(0, -3)}.tsx`
354
+ if (fs.existsSync(tsx)) return tsx
355
+ }
356
+ return null
357
+ }
358
+
359
+ export function readConventionObjectArray(filePath: string, exportName: string): StaticObject[] {
360
+ const file = sourceFile(filePath)
361
+ if (!file) return []
362
+ const context = buildStaticContext(file)
363
+ const initializer = context.initializers.get(exportName)
364
+ if (!initializer || ts.isFunctionDeclaration(initializer)) return []
365
+ const value = staticValue(initializer, context)
366
+ return Array.isArray(value) ? value.filter(isStaticObject) : []
367
+ }
368
+
369
+ function readRootObject(filePath: string, variableName: string): StaticObject | null {
370
+ const file = sourceFile(filePath)
371
+ if (!file) return null
372
+ const context = buildStaticContext(file)
373
+ const initializer = context.initializers.get(variableName)
374
+ if (!initializer || ts.isFunctionDeclaration(initializer)) return null
375
+ const value = staticValue(initializer, context)
376
+ return isStaticObject(value) ? value : null
377
+ }
378
+
379
+ function readCallObjectArguments(filePath: string, calleeNames: ReadonlySet<string>): Array<{
380
+ callee: string
381
+ value: StaticObject
382
+ }> {
383
+ const file = sourceFile(filePath)
384
+ if (!file) return []
385
+ const context = buildStaticContext(file)
386
+ const result: Array<{ callee: string; value: StaticObject }> = []
387
+ const visit = (node: ts.Node): void => {
388
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && calleeNames.has(node.expression.text)) {
389
+ const firstArgument = node.arguments[0]
390
+ const value = firstArgument ? staticValue(firstArgument, context) : undefined
391
+ if (isStaticObject(value)) result.push({ callee: node.expression.text, value })
392
+ }
393
+ node.forEachChild(visit)
394
+ }
395
+ file.forEachChild(visit)
396
+ return result
397
+ }
398
+
399
+ function sortHosts(hosts: ModuleExtensionHostFact[]): ModuleExtensionHostFact[] {
400
+ const sourceKey = (host: ModuleExtensionHostFact): string => host.source.kind === 'fact-ref'
401
+ ? `${host.source.factSection}:${host.source.factKey}`
402
+ : `${host.source.path}:${host.source.symbol}`
403
+ return hosts.sort((left, right) =>
404
+ left.family.localeCompare(right.family)
405
+ || left.id.localeCompare(right.id)
406
+ || sourceKey(left).localeCompare(sourceKey(right))
407
+ || left.key.localeCompare(right.key))
408
+ }
409
+
410
+ function sortContributions(contributions: ModuleExtensionContributionFact[]): ModuleExtensionContributionFact[] {
411
+ const targetKey = (contribution: ModuleExtensionContributionFact): string => contribution.targets
412
+ .map((entry) => entry.id)
413
+ .join('\u0000')
414
+ const sourceKey = (contribution: ModuleExtensionContributionFact): string =>
415
+ `${contribution.source.path}:${contribution.source.symbol ?? ''}`
416
+ return contributions.sort((left, right) =>
417
+ left.kind.localeCompare(right.kind)
418
+ || left.id.localeCompare(right.id)
419
+ || targetKey(left).localeCompare(targetKey(right))
420
+ || sourceKey(left).localeCompare(sourceKey(right)))
421
+ }
422
+
423
+ function target(id: string, resolution: ModuleExtensionTargetFact['resolution'] = 'unresolved'): ModuleExtensionTargetFact {
424
+ return { id, resolution }
425
+ }
426
+
427
+ function factTarget(id: string, factSection: string, factKey = id): ModuleExtensionTargetFact {
428
+ return { id, resolution: 'fact-ref', factRef: { factSection, factKey } }
429
+ }
430
+
431
+ function openTarget(id: string): ModuleExtensionTargetFact {
432
+ return target(id)
433
+ }
434
+
435
+ function declarationSource(sourceRoot: string, symbol: string): ModuleExtensionHostFact['source'] {
436
+ return { kind: 'declaration', path: path.posix.join(sourceRoot, 'extension-points.ts'), symbol }
437
+ }
438
+
439
+ function hasDeclarationBinding(
440
+ moduleRoot: string,
441
+ hostKey: string,
442
+ declaration: StaticObject,
443
+ ): boolean {
444
+ const relativeSource = stringValue(declaration.source)
445
+ if (!relativeSource) return false
446
+ const bindingFile = sourceFile(path.join(moduleRoot, relativeSource))
447
+ if (!bindingFile) return false
448
+ const escapedHostKey = hostKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
449
+ const bindingPattern = new RegExp(
450
+ `extensionPoints\\s*\\.\\s*hosts(?:\\s*\\.\\s*${escapedHostKey}|\\s*\\[\\s*['\"]${escapedHostKey}['\"]\\s*\\])`,
451
+ )
452
+ if (bindingPattern.test(bindingFile.text)) return true
453
+ return declaration.family === 'integration'
454
+ && typeof declaration.pattern === 'string'
455
+ && /resolveIntegrationDetailWidgetSpotId\s*\(/.test(bindingFile.text)
456
+ }
457
+
458
+ function baseHost(options: {
459
+ key: string
460
+ id: string
461
+ moduleId: string
462
+ family: ExtensionHostFamily
463
+ capabilities: ExtensionHostCapability[]
464
+ sourceRoot: string
465
+ declaration: StaticObject
466
+ bound?: boolean
467
+ resolution?: ModuleExtensionHostFact['resolution']
468
+ }): ModuleExtensionHostFact {
469
+ const { declaration } = options
470
+ const aliases = strings(declaration.aliases)
471
+ const fallbacks = strings(declaration.fallbacks)
472
+ const fact: ModuleExtensionHostFact = {
473
+ key: options.key,
474
+ id: options.id,
475
+ resolution: options.resolution ?? (options.id.includes('{') || options.id.includes('*') ? 'pattern' : 'exact'),
476
+ family: options.family,
477
+ ownerModule: options.moduleId,
478
+ capabilities: options.capabilities,
479
+ bound: options.bound ?? true,
480
+ stability: 'frozen',
481
+ source: declarationSource(options.sourceRoot, `extensionPoints.hosts.${options.key.split('.')[0]}`),
482
+ }
483
+ const contextContract = stringValue(declaration.contextContract)
484
+ const dataContract = stringValue(declaration.dataContract)
485
+ const scopeContract = stringValue(declaration.scopeContract)
486
+ const runtimeContract = stringValue(declaration.runtimeContract)
487
+ const activation = stringValue(declaration.activation) as ModuleExtensionHostFact['activation']
488
+ if (contextContract) fact.contextContract = contextContract
489
+ if (dataContract) fact.dataContract = dataContract
490
+ if (scopeContract) fact.scopeContract = scopeContract
491
+ if (runtimeContract) fact.runtimeContract = runtimeContract
492
+ if (activation) fact.activation = activation
493
+ if (aliases.length > 0) fact.aliases = aliases
494
+ if (fallbacks.length > 0) fact.fallbacks = fallbacks
495
+ return fact
496
+ }
497
+
498
+ function extractDeclaredHosts(options: ExtractModuleExtensionFactsOptions): {
499
+ hosts: ModuleExtensionHostFact[]
500
+ unresolved: ModuleExtensionUnresolvedFact[]
501
+ } {
502
+ const filePath = conventionPath(options.moduleRoot, 'extension-points.ts')
503
+ if (!filePath) return { hosts: [], unresolved: [] }
504
+ const declaration = readRootObject(filePath, 'extensionPoints')
505
+ const hostsObject = declaration && isStaticObject(declaration.hosts) ? declaration.hosts : null
506
+ if (!hostsObject) {
507
+ return {
508
+ hosts: [],
509
+ unresolved: [{
510
+ key: `${options.moduleId}.extension-points`,
511
+ source: { path: portablePath(options.moduleRoot, options.sourceRoot, filePath), symbol: 'extensionPoints' },
512
+ reason: 'unclassified-binding',
513
+ }],
514
+ }
515
+ }
516
+
517
+ const hosts: ModuleExtensionHostFact[] = []
518
+ const unresolved: ModuleExtensionUnresolvedFact[] = []
519
+ for (const hostKey of Object.keys(hostsObject).sort((left, right) => left.localeCompare(right))) {
520
+ const host = hostsObject[hostKey]
521
+ if (!isStaticObject(host)) continue
522
+ const family = stringValue(host.family) as ExtensionHostFamily | undefined
523
+ if (!family) continue
524
+ const declarationBound = hasDeclarationBinding(options.moduleRoot, hostKey, host)
525
+ if (!declarationBound) {
526
+ unresolved.push({
527
+ key: `${options.moduleId}.${hostKey}`,
528
+ source: {
529
+ path: stringValue(host.source) ?? portablePath(options.moduleRoot, options.sourceRoot, filePath),
530
+ symbol: `extensionPoints.hosts.${hostKey}`,
531
+ },
532
+ reason: 'unbound-declaration',
533
+ })
534
+ }
535
+ if (family === 'data-table') {
536
+ const tableId = stringValue(host.tableId)
537
+ if (!tableId) continue
538
+ const baseSpotId = stringValue(host.baseSpotId) ?? `data-table:${tableId}`
539
+ for (const surface of DATA_TABLE_EXTENSION_SURFACES) {
540
+ const usesBase = ['header', 'footer', 'toolbar', 'searchTrailing', 'emptyState'].includes(surface.key)
541
+ const id = surface.key === 'replacement'
542
+ ? `data-table:${tableId}`
543
+ : `${usesBase ? baseSpotId : `data-table:${tableId}`}:${surface.suffix}`
544
+ hosts.push(baseHost({
545
+ key: `${hostKey}.${surface.key}`,
546
+ id,
547
+ moduleId: options.moduleId,
548
+ family,
549
+ capabilities: [...surface.capabilities],
550
+ sourceRoot: options.sourceRoot,
551
+ declaration: host,
552
+ bound: surface.bound && declarationBound,
553
+ }))
554
+ }
555
+ continue
556
+ }
557
+ if (family === 'crud-form') {
558
+ const entityId = stringValue(host.entityId)
559
+ if (!entityId) continue
560
+ const spotId = stringValue(host.spotId) ?? `crud-form:${entityId}`
561
+ for (const surface of CRUD_FORM_EXTENSION_SURFACES) {
562
+ const id = surface.suffix ? `${spotId}:${surface.suffix}` : spotId
563
+ const fact = baseHost({
564
+ key: `${hostKey}.${surface.key}`,
565
+ id,
566
+ moduleId: options.moduleId,
567
+ family,
568
+ capabilities: [...surface.capabilities],
569
+ sourceRoot: options.sourceRoot,
570
+ declaration: host,
571
+ bound: surface.bound && declarationBound,
572
+ })
573
+ if (surface.key === 'base') fact.phases = [...CRUD_FORM_LIFECYCLE_PHASES]
574
+ hosts.push(fact)
575
+ }
576
+ continue
577
+ }
578
+ const id = stringValue(host.spotId) ?? stringValue(host.pattern) ?? stringValue(host.componentId)
579
+ if (!id) {
580
+ unresolved.push({
581
+ key: `${options.moduleId}.${hostKey}`,
582
+ source: { path: portablePath(options.moduleRoot, options.sourceRoot, filePath), symbol: hostKey },
583
+ reason: 'dynamic-without-pattern',
584
+ })
585
+ continue
586
+ }
587
+ const capabilities = family === 'component-handle'
588
+ ? ['component-replacement'] as ExtensionHostCapability[]
589
+ : strings(host.supported) as ExtensionHostCapability[]
590
+ const fact = baseHost({
591
+ key: hostKey,
592
+ id,
593
+ moduleId: options.moduleId,
594
+ family,
595
+ capabilities,
596
+ sourceRoot: options.sourceRoot,
597
+ declaration: host,
598
+ bound: declarationBound,
599
+ })
600
+ if (isStaticObject(host.parameters)) {
601
+ fact.patternParameters = Object.fromEntries(Object.entries(host.parameters).flatMap(([key, value]) => {
602
+ if (!isStaticObject(value) || typeof value.source !== 'string') return []
603
+ return [[key, {
604
+ source: value.source,
605
+ ...(typeof value.pattern === 'string' ? { pattern: value.pattern } : {}),
606
+ }]]
607
+ }))
608
+ }
609
+ hosts.push(fact)
610
+ }
611
+ return { hosts: sortHosts(hosts), unresolved }
612
+ }
613
+
614
+ function factRefHost(options: {
615
+ key: string
616
+ id: string
617
+ moduleId: string
618
+ family: ExtensionHostFamily
619
+ capabilities: ExtensionHostCapability[]
620
+ factSection: string
621
+ phases?: string[]
622
+ activation?: ModuleExtensionHostFact['activation']
623
+ scopeContract?: string
624
+ }): ModuleExtensionHostFact {
625
+ return {
626
+ key: options.key,
627
+ id: options.id,
628
+ resolution: 'fact-ref',
629
+ family: options.family,
630
+ ownerModule: options.moduleId,
631
+ capabilities: options.capabilities,
632
+ ...(options.phases ? { phases: options.phases } : {}),
633
+ ...(options.activation ? { activation: options.activation } : {}),
634
+ ...(options.scopeContract ? { scopeContract: options.scopeContract } : {}),
635
+ bound: true,
636
+ stability: 'stable',
637
+ source: { kind: 'fact-ref', factSection: options.factSection, factKey: options.id },
638
+ }
639
+ }
640
+
641
+ function sourceFilesBelow(directory: string): string[] {
642
+ if (!fs.existsSync(directory)) return []
643
+ const files: string[] = []
644
+ const visit = (current: string): void => {
645
+ for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
646
+ const entryPath = path.join(current, entry.name)
647
+ if (entry.isDirectory()) {
648
+ if (entry.name !== '__tests__' && entry.name !== '__integration__') visit(entryPath)
649
+ continue
650
+ }
651
+ if (entry.isFile() && /\.(?:ts|tsx)$/.test(entry.name) && !/\.(?:test|spec)\./.test(entry.name)) files.push(entryPath)
652
+ }
653
+ }
654
+ visit(directory)
655
+ return files
656
+ }
657
+
658
+ function apiRouteId(moduleId: string, moduleRoot: string, filePath: string): string | null {
659
+ const relative = path.relative(path.join(moduleRoot, 'api'), filePath).split(path.sep).join('/')
660
+ if (relative === 'interceptors.ts' || relative === 'openapi.ts') return null
661
+ const withoutExtension = relative.replace(/\.tsx?$/, '')
662
+ const suffix = withoutExtension.endsWith('/route') ? withoutExtension.slice(0, -6) : withoutExtension
663
+ return suffix && !suffix.includes('__tests__') ? `${moduleId.replace(/_/g, '-')}/${suffix}` : null
664
+ }
665
+
666
+ function apiExtensionHostIds(moduleId: string, moduleRoot: string): { entityIds: string[]; commandIds: string[]; routeIds: string[] } {
667
+ const entityIds = new Set<string>()
668
+ const commandIds = new Set<string>()
669
+ const routeIds = new Set<string>()
670
+ for (const filePath of sourceFilesBelow(path.join(moduleRoot, 'api'))) {
671
+ const routeId = apiRouteId(moduleId, moduleRoot, filePath)
672
+ if (routeId) routeIds.add(routeId)
673
+ const file = sourceFile(filePath)
674
+ if (!file) continue
675
+ const context = buildStaticContext(file)
676
+ const visit = (node: ts.Node): void => {
677
+ if (ts.isPropertyAssignment(node)) {
678
+ const name = propertyName(node.name)
679
+ if (name === 'commandId') {
680
+ const id = stringValue(staticValue(node.initializer, context))
681
+ if (id) commandIds.add(id)
682
+ }
683
+ if (name === 'enrichers') {
684
+ const config = staticValue(node.initializer, context)
685
+ const entityId = isStaticObject(config) ? stringValue(config.entityId) : undefined
686
+ if (entityId) entityIds.add(entityId)
687
+ }
688
+ }
689
+ if (
690
+ ts.isCallExpression(node)
691
+ && ts.isIdentifier(node.expression)
692
+ && node.expression.text === 'validateCrudMutationGuard'
693
+ ) {
694
+ const input = node.arguments[1] ? staticValue(node.arguments[1], context) : undefined
695
+ const entityId = isStaticObject(input) ? stringValue(input.resourceKind) : undefined
696
+ if (entityId) entityIds.add(entityId)
697
+ }
698
+ node.forEachChild(visit)
699
+ }
700
+ file.forEachChild(visit)
701
+ }
702
+ return {
703
+ entityIds: [...entityIds].sort((left, right) => left.localeCompare(right)),
704
+ commandIds: [...commandIds].sort((left, right) => left.localeCompare(right)),
705
+ routeIds: [...routeIds].sort((left, right) => left.localeCompare(right)),
706
+ }
707
+ }
708
+
709
+ function moduleCommandIds(moduleRoot: string, apiCommandIds: readonly string[]): string[] {
710
+ const commandIds = new Set(apiCommandIds)
711
+ for (const filePath of sourceFilesBelow(path.join(moduleRoot, 'commands'))) {
712
+ if (path.basename(filePath).replace(/\.(?:ts|tsx)$/, '') === 'interceptors') continue
713
+ for (const id of extractCommandIdsFromSource(filePath)) commandIds.add(id)
714
+ }
715
+ return [...commandIds].sort((left, right) => left.localeCompare(right))
716
+ }
717
+
718
+ export function extractKnownApiRouteIds(moduleId: string, moduleRoot: string): string[] {
719
+ return apiExtensionHostIds(moduleId, moduleRoot).routeIds
720
+ }
721
+
722
+ export function extractKnownCommandIds(moduleId: string, moduleRoot: string): string[] {
723
+ const apiHosts = apiExtensionHostIds(moduleId, moduleRoot)
724
+ return moduleCommandIds(moduleRoot, apiHosts.commandIds)
725
+ }
726
+
727
+ function extractFactRefHosts(options: ExtractModuleExtensionFactsOptions): ModuleExtensionHostFact[] {
728
+ const hosts: ModuleExtensionHostFact[] = []
729
+ for (const entity of options.entities) {
730
+ hosts.push(factRefHost({
731
+ key: `entity.${entity.id}`,
732
+ id: entity.id,
733
+ moduleId: options.moduleId,
734
+ family: 'entity',
735
+ capabilities: ['response-enricher', 'query-enricher', 'mutation-guard', 'entity-extension'],
736
+ factSection: 'entities',
737
+ activation: 'host-opt-in',
738
+ scopeContract: 'tenant-and-organization',
739
+ }))
740
+ }
741
+ for (const route of options.apiRoutes) {
742
+ hosts.push(factRefHost({
743
+ key: `api-route.${route.path}`,
744
+ id: route.path,
745
+ moduleId: options.moduleId,
746
+ family: 'api-route',
747
+ capabilities: ['api-interceptor', 'mutation-guard'],
748
+ factSection: 'apiRoutes',
749
+ phases: ['before', 'after'],
750
+ activation: 'host-opt-in',
751
+ scopeContract: 'request-auth-scope',
752
+ }))
753
+ }
754
+ for (const event of options.events) {
755
+ const capabilities: ExtensionHostCapability[] = ['async-subscriber', 'sync-subscriber']
756
+ if (event.clientBroadcast) capabilities.push('browser-client')
757
+ if (event.portalBroadcast) capabilities.push('browser-portal')
758
+ hosts.push(factRefHost({
759
+ key: `event.${event.id}`,
760
+ id: event.id,
761
+ moduleId: options.moduleId,
762
+ family: 'event',
763
+ capabilities,
764
+ factSection: 'events',
765
+ scopeContract: 'tenant-organization-and-audience',
766
+ }))
767
+ }
768
+ for (const entityId of options.searchEntities) {
769
+ const eventStem = entityId.replace(':', '.')
770
+ for (const phase of ['querying', 'queried'] as const) {
771
+ hosts.push(factRefHost({
772
+ key: `query-lifecycle.${eventStem}.${phase}`,
773
+ id: `${eventStem}.${phase}`,
774
+ moduleId: options.moduleId,
775
+ family: 'query-lifecycle',
776
+ capabilities: ['sync-subscriber'],
777
+ factSection: 'searchEntities',
778
+ phases: phase === 'querying' ? ['block', 'query-transform'] : ['result-transform', 'scope-reapply'],
779
+ activation: 'caller-opt-in',
780
+ scopeContract: 'tenant-and-organization-reapplied-after-result',
781
+ }))
782
+ }
783
+ }
784
+ const apiHosts = apiExtensionHostIds(options.moduleId, options.moduleRoot)
785
+ for (const entityId of apiHosts.entityIds) {
786
+ hosts.push(factRefHost({
787
+ key: `api-entity.${entityId}`,
788
+ id: entityId,
789
+ moduleId: options.moduleId,
790
+ family: 'entity',
791
+ capabilities: ['response-enricher', 'query-enricher', 'mutation-guard'],
792
+ factSection: 'apiRoutes',
793
+ activation: 'host-opt-in',
794
+ scopeContract: 'tenant-and-organization',
795
+ }))
796
+ }
797
+ return sortHosts([...new Map(hosts.map((host) => [`${host.family}:${host.id}`, host])).values()])
798
+ }
799
+
800
+ function contributionBase(
801
+ id: string,
802
+ sourcePath: string,
803
+ sourceSymbol?: string,
804
+ ): ModuleExtensionContributionBase {
805
+ return {
806
+ id,
807
+ targets: [],
808
+ scopeContract: 'tenant-and-organization',
809
+ source: { path: sourcePath, ...(sourceSymbol ? { symbol: sourceSymbol } : {}) },
810
+ }
811
+ }
812
+
813
+ function extractInjectionTable(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
814
+ const filePath = conventionPath(options.moduleRoot, 'widgets/injection-table.ts')
815
+ if (!filePath) return []
816
+ const table = readRootObject(filePath, 'injectionTable')
817
+ if (!table) return []
818
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, filePath)
819
+ const facts: ModuleExtensionContributionFact[] = []
820
+ for (const targetId of Object.keys(table).sort((left, right) => left.localeCompare(right))) {
821
+ const entries = table[targetId]
822
+ if (!Array.isArray(entries)) continue
823
+ for (const entry of entries) {
824
+ if (!isStaticObject(entry)) continue
825
+ const widgetId = stringValue(entry.widgetId)
826
+ if (!widgetId) continue
827
+ const payload = targetId.endsWith(':columns') ? 'column'
828
+ : targetId.endsWith(':row-actions') ? 'row-action'
829
+ : targetId.endsWith(':bulk-actions') ? 'bulk-action'
830
+ : targetId.endsWith(':filters') ? 'filter'
831
+ : targetId.endsWith(':fields') ? 'field'
832
+ : 'render'
833
+ const base = contributionBase(`${widgetId}@${targetId}`, sourcePath, 'injectionTable')
834
+ const shared = {
835
+ ...base,
836
+ targets: [target(targetId)],
837
+ features: strings(entry.features),
838
+ placement: numberValue(entry.priority) !== undefined ? { priority: numberValue(entry.priority) } : undefined,
839
+ }
840
+ if (targetId.startsWith('data-table:')) {
841
+ facts.push({
842
+ ...shared,
843
+ kind: 'data-table',
844
+ details: {
845
+ payload: payload === 'field' ? 'render' : payload,
846
+ tableId: targetId.replace(/^data-table:/, '').replace(/:(?:columns|row-actions|bulk-actions|filters|toolbar|header|footer|search-trailing)$/, ''),
847
+ executionGuard: strings(entry.features).length > 0 ? 'both' : 'host',
848
+ },
849
+ })
850
+ } else if (targetId.startsWith('crud-form:')) {
851
+ facts.push({
852
+ ...shared,
853
+ kind: 'crud-form',
854
+ details: {
855
+ payload: payload === 'field' ? 'field' : 'render',
856
+ entityId: targetId.replace(/^crud-form:/, '').replace(/:(?:fields|header)$/, ''),
857
+ requestHeaderCapability: payload === 'field',
858
+ },
859
+ })
860
+ } else {
861
+ facts.push({
862
+ ...shared,
863
+ kind: 'widget',
864
+ details: {
865
+ payload: 'render',
866
+ registryKey: widgetId,
867
+ executionGuard: strings(entry.features).length > 0 ? 'both' : 'host',
868
+ },
869
+ })
870
+ }
871
+ }
872
+ }
873
+ return facts
874
+ }
875
+
876
+ function extractObjectConvention(options: {
877
+ module: ExtractModuleExtensionFactsOptions
878
+ relativePath: string
879
+ exportName: string
880
+ exportAliases?: string[]
881
+ build: (entry: StaticObject, sourcePath: string, index: number) => ModuleExtensionContributionFact | null
882
+ }): ModuleExtensionContributionFact[] {
883
+ const filePath = conventionPath(options.module.moduleRoot, options.relativePath)
884
+ if (!filePath) return []
885
+ const exportNames = [options.exportName, ...(options.exportAliases ?? [])]
886
+ const entries = exportNames.reduce<StaticObject[]>((found, exportName) =>
887
+ found.length > 0 ? found : readConventionObjectArray(filePath, exportName), [])
888
+ const sourcePath = portablePath(options.module.moduleRoot, options.module.sourceRoot, filePath)
889
+ return entries.flatMap((entry, index) => options.build(entry, sourcePath, index) ?? [])
890
+ }
891
+
892
+ function methodPhases(entry: StaticObject, map: ReadonlyArray<[string, string]>): string[] {
893
+ return map.flatMap(([property, phase]) => entry[property] === true ? [phase] : [])
894
+ }
895
+
896
+ function extractEnrichers(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
897
+ return extractObjectConvention({
898
+ module: options,
899
+ relativePath: 'data/enrichers.ts',
900
+ exportName: 'enrichers',
901
+ build(entry, sourcePath, index) {
902
+ const id = stringValue(entry.id) ?? `${options.moduleId}.enricher.${index}`
903
+ const targetEntity = stringValue(entry.targetEntity)
904
+ if (!targetEntity) return null
905
+ const timeoutMs = numberValue(entry.timeout) ?? 2000
906
+ const queryEngine = isStaticObject(entry.queryEngine) ? entry.queryEngine : null
907
+ const surfaces = [entry.enrichMany === true ? 'list' : null, entry.enrichOne === true ? 'detail' : null]
908
+ .filter((value): value is 'list' | 'detail' => value !== null)
909
+ const base = contributionBase(id, sourcePath, id)
910
+ return {
911
+ ...base,
912
+ kind: 'response-enricher',
913
+ targets: [openTarget(targetEntity)],
914
+ features: strings(entry.features),
915
+ phases: surfaces,
916
+ activation: queryEngine ? 'caller-opt-in' : 'host-opt-in',
917
+ roundTripId: `${targetEntity}:read`,
918
+ details: {
919
+ targetEntity,
920
+ surfaces,
921
+ timeoutMs,
922
+ fallback: entry.fallback === undefined ? 'none' : 'configured',
923
+ critical: booleanValue(entry.critical) ?? false,
924
+ cachePosture: 'rerun-on-list-cache-hit',
925
+ ...(queryEngine ? { queryEngine: {
926
+ engines: strings(queryEngine.engines),
927
+ applyOn: (strings(queryEngine.applyOn).length > 0 ? strings(queryEngine.applyOn) : ['list', 'detail'])
928
+ .filter((value): value is 'list' | 'detail' => value === 'list' || value === 'detail'),
929
+ activation: 'caller-opt-in' as const,
930
+ } } : {}),
931
+ },
932
+ }
933
+ },
934
+ })
935
+ }
936
+
937
+ function extractApiInterceptors(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
938
+ return extractObjectConvention({
939
+ module: options,
940
+ relativePath: 'api/interceptors.ts',
941
+ exportName: 'interceptors',
942
+ build(entry, sourcePath, index) {
943
+ const id = stringValue(entry.id) ?? `${options.moduleId}.api-interceptor.${index}`
944
+ const targetRoute = stringValue(entry.targetRoute)
945
+ if (!targetRoute) return null
946
+ const phases = methodPhases(entry, [['before', 'before'], ['after', 'after']])
947
+ .filter((phase): phase is 'before' | 'after' => phase === 'before' || phase === 'after')
948
+ const methods = strings(entry.methods)
949
+ const base = contributionBase(id, sourcePath, id)
950
+ return {
951
+ ...base,
952
+ kind: 'api-interceptor',
953
+ targets: [openTarget(targetRoute)],
954
+ phases,
955
+ features: strings(entry.features),
956
+ details: {
957
+ route: targetRoute,
958
+ methods,
959
+ phases,
960
+ activation: targetRoute.startsWith('/') ? 'custom-route-bridge' : 'crud-pipeline',
961
+ timeoutMs: numberValue(entry.timeout) ?? 2000,
962
+ failurePosture: booleanValue(entry.critical) ? 'fail-closed' : 'fallback',
963
+ },
964
+ }
965
+ },
966
+ })
967
+ }
968
+
969
+ function extractCommandInterceptors(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
970
+ return extractObjectConvention({
971
+ module: options,
972
+ relativePath: 'commands/interceptors.ts',
973
+ exportName: 'interceptors',
974
+ build(entry, sourcePath, index) {
975
+ const id = stringValue(entry.id) ?? `${options.moduleId}.command-interceptor.${index}`
976
+ const targetCommand = stringValue(entry.targetCommand)
977
+ if (!targetCommand) return null
978
+ const phases = methodPhases(entry, [
979
+ ['beforeExecute', 'before-execute'],
980
+ ['afterExecute', 'after-execute'],
981
+ ['beforeUndo', 'before-undo'],
982
+ ['afterUndo', 'after-undo'],
983
+ ]).filter((phase): phase is 'before-execute' | 'after-execute' | 'before-undo' | 'after-undo' =>
984
+ phase === 'before-execute' || phase === 'after-execute' || phase === 'before-undo' || phase === 'after-undo')
985
+ const base = contributionBase(id, sourcePath, id)
986
+ return {
987
+ ...base,
988
+ kind: 'command-interceptor',
989
+ targets: [openTarget(targetCommand)],
990
+ phases,
991
+ features: strings(entry.features),
992
+ details: { targetCommand, phases },
993
+ }
994
+ },
995
+ })
996
+ }
997
+
998
+ function extractGuards(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
999
+ return extractObjectConvention({
1000
+ module: options,
1001
+ relativePath: 'data/guards.ts',
1002
+ exportName: 'guards',
1003
+ build(entry, sourcePath, index) {
1004
+ const id = stringValue(entry.id) ?? `${options.moduleId}.guard.${index}`
1005
+ const targetEntity = stringValue(entry.targetEntity)
1006
+ if (!targetEntity) return null
1007
+ const operations = strings(entry.operations)
1008
+ .filter((operation): operation is 'create' | 'update' | 'delete' =>
1009
+ operation === 'create' || operation === 'update' || operation === 'delete')
1010
+ const capabilities = [entry.validate === true ? 'block' : null, entry.validate === true ? 'rewrite' : null, entry.afterSuccess === true ? 'after-success' : null]
1011
+ .filter((value): value is 'block' | 'rewrite' | 'after-success' => value !== null)
1012
+ const base = contributionBase(id, sourcePath, id)
1013
+ return {
1014
+ ...base,
1015
+ kind: 'mutation-guard',
1016
+ targets: [openTarget(targetEntity)],
1017
+ operations,
1018
+ features: strings(entry.features),
1019
+ roundTripId: `${targetEntity}:write`,
1020
+ details: { entityId: targetEntity, operations, capabilities, optimisticLock: 'preserved' },
1021
+ }
1022
+ },
1023
+ })
1024
+ }
1025
+
1026
+ function extractEntityExtensions(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1027
+ return extractObjectConvention({
1028
+ module: options,
1029
+ relativePath: 'data/extensions.ts',
1030
+ exportName: 'entityExtensions',
1031
+ exportAliases: ['extensions'],
1032
+ build(entry, sourcePath, index) {
1033
+ const baseEntity = stringValue(entry.base)
1034
+ const extensionEntity = stringValue(entry.extension)
1035
+ const join = isStaticObject(entry.join) ? entry.join : null
1036
+ if (!baseEntity || !extensionEntity || !join) return null
1037
+ const id = `${options.moduleId}.entity-extension.${index}:${baseEntity}->${extensionEntity}`
1038
+ const contribution = contributionBase(id, sourcePath, 'extensions')
1039
+ return {
1040
+ ...contribution,
1041
+ kind: 'entity-extension',
1042
+ targets: [openTarget(baseEntity)],
1043
+ details: {
1044
+ hostEntityId: baseEntity,
1045
+ extensionEntityId: extensionEntity,
1046
+ linkId: `${stringValue(join.baseKey) ?? 'id'}:${stringValue(join.extensionKey) ?? 'id'}`,
1047
+ scopeContract: 'tenant-and-organization',
1048
+ orphanContract: booleanValue(entry.required) ? 'required' : 'optional',
1049
+ },
1050
+ }
1051
+ },
1052
+ })
1053
+ }
1054
+
1055
+ function extractSubscribers(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1056
+ const directory = path.join(options.moduleRoot, 'subscribers')
1057
+ if (!fs.existsSync(directory)) return []
1058
+ const facts: ModuleExtensionContributionFact[] = []
1059
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
1060
+ if (!entry.isFile() || !/\.(?:ts|tsx)$/.test(entry.name) || /\.(?:test|spec)\./.test(entry.name)) continue
1061
+ const filePath = path.join(directory, entry.name)
1062
+ const metadata = readRootObject(filePath, 'metadata')
1063
+ if (!metadata) continue
1064
+ const event = stringValue(metadata.event)
1065
+ if (!event) continue
1066
+ const subscriberId = stringValue(metadata.id) ?? `${options.moduleId}:${entry.name.replace(/\.tsx?$/, '')}`
1067
+ const persistent = booleanValue(metadata.persistent) ?? false
1068
+ const sync = booleanValue(metadata.sync) ?? !persistent
1069
+ const contribution = contributionBase(subscriberId, portablePath(options.moduleRoot, options.sourceRoot, filePath), 'metadata')
1070
+ facts.push({
1071
+ ...contribution,
1072
+ kind: 'subscriber',
1073
+ targets: [openTarget(event)],
1074
+ phases: sync ? ['before-or-after'] : ['async-delivery'],
1075
+ details: {
1076
+ event,
1077
+ subscriberId,
1078
+ persistent,
1079
+ sync,
1080
+ ...(numberValue(metadata.priority) !== undefined ? { priority: numberValue(metadata.priority) as number } : {}),
1081
+ },
1082
+ })
1083
+ }
1084
+ return facts
1085
+ }
1086
+
1087
+ function extractBrowserReactions(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1088
+ return options.events.flatMap((event) => {
1089
+ const transports = [event.clientBroadcast ? 'client' : null, event.portalBroadcast ? 'portal' : null]
1090
+ .filter((value): value is 'client' | 'portal' => value !== null)
1091
+ if (transports.length === 0) return []
1092
+ const contribution = contributionBase(`${event.id}.browser`, path.posix.join(options.sourceRoot, 'events.ts'), 'events')
1093
+ return [{
1094
+ ...contribution,
1095
+ kind: 'browser-reaction' as const,
1096
+ targets: [factTarget(event.id, 'events')],
1097
+ details: {
1098
+ transports,
1099
+ hooks: transports.flatMap((transport) => transport === 'client' ? ['useAppEvent', 'useOperationProgress'] : ['usePortalAppEvent']),
1100
+ audienceScopeContract: 'tenant-organization-user-role-and-customer',
1101
+ },
1102
+ }]
1103
+ })
1104
+ }
1105
+
1106
+ function extractNotificationReactions(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1107
+ return extractObjectConvention({
1108
+ module: options,
1109
+ relativePath: 'notifications.handlers.ts',
1110
+ exportName: 'notificationHandlers',
1111
+ build(entry, sourcePath, index) {
1112
+ const notificationType = stringValue(entry.notificationType)
1113
+ if (!notificationType) return null
1114
+ const id = stringValue(entry.id) ?? `${options.moduleId}.notification-handler.${index}`
1115
+ const contribution = contributionBase(id, sourcePath, 'notificationHandlers')
1116
+ return {
1117
+ ...contribution,
1118
+ kind: 'browser-reaction',
1119
+ targets: [factTarget(notificationType, 'notifications')],
1120
+ features: strings(entry.features),
1121
+ placement: numberValue(entry.priority) !== undefined ? { priority: numberValue(entry.priority) } : undefined,
1122
+ details: {
1123
+ transports: ['notification-effect'],
1124
+ hooks: ['useNotificationEffect'],
1125
+ audienceScopeContract: 'tenant-organization-user-role-and-customer',
1126
+ },
1127
+ }
1128
+ },
1129
+ })
1130
+ }
1131
+
1132
+ function extractComponentOverrides(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1133
+ return extractObjectConvention({
1134
+ module: options,
1135
+ relativePath: 'widgets/components.ts',
1136
+ exportName: 'componentOverrides',
1137
+ build(entry, sourcePath, index) {
1138
+ const targetDefinition = isStaticObject(entry.target) ? entry.target : null
1139
+ const handle = targetDefinition ? stringValue(targetDefinition.componentId) : undefined
1140
+ if (!handle) return null
1141
+ const mode = entry.wrapper !== undefined ? 'wrapper' : entry.props !== undefined ? 'props' : 'replace'
1142
+ const id = `${options.moduleId}.component-override.${index}:${handle}`
1143
+ const contribution = contributionBase(id, sourcePath, 'componentOverrides')
1144
+ return {
1145
+ ...contribution,
1146
+ kind: 'component-override',
1147
+ targets: [target(handle)],
1148
+ details: {
1149
+ handle,
1150
+ mode,
1151
+ propsContract: stringValue(entry.propsContract) ?? 'component-props-schema',
1152
+ },
1153
+ }
1154
+ },
1155
+ })
1156
+ }
1157
+
1158
+ function extractSpecializedRegistries(options: ExtractModuleExtensionFactsOptions): ModuleExtensionContributionFact[] {
1159
+ const facts: ModuleExtensionContributionFact[] = []
1160
+ const add = (
1161
+ id: string,
1162
+ registry: SpecializedRegistry,
1163
+ sourcePath: string,
1164
+ specialistRoute: string,
1165
+ symbol = id,
1166
+ ): void => {
1167
+ const contributionId = `${registry}:${id}`
1168
+ const contribution = contributionBase(contributionId, sourcePath, id)
1169
+ const fact: ModuleExtensionContributionFact = {
1170
+ ...contribution,
1171
+ kind: 'specialized-registry',
1172
+ targets: [factTarget(id, specialistRoute)],
1173
+ source: { path: sourcePath, symbol },
1174
+ details: { registry, registryId: id, specialistRoute },
1175
+ }
1176
+ const existingIndex = facts.findIndex((entry) => entry.id === contributionId)
1177
+ if (existingIndex >= 0) facts[existingIndex] = fact
1178
+ else facts.push(fact)
1179
+ }
1180
+ for (const id of options.notifications ?? []) add(id, 'notification', path.posix.join(options.sourceRoot, 'notifications.ts'), 'notifications')
1181
+ for (const id of options.searchEntities) add(id, 'search', path.posix.join(options.sourceRoot, 'search.ts'), 'search')
1182
+ for (const tool of options.aiTools ?? []) add(tool.name, 'ai', tool.sourcePath, 'aiTools')
1183
+ for (const agent of options.aiAgents ?? []) add(agent.id, 'ai', agent.sourcePath, 'aiAgents')
1184
+
1185
+ const vectorPath = conventionPath(options.moduleRoot, 'vector.ts')
1186
+ if (vectorPath) {
1187
+ const vectorConfig = readRootObject(vectorPath, 'vectorConfig') ?? readRootObject(vectorPath, 'config')
1188
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, vectorPath)
1189
+ if (vectorConfig && Array.isArray(vectorConfig.entities)) {
1190
+ for (const entity of vectorConfig.entities) {
1191
+ if (!isStaticObject(entity)) continue
1192
+ const entityId = stringValue(entity.entityId)
1193
+ if (entityId) add(entityId, 'vector', sourcePath, 'vector', 'vectorConfig')
1194
+ }
1195
+ }
1196
+ }
1197
+
1198
+ const integrationPath = conventionPath(options.moduleRoot, 'integration.ts')
1199
+ if (integrationPath) {
1200
+ const integration = readRootObject(integrationPath, 'integration')
1201
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, integrationPath)
1202
+ const integrationId = integration ? stringValue(integration.id) : undefined
1203
+ if (integrationId) add(integrationId, 'integration', sourcePath, 'integrations', 'integration')
1204
+ const providerKey = integration ? stringValue(integration.providerKey) : undefined
1205
+ const category = integration ? stringValue(integration.category) : undefined
1206
+ const categoryRegistry = category === 'payment' ? 'payment'
1207
+ : category === 'shipping' ? 'shipping'
1208
+ : category === 'currency' ? 'currency'
1209
+ : null
1210
+ if (providerKey && categoryRegistry) {
1211
+ const specialistRoute = categoryRegistry === 'payment' ? 'paymentGateways'
1212
+ : categoryRegistry === 'shipping' ? 'shippingCarriers'
1213
+ : 'currencies'
1214
+ add(providerKey, categoryRegistry, sourcePath, specialistRoute, 'integration.providerKey')
1215
+ }
1216
+ }
1217
+
1218
+ const workflowsPath = conventionPath(options.moduleRoot, 'workflows.ts')
1219
+ if (workflowsPath) {
1220
+ const config = readRootObject(workflowsPath, 'workflowsConfig')
1221
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, workflowsPath)
1222
+ if (config && Array.isArray(config.workflows)) {
1223
+ for (const workflow of config.workflows) {
1224
+ if (!isStaticObject(workflow)) continue
1225
+ const workflowId = stringValue(workflow.workflowId)
1226
+ if (workflowId) add(workflowId, 'workflow', sourcePath, 'workflows', 'workflowsConfig')
1227
+ }
1228
+ }
1229
+ }
1230
+
1231
+ const diPath = conventionPath(options.moduleRoot, 'di.ts')
1232
+ if (diPath) {
1233
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, diPath)
1234
+ const registrations = readCallObjectArguments(diPath, new Set([
1235
+ 'registerPaymentGatewayDescriptor',
1236
+ 'registerShippingAdapter',
1237
+ 'registerCurrencyProvider',
1238
+ ]))
1239
+ for (const registration of registrations) {
1240
+ const registry = registration.callee === 'registerPaymentGatewayDescriptor' ? 'payment'
1241
+ : registration.callee === 'registerShippingAdapter' ? 'shipping'
1242
+ : 'currency'
1243
+ const registryId = stringValue(registration.value.providerKey)
1244
+ ?? stringValue(registration.value.key)
1245
+ ?? stringValue(registration.value.id)
1246
+ if (!registryId) continue
1247
+ const specialistRoute = registry === 'payment' ? 'paymentGateways'
1248
+ : registry === 'shipping' ? 'shippingCarriers'
1249
+ : 'currencies'
1250
+ add(registryId, registry, sourcePath, specialistRoute, registration.callee)
1251
+ }
1252
+ }
1253
+
1254
+ const dashboardRoots = { appBase: path.join(options.moduleRoot, '__app__'), pkgBase: options.moduleRoot }
1255
+ for (const widgetFile of scanModuleDir(dashboardRoots, SCAN_CONFIGS.dashboardWidgets)) {
1256
+ const filePath = path.join(options.moduleRoot, 'widgets', 'dashboard', widgetFile.relPath)
1257
+ const widget = readRootObject(filePath, 'widget')
1258
+ const metadata = widget && isStaticObject(widget.metadata) ? widget.metadata : null
1259
+ if (!metadata) continue
1260
+ const widgetId = stringValue(metadata.id)
1261
+ if (!widgetId) continue
1262
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, filePath)
1263
+ const contribution = contributionBase(`dashboard:${widgetId}`, sourcePath, 'widget.metadata')
1264
+ facts.push({
1265
+ ...contribution,
1266
+ kind: 'widget',
1267
+ targets: [target(`dashboard:${widgetId}`)],
1268
+ features: strings(metadata.features),
1269
+ details: {
1270
+ payload: 'dashboard',
1271
+ registryKey: widgetId,
1272
+ executionGuard: strings(metadata.features).length > 0 ? 'both' : 'host',
1273
+ },
1274
+ })
1275
+ }
1276
+ return facts
1277
+ }
1278
+
1279
+ export function extractModuleExtensionFacts(options: ExtractModuleExtensionFactsOptions): ModuleExtensionSurfaceFacts {
1280
+ const declared = extractDeclaredHosts(options)
1281
+ const hosts = sortHosts([...declared.hosts, ...extractFactRefHosts(options)])
1282
+ const contributions = sortContributions([
1283
+ ...extractInjectionTable(options),
1284
+ ...extractEnrichers(options),
1285
+ ...extractApiInterceptors(options),
1286
+ ...extractCommandInterceptors(options),
1287
+ ...extractGuards(options),
1288
+ ...extractEntityExtensions(options),
1289
+ ...extractSubscribers(options),
1290
+ ...extractBrowserReactions(options),
1291
+ ...extractNotificationReactions(options),
1292
+ ...extractComponentOverrides(options),
1293
+ ...extractSpecializedRegistries(options),
1294
+ ])
1295
+ return { hosts, contributions, unresolved: declared.unresolved }
1296
+ }
1297
+
1298
+ function patternMatches(pattern: string, targetId: string): boolean {
1299
+ const escaped = pattern
1300
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
1301
+ .replace(/\\\{[^}]+\\\}/g, '[^:]+')
1302
+ .replace(/\*/g, '.*')
1303
+ return new RegExp(`^${escaped}$`).test(targetId)
1304
+ }
1305
+
1306
+ function targetModuleId(targetId: string): string | null {
1307
+ const withoutPrefix = targetId.replace(/^(?:data-table|crud-form|detail|portal-page):/, '')
1308
+ const match = /^([a-z][a-z0-9_]*)[.:]/.exec(withoutPrefix)
1309
+ return match?.[1] ?? null
1310
+ }
1311
+
1312
+ export function correlateExtensionTarget(
1313
+ targetFact: ModuleExtensionTargetFact,
1314
+ options: CorrelateExtensionFactsOptions,
1315
+ ): ModuleExtensionTargetFact {
1316
+ if (targetFact.resolution !== 'unresolved') return targetFact
1317
+ const allHosts = Object.values(options.surfacesByModule).flatMap((surface) => surface.hosts)
1318
+ if (targetFact.id.includes('*') || targetFact.id.includes('{')) {
1319
+ const knownIds = [
1320
+ ...allHosts.filter((host) => host.bound).map((host) => host.id),
1321
+ ...options.entityIds,
1322
+ ...options.eventIds,
1323
+ ...options.apiRoutes,
1324
+ ...(options.commandIds ?? []),
1325
+ ]
1326
+ if (targetFact.id === '*' || knownIds.some((knownId) => patternMatches(targetFact.id, knownId))) {
1327
+ return { id: targetFact.id, resolution: 'pattern' }
1328
+ }
1329
+ }
1330
+ const exact = allHosts.find((host) => host.bound && (
1331
+ host.id === targetFact.id
1332
+ || host.aliases?.includes(targetFact.id)
1333
+ || (host.family === 'api-route' && host.id.endsWith(`/${targetFact.id}`))
1334
+ ))
1335
+ if (exact) {
1336
+ return exact.source.kind === 'fact-ref'
1337
+ ? factTarget(targetFact.id, exact.source.factSection, exact.source.factKey)
1338
+ : { id: targetFact.id, resolution: 'exact' }
1339
+ }
1340
+ const patterned = allHosts.find((host) => host.bound && host.resolution === 'pattern' && patternMatches(host.id, targetFact.id))
1341
+ if (patterned) return { id: targetFact.id, resolution: 'pattern' }
1342
+ const framework = allFrameworkHosts().find((host) => patternMatches(host.id, targetFact.id))
1343
+ ?? (FRAMEWORK_PREFIXES.some((prefix) => targetFact.id.startsWith(prefix)) ? FRAMEWORK_HOSTS[0] : undefined)
1344
+ if (framework) return { id: targetFact.id, resolution: 'framework' }
1345
+ if (options.entityIds.has(targetFact.id)) {
1346
+ return { id: targetFact.id, resolution: 'fact-ref', factRef: { factSection: 'entities', factKey: targetFact.id } }
1347
+ }
1348
+ if (options.eventIds.has(targetFact.id)) {
1349
+ return { id: targetFact.id, resolution: 'fact-ref', factRef: { factSection: 'events', factKey: targetFact.id } }
1350
+ }
1351
+ if (options.commandIds?.has(targetFact.id)) {
1352
+ return factTarget(targetFact.id, 'commands')
1353
+ }
1354
+ const route = [...options.apiRoutes].find((apiRoute) => apiRoute === targetFact.id || apiRoute.endsWith(`/${targetFact.id}`))
1355
+ if (route) return { id: targetFact.id, resolution: 'fact-ref', factRef: { factSection: 'apiRoutes', factKey: route } }
1356
+ const ownerModule = targetModuleId(targetFact.id)
1357
+ if (ownerModule && !options.surfacesByModule[ownerModule]) {
1358
+ return { id: targetFact.id, resolution: 'optional-external', optionalOwnerPackage: `module:${ownerModule}` }
1359
+ }
1360
+ if (ownerModule && options.contributingModuleId && ownerModule !== options.contributingModuleId) {
1361
+ return { id: targetFact.id, resolution: 'optional-external', optionalOwnerPackage: `module:${ownerModule}` }
1362
+ }
1363
+ return targetFact
1364
+ }
1365
+
1366
+ export function correlateModuleExtensionFacts(options: CorrelateExtensionFactsOptions): Record<string, ModuleExtensionSurfaceFacts> {
1367
+ const result: Record<string, ModuleExtensionSurfaceFacts> = {}
1368
+ for (const moduleId of Object.keys(options.surfacesByModule).sort((left, right) => left.localeCompare(right))) {
1369
+ const surface = options.surfacesByModule[moduleId]
1370
+ const unresolved: ModuleExtensionUnresolvedFact[] = [...surface.unresolved]
1371
+ const contributions = surface.contributions.map((contribution) => {
1372
+ const targets = contribution.targets.map((entry) => correlateExtensionTarget(entry, {
1373
+ ...options,
1374
+ contributingModuleId: moduleId,
1375
+ }))
1376
+ for (const entry of targets) {
1377
+ if (entry.resolution !== 'unresolved') continue
1378
+ unresolved.push({
1379
+ key: `${contribution.id}:${entry.id}`,
1380
+ source: contribution.source,
1381
+ reason: 'unresolved-first-party-target',
1382
+ })
1383
+ }
1384
+ return { ...contribution, targets }
1385
+ })
1386
+ result[moduleId] = {
1387
+ hosts: sortHosts([...surface.hosts]),
1388
+ contributions: sortContributions(contributions),
1389
+ unresolved: unresolved.sort((left, right) => left.key.localeCompare(right.key)),
1390
+ }
1391
+ }
1392
+ return result
1393
+ }
1394
+
1395
+ export function assertNoUnresolvedExtensionTargets(
1396
+ surfacesByModule: Readonly<Record<string, ModuleExtensionSurfaceFacts>>,
1397
+ ): void {
1398
+ const unresolved = Object.entries(surfacesByModule).flatMap(([moduleId, surface]) =>
1399
+ surface.unresolved
1400
+ .filter((entry) => entry.reason === 'unresolved-first-party-target')
1401
+ .map((entry) => `${moduleId}:${entry.key}`),
1402
+ ).sort((left, right) => left.localeCompare(right))
1403
+ if (unresolved.length > 0) {
1404
+ throw new Error(`[module-facts] unresolved first-party extension targets: ${unresolved.join(', ')}`)
1405
+ }
1406
+ }
1407
+
1408
+ export function getFrameworkExtensionHosts(): ModuleExtensionHostFact[] {
1409
+ return sortHosts(allFrameworkHosts().map((host) => ({
1410
+ ...host,
1411
+ capabilities: [...host.capabilities],
1412
+ ...(host.operations ? { operations: [...host.operations] } : {}),
1413
+ })))
1414
+ }
1415
+
1416
+ export function renderFrameworkExtensionPointsMarkdown(): string {
1417
+ const rows = getFrameworkExtensionHosts().map((host) =>
1418
+ `| ${host.id} | ${host.family} | ${[...host.capabilities, ...(host.operations ?? [])].join(', ')} | ${host.stability.toUpperCase()} |`,
1419
+ )
1420
+ return [
1421
+ '# Framework extension points (generated, do not edit)',
1422
+ '',
1423
+ '| ID / pattern | Family | Supports | Stability |',
1424
+ '|---|---|---|---|',
1425
+ ...rows,
1426
+ '',
1427
+ ].join('\n')
1428
+ }