dd-trace 5.117.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.
- package/ci/diagnose.js +162 -21
- package/ci/init.js +0 -1
- package/ci/runbook.md +187 -180
- package/ci/test-optimization-validation/approval-artifacts.js +3 -1
- package/ci/test-optimization-validation/approval.js +52 -15
- package/ci/test-optimization-validation/ci-command-candidate.js +26 -32
- package/ci/test-optimization-validation/ci-discovery.js +1 -1
- package/ci/test-optimization-validation/ci-package-scripts.js +141 -0
- package/ci/test-optimization-validation/ci-remediation.js +112 -39
- package/ci/test-optimization-validation/cli.js +516 -681
- package/ci/test-optimization-validation/command-blocker.js +188 -33
- package/ci/test-optimization-validation/command-output-policy.js +3 -26
- package/ci/test-optimization-validation/command-runner.js +189 -245
- package/ci/test-optimization-validation/environment.js +90 -0
- package/ci/test-optimization-validation/executable.js +159 -388
- package/ci/test-optimization-validation/framework-adapters/cucumber.js +119 -0
- package/ci/test-optimization-validation/framework-adapters/cypress.js +107 -0
- package/ci/test-optimization-validation/framework-adapters/playwright.js +181 -0
- package/ci/test-optimization-validation/generated-files.js +75 -13
- package/ci/test-optimization-validation/generated-test-contract.js +283 -0
- package/ci/test-optimization-validation/generated-verifier.js +49 -16
- package/ci/test-optimization-validation/literal-environment.js +57 -0
- package/ci/test-optimization-validation/manifest-loader.js +2 -22
- package/ci/test-optimization-validation/manifest-scaffold.js +816 -515
- package/ci/test-optimization-validation/manifest-schema.js +494 -702
- package/ci/test-optimization-validation/offline-fixtures.js +4 -1
- package/ci/test-optimization-validation/plan-writer.js +243 -984
- package/ci/test-optimization-validation/preflight-runner.js +49 -55
- package/ci/test-optimization-validation/redaction.js +2 -1
- package/ci/test-optimization-validation/report-writer.js +391 -1357
- package/ci/test-optimization-validation/result-semantics.js +86 -0
- package/ci/test-optimization-validation/runner-command.js +249 -0
- package/ci/test-optimization-validation/runner-contract.js +664 -0
- package/ci/test-optimization-validation/scenarios/auto-test-retries.js +29 -3
- package/ci/test-optimization-validation/scenarios/basic-reporting.js +240 -543
- package/ci/test-optimization-validation/scenarios/ci-wiring.js +450 -670
- package/ci/test-optimization-validation/scenarios/early-flake-detection.js +4 -3
- package/ci/test-optimization-validation/scenarios/helpers.js +67 -9
- package/ci/test-optimization-validation/scenarios/test-management.js +4 -3
- package/ci/test-optimization-validation/source-text.js +87 -0
- package/ci/test-optimization-validation/test-output.js +12 -22
- package/ext/tags.d.ts +1 -0
- package/index.d.ts +7 -0
- package/package.json +2 -2
- package/packages/datadog-instrumentations/src/helpers/hooks.js +2 -0
- package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/index.js +1 -0
- package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/openai-agents.js +31 -0
- package/packages/datadog-instrumentations/src/openai-agents.js +159 -0
- package/packages/datadog-plugin-openai-agents/src/index.js +74 -0
- package/packages/datadog-plugin-openai-agents/src/integration.js +503 -0
- package/packages/datadog-plugin-openai-agents/src/processor.js +108 -0
- package/packages/datadog-plugin-openai-agents/src/util.js +60 -0
- package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +48 -49
- package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +161 -33
- package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache-schema.js +6 -4
- package/packages/dd-trace/src/config/generated-config-types.d.ts +2 -0
- package/packages/dd-trace/src/config/supported-configurations.json +7 -0
- package/packages/dd-trace/src/encode/span-stats.js +7 -1
- package/packages/dd-trace/src/exporter.js +16 -9
- package/packages/dd-trace/src/llmobs/index.js +9 -1
- package/packages/dd-trace/src/llmobs/plugins/ai/util.js +104 -22
- package/packages/dd-trace/src/llmobs/plugins/openai/index.js +5 -9
- package/packages/dd-trace/src/llmobs/plugins/openai/utils.js +20 -0
- package/packages/dd-trace/src/llmobs/plugins/openai-agents/utils.js +321 -0
- package/packages/dd-trace/src/llmobs/sdk.js +2 -1
- package/packages/dd-trace/src/llmobs/span_processor.js +7 -2
- package/packages/dd-trace/src/llmobs/tagger.js +23 -3
- package/packages/dd-trace/src/llmobs/util.js +56 -3
- package/packages/dd-trace/src/openfeature/agentless_configuration_source.js +3 -6
- package/packages/dd-trace/src/openfeature/configuration_source.js +5 -8
- package/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_transformer.js +5 -1
- package/packages/dd-trace/src/plugins/ci_plugin.js +9 -3
- package/packages/dd-trace/src/plugins/index.js +1 -0
- package/packages/dd-trace/src/plugins/outbound.js +4 -1
- package/packages/dd-trace/src/span_processor.js +1 -1
- package/packages/dd-trace/src/span_stats.js +22 -4
- package/ci/test-optimization-validation/command-suitability.js +0 -471
- package/ci/test-optimization-validation/init-probe-preload.js +0 -163
- package/ci/test-optimization-validation/init-probe.js +0 -246
- package/ci/test-optimization-validation/late-initialization.js +0 -63
- package/ci/test-optimization-validation/local-command.js +0 -163
- package/ci/test-optimization-validation/setup-runner.js +0 -97
- 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
|
+
}
|