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
@@ -1,862 +1,654 @@
1
1
  'use strict'
2
2
 
3
- const path = require('path')
3
+ const path = require('node:path')
4
4
 
5
5
  const { getArtifactId } = require('./artifact-id')
6
6
  const {
7
7
  MAX_GENERATED_FILES,
8
8
  getGeneratedFileContentError,
9
9
  } = require('./generated-file-policy')
10
- const { getInlineDatadogInitialization } = require('./local-command')
10
+ const { getGeneratedTestContractError } = require('./generated-test-contract')
11
+ const { hasUnsafeInvisibleCharacter, sanitizeString } = require('./redaction')
11
12
  const {
12
- hasUnsafeExecutionCharacter,
13
- hasUnsafeInvisibleCharacter,
14
- isSensitiveName,
15
- sanitizeForReport,
16
- sanitizeString,
17
- } = require('./redaction')
18
-
19
- const FRAMEWORKS = new Set([
20
- 'jest',
21
- 'vitest',
22
- 'mocha',
23
- 'cucumber',
24
- 'cypress',
25
- 'playwright',
26
- 'node:test',
27
- 'ava',
28
- 'tap',
29
- 'jasmine',
30
- 'karma',
31
- 'uvu',
32
- 'testcafe',
33
- 'custom',
34
- 'unknown',
35
- ])
13
+ getRunnerArgsError,
14
+ getRunnerEnvironmentError,
15
+ getRunnerInputError,
16
+ } = require('./runner-contract')
36
17
 
18
+ const FRAMEWORKS = new Set(['cucumber', 'cypress', 'jest', 'mocha', 'playwright', 'vitest'])
37
19
  const STATUSES = new Set([
38
- 'runnable',
39
20
  'detected_not_runnable',
40
- 'requires_external_service',
41
21
  'requires_manual_setup',
22
+ 'runnable',
42
23
  'unsupported_by_validator',
43
- 'unknown',
44
24
  ])
45
- const CI_WIRING_STATUSES = new Set([
46
- 'pass',
47
- 'fail',
48
- 'skip',
49
- 'unknown',
50
- ])
51
- const CI_WIRING_REPLAYABILITIES = new Set(['replayable', 'not_replayable'])
52
- const CI_INITIALIZATION_STATUSES = new Set(['configured', 'not_configured', 'unknown'])
53
- const UNRESOLVED_PLACEHOLDER_PATTERN = /\$\{[^}]+\}/
54
- const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
55
- const MAX_COMMAND_TIMEOUT_MS = 30 * 60 * 1000
56
- const MAX_FRAMEWORKS = 100
57
- const MAX_MANIFEST_ARRAY_ENTRIES = 1000
58
- const MAX_SETUP_COMMANDS = 100
59
- const MAX_VALIDATION_ERRORS = 50
60
- const MAX_REPRESENTATIVE_TESTS = 1000
61
- const SECRET_PLACEHOLDER = 'dd-validation-placeholder'
62
- const SAFE_SECRET_FIELD_VALUES = new Set(['', '0', '1', 'false', 'true', 'none', 'disabled'])
63
- const COMMAND_FIELDS = new Set([
25
+ const INITIALIZATION_STATUSES = new Set(['configured', 'not_configured', 'unknown'])
26
+ const TRANSPORT_MODES = new Set(['agent', 'agentless', 'none', 'unknown'])
27
+ const GENERATED_SCENARIOS = new Set(['basic-pass', 'atr-fail-once', 'test-management-target'])
28
+ const GENERATED_EXIT_CODES = {
29
+ 'basic-pass': 0,
30
+ 'atr-fail-once': 1,
31
+ 'test-management-target': 0,
32
+ }
33
+ const FORBIDDEN_EXECUTION_FIELDS = new Set([
64
34
  'argv',
65
- 'cwd',
66
- 'description',
67
- 'env',
68
- 'required',
69
- 'requiredEnvVars',
35
+ 'existingTestCommand',
36
+ 'forcedLocalCommand',
37
+ 'isolationTestCandidate',
38
+ 'isolationTestCandidates',
39
+ 'localTestCandidates',
40
+ 'runCommand',
41
+ 'setup',
70
42
  'shell',
71
43
  'shellCommand',
72
- 'shellReason',
73
- 'timeoutMs',
74
44
  'usesShell',
75
- 'id',
76
- 'outputPaths',
77
45
  ])
46
+ const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
47
+ const SECRET_ENV_PATTERN =
48
+ /(?:^|_)(?:API_?KEY|AUTH(?:ORIZATION)?|CREDENTIALS?|PASS(?:WORD|WD)?|PRIVATE_?KEY|SECRET|TOKEN)(?:_|$)/i
49
+ const EXECUTION_ENV_PATTERN =
50
+ /^(?:(?:BASH_ENV|ENV|NODE_EXTRA_CA_CERTS|NODE_PATH|NODE_REPL_EXTERNAL_MODULE|NODE_TLS_REJECT_UNAUTHORIZED)$|LD_|DYLD_)/i
51
+ const MAX_ARRAY_ENTRIES = 1000
52
+ const MAX_FRAMEWORKS = 100
53
+ const MAX_STRING_BYTES = 256 * 1024
54
+ const MAX_TIMEOUT_MS = 30 * 60 * 1000
55
+ const MAX_VALIDATION_ERRORS = 50
78
56
 
79
- const GENERATED_SCENARIO_IDS = new Set([
80
- 'basic-pass',
81
- 'atr-fail-once',
82
- 'test-management-target',
83
- ])
84
- const GENERATED_SCENARIO_EXIT_CODES = {
85
- 'basic-pass': 0,
86
- 'atr-fail-once': 1,
87
- 'test-management-target': 0,
88
- }
89
-
57
+ /**
58
+ * Validates a data-only Test Optimization manifest.
59
+ *
60
+ * @param {object} manifest parsed manifest
61
+ * @returns {string[]} bounded validation errors
62
+ */
90
63
  function validateManifest (manifest) {
91
64
  const errors = createErrorCollector()
65
+ if (!isObject(manifest)) return ['Manifest must be a JSON object.']
92
66
 
93
- if (!manifest || typeof manifest !== 'object') {
94
- return ['Manifest must be a JSON object.']
95
- }
96
-
97
- requiredString(manifest, 'schemaVersion', errors)
98
- requiredObject(manifest, 'repository', errors)
99
- requiredObject(manifest, 'environment', errors)
100
- requiredArray(manifest, 'frameworks', errors)
101
- if (Array.isArray(manifest.frameworks) && manifest.frameworks.length === 0) {
102
- errors.push('frameworks must include at least one framework entry.')
103
- }
104
- validateArrayLimit(manifest, 'frameworks', MAX_FRAMEWORKS, errors)
105
- validateArrayLimit(manifest, 'omitted', MAX_MANIFEST_ARRAY_ENTRIES, errors)
106
- validateArrayLimit(manifest, 'omittedTestCommands', MAX_MANIFEST_ARRAY_ENTRIES, errors)
107
-
108
- if (manifest.repository) {
109
- requiredAbsolutePath(manifest.repository, 'root', errors)
110
- }
67
+ requiredString(manifest, 'schemaVersion', 'manifest', errors)
68
+ if (manifest.schemaVersion !== '2.0') errors.push('schemaVersion must be "2.0".')
69
+ requiredObject(manifest, 'repository', 'manifest', errors)
70
+ requiredObject(manifest, 'environment', 'manifest', errors)
71
+ requiredArray(manifest, 'frameworks', 'manifest', errors)
72
+ rejectExecutionFields(manifest, 'manifest', errors)
111
73
 
112
- if (manifest.ciDiscovery) {
113
- validateCiDiscovery(manifest.ciDiscovery, 'ciDiscovery', errors)
74
+ if (isObject(manifest.repository)) {
75
+ requiredAbsolutePath(manifest.repository, 'root', 'repository', errors)
114
76
  }
115
77
 
116
78
  if (Array.isArray(manifest.frameworks)) {
117
- const frameworks = manifest.frameworks.slice(0, MAX_FRAMEWORKS)
118
- validateUniqueFrameworkIds(frameworks, errors)
119
- validateUniqueArtifactIds(frameworks, errors)
120
- validateDuplicateRunnableCoverage(frameworks, errors)
121
- validateGeneratedPathCollisions(frameworks, errors)
122
- for (const [index, framework] of frameworks.entries()) {
123
- validateFramework(framework, index, errors)
79
+ if (manifest.frameworks.length === 0) errors.push('frameworks must not be empty.')
80
+ if (manifest.frameworks.length > MAX_FRAMEWORKS) {
81
+ errors.push(`frameworks must contain at most ${MAX_FRAMEWORKS} entries.`)
124
82
  }
83
+ validateFrameworks(manifest, errors)
125
84
  }
126
85
 
127
- validateRepositoryContainedPaths(manifest, errors)
128
-
86
+ validateStringArray(manifest.omitted, 'omitted', errors)
87
+ validateAllStrings(manifest, 'manifest', errors)
129
88
  return errors.finalize()
130
89
  }
131
90
 
132
91
  /**
133
- * Rejects generated files or cleanup targets shared by multiple framework entries.
92
+ * Validates framework entries and cross-framework uniqueness.
134
93
  *
135
- * @param {object[]} frameworks manifest framework entries
136
- * @param {{push: function(string): void}} errors bounded validation error collector
94
+ * @param {object} manifest validation manifest
95
+ * @param {{push: function(string): void, full: function(): boolean}} errors error collector
137
96
  * @returns {void}
138
97
  */
139
- function validateGeneratedPathCollisions (frameworks, errors) {
140
- const seen = new Map()
141
- for (const [index, framework] of frameworks.entries()) {
142
- const strategy = framework?.generatedTestStrategy
143
- const frameworkPaths = new Map([
144
- ...limitedArray(strategy?.files, MAX_GENERATED_FILES).map(file => file?.path),
145
- ...limitedArray(strategy?.cleanupPaths, MAX_MANIFEST_ARRAY_ENTRIES),
146
- ].filter(filename => typeof filename === 'string' && path.isAbsolute(filename))
147
- .map(filename => [path.normalize(filename), filename]))
148
-
149
- for (const [key, filename] of frameworkPaths) {
150
- const previous = seen.get(key)
151
- if (previous === undefined) {
152
- seen.set(key, index)
153
- continue
154
- }
155
- errors.push(
156
- `frameworks[${index}].generatedTestStrategy path ${JSON.stringify(filename)} conflicts with ` +
157
- `frameworks[${previous}].generatedTestStrategy. Generated files and cleanup paths must be unique across ` +
158
- 'framework entries.'
159
- )
160
- }
161
- }
162
- }
98
+ function validateFrameworks (manifest, errors) {
99
+ const ids = new Set()
100
+ const artifactIds = new Set()
101
+ const generatedPaths = new Map()
163
102
 
164
- function validateDuplicateRunnableCoverage (frameworks, errors) {
165
- const seen = new Map()
166
- for (const [index, framework] of frameworks.entries()) {
167
- if (framework?.status !== 'runnable' || !framework.ciWiringCommand) continue
168
- const key = JSON.stringify({
169
- framework: framework.framework,
170
- projectRoot: framework.project?.root,
171
- existingTestCommand: commandExecutionShape(framework.existingTestCommand),
172
- ciWiringCommand: commandExecutionShape(framework.ciWiringCommand),
173
- ciInitializesDatadog: commandInitializesDatadog(framework.ciWiringCommand),
174
- })
175
- const previous = seen.get(key)
176
- if (previous === undefined) {
177
- seen.set(key, index)
103
+ for (const [index, framework] of manifest.frameworks.slice(0, MAX_FRAMEWORKS).entries()) {
104
+ if (errors.full()) return
105
+ const prefix = `frameworks[${index}]`
106
+ if (!isObject(framework)) {
107
+ errors.push(`${prefix} must be an object.`)
178
108
  continue
179
109
  }
180
- errors.push(
181
- `frameworks[${index}] duplicates runnable framework and CI command coverage from frameworks[${previous}]. ` +
182
- 'Keep one representative framework entry and record the other CI job as an omitted or duplicate candidate.'
183
- )
184
- }
185
- }
186
-
187
- function validateUniqueArtifactIds (frameworks, errors) {
188
- const seen = new Map()
189
- for (const [index, framework] of frameworks.entries()) {
190
- if (typeof framework?.id !== 'string') continue
191
- const artifactId = getArtifactId(framework.id)
192
- const previous = seen.get(artifactId)
193
- if (previous !== undefined && previous !== framework.id) {
194
- errors.push(
195
- `frameworks[${index}].id collides with another framework artifact identifier after normalization.`
196
- )
197
- } else {
198
- seen.set(artifactId, framework.id)
199
- }
200
- }
201
- }
202
-
203
- function commandExecutionShape (command) {
204
- if (!command) return null
205
- return {
206
- argv: command.argv,
207
- cwd: command.cwd,
208
- shell: command.shell,
209
- shellCommand: command.shellCommand,
210
- usesShell: Boolean(command.usesShell),
211
- }
212
- }
213
-
214
- function commandInitializesDatadog (command) {
215
- const values = [
216
- ...(command?.argv || []),
217
- command?.shellCommand,
218
- command?.env?.NODE_OPTIONS,
219
- ].filter(Boolean).join(' ')
220
- return /dd-trace\/ci\/init/.test(values)
221
- }
222
110
 
223
- function validateRepositoryContainedPaths (manifest, errors) {
224
- const repositoryRoot = manifest.repository?.root
225
- if (typeof repositoryRoot !== 'string' || !path.isAbsolute(repositoryRoot)) return
111
+ requiredString(framework, 'id', prefix, errors)
112
+ requiredString(framework, 'framework', prefix, errors)
113
+ enumString(framework, 'status', STATUSES, prefix, errors)
114
+ requiredObject(framework, 'project', prefix, errors)
115
+ rejectExecutionFields(framework, prefix, errors)
226
116
 
227
- const frameworks = Array.isArray(manifest.frameworks) ? manifest.frameworks.slice(0, MAX_FRAMEWORKS) : []
228
- for (const [index, framework] of frameworks.entries()) {
229
- if (!framework || typeof framework !== 'object' || Array.isArray(framework)) continue
230
- const prefix = `frameworks[${index}]`
231
- containedPath(repositoryRoot, framework.project?.root, `${prefix}.project.root`, errors)
232
- containedPath(repositoryRoot, framework.project?.packageJson, `${prefix}.project.packageJson`, errors)
233
- for (const [configIndex, configFile] of
234
- limitedArray(framework.project?.configFiles, MAX_MANIFEST_ARRAY_ENTRIES).entries()) {
235
- containedPath(repositoryRoot, configFile, `${prefix}.project.configFiles[${configIndex}]`, errors)
117
+ if (typeof framework.framework === 'string' &&
118
+ framework.status !== 'unsupported_by_validator' &&
119
+ !FRAMEWORKS.has(framework.framework)) {
120
+ errors.push(`${prefix}.framework must be one of: ${[...FRAMEWORKS].join(', ')}.`)
236
121
  }
237
122
 
238
- for (const [name, command] of getFrameworkCommands(framework)) {
239
- containedPath(repositoryRoot, command?.cwd, `${prefix}.${name}.cwd`, errors)
123
+ if (typeof framework.id === 'string') {
124
+ if (ids.has(framework.id)) errors.push(`${prefix}.id must be unique.`)
125
+ ids.add(framework.id)
126
+ const artifactId = getArtifactId(framework.id)
127
+ if (artifactIds.has(artifactId)) errors.push(`${prefix}.id collides after artifact normalization.`)
128
+ artifactIds.add(artifactId)
240
129
  }
241
130
 
242
- containedPath(repositoryRoot, framework.ciWiring?.configFile, `${prefix}.ciWiring.configFile`, errors)
243
- containedPath(repositoryRoot, framework.ciWiring?.workingDirectory, `${prefix}.ciWiring.workingDirectory`, errors)
131
+ validateProject(manifest.repository?.root, framework.project, `${prefix}.project`, errors)
132
+ validateCiWiring(manifest.repository?.root, framework.ciWiring, `${prefix}.ciWiring`, errors)
244
133
 
245
- const strategy = framework.generatedTestStrategy
246
- containedPath(repositoryRoot, strategy?.testDirectory, `${prefix}.generatedTestStrategy.testDirectory`, errors)
247
- for (const [fileIndex, file] of limitedArray(strategy?.files, MAX_GENERATED_FILES).entries()) {
248
- containedPath(repositoryRoot, file?.path, `${prefix}.generatedTestStrategy.files[${fileIndex}].path`, errors)
249
- }
250
- for (const [cleanupIndex, cleanupPath] of
251
- limitedArray(strategy?.cleanupPaths, MAX_MANIFEST_ARRAY_ENTRIES).entries()) {
252
- containedPath(
253
- repositoryRoot,
254
- cleanupPath,
255
- `${prefix}.generatedTestStrategy.cleanupPaths[${cleanupIndex}]`,
256
- errors
257
- )
258
- }
259
- for (const [scenarioIndex, scenario] of
260
- limitedArray(strategy?.scenarios, GENERATED_SCENARIO_IDS.size).entries()) {
261
- for (const [identityIndex, identity] of
262
- limitedArray(scenario?.testIdentities, MAX_MANIFEST_ARRAY_ENTRIES).entries()) {
263
- containedPath(
264
- repositoryRoot,
265
- identity?.file,
266
- `${prefix}.generatedTestStrategy.scenarios[${scenarioIndex}].testIdentities[${identityIndex}].file`,
267
- errors
268
- )
134
+ if (framework.status === 'runnable') {
135
+ validateRunnableFramework(manifest.repository?.root, framework, prefix, generatedPaths, errors)
136
+ } else {
137
+ for (const field of ['validation', 'preflight', 'generatedTestStrategy']) {
138
+ if (framework[field] !== undefined) {
139
+ errors.push(`${prefix}.${field} must be omitted when status is not runnable.`)
140
+ }
269
141
  }
270
142
  }
143
+ validateStringArray(framework.notes, `${prefix}.notes`, errors)
271
144
  }
272
145
  }
273
146
 
274
- function getFrameworkCommands (framework) {
275
- const commands = []
276
- for (const name of ['existingTestCommand', 'ciWiringCommand']) {
277
- if (framework[name]) commands.push([name, framework[name]])
278
- }
279
- for (const [index, command] of limitedArray(framework.setup?.commands, MAX_SETUP_COMMANDS).entries()) {
280
- commands.push([`setup.commands[${index}]`, command])
281
- }
282
- for (const [index, scenario] of
283
- limitedArray(framework.generatedTestStrategy?.scenarios, GENERATED_SCENARIO_IDS.size).entries()) {
284
- if (scenario?.runCommand) {
285
- commands.push([`generatedTestStrategy.scenarios[${index}].runCommand`, scenario.runCommand])
147
+ /**
148
+ * Validates project metadata.
149
+ *
150
+ * @param {string} repositoryRoot repository root
151
+ * @param {object} project project metadata
152
+ * @param {string} prefix error prefix
153
+ * @param {{push: function(string): void}} errors error collector
154
+ * @returns {void}
155
+ */
156
+ function validateProject (repositoryRoot, project, prefix, errors) {
157
+ if (!isObject(project)) return
158
+ requiredString(project, 'name', prefix, errors)
159
+ requiredAbsolutePath(project, 'root', prefix, errors)
160
+ requiredAbsolutePath(project, 'packageJson', prefix, errors)
161
+ containedPath(repositoryRoot, project.root, `${prefix}.root`, errors)
162
+ containedPath(repositoryRoot, project.packageJson, `${prefix}.packageJson`, errors)
163
+ if (Array.isArray(project.configFiles)) {
164
+ if (project.configFiles.length > 20) errors.push(`${prefix}.configFiles must contain at most 20 entries.`)
165
+ for (const [index, filename] of project.configFiles.slice(0, 20).entries()) {
166
+ absolutePathValue(filename, `${prefix}.configFiles[${index}]`, errors)
167
+ containedPath(repositoryRoot, filename, `${prefix}.configFiles[${index}]`, errors)
286
168
  }
169
+ } else {
170
+ errors.push(`${prefix}.configFiles must be an array.`)
287
171
  }
288
- return commands
289
172
  }
290
173
 
291
- function containedPath (root, candidate, key, errors) {
292
- if (typeof candidate !== 'string' || !path.isAbsolute(candidate)) return
293
- const relative = path.relative(path.resolve(root), path.resolve(candidate))
294
- if (relative === '' || (relative && !relative.startsWith('..') && !path.isAbsolute(relative))) return
295
- errors.push(`${key} must be inside repository.root.`)
296
- }
297
-
298
- function validateCiDiscovery (ciDiscovery, prefix, errors) {
299
- if (!ciDiscovery || typeof ciDiscovery !== 'object' || Array.isArray(ciDiscovery)) {
300
- errors.push(`${prefix} must be an object when present.`)
174
+ /**
175
+ * Validates direct-runner fields and generated recipes.
176
+ *
177
+ * @param {string} repositoryRoot repository root
178
+ * @param {object} framework runnable framework entry
179
+ * @param {string} prefix error prefix
180
+ * @param {Map<string, string>} generatedPaths generated paths used by other frameworks
181
+ * @param {{push: function(string): void}} errors error collector
182
+ * @returns {void}
183
+ */
184
+ function validateRunnableFramework (repositoryRoot, framework, prefix, generatedPaths, errors) {
185
+ if (!isObject(framework.validation)) {
186
+ errors.push(`${prefix}.validation must be an object for a runnable framework.`)
301
187
  return
302
188
  }
303
189
 
304
- for (const field of ['searched', 'found', 'staticFound', 'warnings', 'notes', 'contradictions']) {
305
- if (ciDiscovery[field] !== undefined) {
306
- if (Array.isArray(ciDiscovery[field])) {
307
- validateStringArray(ciDiscovery, field, errors, prefix)
308
- } else {
309
- errors.push(`${prefix}.${field} must be an array when present.`)
190
+ const validation = framework.validation
191
+ rejectExecutionFields(validation, `${prefix}.validation`, errors)
192
+ requiredAbsolutePath(validation, 'runner', `${prefix}.validation`, errors)
193
+ requiredAbsolutePath(validation, 'testFile', `${prefix}.validation`, errors)
194
+ if (!['bounded_direct_runner', 'instrumented_event_identity'].includes(validation.selectorScope)) {
195
+ errors.push(
196
+ `${prefix}.validation.selectorScope must be bounded_direct_runner or instrumented_event_identity.`
197
+ )
198
+ }
199
+ containedPath(repositoryRoot, validation.runner, `${prefix}.validation.runner`, errors)
200
+ containedPath(repositoryRoot, validation.testFile, `${prefix}.validation.testFile`, errors)
201
+ const runnerArgsError = getRunnerArgsError(framework.framework, validation.runnerArgs)
202
+ if (runnerArgsError) errors.push(`${prefix}.validation.runnerArgs ${runnerArgsError}.`)
203
+ if (validation.omittedRunnerOptions !== undefined) {
204
+ validateStringArray(validation.omittedRunnerOptions, `${prefix}.validation.omittedRunnerOptions`, errors)
205
+ for (const option of Array.isArray(validation.omittedRunnerOptions) ? validation.omittedRunnerOptions : []) {
206
+ if (!['--run', '--typecheck'].includes(option)) {
207
+ errors.push(`${prefix}.validation.omittedRunnerOptions contains unsupported option ${option}.`)
310
208
  }
311
209
  }
312
210
  }
313
-
314
- if (ciDiscovery.method !== undefined && typeof ciDiscovery.method !== 'string') {
315
- errors.push(`${prefix}.method must be a string when present.`)
316
- }
317
- }
318
-
319
- function validateFramework (framework, index, errors) {
320
- const prefix = `frameworks[${index}]`
321
- if (!framework || typeof framework !== 'object' || Array.isArray(framework)) {
322
- errors.push(`${prefix} must be an object.`)
323
- return
324
- }
325
- requiredString(framework, 'id', errors, prefix)
326
- enumString(framework, 'framework', FRAMEWORKS, errors, prefix)
327
- enumString(framework, 'status', STATUSES, errors, prefix)
328
- requiredObject(framework, 'project', errors, prefix)
329
-
330
- if (framework.project) {
331
- requiredAbsolutePath(framework.project, 'root', errors, `${prefix}.project`)
332
- optionalAbsolutePath(framework.project, 'packageJson', errors, `${prefix}.project`)
333
- optionalAbsolutePathArray(framework.project, 'configFiles', errors, `${prefix}.project`)
334
- validateArrayLimit(framework.project, 'configFiles', MAX_MANIFEST_ARRAY_ENTRIES, errors, `${prefix}.project`)
335
- }
336
-
337
- if (framework.status === 'runnable') {
338
- requiredCommand(framework, 'existingTestCommand', errors, prefix, { datadogClean: true })
339
- validateDatadogCleanCommand(framework.existingTestCommand, `${prefix}.existingTestCommand`, errors)
340
- requiredObject(framework, 'preflight', errors, prefix)
341
- validatePreflight(framework.preflight, `${prefix}.preflight`, errors)
342
- requiredObject(framework, 'ciWiring', errors, prefix)
343
- } else {
344
- requireNonEmptyNotes(framework, errors, prefix)
345
- }
346
-
347
- if (framework.ciWiringCommand) {
348
- requiredCommand(framework, 'ciWiringCommand', errors, prefix)
349
- }
350
-
351
- if (framework.ciWiring) {
352
- validateCiWiring(framework, prefix, errors)
353
- }
354
-
355
- if (framework.forcedLocalCommand !== undefined) {
356
- errors.push(
357
- `${prefix}.forcedLocalCommand is not supported. Use the focused existingTestCommand for Basic Reporting ` +
358
- 'and ciWiringCommand for the CI-shaped replay.'
211
+ const runnerEnvironmentError = getRunnerEnvironmentError(validation.environment)
212
+ if (runnerEnvironmentError) errors.push(`${prefix}.validation.environment ${runnerEnvironmentError}.`)
213
+ if (!runnerArgsError &&
214
+ !runnerEnvironmentError &&
215
+ typeof framework.project?.root === 'string' &&
216
+ typeof repositoryRoot === 'string' &&
217
+ Array.isArray(framework.project?.configFiles)) {
218
+ const runnerInputError = getRunnerInputError(
219
+ validation.runnerArgs,
220
+ validation.environment,
221
+ framework.project?.root,
222
+ repositoryRoot,
223
+ framework.project?.configFiles
359
224
  )
225
+ if (runnerInputError) errors.push(`${prefix}.validation runner configuration ${runnerInputError}.`)
360
226
  }
361
-
362
- if (framework.setup?.commands) {
363
- if (Array.isArray(framework.setup.commands)) {
364
- validateArrayLimit(framework.setup, 'commands', MAX_SETUP_COMMANDS, errors, `${prefix}.setup`)
365
- for (const [commandIndex, command] of framework.setup.commands.slice(0, MAX_SETUP_COMMANDS).entries()) {
366
- requiredCommand({ command }, 'command', errors, `${prefix}.setup.commands[${commandIndex}]`)
367
- }
368
- } else {
369
- errors.push(`${prefix}.setup.commands must be an array.`)
227
+ validateStringArray(validation.requiredEnvVars, `${prefix}.validation.requiredEnvVars`, errors)
228
+ for (const name of validation.requiredEnvVars || []) {
229
+ if (!ENV_NAME_PATTERN.test(name)) {
230
+ errors.push(`${prefix}.validation.requiredEnvVars contains an invalid environment name.`)
231
+ }
232
+ if (/^(?:DD_|DATADOG_|OTEL_|NODE_OPTIONS$|TS_NODE_PROJECT$)/i.test(name)) {
233
+ errors.push(
234
+ `${prefix}.validation.requiredEnvVars must not inherit Datadog, OpenTelemetry, NODE_OPTIONS, or ` +
235
+ 'TS_NODE_PROJECT.'
236
+ )
237
+ }
238
+ if (SECRET_ENV_PATTERN.test(name)) {
239
+ errors.push(`${prefix}.validation.requiredEnvVars must not inherit secret-like environment variables.`)
370
240
  }
241
+ if (EXECUTION_ENV_PATTERN.test(name)) {
242
+ errors.push(`${prefix}.validation.requiredEnvVars must not inherit executable-loading environment variables.`)
243
+ }
244
+ }
245
+ if (!Number.isInteger(validation.timeoutMs) || validation.timeoutMs < 1 || validation.timeoutMs > MAX_TIMEOUT_MS) {
246
+ errors.push(`${prefix}.validation.timeoutMs must be an integer between 1 and ${MAX_TIMEOUT_MS}.`)
371
247
  }
372
248
 
373
- if (framework.generatedTestStrategy) {
374
- validateGeneratedTestStrategy(framework.generatedTestStrategy, `${prefix}.generatedTestStrategy`, errors)
249
+ if (!isObject(framework.generatedTestStrategy)) {
250
+ errors.push(`${prefix}.generatedTestStrategy must be an object for a runnable framework.`)
251
+ return
375
252
  }
253
+ validateGeneratedStrategy(repositoryRoot, framework, prefix, generatedPaths, errors)
376
254
  }
377
255
 
378
256
  /**
379
- * Validates the approved upper bound for a representative test command.
257
+ * Validates canonical generated test data.
380
258
  *
381
- * @param {object} preflight preflight declaration
382
- * @param {string} prefix manifest field path
383
- * @param {{push: function(string): void}} errors bounded validation error collector
259
+ * @param {string} repositoryRoot repository root
260
+ * @param {object} framework framework entry
261
+ * @param {string} prefix framework error prefix
262
+ * @param {Map<string, string>} generatedPaths generated paths used by other frameworks
263
+ * @param {{push: function(string): void}} errors error collector
384
264
  * @returns {void}
385
265
  */
386
- function validatePreflight (preflight, prefix, errors) {
387
- if (!preflight || typeof preflight !== 'object' || Array.isArray(preflight)) return
388
-
389
- if (!Number.isInteger(preflight.maxTestCount) || preflight.maxTestCount < 1) {
390
- errors.push(`${prefix}.maxTestCount must be a positive integer.`)
391
- } else if (preflight.maxTestCount > MAX_REPRESENTATIVE_TESTS) {
392
- errors.push(`${prefix}.maxTestCount must not exceed ${MAX_REPRESENTATIVE_TESTS}.`)
266
+ function validateGeneratedStrategy (repositoryRoot, framework, prefix, generatedPaths, errors) {
267
+ const strategy = framework.generatedTestStrategy
268
+ if (!['planned', 'verified', 'not_possible'].includes(strategy.status)) {
269
+ errors.push(`${prefix}.generatedTestStrategy.status must be planned, verified, or not_possible.`)
393
270
  }
394
- }
395
-
396
- function validateGeneratedTestStrategy (strategy, prefix, errors) {
397
- if (!['planned', 'verified', 'proposed', 'not_possible'].includes(strategy.status)) {
398
- errors.push(`${prefix}.status must be planned, verified, proposed, or not_possible.`)
271
+ if (strategy.status === 'not_possible') {
272
+ requiredString(strategy, 'reason', `${prefix}.generatedTestStrategy`, errors)
273
+ return
399
274
  }
400
275
 
401
- const completeStrategy = strategy.status === 'planned' || strategy.status === 'verified'
402
- if (completeStrategy) {
403
- requiredArray(strategy, 'files', errors, prefix)
404
- requiredArray(strategy, 'scenarios', errors, prefix)
405
- requiredArray(strategy, 'cleanupPaths', errors, prefix)
406
- validateCompleteGeneratedScenarioSet(strategy, prefix, errors)
407
- } else if ((strategy.status === 'proposed' || strategy.status === 'not_possible') &&
408
- (typeof strategy.reason !== 'string' || strategy.reason.trim() === '')) {
409
- errors.push(`${prefix}.reason must explain why the generated test strategy is ${strategy.status}.`)
410
- }
276
+ requiredAbsolutePath(strategy, 'testDirectory', `${prefix}.generatedTestStrategy`, errors)
277
+ containedPath(repositoryRoot, strategy.testDirectory, `${prefix}.generatedTestStrategy.testDirectory`, errors)
278
+ requiredArray(strategy, 'files', `${prefix}.generatedTestStrategy`, errors)
279
+ requiredArray(strategy, 'scenarios', `${prefix}.generatedTestStrategy`, errors)
280
+ requiredArray(strategy, 'cleanupPaths', `${prefix}.generatedTestStrategy`, errors)
411
281
 
412
282
  if (Array.isArray(strategy.files)) {
413
283
  if (strategy.files.length > MAX_GENERATED_FILES) {
414
- errors.push(`${prefix}.files must contain at most ${MAX_GENERATED_FILES} generated files.`)
284
+ errors.push(`${prefix}.generatedTestStrategy.files must contain at most ${MAX_GENERATED_FILES} entries.`)
415
285
  }
416
286
  for (const [index, file] of strategy.files.slice(0, MAX_GENERATED_FILES).entries()) {
417
- requiredAbsolutePath(file, 'path', errors, `${prefix}.files[${index}]`)
418
- requiredArray(file, 'contentLines', errors, `${prefix}.files[${index}]`)
419
- validateStringArray(file, 'contentLines', errors, `${prefix}.files[${index}]`)
287
+ const filePrefix = `${prefix}.generatedTestStrategy.files[${index}]`
288
+ if (!isObject(file)) {
289
+ errors.push(`${filePrefix} must be an object.`)
290
+ continue
291
+ }
292
+ requiredAbsolutePath(file, 'path', filePrefix, errors)
293
+ validateStringArray(file.contentLines, `${filePrefix}.contentLines`, errors)
294
+ containedPath(repositoryRoot, file.path, `${filePrefix}.path`, errors)
420
295
  const policyError = getGeneratedFileContentError(file.contentLines)
421
- if (policyError) errors.push(`${prefix}.files[${index}].contentLines ${policyError}.`)
296
+ if (policyError) errors.push(`${filePrefix}.contentLines ${policyError}.`)
297
+ claimGeneratedPath(file.path, framework.id, filePrefix, generatedPaths, errors)
422
298
  }
423
299
  }
424
300
 
425
- if (Array.isArray(strategy.scenarios)) {
426
- validateArrayLimit(strategy, 'scenarios', GENERATED_SCENARIO_IDS.size, errors, prefix)
427
- for (const [index, scenario] of strategy.scenarios.slice(0, GENERATED_SCENARIO_IDS.size).entries()) {
428
- requiredString(scenario, 'id', errors, `${prefix}.scenarios[${index}]`)
429
- enumString(scenario, 'id', GENERATED_SCENARIO_IDS, errors, `${prefix}.scenarios[${index}]`)
430
- requiredCommand(scenario, 'runCommand', errors, `${prefix}.scenarios[${index}]`, { datadogClean: true })
431
- validateDatadogCleanCommand(scenario.runCommand, `${prefix}.scenarios[${index}].runCommand`, errors)
432
- validateScenarioIdentities(
433
- scenario,
434
- `${prefix}.scenarios[${index}]`,
435
- errors,
436
- completeStrategy
437
- )
438
- if (completeStrategy) {
439
- validateGeneratedScenarioOutcome(scenario, `${prefix}.scenarios[${index}]`, errors)
440
- }
301
+ if (Array.isArray(strategy.cleanupPaths)) {
302
+ for (const [index, filename] of strategy.cleanupPaths.slice(0, MAX_ARRAY_ENTRIES).entries()) {
303
+ const pathPrefix = `${prefix}.generatedTestStrategy.cleanupPaths[${index}]`
304
+ absolutePathValue(filename, pathPrefix, errors)
305
+ containedPath(repositoryRoot, filename, pathPrefix, errors)
306
+ claimGeneratedPath(filename, framework.id, pathPrefix, generatedPaths, errors)
441
307
  }
442
308
  }
443
309
 
444
- optionalAbsolutePath(strategy, 'testDirectory', errors, prefix)
445
- optionalAbsolutePathArray(strategy, 'cleanupPaths', errors, prefix)
446
- }
447
-
448
- function validateCiWiring (framework, prefix, errors) {
449
- const ciWiring = framework.ciWiring
450
- if (!ciWiring || typeof ciWiring !== 'object' || Array.isArray(ciWiring)) {
451
- errors.push(`${prefix}.ciWiring must be an object when present.`)
452
- return
453
- }
454
-
455
- if (!CI_WIRING_STATUSES.has(ciWiring.status)) {
456
- errors.push(`${prefix}.ciWiring.status must be pass, fail, skip, or unknown.`)
457
- }
458
-
459
- if (!CI_WIRING_REPLAYABILITIES.has(ciWiring.replayability)) {
460
- errors.push(`${prefix}.ciWiring.replayability must be replayable or not_replayable.`)
461
- }
462
- if (Object.hasOwn(ciWiring, 'ciWiringCommand')) {
463
- errors.push(`${prefix}.ciWiring.ciWiringCommand is misplaced; use ${prefix}.ciWiringCommand.`)
464
- }
465
- if (ciWiring.replayability === 'replayable' && !framework.ciWiringCommand) {
466
- errors.push(`${prefix}.ciWiringCommand is required when ${prefix}.ciWiring.replayability is replayable.`)
467
- }
468
- if (ciWiring.replayability === 'not_replayable') {
469
- if (ciWiring.status === 'pass' || ciWiring.status === 'fail') {
470
- errors.push(`${prefix}.ciWiring.status must be skip or unknown when replayability is not_replayable.`)
471
- }
472
- if (framework.ciWiringCommand) {
473
- errors.push(`${prefix}.ciWiringCommand must be omitted when ${prefix}.ciWiring.replayability is not_replayable.`)
474
- }
475
- if (!hasNonEmptyString(ciWiring.replayBlocker)) {
476
- errors.push(`${prefix}.ciWiring.replayBlocker must explain why CI replay is not_replayable.`)
477
- }
478
- }
479
-
480
- if (ciWiring.initialization !== undefined) {
481
- validateCiInitialization(ciWiring.initialization, `${prefix}.ciWiring.initialization`, errors)
482
- }
483
-
484
- if (ciWiring.initialization?.status === 'not_configured' &&
485
- commandInitializesDatadog(framework.ciWiringCommand)) {
486
- errors.push(
487
- `${prefix}.ciWiring.initialization.status is not_configured, but ${prefix}.ciWiringCommand adds dd-trace ` +
488
- 'initialization. The replay command must preserve the discovered CI configuration; remove the added ' +
489
- 'initialization or correct the initialization status and evidence.'
490
- )
491
- }
492
-
493
- if (framework.ciWiringCommand) {
494
- for (const field of ['provider', 'configFile', 'job', 'step', 'whySelected']) {
495
- requiredString(ciWiring, field, errors, `${prefix}.ciWiring`)
496
- }
497
- requiredAbsolutePath(ciWiring, 'configFile', errors, `${prefix}.ciWiring`)
498
- requiredAbsolutePath(ciWiring, 'workingDirectory', errors, `${prefix}.ciWiring`)
499
- if (path.resolve(ciWiring.workingDirectory || '') !== path.resolve(framework.ciWiringCommand.cwd || '')) {
500
- errors.push(`${prefix}.ciWiringCommand.cwd must match ${prefix}.ciWiring.workingDirectory.`)
501
- }
502
- if (ciWiring.shell !== undefined && ciWiring.shell !== null) {
503
- if (typeof ciWiring.shell !== 'string' || ciWiring.shell.trim() === '') {
504
- errors.push(`${prefix}.ciWiring.shell must be a non-empty string when present.`)
505
- } else if (hasUnsafeExecutionCharacter(ciWiring.shell)) {
506
- errors.push(`${prefix}.ciWiring.shell must not contain invisible or control characters.`)
310
+ if (Array.isArray(strategy.scenarios)) {
311
+ if (strategy.scenarios.length !== GENERATED_SCENARIOS.size) {
312
+ errors.push(`${prefix}.generatedTestStrategy.scenarios must contain all three canonical scenarios.`)
313
+ }
314
+ const scenarioIds = new Set()
315
+ for (const [index, scenario] of strategy.scenarios.slice(0, GENERATED_SCENARIOS.size).entries()) {
316
+ const scenarioPrefix = `${prefix}.generatedTestStrategy.scenarios[${index}]`
317
+ if (!isObject(scenario)) {
318
+ errors.push(`${scenarioPrefix} must be an object.`)
319
+ continue
320
+ }
321
+ enumString(scenario, 'id', GENERATED_SCENARIOS, scenarioPrefix, errors)
322
+ rejectExecutionFields(scenario, scenarioPrefix, errors)
323
+ scenarioIds.add(scenario.id)
324
+ validateScenarioIdentity(repositoryRoot, scenario, scenarioPrefix, errors)
325
+ const expected = scenario.expectedWithoutDatadog
326
+ if (!isObject(expected) ||
327
+ expected.exitCode !== GENERATED_EXIT_CODES[scenario.id] ||
328
+ expected.observedTestCount !== 1) {
329
+ errors.push(`${scenarioPrefix}.expectedWithoutDatadog must retain the canonical exit code and one test.`)
507
330
  }
508
331
  }
509
- }
510
-
511
- if ((ciWiring.status === 'skip' || ciWiring.status === 'unknown') &&
512
- !hasNonEmptyString(ciWiring.diagnosis) && !hasNonEmptyString(ciWiring.reason)) {
513
- errors.push(`${prefix}.ciWiring must explain why CI wiring is ${ciWiring.status}.`)
514
- }
515
- }
516
-
517
- function validateCiInitialization (initialization, prefix, errors) {
518
- if (!initialization || typeof initialization !== 'object' || Array.isArray(initialization)) {
519
- errors.push(`${prefix} must be an object when present.`)
520
- return
521
- }
522
-
523
- if (!CI_INITIALIZATION_STATUSES.has(initialization.status)) {
524
- errors.push(`${prefix}.status must be configured, not_configured, or unknown.`)
525
- }
526
- if (Array.isArray(initialization.evidence)) {
527
- validateStringArray(initialization, 'evidence', errors, prefix)
528
- if (initialization.status !== 'unknown' && initialization.evidence.length === 0) {
529
- errors.push(`${prefix}.evidence must explain the ${initialization.status} conclusion.`)
332
+ for (const id of GENERATED_SCENARIOS) {
333
+ if (!scenarioIds.has(id)) errors.push(`${prefix}.generatedTestStrategy.scenarios is missing ${id}.`)
530
334
  }
531
- } else {
532
- errors.push(`${prefix}.evidence must be an array.`)
533
335
  }
534
- }
535
336
 
536
- function hasNonEmptyString (value) {
537
- return typeof value === 'string' && value.trim() !== ''
337
+ const contractError = getGeneratedTestContractError(framework)
338
+ if (contractError) errors.push(`${prefix}.generatedTestStrategy ${contractError}`)
538
339
  }
539
340
 
540
- function validateDatadogCleanCommand (command, prefix, errors) {
541
- for (const [name, value] of Object.entries(command?.env || {})) {
542
- if (name.startsWith('DD_') || (name === 'NODE_OPTIONS' && /dd-trace/.test(String(value)))) {
543
- errors.push(`${prefix}.env.${name} must not configure Datadog initialization for local validation.`)
544
- }
341
+ /**
342
+ * Validates one generated test identity.
343
+ *
344
+ * @param {string} repositoryRoot repository root
345
+ * @param {object} scenario generated scenario
346
+ * @param {string} prefix error prefix
347
+ * @param {{push: function(string): void}} errors error collector
348
+ * @returns {void}
349
+ */
350
+ function validateScenarioIdentity (repositoryRoot, scenario, prefix, errors) {
351
+ if (!Array.isArray(scenario.testIdentities) || scenario.testIdentities.length !== 1) {
352
+ errors.push(`${prefix}.testIdentities must contain exactly one identity.`)
353
+ return
545
354
  }
546
-
547
- const inlineInitialization = getInlineDatadogInitialization(command)
548
- if (inlineInitialization) {
549
- errors.push(
550
- `${prefix} ${inlineInitialization} and must be Datadog-clean for local validation. ` +
551
- 'Remove the inline initialization; preserve exact CI initialization only in ciWiringCommand.'
552
- )
355
+ const identity = scenario.testIdentities[0]
356
+ if (!isObject(identity)) {
357
+ errors.push(`${prefix}.testIdentities[0] must be an object.`)
358
+ return
553
359
  }
360
+ requiredAbsolutePath(identity, 'file', `${prefix}.testIdentities[0]`, errors)
361
+ requiredString(identity, 'name', `${prefix}.testIdentities[0]`, errors)
362
+ requiredString(identity, 'suite', `${prefix}.testIdentities[0]`, errors)
363
+ containedPath(repositoryRoot, identity.file, `${prefix}.testIdentities[0].file`, errors)
554
364
  }
555
365
 
556
- function validateGeneratedScenarioOutcome (scenario, prefix, errors) {
557
- const expected = scenario.expectedWithoutDatadog
558
- if (!expected || typeof expected !== 'object' || Array.isArray(expected)) {
559
- errors.push(`${prefix}.expectedWithoutDatadog must be an object when generatedTestStrategy is planned or verified.`)
366
+ /**
367
+ * Validates inert CI evidence.
368
+ *
369
+ * @param {string} repositoryRoot repository root
370
+ * @param {object|undefined} ciWiring CI evidence
371
+ * @param {string} prefix error prefix
372
+ * @param {{push: function(string): void}} errors error collector
373
+ * @returns {void}
374
+ */
375
+ function validateCiWiring (repositoryRoot, ciWiring, prefix, errors) {
376
+ if (ciWiring === undefined) return
377
+ if (!isObject(ciWiring)) {
378
+ errors.push(`${prefix} must be an object.`)
560
379
  return
561
380
  }
562
-
563
- const expectedExitCode = GENERATED_SCENARIO_EXIT_CODES[scenario.id]
564
- if (expectedExitCode !== undefined && expected.exitCode !== expectedExitCode) {
565
- errors.push(`${prefix}.expectedWithoutDatadog.exitCode must be ${expectedExitCode} for ${scenario.id}.`)
566
- }
567
- if (expected.observedTestCount !== 1) {
568
- errors.push(`${prefix}.expectedWithoutDatadog.observedTestCount must be 1 so the command isolates this scenario.`)
569
- }
570
- }
571
-
572
- function validateCompleteGeneratedScenarioSet (strategy, prefix, errors) {
573
- if (!Array.isArray(strategy.scenarios)) return
574
-
575
- const seen = new Set()
576
- for (const scenario of strategy.scenarios.slice(0, GENERATED_SCENARIO_IDS.size)) {
577
- if (typeof scenario?.id === 'string') seen.add(scenario.id)
381
+ rejectExecutionFields(ciWiring, prefix, errors, new Set(['command']))
382
+ for (const field of ['command', 'job', 'step']) optionalStringOrNull(ciWiring, field, prefix, errors)
383
+ optionalAbsolutePathOrNull(ciWiring, 'configFile', prefix, errors)
384
+ optionalAbsolutePathOrNull(ciWiring, 'workingDirectory', prefix, errors)
385
+ containedPath(repositoryRoot, ciWiring.configFile, `${prefix}.configFile`, errors)
386
+ containedPath(repositoryRoot, ciWiring.workingDirectory, `${prefix}.workingDirectory`, errors)
387
+ if (typeof ciWiring.reviewComplete !== 'boolean') errors.push(`${prefix}.reviewComplete must be a boolean.`)
388
+ validateStringArray(ciWiring.unresolved, `${prefix}.unresolved`, errors)
389
+
390
+ if (!isObject(ciWiring.initialization) ||
391
+ !INITIALIZATION_STATUSES.has(ciWiring.initialization.status)) {
392
+ errors.push(`${prefix}.initialization.status must be configured, not_configured, or unknown.`)
393
+ } else {
394
+ validateStringArray(ciWiring.initialization.evidence, `${prefix}.initialization.evidence`, errors)
578
395
  }
579
-
580
- for (const scenarioId of GENERATED_SCENARIO_IDS) {
581
- if (!seen.has(scenarioId)) {
582
- errors.push(`${prefix}.scenarios must include generated scenario "${scenarioId}" when status is planned or ` +
583
- 'verified.')
584
- }
396
+ if (!isObject(ciWiring.transport) || !TRANSPORT_MODES.has(ciWiring.transport.mode)) {
397
+ errors.push(`${prefix}.transport.mode must be agent, agentless, none, or unknown.`)
398
+ } else {
399
+ validateStringArray(ciWiring.transport.evidence, `${prefix}.transport.evidence`, errors)
585
400
  }
586
401
  }
587
402
 
588
- function validateUniqueFrameworkIds (frameworks, errors) {
589
- const seen = new Set()
590
- for (const [index, framework] of frameworks.entries()) {
591
- if (typeof framework?.id !== 'string') continue
592
- if (seen.has(framework.id)) {
593
- errors.push(`frameworks[${index}].id must be unique; duplicate "${framework.id}".`)
403
+ /**
404
+ * Rejects execution-shaped keys anywhere in a data object.
405
+ *
406
+ * @param {object} value object to inspect
407
+ * @param {string} prefix error prefix
408
+ * @param {{push: function(string): void}} errors error collector
409
+ * @param {Set<string>} [allowed] explicitly inert keys
410
+ * @returns {void}
411
+ */
412
+ function rejectExecutionFields (value, prefix, errors, allowed = new Set()) {
413
+ if (!isObject(value)) return
414
+ for (const key of Object.keys(value)) {
415
+ if (FORBIDDEN_EXECUTION_FIELDS.has(key) && !allowed.has(key)) {
416
+ errors.push(`${prefix}.${key} is not supported. Executable commands are validator-owned.`)
594
417
  }
595
- seen.add(framework.id)
596
418
  }
597
419
  }
598
420
 
599
- function requireNonEmptyNotes (framework, errors, prefix) {
600
- if (!Array.isArray(framework.notes) || framework.notes.length === 0) {
601
- errors.push(`${prefix}.notes must include a reason when status is ${framework.status}.`)
602
- return
421
+ /**
422
+ * Claims a generated path for one framework.
423
+ *
424
+ * @param {string} filename generated path
425
+ * @param {string} owner framework id
426
+ * @param {string} prefix error prefix
427
+ * @param {Map<string, string>} generatedPaths previously claimed paths
428
+ * @param {{push: function(string): void}} errors error collector
429
+ * @returns {void}
430
+ */
431
+ function claimGeneratedPath (filename, owner, prefix, generatedPaths, errors) {
432
+ if (typeof filename !== 'string' || !path.isAbsolute(filename)) return
433
+ const normalized = path.normalize(filename)
434
+ const previousOwner = generatedPaths.get(normalized)
435
+ if (previousOwner !== undefined && previousOwner !== owner) {
436
+ errors.push(`${prefix} conflicts with another framework's generated or cleanup path.`)
603
437
  }
604
- validateStringArray(framework, 'notes', errors, prefix)
438
+ generatedPaths.set(normalized, owner)
605
439
  }
606
440
 
607
- function validateScenarioIdentities (scenario, prefix, errors, required = false) {
608
- if (!Array.isArray(scenario.testIdentities)) {
609
- if (required) {
610
- errors.push(`${prefix}.testIdentities must be a non-empty array when generatedTestStrategy is planned or ` +
611
- 'verified.')
612
- }
441
+ /**
442
+ * Validates every string in a bounded object graph.
443
+ *
444
+ * @param {unknown} value current value
445
+ * @param {string} prefix error prefix
446
+ * @param {{push: function(string): void, full: function(): boolean}} errors error collector
447
+ * @returns {void}
448
+ */
449
+ function validateAllStrings (value, prefix, errors) {
450
+ if (errors.full()) return
451
+ if (typeof value === 'string') {
452
+ if (Buffer.byteLength(value) > MAX_STRING_BYTES) errors.push(`${prefix} exceeds the string size limit.`)
453
+ if (hasUnsafeInvisibleCharacter(value)) errors.push(`${prefix} contains an unsafe invisible character.`)
613
454
  return
614
455
  }
615
-
616
- if (required && scenario.testIdentities.length === 0) {
617
- errors.push(`${prefix}.testIdentities must be a non-empty array when generatedTestStrategy is planned or verified.`)
618
- }
619
- validateArrayLimit(
620
- { testIdentities: scenario.testIdentities },
621
- 'testIdentities',
622
- MAX_MANIFEST_ARRAY_ENTRIES,
623
- errors,
624
- prefix
625
- )
626
-
627
- for (const [index, identity] of scenario.testIdentities.slice(0, MAX_MANIFEST_ARRAY_ENTRIES).entries()) {
628
- const identityPrefix = `${prefix}.testIdentities[${index}]`
629
- if (!identity || typeof identity !== 'object' || Array.isArray(identity)) {
630
- errors.push(`${identityPrefix} must be an object.`)
631
- continue
456
+ if (Array.isArray(value)) {
457
+ for (const [index, item] of value.slice(0, MAX_ARRAY_ENTRIES).entries()) {
458
+ validateAllStrings(item, `${prefix}[${index}]`, errors)
632
459
  }
633
- requiredString(identity, 'name', errors, identityPrefix)
634
- if (identity.suite !== undefined && identity.suite !== null && typeof identity.suite !== 'string') {
635
- errors.push(`${identityPrefix}.suite must be a string or null when present.`)
636
- }
637
- optionalAbsolutePath(identity, 'file', errors, identityPrefix)
638
- }
639
- }
640
-
641
- function requiredCommand (target, field, errors, prefix = '', options = {}) {
642
- const value = target && target[field]
643
- const key = join(prefix, field)
644
- if (!value || typeof value !== 'object') {
645
- errors.push(`${key} must be an object.`)
646
460
  return
647
461
  }
648
- for (const name of Object.keys(value)) {
649
- if (!COMMAND_FIELDS.has(name)) errors.push(`${key}.${name} is not an allowed command field.`)
650
- }
651
- if (value.usesShell !== undefined && typeof value.usesShell !== 'boolean') {
652
- errors.push(`${key}.usesShell must be a boolean when present.`)
653
- }
654
- if (value.required !== undefined && typeof value.required !== 'boolean') {
655
- errors.push(`${key}.required must be a boolean when present.`)
656
- }
657
- requiredAbsolutePath(value, 'cwd', errors, key)
658
- rejectUnresolvedPlaceholder(value.cwd, `${key}.cwd`, errors)
659
- if (value.shell !== undefined) {
660
- requiredString(value, 'shell', errors, key)
661
- rejectUnresolvedPlaceholder(value.shell, `${key}.shell`, errors)
662
- if (!value.usesShell) errors.push(`${key}.shell requires usesShell to be true.`)
663
- if (typeof value.shell === 'string' && hasUnsafeExecutionCharacter(value.shell)) {
664
- errors.push(`${key}.shell must not contain invisible or control characters.`)
665
- }
666
- }
667
- if (value.usesShell) {
668
- requiredString(value, 'shellCommand', errors, key)
669
- rejectUnresolvedPlaceholder(value.shellCommand, `${key}.shellCommand`, errors)
670
- if (typeof value.shellCommand === 'string' && hasUnsafeInvisibleCharacter(value.shellCommand)) {
671
- errors.push(`${key}.shellCommand must not contain invisible or control characters.`)
672
- } else if (typeof value.shellCommand === 'string' && sanitizeString(value.shellCommand) !== value.shellCommand) {
673
- errors.push(`${key}.shellCommand must not contain inline secret-like values. Put safe placeholders in env.`)
674
- }
675
- } else if (!Array.isArray(value.argv) || value.argv.length === 0) {
676
- errors.push(`${key}.argv must be a non-empty array unless usesShell is true.`)
677
- } else {
678
- validateStringArray(value, 'argv', errors, key)
679
- for (const [index, arg] of value.argv.slice(0, MAX_MANIFEST_ARRAY_ENTRIES).entries()) {
680
- rejectUnresolvedPlaceholder(arg, `${key}.argv[${index}]`, errors)
681
- }
682
- if (value.argv.some(hasUnsafeExecutionCharacter)) {
683
- errors.push(`${key}.argv must not contain invisible or control characters.`)
684
- } else if (JSON.stringify(sanitizeForReport(value.argv)) !== JSON.stringify(value.argv)) {
685
- errors.push(`${key}.argv must not contain inline secret-like values. Put safe placeholders in env.`)
686
- }
687
- }
688
- if (value.env !== undefined) {
689
- if (!value.env || typeof value.env !== 'object' || Array.isArray(value.env)) {
690
- errors.push(`${key}.env must be an object when present.`)
691
- } else {
692
- const environmentEntries = Object.entries(value.env)
693
- if (environmentEntries.length > MAX_MANIFEST_ARRAY_ENTRIES) {
694
- errors.push(`${key}.env must contain at most ${MAX_MANIFEST_ARRAY_ENTRIES} entries.`)
695
- }
696
- for (const [name, envValue] of environmentEntries.slice(0, MAX_MANIFEST_ARRAY_ENTRIES)) {
697
- if (!ENV_NAME_PATTERN.test(name)) {
698
- errors.push(`${key}.env contains invalid variable name ${JSON.stringify(name)}.`)
699
- }
700
- if (typeof envValue !== 'string') errors.push(`${key}.env.${name} must be a string.`)
701
- const validatePlaceholder = !(options.datadogClean && name.startsWith('DD_'))
702
- if (typeof envValue === 'string' && hasUnsafeExecutionCharacter(envValue)) {
703
- errors.push(`${key}.env.${name} must not contain invisible or control characters.`)
704
- } else if (validatePlaceholder && typeof envValue === 'string' && containsSecretValue(name, envValue) &&
705
- envValue !== SECRET_PLACEHOLDER) {
706
- errors.push(`${key}.env.${name} must use the safe placeholder ${JSON.stringify(SECRET_PLACEHOLDER)}.`)
707
- }
708
- rejectUnresolvedPlaceholder(envValue, `${key}.env.${name}`, errors)
709
- }
710
- }
711
- }
712
- if (value.requiredEnvVars !== undefined) {
713
- if (Array.isArray(value.requiredEnvVars)) {
714
- validateArrayLimit(value, 'requiredEnvVars', MAX_MANIFEST_ARRAY_ENTRIES, errors, key)
715
- for (const [index] of value.requiredEnvVars.slice(0, MAX_MANIFEST_ARRAY_ENTRIES).entries()) {
716
- requiredString(value.requiredEnvVars, index, errors, `${key}.requiredEnvVars`)
717
- }
718
- } else {
719
- errors.push(`${key}.requiredEnvVars must be an array when present.`)
720
- }
721
- }
722
- optionalAbsolutePathArray(value, 'outputPaths', errors, key)
723
- if (value.timeoutMs !== undefined && (!Number.isFinite(value.timeoutMs) || value.timeoutMs <= 0)) {
724
- errors.push(`${key}.timeoutMs must be a positive number when present.`)
725
- } else if (value.timeoutMs > MAX_COMMAND_TIMEOUT_MS) {
726
- errors.push(`${key}.timeoutMs must not exceed ${MAX_COMMAND_TIMEOUT_MS} ms.`)
462
+ if (isObject(value)) {
463
+ for (const [key, item] of Object.entries(value)) validateAllStrings(item, `${prefix}.${key}`, errors)
727
464
  }
728
465
  }
729
466
 
730
- function containsSecretValue (name, value) {
731
- if (SAFE_SECRET_FIELD_VALUES.has(value.toLowerCase())) return false
732
- return isSensitiveName(name) || sanitizeString(value) !== value
467
+ /**
468
+ * Validates a required object.
469
+ *
470
+ * @param {object} object parent object
471
+ * @param {string} field field name
472
+ * @param {string} prefix error prefix
473
+ * @param {{push: function(string): void}} errors error collector
474
+ * @returns {void}
475
+ */
476
+ function requiredObject (object, field, prefix, errors) {
477
+ if (!isObject(object[field])) errors.push(`${prefix}.${field} must be an object.`)
733
478
  }
734
479
 
735
- function rejectUnresolvedPlaceholder (value, key, errors) {
736
- if (typeof value !== 'string' || !UNRESOLVED_PLACEHOLDER_PATTERN.test(value)) return
737
- errors.push(`${key} contains an unresolved placeholder. Resolve it before live validation.`)
480
+ /**
481
+ * Validates a required array.
482
+ *
483
+ * @param {object} object parent object
484
+ * @param {string} field field name
485
+ * @param {string} prefix error prefix
486
+ * @param {{push: function(string): void}} errors error collector
487
+ * @returns {void}
488
+ */
489
+ function requiredArray (object, field, prefix, errors) {
490
+ if (!Array.isArray(object[field])) errors.push(`${prefix}.${field} must be an array.`)
738
491
  }
739
492
 
740
- function requiredObject (target, field, errors, prefix = '') {
741
- const value = target && target[field]
742
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
743
- errors.push(`${join(prefix, field)} must be an object.`)
493
+ /**
494
+ * Validates a required string.
495
+ *
496
+ * @param {object} object parent object
497
+ * @param {string} field field name
498
+ * @param {string} prefix error prefix
499
+ * @param {{push: function(string): void}} errors error collector
500
+ * @returns {void}
501
+ */
502
+ function requiredString (object, field, prefix, errors) {
503
+ if (typeof object?.[field] !== 'string' || object[field].trim() === '') {
504
+ errors.push(`${prefix}.${field} must be a non-empty string.`)
744
505
  }
745
506
  }
746
507
 
747
- function requiredArray (target, field, errors, prefix = '') {
748
- if (!Array.isArray(target && target[field])) {
749
- errors.push(`${join(prefix, field)} must be an array.`)
508
+ /**
509
+ * Validates an enum string.
510
+ *
511
+ * @param {object} object parent object
512
+ * @param {string} field field name
513
+ * @param {Set<string>} values accepted values
514
+ * @param {string} prefix error prefix
515
+ * @param {{push: function(string): void}} errors error collector
516
+ * @returns {void}
517
+ */
518
+ function enumString (object, field, values, prefix, errors) {
519
+ if (!values.has(object?.[field])) {
520
+ errors.push(`${prefix}.${field} must be one of: ${[...values].join(', ')}.`)
750
521
  }
751
522
  }
752
523
 
753
- function requiredString (target, field, errors, prefix = '') {
754
- if (typeof (target && target[field]) !== 'string' || target[field].length === 0) {
755
- errors.push(`${join(prefix, field)} must be a non-empty string.`)
756
- }
524
+ /**
525
+ * Validates a required absolute path.
526
+ *
527
+ * @param {object} object parent object
528
+ * @param {string} field field name
529
+ * @param {string} prefix error prefix
530
+ * @param {{push: function(string): void}} errors error collector
531
+ * @returns {void}
532
+ */
533
+ function requiredAbsolutePath (object, field, prefix, errors) {
534
+ absolutePathValue(object?.[field], `${prefix}.${field}`, errors)
757
535
  }
758
536
 
759
- function enumString (target, field, values, errors, prefix = '') {
760
- if (!values.has(target && target[field])) {
761
- errors.push(`${join(prefix, field)} must be one of: ${[...values].join(', ')}.`)
762
- }
537
+ /**
538
+ * Validates an absolute path value.
539
+ *
540
+ * @param {unknown} value path value
541
+ * @param {string} prefix error prefix
542
+ * @param {{push: function(string): void}} errors error collector
543
+ * @returns {void}
544
+ */
545
+ function absolutePathValue (value, prefix, errors) {
546
+ if (typeof value !== 'string' || !path.isAbsolute(value)) errors.push(`${prefix} must be an absolute path.`)
763
547
  }
764
548
 
765
- function requiredAbsolutePath (target, field, errors, prefix = '') {
766
- const value = target && target[field]
767
- if (typeof value !== 'string' || !path.isAbsolute(value)) {
768
- errors.push(`${join(prefix, field)} must be an absolute path.`)
769
- } else if (hasUnsafeExecutionCharacter(value)) {
770
- errors.push(`${join(prefix, field)} must not contain invisible or control characters.`)
549
+ /**
550
+ * Validates an optional nullable absolute path.
551
+ *
552
+ * @param {object} object parent object
553
+ * @param {string} field field name
554
+ * @param {string} prefix error prefix
555
+ * @param {{push: function(string): void}} errors error collector
556
+ * @returns {void}
557
+ */
558
+ function optionalAbsolutePathOrNull (object, field, prefix, errors) {
559
+ if (object[field] !== null && object[field] !== undefined &&
560
+ (typeof object[field] !== 'string' || !path.isAbsolute(object[field]))) {
561
+ errors.push(`${prefix}.${field} must be an absolute path or null.`)
771
562
  }
772
563
  }
773
564
 
774
- function optionalAbsolutePath (target, field, errors, prefix = '') {
775
- const value = target && target[field]
776
- if (value === undefined || value === null) return
777
- if (typeof value !== 'string' || !path.isAbsolute(value)) {
778
- errors.push(`${join(prefix, field)} must be an absolute path when present.`)
779
- } else if (hasUnsafeExecutionCharacter(value)) {
780
- errors.push(`${join(prefix, field)} must not contain invisible or control characters.`)
565
+ /**
566
+ * Validates an optional nullable string.
567
+ *
568
+ * @param {object} object parent object
569
+ * @param {string} field field name
570
+ * @param {string} prefix error prefix
571
+ * @param {{push: function(string): void}} errors error collector
572
+ * @returns {void}
573
+ */
574
+ function optionalStringOrNull (object, field, prefix, errors) {
575
+ if (object[field] !== null && object[field] !== undefined && typeof object[field] !== 'string') {
576
+ errors.push(`${prefix}.${field} must be a string or null.`)
781
577
  }
782
578
  }
783
579
 
784
- function optionalAbsolutePathArray (target, field, errors, prefix = '') {
785
- const value = target && target[field]
580
+ /**
581
+ * Validates a string array when present.
582
+ *
583
+ * @param {unknown} value array value
584
+ * @param {string} prefix error prefix
585
+ * @param {{push: function(string): void}} errors error collector
586
+ * @returns {void}
587
+ */
588
+ function validateStringArray (value, prefix, errors) {
786
589
  if (value === undefined) return
787
590
  if (!Array.isArray(value)) {
788
- errors.push(`${join(prefix, field)} must be an array when present.`)
591
+ errors.push(`${prefix} must be an array of strings.`)
789
592
  return
790
593
  }
791
-
792
- validateArrayLimit(target, field, MAX_MANIFEST_ARRAY_ENTRIES, errors, prefix)
793
- for (const [index, item] of value.slice(0, MAX_MANIFEST_ARRAY_ENTRIES).entries()) {
794
- if (typeof item !== 'string' || !path.isAbsolute(item)) {
795
- errors.push(`${join(prefix, field)}[${index}] must be an absolute path.`)
796
- } else if (hasUnsafeExecutionCharacter(item)) {
797
- errors.push(`${join(prefix, field)}[${index}] must not contain invisible or control characters.`)
798
- }
799
- }
800
- }
801
-
802
- function validateStringArray (target, field, errors, prefix = '') {
803
- const value = target && target[field]
804
- if (!Array.isArray(value)) return
805
-
806
- validateArrayLimit(target, field, MAX_MANIFEST_ARRAY_ENTRIES, errors, prefix)
807
- for (const [index, item] of value.slice(0, MAX_MANIFEST_ARRAY_ENTRIES).entries()) {
808
- if (typeof item !== 'string') {
809
- errors.push(`${join(prefix, field)}[${index}] must be a string.`)
810
- }
594
+ if (value.length > MAX_ARRAY_ENTRIES) errors.push(`${prefix} must contain at most ${MAX_ARRAY_ENTRIES} entries.`)
595
+ if (value.slice(0, MAX_ARRAY_ENTRIES).some(item => typeof item !== 'string')) {
596
+ errors.push(`${prefix} must contain only strings.`)
811
597
  }
812
598
  }
813
599
 
814
- function validateArrayLimit (target, field, limit, errors, prefix = '') {
815
- const value = target && target[field]
816
- if (Array.isArray(value) && value.length > limit) {
817
- errors.push(`${join(prefix, field)} must contain at most ${limit} entries.`)
600
+ /**
601
+ * Checks lexical repository containment.
602
+ *
603
+ * @param {string} root repository root
604
+ * @param {unknown} filename candidate path
605
+ * @param {string} prefix error prefix
606
+ * @param {{push: function(string): void}} errors error collector
607
+ * @returns {void}
608
+ */
609
+ function containedPath (root, filename, prefix, errors) {
610
+ if (typeof root !== 'string' || !path.isAbsolute(root) ||
611
+ typeof filename !== 'string' || !path.isAbsolute(filename)) return
612
+ const relative = path.relative(path.resolve(root), path.resolve(filename))
613
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
614
+ errors.push(`${prefix} must be inside repository.root.`)
818
615
  }
819
616
  }
820
617
 
821
- function limitedArray (value, limit) {
822
- return Array.isArray(value) ? value.slice(0, limit) : []
618
+ /**
619
+ * Returns whether a value is a plain object.
620
+ *
621
+ * @param {unknown} value candidate value
622
+ * @returns {boolean} whether the value is an object
623
+ */
624
+ function isObject (value) {
625
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
823
626
  }
824
627
 
628
+ /**
629
+ * Creates a bounded error collector.
630
+ *
631
+ * @returns {{push: function(string): void, full: function(): boolean, finalize: function(): string[]}} collector
632
+ */
825
633
  function createErrorCollector () {
826
634
  const errors = []
827
- let omitted = 0
828
- Object.defineProperties(errors, {
829
- push: {
830
- value (...messages) {
831
- for (const message of messages) {
832
- if (this.length < MAX_VALIDATION_ERRORS - 1) {
833
- Array.prototype.push.call(this, message)
834
- } else {
835
- omitted++
836
- }
837
- }
838
- return this.length
839
- },
635
+ let omitted = false
636
+ return {
637
+ push (message) {
638
+ if (errors.length < MAX_VALIDATION_ERRORS) {
639
+ errors.push(sanitizeString(message))
640
+ } else {
641
+ omitted = true
642
+ }
840
643
  },
841
- finalize: {
842
- value () {
843
- if (omitted > 0) {
844
- Array.prototype.push.call(this, `${omitted} additional validation error(s) omitted.`)
845
- }
846
- return this
847
- },
644
+ full () {
645
+ return errors.length >= MAX_VALIDATION_ERRORS
848
646
  },
849
- })
850
- return errors
851
- }
852
-
853
- function join (prefix, field) {
854
- return prefix ? `${prefix}.${field}` : field
647
+ finalize () {
648
+ if (omitted) errors.push('Additional validation errors were omitted.')
649
+ return errors
650
+ },
651
+ }
855
652
  }
856
653
 
857
- module.exports = {
858
- MAX_FRAMEWORKS,
859
- MAX_REPRESENTATIVE_TESTS,
860
- MAX_VALIDATION_ERRORS,
861
- validateManifest,
862
- }
654
+ module.exports = { validateManifest }