dd-trace 5.116.0 → 5.118.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 (98) hide show
  1. package/ci/diagnose.js +162 -21
  2. package/ci/init.js +0 -1
  3. package/ci/runbook.md +187 -180
  4. package/ci/test-optimization-validation/approval-artifacts.js +3 -1
  5. package/ci/test-optimization-validation/approval.js +52 -15
  6. package/ci/test-optimization-validation/ci-command-candidate.js +26 -32
  7. package/ci/test-optimization-validation/ci-discovery.js +1 -1
  8. package/ci/test-optimization-validation/ci-package-scripts.js +141 -0
  9. package/ci/test-optimization-validation/ci-remediation.js +112 -39
  10. package/ci/test-optimization-validation/cli.js +516 -681
  11. package/ci/test-optimization-validation/command-blocker.js +188 -33
  12. package/ci/test-optimization-validation/command-output-policy.js +3 -26
  13. package/ci/test-optimization-validation/command-runner.js +189 -245
  14. package/ci/test-optimization-validation/environment.js +90 -0
  15. package/ci/test-optimization-validation/executable.js +159 -388
  16. package/ci/test-optimization-validation/framework-adapters/cucumber.js +119 -0
  17. package/ci/test-optimization-validation/framework-adapters/cypress.js +107 -0
  18. package/ci/test-optimization-validation/framework-adapters/playwright.js +181 -0
  19. package/ci/test-optimization-validation/generated-files.js +75 -13
  20. package/ci/test-optimization-validation/generated-test-contract.js +283 -0
  21. package/ci/test-optimization-validation/generated-verifier.js +49 -16
  22. package/ci/test-optimization-validation/literal-environment.js +57 -0
  23. package/ci/test-optimization-validation/manifest-loader.js +2 -22
  24. package/ci/test-optimization-validation/manifest-scaffold.js +816 -515
  25. package/ci/test-optimization-validation/manifest-schema.js +494 -702
  26. package/ci/test-optimization-validation/offline-fixtures.js +4 -1
  27. package/ci/test-optimization-validation/plan-writer.js +243 -984
  28. package/ci/test-optimization-validation/preflight-runner.js +49 -55
  29. package/ci/test-optimization-validation/redaction.js +2 -1
  30. package/ci/test-optimization-validation/report-writer.js +391 -1357
  31. package/ci/test-optimization-validation/result-semantics.js +86 -0
  32. package/ci/test-optimization-validation/runner-command.js +249 -0
  33. package/ci/test-optimization-validation/runner-contract.js +664 -0
  34. package/ci/test-optimization-validation/scenarios/auto-test-retries.js +29 -3
  35. package/ci/test-optimization-validation/scenarios/basic-reporting.js +240 -543
  36. package/ci/test-optimization-validation/scenarios/ci-wiring.js +450 -670
  37. package/ci/test-optimization-validation/scenarios/early-flake-detection.js +4 -3
  38. package/ci/test-optimization-validation/scenarios/helpers.js +67 -9
  39. package/ci/test-optimization-validation/scenarios/test-management.js +4 -3
  40. package/ci/test-optimization-validation/source-text.js +87 -0
  41. package/ci/test-optimization-validation/test-output.js +12 -22
  42. package/ext/tags.d.ts +1 -0
  43. package/index.d.ts +7 -0
  44. package/package.json +3 -3
  45. package/packages/datadog-instrumentations/src/cucumber.js +11 -12
  46. package/packages/datadog-instrumentations/src/helpers/channel.js +74 -0
  47. package/packages/datadog-instrumentations/src/helpers/hooks.js +2 -0
  48. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/index.js +1 -0
  49. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/openai-agents.js +31 -0
  50. package/packages/datadog-instrumentations/src/jest.js +18 -17
  51. package/packages/datadog-instrumentations/src/mocha/main.js +79 -30
  52. package/packages/datadog-instrumentations/src/openai-agents.js +159 -0
  53. package/packages/datadog-instrumentations/src/playwright.js +3 -24
  54. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +1 -1
  55. package/packages/datadog-instrumentations/src/vitest-main.js +14 -26
  56. package/packages/datadog-instrumentations/src/vitest-util.js +0 -7
  57. package/packages/datadog-instrumentations/src/vitest-worker.js +5 -8
  58. package/packages/datadog-plugin-cucumber/src/index.js +2 -1
  59. package/packages/datadog-plugin-mocha/src/index.js +2 -1
  60. package/packages/datadog-plugin-openai-agents/src/index.js +74 -0
  61. package/packages/datadog-plugin-openai-agents/src/integration.js +503 -0
  62. package/packages/datadog-plugin-openai-agents/src/processor.js +108 -0
  63. package/packages/datadog-plugin-openai-agents/src/util.js +60 -0
  64. package/packages/datadog-plugin-playwright/src/index.js +4 -1
  65. package/packages/datadog-plugin-vitest/src/index.js +5 -5
  66. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +48 -49
  67. package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +161 -33
  68. package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache-schema.js +6 -4
  69. package/packages/dd-trace/src/config/generated-config-types.d.ts +2 -0
  70. package/packages/dd-trace/src/config/supported-configurations.json +7 -0
  71. package/packages/dd-trace/src/encode/span-stats.js +7 -1
  72. package/packages/dd-trace/src/exporter.js +16 -9
  73. package/packages/dd-trace/src/llmobs/index.js +9 -1
  74. package/packages/dd-trace/src/llmobs/plugins/ai/util.js +104 -22
  75. package/packages/dd-trace/src/llmobs/plugins/openai/index.js +5 -9
  76. package/packages/dd-trace/src/llmobs/plugins/openai/utils.js +20 -0
  77. package/packages/dd-trace/src/llmobs/plugins/openai-agents/utils.js +321 -0
  78. package/packages/dd-trace/src/llmobs/sdk.js +2 -1
  79. package/packages/dd-trace/src/llmobs/span_processor.js +7 -2
  80. package/packages/dd-trace/src/llmobs/tagger.js +23 -3
  81. package/packages/dd-trace/src/llmobs/util.js +56 -3
  82. package/packages/dd-trace/src/openfeature/agentless_configuration_source.js +3 -6
  83. package/packages/dd-trace/src/openfeature/configuration_source.js +5 -8
  84. package/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_transformer.js +5 -1
  85. package/packages/dd-trace/src/plugins/ci_plugin.js +9 -3
  86. package/packages/dd-trace/src/plugins/index.js +1 -0
  87. package/packages/dd-trace/src/plugins/outbound.js +4 -1
  88. package/packages/dd-trace/src/profiling/profilers/space.js +1 -1
  89. package/packages/dd-trace/src/profiling/profilers/wall.js +1 -0
  90. package/packages/dd-trace/src/span_processor.js +1 -1
  91. package/packages/dd-trace/src/span_stats.js +22 -4
  92. package/ci/test-optimization-validation/command-suitability.js +0 -471
  93. package/ci/test-optimization-validation/init-probe-preload.js +0 -163
  94. package/ci/test-optimization-validation/init-probe.js +0 -246
  95. package/ci/test-optimization-validation/late-initialization.js +0 -63
  96. package/ci/test-optimization-validation/local-command.js +0 -163
  97. package/ci/test-optimization-validation/setup-runner.js +0 -97
  98. package/ci/test-optimization-validation-manifest.schema.json +0 -1
@@ -2,236 +2,140 @@
2
2
 
3
3
  /* eslint-disable no-console */
4
4
 
5
- const fs = require('fs')
6
- const path = require('path')
5
+ const fs = require('node:fs')
6
+ const path = require('node:path')
7
7
 
8
- const { getFrameworkDefinitions } = require('../diagnose')
9
- const { DD_MAJOR } = require('../../version')
10
-
11
- const { assertApprovalDigest, getApprovalDigest } = require('./approval')
8
+ const { assertApprovalDigest } = require('./approval')
12
9
  const { loadApprovedPlan } = require('./approval-artifacts')
13
-
14
- const { runBasicReporting } = require('./scenarios/basic-reporting')
15
- const { runEarlyFlakeDetection } = require('./scenarios/early-flake-detection')
16
- const { runAutoTestRetries } = require('./scenarios/auto-test-retries')
17
- const { runTestManagement } = require('./scenarios/test-management')
18
- const { runCiWiring } = require('./scenarios/ci-wiring')
10
+ const { annotateCiDiscovery } = require('./ci-discovery')
19
11
  const { cleanupGeneratedFiles } = require('./generated-files')
20
12
  const { verifyGeneratedTestStrategy } = require('./generated-verifier')
21
- const { annotateCiDiscovery } = require('./ci-discovery')
22
13
  const { loadManifest } = require('./manifest-loader')
23
14
  const { createManifestScaffold } = require('./manifest-scaffold')
24
- const { formatExecutionPlan, getApprovalSummaryPath, getExecutionPlanPath } = require('./plan-writer')
15
+ const {
16
+ formatExecutionPlanArtifacts,
17
+ getExecutionPlanPath,
18
+ } = require('./plan-writer')
25
19
  const { runFrameworkPreflight } = require('./preflight-runner')
26
20
  const { sanitizeConsoleText } = require('./redaction')
21
+ const {
22
+ annotateResults,
23
+ getExecutionStatus,
24
+ getValidationCoverage,
25
+ getValidatorExitCode,
26
+ } = require('./result-semantics')
27
27
  const { writePendingReport, writeReport } = require('./report-writer')
28
+ const { getBasicCommand } = require('./runner-command')
29
+ const { getUnavailableExecutable } = require('./executable')
30
+ const { runAutoTestRetries } = require('./scenarios/auto-test-retries')
31
+ const { runBasicReporting } = require('./scenarios/basic-reporting')
32
+ const { runCiWiring } = require('./scenarios/ci-wiring')
33
+ const { runEarlyFlakeDetection } = require('./scenarios/early-flake-detection')
34
+ const { runTestManagement } = require('./scenarios/test-management')
28
35
  const { ensureSafeDirectory } = require('./safe-files')
29
- const { runSetupCommands } = require('./setup-runner')
30
- const {
31
- getStaticBlocker,
32
- runStaticDiagnosis,
33
- } = require('./static-diagnosis')
36
+ const { getStaticBlocker, runStaticDiagnosis } = require('./static-diagnosis')
34
37
 
35
38
  const DEFAULT_MANIFEST = './dd-test-optimization-validation-manifest.json'
36
39
  const DEFAULT_OUT = './dd-test-optimization-validation-results'
37
-
38
40
  const SCENARIOS = {
39
41
  'basic-reporting': runBasicReporting,
40
42
  efd: runEarlyFlakeDetection,
41
43
  atr: runAutoTestRetries,
42
44
  'test-management': runTestManagement,
43
45
  }
44
- const BASIC_REPORTING_SCENARIO = 'basic-reporting'
45
- const CI_WIRING_SCENARIO = 'ci-wiring'
46
+ const BASIC_REPORTING = 'basic-reporting'
47
+ const CI_WIRING = 'ci-wiring'
46
48
 
49
+ /**
50
+ * Parses validator CLI arguments.
51
+ *
52
+ * @param {string[]} argv command arguments
53
+ * @returns {object} parsed options
54
+ */
47
55
  function parseArgs (argv) {
48
56
  const options = {
57
+ approvalOverrides: [],
58
+ frameworks: new Set(),
59
+ keepTempFiles: false,
49
60
  manifest: DEFAULT_MANIFEST,
50
61
  out: DEFAULT_OUT,
51
- frameworks: new Set(),
52
- scenarios: new Set(getSelectableScenarios()),
53
62
  requestedScenario: null,
54
- keepTempFiles: false,
63
+ scenarios: new Set(getSelectableScenarios()),
55
64
  verbose: false,
56
- approvalOverrides: [],
57
65
  }
58
66
 
59
- for (let i = 0; i < argv.length; i++) {
60
- const arg = argv[i]
61
- switch (arg) {
62
- case '--manifest':
63
- options.manifest = requireValue(argv, ++i, arg)
64
- options.approvalOverrides.push(arg)
65
- break
66
- case '--out':
67
- options.out = requireValue(argv, ++i, arg)
68
- options.approvalOverrides.push(arg)
69
- break
70
- case '--framework':
71
- options.frameworks.add(normalizeFrameworkTarget(requireValue(argv, ++i, arg)))
72
- options.approvalOverrides.push(arg)
73
- break
74
- case '--scenario':
75
- options.requestedScenario = requireValue(argv, ++i, arg)
76
- options.scenarios = normalizeScenarioSelection(options.requestedScenario)
77
- options.approvalOverrides.push(arg)
78
- break
79
- case '--keep-temp-files':
80
- options.keepTempFiles = true
81
- options.approvalOverrides.push(arg)
82
- break
83
- case '--verbose':
84
- options.verbose = true
85
- options.approvalOverrides.push(arg)
86
- break
87
- case '--validate-manifest':
88
- options.validateManifest = true
89
- break
90
- case '--init-manifest':
91
- options.initManifest = true
92
- break
93
- case '--print-plan':
94
- options.printPlan = true
95
- break
96
- case '--print-approval-sha256':
97
- options.printApprovalSha256 = true
98
- break
99
- case '--approved-plan-sha256':
100
- options.approvedPlanSha256 = requireValue(argv, ++i, arg)
101
- break
102
- case '--offline-fixture-nonce':
103
- options.offlineFixtureNonce = requireValue(argv, ++i, arg)
104
- break
105
- case '--run-approved-plan':
106
- options.runApprovedPlan = requireValue(argv, ++i, arg)
107
- break
108
- case '--sha256':
109
- options.approvedArtifactSha256 = requireValue(argv, ++i, arg)
110
- break
111
- case '--help':
112
- case '-h':
113
- options.help = true
114
- break
115
- default:
116
- throw new Error(`Unknown argument: ${arg}`)
67
+ for (let index = 0; index < argv.length; index++) {
68
+ const argument = argv[index]
69
+ if (argument === '--manifest') {
70
+ options.manifest = requireValue(argv, ++index, argument)
71
+ options.approvalOverrides.push(argument)
72
+ } else if (argument === '--out') {
73
+ options.out = requireValue(argv, ++index, argument)
74
+ options.approvalOverrides.push(argument)
75
+ } else if (argument === '--framework') {
76
+ options.frameworks.add(normalizeFrameworkTarget(requireValue(argv, ++index, argument)))
77
+ options.approvalOverrides.push(argument)
78
+ } else if (argument === '--scenario') {
79
+ options.requestedScenario = requireValue(argv, ++index, argument)
80
+ options.scenarios = normalizeScenarioSelection(options.requestedScenario)
81
+ options.approvalOverrides.push(argument)
82
+ } else if (argument === '--keep-temp-files') {
83
+ options.keepTempFiles = true
84
+ options.approvalOverrides.push(argument)
85
+ } else if (argument === '--verbose') {
86
+ options.verbose = true
87
+ options.approvalOverrides.push(argument)
88
+ } else if (argument === '--validate-manifest') {
89
+ options.validateManifest = true
90
+ } else if (argument === '--init-manifest') {
91
+ options.initManifest = true
92
+ } else if (argument === '--print-plan') {
93
+ options.printPlan = true
94
+ } else if (argument === '--run-approved-plan') {
95
+ options.runApprovedPlan = requireValue(argv, ++index, argument)
96
+ } else if (argument === '--sha256') {
97
+ options.approvedArtifactSha256 = requireValue(argv, ++index, argument)
98
+ } else if (argument === '--help' || argument === '-h') {
99
+ options.help = true
100
+ } else {
101
+ throw new Error(`Unknown argument: ${argument}`)
117
102
  }
118
103
  }
119
-
120
- for (const scenario of options.scenarios) {
121
- if (!getSelectableScenarios().includes(scenario)) {
122
- throw new Error(`Unknown scenario "${scenario}". Expected one of: ${getSelectableScenarios().join(', ')}`)
123
- }
124
- }
125
-
126
104
  return options
127
105
  }
128
106
 
129
- function requireValue (argv, index, flag) {
130
- if (!argv[index]) {
131
- throw new Error(`${flag} requires a value`)
132
- }
133
- return argv[index]
134
- }
135
-
136
- function printHelp () {
137
- console.log(`Usage:
138
- node ci/validate-test-optimization.js [options]
139
-
140
- Options:
141
- --manifest <path> Manifest path. Defaults to ${DEFAULT_MANIFEST}
142
- --out <path> Output directory. Defaults to ${DEFAULT_OUT}
143
- --framework <id> Run one framework entry. Can be repeated. A trailing ":" is ignored.
144
- A framework kind such as "vitest" runs all matching Vitest entries.
145
- --scenario <name> Run one scenario: ${getSelectableScenarios().join(', ')}
146
- --keep-temp-files Leave generated validation files in place.
147
- --verbose Print command progress.
148
- --validate-manifest Validate the manifest and exit without running project code.
149
- --init-manifest Create a schema-valid manifest scaffold without running project code.
150
- --print-plan Write the plan and approval artifacts without running project code.
151
- --run-approved-plan Run the exact approval.json produced by --print-plan.
152
- --sha256 <digest> Require approval.json and reconstructed current inputs to match this SHA-256.
153
- --help Show this help.
154
- `)
155
- }
156
-
107
+ /**
108
+ * Runs the validator CLI.
109
+ *
110
+ * @param {string[]} argv command arguments
111
+ * @returns {Promise<void>} completion
112
+ */
157
113
  async function main (argv) {
114
+ let activeManifest
115
+ let activeOut
116
+ let cleanupOutcome = { status: 'not_started' }
117
+
158
118
  try {
159
119
  const options = parseArgs(argv)
160
- if (options.help) {
161
- printHelp()
162
- return
163
- }
164
-
165
- if (options.initManifest) {
166
- const manifestPath = path.resolve(options.manifest)
167
- if (path.dirname(manifestPath) !== process.cwd()) {
168
- throw new Error('The generated manifest must be stored directly in the current repository root.')
169
- }
170
- const manifest = createManifestScaffold({ root: process.cwd(), frameworks: options.frameworks })
171
- fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' })
172
- console.log(sanitizeConsoleText(
173
- `Created validation manifest scaffold without running project code: ${manifestPath}\n` +
174
- 'This scaffold is already schema-valid; preserve its command boilerplate. Review the selected test commands ' +
175
- 'and CI files listed in ciDiscovery, record one replayable CI test step when available, then run ' +
176
- '--validate-manifest and follow its field-specific errors.'
177
- ))
178
- return
179
- }
120
+ assertCompatibleModes(options)
121
+ if (options.help) return printHelp()
122
+ if (options.initManifest) return initializeManifest(options)
180
123
 
181
124
  if (options.runApprovedPlan) applyApprovedPlanOptions(options)
182
-
183
125
  const manifest = loadManifest(options.manifest)
184
- if (options.printPlan) {
185
- const out = validateOutputPath(manifest, options.out)
186
- const approvalManifest = getApprovalManifest(manifest, options.frameworks)
187
- formatExecutionPlan({
188
- manifest: approvalManifest,
189
- out,
190
- selectedFrameworkIds: options.frameworks.size > 0
191
- ? approvalManifest.frameworks.map(framework => framework.id)
192
- : [],
193
- requestedScenario: options.requestedScenario,
194
- keepTempFiles: options.keepTempFiles,
195
- verbose: options.verbose,
196
- })
197
- console.log(sanitizeConsoleText(
198
- '[test-optimization-validator-agent] Customer approval summary written to ' +
199
- `${getApprovalSummaryPath(out)}. Read that file and send its complete contents in a user-facing message ` +
200
- `before requesting approval. Detailed audit information is in ${getExecutionPlanPath(out)}. ` +
201
- 'The approval command is available only in the summary.'
202
- ))
203
- return
204
- }
205
- if (options.printApprovalSha256) {
206
- if (!options.offlineFixtureNonce) {
207
- throw new Error('--print-approval-sha256 requires the --offline-fixture-nonce value shown in the plan.')
208
- }
209
- const out = validateOutputPath(manifest, options.out)
210
- const approvalManifest = getApprovalManifest(manifest, options.frameworks)
211
- console.log(getApprovalDigest({
212
- manifest: approvalManifest,
213
- out,
214
- selectedFrameworkIds: options.frameworks.size > 0
215
- ? approvalManifest.frameworks.map(framework => framework.id)
216
- : [],
217
- requestedScenario: options.requestedScenario,
218
- offlineFixtureNonce: options.offlineFixtureNonce,
219
- keepTempFiles: options.keepTempFiles,
220
- verbose: options.verbose,
221
- }))
222
- return
223
- }
126
+
224
127
  if (options.validateManifest) {
225
128
  console.log(sanitizeConsoleText(`Validation manifest is valid: ${manifest.__path}`))
226
129
  return
227
130
  }
131
+ if (options.printPlan) return printPlan(manifest, options)
228
132
  if (!options.approvedPlanSha256 || !options.offlineFixtureNonce) {
229
- throw new Error(
230
- 'Live validation requires --run-approved-plan and --sha256 from a reviewed --print-plan result. Render and ' +
231
- 'approve a fresh execution plan first.'
232
- )
133
+ throw new Error('Live validation requires the checksum-bound command produced by --print-plan.')
233
134
  }
135
+
234
136
  const out = validateOutputPath(manifest, options.out)
137
+ activeManifest = manifest
138
+ activeOut = out
235
139
  options.repositoryRoot = manifest.repository.root
236
140
  const selectedFrameworks = filterFrameworks(manifest.frameworks, options.frameworks)
237
141
  const approvalManifest = getApprovalManifest(manifest, options.frameworks)
@@ -248,172 +152,233 @@ async function main (argv) {
248
152
  })
249
153
  options.requireExecutableApproval = true
250
154
  ensureSafeDirectory(manifest.repository.root, out, 'validation output directory', { allowRootSymlink: true })
251
- if (writePendingReport) writePendingReport({ manifest, out })
155
+ writePendingReport({ manifest, out })
156
+
252
157
  const staticDiagnosis = runStaticDiagnosis({ manifest, out })
253
158
  annotateCiDiscovery({ manifest, diagnosis: staticDiagnosis.report })
254
-
255
159
  const results = []
256
- const runnableFrameworks = []
257
160
 
258
161
  try {
259
- const frameworks = filterFrameworks(manifest.frameworks, options.frameworks)
260
- const liveReadyFrameworks = []
261
-
262
- for (const framework of frameworks) {
263
- if (framework.status !== 'runnable') {
264
- results.push(getFrameworkStatusResult(framework))
265
- continue
266
- }
267
-
268
- const staticBlocker = getStaticBlocker(framework, staticDiagnosis.report)
269
- if (staticBlocker) {
270
- results.push(getStaticFailure(framework, staticBlocker, staticDiagnosis.reportPath))
271
- continue
272
- }
273
-
274
- liveReadyFrameworks.push(framework)
275
- }
276
-
277
- for (const framework of liveReadyFrameworks) {
278
- // Setup commands are project preparation, not Test Optimization signal collection.
279
- if (framework.setup?.commands?.length > 0) logPhaseStart(framework, 'Project setup')
162
+ for (const framework of selectedFrameworks) {
163
+ // Each framework is independent. A setup blocker must not discard useful results from another adapter.
280
164
  // eslint-disable-next-line no-await-in-loop
281
- const setup = await runSetupCommands({ framework, out, options })
282
- if (framework.setup?.commands?.length > 0) {
283
- logPhaseComplete(framework, 'Project setup', setup.ok ? 'pass' : setup.failure?.status)
284
- }
285
- if (!setup.ok) {
286
- results.push(setup.failure)
287
- continue
288
- }
289
-
290
- runnableFrameworks.push(framework)
165
+ await validateFramework({ framework, manifest, options, out, results, staticDiagnosis })
291
166
  }
292
- for (const framework of runnableFrameworks) {
293
- let basicResult
294
- if (options.scenarios.has(BASIC_REPORTING_SCENARIO)) {
295
- // The validator owns the dd-trace-less control so ambient agent initialization cannot contaminate it.
296
- logPhaseStart(framework, 'Test execution without Datadog')
297
- // eslint-disable-next-line no-await-in-loop
298
- const preflight = await runFrameworkPreflight({ framework, out, options })
299
- logPhaseComplete(
300
- framework,
301
- 'Test execution without Datadog',
302
- preflight.ok ? 'pass' : preflight.failure?.status
303
- )
304
- // Scenarios intentionally run in order so each one can use an isolated offline fixture.
305
- if (preflight.ok) {
306
- logPhaseStart(framework, 'Basic Reporting')
307
- // eslint-disable-next-line no-await-in-loop
308
- basicResult = await SCENARIOS[BASIC_REPORTING_SCENARIO]({ manifest, framework, out, options })
309
- logPhaseComplete(framework, 'Basic Reporting', basicResult.status)
310
- } else {
311
- basicResult = preflight.failure
312
- }
313
- results.push(basicResult)
314
- }
315
-
316
- if (options.scenarios.has(CI_WIRING_SCENARIO)) {
317
- if (basicResult && basicResult.status !== 'pass') {
318
- results.push(getSkippedCiWiringAfterBasicFailure(framework, basicResult))
319
- } else {
320
- // CI wiring runs after Basic Reporting proves this framework can report when initialized directly.
321
- logPhaseStart(framework, 'CI wiring')
322
- // eslint-disable-next-line no-await-in-loop
323
- const ciWiringResult = await runCiWiring({ manifest, framework, out, options, basicResult })
324
- results.push(ciWiringResult)
325
- logPhaseComplete(framework, 'CI wiring', ciWiringResult.status)
326
- }
327
- }
328
-
329
- const advancedScenarios = getAdvancedScenarios(options.scenarios)
330
- if (basicResult && basicResult.status !== 'pass') {
331
- for (const scenario of advancedScenarios) {
332
- results.push(getSkippedAfterBasicFailure(framework, scenario, basicResult))
333
- }
334
- continue
335
- }
336
-
337
- if (advancedScenarios.length > 0) {
338
- logPhaseStart(framework, 'Temporary test verification')
339
- // eslint-disable-next-line no-await-in-loop
340
- const generatedVerification = await verifyGeneratedTestStrategy({ framework, out, options })
341
- logPhaseComplete(
342
- framework,
343
- 'Temporary test verification',
344
- generatedVerification.ok ? 'pass' : generatedVerification.failure?.status
345
- )
346
- if (!generatedVerification.ok) {
347
- results.push(generatedVerification.failure)
348
- for (const scenario of advancedScenarios) {
349
- results.push(getSkippedAfterGeneratedVerificationFailure(
350
- framework,
351
- scenario,
352
- generatedVerification.failure
353
- ))
354
- }
355
- continue
356
- }
357
- }
358
-
359
- for (const scenario of advancedScenarios) {
360
- const runScenario = SCENARIOS[scenario]
361
- // Scenarios intentionally run in order so each one can use an isolated offline fixture.
362
- logPhaseStart(framework, getScenarioDisplayName(scenario))
363
- // eslint-disable-next-line no-await-in-loop
364
- const result = await runScenario({ manifest, framework, out, options })
365
- results.push(result)
366
- logPhaseComplete(framework, getScenarioDisplayName(scenario), result.status)
167
+ } finally {
168
+ try {
169
+ cleanupOutcome = await cleanupGeneratedFiles(manifest, { keep: options.keepTempFiles })
170
+ if (cleanupOutcome.status === 'incomplete') {
171
+ results.push(getCleanupFailure(undefined, cleanupOutcome))
367
172
  }
173
+ } catch (error) {
174
+ cleanupOutcome = { errorCount: 1, status: 'incomplete' }
175
+ results.push(getCleanupFailure(error, cleanupOutcome))
368
176
  }
369
- } finally {
370
- await cleanupGeneratedFiles(manifest, { keep: options.keepTempFiles })
371
177
  }
372
178
 
373
- addMissingRequiredResults(results, runnableFrameworks, options.scenarios)
374
- const validatorExitCode = results.some(isUnsuccessfulResult) || !didRunLiveValidation(results) ? 1 : 0
179
+ addMissingResults(results, selectedFrameworks, options.scenarios)
180
+ const annotatedResults = annotateResults(results)
181
+ const executionStatus = getExecutionStatus(annotatedResults)
182
+ const validatorExitCode = getValidatorExitCode(annotatedResults, executionStatus)
375
183
  await writeReport({
376
184
  manifest,
377
- results,
378
185
  out,
186
+ results: annotatedResults,
379
187
  staticDiagnosis,
380
188
  runSummary: {
381
- runCompleted: true,
382
- validatorExitCode,
383
- validationCoverage: getValidationCoverage({
384
- results,
385
- requestedScenario: options.requestedScenario,
386
- frameworks: selectedFrameworks,
387
- scenarios: options.scenarios,
388
- }),
389
189
  checkedScenarios: [...options.scenarios],
190
+ cleanup: cleanupOutcome,
191
+ executionStatus,
390
192
  omittedScenarios: getSelectableScenarios().filter(scenario => !options.scenarios.has(scenario)),
391
193
  requestedScenario: options.requestedScenario,
194
+ runCompleted: true,
195
+ selectedFrameworkIds: selectedFrameworks.map(framework => framework.id),
196
+ validationCoverage: getValidationCoverage(annotatedResults),
197
+ validatorExitCode,
392
198
  },
393
199
  })
200
+ console.log(`Validation report: ${path.join(out, 'report.md')}`)
201
+ console.log('Present the report and stop. Any correction or retry requires a fresh plan and approval.')
394
202
  process.exitCode = validatorExitCode
395
- } catch (err) {
396
- process.exitCode = 1
397
- console.error(sanitizeConsoleText(err && err.stack ? err.stack : err))
203
+ } catch (error) {
204
+ process.exitCode = 3
205
+ if (activeManifest && activeOut) {
206
+ try {
207
+ await writeReport({
208
+ manifest: activeManifest,
209
+ out: activeOut,
210
+ results: [getOrchestrationFailure(error)],
211
+ runSummary: {
212
+ checkedScenarios: [],
213
+ cleanup: cleanupOutcome,
214
+ executionStatus: 'validator_error',
215
+ omittedScenarios: getSelectableScenarios(),
216
+ runCompleted: true,
217
+ selectedFrameworkIds: [],
218
+ validationCoverage: 'partial',
219
+ validatorExitCode: 3,
220
+ },
221
+ })
222
+ } catch {}
223
+ }
224
+ console.error(sanitizeConsoleText(error?.stack || error))
398
225
  }
399
226
  }
400
227
 
401
228
  /**
402
- * Reconstructs live options from a hash-verified approval artifact.
229
+ * Runs selected checks for one framework.
403
230
  *
404
- * @param {object} options parsed CLI options
405
- * @returns {void}
231
+ * @param {object} input framework execution inputs
232
+ * @param {object} input.framework framework manifest entry
233
+ * @param {object} input.manifest validation manifest
234
+ * @param {object} input.options validator options
235
+ * @param {string} input.out output directory
236
+ * @param {object[]} input.results accumulated results
237
+ * @param {object} input.staticDiagnosis static diagnosis
238
+ * @returns {Promise<void>} completion
406
239
  */
407
- function applyApprovedPlanOptions (options) {
408
- if (!options.approvedArtifactSha256) {
409
- throw new Error('--run-approved-plan requires --sha256 from the reviewed execution plan.')
240
+ async function validateFramework ({ framework, manifest, options, out, results, staticDiagnosis }) {
241
+ let ciResult
242
+ if (options.scenarios.has(CI_WIRING)) {
243
+ logPhase(framework, 'CI configuration audit', 'start')
244
+ ciResult = runCiWiring({ manifest, framework })
245
+ logPhase(framework, 'CI configuration audit', ciResult.status)
246
+ }
247
+ if (![...options.scenarios].some(scenario => scenario !== CI_WIRING)) {
248
+ if (ciResult) results.push(ciResult)
249
+ return
410
250
  }
411
- if (options.approvalOverrides.length > 0 || options.offlineFixtureNonce || options.approvedPlanSha256) {
412
- throw new Error(
413
- '--run-approved-plan cannot be combined with manifest, output, selection, or legacy approval flags.'
414
- )
251
+
252
+ if (framework.status !== 'runnable') {
253
+ results.push(getFrameworkStatusResult(framework))
254
+ if (ciResult) results.push(ciResult)
255
+ addNotReachedLocalResults(results, framework, options.scenarios, 'framework-not-runnable')
256
+ return
257
+ }
258
+
259
+ const unavailable = getUnavailableExecutable(getBasicCommand(framework))
260
+ if (unavailable) {
261
+ results.push(getUnavailableRunnerResult(framework, unavailable))
262
+ if (ciResult) results.push(ciResult)
263
+ addNotReachedLocalResults(results, framework, options.scenarios, 'runner-unavailable')
264
+ return
415
265
  }
416
266
 
267
+ const blocker = getStaticBlocker(framework, staticDiagnosis.report)
268
+ if (blocker) {
269
+ const staticFailure = getStaticFailure(framework, blocker, staticDiagnosis.reportPath)
270
+ const basicNotReached = getBasicNotReached(framework, blocker.reason, 'static-project-blocker')
271
+ results.push(staticFailure, basicNotReached)
272
+ if (ciResult) results.push(ciResult)
273
+ addAdvancedNotReached(results, framework, options.scenarios, basicNotReached)
274
+ return
275
+ }
276
+
277
+ let basicResult
278
+ if (options.scenarios.has(BASIC_REPORTING)) {
279
+ logPhase(framework, 'clean direct test', 'start')
280
+ const preflight = await runFrameworkPreflight({ framework, out, options })
281
+ logPhase(framework, 'clean direct test', preflight.ok ? 'pass' : 'incomplete')
282
+ if (preflight.ok) {
283
+ logPhase(framework, 'Basic Reporting', 'start')
284
+ basicResult = await runBasicReporting({ manifest, framework, out, options })
285
+ logPhase(framework, 'Basic Reporting', basicResult.status)
286
+ } else {
287
+ basicResult = preflight.failure
288
+ }
289
+ results.push(basicResult)
290
+ }
291
+ if (ciResult) results.push(ciResult)
292
+
293
+ const advanced = getAdvancedScenarios(options.scenarios)
294
+ if (advanced.length === 0) return
295
+ if (basicResult?.evidence?.foundationalReportingEstablished !== true) {
296
+ addAdvancedNotReached(results, framework, options.scenarios, basicResult)
297
+ return
298
+ }
299
+
300
+ const generated = await verifyGeneratedTestStrategy({ framework, out, options })
301
+ if (!generated.ok) {
302
+ results.push(generated.failure)
303
+ addAdvancedNotReached(results, framework, options.scenarios, generated.failure)
304
+ return
305
+ }
306
+ for (const scenario of advanced) {
307
+ // Advanced scenarios are serial because their offline fixtures and generated retry state are isolated.
308
+ // eslint-disable-next-line no-await-in-loop
309
+ results.push(await SCENARIOS[scenario]({ manifest, framework, out, options }))
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Creates a manifest scaffold and prints the bounded next step.
315
+ *
316
+ * @param {object} options CLI options
317
+ * @returns {void}
318
+ */
319
+ function initializeManifest (options) {
320
+ const manifestPath = path.resolve(options.manifest)
321
+ if (path.dirname(manifestPath) !== process.cwd()) {
322
+ throw new Error('The generated manifest must be stored directly in the current repository root.')
323
+ }
324
+ const manifest = createManifestScaffold({ root: process.cwd(), frameworks: options.frameworks })
325
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' })
326
+ const targets = manifest.ciDiscovery.reviewTargets
327
+ console.log(sanitizeConsoleText([
328
+ `Created a data-only validation manifest: ${manifestPath}`,
329
+ 'No project code ran. The validator selected repository-contained runners and one test file per framework.',
330
+ targets.length > 0
331
+ ? `Review only these CI files, in order: ${targets.join(', ')}. Record one exact test job, command, and ` +
332
+ 'effective initialization/transport evidence. Set reviewComplete=true only after resolving wrappers and ' +
333
+ 'inherited configuration; otherwise leave the uncertainty in unresolved.'
334
+ : 'No supported CI configuration file was found. Leave CI review incomplete.',
335
+ 'Do not add commands, setup steps, package scripts, wrapper chains, or fallback tests to the manifest.',
336
+ 'Then run --validate-manifest and --print-plan.',
337
+ ].join('\n')))
338
+ }
339
+
340
+ /**
341
+ * Writes and prints the approval plan.
342
+ *
343
+ * @param {object} manifest loaded manifest
344
+ * @param {object} options CLI options
345
+ * @returns {void}
346
+ */
347
+ function printPlan (manifest, options) {
348
+ const out = validateOutputPath(manifest, options.out)
349
+ const approvalManifest = getApprovalManifest(manifest, options.frameworks)
350
+ const { plan } = formatExecutionPlanArtifacts({
351
+ manifest: approvalManifest,
352
+ out,
353
+ selectedFrameworkIds: options.frameworks.size > 0
354
+ ? approvalManifest.frameworks.map(framework => framework.id)
355
+ : [],
356
+ requestedScenario: options.requestedScenario,
357
+ keepTempFiles: options.keepTempFiles,
358
+ verbose: options.verbose,
359
+ })
360
+ console.log(sanitizeConsoleText([
361
+ '===== CUSTOMER APPROVAL PLAN =====',
362
+ plan,
363
+ '===== END CUSTOMER APPROVAL PLAN =====',
364
+ '',
365
+ `Saved execution plan: ${getExecutionPlanPath(out)}`,
366
+ 'LIVE VALIDATION HAS NOT RUN.',
367
+ 'Present the complete delimited plan and ask exactly: Approve executing exactly the plan above?',
368
+ ].join('\n')))
369
+ }
370
+
371
+ /**
372
+ * Restores approved execution options from approval.json.
373
+ *
374
+ * @param {object} options parsed options
375
+ * @returns {void}
376
+ */
377
+ function applyApprovedPlanOptions (options) {
378
+ if (!options.approvedArtifactSha256) throw new Error('--run-approved-plan requires --sha256.')
379
+ if (options.approvalOverrides.length > 0) {
380
+ throw new Error('--run-approved-plan cannot be combined with manifest, output, or selection flags.')
381
+ }
417
382
  const { material } = loadApprovedPlan(options.runApprovedPlan, options.approvedArtifactSha256)
418
383
  options.manifest = material.manifest.path
419
384
  options.out = material.validation.outputDirectory
@@ -428,85 +393,94 @@ function applyApprovedPlanOptions (options) {
428
393
  options.approvedPlanSha256 = options.approvedArtifactSha256
429
394
  }
430
395
 
431
- function validateOutputPath (manifest, outputPath) {
432
- const root = path.resolve(manifest.repository.root)
433
- const out = path.resolve(outputPath)
434
- const relative = path.relative(root, out)
435
- if (relative === '' || !relative || relative.startsWith('..') || path.isAbsolute(relative)) {
436
- throw new Error('Validation output directory must be a dedicated child directory inside repository.root.')
437
- }
438
- return out
439
- }
440
-
396
+ /**
397
+ * Filters manifest frameworks.
398
+ *
399
+ * @param {object[]} frameworks framework entries
400
+ * @param {Set<string>} targets selected ids or kinds
401
+ * @returns {object[]} selected entries
402
+ */
441
403
  function filterFrameworks (frameworks, targets) {
442
404
  if (targets.size === 0) return frameworks
443
-
444
- const selected = frameworks.filter(framework => {
445
- return targets.has(framework.id) || targets.has(framework.framework)
446
- })
447
-
448
- if (selected.length === 0) {
449
- throw new Error(`No framework entries matched ${formatFrameworkTargets(targets)}. Available entries: ${
450
- frameworks.map(framework => framework.id).join(', ') || 'none'
451
- }`)
452
- }
453
-
405
+ const selected = frameworks.filter(framework => targets.has(framework.id) || targets.has(framework.framework))
406
+ if (selected.length === 0) throw new Error('No framework matched the requested selection.')
454
407
  return selected
455
408
  }
456
409
 
457
410
  /**
458
- * Creates the manifest view covered by a framework-scoped approval.
411
+ * Returns a framework-scoped approval manifest.
459
412
  *
460
413
  * @param {object} manifest loaded manifest
461
- * @param {Set<string>} targets selected framework targets
414
+ * @param {Set<string>} targets selected frameworks
462
415
  * @returns {object} approval manifest
463
416
  */
464
417
  function getApprovalManifest (manifest, targets) {
465
- const frameworks = filterFrameworks(manifest.frameworks, targets)
466
- if (frameworks === manifest.frameworks) return manifest
467
-
468
- const approvalManifest = { ...manifest, frameworks }
418
+ if (targets.size === 0) return manifest
419
+ const approvalManifest = { ...manifest, frameworks: filterFrameworks(manifest.frameworks, targets) }
469
420
  Object.defineProperty(approvalManifest, '__sourceSha256', {
470
- configurable: false,
471
421
  enumerable: false,
472
422
  value: manifest.__sourceSha256,
473
- writable: false,
474
423
  })
475
424
  return approvalManifest
476
425
  }
477
426
 
478
- function normalizeFrameworkTarget (target) {
479
- const normalized = String(target).trim().replaceAll(/:+$/g, '')
480
- if (!normalized) {
481
- throw new Error('Framework target cannot be empty')
427
+ /**
428
+ * Adds explicit not-reached results for advanced scenarios.
429
+ *
430
+ * @param {object[]} results result list
431
+ * @param {object} framework framework entry
432
+ * @param {Set<string>} scenarios selected scenarios
433
+ * @param {object} blocker blocking result
434
+ * @returns {void}
435
+ */
436
+ function addAdvancedNotReached (results, framework, scenarios, blocker) {
437
+ for (const scenario of getAdvancedScenarios(scenarios)) {
438
+ results.push({
439
+ frameworkId: framework.id,
440
+ scenario,
441
+ status: 'skip',
442
+ diagnosis: 'Not reached because Basic Reporting did not establish the direct reporting path.',
443
+ evidence: {
444
+ blockedBy: blocker?.scenario || 'basic-reporting',
445
+ validationIncomplete: true,
446
+ },
447
+ artifacts: [],
448
+ })
482
449
  }
483
- return normalized
484
- }
485
-
486
- function formatFrameworkTargets (targets) {
487
- return [...targets].map(target => `"${target}"`).join(', ')
488
- }
489
-
490
- function normalizeScenarioSelection (scenario) {
491
- if (scenario === BASIC_REPORTING_SCENARIO) return new Set([scenario])
492
- return new Set([BASIC_REPORTING_SCENARIO, scenario])
493
450
  }
494
451
 
495
- function getAdvancedScenarios (scenarios) {
496
- return Object.keys(SCENARIOS).filter(scenario => {
497
- return scenario !== BASIC_REPORTING_SCENARIO && scenarios.has(scenario)
498
- })
452
+ /**
453
+ * Adds local not-reached results for a non-runnable framework.
454
+ *
455
+ * @param {object[]} results result list
456
+ * @param {object} framework framework entry
457
+ * @param {Set<string>} scenarios selected scenarios
458
+ * @param {string} reasonCode blocker id
459
+ * @returns {void}
460
+ */
461
+ function addNotReachedLocalResults (results, framework, scenarios, reasonCode) {
462
+ for (const scenario of scenarios) {
463
+ if (scenario === CI_WIRING) continue
464
+ results.push({
465
+ frameworkId: framework.id,
466
+ scenario,
467
+ status: 'skip',
468
+ diagnosis: 'Not reached because this framework has no available direct-runner validation target.',
469
+ evidence: { reasonCode, validationIncomplete: true },
470
+ artifacts: [],
471
+ })
472
+ }
499
473
  }
500
474
 
501
475
  /**
502
- * Fails closed when orchestration omits a selected check for a runnable framework.
476
+ * Adds fail-closed results for missing orchestration output.
503
477
  *
504
- * @param {object[]} results collected validation results
505
- * @param {object[]} frameworks runnable frameworks whose live phases started
478
+ * @param {object[]} results result list
479
+ * @param {object[]} frameworks selected frameworks
506
480
  * @param {Set<string>} scenarios selected scenarios
507
481
  * @returns {void}
508
482
  */
509
- function addMissingRequiredResults (results, frameworks, scenarios) {
483
+ function addMissingResults (results, frameworks, scenarios) {
510
484
  for (const framework of frameworks) {
511
485
  for (const scenario of scenarios) {
512
486
  if (results.some(result => result.frameworkId === framework.id && result.scenario === scenario)) continue
@@ -514,12 +488,8 @@ function addMissingRequiredResults (results, frameworks, scenarios) {
514
488
  frameworkId: framework.id,
515
489
  scenario,
516
490
  status: 'error',
517
- diagnosis: `${getScenarioDisplayName(scenario)} was selected but produced no validation result.`,
518
- evidence: {
519
- validationIncomplete: true,
520
- recommendation: 'Rerun the validator. If the check remains absent, report this validator orchestration ' +
521
- 'error instead of treating the validation as successful.',
522
- },
491
+ diagnosis: 'The selected check produced no result. This is a validator orchestration error.',
492
+ evidence: { validationIncomplete: true, validationOrchestrationFailed: true },
523
493
  artifacts: [],
524
494
  })
525
495
  }
@@ -527,377 +497,242 @@ function addMissingRequiredResults (results, frameworks, scenarios) {
527
497
  }
528
498
 
529
499
  /**
530
- * Reports whether all default checks produced results in an unscoped run.
500
+ * Returns selected advanced scenario ids.
531
501
  *
532
- * @param {object} input coverage inputs
533
- * @param {object[]} input.results validation results
534
- * @param {string|null} input.requestedScenario explicitly selected scenario
535
- * @param {object[]} input.frameworks selected manifest frameworks
536
- * @param {Set<string>} input.scenarios selected scenarios
537
- * @returns {'complete'|'partial'} validation coverage
502
+ * @param {Set<string>} scenarios selected scenarios
503
+ * @returns {string[]} advanced scenarios
538
504
  */
539
- function getValidationCoverage ({ results, requestedScenario, frameworks, scenarios }) {
540
- if (requestedScenario) return 'partial'
541
- if (results.some(result => result.evidence?.manifestIncomplete || result.evidence?.validationIncomplete)) {
542
- return 'partial'
543
- }
544
-
545
- const runnableFrameworks = frameworks.filter(framework => framework.status === 'runnable')
546
- if (runnableFrameworks.length === 0) return 'partial'
547
- for (const framework of runnableFrameworks) {
548
- for (const scenario of scenarios) {
549
- if (!results.some(result => result.frameworkId === framework.id && result.scenario === scenario)) {
550
- return 'partial'
551
- }
552
- }
553
- }
554
- return 'complete'
505
+ function getAdvancedScenarios (scenarios) {
506
+ return Object.keys(SCENARIOS).filter(scenario => scenario !== BASIC_REPORTING && scenarios.has(scenario))
555
507
  }
556
508
 
509
+ /**
510
+ * Returns all selectable scenarios.
511
+ *
512
+ * @returns {string[]} scenario ids
513
+ */
557
514
  function getSelectableScenarios () {
558
- return [
559
- BASIC_REPORTING_SCENARIO,
560
- CI_WIRING_SCENARIO,
561
- ...Object.keys(SCENARIOS).filter(scenario => scenario !== BASIC_REPORTING_SCENARIO),
562
- ]
515
+ return [...Object.keys(SCENARIOS), CI_WIRING]
563
516
  }
564
517
 
565
- function getSkippedCiWiringAfterBasicFailure (framework, basicResult) {
566
- return {
567
- frameworkId: framework.id,
568
- scenario: 'ci-wiring',
569
- status: 'skip',
570
- diagnosis: 'Skipped CI wiring validation because Basic Reporting did not pass with direct Datadog ' +
571
- 'initialization. Fix the selected test command or local Test Optimization capability before diagnosing CI ' +
572
- 'wiring.',
573
- evidence: {
574
- blockedBy: BASIC_REPORTING_SCENARIO,
575
- basicReportingStatus: basicResult.status,
576
- basicReportingDiagnosis: basicResult.diagnosis,
577
- featureEligibility: {
578
- eligible: false,
579
- blockedBy: BASIC_REPORTING_SCENARIO,
580
- reasonCode: 'basic-reporting-failed',
581
- scenario: 'ci-wiring',
582
- },
583
- ciWiring: framework.ciWiring,
584
- },
585
- artifacts: [],
586
- }
518
+ /**
519
+ * Normalizes scenario selection.
520
+ *
521
+ * @param {string} scenario requested scenario
522
+ * @returns {Set<string>} effective scenarios
523
+ */
524
+ function normalizeScenarioSelection (scenario) {
525
+ if (!getSelectableScenarios().includes(scenario)) throw new Error(`Unknown scenario: ${scenario}`)
526
+ if (scenario === BASIC_REPORTING || scenario === CI_WIRING) return new Set([scenario])
527
+ return new Set([BASIC_REPORTING, scenario])
587
528
  }
588
529
 
589
- function getSkippedAfterBasicFailure (framework, scenario, basicResult) {
590
- return {
591
- frameworkId: framework.id,
592
- scenario,
593
- status: 'skip',
594
- diagnosis: `Skipped because basic reporting did not pass: ${basicResult.diagnosis}`,
595
- evidence: {
596
- blockedBy: BASIC_REPORTING_SCENARIO,
597
- basicReportingStatus: basicResult.status,
598
- basicReportingDiagnosis: basicResult.diagnosis,
599
- featureEligibility: {
600
- eligible: false,
601
- blockedBy: BASIC_REPORTING_SCENARIO,
602
- reasonCode: 'basic-reporting-failed',
603
- scenario,
604
- },
605
- },
606
- artifacts: [],
607
- }
530
+ /**
531
+ * Normalizes a framework target.
532
+ *
533
+ * @param {string} target target value
534
+ * @returns {string} normalized target
535
+ */
536
+ function normalizeFrameworkTarget (target) {
537
+ const normalized = String(target).trim().replaceAll(/:+$/g, '')
538
+ if (!normalized) throw new Error('Framework target cannot be empty.')
539
+ return normalized
608
540
  }
609
541
 
610
- function getSkippedAfterGeneratedVerificationFailure (framework, scenario, failure) {
611
- return {
612
- frameworkId: framework.id,
613
- scenario,
614
- status: 'skip',
615
- diagnosis: `Skipped because the temporary validation test could not run as expected: ${failure.diagnosis}`,
616
- evidence: {
617
- blockedBy: 'generated-test-verification',
618
- verificationStatus: failure.status,
619
- verificationDiagnosis: failure.diagnosis,
620
- featureEligibility: {
621
- eligible: false,
622
- blockedBy: 'generated-test-verification',
623
- reasonCode: 'generated-test-verification-failed',
624
- scenario,
625
- },
626
- },
627
- artifacts: [],
542
+ /**
543
+ * Validates the output directory.
544
+ *
545
+ * @param {object} manifest loaded manifest
546
+ * @param {string} outputPath output path
547
+ * @returns {string} absolute output path
548
+ */
549
+ function validateOutputPath (manifest, outputPath) {
550
+ const root = path.resolve(manifest.repository.root)
551
+ const out = path.resolve(outputPath)
552
+ const relative = path.relative(root, out)
553
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
554
+ throw new Error('Validation output directory must be a dedicated child of repository.root.')
628
555
  }
629
- }
630
-
631
- function isUnsuccessfulResult (result) {
632
- return result.status === 'fail' || result.status === 'error' || result.status === 'blocked'
556
+ return out
633
557
  }
634
558
 
635
559
  /**
636
- * Reports whether at least one live validation check produced a result.
637
- *
638
- * Framework discovery-only entries use the synthetic `all` scenario and do not prove Test Optimization behavior.
560
+ * Prevents incompatible CLI modes.
639
561
  *
640
- * @param {object[]} results validation results
641
- * @returns {boolean} whether live validation ran
562
+ * @param {object} options parsed options
563
+ * @returns {void}
642
564
  */
643
- function didRunLiveValidation (results) {
644
- return results.some(result => result.scenario !== 'all')
565
+ function assertCompatibleModes (options) {
566
+ if (!options.runApprovedPlan) return
567
+ if (options.help || options.initManifest || options.printPlan || options.validateManifest) {
568
+ throw new Error('--run-approved-plan cannot be combined with another mode.')
569
+ }
645
570
  }
646
571
 
647
572
  /**
648
- * Prints the start of one framework validation phase.
573
+ * Reads a required flag value.
649
574
  *
650
- * @param {object} framework manifest framework entry
651
- * @param {string} phase customer-facing phase name
652
- * @returns {void}
575
+ * @param {string[]} argv arguments
576
+ * @param {number} index value index
577
+ * @param {string} flag flag name
578
+ * @returns {string} flag value
653
579
  */
654
- function logPhaseStart (framework, phase) {
655
- logValidationProgress(`${framework.id}: ${phase} started.`)
580
+ function requireValue (argv, index, flag) {
581
+ if (!argv[index]) throw new Error(`${flag} requires a value.`)
582
+ return argv[index]
656
583
  }
657
584
 
658
585
  /**
659
- * Prints the outcome of one framework validation phase.
586
+ * Prints CLI help.
660
587
  *
661
- * @param {object} framework manifest framework entry
662
- * @param {string} phase customer-facing phase name
663
- * @param {string|undefined} status phase outcome
664
588
  * @returns {void}
665
589
  */
666
- function logPhaseComplete (framework, phase, status) {
667
- logValidationProgress(`${framework.id}: ${phase} ${status || 'complete'}.`)
590
+ function printHelp () {
591
+ console.log(`Usage: node ci/validate-test-optimization.js [options]
592
+
593
+ --init-manifest
594
+ --validate-manifest
595
+ --print-plan
596
+ --run-approved-plan <approval.json> --sha256 <digest>
597
+ --manifest <path>
598
+ --out <path>
599
+ --framework <id>
600
+ --scenario <${getSelectableScenarios().join('|')}>
601
+ --keep-temp-files
602
+ --verbose`)
668
603
  }
669
604
 
670
605
  /**
671
- * Prints a sanitized validator progress line.
606
+ * Logs one phase transition.
672
607
  *
673
- * @param {string} message progress message
608
+ * @param {object} framework framework entry
609
+ * @param {string} phase phase name
610
+ * @param {string} status status
674
611
  * @returns {void}
675
612
  */
676
- function logValidationProgress (message) {
677
- console.log(sanitizeConsoleText(`[test-optimization-validator] ${message}`))
613
+ function logPhase (framework, phase, status) {
614
+ console.log(sanitizeConsoleText(`[test-optimization-validator] ${framework.id}: ${phase}: ${status}`))
678
615
  }
679
616
 
680
617
  /**
681
- * Converts an advanced scenario id to customer-facing text.
618
+ * Builds a non-runnable framework result.
682
619
  *
683
- * @param {string} scenario scenario id
684
- * @returns {string} display name
620
+ * @param {object} framework framework entry
621
+ * @returns {object} result
685
622
  */
686
- function getScenarioDisplayName (scenario) {
687
- return {
688
- 'basic-reporting': 'Basic Reporting',
689
- 'ci-wiring': 'CI Wiring',
690
- efd: 'Early Flake Detection',
691
- atr: 'Auto Test Retries',
692
- 'test-management': 'Test Management',
693
- }[scenario] || scenario
694
- }
695
-
696
- function getStaticFailure (framework, blocker, staticDiagnosisPath) {
623
+ function getFrameworkStatusResult (framework) {
624
+ const requiresProjectSetup = framework.status === 'requires_manual_setup'
697
625
  return {
698
626
  frameworkId: framework.id,
699
627
  scenario: 'all',
700
- status: 'fail',
701
- diagnosis: blocker.reason,
628
+ status: 'skip',
629
+ diagnosis: framework.notes?.[0] || `Framework status is ${framework.status}.`,
702
630
  evidence: {
703
- staticDiagnosis: true,
704
- recommendation: blocker.recommendation,
631
+ frameworkStatus: framework.status,
632
+ validationIncomplete: true,
633
+ ...(requiresProjectSetup
634
+ ? {
635
+ blockedByProjectSetup: true,
636
+ ...(framework.notes?.[0] ? { recommendation: framework.notes[0] } : {}),
637
+ }
638
+ : { validatorAdapterUnavailable: true }),
705
639
  },
706
- artifacts: [staticDiagnosisPath],
640
+ artifacts: [],
707
641
  }
708
642
  }
709
643
 
710
- function getFrameworkStatusResult (framework) {
711
- const evidence = getFrameworkStatusEvidence(framework)
712
-
713
- if (framework.status === 'unsupported_by_validator') {
714
- const frameworkName = getDisplayFrameworkName(framework.framework)
715
-
716
- return {
717
- frameworkId: framework.id,
718
- scenario: 'all',
719
- status: 'skip',
720
- diagnosis: `${frameworkName} is not supported as a Test Optimization test framework.`,
721
- evidence: {
722
- ...evidence,
723
- recommendation: 'Choose a supported framework before running live validation.',
724
- },
725
- artifacts: [],
726
- }
727
- }
728
-
644
+ /**
645
+ * Builds an unavailable runner result.
646
+ *
647
+ * @param {object} framework framework entry
648
+ * @param {string} unavailable missing file
649
+ * @returns {object} result
650
+ */
651
+ function getUnavailableRunnerResult (framework, unavailable) {
729
652
  return {
730
653
  frameworkId: framework.id,
731
654
  scenario: 'all',
732
655
  status: 'skip',
733
- diagnosis: getFrameworkStatusDiagnosis(framework, evidence),
734
- evidence: {
735
- ...evidence,
736
- recommendation: 'Provide a small runnable command for this framework, or mark the setup blocker explicitly.',
737
- },
656
+ diagnosis: `The direct runner is unavailable: ${unavailable}. Complete normal project setup and retry.`,
657
+ evidence: { blockedByProjectSetup: true, unavailableRunner: unavailable, validationIncomplete: true },
738
658
  artifacts: [],
739
659
  }
740
660
  }
741
661
 
742
- function getFrameworkStatusDiagnosis (framework, evidence) {
743
- const frameworkName = framework.framework
744
- const notes = evidence.manifestNotes || []
745
-
746
- if (isDependencyOnlyDetection(evidence)) {
747
- return getDependencyOnlyDiagnosis(framework, evidence)
748
- }
749
-
750
- if (notes.length > 0) {
751
- return `${frameworkName} was detected, but no runnable validation command was available. ` +
752
- `Basic reporting was not run. Manifest reason: ${notes[0]}`
753
- }
754
-
755
- return `${frameworkName} was detected, but the manifest did not prove a runnable validation command. ` +
756
- 'Basic reporting was not run. See discovery evidence for scripts/config files to turn into a small command.'
757
- }
758
-
759
- function isDependencyOnlyDetection (evidence) {
760
- return evidence.directDependency && evidence.configFiles.length === 0 && evidence.frameworkScripts.length === 0
761
- }
762
-
763
- function getDependencyOnlyDiagnosis (framework, evidence) {
764
- const frameworkName = getDisplayFrameworkName(framework.framework)
765
- const dependency = formatDependency(evidence.directDependency)
766
- const note = evidence.manifestNotes?.[0] ? ` Manifest note: ${evidence.manifestNotes[0]}` : ''
767
- const common = `${frameworkName} is installed${dependency}, but this repository does not appear to use ` +
768
- `${frameworkName} to run tests: no ${framework.framework} config, package script, or runnable ` +
769
- `${framework.framework} test command was found. Basic reporting was not run for ${frameworkName}.`
770
-
771
- if (framework.framework === 'playwright') {
772
- return `${frameworkName} is installed${dependency}, but no Playwright Test setup was found. ` +
773
- 'The playwright package can be used only for browser automation; Test Optimization validation needs a ' +
774
- '`playwright test` setup with a config, script, or runnable test command. Basic reporting was not run ' +
775
- `for ${frameworkName}.${note}`
776
- }
777
-
778
- return `${common} If this repo does use ${frameworkName}, provide a small ${frameworkName} test command; ` +
779
- `otherwise this dependency-only detection can be ignored.${note}`
780
- }
781
-
782
- function getDisplayFrameworkName (frameworkName) {
783
- return {
784
- cucumber: 'Cucumber',
785
- cypress: 'Cypress',
786
- jest: 'Jest',
787
- mocha: 'Mocha',
788
- playwright: 'Playwright',
789
- vitest: 'Vitest',
790
- }[frameworkName] || frameworkName
791
- }
792
-
793
- function formatDependency (dependency) {
794
- if (!dependency) return ''
795
- return ` in ${dependency.field}${dependency.version ? ` (${dependency.version})` : ''}`
796
- }
797
-
798
- function getFrameworkStatusEvidence (framework) {
799
- const root = framework.project?.root
662
+ /**
663
+ * Builds a static project blocker result.
664
+ *
665
+ * @param {object} framework framework entry
666
+ * @param {object} blocker static blocker
667
+ * @param {string} reportPath static report path
668
+ * @returns {object} result
669
+ */
670
+ function getStaticFailure (framework, blocker, reportPath) {
800
671
  return {
801
- frameworkStatus: framework.status,
802
- frameworkVersion: framework.frameworkVersion,
803
- manifestNotes: Array.isArray(framework.notes) ? framework.notes : [],
804
- directDependency: root ? getDirectDependency(root, framework.framework) : undefined,
805
- frameworkScripts: root ? findFrameworkScripts(root, framework.framework) : [],
806
- testLikeScripts: root ? findTestLikeScripts(root) : [],
807
- configFiles: root ? findFrameworkConfigFiles(root, framework.framework) : [],
808
- }
809
- }
810
-
811
- function getDirectDependency (root, frameworkName) {
812
- const packageJson = readPackageJson(root)
813
- if (!packageJson) return
814
-
815
- for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) {
816
- const value = packageJson[field]?.[frameworkName]
817
- if (value) return { field, version: value }
672
+ frameworkId: framework.id,
673
+ scenario: 'all',
674
+ status: 'error',
675
+ diagnosis: blocker.reason,
676
+ evidence: { blockedByProjectSetup: true, staticDiagnosis: reportPath, validationIncomplete: true },
677
+ artifacts: [reportPath],
818
678
  }
819
679
  }
820
680
 
821
- function findFrameworkScripts (root, frameworkName) {
822
- return findScripts(root, (name, command) => {
823
- return includesWord(name, frameworkName) || includesWord(command, frameworkName)
824
- })
825
- }
826
-
827
- function findTestLikeScripts (root) {
828
- return findScripts(root, name => /(^|:)(test|unit|e2e|integration|ci)(:|$)/.test(name)).slice(0, 8)
829
- }
830
-
831
- function findScripts (root, predicate) {
832
- const packageJson = readPackageJson(root)
833
- const scripts = packageJson?.scripts || {}
834
- const matches = []
835
- for (const [name, command] of Object.entries(scripts)) {
836
- if (predicate(name, command)) matches.push({ name, command })
681
+ /**
682
+ * Builds a Basic Reporting not-reached result.
683
+ *
684
+ * @param {object} framework framework entry
685
+ * @param {string} diagnosis blocker diagnosis
686
+ * @param {string} reasonCode blocker id
687
+ * @returns {object} result
688
+ */
689
+ function getBasicNotReached (framework, diagnosis, reasonCode) {
690
+ return {
691
+ frameworkId: framework.id,
692
+ scenario: BASIC_REPORTING,
693
+ status: 'skip',
694
+ diagnosis: `Basic Reporting was not reached: ${diagnosis}`,
695
+ evidence: { reasonCode, validationIncomplete: true },
696
+ artifacts: [],
837
697
  }
838
- return matches.slice(0, 8)
839
698
  }
840
699
 
841
- function findFrameworkConfigFiles (root, frameworkName) {
842
- const patterns = getFrameworkConfigPatterns(frameworkName)
843
- if (patterns.length === 0) return []
844
-
845
- const files = []
846
- findFiles(root, 4, file => {
847
- if (patterns.some(pattern => pattern.test(path.basename(file)))) {
848
- files.push(path.relative(root, file))
849
- }
850
- return files.length < 8
851
- })
852
- return files
853
- }
854
-
855
- function getFrameworkConfigPatterns (frameworkName) {
856
- const definition = getFrameworkDefinitions(DD_MAJOR).find(definition => definition.id === frameworkName)
857
- return definition?.configPatterns || []
858
- }
859
-
860
- function readPackageJson (root) {
861
- try {
862
- return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
863
- } catch {
864
- return null
700
+ /**
701
+ * Builds a cleanup failure.
702
+ *
703
+ * @param {Error|undefined} error cleanup error
704
+ * @param {object} cleanup cleanup outcome
705
+ * @returns {object} result
706
+ */
707
+ function getCleanupFailure (error, cleanup) {
708
+ const retained = (cleanup.filesRetained || 0) + (cleanup.directoriesRetained || 0)
709
+ const reason = error?.message || error ||
710
+ `${retained} temporary validation path${retained === 1 ? '' : 's'} remained after safe cleanup`
711
+ return {
712
+ frameworkId: 'validation-cleanup',
713
+ scenario: 'all',
714
+ status: 'error',
715
+ diagnosis: `Temporary validation files could not be removed safely: ${reason}`,
716
+ evidence: { cleanup, cleanupFailed: true, validationIncomplete: true },
717
+ artifacts: [],
865
718
  }
866
719
  }
867
720
 
868
- function findFiles (dir, depth, visit) {
869
- if (depth < 0) return true
870
-
871
- let entries
872
- try {
873
- entries = fs.readdirSync(dir, { withFileTypes: true })
874
- } catch {
875
- return true
876
- }
877
-
878
- for (const entry of entries) {
879
- if (shouldSkipDirectory(entry.name)) continue
880
- const filename = path.join(dir, entry.name)
881
- if (entry.isDirectory()) {
882
- if (!findFiles(filename, depth - 1, visit)) return false
883
- } else if (!visit(filename)) {
884
- return false
885
- }
721
+ /**
722
+ * Builds a top-level orchestration failure.
723
+ *
724
+ * @param {Error} error failure
725
+ * @returns {object} result
726
+ */
727
+ function getOrchestrationFailure (error) {
728
+ return {
729
+ frameworkId: 'validator',
730
+ scenario: 'all',
731
+ status: 'error',
732
+ diagnosis: error?.message || String(error),
733
+ evidence: { validationIncomplete: true, validationOrchestrationFailed: true },
734
+ artifacts: [],
886
735
  }
887
-
888
- return true
889
- }
890
-
891
- function shouldSkipDirectory (name) {
892
- return name === '.git' || name === 'node_modules' || name === 'dist' || name === 'coverage'
893
- }
894
-
895
- function includesWord (value, word) {
896
- return new RegExp(`(^|[^a-zA-Z0-9_-])${escapeRegExp(word)}([^a-zA-Z0-9_-]|$)`).test(value)
897
- }
898
-
899
- function escapeRegExp (value) {
900
- return value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
901
736
  }
902
737
 
903
738
  module.exports = { filterFrameworks, main, normalizeFrameworkTarget, parseArgs }