agent-scenario-loop 0.1.0

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -0
  3. package/app/profile-session.ts +812 -0
  4. package/core/config-template.json +41 -0
  5. package/dist/core/agent-summary.d.ts +15 -0
  6. package/dist/core/agent-summary.js +177 -0
  7. package/dist/core/artifact-contract.d.ts +151 -0
  8. package/dist/core/artifact-contract.js +897 -0
  9. package/dist/core/artifact-layout.d.ts +56 -0
  10. package/dist/core/artifact-layout.js +61 -0
  11. package/dist/core/artifact-writer.d.ts +44 -0
  12. package/dist/core/artifact-writer.js +55 -0
  13. package/dist/core/comparison.d.ts +133 -0
  14. package/dist/core/comparison.js +294 -0
  15. package/dist/core/evidence-interpreter.d.ts +28 -0
  16. package/dist/core/evidence-interpreter.js +69 -0
  17. package/dist/core/execution-plan.d.ts +44 -0
  18. package/dist/core/execution-plan.js +95 -0
  19. package/dist/core/planner.d.ts +132 -0
  20. package/dist/core/planner.js +812 -0
  21. package/dist/core/ports.d.ts +198 -0
  22. package/dist/core/ports.js +146 -0
  23. package/dist/core/run-index.d.ts +62 -0
  24. package/dist/core/run-index.js +143 -0
  25. package/dist/core/schema-validator.d.ts +86 -0
  26. package/dist/core/schema-validator.js +407 -0
  27. package/dist/index.d.ts +11 -0
  28. package/dist/index.js +27 -0
  29. package/dist/runner/agent-device-driver.d.ts +126 -0
  30. package/dist/runner/agent-device-driver.js +168 -0
  31. package/dist/runner/agent-device.d.ts +295 -0
  32. package/dist/runner/agent-device.js +1271 -0
  33. package/dist/runner/android-adb-driver.d.ts +175 -0
  34. package/dist/runner/android-adb-driver.js +399 -0
  35. package/dist/runner/android-adb.d.ts +254 -0
  36. package/dist/runner/android-adb.js +1618 -0
  37. package/dist/runner/argent-driver.d.ts +183 -0
  38. package/dist/runner/argent-driver.js +297 -0
  39. package/dist/runner/argent.d.ts +349 -0
  40. package/dist/runner/argent.js +1211 -0
  41. package/dist/runner/check-plan.d.ts +45 -0
  42. package/dist/runner/check-plan.js +210 -0
  43. package/dist/runner/cli.d.ts +20 -0
  44. package/dist/runner/cli.js +23 -0
  45. package/dist/runner/compare-latest.d.ts +99 -0
  46. package/dist/runner/compare-latest.js +233 -0
  47. package/dist/runner/compare.d.ts +58 -0
  48. package/dist/runner/compare.js +157 -0
  49. package/dist/runner/demo-loop.d.ts +45 -0
  50. package/dist/runner/demo-loop.js +170 -0
  51. package/dist/runner/example-android-live.d.ts +137 -0
  52. package/dist/runner/example-android-live.js +454 -0
  53. package/dist/runner/example-ios-live.d.ts +137 -0
  54. package/dist/runner/example-ios-live.js +471 -0
  55. package/dist/runner/host-doctor.d.ts +131 -0
  56. package/dist/runner/host-doctor.js +628 -0
  57. package/dist/runner/init-project.d.ts +88 -0
  58. package/dist/runner/init-project.js +263 -0
  59. package/dist/runner/ios-simctl-driver.d.ts +69 -0
  60. package/dist/runner/ios-simctl-driver.js +97 -0
  61. package/dist/runner/ios-simctl.d.ts +254 -0
  62. package/dist/runner/ios-simctl.js +1415 -0
  63. package/dist/runner/live-android.d.ts +137 -0
  64. package/dist/runner/live-android.js +539 -0
  65. package/dist/runner/live-comparison.d.ts +67 -0
  66. package/dist/runner/live-comparison.js +147 -0
  67. package/dist/runner/live-ios.d.ts +137 -0
  68. package/dist/runner/live-ios.js +460 -0
  69. package/dist/runner/live-proof-summary.d.ts +263 -0
  70. package/dist/runner/live-proof-summary.js +465 -0
  71. package/dist/runner/live-proof.d.ts +467 -0
  72. package/dist/runner/live-proof.js +920 -0
  73. package/dist/runner/local-env.d.ts +64 -0
  74. package/dist/runner/local-env.js +155 -0
  75. package/dist/runner/profile-android.d.ts +82 -0
  76. package/dist/runner/profile-android.js +671 -0
  77. package/dist/runner/profile-ios.d.ts +108 -0
  78. package/dist/runner/profile-ios.js +532 -0
  79. package/dist/runner/profile-mobile.d.ts +254 -0
  80. package/dist/runner/profile-mobile.js +1307 -0
  81. package/dist/runner/validate-project.d.ts +273 -0
  82. package/dist/runner/validate-project.js +1501 -0
  83. package/docs/adapters.md +145 -0
  84. package/docs/api.md +94 -0
  85. package/docs/authoring.md +196 -0
  86. package/docs/concepts.md +136 -0
  87. package/docs/consumer-rehearsal.md +115 -0
  88. package/docs/contracts.md +267 -0
  89. package/docs/live-proofs.md +270 -0
  90. package/docs/principles.md +46 -0
  91. package/examples/event-logs/app-startup-baseline.log +4 -0
  92. package/examples/event-logs/app-startup-current.log +4 -0
  93. package/examples/minimal-app/README.md +70 -0
  94. package/examples/mobile-app/README.md +302 -0
  95. package/examples/mobile-app/app.json +22 -0
  96. package/examples/mobile-app/asl/package-scripts.json +32 -0
  97. package/examples/mobile-app/asl.config.json +37 -0
  98. package/examples/mobile-app/event-logs/android-app-startup.log +4 -0
  99. package/examples/mobile-app/event-logs/android-open-close-cycle.log +12 -0
  100. package/examples/mobile-app/event-logs/android-scroll-settle.log +12 -0
  101. package/examples/mobile-app/event-logs/app-startup.log +4 -0
  102. package/examples/mobile-app/event-logs/open-close-cycle.log +12 -0
  103. package/examples/mobile-app/event-logs/scroll-settle.log +12 -0
  104. package/examples/mobile-app/index.ts +20 -0
  105. package/examples/mobile-app/metro.config.js +20 -0
  106. package/examples/mobile-app/package.json +62 -0
  107. package/examples/mobile-app/patches/expo-modules-jsi@56.0.10.patch +19 -0
  108. package/examples/mobile-app/plugins/with-ios-build-compat.js +271 -0
  109. package/examples/mobile-app/pnpm-lock.yaml +4440 -0
  110. package/examples/mobile-app/runner-manifests/evidence-provider.json +79 -0
  111. package/examples/mobile-app/runner-manifests/primary-runner.json +19 -0
  112. package/examples/mobile-app/scenarios/android/app-startup-video.json +73 -0
  113. package/examples/mobile-app/scenarios/android/app-startup.json +44 -0
  114. package/examples/mobile-app/scenarios/android/open-close-cycle.json +54 -0
  115. package/examples/mobile-app/scenarios/android/scroll-settle.json +49 -0
  116. package/examples/mobile-app/scenarios/ios/app-startup.json +44 -0
  117. package/examples/mobile-app/scenarios/ios/open-close-cycle.json +54 -0
  118. package/examples/mobile-app/scenarios/ios/scroll-settle.json +49 -0
  119. package/examples/mobile-app/scenarios/mobile/app-startup.json +91 -0
  120. package/examples/mobile-app/scenarios/mobile/open-close-cycle.json +160 -0
  121. package/examples/mobile-app/scenarios/mobile/scroll-settle.json +148 -0
  122. package/examples/mobile-app/scripts/asl-capture-accessibility-provider.mjs +112 -0
  123. package/examples/mobile-app/scripts/asl-capture-profiler-provider.mjs +127 -0
  124. package/examples/mobile-app/src/devtools/profile-session.ts +7 -0
  125. package/examples/mobile-app/src/example-screen.tsx +322 -0
  126. package/examples/mobile-app/tsconfig.json +16 -0
  127. package/examples/mobile-app/tsconfig.typecheck.json +13 -0
  128. package/examples/runners/README.md +44 -0
  129. package/examples/runners/adb-android.json +25 -0
  130. package/examples/runners/agent-device-android.json +27 -0
  131. package/examples/runners/agent-device-ios.json +27 -0
  132. package/examples/runners/argent-android.json +32 -0
  133. package/examples/runners/argent-ios.json +32 -0
  134. package/examples/runners/argent-react-profiler-provider.json +15 -0
  135. package/examples/runners/axe-accessibility-provider.json +24 -0
  136. package/examples/runners/manual-log-ingest.json +9 -0
  137. package/examples/runners/rozenite-profiler-provider.json +9 -0
  138. package/examples/runners/script-accessibility-provider.json +24 -0
  139. package/examples/runners/script-memory-provider.json +24 -0
  140. package/examples/runners/script-network-provider.json +24 -0
  141. package/examples/runners/script-profiler-provider.json +30 -0
  142. package/examples/runners/xcodebuildmcp-ios.json +29 -0
  143. package/examples/scenarios/ios/app-startup.json +28 -0
  144. package/examples/scenarios/ios/open-close-cycle.json +35 -0
  145. package/examples/scenarios/mobile/app-startup.json +72 -0
  146. package/examples/scenarios/mobile/media-open-close.json +141 -0
  147. package/examples/scenarios/mobile/open-close-cycle.json +135 -0
  148. package/examples/scenarios/mobile/scroll-settle.json +106 -0
  149. package/package.json +240 -0
  150. package/schemas/budget-verdict.schema.json +115 -0
  151. package/schemas/causal-run.schema.json +279 -0
  152. package/schemas/comparison.schema.json +196 -0
  153. package/schemas/health.schema.json +108 -0
  154. package/schemas/live-proof-set.schema.json +195 -0
  155. package/schemas/live-proof.schema.json +413 -0
  156. package/schemas/manifest.schema.json +204 -0
  157. package/schemas/metrics.schema.json +137 -0
  158. package/schemas/project-validation.schema.json +343 -0
  159. package/schemas/runner-capabilities.schema.json +217 -0
  160. package/schemas/scenario.schema.json +400 -0
  161. package/schemas/verdict.schema.json +88 -0
  162. package/templates/evidence-provider.json +83 -0
  163. package/templates/gitignore-snippet +9 -0
  164. package/templates/integration-readme.md +125 -0
  165. package/templates/mobile-scenario.json +133 -0
  166. package/templates/package-scripts.json +32 -0
  167. package/templates/primary-runner.json +19 -0
  168. package/templates/project.config.json +37 -0
  169. package/templates/scripts/asl-capture-accessibility-provider.mjs +112 -0
  170. package/templates/scripts/asl-capture-profiler-provider.mjs +127 -0
@@ -0,0 +1,1501 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.buildValidationRunId = buildValidationRunId;
5
+ exports.formatResult = formatResult;
6
+ exports.listScenarioFiles = listScenarioFiles;
7
+ exports.listScenarioFilesFromDirectories = listScenarioFilesFromDirectories;
8
+ exports.main = main;
9
+ exports.parseArgs = parseArgs;
10
+ exports.resolveScenarioDirectories = resolveScenarioDirectories;
11
+ exports.resolvePlatforms = resolvePlatforms;
12
+ exports.usage = usage;
13
+ exports.buildNextActions = buildNextActions;
14
+ exports.validateConfigPlaceholders = validateConfigPlaceholders;
15
+ exports.validateProjectConfig = validateProjectConfig;
16
+ exports.validateGitignore = validateGitignore;
17
+ exports.validatePackageJsonScripts = validatePackageJsonScripts;
18
+ exports.validateProject = validateProject;
19
+ exports.validateAppHelper = validateAppHelper;
20
+ exports.validatePackageScriptShape = validatePackageScriptShape;
21
+ exports.validatePackageScripts = validatePackageScripts;
22
+ exports.validateProviderCommandReferences = validateProviderCommandReferences;
23
+ const fs = require('node:fs');
24
+ const fsp = require('node:fs/promises');
25
+ const path = require('node:path');
26
+ const { writeJsonArtifact, writeTextArtifact } = require('../core/artifact-writer');
27
+ const { SCHEMAS } = require('../core/schema-validator');
28
+ const { hasHelpFlag, writeUsage } = require('./cli');
29
+ const { buildPlanArtifacts } = require('./check-plan');
30
+ const REQUIRED_APP_HELPER_EXPORTS = [
31
+ 'emitProfileEvent',
32
+ 'registerProfileCommandTargetHandler',
33
+ 'useProfileSessionBootstrap',
34
+ ];
35
+ const REQUIRED_PACKAGE_SCRIPT_NAMES = [
36
+ 'asl:check:ios',
37
+ 'asl:check:android',
38
+ 'asl:validate',
39
+ 'asl:host:doctor',
40
+ 'asl:profile:ios',
41
+ 'asl:profile:android',
42
+ 'asl:profile:ios:provider',
43
+ 'asl:profile:android:provider',
44
+ 'asl:agent-device:check',
45
+ 'asl:agent-device:ios',
46
+ 'asl:agent-device:android',
47
+ 'asl:argent:check',
48
+ 'asl:argent:ios',
49
+ 'asl:argent:android',
50
+ 'asl:ios:live',
51
+ 'asl:android:live',
52
+ 'asl:ios:live:agent-device',
53
+ 'asl:android:live:agent-device',
54
+ 'asl:ios:live:argent',
55
+ 'asl:android:live:argent',
56
+ 'asl:ios:live:runners',
57
+ 'asl:android:live:runners',
58
+ 'asl:profile:ios:live',
59
+ 'asl:profile:android:live',
60
+ 'asl:compare:ios',
61
+ 'asl:compare:android',
62
+ 'asl:live-proof:ios',
63
+ 'asl:live-proof:android',
64
+ 'asl:live-proof:both',
65
+ 'asl:live-proof',
66
+ ];
67
+ const PATH_ARGUMENT_FLAGS = new Set(['--config', '--provider', '--runner', '--scenario']);
68
+ const REQUIRED_PACKAGE_SCRIPT_SHAPES = {
69
+ 'asl:check:ios': {
70
+ command: 'asl-check-plan',
71
+ flags: ['--scenario', '--runner', '--provider', '--platform', '--out'],
72
+ values: { '--platform': 'ios' },
73
+ },
74
+ 'asl:check:android': {
75
+ command: 'asl-check-plan',
76
+ flags: ['--scenario', '--runner', '--provider', '--platform', '--out'],
77
+ values: { '--platform': 'android' },
78
+ },
79
+ 'asl:validate': {
80
+ command: 'asl-validate-project',
81
+ flags: ['--root', '--platform', '--out'],
82
+ values: {},
83
+ },
84
+ 'asl:host:doctor': {
85
+ command: 'asl-host-doctor',
86
+ flags: ['--out'],
87
+ values: {},
88
+ },
89
+ 'asl:profile:ios': {
90
+ command: 'asl-profile-ios',
91
+ flags: ['--config', '--scenario', '--comparison-lane', '--out', '--run-id'],
92
+ values: {},
93
+ },
94
+ 'asl:profile:android': {
95
+ command: 'asl-profile-android',
96
+ flags: ['--config', '--scenario', '--comparison-lane', '--out', '--run-id'],
97
+ values: {},
98
+ },
99
+ 'asl:profile:ios:provider': {
100
+ command: 'asl-profile-ios',
101
+ flags: ['--config', '--scenario', '--provider', '--comparison-lane', '--out', '--run-id'],
102
+ values: {},
103
+ },
104
+ 'asl:profile:android:provider': {
105
+ command: 'asl-profile-android',
106
+ flags: ['--config', '--scenario', '--provider', '--comparison-lane', '--out', '--run-id'],
107
+ values: {},
108
+ },
109
+ 'asl:agent-device:check': {
110
+ command: 'asl-agent-device',
111
+ flags: ['--check', '--out'],
112
+ values: {},
113
+ },
114
+ 'asl:agent-device:ios': {
115
+ command: 'asl-agent-device',
116
+ flags: ['--platform', '--scenario', '--app', '--open', '--out', '--run-id'],
117
+ values: { '--platform': 'ios' },
118
+ },
119
+ 'asl:agent-device:android': {
120
+ command: 'asl-agent-device',
121
+ flags: ['--platform', '--scenario', '--app', '--open', '--out', '--run-id'],
122
+ values: { '--platform': 'android' },
123
+ },
124
+ 'asl:argent:check': {
125
+ command: 'asl-argent',
126
+ flags: ['--check', '--out'],
127
+ values: {},
128
+ },
129
+ 'asl:argent:ios': {
130
+ command: 'asl-argent',
131
+ flags: ['--platform', '--scenario', '--app', '--device', '--out', '--run-id'],
132
+ values: { '--platform': 'ios' },
133
+ },
134
+ 'asl:argent:android': {
135
+ command: 'asl-argent',
136
+ flags: ['--platform', '--scenario', '--app', '--device', '--out', '--run-id'],
137
+ values: { '--platform': 'android' },
138
+ },
139
+ 'asl:ios:live': {
140
+ command: 'asl-live-ios',
141
+ flags: ['--config', '--scenario', '--out', '--compare-latest', '--fail-on-regression'],
142
+ values: {},
143
+ },
144
+ 'asl:android:live': {
145
+ command: 'asl-live-android',
146
+ flags: ['--config', '--scenario', '--out', '--compare-latest', '--fail-on-regression'],
147
+ values: {},
148
+ },
149
+ 'asl:ios:live:agent-device': {
150
+ command: 'asl-live-ios',
151
+ flags: ['--config', '--scenario', '--out', '--agent-device-proof', '--compare-latest', '--fail-on-regression'],
152
+ values: {},
153
+ },
154
+ 'asl:android:live:agent-device': {
155
+ command: 'asl-live-android',
156
+ flags: ['--config', '--scenario', '--out', '--agent-device-proof', '--compare-latest', '--fail-on-regression'],
157
+ values: {},
158
+ },
159
+ 'asl:ios:live:argent': {
160
+ command: 'asl-live-ios',
161
+ flags: ['--config', '--scenario', '--out', '--argent-proof', '--compare-latest', '--fail-on-regression'],
162
+ values: {},
163
+ },
164
+ 'asl:android:live:argent': {
165
+ command: 'asl-live-android',
166
+ flags: ['--config', '--scenario', '--out', '--argent-proof', '--compare-latest', '--fail-on-regression'],
167
+ values: {},
168
+ },
169
+ 'asl:ios:live:runners': {
170
+ command: 'asl-live-ios',
171
+ flags: ['--config', '--scenario', '--out', '--agent-device-proof', '--argent-proof', '--compare-latest', '--fail-on-regression'],
172
+ values: {},
173
+ },
174
+ 'asl:android:live:runners': {
175
+ command: 'asl-live-android',
176
+ flags: ['--config', '--scenario', '--out', '--agent-device-proof', '--argent-proof', '--compare-latest', '--fail-on-regression'],
177
+ values: {},
178
+ },
179
+ 'asl:profile:ios:live': {
180
+ command: 'asl-profile-ios',
181
+ flags: ['--config', '--scenario', '--simctl-capture', '--profile-session', '--launch', '--comparison-lane', '--out', '--run-id'],
182
+ values: {},
183
+ },
184
+ 'asl:profile:android:live': {
185
+ command: 'asl-profile-android',
186
+ flags: ['--config', '--scenario', '--adb-capture', '--profile-session', '--launch', '--comparison-lane', '--out', '--run-id'],
187
+ values: {},
188
+ },
189
+ 'asl:compare:ios': {
190
+ command: 'asl-compare-latest',
191
+ flags: ['--root', '--scenario', '--current', '--out', '--fail-on-regression'],
192
+ values: {},
193
+ },
194
+ 'asl:compare:android': {
195
+ command: 'asl-compare-latest',
196
+ flags: ['--root', '--scenario', '--current', '--out', '--fail-on-regression'],
197
+ values: {},
198
+ },
199
+ 'asl:live-proof:ios': {
200
+ command: 'asl-live-proof',
201
+ flags: ['--file', '--fail-on-regression'],
202
+ values: {},
203
+ },
204
+ 'asl:live-proof:android': {
205
+ command: 'asl-live-proof',
206
+ flags: ['--file', '--fail-on-regression'],
207
+ values: {},
208
+ },
209
+ 'asl:live-proof:both': {
210
+ command: 'asl-live-proof',
211
+ flags: ['--file', '--require-platforms', '--out', '--fail-on-regression'],
212
+ values: { '--require-platforms': 'android,ios' },
213
+ },
214
+ 'asl:live-proof': {
215
+ command: 'asl-live-proof',
216
+ flags: ['--file', '--fail-on-regression'],
217
+ values: {},
218
+ },
219
+ };
220
+ const CONFIG_PLACEHOLDER_VALUES = [
221
+ {
222
+ path: ['projectName'],
223
+ values: ['replace-me'],
224
+ },
225
+ {
226
+ path: ['app', 'displayName'],
227
+ values: ['Example App'],
228
+ },
229
+ {
230
+ path: ['app', 'scheme'],
231
+ values: ['example-app'],
232
+ },
233
+ {
234
+ path: ['app', 'profileSessionScheme'],
235
+ values: ['example-app'],
236
+ },
237
+ {
238
+ path: ['app', 'iosBundleId'],
239
+ values: ['com.example.app'],
240
+ },
241
+ {
242
+ path: ['app', 'androidPackage'],
243
+ values: ['com.example.app'],
244
+ },
245
+ {
246
+ path: ['app', 'ios', 'xcodeScheme'],
247
+ values: ['Example App'],
248
+ },
249
+ ];
250
+ const REQUIRED_GITIGNORE_PATTERNS = [
251
+ '.asl.local.env',
252
+ '*.memgraph',
253
+ '*.trace',
254
+ '*.xcresult',
255
+ ];
256
+ const CONFIG_ARTIFACT_ROOT_FIELDS = [
257
+ ['paths', 'artifactRoot'],
258
+ ['paths', 'iosArtifactsRoot'],
259
+ ['paths', 'androidArtifactsRoot'],
260
+ ];
261
+ const PACKAGE_SUPPORTED_DRIVERS = [
262
+ 'fixture-log-ingest',
263
+ 'adb',
264
+ 'ios-simctl',
265
+ 'agent-device',
266
+ 'argent',
267
+ ];
268
+ const KNOWN_EXTERNAL_TARGET_DRIVERS = [
269
+ 'axe',
270
+ 'xcodebuildmcp',
271
+ ];
272
+ const REQUIRED_COMMON_CONFIG_STRING_FIELDS = [
273
+ ['app', 'profileSessionScheme'],
274
+ ['drivers', 'supported'],
275
+ ];
276
+ const PLATFORM_CONFIG_STRING_FIELDS = {
277
+ android: [
278
+ ['app', 'androidPackage'],
279
+ ],
280
+ ios: [
281
+ ['app', 'iosBundleId'],
282
+ ],
283
+ };
284
+ const PLATFORM_ARTIFACT_ROOT_FIELDS = {
285
+ android: ['paths', 'androidArtifactsRoot'],
286
+ ios: ['paths', 'iosArtifactsRoot'],
287
+ };
288
+ const PLATFORM_SCENARIO_ROOT_FIELDS = {
289
+ android: ['paths', 'androidScenarioRoot'],
290
+ ios: ['paths', 'iosScenarioRoot'],
291
+ };
292
+ /**
293
+ * Prints CLI usage.
294
+ *
295
+ * @param {{write: (message: string) => unknown}} [output]
296
+ * @returns {void}
297
+ */
298
+ function usage(output = process.stderr) {
299
+ writeUsage([
300
+ 'Usage: asl-validate-project [--root <dir>] [--config <file>] [--platform <ios|android|all>] [--out <dir>]',
301
+ '',
302
+ 'Validates an initialized Agent Scenario Loop project before live device execution.',
303
+ 'Checks config presence, scenario manifests, runner manifests, and planner compatibility.',
304
+ ], output);
305
+ }
306
+ /**
307
+ * Parses the small flag set for the project validator.
308
+ *
309
+ * @param {string[]} argv
310
+ * @returns {CliArgs}
311
+ */
312
+ function parseArgs(argv) {
313
+ const args = {};
314
+ for (let index = 0; index < argv.length; index += 1) {
315
+ const token = argv[index];
316
+ if (!token || !token.startsWith('--')) {
317
+ continue;
318
+ }
319
+ const key = token.slice(2);
320
+ const next = argv[index + 1];
321
+ if (next && !next.startsWith('--')) {
322
+ args[key] = next;
323
+ index += 1;
324
+ }
325
+ else {
326
+ args[key] = true;
327
+ }
328
+ }
329
+ return args;
330
+ }
331
+ /**
332
+ * Reads and parses a JSON file.
333
+ *
334
+ * @param {string} filePath
335
+ * @returns {Record<string, unknown>}
336
+ */
337
+ function readJson(filePath) {
338
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
339
+ }
340
+ /**
341
+ * Reads a nested string property from an object.
342
+ *
343
+ * @param {Record<string, unknown>} source
344
+ * @param {string[]} pathSegments
345
+ * @returns {string | null}
346
+ */
347
+ function readNestedString(source, pathSegments) {
348
+ let value = source;
349
+ for (const segment of pathSegments) {
350
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
351
+ return null;
352
+ }
353
+ value = value[segment];
354
+ }
355
+ return typeof value === 'string' ? value : null;
356
+ }
357
+ /**
358
+ * Reads a nested value from an object.
359
+ *
360
+ * @param {Record<string, unknown>} source
361
+ * @param {string[]} pathSegments
362
+ * @returns {unknown}
363
+ */
364
+ function readNestedValue(source, pathSegments) {
365
+ let value = source;
366
+ for (const segment of pathSegments) {
367
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
368
+ return undefined;
369
+ }
370
+ value = value[segment];
371
+ }
372
+ return value;
373
+ }
374
+ /**
375
+ * Returns true when a config value is a non-empty string.
376
+ *
377
+ * @param {unknown} value
378
+ * @returns {value is string}
379
+ */
380
+ function isNonEmptyString(value) {
381
+ return typeof value === 'string' && value.trim().length > 0;
382
+ }
383
+ /**
384
+ * Resolves the package root for bin-name checks from source or built CLI execution.
385
+ *
386
+ * @returns {string}
387
+ */
388
+ function defaultPackageRoot() {
389
+ return path.resolve(__dirname, '..', '..');
390
+ }
391
+ /**
392
+ * Lists sorted JSON files from a directory, returning an empty list when absent.
393
+ *
394
+ * @param {string} directory
395
+ * @returns {string[]}
396
+ */
397
+ function listJsonFiles(directory) {
398
+ if (!fs.existsSync(directory)) {
399
+ return [];
400
+ }
401
+ return fs
402
+ .readdirSync(directory)
403
+ .filter((name) => name.endsWith('.json'))
404
+ .sort()
405
+ .map((name) => path.join(directory, name));
406
+ }
407
+ /**
408
+ * Resolves a config path relative to the project root, falling back to the current cwd for repo-local examples.
409
+ *
410
+ * @param {{rootDir: string, value: string}} options
411
+ * @returns {string}
412
+ */
413
+ function resolveProjectPath({ rootDir, value, }) {
414
+ if (path.isAbsolute(value)) {
415
+ return value;
416
+ }
417
+ const projectRelative = path.resolve(rootDir, value);
418
+ if (fs.existsSync(projectRelative)) {
419
+ return projectRelative;
420
+ }
421
+ return path.resolve(value);
422
+ }
423
+ /**
424
+ * Adds a directory and common scenario subdirectories to a candidate set.
425
+ *
426
+ * @param {{candidates: Set<string>, directory: string, platform?: string}} options
427
+ * @returns {void}
428
+ */
429
+ function addScenarioDirectoryCandidates({ candidates, directory, platform, }) {
430
+ candidates.add(directory);
431
+ candidates.add(path.join(directory, 'mobile'));
432
+ if (platform) {
433
+ candidates.add(path.join(directory, platform));
434
+ }
435
+ }
436
+ /**
437
+ * Resolves scenario directories from project config plus the scaffold default.
438
+ *
439
+ * @param {{configPath: string, requestedPlatform: string, rootDir: string}} options
440
+ * @returns {string[]}
441
+ */
442
+ function resolveScenarioDirectories({ configPath, requestedPlatform, rootDir, }) {
443
+ const candidates = new Set();
444
+ addScenarioDirectoryCandidates({
445
+ candidates,
446
+ directory: path.join(rootDir, 'scenarios'),
447
+ });
448
+ if (!fs.existsSync(configPath)) {
449
+ return [...candidates].filter((directory) => fs.existsSync(directory)).sort();
450
+ }
451
+ const config = readJson(configPath);
452
+ const platformRoots = [
453
+ ...(['ios', 'all'].includes(requestedPlatform) ? [{ key: 'iosScenarioRoot', platform: 'ios' }] : []),
454
+ ...(['android', 'all'].includes(requestedPlatform) ? [{ key: 'androidScenarioRoot', platform: 'android' }] : []),
455
+ ];
456
+ for (const { key, platform } of platformRoots) {
457
+ const configuredRoot = readNestedString(config, ['paths', key]);
458
+ if (configuredRoot) {
459
+ addScenarioDirectoryCandidates({
460
+ candidates,
461
+ directory: resolveProjectPath({ rootDir, value: configuredRoot }),
462
+ platform,
463
+ });
464
+ }
465
+ }
466
+ const commonRoot = readNestedString(config, ['paths', 'scenarioRoot']);
467
+ if (commonRoot) {
468
+ addScenarioDirectoryCandidates({
469
+ candidates,
470
+ directory: resolveProjectPath({ rootDir, value: commonRoot }),
471
+ ...(requestedPlatform !== 'all' ? { platform: requestedPlatform } : {}),
472
+ });
473
+ }
474
+ return [...candidates].filter((directory) => fs.existsSync(directory)).sort();
475
+ }
476
+ /**
477
+ * Lists scenario JSON files from resolved scenario directories.
478
+ *
479
+ * @param {string[]} scenarioCandidateDirectories
480
+ * @returns {string[]}
481
+ */
482
+ function listScenarioFilesFromDirectories(scenarioCandidateDirectories) {
483
+ const scenarioPaths = new Set();
484
+ for (const directory of scenarioCandidateDirectories) {
485
+ for (const scenarioPath of listJsonFiles(directory)) {
486
+ scenarioPaths.add(scenarioPath);
487
+ }
488
+ }
489
+ return [...scenarioPaths].sort();
490
+ }
491
+ /**
492
+ * Lists scenario JSON files from configured scenario directories.
493
+ *
494
+ * @param {{configPath: string, requestedPlatform: string, rootDir: string}} options
495
+ * @returns {string[]}
496
+ */
497
+ function listScenarioFiles({ configPath, requestedPlatform, rootDir, }) {
498
+ return listScenarioFilesFromDirectories(resolveScenarioDirectories({ configPath, requestedPlatform, rootDir }));
499
+ }
500
+ /**
501
+ * Resolves platforms that should be validated for one scenario.
502
+ *
503
+ * @param {{requestedPlatform: string, scenario: Record<string, unknown>}} options
504
+ * @returns {string[]}
505
+ */
506
+ function resolvePlatforms({ requestedPlatform, scenario, }) {
507
+ if (requestedPlatform !== 'all') {
508
+ return [requestedPlatform];
509
+ }
510
+ const platforms = Array.isArray(scenario.platforms)
511
+ ? scenario.platforms.filter((platform) => typeof platform === 'string')
512
+ : [];
513
+ return platforms.length > 0 ? platforms : ['ios', 'android'];
514
+ }
515
+ /**
516
+ * Builds a stable run id for one project-validation plan check.
517
+ *
518
+ * @param {{platform: string, scenarioId: string}} options
519
+ * @returns {string}
520
+ */
521
+ function buildValidationRunId({ platform, scenarioId, }) {
522
+ return `validate-${platform}-${scenarioId}`;
523
+ }
524
+ /**
525
+ * Escapes a string for use inside a regular expression.
526
+ *
527
+ * @param {string} value
528
+ * @returns {string}
529
+ */
530
+ function escapeRegExp(value) {
531
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
532
+ }
533
+ /**
534
+ * Returns whether source text declares or re-exports a named symbol.
535
+ *
536
+ * This intentionally avoids a full TypeScript parser while still rejecting
537
+ * comments or unrelated strings that only mention the helper name.
538
+ *
539
+ * @param {string} source
540
+ * @param {string} exportName
541
+ * @returns {boolean}
542
+ */
543
+ function hasNamedExport(source, exportName) {
544
+ const escapedName = escapeRegExp(exportName);
545
+ const directExport = new RegExp(`^\\s*export\\s+(?:async\\s+)?(?:function|const|let|var)\\s+${escapedName}\\b`, 'mu');
546
+ if (directExport.test(source)) {
547
+ return true;
548
+ }
549
+ for (const match of source.matchAll(/^\s*export\s*\{(?<names>[\s\S]*?)\}\s*(?:from\s+['"][^'"]+['"])?\s*;?/gmu)) {
550
+ const names = match.groups?.names ?? '';
551
+ const exportedNames = names
552
+ .split(',')
553
+ .map((name) => name.trim())
554
+ .filter(Boolean)
555
+ .map((name) => {
556
+ const aliasMatch = /^(?<local>[A-Za-z_$][\w$]*)\s+as\s+(?<exported>[A-Za-z_$][\w$]*)$/u.exec(name);
557
+ return aliasMatch?.groups?.exported ?? name;
558
+ });
559
+ if (exportedNames.includes(exportName)) {
560
+ return true;
561
+ }
562
+ }
563
+ return false;
564
+ }
565
+ /**
566
+ * Validates that the generated app helper is present and still exposes the expected integration API.
567
+ *
568
+ * @param {string} rootDir
569
+ * @returns {ProjectValidationAppHelper}
570
+ */
571
+ function validateAppHelper(rootDir) {
572
+ const helperPath = path.join(rootDir, 'src', 'devtools', 'profile-session.ts');
573
+ if (!fs.existsSync(helperPath)) {
574
+ return {
575
+ missingExports: REQUIRED_APP_HELPER_EXPORTS,
576
+ path: helperPath,
577
+ status: 'missing',
578
+ };
579
+ }
580
+ const source = fs.readFileSync(helperPath, 'utf8');
581
+ const missingExports = REQUIRED_APP_HELPER_EXPORTS.filter((exportName) => !hasNamedExport(source, exportName));
582
+ return {
583
+ missingExports,
584
+ path: helperPath,
585
+ status: missingExports.length > 0 ? 'incomplete' : 'present',
586
+ };
587
+ }
588
+ /**
589
+ * Finds known placeholder values in the initialized project config.
590
+ *
591
+ * @param {Record<string, unknown>} config
592
+ * @returns {string[]}
593
+ */
594
+ function validateConfigPlaceholders(config) {
595
+ return CONFIG_PLACEHOLDER_VALUES.flatMap((placeholder) => {
596
+ const value = readNestedString(config, placeholder.path);
597
+ if (value === null || !placeholder.values.includes(value)) {
598
+ return [];
599
+ }
600
+ return [`Config field ${placeholder.path.join('.')} still uses placeholder value '${value}'.`];
601
+ });
602
+ }
603
+ /**
604
+ * Validates required project config fields for live profile execution.
605
+ *
606
+ * @param {{configPath: string, requestedPlatform: string}} options
607
+ * @returns {ProjectValidationConfig}
608
+ */
609
+ function validateProjectConfig({ configPath, requestedPlatform, }) {
610
+ if (!fs.existsSync(configPath)) {
611
+ return {
612
+ customDrivers: [],
613
+ externalTargetDrivers: [],
614
+ invalidFields: [],
615
+ missingFields: [],
616
+ missingSupportedDrivers: PACKAGE_SUPPORTED_DRIVERS,
617
+ packageSupportedDrivers: [],
618
+ path: configPath,
619
+ status: 'missing',
620
+ supportedDrivers: [],
621
+ };
622
+ }
623
+ const config = readJson(configPath);
624
+ const requiredPlatforms = requestedPlatform === 'all'
625
+ ? ['ios', 'android']
626
+ : ['ios', 'android'].filter((platform) => platform === requestedPlatform);
627
+ const requiredFields = [
628
+ ...REQUIRED_COMMON_CONFIG_STRING_FIELDS,
629
+ ...requiredPlatforms.flatMap((platform) => PLATFORM_CONFIG_STRING_FIELDS[platform] ?? []),
630
+ ];
631
+ const missingFields = [];
632
+ const invalidFields = [];
633
+ for (const fieldPath of requiredFields) {
634
+ const value = readNestedValue(config, fieldPath);
635
+ const label = fieldPath.join('.');
636
+ if (value === undefined) {
637
+ missingFields.push(label);
638
+ }
639
+ else if (fieldPath.join('.') === 'drivers.supported') {
640
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item.trim().length === 0)) {
641
+ invalidFields.push(label);
642
+ }
643
+ }
644
+ else if (!isNonEmptyString(value)) {
645
+ invalidFields.push(label);
646
+ }
647
+ }
648
+ const commonScenarioRoot = readNestedValue(config, ['paths', 'scenarioRoot']);
649
+ if (commonScenarioRoot !== undefined && !isNonEmptyString(commonScenarioRoot)) {
650
+ invalidFields.push('paths.scenarioRoot');
651
+ }
652
+ const commonArtifactRoot = readNestedValue(config, ['paths', 'artifactRoot']);
653
+ if (commonArtifactRoot !== undefined && !isNonEmptyString(commonArtifactRoot)) {
654
+ invalidFields.push('paths.artifactRoot');
655
+ }
656
+ for (const platform of requiredPlatforms) {
657
+ const platformRootPath = PLATFORM_SCENARIO_ROOT_FIELDS[platform] ?? [];
658
+ const platformRootLabel = platformRootPath.join('.');
659
+ const platformScenarioRoot = readNestedValue(config, platformRootPath);
660
+ if (platformScenarioRoot !== undefined && !isNonEmptyString(platformScenarioRoot)) {
661
+ invalidFields.push(platformRootLabel);
662
+ }
663
+ if (commonScenarioRoot === undefined && platformScenarioRoot === undefined) {
664
+ missingFields.push(`paths.scenarioRoot or ${platformRootLabel}`);
665
+ }
666
+ const platformArtifactRootPath = PLATFORM_ARTIFACT_ROOT_FIELDS[platform] ?? [];
667
+ const platformArtifactRootLabel = platformArtifactRootPath.join('.');
668
+ const platformArtifactRoot = readNestedValue(config, platformArtifactRootPath);
669
+ if (platformArtifactRoot !== undefined && !isNonEmptyString(platformArtifactRoot)) {
670
+ invalidFields.push(platformArtifactRootLabel);
671
+ }
672
+ if (commonArtifactRoot === undefined && platformArtifactRoot === undefined) {
673
+ missingFields.push(`paths.artifactRoot or ${platformArtifactRootLabel}`);
674
+ }
675
+ }
676
+ const iosConflictingBundleIds = readNestedValue(config, ['app', 'iosConflictingBundleIds']);
677
+ if (iosConflictingBundleIds !== undefined &&
678
+ (!Array.isArray(iosConflictingBundleIds) ||
679
+ iosConflictingBundleIds.some((value) => typeof value !== 'string' || value.trim().length === 0))) {
680
+ invalidFields.push('app.iosConflictingBundleIds');
681
+ }
682
+ const rawSupportedDrivers = readNestedValue(config, ['drivers', 'supported']);
683
+ const rawAndroidSupportedDrivers = readNestedValue(config, ['androidDrivers', 'supported']);
684
+ if (rawAndroidSupportedDrivers !== undefined &&
685
+ (!Array.isArray(rawAndroidSupportedDrivers) ||
686
+ rawAndroidSupportedDrivers.some((item) => typeof item !== 'string' || item.trim().length === 0))) {
687
+ invalidFields.push('androidDrivers.supported');
688
+ }
689
+ const supportedDrivers = [
690
+ ...(Array.isArray(rawSupportedDrivers) ? rawSupportedDrivers : []),
691
+ ...(Array.isArray(rawAndroidSupportedDrivers) ? rawAndroidSupportedDrivers : []),
692
+ ]
693
+ .filter((value) => typeof value === 'string' && value.trim().length > 0)
694
+ .map((value) => value.trim())
695
+ .filter((value, index, values) => values.indexOf(value) === index)
696
+ .sort();
697
+ const missingSupportedDrivers = PACKAGE_SUPPORTED_DRIVERS
698
+ .filter((driver) => !supportedDrivers.includes(driver));
699
+ const packageSupportedDrivers = supportedDrivers
700
+ .filter((driver) => PACKAGE_SUPPORTED_DRIVERS.includes(driver));
701
+ const externalTargetDrivers = supportedDrivers
702
+ .filter((driver) => KNOWN_EXTERNAL_TARGET_DRIVERS.includes(driver));
703
+ const customDrivers = supportedDrivers
704
+ .filter((driver) => (!PACKAGE_SUPPORTED_DRIVERS.includes(driver) &&
705
+ !KNOWN_EXTERNAL_TARGET_DRIVERS.includes(driver)));
706
+ return {
707
+ customDrivers,
708
+ externalTargetDrivers,
709
+ invalidFields,
710
+ missingFields,
711
+ missingSupportedDrivers,
712
+ packageSupportedDrivers,
713
+ path: configPath,
714
+ status: missingFields.length > 0 || invalidFields.length > 0
715
+ ? 'incomplete'
716
+ : 'present',
717
+ supportedDrivers,
718
+ };
719
+ }
720
+ /**
721
+ * Normalizes a relative directory path into the simple gitignore form this scaffold emits.
722
+ *
723
+ * @param {string} value
724
+ * @returns {string}
725
+ */
726
+ function normalizeGitignoreDirectoryPattern(value) {
727
+ const normalized = path.normalize(value).split(path.sep).join('/');
728
+ const trimmed = normalized.replace(/^\.\//u, '').replace(/\/+$/u, '');
729
+ return trimmed.length > 0 ? `${trimmed}/` : '';
730
+ }
731
+ /**
732
+ * Returns true when one simple gitignore directory pattern covers another.
733
+ *
734
+ * @param {string} pattern
735
+ * @param {string} requiredPattern
736
+ * @returns {boolean}
737
+ */
738
+ function gitignorePatternCovers(pattern, requiredPattern) {
739
+ const normalizedPattern = pattern.trim().replace(/^\.\//u, '');
740
+ const normalizedRequired = requiredPattern.trim().replace(/^\.\//u, '');
741
+ if (normalizedPattern === normalizedRequired) {
742
+ return true;
743
+ }
744
+ if (normalizedPattern.endsWith('/')) {
745
+ return normalizedRequired.startsWith(normalizedPattern);
746
+ }
747
+ if (normalizedPattern.endsWith('/**')) {
748
+ return normalizedRequired.startsWith(normalizedPattern.slice(0, -2));
749
+ }
750
+ return false;
751
+ }
752
+ /**
753
+ * Removes required ignore patterns already covered by a broader required directory pattern.
754
+ *
755
+ * @param {string[]} patterns
756
+ * @returns {string[]}
757
+ */
758
+ function compactGitignorePatterns(patterns) {
759
+ return [...new Set(patterns)]
760
+ .filter((pattern) => pattern.length > 0)
761
+ .sort()
762
+ .reduce((result, pattern) => {
763
+ if (result.some((existing) => gitignorePatternCovers(existing, pattern))) {
764
+ return result;
765
+ }
766
+ result.push(pattern);
767
+ return result;
768
+ }, []);
769
+ }
770
+ /**
771
+ * Reads configured artifact roots and converts project-local roots into gitignore directory patterns.
772
+ *
773
+ * @param {{configPath: string, rootDir: string}} options
774
+ * @returns {string[]}
775
+ */
776
+ function collectConfigArtifactPatterns({ configPath, rootDir, }) {
777
+ if (!fs.existsSync(configPath)) {
778
+ return [];
779
+ }
780
+ const config = readJson(configPath);
781
+ return compactGitignorePatterns(CONFIG_ARTIFACT_ROOT_FIELDS
782
+ .map((fieldPath) => readNestedString(config, fieldPath))
783
+ .filter(isNonEmptyString)
784
+ .map((artifactRoot) => {
785
+ const absoluteArtifactRoot = path.isAbsolute(artifactRoot)
786
+ ? path.normalize(artifactRoot)
787
+ : path.resolve(rootDir, artifactRoot);
788
+ const relativeArtifactRoot = path.relative(rootDir, absoluteArtifactRoot);
789
+ if (relativeArtifactRoot.startsWith('..') ||
790
+ path.isAbsolute(relativeArtifactRoot) ||
791
+ relativeArtifactRoot.length === 0) {
792
+ return '';
793
+ }
794
+ return normalizeGitignoreDirectoryPattern(relativeArtifactRoot);
795
+ }));
796
+ }
797
+ /**
798
+ * Builds the full set of runtime artifact ignore patterns required for the project.
799
+ *
800
+ * @param {{configPath: string, rootDir: string}} options
801
+ * @returns {{configArtifactPatterns: string[], requiredPatterns: string[]}}
802
+ */
803
+ function buildRequiredGitignorePatterns({ configPath, rootDir, }) {
804
+ const configArtifactPatterns = collectConfigArtifactPatterns({ configPath, rootDir });
805
+ return {
806
+ configArtifactPatterns,
807
+ requiredPatterns: compactGitignorePatterns([
808
+ ...REQUIRED_GITIGNORE_PATTERNS,
809
+ ...configArtifactPatterns,
810
+ ]),
811
+ };
812
+ }
813
+ /**
814
+ * Validates that runtime proof artifacts are ignored by the consuming app.
815
+ *
816
+ * @param {string} rootDir
817
+ * @param {string} [configPath]
818
+ * @returns {ProjectValidationGitignore}
819
+ */
820
+ function validateGitignore(rootDir, configPath = path.join(rootDir, 'asl.config.json')) {
821
+ const gitignorePath = path.join(rootDir, '.gitignore');
822
+ const snippetPath = path.join(rootDir, 'asl', 'gitignore-snippet');
823
+ const { configArtifactPatterns, requiredPatterns } = buildRequiredGitignorePatterns({ configPath, rootDir });
824
+ if (!fs.existsSync(gitignorePath)) {
825
+ return {
826
+ configArtifactPatterns,
827
+ missingConfigArtifactPatterns: configArtifactPatterns,
828
+ missingPatterns: requiredPatterns,
829
+ path: gitignorePath,
830
+ requiredPatterns,
831
+ snippetPath,
832
+ status: 'missing',
833
+ };
834
+ }
835
+ const lines = new Set(fs.readFileSync(gitignorePath, 'utf8')
836
+ .split(/\r?\n/u)
837
+ .map((line) => line.trim())
838
+ .filter((line) => line.length > 0 && !line.startsWith('#') && !line.startsWith('!')));
839
+ const presentPatterns = [...lines];
840
+ const missingPatterns = requiredPatterns.filter((pattern) => (!presentPatterns.some((existingPattern) => gitignorePatternCovers(existingPattern, pattern))));
841
+ const missingConfigArtifactPatterns = configArtifactPatterns.filter((pattern) => (!presentPatterns.some((existingPattern) => gitignorePatternCovers(existingPattern, pattern))));
842
+ return {
843
+ configArtifactPatterns,
844
+ missingConfigArtifactPatterns,
845
+ missingPatterns,
846
+ path: gitignorePath,
847
+ requiredPatterns,
848
+ snippetPath,
849
+ status: missingPatterns.length > 0 ? 'incomplete' : 'present',
850
+ };
851
+ }
852
+ /**
853
+ * Reads public package binary names from package.json.
854
+ *
855
+ * @param {string} packageRoot
856
+ * @returns {Set<string>}
857
+ */
858
+ function readPackageBinNames(packageRoot) {
859
+ const packageJson = readJson(path.join(packageRoot, 'package.json'));
860
+ const bins = packageJson.bin;
861
+ if (!bins || typeof bins !== 'object' || Array.isArray(bins)) {
862
+ return new Set();
863
+ }
864
+ return new Set(Object.keys(bins));
865
+ }
866
+ /**
867
+ * Splits the generated script snippets into tokens. The template intentionally avoids shell quoting.
868
+ *
869
+ * @param {string} command
870
+ * @returns {string[]}
871
+ */
872
+ function tokenizeScript(command) {
873
+ return command.trim().split(/\s+/u).filter(Boolean);
874
+ }
875
+ /**
876
+ * Reads a script flag value from tokenized package-script snippets.
877
+ *
878
+ * @param {string[]} tokens
879
+ * @param {string} flag
880
+ * @returns {string | null}
881
+ */
882
+ function readScriptFlagValue(tokens, flag) {
883
+ const index = tokens.indexOf(flag);
884
+ if (index === -1) {
885
+ return null;
886
+ }
887
+ const value = tokens[index + 1];
888
+ return value && !value.startsWith('--') ? value : null;
889
+ }
890
+ /**
891
+ * Validates one generated package-script snippet against its expected lifecycle shape.
892
+ *
893
+ * @param {{command: string, scriptName: string}} options
894
+ * @returns {string | null}
895
+ */
896
+ function validatePackageScriptShape({ command, scriptName, }) {
897
+ const expected = REQUIRED_PACKAGE_SCRIPT_SHAPES[scriptName];
898
+ if (!expected) {
899
+ return null;
900
+ }
901
+ const tokens = tokenizeScript(command);
902
+ if (tokens[0] !== expected.command) {
903
+ return `${scriptName} should start with ${expected.command}.`;
904
+ }
905
+ const missingFlags = expected.flags.filter((flag) => !tokens.includes(flag));
906
+ if (missingFlags.length > 0) {
907
+ return `${scriptName} is missing required flag(s): ${missingFlags.join(', ')}.`;
908
+ }
909
+ const wrongValues = Object.entries(expected.values).flatMap(([flag, expectedValue]) => {
910
+ const actual = readScriptFlagValue(tokens, flag);
911
+ return actual === expectedValue ? [] : [`${flag}=${expectedValue}`];
912
+ });
913
+ if (wrongValues.length > 0) {
914
+ return `${scriptName} has incorrect required value(s): ${wrongValues.join(', ')}.`;
915
+ }
916
+ return null;
917
+ }
918
+ /**
919
+ * Returns true for concrete path-like script arguments while leaving ids and placeholders alone.
920
+ *
921
+ * @param {string} value
922
+ * @returns {boolean}
923
+ */
924
+ function isPathLikeArgument(value) {
925
+ return value.includes('/') || value.includes(path.sep) || /\.[a-z0-9]+$/iu.test(value);
926
+ }
927
+ /**
928
+ * Validates the generated package-script snippets against installed CLI bins and project-local inputs.
929
+ *
930
+ * @param {{packageRoot?: string, rootDir: string}} options
931
+ * @returns {ProjectValidationScripts}
932
+ */
933
+ function validatePackageScripts({ packageRoot = defaultPackageRoot(), rootDir, }) {
934
+ const scriptPath = path.join(rootDir, 'asl', 'package-scripts.json');
935
+ const packageJsonPath = path.join(rootDir, 'package.json');
936
+ if (!fs.existsSync(scriptPath)) {
937
+ const packageScripts = validatePackageJsonScripts({ packageJsonPath });
938
+ return {
939
+ invalidScripts: [],
940
+ invalidPackageJsonScripts: packageScripts.invalidPackageJsonScripts,
941
+ missingPaths: [],
942
+ missingPackageJsonScripts: packageScripts.missingPackageJsonScripts,
943
+ mismatchedPackageJsonScripts: packageScripts.mismatchedPackageJsonScripts,
944
+ missingScripts: REQUIRED_PACKAGE_SCRIPT_NAMES,
945
+ packageJsonPath,
946
+ packageJsonStatus: packageScripts.packageJsonStatus,
947
+ path: scriptPath,
948
+ scriptNames: [],
949
+ status: 'missing',
950
+ unknownCommands: [],
951
+ };
952
+ }
953
+ const scripts = readJson(scriptPath);
954
+ const packageScripts = validatePackageJsonScripts({
955
+ expectedScripts: scripts,
956
+ packageJsonPath,
957
+ });
958
+ const binNames = readPackageBinNames(packageRoot);
959
+ const scriptNames = Object.keys(scripts).sort();
960
+ const missingScripts = REQUIRED_PACKAGE_SCRIPT_NAMES.filter((scriptName) => !(scriptName in scripts));
961
+ const commandNames = new Set();
962
+ const invalidScripts = [];
963
+ const missingPaths = new Set();
964
+ for (const [scriptName, value] of Object.entries(scripts)) {
965
+ if (typeof value !== 'string') {
966
+ invalidScripts.push(`${scriptName} should be a string command.`);
967
+ continue;
968
+ }
969
+ const shapeError = validatePackageScriptShape({
970
+ command: value,
971
+ scriptName,
972
+ });
973
+ if (shapeError) {
974
+ invalidScripts.push(shapeError);
975
+ }
976
+ const tokens = tokenizeScript(value);
977
+ if (tokens[0]) {
978
+ commandNames.add(tokens[0]);
979
+ }
980
+ for (let index = 0; index < tokens.length; index += 1) {
981
+ const token = tokens[index];
982
+ const next = tokens[index + 1];
983
+ if (!token || !PATH_ARGUMENT_FLAGS.has(token) || !next || next.startsWith('<') || !isPathLikeArgument(next)) {
984
+ continue;
985
+ }
986
+ const candidatePath = path.resolve(rootDir, next);
987
+ if (!fs.existsSync(candidatePath)) {
988
+ missingPaths.add(candidatePath);
989
+ }
990
+ }
991
+ }
992
+ const unknownCommands = [...commandNames].filter((commandName) => !binNames.has(commandName)).sort();
993
+ return {
994
+ invalidScripts: invalidScripts.sort(),
995
+ invalidPackageJsonScripts: packageScripts.invalidPackageJsonScripts,
996
+ missingPaths: [...missingPaths].sort(),
997
+ missingPackageJsonScripts: packageScripts.missingPackageJsonScripts,
998
+ mismatchedPackageJsonScripts: packageScripts.mismatchedPackageJsonScripts,
999
+ missingScripts,
1000
+ packageJsonPath,
1001
+ packageJsonStatus: packageScripts.packageJsonStatus,
1002
+ path: scriptPath,
1003
+ scriptNames,
1004
+ status: missingScripts.length > 0 ||
1005
+ missingPaths.size > 0 ||
1006
+ unknownCommands.length > 0 ||
1007
+ invalidScripts.length > 0 ||
1008
+ packageScripts.mismatchedPackageJsonScripts.length > 0 ||
1009
+ packageScripts.packageJsonStatus !== 'present'
1010
+ ? 'incomplete'
1011
+ : 'present',
1012
+ unknownCommands,
1013
+ };
1014
+ }
1015
+ /**
1016
+ * Validates that generated snippets were intentionally merged into app package scripts.
1017
+ *
1018
+ * The generated snippet file owns canonical command shape. This check only
1019
+ * proves the app exposes runnable script names so teams do not leave the
1020
+ * snippets stranded under `asl/`.
1021
+ *
1022
+ * Direct installed-bin scripts are also compared against the generated
1023
+ * snippets, catching stale merges while allowing source-tree wrappers.
1024
+ *
1025
+ * @param {{expectedScripts?: Record<string, unknown>, packageJsonPath: string}} options
1026
+ * @returns {{invalidPackageJsonScripts: string[], missingPackageJsonScripts: string[], mismatchedPackageJsonScripts: string[], packageJsonStatus: 'present' | 'missing' | 'incomplete'}}
1027
+ */
1028
+ function validatePackageJsonScripts({ expectedScripts = {}, packageJsonPath, }) {
1029
+ if (!fs.existsSync(packageJsonPath)) {
1030
+ return {
1031
+ invalidPackageJsonScripts: [],
1032
+ missingPackageJsonScripts: REQUIRED_PACKAGE_SCRIPT_NAMES,
1033
+ mismatchedPackageJsonScripts: [],
1034
+ packageJsonStatus: 'missing',
1035
+ };
1036
+ }
1037
+ const packageJson = readJson(packageJsonPath);
1038
+ const scripts = packageJson.scripts;
1039
+ if (!scripts || typeof scripts !== 'object' || Array.isArray(scripts)) {
1040
+ return {
1041
+ invalidPackageJsonScripts: [],
1042
+ missingPackageJsonScripts: REQUIRED_PACKAGE_SCRIPT_NAMES,
1043
+ mismatchedPackageJsonScripts: [],
1044
+ packageJsonStatus: 'incomplete',
1045
+ };
1046
+ }
1047
+ const record = scripts;
1048
+ const missingPackageJsonScripts = REQUIRED_PACKAGE_SCRIPT_NAMES.filter((scriptName) => !(scriptName in record));
1049
+ const invalidPackageJsonScripts = REQUIRED_PACKAGE_SCRIPT_NAMES.filter((scriptName) => (scriptName in record && typeof record[scriptName] !== 'string'));
1050
+ const mismatchedPackageJsonScripts = REQUIRED_PACKAGE_SCRIPT_NAMES.filter((scriptName) => {
1051
+ const actual = record[scriptName];
1052
+ const expected = expectedScripts[scriptName];
1053
+ if (typeof actual !== 'string' || typeof expected !== 'string') {
1054
+ return false;
1055
+ }
1056
+ const actualTokens = tokenizeScript(actual);
1057
+ const expectedTokens = tokenizeScript(expected);
1058
+ if (!actualTokens[0]?.startsWith('asl-') || actualTokens[0] !== expectedTokens[0]) {
1059
+ return false;
1060
+ }
1061
+ return actual.trim() !== expected.trim();
1062
+ });
1063
+ return {
1064
+ invalidPackageJsonScripts,
1065
+ missingPackageJsonScripts,
1066
+ mismatchedPackageJsonScripts,
1067
+ packageJsonStatus: missingPackageJsonScripts.length > 0 ||
1068
+ invalidPackageJsonScripts.length > 0 ||
1069
+ mismatchedPackageJsonScripts.length > 0
1070
+ ? 'incomplete'
1071
+ : 'present',
1072
+ };
1073
+ }
1074
+ /**
1075
+ * Resolves a provider command cwd using the same manifest-relative rule as profile runners.
1076
+ *
1077
+ * @param {{manifestDir: string, value?: string}} options
1078
+ * @returns {string}
1079
+ */
1080
+ function resolveProviderCommandCwd({ manifestDir, value, }) {
1081
+ if (!value || value.includes('{')) {
1082
+ return manifestDir;
1083
+ }
1084
+ return path.isAbsolute(value) ? value : path.resolve(manifestDir, value);
1085
+ }
1086
+ /**
1087
+ * Returns the first local script argument from a node-backed provider command.
1088
+ *
1089
+ * @param {ProviderCommandReference} command
1090
+ * @returns {string | null}
1091
+ */
1092
+ function readNodeProviderScriptArg(command) {
1093
+ if (command.command !== 'node' || !Array.isArray(command.args)) {
1094
+ return null;
1095
+ }
1096
+ return command.args.find((arg) => {
1097
+ if (arg.startsWith('-') || arg.includes('{')) {
1098
+ return false;
1099
+ }
1100
+ return /\.(?:cjs|js|mjs|ts)$/iu.test(arg);
1101
+ }) ?? null;
1102
+ }
1103
+ /**
1104
+ * Validates project-local script files referenced by evidence-provider commands.
1105
+ *
1106
+ * @param {{providerPaths: string[]}} options
1107
+ * @returns {string[]}
1108
+ */
1109
+ function validateProviderCommandReferences({ providerPaths, }) {
1110
+ const missingPaths = new Set();
1111
+ for (const providerPath of providerPaths) {
1112
+ const provider = readJson(providerPath);
1113
+ const providerCommands = Array.isArray(provider.providerCommands)
1114
+ ? provider.providerCommands
1115
+ : [];
1116
+ const manifestDir = path.dirname(providerPath);
1117
+ for (const providerCommand of providerCommands) {
1118
+ const scriptArg = readNodeProviderScriptArg(providerCommand);
1119
+ if (!scriptArg) {
1120
+ continue;
1121
+ }
1122
+ const commandCwd = resolveProviderCommandCwd({
1123
+ manifestDir,
1124
+ ...(providerCommand.cwd ? { value: providerCommand.cwd } : {}),
1125
+ });
1126
+ const candidatePath = path.isAbsolute(scriptArg) ? scriptArg : path.resolve(commandCwd, scriptArg);
1127
+ if (!fs.existsSync(candidatePath)) {
1128
+ missingPaths.add(candidatePath);
1129
+ }
1130
+ }
1131
+ }
1132
+ return [...missingPaths].sort();
1133
+ }
1134
+ /**
1135
+ * Builds stable agent-readable next actions from project validation facts.
1136
+ *
1137
+ * @param {{appHelper: ProjectValidationAppHelper, config: ProjectValidationConfig, configPath: string, gitignore: ProjectValidationGitignore, plans: ProjectValidationPlan[], providerCommandMissingPaths: string[], requestedPlatform: string, rootDir: string, runnerPath: string, scenarioPaths: string[], scripts: ProjectValidationScripts, warnings: string[]}} options
1138
+ * @returns {ProjectValidationNextAction[]}
1139
+ */
1140
+ function buildNextActions({ appHelper, config, configPath, gitignore, plans, providerCommandMissingPaths, requestedPlatform, rootDir, runnerPath, scenarioPaths, scripts, warnings, }) {
1141
+ const actions = [];
1142
+ if (!['ios', 'android', 'all'].includes(requestedPlatform)) {
1143
+ actions.push({
1144
+ code: 'choose_supported_platform',
1145
+ message: 'Rerun project validation with --platform ios, --platform android, or --platform all.',
1146
+ severity: 'error',
1147
+ target: 'platform',
1148
+ });
1149
+ }
1150
+ if (!fs.existsSync(configPath)) {
1151
+ actions.push({
1152
+ code: 'add_project_config',
1153
+ message: 'Create asl.config.json from the package template and fill in app identifiers.',
1154
+ severity: 'error',
1155
+ target: configPath,
1156
+ });
1157
+ }
1158
+ else if (config.status === 'incomplete') {
1159
+ actions.push({
1160
+ code: 'fix_project_config',
1161
+ message: 'Fill required app identifiers in asl.config.json before live profile proof.',
1162
+ severity: 'error',
1163
+ target: config.path,
1164
+ });
1165
+ }
1166
+ if (!fs.existsSync(runnerPath)) {
1167
+ actions.push({
1168
+ code: 'add_primary_runner_manifest',
1169
+ message: 'Create runner-manifests/primary-runner.json or rerun asl-init for the scaffolded runner manifest.',
1170
+ severity: 'error',
1171
+ target: runnerPath,
1172
+ });
1173
+ }
1174
+ if (scenarioPaths.length === 0) {
1175
+ actions.push({
1176
+ code: 'add_mobile_scenario',
1177
+ message: 'Add at least one scenario manifest under a configured scenario root or scenarios/mobile.',
1178
+ severity: 'error',
1179
+ target: path.join(rootDir, 'scenarios', 'mobile'),
1180
+ });
1181
+ }
1182
+ if (appHelper.status === 'missing') {
1183
+ actions.push({
1184
+ code: 'add_profile_session_helper',
1185
+ message: 'Copy app/profile-session.ts into src/devtools/profile-session.ts and mount useProfileSessionBootstrap near the app root.',
1186
+ severity: 'error',
1187
+ target: appHelper.path,
1188
+ });
1189
+ }
1190
+ else if (appHelper.status === 'incomplete') {
1191
+ actions.push({
1192
+ code: 'restore_profile_session_exports',
1193
+ message: `Restore missing profile-session export(s): ${appHelper.missingExports.join(', ')}.`,
1194
+ severity: 'error',
1195
+ target: appHelper.path,
1196
+ });
1197
+ }
1198
+ if (scripts.status === 'missing') {
1199
+ actions.push({
1200
+ code: 'add_package_script_snippets',
1201
+ message: 'Create asl/package-scripts.json from the package template and merge needed snippets into package.json.',
1202
+ severity: 'error',
1203
+ target: scripts.path,
1204
+ });
1205
+ }
1206
+ else if (scripts.missingScripts.length > 0 ||
1207
+ scripts.missingPaths.length > 0 ||
1208
+ scripts.unknownCommands.length > 0 ||
1209
+ scripts.invalidScripts.length > 0) {
1210
+ actions.push({
1211
+ code: 'fix_package_script_snippets',
1212
+ message: 'Fix missing package-script snippets, invalid snippet shapes, unknown CLI commands, or missing project-local paths before live proof.',
1213
+ severity: 'error',
1214
+ target: scripts.path,
1215
+ });
1216
+ }
1217
+ if (scripts.packageJsonStatus !== 'present') {
1218
+ actions.push({
1219
+ code: 'merge_package_scripts',
1220
+ message: 'Merge the required asl/package-scripts.json entries into package.json before relying on project-local commands.',
1221
+ severity: 'error',
1222
+ target: scripts.packageJsonPath,
1223
+ });
1224
+ }
1225
+ if (providerCommandMissingPaths.length > 0) {
1226
+ actions.push({
1227
+ code: 'fix_provider_command_paths',
1228
+ message: 'Restore missing project-local provider command script files before live provider proof.',
1229
+ severity: 'error',
1230
+ target: providerCommandMissingPaths[0] ?? rootDir,
1231
+ });
1232
+ }
1233
+ for (const plan of plans.filter((candidate) => candidate.healthStatus !== 'passed')) {
1234
+ actions.push({
1235
+ code: 'fix_planner_compatibility',
1236
+ message: `Fix runner/provider compatibility for ${plan.platform} ${plan.scenarioId}.`,
1237
+ severity: 'error',
1238
+ target: plan.scenarioPath,
1239
+ });
1240
+ }
1241
+ if (warnings.some((warning) => warning.startsWith('Config field '))) {
1242
+ actions.push({
1243
+ code: 'replace_config_placeholders',
1244
+ message: 'Replace scaffold placeholder values in asl.config.json before relying on live device proof.',
1245
+ severity: 'warning',
1246
+ target: configPath,
1247
+ });
1248
+ }
1249
+ if (gitignore.status !== 'present') {
1250
+ actions.push({
1251
+ code: 'ignore_runtime_artifacts',
1252
+ message: 'Merge asl/gitignore-snippet into .gitignore so runtime artifacts, traces, and local proof captures stay out of source control.',
1253
+ severity: 'warning',
1254
+ target: gitignore.path,
1255
+ });
1256
+ }
1257
+ return actions;
1258
+ }
1259
+ /**
1260
+ * Validates a generated or hand-authored Agent Scenario Loop project.
1261
+ *
1262
+ * @param {{rootDir?: string, configPath?: string, platform?: string}} [options]
1263
+ * @returns {Promise<ProjectValidationResult>}
1264
+ */
1265
+ async function validateProject(options = {}) {
1266
+ const rootDir = path.resolve(options.rootDir ?? process.cwd());
1267
+ const requestedPlatform = options.platform ?? 'all';
1268
+ const configPath = path.resolve(rootDir, options.configPath ?? 'asl.config.json');
1269
+ const config = validateProjectConfig({ configPath, requestedPlatform });
1270
+ const runnerPath = path.join(rootDir, 'runner-manifests', 'primary-runner.json');
1271
+ const providerPaths = listJsonFiles(path.join(rootDir, 'runner-manifests'))
1272
+ .filter((filePath) => path.basename(filePath) !== 'primary-runner.json');
1273
+ const scenarioCandidateDirectories = resolveScenarioDirectories({ configPath, requestedPlatform, rootDir });
1274
+ const scenarioPaths = listScenarioFilesFromDirectories(scenarioCandidateDirectories);
1275
+ const appHelper = validateAppHelper(rootDir);
1276
+ const gitignore = validateGitignore(rootDir, configPath);
1277
+ const scripts = validatePackageScripts({
1278
+ ...(options.packageRoot ? { packageRoot: options.packageRoot } : {}),
1279
+ rootDir,
1280
+ });
1281
+ const errors = [];
1282
+ const plans = [];
1283
+ const warnings = [];
1284
+ const providerCommandMissingPaths = validateProviderCommandReferences({ providerPaths });
1285
+ if (!['ios', 'android', 'all'].includes(requestedPlatform)) {
1286
+ errors.push(`Unsupported platform '${requestedPlatform}'. Expected ios, android, or all.`);
1287
+ }
1288
+ if (!fs.existsSync(configPath)) {
1289
+ errors.push(`Missing config: ${configPath}`);
1290
+ }
1291
+ else {
1292
+ warnings.push(...validateConfigPlaceholders(readJson(configPath)));
1293
+ }
1294
+ if (config.missingFields.length > 0) {
1295
+ errors.push(`Project config is missing required field(s): ${config.missingFields.join(', ')}.`);
1296
+ }
1297
+ if (config.invalidFields.length > 0) {
1298
+ errors.push(`Project config has invalid required field(s): ${config.invalidFields.join(', ')}.`);
1299
+ }
1300
+ if (config.missingSupportedDrivers.length > 0 && fs.existsSync(configPath)) {
1301
+ warnings.push(`Project config drivers.supported does not list optional package driver(s): ${config.missingSupportedDrivers.join(', ')}.`);
1302
+ }
1303
+ if (gitignore.status !== 'present') {
1304
+ warnings.push(`Runtime artifact gitignore is missing pattern(s): ${gitignore.missingPatterns.join(', ')}.`);
1305
+ }
1306
+ if (!fs.existsSync(runnerPath)) {
1307
+ errors.push(`Missing primary runner manifest: ${runnerPath}`);
1308
+ }
1309
+ if (scenarioPaths.length === 0) {
1310
+ errors.push('No scenario manifests found under configured scenario roots or scenarios/mobile.');
1311
+ }
1312
+ if (appHelper.status === 'missing') {
1313
+ errors.push(`Missing app profile-session helper: ${appHelper.path}`);
1314
+ }
1315
+ else if (appHelper.status === 'incomplete') {
1316
+ errors.push(`App profile-session helper is missing export(s): ${appHelper.missingExports.join(', ')}.`);
1317
+ }
1318
+ if (scripts.status === 'missing') {
1319
+ errors.push(`Missing package-script snippets: ${scripts.path}`);
1320
+ }
1321
+ else if (scripts.status === 'incomplete') {
1322
+ if (scripts.missingScripts.length > 0) {
1323
+ errors.push(`Package-script snippets are missing script(s): ${scripts.missingScripts.join(', ')}.`);
1324
+ }
1325
+ if (scripts.unknownCommands.length > 0) {
1326
+ errors.push(`Package-script snippets reference unknown command(s): ${scripts.unknownCommands.join(', ')}.`);
1327
+ }
1328
+ if (scripts.invalidScripts.length > 0) {
1329
+ errors.push(`Package-script snippets have invalid command shape(s): ${scripts.invalidScripts.join(' ')}`);
1330
+ }
1331
+ if (scripts.missingPaths.length > 0) {
1332
+ errors.push(`Package-script snippets reference missing path(s): ${scripts.missingPaths.join(', ')}.`);
1333
+ }
1334
+ }
1335
+ if (scripts.packageJsonStatus === 'missing') {
1336
+ errors.push(`Missing app package.json for package-script merge validation: ${scripts.packageJsonPath}`);
1337
+ }
1338
+ else if (scripts.missingPackageJsonScripts.length > 0) {
1339
+ errors.push(`App package.json is missing ASL script(s): ${scripts.missingPackageJsonScripts.join(', ')}.`);
1340
+ }
1341
+ if (scripts.invalidPackageJsonScripts.length > 0) {
1342
+ errors.push(`App package.json has non-string ASL script(s): ${scripts.invalidPackageJsonScripts.join(', ')}.`);
1343
+ }
1344
+ if (scripts.mismatchedPackageJsonScripts.length > 0) {
1345
+ errors.push(`App package.json ASL script(s) differ from asl/package-scripts.json: ${scripts.mismatchedPackageJsonScripts.join(', ')}.`);
1346
+ }
1347
+ if (providerCommandMissingPaths.length > 0) {
1348
+ errors.push(`Provider commands reference missing path(s): ${providerCommandMissingPaths.join(', ')}.`);
1349
+ }
1350
+ if (errors.length === 0) {
1351
+ for (const scenarioPath of scenarioPaths) {
1352
+ const scenario = readJson(scenarioPath);
1353
+ const scenarioId = typeof scenario.id === 'string' ? scenario.id : path.basename(scenarioPath, '.json');
1354
+ for (const platform of resolvePlatforms({ requestedPlatform, scenario })) {
1355
+ const runId = buildValidationRunId({ platform, scenarioId });
1356
+ const artifacts = await buildPlanArtifacts({
1357
+ scenarioPath,
1358
+ runnerPath,
1359
+ providerPaths,
1360
+ platform,
1361
+ runId,
1362
+ });
1363
+ const healthStatus = String(artifacts.health.healthStatus ?? 'unknown');
1364
+ plans.push({
1365
+ healthStatus,
1366
+ platform,
1367
+ runId,
1368
+ scenarioId,
1369
+ scenarioPath,
1370
+ });
1371
+ if (healthStatus !== 'passed') {
1372
+ errors.push(`${scenarioId} is incompatible on ${platform}; healthStatus=${healthStatus}.`);
1373
+ }
1374
+ }
1375
+ }
1376
+ }
1377
+ const nextActions = buildNextActions({
1378
+ appHelper,
1379
+ config,
1380
+ configPath,
1381
+ gitignore,
1382
+ plans,
1383
+ providerCommandMissingPaths,
1384
+ requestedPlatform,
1385
+ rootDir,
1386
+ runnerPath,
1387
+ scenarioPaths,
1388
+ scripts,
1389
+ warnings,
1390
+ });
1391
+ return {
1392
+ appHelper,
1393
+ config,
1394
+ configPath,
1395
+ errors,
1396
+ gitignore,
1397
+ nextActions,
1398
+ platform: requestedPlatform,
1399
+ plans,
1400
+ providerPaths,
1401
+ rootDir,
1402
+ runnerPath,
1403
+ scripts,
1404
+ scenarioCandidateDirectories,
1405
+ scenarioPaths,
1406
+ status: errors.length > 0 ? 'failed' : 'passed',
1407
+ warnings,
1408
+ };
1409
+ }
1410
+ /**
1411
+ * Formats project validation output for humans and agents.
1412
+ *
1413
+ * @param {ProjectValidationResult} result
1414
+ * @returns {string}
1415
+ */
1416
+ function formatResult(result) {
1417
+ return [
1418
+ `Agent Scenario Loop project validation ${result.status}.`,
1419
+ `Root: ${result.rootDir}`,
1420
+ `Config: ${result.configPath}`,
1421
+ `Config status: ${result.config.status}`,
1422
+ `Supported drivers: ${result.config.supportedDrivers.length > 0 ? result.config.supportedDrivers.join(', ') : 'none'}`,
1423
+ `Package-supported drivers: ${result.config.packageSupportedDrivers.length > 0 ? result.config.packageSupportedDrivers.join(', ') : 'none'}`,
1424
+ `External target drivers: ${result.config.externalTargetDrivers.length > 0 ? result.config.externalTargetDrivers.join(', ') : 'none'}`,
1425
+ `Custom drivers: ${result.config.customDrivers.length > 0 ? result.config.customDrivers.join(', ') : 'none'}`,
1426
+ `App helper: ${result.appHelper.status}`,
1427
+ `Gitignore: ${result.gitignore.status}`,
1428
+ `Config artifact ignore patterns: ${result.gitignore.configArtifactPatterns.length > 0 ? result.gitignore.configArtifactPatterns.join(', ') : 'none'}`,
1429
+ `Package scripts: ${result.scripts.status}`,
1430
+ `Package.json scripts: ${result.scripts.packageJsonStatus}`,
1431
+ `Scenario candidate directories: ${result.scenarioCandidateDirectories.length}`,
1432
+ `Scenarios: ${result.scenarioPaths.length}`,
1433
+ `Providers: ${result.providerPaths.length}`,
1434
+ ...(result.warnings.length > 0
1435
+ ? [
1436
+ 'Warnings:',
1437
+ ...result.warnings.map((warning) => `- ${warning}`),
1438
+ ]
1439
+ : []),
1440
+ ...(result.nextActions.length > 0
1441
+ ? [
1442
+ 'Next actions:',
1443
+ ...result.nextActions.map((action) => `- ${action.severity} ${action.code}: ${action.message}`),
1444
+ ]
1445
+ : []),
1446
+ ...(result.plans.length > 0
1447
+ ? [
1448
+ 'Plans:',
1449
+ ...result.plans.map((plan) => `- ${plan.platform} ${plan.scenarioId}: ${plan.healthStatus}`),
1450
+ ]
1451
+ : []),
1452
+ ...(result.errors.length > 0
1453
+ ? [
1454
+ 'Errors:',
1455
+ ...result.errors.map((error) => `- ${error}`),
1456
+ ]
1457
+ : []),
1458
+ ].join('\n');
1459
+ }
1460
+ /**
1461
+ * Runs the project validation CLI.
1462
+ *
1463
+ * @returns {Promise<void>}
1464
+ */
1465
+ async function main() {
1466
+ const argv = process.argv.slice(2);
1467
+ if (hasHelpFlag(argv)) {
1468
+ usage(process.stdout);
1469
+ return;
1470
+ }
1471
+ const args = parseArgs(argv);
1472
+ const result = await validateProject({
1473
+ ...(typeof args.config === 'string' ? { configPath: args.config } : {}),
1474
+ ...(typeof args.root === 'string' ? { rootDir: args.root } : {}),
1475
+ ...(typeof args.platform === 'string' ? { platform: args.platform } : {}),
1476
+ });
1477
+ if (typeof args.out === 'string' && args.out.length > 0) {
1478
+ const outDir = path.resolve(args.out);
1479
+ await fsp.mkdir(outDir, { recursive: true });
1480
+ await writeJsonArtifact({
1481
+ filePath: path.join(outDir, 'project-validation.json'),
1482
+ value: result,
1483
+ schema: SCHEMAS.projectValidation,
1484
+ label: 'Project validation artifact',
1485
+ });
1486
+ await writeTextArtifact({
1487
+ filePath: path.join(outDir, 'agent-summary.md'),
1488
+ content: `${formatResult(result)}\n`,
1489
+ });
1490
+ }
1491
+ process.stdout.write(`${formatResult(result)}\n`);
1492
+ if (result.status !== 'passed') {
1493
+ process.exitCode = 1;
1494
+ }
1495
+ }
1496
+ if (require.main === module) {
1497
+ main().catch((error) => {
1498
+ console.error(error instanceof Error ? error.message : String(error));
1499
+ process.exitCode = 1;
1500
+ });
1501
+ }