@open-mercato/cli 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3045.b4b3320cc2

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 +1 -1
  2. package/dist/agentic/shared/AGENTS.md.template +1 -1
  3. package/dist/lib/__integration__/TC-INT-007.spec.js +201 -0
  4. package/dist/lib/__integration__/TC-INT-007.spec.js.map +7 -0
  5. package/dist/lib/dev-env-reload.js +89 -0
  6. package/dist/lib/dev-env-reload.js.map +7 -0
  7. package/dist/lib/generators/extensions/ai-agents.js +218 -0
  8. package/dist/lib/generators/extensions/ai-agents.js.map +7 -0
  9. package/dist/lib/generators/extensions/ai-tools.js +56 -1
  10. package/dist/lib/generators/extensions/ai-tools.js.map +2 -2
  11. package/dist/lib/generators/extensions/index.js +2 -0
  12. package/dist/lib/generators/extensions/index.js.map +2 -2
  13. package/dist/lib/testing/integration-discovery.js +102 -5
  14. package/dist/lib/testing/integration-discovery.js.map +2 -2
  15. package/dist/mercato.js +85 -44
  16. package/dist/mercato.js.map +2 -2
  17. package/package.json +5 -5
  18. package/src/__tests__/mercato.test.ts +112 -0
  19. package/src/lib/__integration__/TC-INT-007.spec.ts +228 -0
  20. package/src/lib/__tests__/dev-env-reload.test.ts +62 -0
  21. package/src/lib/dev-env-reload.ts +110 -0
  22. package/src/lib/generators/__tests__/module-subset.test.ts +14 -0
  23. package/src/lib/generators/__tests__/output-snapshots.test.ts +17 -0
  24. package/src/lib/generators/__tests__/scanner.test.ts +1 -1
  25. package/src/lib/generators/__tests__/structural-contracts.test.ts +26 -0
  26. package/src/lib/generators/extensions/ai-agents.ts +240 -0
  27. package/src/lib/generators/extensions/ai-tools.ts +72 -1
  28. package/src/lib/generators/extensions/index.ts +2 -0
  29. package/src/lib/testing/__tests__/integration-discovery.test.ts +68 -0
  30. package/src/lib/testing/integration-discovery.ts +127 -3
  31. package/src/mercato.ts +100 -46
@@ -0,0 +1,240 @@
1
+ import { VariableDeclarationKind, type WriterFunction } from 'ts-morph'
2
+ import type { GeneratorExtension } from '../extension'
3
+ import {
4
+ arrayLiteral,
5
+ arrowFunction,
6
+ binaryExpression,
7
+ identifier,
8
+ methodCall,
9
+ objectLiteral,
10
+ parenthesized,
11
+ propertyAccess,
12
+ writeValue,
13
+ } from '../ast'
14
+ import {
15
+ emptyArray,
16
+ emptyObject,
17
+ moduleEntry,
18
+ namespaceFallback,
19
+ namespaceImportSpec,
20
+ renderGeneratedTsSource,
21
+ } from './shared'
22
+
23
+ /**
24
+ * Generator extension for `<module>/ai-agents.ts` files.
25
+ *
26
+ * Each module's `ai-agents.ts` may export both base agent contributions
27
+ * (`aiAgents`), additive extensions for existing agents
28
+ * (`aiAgentExtensions`), AND cross-module override declarations
29
+ * (`aiAgentOverrides`). The generator scans the file once, emits the
30
+ * configuration entry with all fields, and produces filtered
31
+ * exports inside `ai-agents.generated.ts`:
32
+ *
33
+ * - `aiAgentConfigEntries` (entries that declare base agents)
34
+ * - `aiAgentExtensionEntries` / `allAiAgentExtensions` (entries that append to agents)
35
+ * - `aiAgentOverrideEntries` (entries that declare overrides)
36
+ *
37
+ * The runtime (`@open-mercato/ai-assistant`) reads
38
+ * `aiAgentConfigEntries` to populate the agent registry,
39
+ * `aiAgentOverrideEntries` to apply cross-module replacements, and
40
+ * `allAiAgentExtensions` to append safe metadata after the base load. See spec
41
+ * `.ai/specs/2026-04-30-ai-overrides-and-module-disable.md`.
42
+ */
43
+ export function createAiAgentsExtension(): GeneratorExtension {
44
+ const imports = [] as Array<ReturnType<typeof namespaceImportSpec>>
45
+ const entries: WriterFunction[] = []
46
+
47
+ return {
48
+ id: 'registry.ai-agents',
49
+ outputFiles: ['ai-agents.generated.ts'],
50
+ scanModule(ctx) {
51
+ ctx.processStandaloneConfig({
52
+ roots: ctx.roots,
53
+ imps: ctx.imps,
54
+ modId: ctx.moduleId,
55
+ relativePath: 'ai-agents.ts',
56
+ prefix: 'AI_AGENTS',
57
+ importIdRef: ctx.importIdRef,
58
+ standaloneImports: imports,
59
+ standaloneEntries: entries,
60
+ writeConfig: ({ importName, moduleId }) =>
61
+ moduleEntry(moduleId, [
62
+ {
63
+ name: 'agents',
64
+ value: namespaceFallback({
65
+ importName,
66
+ members: ['aiAgents', 'default'],
67
+ fallback: emptyArray(),
68
+ castType: 'unknown[]',
69
+ }),
70
+ },
71
+ {
72
+ name: 'overrides',
73
+ value: namespaceFallback({
74
+ importName,
75
+ members: ['aiAgentOverrides'],
76
+ fallback: emptyObject(),
77
+ castType: 'Record<string, unknown>',
78
+ }),
79
+ },
80
+ {
81
+ name: 'extensions',
82
+ value: namespaceFallback({
83
+ importName,
84
+ members: ['aiAgentExtensions'],
85
+ fallback: emptyArray(),
86
+ castType: 'unknown[]',
87
+ }),
88
+ },
89
+ ]),
90
+ })
91
+ },
92
+ generateOutput() {
93
+ const output = renderGeneratedTsSource({
94
+ fileName: 'ai-agents.generated.ts',
95
+ imports,
96
+ build(sourceFile) {
97
+ sourceFile.addTypeAlias({
98
+ name: 'AiAgentConfigEntry',
99
+ type: '{ moduleId: string; agents: unknown[]; overrides: Record<string, unknown>; extensions: unknown[] }',
100
+ })
101
+ sourceFile.addTypeAlias({
102
+ name: 'AiAgentOverrideConfigEntry',
103
+ type: '{ moduleId: string; overrides: Record<string, unknown> }',
104
+ })
105
+ sourceFile.addTypeAlias({
106
+ name: 'AiAgentExtensionConfigEntry',
107
+ type: '{ moduleId: string; extensions: unknown[] }',
108
+ })
109
+ sourceFile.addVariableStatement({
110
+ declarationKind: VariableDeclarationKind.Const,
111
+ declarations: [
112
+ {
113
+ name: 'aiAgentConfigEntriesRaw',
114
+ type: 'AiAgentConfigEntry[]',
115
+ initializer: arrayLiteral(entries, writeValue),
116
+ },
117
+ ],
118
+ })
119
+ sourceFile.addVariableStatement({
120
+ declarationKind: VariableDeclarationKind.Const,
121
+ isExported: true,
122
+ declarations: [
123
+ {
124
+ name: 'aiAgentConfigEntries',
125
+ type: 'AiAgentConfigEntry[]',
126
+ initializer: methodCall(identifier('aiAgentConfigEntriesRaw'), 'filter', [
127
+ arrowFunction({
128
+ parameters: ['entry'],
129
+ body: binaryExpression(propertyAccess(propertyAccess(identifier('entry'), 'agents'), 'length'), '>', 0),
130
+ }),
131
+ ]),
132
+ },
133
+ ],
134
+ })
135
+ sourceFile.addVariableStatement({
136
+ declarationKind: VariableDeclarationKind.Const,
137
+ isExported: true,
138
+ declarations: [
139
+ {
140
+ name: 'aiAgentExtensionEntries',
141
+ type: 'AiAgentExtensionConfigEntry[]',
142
+ initializer: methodCall(
143
+ methodCall(identifier('aiAgentConfigEntriesRaw'), 'filter', [
144
+ arrowFunction({
145
+ parameters: ['entry'],
146
+ body: binaryExpression(propertyAccess(propertyAccess(identifier('entry'), 'extensions'), 'length'), '>', 0),
147
+ }),
148
+ ]),
149
+ 'map',
150
+ [
151
+ arrowFunction({
152
+ parameters: ['entry'],
153
+ body: parenthesized(
154
+ objectLiteral([
155
+ { name: 'moduleId', value: propertyAccess(identifier('entry'), 'moduleId') },
156
+ { name: 'extensions', value: propertyAccess(identifier('entry'), 'extensions') },
157
+ ]),
158
+ ),
159
+ }),
160
+ ],
161
+ ),
162
+ },
163
+ ],
164
+ })
165
+ sourceFile.addVariableStatement({
166
+ declarationKind: VariableDeclarationKind.Const,
167
+ isExported: true,
168
+ declarations: [
169
+ {
170
+ name: 'allAiAgentExtensions',
171
+ initializer: methodCall(identifier('aiAgentExtensionEntries'), 'flatMap', [
172
+ arrowFunction({
173
+ parameters: ['entry'],
174
+ body: propertyAccess(identifier('entry'), 'extensions'),
175
+ }),
176
+ ]),
177
+ },
178
+ ],
179
+ })
180
+ sourceFile.addVariableStatement({
181
+ declarationKind: VariableDeclarationKind.Const,
182
+ isExported: true,
183
+ declarations: [
184
+ {
185
+ name: 'allAiAgents',
186
+ initializer: methodCall(identifier('aiAgentConfigEntries'), 'flatMap', [
187
+ arrowFunction({
188
+ parameters: ['entry'],
189
+ body: propertyAccess(identifier('entry'), 'agents'),
190
+ }),
191
+ ]),
192
+ },
193
+ ],
194
+ })
195
+ sourceFile.addVariableStatement({
196
+ declarationKind: VariableDeclarationKind.Const,
197
+ isExported: true,
198
+ declarations: [
199
+ {
200
+ name: 'aiAgentOverrideEntries',
201
+ type: 'AiAgentOverrideConfigEntry[]',
202
+ initializer: methodCall(
203
+ methodCall(identifier('aiAgentConfigEntriesRaw'), 'filter', [
204
+ arrowFunction({
205
+ parameters: ['entry'],
206
+ body: binaryExpression(
207
+ propertyAccess(
208
+ methodCall(identifier('Object'), 'keys', [
209
+ propertyAccess(identifier('entry'), 'overrides'),
210
+ ]),
211
+ 'length',
212
+ ),
213
+ '>',
214
+ 0,
215
+ ),
216
+ }),
217
+ ]),
218
+ 'map',
219
+ [
220
+ arrowFunction({
221
+ parameters: ['entry'],
222
+ body: parenthesized(
223
+ objectLiteral([
224
+ { name: 'moduleId', value: propertyAccess(identifier('entry'), 'moduleId') },
225
+ { name: 'overrides', value: propertyAccess(identifier('entry'), 'overrides') },
226
+ ]),
227
+ ),
228
+ }),
229
+ ],
230
+ ),
231
+ },
232
+ ],
233
+ })
234
+ },
235
+ })
236
+
237
+ return new Map([['ai-agents.generated.ts', output]])
238
+ },
239
+ }
240
+ }
@@ -6,17 +6,36 @@ import {
6
6
  binaryExpression,
7
7
  identifier,
8
8
  methodCall,
9
+ objectLiteral,
10
+ parenthesized,
9
11
  propertyAccess,
10
12
  writeValue,
11
13
  } from '../ast'
12
14
  import {
13
15
  emptyArray,
16
+ emptyObject,
14
17
  moduleEntry,
15
18
  namespaceFallback,
16
19
  namespaceImportSpec,
17
20
  renderGeneratedTsSource,
18
21
  } from './shared'
19
22
 
23
+ /**
24
+ * Generator extension for `<module>/ai-tools.ts` files.
25
+ *
26
+ * Each module's `ai-tools.ts` may export both base tool contributions
27
+ * (`aiTools`) AND cross-module override declarations (`aiToolOverrides`).
28
+ * The generator scans the file once and emits two filtered exports
29
+ * inside `ai-tools.generated.ts`:
30
+ *
31
+ * - `aiToolConfigEntries` (entries that declare base tools)
32
+ * - `aiToolOverrideEntries` (entries that declare overrides)
33
+ *
34
+ * The runtime (`@open-mercato/ai-assistant`) reads `aiToolConfigEntries`
35
+ * to populate the tool registry and `aiToolOverrideEntries` to apply
36
+ * cross-module replacements after the base load. See spec
37
+ * `.ai/specs/2026-04-30-ai-overrides-and-module-disable.md`.
38
+ */
20
39
  export function createAiToolsExtension(): GeneratorExtension {
21
40
  const imports = [] as Array<ReturnType<typeof namespaceImportSpec>>
22
41
  const entries: WriterFunction[] = []
@@ -45,6 +64,15 @@ export function createAiToolsExtension(): GeneratorExtension {
45
64
  castType: 'unknown[]',
46
65
  }),
47
66
  },
67
+ {
68
+ name: 'overrides',
69
+ value: namespaceFallback({
70
+ importName,
71
+ members: ['aiToolOverrides'],
72
+ fallback: emptyObject(),
73
+ castType: 'Record<string, unknown>',
74
+ }),
75
+ },
48
76
  ]),
49
77
  })
50
78
  },
@@ -55,7 +83,11 @@ export function createAiToolsExtension(): GeneratorExtension {
55
83
  build(sourceFile) {
56
84
  sourceFile.addTypeAlias({
57
85
  name: 'AiToolConfigEntry',
58
- type: '{ moduleId: string; tools: unknown[] }',
86
+ type: '{ moduleId: string; tools: unknown[]; overrides: Record<string, unknown> }',
87
+ })
88
+ sourceFile.addTypeAlias({
89
+ name: 'AiToolOverrideConfigEntry',
90
+ type: '{ moduleId: string; overrides: Record<string, unknown> }',
59
91
  })
60
92
  sourceFile.addVariableStatement({
61
93
  declarationKind: VariableDeclarationKind.Const,
@@ -98,6 +130,45 @@ export function createAiToolsExtension(): GeneratorExtension {
98
130
  },
99
131
  ],
100
132
  })
133
+ sourceFile.addVariableStatement({
134
+ declarationKind: VariableDeclarationKind.Const,
135
+ isExported: true,
136
+ declarations: [
137
+ {
138
+ name: 'aiToolOverrideEntries',
139
+ type: 'AiToolOverrideConfigEntry[]',
140
+ initializer: methodCall(
141
+ methodCall(identifier('aiToolConfigEntriesRaw'), 'filter', [
142
+ arrowFunction({
143
+ parameters: ['entry'],
144
+ body: binaryExpression(
145
+ propertyAccess(
146
+ methodCall(identifier('Object'), 'keys', [
147
+ propertyAccess(identifier('entry'), 'overrides'),
148
+ ]),
149
+ 'length',
150
+ ),
151
+ '>',
152
+ 0,
153
+ ),
154
+ }),
155
+ ]),
156
+ 'map',
157
+ [
158
+ arrowFunction({
159
+ parameters: ['entry'],
160
+ body: parenthesized(
161
+ objectLiteral([
162
+ { name: 'moduleId', value: propertyAccess(identifier('entry'), 'moduleId') },
163
+ { name: 'overrides', value: propertyAccess(identifier('entry'), 'overrides') },
164
+ ]),
165
+ ),
166
+ }),
167
+ ],
168
+ ),
169
+ },
170
+ ],
171
+ })
101
172
  },
102
173
  })
103
174
 
@@ -1,4 +1,5 @@
1
1
  import type { GeneratorExtension } from '../extension'
2
+ import { createAiAgentsExtension } from './ai-agents'
2
3
  import { createAiToolsExtension } from './ai-tools'
3
4
  import { createAnalyticsExtension } from './analytics'
4
5
  import { createCommandInterceptorsExtension } from './command-interceptors'
@@ -22,6 +23,7 @@ export function loadGeneratorExtensions(): GeneratorExtension[] {
22
23
  createNotificationsExtension(),
23
24
  createMessagesExtension(),
24
25
  createAiToolsExtension(),
26
+ createAiAgentsExtension(),
25
27
  createEventsExtension(),
26
28
  createAnalyticsExtension(),
27
29
  createTranslatableFieldsExtension(),
@@ -12,10 +12,20 @@ async function writeTestFile(projectRoot: string, relativePath: string, content
12
12
  describe('integration discovery', () => {
13
13
  let tempRoot = ''
14
14
  const previousEnterpriseFlag = process.env.OM_ENABLE_ENTERPRISE_MODULES
15
+ const testEnvKeys = [
16
+ 'OM_TEST_DISCOVERY_AI_KEY',
17
+ 'OM_TEST_DISCOVERY_FALLBACK_AI_KEY',
18
+ 'OM_TEST_DISCOVERY_REQUIRED_KEY',
19
+ ] as const
20
+ const previousTestEnvValues = new Map<string, string | undefined>()
15
21
 
16
22
  beforeEach(async () => {
17
23
  tempRoot = await mkdtemp(path.join(os.tmpdir(), 'om-integration-discovery-'))
18
24
  delete process.env.OM_ENABLE_ENTERPRISE_MODULES
25
+ for (const key of testEnvKeys) {
26
+ previousTestEnvValues.set(key, process.env[key])
27
+ delete process.env[key]
28
+ }
19
29
  })
20
30
 
21
31
  afterEach(async () => {
@@ -27,6 +37,15 @@ describe('integration discovery', () => {
27
37
  } else {
28
38
  process.env.OM_ENABLE_ENTERPRISE_MODULES = previousEnterpriseFlag
29
39
  }
40
+ for (const key of testEnvKeys) {
41
+ const previousValue = previousTestEnvValues.get(key)
42
+ if (previousValue === undefined) {
43
+ delete process.env[key]
44
+ } else {
45
+ process.env[key] = previousValue
46
+ }
47
+ }
48
+ previousTestEnvValues.clear()
30
49
  })
31
50
 
32
51
  it('applies folder and per-test metadata dependencies', async () => {
@@ -139,4 +158,53 @@ describe('integration discovery', () => {
139
158
  'node_modules/@open-mercato/core/src/modules/customers/__integration__/TC-CRM-020.spec.ts',
140
159
  ])
141
160
  })
161
+
162
+ it('applies folder and per-test metadata environment requirements', async () => {
163
+ await writeTestFile(tempRoot, 'packages/ai-assistant/src/modules/ai_assistant/.gitkeep')
164
+ await writeTestFile(
165
+ tempRoot,
166
+ 'packages/ai-assistant/src/modules/ai_assistant/__integration__/TC-AI-001.spec.ts',
167
+ 'export {}\n',
168
+ )
169
+ await writeTestFile(
170
+ tempRoot,
171
+ 'packages/ai-assistant/src/modules/ai_assistant/__integration__/TC-AI-002.spec.ts',
172
+ 'export {}\n',
173
+ )
174
+ await writeTestFile(
175
+ tempRoot,
176
+ 'packages/ai-assistant/src/modules/ai_assistant/__integration__/TC-AI-002.meta.ts',
177
+ "export const integrationMeta = { requiredAnyEnvVars: ['OM_TEST_DISCOVERY_AI_KEY', 'OM_TEST_DISCOVERY_FALLBACK_AI_KEY'] }\n",
178
+ )
179
+ await writeTestFile(
180
+ tempRoot,
181
+ 'packages/ai-assistant/src/modules/ai_assistant/__integration__/live/meta.ts',
182
+ "export const integrationMeta = { requiredEnvVars: ['OM_TEST_DISCOVERY_REQUIRED_KEY'] }\n",
183
+ )
184
+ await writeTestFile(
185
+ tempRoot,
186
+ 'packages/ai-assistant/src/modules/ai_assistant/__integration__/live/TC-AI-003.spec.ts',
187
+ 'export {}\n',
188
+ )
189
+
190
+ let discovered = discoverIntegrationSpecFiles(tempRoot, path.join(tempRoot, '.ai', 'qa', 'tests'))
191
+ expect(discovered.map((entry) => entry.path)).toEqual([
192
+ 'packages/ai-assistant/src/modules/ai_assistant/__integration__/TC-AI-001.spec.ts',
193
+ ])
194
+
195
+ process.env.OM_TEST_DISCOVERY_FALLBACK_AI_KEY = 'test-key'
196
+ discovered = discoverIntegrationSpecFiles(tempRoot, path.join(tempRoot, '.ai', 'qa', 'tests'))
197
+ expect(discovered.map((entry) => entry.path)).toEqual([
198
+ 'packages/ai-assistant/src/modules/ai_assistant/__integration__/TC-AI-001.spec.ts',
199
+ 'packages/ai-assistant/src/modules/ai_assistant/__integration__/TC-AI-002.spec.ts',
200
+ ])
201
+
202
+ process.env.OM_TEST_DISCOVERY_REQUIRED_KEY = 'required-key'
203
+ discovered = discoverIntegrationSpecFiles(tempRoot, path.join(tempRoot, '.ai', 'qa', 'tests'))
204
+ expect(discovered.map((entry) => entry.path)).toEqual([
205
+ 'packages/ai-assistant/src/modules/ai_assistant/__integration__/live/TC-AI-003.spec.ts',
206
+ 'packages/ai-assistant/src/modules/ai_assistant/__integration__/TC-AI-001.spec.ts',
207
+ 'packages/ai-assistant/src/modules/ai_assistant/__integration__/TC-AI-002.spec.ts',
208
+ ])
209
+ })
142
210
  })
@@ -6,6 +6,8 @@ export type IntegrationSpecDiscoveryItem = {
6
6
  moduleName: string | null
7
7
  isOverlay: boolean
8
8
  requiredModules: string[]
9
+ requiredEnvVars: string[]
10
+ requiredAnyEnvVars: string[]
9
11
  }
10
12
 
11
13
  const MODULE_INTEGRATION_DIRECTORY_NAME = '__integration__'
@@ -26,6 +28,8 @@ const DISCOVERY_IGNORED_DIRS = new Set([
26
28
  ])
27
29
  const INTEGRATION_META_FILE_NAMES = ['meta.ts', 'index.ts'] as const
28
30
  const INTEGRATION_META_DEPENDENCY_KEYS = ['dependsOnModules', 'requiredModules', 'requiresModules'] as const
31
+ const INTEGRATION_META_REQUIRED_ENV_KEYS = ['requiredEnvVars', 'requiresEnvVars'] as const
32
+ const INTEGRATION_META_REQUIRED_ANY_ENV_KEYS = ['requiredAnyEnvVars', 'requiresAnyEnvVars'] as const
29
33
  const DEFAULT_OVERLAY_ROOT = 'packages/enterprise'
30
34
 
31
35
  export function normalizePath(filePath: string): string {
@@ -160,15 +164,15 @@ function isOverlayIntegrationPath(relativePath: string, overlayRoot: string): bo
160
164
  return normalizePath(relativePath).startsWith(`${normalizePath(overlayRoot)}/`)
161
165
  }
162
166
 
163
- function extractDependencyListFromSource(source: string): string[] {
167
+ function extractStringArrayFromSource(source: string, keys: readonly string[]): string[] {
164
168
  const collected = new Set<string>()
165
- for (const key of INTEGRATION_META_DEPENDENCY_KEYS) {
169
+ for (const key of keys) {
166
170
  const keyPattern = new RegExp(`${key}\\s*:\\s*\\[([\\s\\S]*?)\\]`, 'm')
167
171
  const keyMatch = source.match(keyPattern)
168
172
  if (!keyMatch?.[1]) continue
169
173
  const valuesPattern = /['"`]([a-zA-Z0-9_.-]+)['"`]/g
170
174
  for (const valueMatch of keyMatch[1].matchAll(valuesPattern)) {
171
- const value = normalizeModuleId(valueMatch[1] ?? '')
175
+ const value = String(valueMatch[1] ?? '').trim()
172
176
  if (value) {
173
177
  collected.add(value)
174
178
  }
@@ -177,6 +181,18 @@ function extractDependencyListFromSource(source: string): string[] {
177
181
  return Array.from(collected)
178
182
  }
179
183
 
184
+ function extractDependencyListFromSource(source: string): string[] {
185
+ return extractStringArrayFromSource(source, INTEGRATION_META_DEPENDENCY_KEYS).map(normalizeModuleId)
186
+ }
187
+
188
+ function extractRequiredEnvVarsFromSource(source: string): string[] {
189
+ return extractStringArrayFromSource(source, INTEGRATION_META_REQUIRED_ENV_KEYS)
190
+ }
191
+
192
+ function extractRequiredAnyEnvVarsFromSource(source: string): string[] {
193
+ return extractStringArrayFromSource(source, INTEGRATION_META_REQUIRED_ANY_ENV_KEYS)
194
+ }
195
+
180
196
  function readDependenciesFromMetadataFile(absolutePath: string): string[] {
181
197
  try {
182
198
  return extractDependencyListFromSource(readFileSync(absolutePath, 'utf8'))
@@ -185,6 +201,22 @@ function readDependenciesFromMetadataFile(absolutePath: string): string[] {
185
201
  }
186
202
  }
187
203
 
204
+ function readRequiredEnvVarsFromMetadataFile(absolutePath: string): string[] {
205
+ try {
206
+ return extractRequiredEnvVarsFromSource(readFileSync(absolutePath, 'utf8'))
207
+ } catch {
208
+ return []
209
+ }
210
+ }
211
+
212
+ function readRequiredAnyEnvVarsFromMetadataFile(absolutePath: string): string[] {
213
+ try {
214
+ return extractRequiredAnyEnvVarsFromSource(readFileSync(absolutePath, 'utf8'))
215
+ } catch {
216
+ return []
217
+ }
218
+ }
219
+
188
220
  function resolveIntegrationRootDirectory(relativeSpecPath: string): string | null {
189
221
  const segments = normalizePath(relativeSpecPath).split('/')
190
222
  const integrationDirectoryIndex = segments.indexOf(MODULE_INTEGRATION_DIRECTORY_NAME)
@@ -231,6 +263,85 @@ function resolveRequiredModulesForSpec(projectRoot: string, relativeSpecPath: st
231
263
  return Array.from(requiredModules)
232
264
  }
233
265
 
266
+ function resolveRequiredEnvVarsForSpec(projectRoot: string, relativeSpecPath: string): string[] {
267
+ const requiredEnvVars = new Set<string>()
268
+ const normalizedSpecPath = normalizePath(relativeSpecPath)
269
+ const absoluteSpecPath = path.join(projectRoot, normalizedSpecPath)
270
+ const integrationRoot = resolveIntegrationRootDirectory(normalizedSpecPath)
271
+
272
+ if (integrationRoot) {
273
+ const relativeDirectory = normalizePath(path.dirname(normalizedSpecPath))
274
+ const integrationRootAbsolute = path.join(projectRoot, integrationRoot)
275
+ const directoryAbsolute = path.join(projectRoot, relativeDirectory)
276
+ const relativeFromIntegrationRoot = normalizePath(path.relative(integrationRootAbsolute, directoryAbsolute))
277
+ const pathSegments = relativeFromIntegrationRoot === '.'
278
+ ? []
279
+ : relativeFromIntegrationRoot.split('/').filter(Boolean)
280
+
281
+ let currentDirectory = integrationRootAbsolute
282
+ const traversal = [currentDirectory, ...pathSegments.map((segment) => {
283
+ currentDirectory = path.join(currentDirectory, segment)
284
+ return currentDirectory
285
+ })]
286
+
287
+ for (const directoryPath of traversal) {
288
+ for (const metadataFileName of INTEGRATION_META_FILE_NAMES) {
289
+ const metadataPath = path.join(directoryPath, metadataFileName)
290
+ readRequiredEnvVarsFromMetadataFile(metadataPath).forEach((envVar) => requiredEnvVars.add(envVar))
291
+ }
292
+ }
293
+ }
294
+
295
+ readRequiredEnvVarsFromMetadataFile(absoluteSpecPath).forEach((envVar) => requiredEnvVars.add(envVar))
296
+ const specFileName = path.basename(normalizedSpecPath, '.spec.ts')
297
+ const perTestMetadataPath = path.join(path.dirname(absoluteSpecPath), `${specFileName}.meta.ts`)
298
+ readRequiredEnvVarsFromMetadataFile(perTestMetadataPath).forEach((envVar) => requiredEnvVars.add(envVar))
299
+
300
+ return Array.from(requiredEnvVars)
301
+ }
302
+
303
+ function resolveRequiredAnyEnvVarsForSpec(projectRoot: string, relativeSpecPath: string): string[] {
304
+ const requiredAnyEnvVars = new Set<string>()
305
+ const normalizedSpecPath = normalizePath(relativeSpecPath)
306
+ const absoluteSpecPath = path.join(projectRoot, normalizedSpecPath)
307
+ const integrationRoot = resolveIntegrationRootDirectory(normalizedSpecPath)
308
+
309
+ if (integrationRoot) {
310
+ const relativeDirectory = normalizePath(path.dirname(normalizedSpecPath))
311
+ const integrationRootAbsolute = path.join(projectRoot, integrationRoot)
312
+ const directoryAbsolute = path.join(projectRoot, relativeDirectory)
313
+ const relativeFromIntegrationRoot = normalizePath(path.relative(integrationRootAbsolute, directoryAbsolute))
314
+ const pathSegments = relativeFromIntegrationRoot === '.'
315
+ ? []
316
+ : relativeFromIntegrationRoot.split('/').filter(Boolean)
317
+
318
+ let currentDirectory = integrationRootAbsolute
319
+ const traversal = [currentDirectory, ...pathSegments.map((segment) => {
320
+ currentDirectory = path.join(currentDirectory, segment)
321
+ return currentDirectory
322
+ })]
323
+
324
+ for (const directoryPath of traversal) {
325
+ for (const metadataFileName of INTEGRATION_META_FILE_NAMES) {
326
+ const metadataPath = path.join(directoryPath, metadataFileName)
327
+ readRequiredAnyEnvVarsFromMetadataFile(metadataPath).forEach((envVar) => requiredAnyEnvVars.add(envVar))
328
+ }
329
+ }
330
+ }
331
+
332
+ readRequiredAnyEnvVarsFromMetadataFile(absoluteSpecPath).forEach((envVar) => requiredAnyEnvVars.add(envVar))
333
+ const specFileName = path.basename(normalizedSpecPath, '.spec.ts')
334
+ const perTestMetadataPath = path.join(path.dirname(absoluteSpecPath), `${specFileName}.meta.ts`)
335
+ readRequiredAnyEnvVarsFromMetadataFile(perTestMetadataPath).forEach((envVar) => requiredAnyEnvVars.add(envVar))
336
+
337
+ return Array.from(requiredAnyEnvVars)
338
+ }
339
+
340
+ function isEnvironmentVariableConfigured(name: string): boolean {
341
+ const value = process.env[name]
342
+ return typeof value === 'string' && value.trim().length > 0
343
+ }
344
+
234
345
  export function discoverIntegrationSpecFiles(projectRoot: string, legacyIntegrationRoot: string): IntegrationSpecDiscoveryItem[] {
235
346
  const discoveredByPath = new Map<string, IntegrationSpecDiscoveryItem>()
236
347
  const overlayRoot = resolveOverlayRootPath()
@@ -249,6 +360,8 @@ export function discoverIntegrationSpecFiles(projectRoot: string, legacyIntegrat
249
360
  moduleName: extractModuleNameFromIntegrationPath(relativePath),
250
361
  isOverlay: isOverlayIntegrationPath(relativePath, overlayRoot),
251
362
  requiredModules: resolveRequiredModulesForSpec(projectRoot, relativePath),
363
+ requiredEnvVars: resolveRequiredEnvVarsForSpec(projectRoot, relativePath),
364
+ requiredAnyEnvVars: resolveRequiredAnyEnvVarsForSpec(projectRoot, relativePath),
252
365
  })
253
366
  }
254
367
 
@@ -261,6 +374,8 @@ export function discoverIntegrationSpecFiles(projectRoot: string, legacyIntegrat
261
374
  moduleName: extractModuleNameFromIntegrationPath(relativePath),
262
375
  isOverlay: isOverlayIntegrationPath(relativePath, overlayRoot),
263
376
  requiredModules: resolveRequiredModulesForSpec(projectRoot, relativePath),
377
+ requiredEnvVars: resolveRequiredEnvVarsForSpec(projectRoot, relativePath),
378
+ requiredAnyEnvVars: resolveRequiredAnyEnvVarsForSpec(projectRoot, relativePath),
264
379
  })
265
380
  }
266
381
  }
@@ -280,6 +395,15 @@ export function discoverIntegrationSpecFiles(projectRoot: string, legacyIntegrat
280
395
  if (file.requiredModules.some((moduleId) => !enabledModules.has(normalizeModuleId(moduleId)))) {
281
396
  return false
282
397
  }
398
+ if (file.requiredEnvVars.some((envVar) => !isEnvironmentVariableConfigured(envVar))) {
399
+ return false
400
+ }
401
+ if (
402
+ file.requiredAnyEnvVars.length > 0 &&
403
+ !file.requiredAnyEnvVars.some((envVar) => isEnvironmentVariableConfigured(envVar))
404
+ ) {
405
+ return false
406
+ }
283
407
  if (!file.isOverlay) {
284
408
  return true
285
409
  }