@open-mercato/cli 0.7.0 → 0.7.1-develop.7103.1.41ff100d93

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 (184) hide show
  1. package/.turbo/turbo-build.log +7 -1
  2. package/dist/agentic/guides/module-facts.json +5509 -599
  3. package/dist/agentic/guides/module-facts.v2.json +5595 -599
  4. package/dist/agentic/guides/modules/ai_assistant/index.md +1 -1
  5. package/dist/agentic/guides/modules/api_docs/index.md +1 -1
  6. package/dist/agentic/guides/modules/api_keys/di-registrations-rich.md +11 -0
  7. package/dist/agentic/guides/modules/api_keys/di-service-tokens.md +11 -0
  8. package/dist/agentic/guides/modules/api_keys/exact-override-targets.md +1 -0
  9. package/dist/agentic/guides/modules/api_keys/index.md +4 -2
  10. package/dist/agentic/guides/modules/attachments/di-registrations-rich.md +6 -5
  11. package/dist/agentic/guides/modules/attachments/di-service-tokens.md +5 -4
  12. package/dist/agentic/guides/modules/attachments/exact-override-targets.md +6 -5
  13. package/dist/agentic/guides/modules/attachments/index.md +2 -2
  14. package/dist/agentic/guides/modules/audit_logs/index.md +1 -1
  15. package/dist/agentic/guides/modules/auth/di-registrations-rich.md +3 -2
  16. package/dist/agentic/guides/modules/auth/di-service-tokens.md +3 -2
  17. package/dist/agentic/guides/modules/auth/exact-override-targets.md +3 -2
  18. package/dist/agentic/guides/modules/auth/index.md +1 -1
  19. package/dist/agentic/guides/modules/business_rules/index.md +1 -1
  20. package/dist/agentic/guides/modules/catalog/incoming-installed-contributions.md +3 -0
  21. package/dist/agentic/guides/modules/catalog/index.md +2 -2
  22. package/dist/agentic/guides/modules/channel_apns/index.md +1 -1
  23. package/dist/agentic/guides/modules/channel_discord/acl-features.md +13 -0
  24. package/dist/agentic/guides/modules/channel_discord/ai-agents.md +11 -0
  25. package/dist/agentic/guides/modules/channel_discord/backend-pages.md +11 -0
  26. package/dist/agentic/guides/modules/channel_discord/cli-commands.md +13 -0
  27. package/dist/agentic/guides/modules/channel_discord/contribution-resolutions.md +14 -0
  28. package/dist/agentic/guides/modules/channel_discord/di-registrations-rich.md +12 -0
  29. package/dist/agentic/guides/modules/channel_discord/exact-override-targets.md +26 -0
  30. package/dist/agentic/guides/modules/channel_discord/index.md +22 -0
  31. package/dist/agentic/guides/modules/channel_discord/owned-contract-module-metadata.md +11 -0
  32. package/dist/agentic/guides/modules/channel_discord/setup.md +11 -0
  33. package/dist/agentic/guides/modules/channel_discord/umes-contributions.md +14 -0
  34. package/dist/agentic/guides/modules/channel_discord/workers.md +12 -0
  35. package/dist/agentic/guides/modules/channel_expo/index.md +1 -1
  36. package/dist/agentic/guides/modules/channel_fcm/index.md +1 -1
  37. package/dist/agentic/guides/modules/channel_gmail/index.md +1 -1
  38. package/dist/agentic/guides/modules/channel_imap/index.md +1 -1
  39. package/dist/agentic/guides/modules/checkout/index.md +1 -1
  40. package/dist/agentic/guides/modules/communication_channels/di-registrations-rich.md +9 -8
  41. package/dist/agentic/guides/modules/communication_channels/exact-override-targets.md +9 -8
  42. package/dist/agentic/guides/modules/communication_channels/incoming-installed-contributions.md +2 -0
  43. package/dist/agentic/guides/modules/communication_channels/index.md +3 -3
  44. package/dist/agentic/guides/modules/configs/index.md +1 -1
  45. package/dist/agentic/guides/modules/content/index.md +1 -1
  46. package/dist/agentic/guides/modules/currencies/index.md +1 -1
  47. package/dist/agentic/guides/modules/customer_accounts/index.md +1 -1
  48. package/dist/agentic/guides/modules/customers/active-extension-bindings.md +6 -3
  49. package/dist/agentic/guides/modules/customers/contribution-resolutions.md +1 -0
  50. package/dist/agentic/guides/modules/customers/events.md +2 -1
  51. package/dist/agentic/guides/modules/customers/incoming-installed-contributions.md +7 -0
  52. package/dist/agentic/guides/modules/customers/index.md +4 -4
  53. package/dist/agentic/guides/modules/customers/umes-contributions.md +1 -0
  54. package/dist/agentic/guides/modules/customers/umes-hosts.md +1 -0
  55. package/dist/agentic/guides/modules/dashboards/index.md +1 -1
  56. package/dist/agentic/guides/modules/data_sync/exact-override-targets.md +2 -2
  57. package/dist/agentic/guides/modules/data_sync/index.md +1 -1
  58. package/dist/agentic/guides/modules/data_sync/workers.md +2 -2
  59. package/dist/agentic/guides/modules/design_system/index.md +1 -1
  60. package/dist/agentic/guides/modules/devices/index.md +1 -1
  61. package/dist/agentic/guides/modules/dictionaries/index.md +1 -1
  62. package/dist/agentic/guides/modules/directory/di-registrations-rich.md +12 -0
  63. package/dist/agentic/guides/modules/directory/di-service-tokens.md +12 -0
  64. package/dist/agentic/guides/modules/directory/exact-override-targets.md +2 -0
  65. package/dist/agentic/guides/modules/directory/index.md +5 -3
  66. package/dist/agentic/guides/modules/documents/acl-features.md +17 -0
  67. package/dist/agentic/guides/modules/documents/backend-pages.md +13 -0
  68. package/dist/agentic/guides/modules/documents/contribution-resolutions.md +27 -0
  69. package/dist/agentic/guides/modules/documents/di-registrations-rich.md +21 -0
  70. package/dist/agentic/guides/modules/documents/domain-commands.md +37 -0
  71. package/dist/agentic/guides/modules/documents/encryption.md +11 -0
  72. package/dist/agentic/guides/modules/documents/entities.md +21 -0
  73. package/dist/agentic/guides/modules/documents/events.md +25 -0
  74. package/dist/agentic/guides/modules/documents/exact-override-targets.md +39 -0
  75. package/dist/agentic/guides/modules/documents/index.md +25 -0
  76. package/dist/agentic/guides/modules/documents/notifications.md +13 -0
  77. package/dist/agentic/guides/modules/documents/owned-contract-module-metadata.md +11 -0
  78. package/dist/agentic/guides/modules/documents/setup.md +11 -0
  79. package/dist/agentic/guides/modules/documents/umes-contributions.md +27 -0
  80. package/dist/agentic/guides/modules/documents/umes-hosts.md +36 -0
  81. package/dist/agentic/guides/modules/entities/index.md +1 -1
  82. package/dist/agentic/guides/modules/eudr/index.md +1 -1
  83. package/dist/agentic/guides/modules/events/index.md +1 -1
  84. package/dist/agentic/guides/modules/feature_toggles/index.md +1 -1
  85. package/dist/agentic/guides/modules/gateway_stripe/exact-override-targets.md +1 -1
  86. package/dist/agentic/guides/modules/gateway_stripe/index.md +1 -1
  87. package/dist/agentic/guides/modules/gateway_stripe/workers.md +1 -1
  88. package/dist/agentic/guides/modules/generators/index.md +1 -1
  89. package/dist/agentic/guides/modules/inbox_ops/index.md +1 -1
  90. package/dist/agentic/guides/modules/integrations/index.md +1 -1
  91. package/dist/agentic/guides/modules/messages/index.md +1 -1
  92. package/dist/agentic/guides/modules/notifications/index.md +1 -1
  93. package/dist/agentic/guides/modules/onboarding/index.md +1 -1
  94. package/dist/agentic/guides/modules/payment_gateways/index.md +1 -1
  95. package/dist/agentic/guides/modules/perspectives/index.md +1 -1
  96. package/dist/agentic/guides/modules/planner/index.md +1 -1
  97. package/dist/agentic/guides/modules/portal/index.md +1 -1
  98. package/dist/agentic/guides/modules/progress/index.md +1 -1
  99. package/dist/agentic/guides/modules/push_notifications/index.md +1 -1
  100. package/dist/agentic/guides/modules/query_index/index.md +1 -1
  101. package/dist/agentic/guides/modules/record_locks/index.md +1 -1
  102. package/dist/agentic/guides/modules/resources/index.md +1 -1
  103. package/dist/agentic/guides/modules/sales/active-extension-bindings.md +1 -0
  104. package/dist/agentic/guides/modules/sales/contribution-resolutions.md +1 -0
  105. package/dist/agentic/guides/modules/sales/exact-override-targets.md +5 -4
  106. package/dist/agentic/guides/modules/sales/host-extension-points.md +1 -1
  107. package/dist/agentic/guides/modules/sales/incoming-installed-contributions.md +2 -0
  108. package/dist/agentic/guides/modules/sales/index.md +3 -3
  109. package/dist/agentic/guides/modules/sales/setup.md +1 -1
  110. package/dist/agentic/guides/modules/sales/umes-contributions.md +1 -0
  111. package/dist/agentic/guides/modules/sales/umes-diagnostics.md +1 -0
  112. package/dist/agentic/guides/modules/sales/umes-hosts.md +9 -0
  113. package/dist/agentic/guides/modules/scheduler/cli-commands.md +1 -0
  114. package/dist/agentic/guides/modules/scheduler/exact-override-targets.md +3 -1
  115. package/dist/agentic/guides/modules/scheduler/index.md +2 -2
  116. package/dist/agentic/guides/modules/scheduler/workers.md +2 -1
  117. package/dist/agentic/guides/modules/search/index.md +1 -1
  118. package/dist/agentic/guides/modules/security/index.md +1 -1
  119. package/dist/agentic/guides/modules/security/owned-contract-module-metadata.md +1 -1
  120. package/dist/agentic/guides/modules/shipping_carriers/index.md +1 -1
  121. package/dist/agentic/guides/modules/sso/index.md +1 -1
  122. package/dist/agentic/guides/modules/staff/index.md +1 -1
  123. package/dist/agentic/guides/modules/storage_s3/index.md +1 -1
  124. package/dist/agentic/guides/modules/sync_akeneo/index.md +1 -1
  125. package/dist/agentic/guides/modules/sync_excel/index.md +1 -1
  126. package/dist/agentic/guides/modules/system_status_overlays/index.md +1 -1
  127. package/dist/agentic/guides/modules/translations/index.md +1 -1
  128. package/dist/agentic/guides/modules/warranty_claims/index.md +1 -1
  129. package/dist/agentic/guides/modules/webhooks/index.md +1 -1
  130. package/dist/agentic/guides/modules/wms/index.md +1 -1
  131. package/dist/agentic/guides/modules/workflows/index.md +1 -1
  132. package/dist/agentic/guides/reference-module-facts.json +2 -2
  133. package/dist/agentic/guides/reference-modules/example/index.md +1 -1
  134. package/dist/agentic/guides/upstream/manifest.json +1 -1
  135. package/dist/agentic/shared/AGENTS.md.template +3 -3
  136. package/dist/agentic/shared/ai/harness/README.md +2 -2
  137. package/dist/agentic/shared/ai/harness/RELEASE.md +8 -6
  138. package/dist/agentic/shared/ai/harness/cases.json +83 -0
  139. package/dist/agentic/shared/ai/harness/cases.schema.json +4 -4
  140. package/dist/agentic/shared/ai/harness/validators.json +1 -1
  141. package/dist/agentic/shared/scripts/run-agent-harness-release.mjs +37 -11
  142. package/dist/lib/generators/ast/index.js +4 -1
  143. package/dist/lib/generators/ast/index.js.map +2 -2
  144. package/dist/lib/generators/ast/source-file.js +8 -1
  145. package/dist/lib/generators/ast/source-file.js.map +2 -2
  146. package/dist/lib/generators/ast/writers.js +9 -1
  147. package/dist/lib/generators/ast/writers.js.map +2 -2
  148. package/dist/lib/generators/module-registry.js +424 -189
  149. package/dist/lib/generators/module-registry.js.map +2 -2
  150. package/dist/lib/generators/route-manifest-shards.js +4 -1
  151. package/dist/lib/generators/route-manifest-shards.js.map +2 -2
  152. package/dist/lib/spawn.js +3 -2
  153. package/dist/lib/spawn.js.map +2 -2
  154. package/dist/lib/testing/integration.js +162 -24
  155. package/dist/lib/testing/integration.js.map +2 -2
  156. package/dist/mercato.js +14 -0
  157. package/dist/mercato.js.map +2 -2
  158. package/package.json +8 -7
  159. package/src/__tests__/resolve-queue-abandon-hook.test.ts +39 -0
  160. package/src/lib/__tests__/spawn.test.ts +23 -0
  161. package/src/lib/generators/__tests__/__snapshots__/bootstrap-registrations.test.ts.snap +14 -0
  162. package/src/lib/generators/__tests__/__snapshots__/command-loaders.test.ts.snap +23 -0
  163. package/src/lib/generators/__tests__/ast-writers-escaping.test.ts +64 -0
  164. package/src/lib/generators/__tests__/bootstrap-registrations.test.ts +176 -0
  165. package/src/lib/generators/__tests__/command-loaders.test.ts +239 -0
  166. package/src/lib/generators/__tests__/generated-registry-imports.test.ts +128 -0
  167. package/src/lib/generators/__tests__/metadata-extraction.test.ts +44 -0
  168. package/src/lib/generators/__tests__/module-facts.bc-guard.test.ts +32 -8
  169. package/src/lib/generators/__tests__/module-facts.customers.fixture.test.ts +4 -1
  170. package/src/lib/generators/__tests__/module-facts.extension-hosts.test.ts +1 -1
  171. package/src/lib/generators/__tests__/module-subset.test.ts +96 -72
  172. package/src/lib/generators/__tests__/output-snapshots.test.ts +1 -1
  173. package/src/lib/generators/__tests__/registry-variant-parity.test.ts +258 -0
  174. package/src/lib/generators/__tests__/route-manifest-shards.test.ts +15 -0
  175. package/src/lib/generators/__tests__/structural-contracts.test.ts +7 -3
  176. package/src/lib/generators/ast/index.ts +2 -1
  177. package/src/lib/generators/ast/source-file.ts +13 -0
  178. package/src/lib/generators/ast/writers.ts +26 -1
  179. package/src/lib/generators/module-registry.ts +534 -227
  180. package/src/lib/generators/route-manifest-shards.ts +8 -1
  181. package/src/lib/spawn.ts +5 -4
  182. package/src/lib/testing/__tests__/integration.test.ts +435 -0
  183. package/src/lib/testing/integration.ts +254 -32
  184. package/src/mercato.ts +27 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.1-develop.7103.1.41ff100d93",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -60,9 +60,9 @@
60
60
  "@mikro-orm/decorators": "^7.1.8",
61
61
  "@mikro-orm/migrations": "^7.1.8",
62
62
  "@mikro-orm/postgresql": "^7.1.8",
63
- "@open-mercato/queue": "0.7.0",
64
- "@open-mercato/shared": "0.7.0",
65
- "@open-mercato/telemetry": "0.7.0",
63
+ "@open-mercato/queue": "0.7.1-develop.7103.1.41ff100d93",
64
+ "@open-mercato/shared": "0.7.1-develop.7103.1.41ff100d93",
65
+ "@open-mercato/telemetry": "0.7.1-develop.7103.1.41ff100d93",
66
66
  "cross-spawn": "^7.0.6",
67
67
  "pg": "8.22.0",
68
68
  "semver": "^7.8.5",
@@ -73,10 +73,10 @@
73
73
  "typescript-js": "npm:typescript@6.0.3"
74
74
  },
75
75
  "peerDependencies": {
76
- "@open-mercato/shared": "0.7.0"
76
+ "@open-mercato/shared": "0.7.1-develop.7103.1.41ff100d93"
77
77
  },
78
78
  "devDependencies": {
79
- "@open-mercato/shared": "0.7.0",
79
+ "@open-mercato/shared": "0.7.1-develop.7103.1.41ff100d93",
80
80
  "@types/jest": "^30.0.0",
81
81
  "jest": "^30.4.2",
82
82
  "ts-jest": "^29.4.12"
@@ -88,5 +88,6 @@
88
88
  "type": "git",
89
89
  "url": "https://github.com/open-mercato/open-mercato",
90
90
  "directory": "packages/cli"
91
- }
91
+ },
92
+ "stableVersion": "0.7.0"
92
93
  }
@@ -0,0 +1,39 @@
1
+ import { resolveQueueAbandonHook } from '../mercato'
2
+
3
+ /**
4
+ * Covers the CLI's per-queue pick, which is the last link between worker metadata and the queue the
5
+ * worker runs on. The warning exists because two handlers on one queue each expecting to report is a
6
+ * wiring mistake, and a silent first-wins would hide it.
7
+ */
8
+ describe('resolveQueueAbandonHook', () => {
9
+ const hookA = jest.fn(async () => {})
10
+ const hookB = jest.fn(async () => {})
11
+
12
+ it('returns undefined when no worker declares one', () => {
13
+ const warn = jest.fn()
14
+ expect(resolveQueueAbandonHook('q', [{ id: 'a' }, { id: 'b' }], warn)).toBeUndefined()
15
+ expect(warn).not.toHaveBeenCalled()
16
+ })
17
+
18
+ it('returns the single declared callback without warning', () => {
19
+ const warn = jest.fn()
20
+ expect(resolveQueueAbandonHook('q', [{ id: 'a' }, { id: 'b', onJobAbandoned: hookA }], warn)).toBe(hookA)
21
+ expect(warn).not.toHaveBeenCalled()
22
+ })
23
+
24
+ it('keeps the first and warns, naming the queue and every declaring worker', () => {
25
+ const warn = jest.fn()
26
+ const picked = resolveQueueAbandonHook(
27
+ 'data-sync-import',
28
+ [{ id: 'first', onJobAbandoned: hookA }, { id: 'second', onJobAbandoned: hookB }],
29
+ warn,
30
+ )
31
+
32
+ expect(picked).toBe(hookA)
33
+ expect(warn).toHaveBeenCalledTimes(1)
34
+ const message = warn.mock.calls[0][0] as string
35
+ expect(message).toContain('data-sync-import')
36
+ expect(message).toContain('first')
37
+ expect(message).toContain('second')
38
+ })
39
+ })
@@ -39,3 +39,26 @@ describe('resolveSpawnCommand', () => {
39
39
  )
40
40
  })
41
41
  })
42
+
43
+ describe('resolveSpawnCommand detached passthrough', () => {
44
+ // The whole process-group teardown in the ephemeral harness rests on this flag reaching `spawn`:
45
+ // without it the app tree stays in the runner's group and the negated-pid kill has nothing of its
46
+ // own to signal.
47
+ it('passes detached through on the POSIX branch', () => {
48
+ const result = resolveSpawnCommand('yarn', ['start'], { platform: 'linux', detached: true })
49
+
50
+ expect(result.spawnOptions).toEqual({ detached: true })
51
+ })
52
+
53
+ it('passes detached through on the Windows cmd branch', () => {
54
+ const result = resolveSpawnCommand('yarn.cmd', ['start'], { platform: 'win32', detached: true })
55
+
56
+ expect(result.spawnOptions).toEqual({ detached: true })
57
+ })
58
+
59
+ it('omits detached entirely when the caller does not ask for it', () => {
60
+ expect(resolveSpawnCommand('yarn', ['start'], { platform: 'linux' }).spawnOptions).toEqual({})
61
+ expect(resolveSpawnCommand('yarn', ['start']).spawnOptions).not.toHaveProperty('detached')
62
+ expect(resolveSpawnCommand('yarn', ['start'], { platform: 'linux', detached: false }).spawnOptions).toEqual({})
63
+ })
64
+ })
@@ -0,0 +1,14 @@
1
+ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2
+
3
+ exports[`renderBootstrapRegistrationsFile pins the emitted text so emitter changes surface as a reviewable diff 1`] = `
4
+ "// AUTO-GENERATED by mercato generate registry
5
+ import { backendRoutes } from "./backend-routes.generated";
6
+ import { frontendRoutes } from "./frontend-routes.generated";
7
+ import { registerBackendRouteManifests, registerFrontendRouteManifests } from "@open-mercato/shared/modules/registry";
8
+
9
+ export function runBootstrapRegistrations(): void {
10
+ registerBackendRouteManifests(backendRoutes)
11
+ registerFrontendRouteManifests(frontendRoutes)
12
+ }
13
+ "
14
+ `;
@@ -0,0 +1,23 @@
1
+ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2
+
3
+ exports[`renderCommandLoadersFile pins the emitted text so emitter changes surface as a reviewable diff 1`] = `
4
+ "// AUTO-GENERATED by mercato generate command-loaders
5
+ import type { CommandLoader } from "@open-mercato/shared/lib/commands";
6
+
7
+ export const commandLoaderEntries: CommandLoader[] = [
8
+ {
9
+ moduleId: "catalog",
10
+ id: "catalog.products.create",
11
+ key: "catalog:commands:products",
12
+ load: () => import("@open-mercato/core/modules/catalog/commands/products")
13
+ },
14
+ {
15
+ moduleId: "catalog",
16
+ key: "catalog:commands:products",
17
+ load: () => import("@open-mercato/core/modules/catalog/commands/products")
18
+ }
19
+ ];
20
+
21
+ export default commandLoaderEntries;
22
+ "
23
+ `;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `stringLiteral` is the only place a JavaScript string value becomes source text in the
3
+ * AST generator stack. It must escape the characters that are legal inside a JSON string
4
+ * but hazardous inside an emitted source file, matching the escaping the string emitters
5
+ * applied before they were retired — otherwise the migration would have silently dropped
6
+ * that hardening from every generated file.
7
+ */
8
+ import { escapeGeneratedJsonLiteral, stringLiteral, writeValue } from '../ast/writers'
9
+ import type { CodeBlockWriter } from 'ts-morph'
10
+
11
+ function render(value: unknown): string {
12
+ const chunks: string[] = []
13
+ const writer = { write: (text: string) => { chunks.push(text) } } as unknown as CodeBlockWriter
14
+ writeValue(writer, value as never)
15
+ return chunks.join('')
16
+ }
17
+
18
+ describe('stringLiteral', () => {
19
+ it('escapes angle brackets so an emitted value cannot close a surrounding script context', () => {
20
+ expect(stringLiteral('a</script>b')).toBe('"a\\u003c/script\\u003eb"')
21
+ })
22
+
23
+ it('escapes the line and paragraph separators older parsers reject inside a string literal', () => {
24
+ expect(stringLiteral('a
b
c')).toBe('"a\\u2028b\\u2029c"')
25
+ })
26
+
27
+ it('leaves an ordinary string as plain JSON', () => {
28
+ expect(stringLiteral('catalog:commands:products')).toBe('"catalog:commands:products"')
29
+ })
30
+
31
+ it('still escapes what JSON escapes', () => {
32
+ expect(stringLiteral('a"b\\c\nd')).toBe('"a\\"b\\\\c\\nd"')
33
+ })
34
+
35
+ it('denotes the same runtime string after escaping', () => {
36
+ const original = 'a</script>b
c'
37
+ expect(JSON.parse(stringLiteral(original))).toBe(original)
38
+ })
39
+ })
40
+
41
+ describe('escapeGeneratedJsonLiteral', () => {
42
+ it('hardens strings nested inside a serialized object', () => {
43
+ const literal = escapeGeneratedJsonLiteral(JSON.stringify({ GET: { title: 'a</script>b' } }))
44
+ expect(literal).toBe('{"GET":{"title":"a\\u003c/script\\u003eb"}}')
45
+ })
46
+
47
+ it('keeps the object literal parseable back to the same value', () => {
48
+ const value = { GET: { title: 'a</script>b
c
d' } }
49
+ expect(JSON.parse(escapeGeneratedJsonLiteral(JSON.stringify(value)))).toEqual(value)
50
+ })
51
+ })
52
+
53
+ describe('writeValue', () => {
54
+ it('routes string values through the escaping helper', () => {
55
+ expect(render('a<b>c')).toBe('"a\\u003cb\\u003ec"')
56
+ })
57
+
58
+ it('leaves non-string primitives alone', () => {
59
+ expect(render(42)).toBe('42')
60
+ expect(render(true)).toBe('true')
61
+ expect(render(null)).toBe('null')
62
+ expect(render(undefined)).toBe('undefined')
63
+ })
64
+ })
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Contract tests for `renderBootstrapRegistrationsFile`, the emitter behind
3
+ * `bootstrap-registrations.generated.ts`.
4
+ *
5
+ * The file is always written — even when no generator plugin contributes a bootstrap
6
+ * registration — so `bootstrap.ts` can import it unconditionally. These tests cover
7
+ * that core-only case, one plugin, two plugins whose registration imports overlap
8
+ * (de-duplication), and a malformed import statement, which must fail at generation
9
+ * time with the offending statement named rather than emit a broken file.
10
+ */
11
+ import ts from 'typescript-js'
12
+ import { renderBootstrapRegistrationsFile } from '../module-registry'
13
+
14
+ const CORE_ENTRY_IMPORTS = [
15
+ 'import { backendRoutes } from "./backend-routes.generated"',
16
+ 'import { frontendRoutes } from "./frontend-routes.generated"',
17
+ ]
18
+ const CORE_REGISTRATION_IMPORTS = [
19
+ "import { registerBackendRouteManifests, registerFrontendRouteManifests } from '@open-mercato/shared/modules/registry'",
20
+ ]
21
+ const CORE_CALLS = [
22
+ 'registerBackendRouteManifests(backendRoutes)',
23
+ 'registerFrontendRouteManifests(frontendRoutes)',
24
+ ]
25
+
26
+ function parseFile(content: string): ts.SourceFile {
27
+ return ts.createSourceFile('bootstrap-registrations.generated.ts', content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
28
+ }
29
+
30
+ function expectNoSyntaxErrors(content: string): void {
31
+ const sourceFile = parseFile(content)
32
+ const diagnostics = (sourceFile as unknown as { parseDiagnostics?: ts.Diagnostic[] }).parseDiagnostics ?? []
33
+ expect(diagnostics.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, ' '))).toEqual([])
34
+ }
35
+
36
+ function parseImports(content: string): Array<{ moduleSpecifier: string; names: string[] }> {
37
+ return parseFile(content)
38
+ .statements.filter(ts.isImportDeclaration)
39
+ .map((declaration) => {
40
+ const bindings = declaration.importClause?.namedBindings
41
+ return {
42
+ moduleSpecifier: (declaration.moduleSpecifier as ts.StringLiteral).text,
43
+ names: bindings && ts.isNamedImports(bindings) ? bindings.elements.map((element) => element.name.text) : [],
44
+ }
45
+ })
46
+ }
47
+
48
+ function parseRegistrationCalls(content: string): string[] {
49
+ const sourceFile = parseFile(content)
50
+ const declaration = sourceFile.statements.find(
51
+ (statement): statement is ts.FunctionDeclaration =>
52
+ ts.isFunctionDeclaration(statement) && statement.name?.text === 'runBootstrapRegistrations',
53
+ )
54
+ if (!declaration) throw new Error('runBootstrapRegistrations declaration not found')
55
+ return (declaration.body?.statements ?? []).map((statement) => statement.getText(sourceFile).replace(/;$/, ''))
56
+ }
57
+
58
+ function isExportedVoidFunction(content: string): boolean {
59
+ const declaration = parseFile(content).statements.find(
60
+ (statement): statement is ts.FunctionDeclaration =>
61
+ ts.isFunctionDeclaration(statement) && statement.name?.text === 'runBootstrapRegistrations',
62
+ )
63
+ if (!declaration) return false
64
+ const isExported = declaration.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false
65
+ return isExported && declaration.type?.kind === ts.SyntaxKind.VoidKeyword
66
+ }
67
+
68
+ describe('renderBootstrapRegistrationsFile', () => {
69
+ it('emits the core route registrations when no plugin contributes one', () => {
70
+ const content = renderBootstrapRegistrationsFile({
71
+ entryImports: CORE_ENTRY_IMPORTS,
72
+ registrationImports: CORE_REGISTRATION_IMPORTS,
73
+ calls: CORE_CALLS,
74
+ })
75
+
76
+ expectNoSyntaxErrors(content)
77
+ expect(content.startsWith('// AUTO-GENERATED by mercato generate registry\n')).toBe(true)
78
+ expect(isExportedVoidFunction(content)).toBe(true)
79
+ expect(parseImports(content)).toEqual([
80
+ { moduleSpecifier: './backend-routes.generated', names: ['backendRoutes'] },
81
+ { moduleSpecifier: './frontend-routes.generated', names: ['frontendRoutes'] },
82
+ {
83
+ moduleSpecifier: '@open-mercato/shared/modules/registry',
84
+ names: ['registerBackendRouteManifests', 'registerFrontendRouteManifests'],
85
+ },
86
+ ])
87
+ expect(parseRegistrationCalls(content)).toEqual(CORE_CALLS)
88
+ })
89
+
90
+ it('appends a single plugin registration after the core ones', () => {
91
+ const content = renderBootstrapRegistrationsFile({
92
+ entryImports: [
93
+ ...CORE_ENTRY_IMPORTS,
94
+ 'import { securityMfaProviderEntries } from "./security-mfa-providers.generated"',
95
+ ],
96
+ registrationImports: [
97
+ ...CORE_REGISTRATION_IMPORTS,
98
+ "import { registerMfaProviders } from '@open-mercato/core/modules/security/mfa'",
99
+ ],
100
+ calls: [...CORE_CALLS, 'registerMfaProviders(securityMfaProviderEntries)'],
101
+ })
102
+
103
+ expectNoSyntaxErrors(content)
104
+ expect(parseImports(content).map((parsed) => parsed.moduleSpecifier)).toEqual([
105
+ './backend-routes.generated',
106
+ './frontend-routes.generated',
107
+ './security-mfa-providers.generated',
108
+ '@open-mercato/shared/modules/registry',
109
+ '@open-mercato/core/modules/security/mfa',
110
+ ])
111
+ expect(parseRegistrationCalls(content)).toEqual([
112
+ ...CORE_CALLS,
113
+ 'registerMfaProviders(securityMfaProviderEntries)',
114
+ ])
115
+ })
116
+
117
+ it('de-duplicates an identical registration import contributed by two plugins', () => {
118
+ const sharedRegistrationImport = "import { registerEntries } from '@open-mercato/core/modules/shared/registrations'"
119
+ const content = renderBootstrapRegistrationsFile({
120
+ entryImports: [
121
+ ...CORE_ENTRY_IMPORTS,
122
+ 'import { firstPluginEntries } from "./first-plugin.generated"',
123
+ 'import { secondPluginEntries } from "./second-plugin.generated"',
124
+ ],
125
+ registrationImports: [...CORE_REGISTRATION_IMPORTS, sharedRegistrationImport, sharedRegistrationImport],
126
+ calls: [...CORE_CALLS, 'registerEntries(firstPluginEntries)', 'registerEntries(secondPluginEntries)'],
127
+ })
128
+
129
+ expectNoSyntaxErrors(content)
130
+ expect(parseImports(content).filter((parsed) => parsed.names.includes('registerEntries'))).toHaveLength(1)
131
+ expect(parseRegistrationCalls(content)).toEqual([
132
+ ...CORE_CALLS,
133
+ 'registerEntries(firstPluginEntries)',
134
+ 'registerEntries(secondPluginEntries)',
135
+ ])
136
+ })
137
+
138
+ it('emits a valid module with an empty body when nothing registers at all', () => {
139
+ const content = renderBootstrapRegistrationsFile({ entryImports: [], registrationImports: [], calls: [] })
140
+
141
+ expectNoSyntaxErrors(content)
142
+ expect(isExportedVoidFunction(content)).toBe(true)
143
+ expect(parseImports(content)).toEqual([])
144
+ expect(parseRegistrationCalls(content)).toEqual([])
145
+ })
146
+
147
+ it('fails at generation time and names the statement when a plugin import is malformed', () => {
148
+ expect(() =>
149
+ renderBootstrapRegistrationsFile({
150
+ entryImports: CORE_ENTRY_IMPORTS,
151
+ registrationImports: [...CORE_REGISTRATION_IMPORTS, 'registerSomething from "./not-an-import"'],
152
+ calls: CORE_CALLS,
153
+ }),
154
+ ).toThrow('Expected import declaration, received: registerSomething from "./not-an-import"')
155
+ })
156
+
157
+ it('fails when a plugin import uses a non-literal module specifier', () => {
158
+ expect(() =>
159
+ renderBootstrapRegistrationsFile({
160
+ entryImports: CORE_ENTRY_IMPORTS,
161
+ registrationImports: [...CORE_REGISTRATION_IMPORTS, 'import { registerSomething } from `./templated`'],
162
+ calls: CORE_CALLS,
163
+ }),
164
+ ).toThrow('Unsupported import declaration: import { registerSomething } from `./templated`')
165
+ })
166
+
167
+ it('pins the emitted text so emitter changes surface as a reviewable diff', () => {
168
+ expect(
169
+ renderBootstrapRegistrationsFile({
170
+ entryImports: CORE_ENTRY_IMPORTS,
171
+ registrationImports: CORE_REGISTRATION_IMPORTS,
172
+ calls: CORE_CALLS,
173
+ }),
174
+ ).toMatchSnapshot()
175
+ })
176
+ })
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Contract tests for `renderCommandLoadersFile`, the emitter behind
3
+ * `command-loaders.generated.ts`.
4
+ *
5
+ * The emitter is exercised directly rather than through a full generate run so the
6
+ * assertions stay readable and fast. Expectations are structural — the file is parsed
7
+ * and its exported array is inspected — so they survive the formatting differences a
8
+ * ts-morph emitter introduces. One inline snapshot pins the emitted text as well, so a
9
+ * reviewer can see the formatting delta of any future emitter change in the diff.
10
+ */
11
+ import ts from 'typescript-js'
12
+ import { renderCommandLoadersFile, type CommandLoaderGenerationEntry } from '../module-registry'
13
+
14
+ type ParsedLoaderEntry = {
15
+ moduleId?: string
16
+ id?: string
17
+ key?: string
18
+ loadImportPath?: string
19
+ propertyOrder: string[]
20
+ }
21
+
22
+ function parseFile(content: string): ts.SourceFile {
23
+ return ts.createSourceFile('command-loaders.generated.ts', content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
24
+ }
25
+
26
+ function expectNoSyntaxErrors(content: string): void {
27
+ const sourceFile = parseFile(content)
28
+ const diagnostics = (sourceFile as unknown as { parseDiagnostics?: ts.Diagnostic[] }).parseDiagnostics ?? []
29
+ expect(diagnostics.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, ' '))).toEqual([])
30
+ }
31
+
32
+ function findExportedArray(sourceFile: ts.SourceFile, exportName: string): ts.ArrayLiteralExpression {
33
+ let found: ts.ArrayLiteralExpression | null = null
34
+ const visit = (node: ts.Node): void => {
35
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === exportName) {
36
+ if (node.initializer && ts.isArrayLiteralExpression(node.initializer)) {
37
+ found = node.initializer
38
+ }
39
+ }
40
+ ts.forEachChild(node, visit)
41
+ }
42
+ visit(sourceFile)
43
+ if (!found) throw new Error(`Exported array "${exportName}" not found`)
44
+ return found
45
+ }
46
+
47
+ function readStringLiteral(node: ts.Expression): string | undefined {
48
+ return ts.isStringLiteralLike(node) ? node.text : undefined
49
+ }
50
+
51
+ function parseTypeOnlyImports(sourceFile: ts.SourceFile): Array<{ moduleSpecifier: string; names: string[] }> {
52
+ return sourceFile.statements
53
+ .filter(ts.isImportDeclaration)
54
+ .filter((declaration) => declaration.importClause?.isTypeOnly === true)
55
+ .map((declaration) => {
56
+ const bindings = declaration.importClause?.namedBindings
57
+ return {
58
+ moduleSpecifier: (declaration.moduleSpecifier as ts.StringLiteral).text,
59
+ names: bindings && ts.isNamedImports(bindings) ? bindings.elements.map((element) => element.name.text) : [],
60
+ }
61
+ })
62
+ }
63
+
64
+ function readExportedArrayTypeAnnotation(sourceFile: ts.SourceFile, exportName: string): string | undefined {
65
+ let annotation: string | undefined
66
+ const visit = (node: ts.Node): void => {
67
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === exportName) {
68
+ annotation = node.type?.getText(sourceFile)
69
+ }
70
+ ts.forEachChild(node, visit)
71
+ }
72
+ visit(sourceFile)
73
+ return annotation
74
+ }
75
+
76
+ function readDefaultExportedIdentifier(sourceFile: ts.SourceFile): string | undefined {
77
+ const assignment = sourceFile.statements.find(ts.isExportAssignment)
78
+ if (!assignment || assignment.isExportEquals) return undefined
79
+ return ts.isIdentifier(assignment.expression) ? assignment.expression.text : undefined
80
+ }
81
+
82
+ function readLoadImportPath(node: ts.Expression): string | undefined {
83
+ if (!ts.isArrowFunction(node)) return undefined
84
+ const body = node.body
85
+ if (ts.isBlock(body)) return undefined
86
+ if (!ts.isCallExpression(body) || body.expression.kind !== ts.SyntaxKind.ImportKeyword) return undefined
87
+ const [specifier] = body.arguments
88
+ return specifier ? readStringLiteral(specifier) : undefined
89
+ }
90
+
91
+ function parseLoaderEntries(content: string): ParsedLoaderEntry[] {
92
+ const sourceFile = parseFile(content)
93
+ const array = findExportedArray(sourceFile, 'commandLoaderEntries')
94
+ return array.elements.map((element) => {
95
+ if (!ts.isObjectLiteralExpression(element)) throw new Error('Expected an object literal entry')
96
+ const parsed: ParsedLoaderEntry = { propertyOrder: [] }
97
+ for (const property of element.properties) {
98
+ if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name)) continue
99
+ const name = property.name.text
100
+ parsed.propertyOrder.push(name)
101
+ if (name === 'moduleId') parsed.moduleId = readStringLiteral(property.initializer)
102
+ if (name === 'id') parsed.id = readStringLiteral(property.initializer)
103
+ if (name === 'key') parsed.key = readStringLiteral(property.initializer)
104
+ if (name === 'load') parsed.loadImportPath = readLoadImportPath(property.initializer)
105
+ }
106
+ return parsed
107
+ })
108
+ }
109
+
110
+ function entry(overrides: Partial<CommandLoaderGenerationEntry> = {}): CommandLoaderGenerationEntry {
111
+ return {
112
+ moduleId: 'catalog',
113
+ key: 'catalog:commands:products',
114
+ importPath: '@open-mercato/core/modules/catalog/commands/products',
115
+ ids: ['catalog.products.create'],
116
+ ...overrides,
117
+ }
118
+ }
119
+
120
+ describe('renderCommandLoadersFile', () => {
121
+ it('emits a parseable module with the documented exports', () => {
122
+ const content = renderCommandLoadersFile([entry()])
123
+
124
+ expectNoSyntaxErrors(content)
125
+ expect(content.startsWith('// AUTO-GENERATED by mercato generate command-loaders\n')).toBe(true)
126
+ expect(content.endsWith('\n')).toBe(true)
127
+
128
+ const sourceFile = parseFile(content)
129
+ expect(parseTypeOnlyImports(sourceFile)).toEqual([
130
+ { moduleSpecifier: '@open-mercato/shared/lib/commands', names: ['CommandLoader'] },
131
+ ])
132
+ expect(readExportedArrayTypeAnnotation(sourceFile, 'commandLoaderEntries')).toBe('CommandLoader[]')
133
+ expect(readDefaultExportedIdentifier(sourceFile)).toBe('commandLoaderEntries')
134
+ })
135
+
136
+ it('emits one id-scoped entry per command id followed by the module-scoped fallback entry', () => {
137
+ const content = renderCommandLoadersFile([
138
+ entry({ ids: ['catalog.products.create', 'catalog.products.delete'] }),
139
+ ])
140
+
141
+ expect(parseLoaderEntries(content)).toEqual([
142
+ {
143
+ moduleId: 'catalog',
144
+ id: 'catalog.products.create',
145
+ key: 'catalog:commands:products',
146
+ loadImportPath: '@open-mercato/core/modules/catalog/commands/products',
147
+ propertyOrder: ['moduleId', 'id', 'key', 'load'],
148
+ },
149
+ {
150
+ moduleId: 'catalog',
151
+ id: 'catalog.products.delete',
152
+ key: 'catalog:commands:products',
153
+ loadImportPath: '@open-mercato/core/modules/catalog/commands/products',
154
+ propertyOrder: ['moduleId', 'id', 'key', 'load'],
155
+ },
156
+ {
157
+ moduleId: 'catalog',
158
+ id: undefined,
159
+ key: 'catalog:commands:products',
160
+ loadImportPath: '@open-mercato/core/modules/catalog/commands/products',
161
+ propertyOrder: ['moduleId', 'key', 'load'],
162
+ },
163
+ ])
164
+ })
165
+
166
+ it('keeps entries from several modules in input order', () => {
167
+ const content = renderCommandLoadersFile([
168
+ entry(),
169
+ entry({
170
+ moduleId: 'sales',
171
+ key: 'sales:commands:orders',
172
+ importPath: '@open-mercato/core/modules/sales/commands/orders',
173
+ ids: ['sales.orders.create'],
174
+ }),
175
+ ])
176
+
177
+ expect(parseLoaderEntries(content).map((parsed) => [parsed.key, parsed.id])).toEqual([
178
+ ['catalog:commands:products', 'catalog.products.create'],
179
+ ['catalog:commands:products', undefined],
180
+ ['sales:commands:orders', 'sales.orders.create'],
181
+ ['sales:commands:orders', undefined],
182
+ ])
183
+ })
184
+
185
+ it('emits the module-scoped entry only when a module declares no command ids', () => {
186
+ const content = renderCommandLoadersFile([entry({ ids: [] })])
187
+
188
+ expectNoSyntaxErrors(content)
189
+ expect(parseLoaderEntries(content)).toEqual([
190
+ {
191
+ moduleId: 'catalog',
192
+ id: undefined,
193
+ key: 'catalog:commands:products',
194
+ loadImportPath: '@open-mercato/core/modules/catalog/commands/products',
195
+ propertyOrder: ['moduleId', 'key', 'load'],
196
+ },
197
+ ])
198
+ })
199
+
200
+ it('emits a valid module with an empty array when no module contributes commands', () => {
201
+ const content = renderCommandLoadersFile([])
202
+
203
+ expectNoSyntaxErrors(content)
204
+ expect(parseLoaderEntries(content)).toEqual([])
205
+ })
206
+
207
+ it('rejects the same command id contributed by two different modules', () => {
208
+ expect(() =>
209
+ renderCommandLoadersFile([
210
+ entry(),
211
+ entry({
212
+ moduleId: 'sales',
213
+ key: 'sales:commands:products',
214
+ importPath: '@open-mercato/core/modules/sales/commands/products',
215
+ }),
216
+ ]),
217
+ ).toThrow(
218
+ '[generate] Duplicate command id "catalog.products.create" discovered in "catalog:commands:products" and "sales:commands:products"',
219
+ )
220
+ })
221
+
222
+ it('tolerates the same command id repeated within one module key', () => {
223
+ const content = renderCommandLoadersFile([
224
+ entry({ ids: ['catalog.products.create', 'catalog.products.create'] }),
225
+ ])
226
+
227
+ expect(parseLoaderEntries(content)).toHaveLength(3)
228
+ })
229
+
230
+ it('rejects an unsafe import path instead of emitting it', () => {
231
+ expect(() => renderCommandLoadersFile([entry({ importPath: '' })])).toThrow(
232
+ 'Unsafe generated module specifier',
233
+ )
234
+ })
235
+
236
+ it('pins the emitted text so emitter changes surface as a reviewable diff', () => {
237
+ expect(renderCommandLoadersFile([entry()])).toMatchSnapshot()
238
+ })
239
+ })