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
@@ -0,0 +1,664 @@
1
+ 'use strict'
2
+
3
+ const fs = require('node:fs')
4
+ const path = require('node:path')
5
+
6
+ const { environmentNamesEqual, setEnvironmentValue } = require('./environment')
7
+
8
+ const RUNNER_OPTIONS = {
9
+ cucumber: {
10
+ flags: new Set(),
11
+ values: new Set([
12
+ '-p', '--config', '--import', '--language', '--loader', '--profile', '--require', '--require-module',
13
+ '--world-parameters',
14
+ ]),
15
+ },
16
+ cypress: {
17
+ flags: new Set(['--component', '--e2e', '--headed', '--headless']),
18
+ values: new Set(['--browser', '--config-file']),
19
+ },
20
+ jest: {
21
+ flags: new Set(['--detectLeaks']),
22
+ values: new Set(['-c', '--config', '--env', '--runner', '--testEnvironment']),
23
+ },
24
+ mocha: {
25
+ flags: new Set(['--check-leaks', '--enable-source-maps']),
26
+ values: new Set(['-r', '-t', '-u', '--config', '--extension', '--loader', '--require', '--timeout', '--ui']),
27
+ },
28
+ playwright: {
29
+ flags: new Set(),
30
+ values: new Set(['-c', '--config', '--project']),
31
+ },
32
+ vitest: {
33
+ flags: new Set(['--browser']),
34
+ values: new Set(['--config', '--environment', '--project', '--root']),
35
+ },
36
+ }
37
+ const IGNORED_RUNNER_OPTIONS = {
38
+ mocha: {
39
+ flags: new Set(),
40
+ values: new Set(['-R', '--reporter']),
41
+ },
42
+ }
43
+ const OMITTED_RUNNER_OPTIONS = {
44
+ vitest: new Set(['--run', '--typecheck']),
45
+ }
46
+ const SOURCE_SELECTOR_OPTIONS = {
47
+ cypress: new Set(['--spec']),
48
+ }
49
+ const RUNNER_ENVIRONMENT_NAMES = new Set([
50
+ 'BABEL_ENV',
51
+ 'CI',
52
+ 'NODE_ENV',
53
+ 'TS_NODE_PROJECT',
54
+ 'TZ',
55
+ ])
56
+ const RUNNER_LAUNCHERS = new Set(['c8', 'cross-env', 'env', 'npx', 'nyc', 'node'])
57
+ const MODULE_OPTIONS = new Set([
58
+ '-r',
59
+ '-u',
60
+ '--env',
61
+ '--environment',
62
+ '--import',
63
+ '--loader',
64
+ '--require',
65
+ '--require-module',
66
+ '--runner',
67
+ '--testEnvironment',
68
+ '--ui',
69
+ ])
70
+ const FILE_OPTIONS = new Set(['-c', '--config', '--config-file'])
71
+ const DIRECTORY_OPTIONS = new Set(['--root'])
72
+ const UNSUPPORTED_CONFIGURATION_OPTIONS = {
73
+ cypress: new Set(['--config']),
74
+ jest: new Set(['--projects']),
75
+ mocha: new Set(['--config']),
76
+ }
77
+ const BUILTIN_MODULE_VALUES = new Map([
78
+ ['-u', new Set(['bdd', 'exports', 'qunit', 'tdd'])],
79
+ ['--env', new Set(['node'])],
80
+ ['--environment', new Set(['node'])],
81
+ ['--testEnvironment', new Set(['node'])],
82
+ ['--ui', new Set(['bdd', 'exports', 'qunit', 'tdd'])],
83
+ ])
84
+ const CONTROL_PATTERN = /[\0\r\n;&|`]|\$\(|\$\{/
85
+ const ENVIRONMENT_EXPANSION_PATTERN = /\$|%[^%]+%|![^!]+!/
86
+
87
+ /**
88
+ * Extracts bounded runner configuration from a detected package script.
89
+ *
90
+ * @param {string} framework framework name
91
+ * @param {string} command detected package script
92
+ * @param {string} projectRoot detected project root
93
+ * @param {string} repositoryRoot repository root
94
+ * @param {string} [platform] target platform
95
+ * @returns {{
96
+ * environment: Record<string, string>,
97
+ * error?: string,
98
+ * inputFiles: string[],
99
+ * runnerArgs: string[]
100
+ * }} runner contract
101
+ */
102
+ function getRunnerContract (framework, command, projectRoot, repositoryRoot, platform = process.platform) {
103
+ const dynamicEnvironmentError = getDynamicRunnerEnvironmentError(command)
104
+ if (dynamicEnvironmentError) {
105
+ return { environment: {}, error: dynamicEnvironmentError, inputFiles: [], runnerArgs: [] }
106
+ }
107
+ const invocation = getFrameworkInvocation(command, framework)
108
+ if (!invocation) return { environment: {}, inputFiles: [], runnerArgs: [] }
109
+ if (invocation.error) {
110
+ return { environment: {}, error: invocation.error, inputFiles: [], runnerArgs: [] }
111
+ }
112
+ const unsupportedOption = getUnsupportedConfigurationOption(framework, invocation)
113
+ if (unsupportedOption) {
114
+ return {
115
+ environment: {},
116
+ error: `${unsupportedOption} has configuration semantics that the validator does not preserve`,
117
+ inputFiles: [],
118
+ runnerArgs: [],
119
+ }
120
+ }
121
+ const unknownOption = getUnknownRunnerOption(framework, invocation)
122
+ if (unknownOption) {
123
+ return {
124
+ environment: {},
125
+ error: `${unknownOption} is not preserved by the validator direct-runner contract`,
126
+ inputFiles: [],
127
+ runnerArgs: [],
128
+ }
129
+ }
130
+ const omittedOptions = getOmittedRunnerOptions(framework, invocation)
131
+ if (framework === 'cucumber') {
132
+ const language = getOptionValue(invocation, '--language')
133
+ if (language && language !== 'en') {
134
+ return {
135
+ environment: {},
136
+ error: `--language ${language} is not supported by the validator-generated English scenarios`,
137
+ inputFiles: [],
138
+ runnerArgs: [],
139
+ }
140
+ }
141
+ }
142
+
143
+ const environment = getRunnerEnvironment(invocation, platform)
144
+ const environmentError = getRunnerEnvironmentError(environment, platform)
145
+ if (environmentError) {
146
+ return { environment: {}, error: `runner environment ${environmentError}`, inputFiles: [], runnerArgs: [] }
147
+ }
148
+ const runnerArgs = getRunnerArgs(framework, invocation)
149
+ const runnerArgsError = getRunnerArgsError(framework, runnerArgs)
150
+ if (runnerArgsError) {
151
+ return {
152
+ environment: {},
153
+ error: `runner arguments ${runnerArgsError}`,
154
+ inputFiles: [],
155
+ runnerArgs: [],
156
+ }
157
+ }
158
+ const inputs = getRunnerInputs(runnerArgs, environment, projectRoot, repositoryRoot)
159
+ if (inputs.error) {
160
+ return { environment: {}, error: inputs.error, inputFiles: [], runnerArgs: [] }
161
+ }
162
+ return {
163
+ environment,
164
+ inputFiles: inputs.files,
165
+ omittedOptions,
166
+ runnerArgs,
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Rejects shell-expanded allowlisted environment assignments before command tokenization.
172
+ *
173
+ * @param {string} command detected package script
174
+ * @returns {string|undefined} validation error
175
+ */
176
+ function getDynamicRunnerEnvironmentError (command) {
177
+ const source = String(command || '')
178
+ for (const match of source.matchAll(
179
+ /(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"]*)"|'([^']*)'|([^\s]+))/g
180
+ )) {
181
+ const value = match[2] ?? match[3] ?? match[4]
182
+ if (RUNNER_ENVIRONMENT_NAMES.has(match[1]) && ENVIRONMENT_EXPANSION_PATTERN.test(value)) {
183
+ return `runner environment contains an unsafe value for ${match[1]}`
184
+ }
185
+ }
186
+ if (ENVIRONMENT_EXPANSION_PATTERN.test(source)) {
187
+ return 'runner command contains shell-expanded values that the validator does not preserve'
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Returns literal existing test roots selected by a direct framework invocation.
193
+ *
194
+ * @param {string} framework framework name
195
+ * @param {string} command detected package script
196
+ * @param {string} projectRoot detected project root
197
+ * @param {string} repositoryRoot repository root
198
+ * @returns {string[]} physical search roots
199
+ */
200
+ function getRunnerSearchRoots (framework, command, projectRoot, repositoryRoot) {
201
+ const invocation = getFrameworkInvocation(command, framework)
202
+ const options = RUNNER_OPTIONS[framework]
203
+ if (!invocation || invocation.error || !options) return []
204
+
205
+ const roots = new Set()
206
+ const selectors = SOURCE_SELECTOR_OPTIONS[framework] || new Set()
207
+ const tokens = invocation.tokens.slice(invocation.runnerIndex + 1)
208
+ for (let index = 0; index < tokens.length; index++) {
209
+ const token = tokens[index]
210
+ if (token.startsWith('-')) {
211
+ const option = token.split('=', 1)[0]
212
+ if (DIRECTORY_OPTIONS.has(option)) {
213
+ const value = token.includes('=') ? token.slice(token.indexOf('=') + 1) : tokens[++index]
214
+ addRunnerSearchRoot(roots, value, projectRoot, repositoryRoot, true)
215
+ } else if (selectors.has(option)) {
216
+ const value = token.includes('=') ? token.slice(token.indexOf('=') + 1) : tokens[++index]
217
+ addRunnerSearchRoot(roots, value, projectRoot, repositoryRoot, false)
218
+ } else if (options.values.has(option) && !token.includes('=')) {
219
+ index++
220
+ }
221
+ continue
222
+ }
223
+ if (['run', 'test'].includes(token)) continue
224
+ addRunnerSearchRoot(roots, token, projectRoot, repositoryRoot, false)
225
+ if (roots.size === 3) break
226
+ }
227
+ return [...roots]
228
+ }
229
+
230
+ /**
231
+ * Adds one literal, contained command selector to the representative search roots.
232
+ *
233
+ * @param {Set<string>} roots accumulated physical roots
234
+ * @param {string|undefined} value command selector
235
+ * @param {string} projectRoot detected project root
236
+ * @param {string} repositoryRoot repository root
237
+ * @param {boolean} directory whether the option explicitly requires a directory
238
+ * @returns {void}
239
+ */
240
+ function addRunnerSearchRoot (roots, value, projectRoot, repositoryRoot, directory) {
241
+ if (typeof value !== 'string' || !value) return
242
+ const literal = value.split(/[*?{[]/, 1)[0].replace(/[\\/]+$/, '')
243
+ if (!literal) return
244
+ const candidate = path.resolve(projectRoot, literal)
245
+ const searchRoot = directory || !path.extname(candidate) ? candidate : path.dirname(candidate)
246
+ try {
247
+ const physical = fs.realpathSync(searchRoot)
248
+ if (fs.statSync(physical).isDirectory() &&
249
+ isPathInside(fs.realpathSync(repositoryRoot), physical)) roots.add(physical)
250
+ } catch {}
251
+ }
252
+
253
+ /**
254
+ * Returns a configuration-bearing option that the direct-runner contract intentionally rejects.
255
+ *
256
+ * @param {string} framework framework name
257
+ * @param {{runnerIndex: number, tokens: string[]}} invocation parsed invocation
258
+ * @returns {string|undefined} unsupported option
259
+ */
260
+ function getUnsupportedConfigurationOption (framework, invocation) {
261
+ const unsupported = UNSUPPORTED_CONFIGURATION_OPTIONS[framework]
262
+ if (!unsupported) return
263
+ return invocation.tokens.slice(invocation.runnerIndex + 1).find(token => {
264
+ return token.startsWith('-') && unsupported.has(token.split('=', 1)[0])
265
+ })?.split('=', 1)[0]
266
+ }
267
+
268
+ /**
269
+ * Returns the first runner option whose semantics are neither retained nor explicitly safe to omit.
270
+ *
271
+ * @param {string} framework framework name
272
+ * @param {{runnerIndex: number, tokens: string[]}} invocation parsed invocation
273
+ * @returns {string|undefined} unsupported option
274
+ */
275
+ function getUnknownRunnerOption (framework, invocation) {
276
+ const retained = RUNNER_OPTIONS[framework]
277
+ if (!retained) return
278
+ const ignored = IGNORED_RUNNER_OPTIONS[framework] || { flags: new Set(), values: new Set() }
279
+ const omitted = OMITTED_RUNNER_OPTIONS[framework] || new Set()
280
+ const selectors = SOURCE_SELECTOR_OPTIONS[framework] || new Set()
281
+ const tokens = invocation.tokens.slice(invocation.runnerIndex + 1)
282
+
283
+ for (let index = 0; index < tokens.length; index++) {
284
+ const token = tokens[index]
285
+ if (!token.startsWith('-')) continue
286
+ const option = token.split('=', 1)[0]
287
+ if (omitted.has(option)) {
288
+ if (token !== option) return option
289
+ continue
290
+ }
291
+ if (selectors.has(option)) {
292
+ const value = token.includes('=') ? token.slice(token.indexOf('=') + 1) : tokens[index + 1]
293
+ if (!value || value.startsWith('-')) return option
294
+ if (!token.includes('=')) index++
295
+ continue
296
+ }
297
+ if (retained.flags.has(option) || ignored.flags.has(option)) continue
298
+ if (retained.values.has(option) || ignored.values.has(option)) {
299
+ if (!token.includes('=') && tokens[index + 1] && !tokens[index + 1].startsWith('-')) index++
300
+ continue
301
+ }
302
+ return option
303
+ }
304
+ }
305
+
306
+ function getOmittedRunnerOptions (framework, invocation) {
307
+ const omitted = OMITTED_RUNNER_OPTIONS[framework] || new Set()
308
+ return [...new Set(invocation.tokens
309
+ .slice(invocation.runnerIndex + 1)
310
+ .filter(token => token.startsWith('-') && omitted.has(token.split('=', 1)[0])))]
311
+ }
312
+
313
+ function getOptionValue (invocation, expected) {
314
+ const tokens = invocation.tokens.slice(invocation.runnerIndex + 1)
315
+ for (let index = 0; index < tokens.length; index++) {
316
+ if (tokens[index].split('=', 1)[0] !== expected) continue
317
+ return tokens[index].includes('=') ? tokens[index].slice(tokens[index].indexOf('=') + 1) : tokens[index + 1]
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Selects a project-owned runner only for an exact `node <regular-file>` package script.
323
+ *
324
+ * @param {string} command detected package script
325
+ * @param {string} projectRoot detected project root
326
+ * @param {string} repositoryRoot repository root
327
+ * @returns {string|undefined} physical runner path
328
+ */
329
+ function getProjectNodeRunner (command, projectRoot, repositoryRoot) {
330
+ if (CONTROL_PATTERN.test(String(command || ''))) return
331
+ const tokens = tokenizeCommand(command)
332
+ if (tokens.length !== 2 || !/^node(?:\.exe)?$/i.test(path.basename(tokens[0])) || tokens[1].startsWith('-')) return
333
+
334
+ const filename = path.resolve(projectRoot, tokens[1])
335
+ try {
336
+ const lexicalStat = fs.lstatSync(filename)
337
+ const physical = fs.realpathSync(filename)
338
+ if (!lexicalStat.isFile() || lexicalStat.isSymbolicLink() ||
339
+ !fs.statSync(physical).isFile() ||
340
+ !isPathInside(fs.realpathSync(repositoryRoot), physical)) return
341
+ return physical
342
+ } catch {}
343
+ }
344
+
345
+ /**
346
+ * Validates scaffold-owned runner arguments before they can affect execution.
347
+ *
348
+ * @param {string} framework framework name
349
+ * @param {unknown} args runner arguments
350
+ * @returns {string|undefined} validation error
351
+ */
352
+ function getRunnerArgsError (framework, args) {
353
+ if (!Array.isArray(args)) return 'must be an array'
354
+ const options = RUNNER_OPTIONS[framework]
355
+ if (!options) return 'are unsupported for this framework'
356
+
357
+ for (let index = 0; index < args.length; index++) {
358
+ const token = args[index]
359
+ if (typeof token !== 'string' || !token || CONTROL_PATTERN.test(token)) {
360
+ return 'must contain only bounded strings without shell control syntax'
361
+ }
362
+ const option = token.split('=', 1)[0]
363
+ if (options.flags.has(option)) {
364
+ if (token !== option && !/^--detectLeaks=(?:true|false)$/.test(token)) {
365
+ return `contain an invalid value for ${option}`
366
+ }
367
+ continue
368
+ }
369
+ if (!options.values.has(option)) return `contain unsupported option ${option}`
370
+ if (token.includes('=')) {
371
+ if (!token.slice(token.indexOf('=') + 1)) return `contain a missing value for ${option}`
372
+ continue
373
+ }
374
+ const value = args[++index]
375
+ if (typeof value !== 'string' || !value || value.startsWith('-') || CONTROL_PATTERN.test(value)) {
376
+ return `contain a missing or unsafe value for ${option}`
377
+ }
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Validates the small static environment retained from a runner invocation.
383
+ *
384
+ * @param {unknown} environment runner environment
385
+ * @param {string} [platform] target platform
386
+ * @returns {string|undefined} validation error
387
+ */
388
+ function getRunnerEnvironmentError (environment, platform = process.platform) {
389
+ if (!environment || typeof environment !== 'object' || Array.isArray(environment)) return 'must be an object'
390
+ for (const [name, value] of Object.entries(environment)) {
391
+ if (!getCanonicalRunnerEnvironmentName(name, platform)) return `contains unsupported variable ${name}`
392
+ if (typeof value !== 'string' || Buffer.byteLength(value) > 4096 ||
393
+ CONTROL_PATTERN.test(value) || ENVIRONMENT_EXPANSION_PATTERN.test(value)) {
394
+ return `contains an unsafe value for ${name}`
395
+ }
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Validates that retained code-loading inputs are contained and approval-bound.
401
+ *
402
+ * @param {string[]} args runner arguments
403
+ * @param {Record<string, string>} environment runner environment
404
+ * @param {string} projectRoot project root
405
+ * @param {string} repositoryRoot repository root
406
+ * @param {string[]} configFiles approval-bound project inputs
407
+ * @returns {string|undefined} validation error
408
+ */
409
+ function getRunnerInputError (args, environment, projectRoot, repositoryRoot, configFiles) {
410
+ const inputs = getRunnerInputs(args, environment, projectRoot, repositoryRoot)
411
+ if (inputs.error) return inputs.error
412
+
413
+ const approved = new Set()
414
+ for (const filename of configFiles || []) {
415
+ try {
416
+ approved.add(fs.realpathSync(filename))
417
+ } catch {}
418
+ }
419
+ const unbound = inputs.files.find(filename => !approved.has(filename))
420
+ if (unbound) return `references an input that is not approval-bound: ${unbound}`
421
+ }
422
+
423
+ /**
424
+ * Finds a direct framework executable behind inert launch wrappers.
425
+ *
426
+ * @param {string} command detected package script
427
+ * @param {string} framework framework name
428
+ * @returns {{error?: string, runnerIndex: number, tokens: string[]}|undefined} parsed invocation
429
+ */
430
+ function getFrameworkInvocation (command, framework) {
431
+ if (CONTROL_PATTERN.test(String(command || ''))) return
432
+ if (command === `direct ${framework} binary`) return
433
+ const tokens = tokenizeCommand(command)
434
+ const executable = getRunnerExecutableName(framework)
435
+ const runnerIndex = tokens.findIndex(token => {
436
+ const basename = normalizeRunnerBasename(token)
437
+ return basename === executable ||
438
+ (framework === 'cucumber' && ['cucumber', 'cucumber-js'].includes(basename))
439
+ })
440
+ if (runnerIndex === -1) return
441
+
442
+ const prefix = tokens.slice(0, runnerIndex)
443
+ const firstCommand = prefix.find(token => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(token))
444
+ if (firstCommand && !RUNNER_LAUNCHERS.has(path.basename(firstCommand).toLowerCase())) {
445
+ return {
446
+ error: `runner launch wrapper ${path.basename(firstCommand)} is not allowlisted`,
447
+ runnerIndex,
448
+ tokens,
449
+ }
450
+ }
451
+ for (const token of prefix) {
452
+ if (RUNNER_LAUNCHERS.has(path.basename(token).toLowerCase())) continue
453
+ if (/^[A-Za-z_][A-Za-z0-9_]*=[^;&|`]*$/.test(token)) continue
454
+ return {
455
+ error: 'runner launch wrapper contains options or positional arguments whose semantics the validator does not ' +
456
+ 'preserve',
457
+ runnerIndex,
458
+ tokens,
459
+ }
460
+ }
461
+ return { runnerIndex, tokens }
462
+ }
463
+
464
+ /**
465
+ * Extracts allowlisted runner options while dropping source selectors and presentation flags.
466
+ *
467
+ * @param {string} framework framework name
468
+ * @param {{runnerIndex: number, tokens: string[]}} invocation parsed invocation
469
+ * @returns {string[]} retained options
470
+ */
471
+ function getRunnerArgs (framework, invocation) {
472
+ const options = RUNNER_OPTIONS[framework]
473
+ if (!options) return []
474
+ const args = []
475
+ for (let index = invocation.runnerIndex + 1; index < invocation.tokens.length; index++) {
476
+ const token = invocation.tokens[index]
477
+ if (!token.startsWith('-')) continue
478
+ const option = token.split('=', 1)[0]
479
+ if (options.flags.has(option)) {
480
+ args.push(token)
481
+ } else if (options.values.has(option)) {
482
+ args.push(token)
483
+ if (!token.includes('=') && invocation.tokens[index + 1] && !invocation.tokens[index + 1].startsWith('-')) {
484
+ args.push(invocation.tokens[++index])
485
+ }
486
+ }
487
+ }
488
+ return args
489
+ }
490
+
491
+ /**
492
+ * Normalizes executable shims and supported JavaScript runner entrypoints.
493
+ *
494
+ * @param {string} token command token
495
+ * @returns {string} normalized executable basename
496
+ */
497
+ function normalizeRunnerBasename (token) {
498
+ return path.basename(token)
499
+ .replace(/\.cmd$/i, '')
500
+ .replace(/\.(?:cjs|mjs|js)$/i, '')
501
+ .toLowerCase()
502
+ }
503
+
504
+ /**
505
+ * Extracts allowlisted literal environment assignments before the framework runner.
506
+ *
507
+ * @param {{runnerIndex: number, tokens: string[]}} invocation parsed invocation
508
+ * @param {string} [platform] target platform
509
+ * @returns {Record<string, string>} retained environment
510
+ */
511
+ function getRunnerEnvironment (invocation, platform = process.platform) {
512
+ const environment = {}
513
+ for (const token of invocation.tokens.slice(0, invocation.runnerIndex)) {
514
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(token)
515
+ const name = match && getCanonicalRunnerEnvironmentName(match[1], platform)
516
+ if (name && !CONTROL_PATTERN.test(match[2])) {
517
+ setEnvironmentValue(environment, name, match[2], platform)
518
+ }
519
+ }
520
+ return environment
521
+ }
522
+
523
+ function getCanonicalRunnerEnvironmentName (name, platform) {
524
+ return [...RUNNER_ENVIRONMENT_NAMES].find(candidate => {
525
+ return environmentNamesEqual(candidate, name, platform)
526
+ })
527
+ }
528
+
529
+ /**
530
+ * Resolves regular files referenced by retained configuration.
531
+ *
532
+ * @param {string[]} args runner arguments
533
+ * @param {Record<string, string>} environment runner environment
534
+ * @param {string} projectRoot project root
535
+ * @param {string} repositoryRoot repository root
536
+ * @returns {{error?: string, files: string[]}} physical input files
537
+ */
538
+ function getRunnerInputs (args, environment, projectRoot, repositoryRoot) {
539
+ const values = []
540
+ for (let index = 0; index < args.length; index++) {
541
+ const option = args[index].split('=', 1)[0]
542
+ if (!MODULE_OPTIONS.has(option) && !FILE_OPTIONS.has(option) && !DIRECTORY_OPTIONS.has(option)) continue
543
+ const value = args[index].includes('=') ? args[index].slice(args[index].indexOf('=') + 1) : args[++index]
544
+ values.push({
545
+ directory: DIRECTORY_OPTIONS.has(option),
546
+ label: option,
547
+ module: MODULE_OPTIONS.has(option),
548
+ value,
549
+ })
550
+ }
551
+ if (environment.TS_NODE_PROJECT) {
552
+ values.push({
553
+ directory: false,
554
+ label: 'TS_NODE_PROJECT',
555
+ module: false,
556
+ value: environment.TS_NODE_PROJECT,
557
+ })
558
+ }
559
+
560
+ const files = new Set()
561
+ for (const input of values) {
562
+ if (input.directory) {
563
+ const directoryError = getContainedDirectoryError(input, projectRoot, repositoryRoot)
564
+ if (directoryError) return { error: directoryError, files: [] }
565
+ continue
566
+ }
567
+ if (input.module && BUILTIN_MODULE_VALUES.get(input.label)?.has(input.value)) continue
568
+ const filename = resolveInputFile(input, projectRoot)
569
+ if (!filename) return { error: `${input.label} does not resolve to a repository-contained file`, files: [] }
570
+ try {
571
+ const physical = fs.realpathSync(filename)
572
+ if (!fs.statSync(physical).isFile() || !isPathInside(fs.realpathSync(repositoryRoot), physical)) {
573
+ return { error: `${input.label} resolves outside the repository`, files: [] }
574
+ }
575
+ files.add(physical)
576
+ } catch {
577
+ return { error: `${input.label} does not resolve to a repository-contained file`, files: [] }
578
+ }
579
+ }
580
+ if (files.size > 20) return { error: 'retains more than 20 code-loading inputs', files: [] }
581
+ return { files: [...files].sort() }
582
+ }
583
+
584
+ /**
585
+ * Checks one retained directory selector without traversing it.
586
+ *
587
+ * @param {{label: string, value: string}} input input descriptor
588
+ * @param {string} projectRoot project root
589
+ * @param {string} repositoryRoot repository root
590
+ * @returns {string|undefined} validation error
591
+ */
592
+ function getContainedDirectoryError (input, projectRoot, repositoryRoot) {
593
+ try {
594
+ const physical = fs.realpathSync(path.resolve(projectRoot, input.value))
595
+ if (!fs.statSync(physical).isDirectory() || !isPathInside(fs.realpathSync(repositoryRoot), physical)) {
596
+ return `${input.label} resolves outside the repository`
597
+ }
598
+ } catch {
599
+ return `${input.label} does not resolve to a repository-contained directory`
600
+ }
601
+ }
602
+
603
+ /**
604
+ * Resolves one module or path without loading it.
605
+ *
606
+ * @param {{module: boolean, value: string}} input input descriptor
607
+ * @param {string} projectRoot project root
608
+ * @returns {string|undefined} resolved filename
609
+ */
610
+ function resolveInputFile (input, projectRoot) {
611
+ if (!input.value) return
612
+ if (!input.module || path.isAbsolute(input.value) || input.value.startsWith('.')) {
613
+ return path.resolve(projectRoot, input.value)
614
+ }
615
+ try {
616
+ return require.resolve(input.value, { paths: [projectRoot] })
617
+ } catch {}
618
+ }
619
+
620
+ /**
621
+ * Tokenizes bounded package-script evidence without expanding variables or invoking a shell.
622
+ *
623
+ * @param {string} command package script
624
+ * @returns {string[]} inert tokens
625
+ */
626
+ function tokenizeCommand (command) {
627
+ const tokens = []
628
+ for (const match of String(command || '').matchAll(/"([^"\r\n]*)"|'([^'\r\n]*)'|([^\s"']+)/g)) {
629
+ tokens.push(match[1] ?? match[2] ?? match[3])
630
+ }
631
+ return tokens
632
+ }
633
+
634
+ /**
635
+ * Returns the executable name for a framework.
636
+ *
637
+ * @param {string} framework framework name
638
+ * @returns {string} executable name
639
+ */
640
+ function getRunnerExecutableName (framework) {
641
+ if (framework === 'cucumber') return 'cucumber-js'
642
+ return framework === 'playwright' ? 'playwright' : framework
643
+ }
644
+
645
+ /**
646
+ * Checks physical path containment.
647
+ *
648
+ * @param {string} root root path
649
+ * @param {string} filename candidate path
650
+ * @returns {boolean} whether the candidate is contained
651
+ */
652
+ function isPathInside (root, filename) {
653
+ const relative = path.relative(path.resolve(root), path.resolve(filename))
654
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
655
+ }
656
+
657
+ module.exports = {
658
+ getProjectNodeRunner,
659
+ getRunnerArgsError,
660
+ getRunnerContract,
661
+ getRunnerEnvironmentError,
662
+ getRunnerInputError,
663
+ getRunnerSearchRoots,
664
+ }