@open-mercato/cli 0.6.6-develop.6377.1.d26fed7324 → 0.6.6-develop.6382.1.4b9b9091ab

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 (31) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/agentic/guides/core.md +3 -38
  3. package/dist/agentic/guides/module-system.md +130 -0
  4. package/dist/agentic/shared/AGENTS.md.template +4 -12
  5. package/dist/lib/generators/index.js +2 -0
  6. package/dist/lib/generators/index.js.map +2 -2
  7. package/dist/lib/generators/module-facts-generate.js +52 -0
  8. package/dist/lib/generators/module-facts-generate.js.map +7 -0
  9. package/dist/lib/generators/module-facts.js +734 -0
  10. package/dist/lib/generators/module-facts.js.map +7 -0
  11. package/dist/mercato.js +3 -1
  12. package/dist/mercato.js.map +2 -2
  13. package/package.json +5 -5
  14. package/src/__tests__/mercato.test.ts +5 -0
  15. package/src/lib/generators/__tests__/module-facts.auth-source.test.ts +83 -0
  16. package/src/lib/generators/__tests__/module-facts.bc-guard.test.ts +56 -0
  17. package/src/lib/generators/__tests__/module-facts.customers.fixture.test.ts +69 -0
  18. package/src/lib/generators/__tests__/module-facts.malformed.test.ts +76 -0
  19. package/src/lib/generators/index.ts +1 -0
  20. package/src/lib/generators/module-facts-generate.ts +68 -0
  21. package/src/lib/generators/module-facts.ts +958 -0
  22. package/src/mercato.ts +2 -0
  23. package/dist/agentic/guides/core.auth.md +0 -101
  24. package/dist/agentic/guides/core.catalog.md +0 -79
  25. package/dist/agentic/guides/core.currencies.md +0 -43
  26. package/dist/agentic/guides/core.customer_accounts.md +0 -129
  27. package/dist/agentic/guides/core.customers.md +0 -138
  28. package/dist/agentic/guides/core.data_sync.md +0 -107
  29. package/dist/agentic/guides/core.integrations.md +0 -113
  30. package/dist/agentic/guides/core.sales.md +0 -104
  31. package/dist/agentic/guides/core.workflows.md +0 -152
@@ -0,0 +1,958 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import ts from 'typescript'
4
+ import { toSnake } from '../utils'
5
+
6
+ export interface ModuleEntityFact {
7
+ id: string
8
+ class: string
9
+ table: string
10
+ editable: boolean
11
+ customFields: boolean
12
+ }
13
+
14
+ export interface ApiRouteAuthRule {
15
+ requireAuth?: boolean
16
+ requireFeatures?: string[]
17
+ requireRoles?: string[]
18
+ }
19
+
20
+ export interface ModuleApiRouteFact {
21
+ path: string
22
+ methods: string[]
23
+ auth: Record<string, ApiRouteAuthRule>
24
+ }
25
+
26
+ export interface ModuleEventFact {
27
+ id: string
28
+ label?: string
29
+ category: string | null
30
+ entity: string | null
31
+ }
32
+
33
+ export interface ModuleHostTokens {
34
+ entityIds: string[]
35
+ tableIds: string[]
36
+ }
37
+
38
+ export interface ModuleFacts {
39
+ module: string
40
+ title: string | null
41
+ description: string | null
42
+ coreVersion: string | null
43
+ entities: ModuleEntityFact[]
44
+ events: ModuleEventFact[]
45
+ aclFeatures: string[]
46
+ apiRoutes: ModuleApiRouteFact[]
47
+ diTokens: string[]
48
+ searchEntities: string[]
49
+ hostTokens: ModuleHostTokens
50
+ notifications: string[]
51
+ cli: string[]
52
+ warnings: string[]
53
+ }
54
+
55
+ export interface ExtractModuleFactsOptions {
56
+ moduleId: string
57
+ coreSrcRoot: string
58
+ coreVersion?: string | null
59
+ registryPath?: string | null
60
+ registrySource?: string | null
61
+ }
62
+
63
+ export const MODULE_FACTS_ALLOWLIST = [
64
+ 'auth',
65
+ 'catalog',
66
+ 'currencies',
67
+ 'customer_accounts',
68
+ 'customers',
69
+ 'data_sync',
70
+ 'integrations',
71
+ 'sales',
72
+ 'workflows',
73
+ ] as const
74
+
75
+ export type ModuleFactsModuleId = (typeof MODULE_FACTS_ALLOWLIST)[number]
76
+
77
+ function readSourceFile(filePath: string): ts.SourceFile | null {
78
+ if (!fs.existsSync(filePath)) return null
79
+ const source = fs.readFileSync(filePath, 'utf8')
80
+ const scriptKind = filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
81
+ return ts.createSourceFile(filePath, source, ts.ScriptTarget.ES2020, true, scriptKind)
82
+ }
83
+
84
+ function resolveConventionFile(baseDir: string, basename: string): string | null {
85
+ for (const extension of ['.ts', '.tsx']) {
86
+ const candidate = path.join(baseDir, `${basename}${extension}`)
87
+ if (fs.existsSync(candidate)) return candidate
88
+ }
89
+ return null
90
+ }
91
+
92
+ function readStringPropertyInitializer(
93
+ objectLiteral: ts.ObjectLiteralExpression,
94
+ propertyName: string,
95
+ ): string | undefined {
96
+ for (const property of objectLiteral.properties) {
97
+ if (!ts.isPropertyAssignment(property)) continue
98
+ const name = ts.isIdentifier(property.name)
99
+ ? property.name.text
100
+ : ts.isStringLiteralLike(property.name)
101
+ ? property.name.text
102
+ : undefined
103
+ if (name !== propertyName) continue
104
+ if (ts.isStringLiteralLike(property.initializer)) return property.initializer.text
105
+ return undefined
106
+ }
107
+ return undefined
108
+ }
109
+
110
+ function getClassDecoratorCall(node: ts.ClassDeclaration, decoratorName: string): ts.CallExpression | undefined {
111
+ const decorators = ts.canHaveDecorators(node) ? ts.getDecorators(node) ?? [] : []
112
+ for (const decorator of decorators) {
113
+ const expression = decorator.expression
114
+ if (!ts.isCallExpression(expression)) continue
115
+ if (ts.isIdentifier(expression.expression) && expression.expression.text === decoratorName) {
116
+ return expression
117
+ }
118
+ }
119
+ return undefined
120
+ }
121
+
122
+ function readDecoratorTableName(decoratorCall: ts.CallExpression): string | undefined {
123
+ const firstArgument = decoratorCall.arguments[0]
124
+ if (!firstArgument || !ts.isObjectLiteralExpression(firstArgument)) return undefined
125
+ return readStringPropertyInitializer(firstArgument, 'tableName')
126
+ }
127
+
128
+ function getPropertyDecoratorName(member: ts.PropertyDeclaration): string | undefined {
129
+ const decorators = ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []
130
+ for (const decorator of decorators) {
131
+ const expression = decorator.expression
132
+ if (!ts.isCallExpression(expression)) continue
133
+ const firstArgument = expression.arguments[0]
134
+ if (!firstArgument || !ts.isObjectLiteralExpression(firstArgument)) continue
135
+ const columnName = readStringPropertyInitializer(firstArgument, 'name')
136
+ if (columnName) return columnName
137
+ }
138
+ return undefined
139
+ }
140
+
141
+ function classHasUpdatedAtColumn(node: ts.ClassDeclaration): boolean {
142
+ for (const member of node.members) {
143
+ if (!ts.isPropertyDeclaration(member) || !member.name) continue
144
+ const propertyName = ts.isIdentifier(member.name)
145
+ ? member.name.text
146
+ : ts.isStringLiteralLike(member.name)
147
+ ? member.name.text
148
+ : undefined
149
+ if (propertyName === 'updatedAt') return true
150
+ if (getPropertyDecoratorName(member) === 'updated_at') return true
151
+ }
152
+ return false
153
+ }
154
+
155
+ function collectCustomFieldEntityIds(ceFilePath: string | null): Set<string> {
156
+ const result = new Set<string>()
157
+ if (!ceFilePath) return result
158
+ const sourceFile = readSourceFile(ceFilePath)
159
+ if (!sourceFile) return result
160
+
161
+ const visit = (node: ts.Node): void => {
162
+ if (ts.isObjectLiteralExpression(node)) {
163
+ const id = readStringPropertyInitializer(node, 'id')
164
+ if (id && id.includes(':')) result.add(id)
165
+ }
166
+ node.forEachChild(visit)
167
+ }
168
+ sourceFile.forEachChild(visit)
169
+ return result
170
+ }
171
+
172
+ function extractEntities(
173
+ moduleId: string,
174
+ entitiesFilePath: string | null,
175
+ customFieldEntityIds: Set<string>,
176
+ ): ModuleEntityFact[] {
177
+ const sourceFile = entitiesFilePath ? readSourceFile(entitiesFilePath) : null
178
+ if (!sourceFile) return []
179
+
180
+ const facts: ModuleEntityFact[] = []
181
+ sourceFile.forEachChild((node) => {
182
+ if (!ts.isClassDeclaration(node) || !node.name) return
183
+ const isExported = node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
184
+ if (!isExported) return
185
+ const entityDecorator = getClassDecoratorCall(node, 'Entity')
186
+ if (!entityDecorator) return
187
+
188
+ const className = node.name.text
189
+ const table = readDecoratorTableName(entityDecorator)
190
+ if (!table) return
191
+
192
+ const entityId = `${moduleId}:${toSnake(className)}`
193
+ facts.push({
194
+ id: entityId,
195
+ class: className,
196
+ table,
197
+ editable: classHasUpdatedAtColumn(node),
198
+ customFields: customFieldEntityIds.has(entityId),
199
+ })
200
+ })
201
+
202
+ return facts
203
+ }
204
+
205
+ function unwrapExpression(expression: ts.Expression): ts.Expression {
206
+ let current = expression
207
+ while (ts.isAsExpression(current) || ts.isParenthesizedExpression(current) || ts.isTypeAssertionExpression(current)) {
208
+ current = current.expression
209
+ }
210
+ return current
211
+ }
212
+
213
+ function unwrapArrayLiteral(expression: ts.Expression): ts.ArrayLiteralExpression | null {
214
+ const current = unwrapExpression(expression)
215
+ return ts.isArrayLiteralExpression(current) ? current : null
216
+ }
217
+
218
+ function getPropertyName(property: ts.ObjectLiteralElementLike): string | undefined {
219
+ if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) return undefined
220
+ const name = property.name
221
+ if (!name) return undefined
222
+ if (ts.isIdentifier(name)) return name.text
223
+ if (ts.isStringLiteralLike(name)) return name.text
224
+ return undefined
225
+ }
226
+
227
+ function getObjectPropertyInitializer(
228
+ objectLiteral: ts.ObjectLiteralExpression,
229
+ propertyName: string,
230
+ ): ts.Expression | undefined {
231
+ for (const property of objectLiteral.properties) {
232
+ if (!ts.isPropertyAssignment(property)) continue
233
+ if (getPropertyName(property) === propertyName) return property.initializer
234
+ }
235
+ return undefined
236
+ }
237
+
238
+ function findObjectLiteralDeclaration(
239
+ sourceFile: ts.SourceFile,
240
+ variableName: string,
241
+ ): ts.ObjectLiteralExpression | null {
242
+ let result: ts.ObjectLiteralExpression | null = null
243
+ const visit = (node: ts.Node): void => {
244
+ if (result) return
245
+ if (
246
+ ts.isVariableDeclaration(node) &&
247
+ ts.isIdentifier(node.name) &&
248
+ node.name.text === variableName &&
249
+ node.initializer
250
+ ) {
251
+ const unwrapped = unwrapExpression(node.initializer)
252
+ if (ts.isObjectLiteralExpression(unwrapped)) {
253
+ result = unwrapped
254
+ return
255
+ }
256
+ }
257
+ node.forEachChild(visit)
258
+ }
259
+ sourceFile.forEachChild(visit)
260
+ return result
261
+ }
262
+
263
+ function buildVariableInitializerMap(sourceFile: ts.SourceFile): Map<string, ts.Expression> {
264
+ const initializers = new Map<string, ts.Expression>()
265
+ const visit = (node: ts.Node): void => {
266
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
267
+ if (!initializers.has(node.name.text)) initializers.set(node.name.text, node.initializer)
268
+ }
269
+ node.forEachChild(visit)
270
+ }
271
+ sourceFile.forEachChild(visit)
272
+ return initializers
273
+ }
274
+
275
+ function resolveToObjectLiteral(
276
+ expression: ts.Expression,
277
+ initializers: Map<string, ts.Expression>,
278
+ ): ts.ObjectLiteralExpression | null {
279
+ const unwrapped = unwrapExpression(expression)
280
+ if (ts.isObjectLiteralExpression(unwrapped)) return unwrapped
281
+ if (ts.isIdentifier(unwrapped)) {
282
+ const referenced = initializers.get(unwrapped.text)
283
+ if (referenced) return resolveToObjectLiteral(referenced, initializers)
284
+ }
285
+ return null
286
+ }
287
+
288
+ function resolveToArrayLiteral(
289
+ expression: ts.Expression,
290
+ initializers: Map<string, ts.Expression>,
291
+ ): ts.ArrayLiteralExpression | null {
292
+ const unwrapped = unwrapExpression(expression)
293
+ if (ts.isArrayLiteralExpression(unwrapped)) return unwrapped
294
+ if (ts.isIdentifier(unwrapped)) {
295
+ const referenced = initializers.get(unwrapped.text)
296
+ if (referenced) return resolveToArrayLiteral(referenced, initializers)
297
+ }
298
+ return null
299
+ }
300
+
301
+ function findDefaultExportExpression(sourceFile: ts.SourceFile): ts.Expression | null {
302
+ for (const statement of sourceFile.statements) {
303
+ if (ts.isExportAssignment(statement) && !statement.isExportEquals) return statement.expression
304
+ }
305
+ return null
306
+ }
307
+
308
+ function findArrayLiteralDeclaration(
309
+ sourceFile: ts.SourceFile,
310
+ variableName: string,
311
+ ): ts.ArrayLiteralExpression | null {
312
+ let result: ts.ArrayLiteralExpression | null = null
313
+ const visit = (node: ts.Node): void => {
314
+ if (result) return
315
+ if (
316
+ ts.isVariableDeclaration(node) &&
317
+ ts.isIdentifier(node.name) &&
318
+ node.name.text === variableName &&
319
+ node.initializer
320
+ ) {
321
+ const arrayLiteral = unwrapArrayLiteral(node.initializer)
322
+ if (arrayLiteral) {
323
+ result = arrayLiteral
324
+ return
325
+ }
326
+ }
327
+ node.forEachChild(visit)
328
+ }
329
+ sourceFile.forEachChild(visit)
330
+ return result
331
+ }
332
+
333
+ function extractEvents(eventsFilePath: string | null): ModuleEventFact[] {
334
+ const sourceFile = eventsFilePath ? readSourceFile(eventsFilePath) : null
335
+ if (!sourceFile) return []
336
+
337
+ const eventsArray = findArrayLiteralDeclaration(sourceFile, 'events')
338
+ if (!eventsArray) return []
339
+
340
+ const facts: ModuleEventFact[] = []
341
+ for (const element of eventsArray.elements) {
342
+ if (!ts.isObjectLiteralExpression(element)) continue
343
+ const id = readStringPropertyInitializer(element, 'id')
344
+ if (!id) continue
345
+ const label = readStringPropertyInitializer(element, 'label')
346
+ const category = readStringPropertyInitializer(element, 'category')
347
+ const entity = readStringPropertyInitializer(element, 'entity')
348
+ const fact: ModuleEventFact = {
349
+ id,
350
+ category: category ?? null,
351
+ entity: entity ?? null,
352
+ }
353
+ if (label !== undefined) fact.label = label
354
+ facts.push(fact)
355
+ }
356
+
357
+ return facts
358
+ }
359
+
360
+ function extractAclFeatures(aclFilePath: string | null): string[] {
361
+ const sourceFile = aclFilePath ? readSourceFile(aclFilePath) : null
362
+ if (!sourceFile) return []
363
+
364
+ const featuresArray = findArrayLiteralDeclaration(sourceFile, 'features')
365
+ if (!featuresArray) return []
366
+
367
+ const featureIds: string[] = []
368
+ const seen = new Set<string>()
369
+ for (const element of featuresArray.elements) {
370
+ let featureId: string | undefined
371
+ if (ts.isObjectLiteralExpression(element)) {
372
+ featureId = readStringPropertyInitializer(element, 'id')
373
+ } else if (ts.isStringLiteralLike(element)) {
374
+ featureId = element.text
375
+ }
376
+ if (!featureId || seen.has(featureId)) continue
377
+ seen.add(featureId)
378
+ featureIds.push(featureId)
379
+ }
380
+
381
+ return featureIds
382
+ }
383
+
384
+ function readBooleanPropertyInitializer(
385
+ objectLiteral: ts.ObjectLiteralExpression,
386
+ propertyName: string,
387
+ ): boolean | undefined {
388
+ const initializer = getObjectPropertyInitializer(objectLiteral, propertyName)
389
+ if (!initializer) return undefined
390
+ if (initializer.kind === ts.SyntaxKind.TrueKeyword) return true
391
+ if (initializer.kind === ts.SyntaxKind.FalseKeyword) return false
392
+ return undefined
393
+ }
394
+
395
+ function readStringArrayPropertyInitializer(
396
+ objectLiteral: ts.ObjectLiteralExpression,
397
+ propertyName: string,
398
+ ): string[] | undefined {
399
+ const initializer = getObjectPropertyInitializer(objectLiteral, propertyName)
400
+ if (!initializer) return undefined
401
+ const arrayLiteral = unwrapArrayLiteral(initializer)
402
+ if (!arrayLiteral) return undefined
403
+ const values: string[] = []
404
+ for (const element of arrayLiteral.elements) {
405
+ if (ts.isStringLiteralLike(element)) values.push(element.text)
406
+ }
407
+ return values
408
+ }
409
+
410
+ function parseApiRouteAuthRule(metadataLiteral: ts.ObjectLiteralExpression): ApiRouteAuthRule {
411
+ const rule: ApiRouteAuthRule = {}
412
+ const requireAuth = readBooleanPropertyInitializer(metadataLiteral, 'requireAuth')
413
+ if (requireAuth !== undefined) rule.requireAuth = requireAuth
414
+ const requireFeatures = readStringArrayPropertyInitializer(metadataLiteral, 'requireFeatures')
415
+ if (requireFeatures && requireFeatures.length > 0) rule.requireFeatures = requireFeatures
416
+ const requireRoles = readStringArrayPropertyInitializer(metadataLiteral, 'requireRoles')
417
+ if (requireRoles && requireRoles.length > 0) rule.requireRoles = requireRoles
418
+ return rule
419
+ }
420
+
421
+ function parseApiRouteEntry(entryLiteral: ts.ObjectLiteralExpression): ModuleApiRouteFact | null {
422
+ const routePath = readStringPropertyInitializer(entryLiteral, 'path')
423
+ if (!routePath) return null
424
+
425
+ const methods: string[] = []
426
+ const seenMethods = new Set<string>()
427
+ const handlersInitializer = getObjectPropertyInitializer(entryLiteral, 'handlers')
428
+ if (handlersInitializer && ts.isObjectLiteralExpression(handlersInitializer)) {
429
+ for (const property of handlersInitializer.properties) {
430
+ const methodName = getPropertyName(property)
431
+ if (methodName && !seenMethods.has(methodName)) {
432
+ seenMethods.add(methodName)
433
+ methods.push(methodName)
434
+ }
435
+ }
436
+ } else {
437
+ const singleMethod = readStringPropertyInitializer(entryLiteral, 'method')
438
+ if (singleMethod && !seenMethods.has(singleMethod)) {
439
+ seenMethods.add(singleMethod)
440
+ methods.push(singleMethod)
441
+ }
442
+ }
443
+
444
+ const auth: Record<string, ApiRouteAuthRule> = {}
445
+ const metadataInitializer = getObjectPropertyInitializer(entryLiteral, 'metadata')
446
+ if (metadataInitializer && ts.isObjectLiteralExpression(metadataInitializer)) {
447
+ for (const property of metadataInitializer.properties) {
448
+ if (!ts.isPropertyAssignment(property)) continue
449
+ const methodName = getPropertyName(property)
450
+ if (!methodName) continue
451
+ const methodMetadata = unwrapExpression(property.initializer)
452
+ if (!ts.isObjectLiteralExpression(methodMetadata)) continue
453
+ auth[methodName] = parseApiRouteAuthRule(methodMetadata)
454
+ if (!seenMethods.has(methodName)) {
455
+ seenMethods.add(methodName)
456
+ methods.push(methodName)
457
+ }
458
+ }
459
+ }
460
+
461
+ return { path: routePath, methods, auth }
462
+ }
463
+
464
+ function extractApiRoutes(
465
+ moduleId: string,
466
+ registrySource: string | null,
467
+ registryDescription: string,
468
+ warnings: string[],
469
+ ): ModuleApiRouteFact[] {
470
+ if (registrySource == null) {
471
+ warnings.push(`[module-facts] module registry unavailable (${registryDescription}); API route auth omitted for ${moduleId}`)
472
+ return []
473
+ }
474
+
475
+ const sourceFile = ts.createSourceFile(
476
+ 'module-registry.generated.ts',
477
+ registrySource,
478
+ ts.ScriptTarget.ES2020,
479
+ true,
480
+ ts.ScriptKind.TS,
481
+ )
482
+
483
+ const routes: ModuleApiRouteFact[] = []
484
+ const seenPaths = new Set<string>()
485
+ const visit = (node: ts.Node): void => {
486
+ if (ts.isObjectLiteralExpression(node) && readStringPropertyInitializer(node, 'id') === moduleId) {
487
+ const apisInitializer = getObjectPropertyInitializer(node, 'apis')
488
+ const apisArray = apisInitializer ? unwrapArrayLiteral(apisInitializer) : null
489
+ if (apisArray) {
490
+ for (const element of apisArray.elements) {
491
+ if (!ts.isObjectLiteralExpression(element)) continue
492
+ const route = parseApiRouteEntry(element)
493
+ if (route && !seenPaths.has(route.path)) {
494
+ seenPaths.add(route.path)
495
+ routes.push(route)
496
+ }
497
+ }
498
+ }
499
+ }
500
+ node.forEachChild(visit)
501
+ }
502
+ sourceFile.forEachChild(visit)
503
+ return routes
504
+ }
505
+
506
+ function resolveRegistrySource(options: ExtractModuleFactsOptions): { source: string | null; description: string } {
507
+ if (typeof options.registrySource === 'string') {
508
+ return { source: options.registrySource, description: 'registrySource' }
509
+ }
510
+ if (options.registryPath) {
511
+ if (!fs.existsSync(options.registryPath)) {
512
+ return { source: null, description: options.registryPath }
513
+ }
514
+ return { source: fs.readFileSync(options.registryPath, 'utf8'), description: options.registryPath }
515
+ }
516
+ return { source: null, description: 'registryPath not provided' }
517
+ }
518
+
519
+ function detectAwilixRegistrationKind(expression: ts.Expression): string | null {
520
+ let current: ts.Expression = unwrapExpression(expression)
521
+ while (ts.isCallExpression(current)) {
522
+ const callee = current.expression
523
+ if (ts.isIdentifier(callee)) return callee.text
524
+ if (ts.isPropertyAccessExpression(callee)) {
525
+ current = callee.expression
526
+ continue
527
+ }
528
+ break
529
+ }
530
+ return null
531
+ }
532
+
533
+ function extractDiTokens(diFilePath: string | null): string[] {
534
+ if (!diFilePath) return []
535
+ const sourceFile = readSourceFile(diFilePath)
536
+ if (!sourceFile) return []
537
+
538
+ const tokens: string[] = []
539
+ const seen = new Set<string>()
540
+ const visit = (node: ts.Node): void => {
541
+ if (
542
+ ts.isCallExpression(node) &&
543
+ ts.isPropertyAccessExpression(node.expression) &&
544
+ node.expression.name.text === 'register'
545
+ ) {
546
+ const argument = node.arguments[0]
547
+ if (argument && ts.isObjectLiteralExpression(argument)) {
548
+ for (const property of argument.properties) {
549
+ if (!ts.isPropertyAssignment(property)) continue
550
+ const kind = detectAwilixRegistrationKind(property.initializer)
551
+ if (kind !== 'asFunction' && kind !== 'asClass') continue
552
+ const tokenName = getPropertyName(property)
553
+ if (tokenName && !seen.has(tokenName)) {
554
+ seen.add(tokenName)
555
+ tokens.push(tokenName)
556
+ }
557
+ }
558
+ }
559
+ }
560
+ node.forEachChild(visit)
561
+ }
562
+ sourceFile.forEachChild(visit)
563
+ return tokens
564
+ }
565
+
566
+ function extractSearchEntities(searchFilePath: string | null, warnings: string[]): string[] {
567
+ if (!searchFilePath) return []
568
+ const sourceFile = readSourceFile(searchFilePath)
569
+ if (!sourceFile) return []
570
+
571
+ const searchConfig = findObjectLiteralDeclaration(sourceFile, 'searchConfig')
572
+ if (!searchConfig) {
573
+ warnings.push(`[module-facts] search.ts present but no searchConfig object literal: ${searchFilePath}`)
574
+ return []
575
+ }
576
+ const entitiesInitializer = getObjectPropertyInitializer(searchConfig, 'entities')
577
+ const entitiesArray = entitiesInitializer ? unwrapArrayLiteral(entitiesInitializer) : null
578
+ if (!entitiesArray) {
579
+ warnings.push(`[module-facts] searchConfig.entities is not an array literal: ${searchFilePath}`)
580
+ return []
581
+ }
582
+
583
+ const entityIds: string[] = []
584
+ const seen = new Set<string>()
585
+ for (const element of entitiesArray.elements) {
586
+ if (!ts.isObjectLiteralExpression(element)) continue
587
+ const entityId = readStringPropertyInitializer(element, 'entityId')
588
+ if (entityId && !seen.has(entityId)) {
589
+ seen.add(entityId)
590
+ entityIds.push(entityId)
591
+ }
592
+ }
593
+ return entityIds
594
+ }
595
+
596
+ function extractNotifications(notificationsFilePath: string | null, warnings: string[]): string[] {
597
+ if (!notificationsFilePath) return []
598
+ const sourceFile = readSourceFile(notificationsFilePath)
599
+ if (!sourceFile) return []
600
+
601
+ const notificationsArray =
602
+ findArrayLiteralDeclaration(sourceFile, 'notificationTypes') ??
603
+ findArrayLiteralDeclaration(sourceFile, 'notifications')
604
+ if (!notificationsArray) {
605
+ warnings.push(`[module-facts] notifications.ts present but no notificationTypes array literal: ${notificationsFilePath}`)
606
+ return []
607
+ }
608
+
609
+ const notificationIds: string[] = []
610
+ const seen = new Set<string>()
611
+ for (const element of notificationsArray.elements) {
612
+ if (!ts.isObjectLiteralExpression(element)) continue
613
+ const notificationId = readStringPropertyInitializer(element, 'type')
614
+ if (notificationId && !seen.has(notificationId)) {
615
+ seen.add(notificationId)
616
+ notificationIds.push(notificationId)
617
+ }
618
+ }
619
+ return notificationIds
620
+ }
621
+
622
+ function extractCli(cliFilePath: string | null, warnings: string[]): string[] {
623
+ if (!cliFilePath) return []
624
+ const sourceFile = readSourceFile(cliFilePath)
625
+ if (!sourceFile) return []
626
+
627
+ const defaultExport = findDefaultExportExpression(sourceFile)
628
+ if (!defaultExport) {
629
+ warnings.push(`[module-facts] cli.ts present but no default export: ${cliFilePath}`)
630
+ return []
631
+ }
632
+
633
+ const initializers = buildVariableInitializerMap(sourceFile)
634
+ const collectCommand = (objectLiteral: ts.ObjectLiteralExpression): string | undefined =>
635
+ readStringPropertyInitializer(objectLiteral, 'command')
636
+
637
+ const commands: string[] = []
638
+ const seen = new Set<string>()
639
+ const pushCommand = (command: string | undefined): void => {
640
+ if (command && !seen.has(command)) {
641
+ seen.add(command)
642
+ commands.push(command)
643
+ }
644
+ }
645
+
646
+ const arrayLiteral = resolveToArrayLiteral(defaultExport, initializers)
647
+ if (arrayLiteral) {
648
+ for (const element of arrayLiteral.elements) {
649
+ const objectLiteral = resolveToObjectLiteral(element, initializers)
650
+ if (objectLiteral) pushCommand(collectCommand(objectLiteral))
651
+ }
652
+ return commands
653
+ }
654
+
655
+ const singleObject = resolveToObjectLiteral(defaultExport, initializers)
656
+ if (singleObject) {
657
+ pushCommand(collectCommand(singleObject))
658
+ return commands
659
+ }
660
+
661
+ warnings.push(`[module-facts] cli.ts default export is neither an array nor an object literal: ${cliFilePath}`)
662
+ return commands
663
+ }
664
+
665
+ function listSourceFilesRecursive(directory: string): string[] {
666
+ const files: string[] = []
667
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
668
+ if (entry.name === '__tests__' || entry.name === 'node_modules') continue
669
+ const fullPath = path.join(directory, entry.name)
670
+ if (entry.isDirectory()) {
671
+ files.push(...listSourceFilesRecursive(fullPath))
672
+ } else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
673
+ files.push(fullPath)
674
+ }
675
+ }
676
+ return files
677
+ }
678
+
679
+ function extractTableIds(backendDir: string): string[] {
680
+ if (!fs.existsSync(backendDir)) return []
681
+
682
+ const tableIds: string[] = []
683
+ const seen = new Set<string>()
684
+ for (const filePath of listSourceFilesRecursive(backendDir)) {
685
+ const sourceFile = readSourceFile(filePath)
686
+ if (!sourceFile) continue
687
+ const visit = (node: ts.Node): void => {
688
+ if (ts.isPropertyAssignment(node)) {
689
+ const propertyName = getPropertyName(node)
690
+ if (
691
+ (propertyName === 'tableId' || propertyName === 'extensionTableId') &&
692
+ ts.isStringLiteralLike(node.initializer)
693
+ ) {
694
+ const value = node.initializer.text
695
+ if (value && !seen.has(value)) {
696
+ seen.add(value)
697
+ tableIds.push(value)
698
+ }
699
+ }
700
+ }
701
+ node.forEachChild(visit)
702
+ }
703
+ sourceFile.forEachChild(visit)
704
+ }
705
+ tableIds.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
706
+ return tableIds
707
+ }
708
+
709
+ function extractHostEntityIds(entities: ModuleEntityFact[]): string[] {
710
+ return entities.filter((entity) => entity.id.endsWith('_entity')).map((entity) => entity.id)
711
+ }
712
+
713
+ function extractModuleMeta(indexFilePath: string | null): { title: string | null; description: string | null } {
714
+ if (!indexFilePath) return { title: null, description: null }
715
+ const sourceFile = readSourceFile(indexFilePath)
716
+ if (!sourceFile) return { title: null, description: null }
717
+ const metadata = findObjectLiteralDeclaration(sourceFile, 'metadata')
718
+ if (!metadata) return { title: null, description: null }
719
+ return {
720
+ title: readStringPropertyInitializer(metadata, 'title') ?? null,
721
+ description: readStringPropertyInitializer(metadata, 'description') ?? null,
722
+ }
723
+ }
724
+
725
+ export function extractModuleFacts(options: ExtractModuleFactsOptions): ModuleFacts {
726
+ const { moduleId, coreSrcRoot, coreVersion = null } = options
727
+ const moduleRoot = path.join(coreSrcRoot, moduleId)
728
+
729
+ const entitiesFilePath =
730
+ resolveConventionFile(path.join(moduleRoot, 'data'), 'entities') ??
731
+ resolveConventionFile(path.join(moduleRoot, 'db'), 'entities') ??
732
+ resolveConventionFile(path.join(moduleRoot, 'data'), 'schema')
733
+ const ceFilePath = resolveConventionFile(moduleRoot, 'ce')
734
+ const eventsFilePath = resolveConventionFile(moduleRoot, 'events')
735
+ const aclFilePath = resolveConventionFile(moduleRoot, 'acl')
736
+ const diFilePath = resolveConventionFile(moduleRoot, 'di')
737
+ const searchFilePath = resolveConventionFile(moduleRoot, 'search')
738
+ const notificationsFilePath = resolveConventionFile(moduleRoot, 'notifications')
739
+ const cliFilePath = resolveConventionFile(moduleRoot, 'cli')
740
+ const indexFilePath = resolveConventionFile(moduleRoot, 'index')
741
+ const backendDir = path.join(moduleRoot, 'backend')
742
+
743
+ const warnings: string[] = []
744
+ const { title, description } = extractModuleMeta(indexFilePath)
745
+ const customFieldEntityIds = collectCustomFieldEntityIds(ceFilePath)
746
+ const entities = extractEntities(moduleId, entitiesFilePath, customFieldEntityIds)
747
+ const events = extractEvents(eventsFilePath)
748
+ const aclFeatures = extractAclFeatures(aclFilePath)
749
+
750
+ const { source: registrySource, description: registryDescription } = resolveRegistrySource(options)
751
+ const apiRoutes = extractApiRoutes(moduleId, registrySource, registryDescription, warnings)
752
+ const diTokens = extractDiTokens(diFilePath)
753
+ const searchEntities = extractSearchEntities(searchFilePath, warnings)
754
+ const notifications = extractNotifications(notificationsFilePath, warnings)
755
+ const cli = extractCli(cliFilePath, warnings)
756
+ const hostTokens: ModuleHostTokens = {
757
+ entityIds: extractHostEntityIds(entities),
758
+ tableIds: extractTableIds(backendDir),
759
+ }
760
+
761
+ return {
762
+ module: moduleId,
763
+ title,
764
+ description,
765
+ coreVersion,
766
+ entities,
767
+ events,
768
+ aclFeatures,
769
+ apiRoutes,
770
+ diTokens,
771
+ searchEntities,
772
+ hostTokens,
773
+ notifications,
774
+ cli,
775
+ warnings,
776
+ }
777
+ }
778
+
779
+ export interface ModuleFactsJsonEvent {
780
+ id: string
781
+ category: string | null
782
+ entity: string | null
783
+ }
784
+
785
+ export interface ModuleFactsJsonEntry {
786
+ title: string | null
787
+ description: string | null
788
+ coreVersion: string | null
789
+ entities: ModuleEntityFact[]
790
+ events: ModuleFactsJsonEvent[]
791
+ aclFeatures: string[]
792
+ apiRoutes: ModuleApiRouteFact[]
793
+ diTokens: string[]
794
+ searchEntities: string[]
795
+ hostTokens: ModuleHostTokens
796
+ notifications: string[]
797
+ cli: string[]
798
+ }
799
+
800
+ const EMPTY_SECTION_MARKER = '_none_'
801
+
802
+ function renderVersionStamp(coreVersion: string | null): string {
803
+ const version = coreVersion && coreVersion.length > 0 ? coreVersion : '<unknown>'
804
+ return `<!-- generated from @open-mercato/core ${version} — R1 staleness stamp -->`
805
+ }
806
+
807
+ function renderEntitiesSection(entities: ModuleEntityFact[]): string {
808
+ if (entities.length === 0) return `## Entities\n\n${EMPTY_SECTION_MARKER}`
809
+ const header = '| Entity ID | Class | Table | Editable | CustomFields |'
810
+ const divider = '|---|---|---|---|---|'
811
+ const rows = entities.map(
812
+ (entity) =>
813
+ `| ${entity.id} | ${entity.class} | ${entity.table} | ${entity.editable ? 'yes' : 'no'} | ${entity.customFields ? 'yes' : 'no'} |`,
814
+ )
815
+ return ['## Entities', '', header, divider, ...rows].join('\n')
816
+ }
817
+
818
+ function renderEventsSection(events: ModuleEventFact[]): string {
819
+ const heading = `## Events (${events.length})`
820
+ if (events.length === 0) return `${heading}\n\n${EMPTY_SECTION_MARKER}`
821
+ const header = '| ID | Category | Entity |'
822
+ const divider = '|---|---|---|'
823
+ const rows = events.map((event) => `| ${event.id} | ${event.category ?? '—'} | ${event.entity ?? '—'} |`)
824
+ return [heading, '', header, divider, ...rows].join('\n')
825
+ }
826
+
827
+ function renderInlineListSection(heading: string, values: string[]): string {
828
+ if (values.length === 0) return `${heading}\n\n${EMPTY_SECTION_MARKER}`
829
+ return `${heading}\n\n${values.join(' · ')}`
830
+ }
831
+
832
+ function describeAuthRule(rule: ApiRouteAuthRule | undefined): string {
833
+ if (!rule) return 'public'
834
+ if (rule.requireFeatures && rule.requireFeatures.length > 0) return rule.requireFeatures.join(', ')
835
+ if (rule.requireRoles && rule.requireRoles.length > 0) return rule.requireRoles.join(', ')
836
+ if (rule.requireAuth) return 'auth'
837
+ return 'public'
838
+ }
839
+
840
+ function renderApiRouteAuthCell(route: ModuleApiRouteFact): string {
841
+ if (route.methods.length === 0) return '—'
842
+ const groups: Array<{ label: string; methods: string[] }> = []
843
+ for (const method of route.methods) {
844
+ const label = describeAuthRule(route.auth[method])
845
+ const existing = groups.find((group) => group.label === label)
846
+ if (existing) existing.methods.push(method)
847
+ else groups.push({ label, methods: [method] })
848
+ }
849
+ return groups.map((group) => `${group.methods.join('/')} → ${group.label}`).join(' · ')
850
+ }
851
+
852
+ function renderApiRoutesSection(routes: ModuleApiRouteFact[]): string {
853
+ if (routes.length === 0) return `## API routes\n\n${EMPTY_SECTION_MARKER}`
854
+ const header = '| Path | Methods | Auth (per-method requireFeatures) |'
855
+ const divider = '|---|---|---|'
856
+ const rows = routes.map(
857
+ (route) => `| ${route.path} | ${route.methods.join(' ')} | ${renderApiRouteAuthCell(route)} |`,
858
+ )
859
+ return ['## API routes', '', header, divider, ...rows].join('\n')
860
+ }
861
+
862
+ function renderHostTokensSection(hostTokens: ModuleHostTokens): string {
863
+ const entityIdsLine = hostTokens.entityIds.length > 0 ? hostTokens.entityIds.join(' · ') : EMPTY_SECTION_MARKER
864
+ const tableIdsLine = hostTokens.tableIds.length > 0 ? hostTokens.tableIds.join(' · ') : EMPTY_SECTION_MARKER
865
+ return ['## Host extension points', '', `- Entity IDs: ${entityIdsLine}`, `- Table IDs: ${tableIdsLine}`].join('\n')
866
+ }
867
+
868
+ export function renderModuleFactsMarkdown(facts: ModuleFacts): string {
869
+ const sections = [
870
+ `# ${facts.module} — module facts (generated, do not edit)`,
871
+ renderVersionStamp(facts.coreVersion),
872
+ '',
873
+ renderEntitiesSection(facts.entities),
874
+ '',
875
+ renderEventsSection(facts.events),
876
+ '',
877
+ renderInlineListSection(`## ACL features (${facts.aclFeatures.length})`, facts.aclFeatures),
878
+ '',
879
+ renderApiRoutesSection(facts.apiRoutes),
880
+ '',
881
+ renderInlineListSection('## DI service tokens', facts.diTokens),
882
+ '',
883
+ renderInlineListSection('## Search entities', facts.searchEntities),
884
+ '',
885
+ renderHostTokensSection(facts.hostTokens),
886
+ '',
887
+ renderInlineListSection('## Notifications', facts.notifications),
888
+ '',
889
+ renderInlineListSection('## CLI', facts.cli),
890
+ '',
891
+ ]
892
+ return sections.join('\n')
893
+ }
894
+
895
+ export function toModuleFactsJsonEntry(facts: ModuleFacts): ModuleFactsJsonEntry {
896
+ return {
897
+ title: facts.title,
898
+ description: facts.description,
899
+ coreVersion: facts.coreVersion,
900
+ entities: facts.entities,
901
+ events: facts.events.map((event) => ({ id: event.id, category: event.category, entity: event.entity })),
902
+ aclFeatures: facts.aclFeatures,
903
+ apiRoutes: facts.apiRoutes,
904
+ diTokens: facts.diTokens,
905
+ searchEntities: facts.searchEntities,
906
+ hostTokens: facts.hostTokens,
907
+ notifications: facts.notifications,
908
+ cli: facts.cli,
909
+ }
910
+ }
911
+
912
+ export function buildModuleFactsJsonObject(
913
+ factsByModule: Record<string, ModuleFacts>,
914
+ ): Record<string, ModuleFactsJsonEntry> {
915
+ const result: Record<string, ModuleFactsJsonEntry> = {}
916
+ for (const moduleId of Object.keys(factsByModule).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))) {
917
+ result[moduleId] = toModuleFactsJsonEntry(factsByModule[moduleId])
918
+ }
919
+ return result
920
+ }
921
+
922
+ export function renderModuleFactsJson(factsByModule: Record<string, ModuleFacts>): string {
923
+ return `${JSON.stringify(buildModuleFactsJsonObject(factsByModule), null, 2)}\n`
924
+ }
925
+
926
+ export interface ExtractAllModuleFactsOptions {
927
+ coreSrcRoot: string
928
+ registryPath?: string | null
929
+ registrySource?: string | null
930
+ coreVersion?: string | null
931
+ moduleIds?: readonly string[]
932
+ }
933
+
934
+ export interface ExtractAllModuleFactsResult {
935
+ factsByModule: Record<string, ModuleFacts>
936
+ markdownByModule: Record<string, string>
937
+ warnings: string[]
938
+ }
939
+
940
+ export function extractAllModuleFacts(options: ExtractAllModuleFactsOptions): ExtractAllModuleFactsResult {
941
+ const moduleIds = options.moduleIds ?? MODULE_FACTS_ALLOWLIST
942
+ const factsByModule: Record<string, ModuleFacts> = {}
943
+ const markdownByModule: Record<string, string> = {}
944
+ const warnings: string[] = []
945
+ for (const moduleId of moduleIds) {
946
+ const facts = extractModuleFacts({
947
+ moduleId,
948
+ coreSrcRoot: options.coreSrcRoot,
949
+ coreVersion: options.coreVersion ?? null,
950
+ registryPath: options.registryPath ?? null,
951
+ registrySource: options.registrySource ?? null,
952
+ })
953
+ factsByModule[moduleId] = facts
954
+ markdownByModule[moduleId] = renderModuleFactsMarkdown(facts)
955
+ warnings.push(...facts.warnings)
956
+ }
957
+ return { factsByModule, markdownByModule, warnings }
958
+ }