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,780 +1,560 @@
1
1
  'use strict'
2
2
 
3
- const fs = require('fs')
4
- const path = require('path')
3
+ const fs = require('node:fs')
4
+ const path = require('node:path')
5
5
 
6
6
  const { buildCiCommandCandidate } = require('../ci-command-candidate')
7
+ const { expandLocalPackageScripts } = require('../ci-package-scripts')
7
8
  const { buildCiRemediation } = require('../ci-remediation')
8
- const { getCommandBlocker } = require('../command-blocker')
9
- const { serializeCommand } = require('../command-runner')
10
9
  const { getFrameworkCiDiscoveryContradiction } = require('../ci-discovery')
11
- const { runInitializationProbe } = require('../init-probe')
12
- const { findLateInitialization } = require('../late-initialization')
13
- const { getCiWiringCommand } = require('../local-command')
14
- const { ensureSafeDirectory } = require('../safe-files')
15
- const { getMissingEventDiagnosis, summarizeTestOutput } = require('./basic-reporting')
16
- const {
17
- basicEventEvidence,
18
- error,
19
- fail,
20
- findInterestingLines,
21
- frameworkOutDir,
22
- hasAllBasicEventTypes,
23
- incomplete,
24
- inconclusive,
25
- pass,
26
- runInstrumentedCommand,
27
- tailInterestingLines,
28
- } = require('./helpers')
29
-
30
- const INCONCLUSIVE_CI_WIRING_FAILURES = new Set([
31
- 'package-manager-filesystem-blocked',
32
- 'package-manager-version-mismatch',
33
- 'watchman-filesystem-blocked',
34
- 'ci-wiring-command-failed-before-tests',
35
- 'ci-wiring-command-timed-out',
36
- 'ci-wiring-no-observed-tests',
37
- 'ci-wiring-project-filter-mismatch',
38
- 'ci-wiring-test-filter-mismatch',
39
- 'no-test-optimization-events',
40
- ])
41
-
42
- async function runCiWiring ({ manifest, framework, out, options, basicResult }) {
43
- const scenarioName = 'ci-wiring'
44
-
45
- try {
46
- const command = getCiWiringCommand(framework)
47
- if (!command) return getMissingCiWiringCommandResult(framework, manifest)
48
-
49
- const outDir = frameworkOutDir(out, framework, scenarioName)
50
- ensureSafeDirectory(out, outDir, 'CI wiring artifact directory')
51
- const baseEvidence = getCiWiringBaseEvidence({ framework, manifest, basicResult, command })
52
- const run = await runInstrumentedCommand({
53
- framework,
54
- out,
55
- scenarioName,
56
- command,
57
- options,
58
- ciWiring: true,
59
- })
60
- const { result, events } = run
61
-
62
- const ciWiringPreflight = getComparableCiWiringPreflight(framework, command)
63
- const evidence = {
64
- ...baseEvidence,
65
- commandExitCode: result.exitCode,
66
- commandTimedOut: result.timedOut,
67
- commandDescription: command.description,
68
- commandOutputSummary: summarizeTestOutput(result.stdout, result.stderr),
69
- ciCommandExecution: {
70
- mode: 'full-replay',
71
- fullReplayRan: true,
72
- },
73
- preflight: summarizePreflight(ciWiringPreflight),
74
- settingsLoadedFromCache: run.offline.inputs.settings?.status === 'loaded',
75
- offlineExporterCapture: {
76
- mode: run.offline.captureMode,
77
- completionCount: run.offline.completionCount,
78
- observedEventCount: run.offline.observedEventCount,
79
- retainedEventCount: run.offline.retainedEventCount,
80
- sampled: run.offline.sampled,
81
- },
82
- offlineExporterSummary: run.offline.summary,
83
- ...basicEventEvidence(events),
84
- }
85
-
86
- if (!hasAllBasicEventTypes(events)) {
87
- const commandFailure = summarizeCiCommandFailure(result, evidence)
88
- if (commandFailure.kind !== 'ci-wiring-command-result-unknown') {
89
- evidence.commandFailure = commandFailure
90
- }
91
- evidence.debugSignals = summarizeCiDebugSignals(result)
92
- const probe = await maybeRunInitializationProbe({ command, framework, options, outDir, result, evidence })
93
- if (probe.summary) evidence.initializationProbe = probe.summary
94
- evidence.monorepoFindings = getMonorepoFindings({ framework, command, probe: probe.summary })
95
- evidence.eventLevelFailure = getCiWiringEventFailure({ framework, result, evidence, basicResult })
96
- if (isInconclusiveCiWiringFailure(evidence.eventLevelFailure)) {
97
- return inconclusive(
98
- framework,
99
- scenarioName,
100
- evidence.eventLevelFailure.summary,
101
- evidence,
102
- outDir,
103
- probe.artifacts
104
- )
105
- }
106
- return fail(framework, scenarioName, evidence.eventLevelFailure.summary, evidence, outDir, probe.artifacts)
107
- }
108
-
109
- if (result.exitCode === 0) {
110
- return pass(
111
- framework,
112
- scenarioName,
113
- 'The CI test command emitted session, module, suite, and test events with the initialization configured ' +
114
- 'by CI.',
115
- evidence,
116
- outDir
117
- )
118
- }
119
-
120
- if (matchesPreflightExitCode(ciWiringPreflight, result.exitCode)) {
121
- evidence.commandExitMatchesPreflight = true
122
- return pass(
123
- framework,
124
- scenarioName,
125
- 'The CI test command emitted session, module, suite, and test events with the initialization configured ' +
126
- 'by CI. ' +
127
- `The command exited ${result.exitCode}, matching the dd-trace-less preflight run.`,
128
- evidence,
129
- outDir
130
- )
131
- }
132
-
133
- evidence.commandExitMatchesPreflight = false
134
- return fail(
135
- framework,
136
- scenarioName,
137
- `CI wiring emitted Test Optimization events, but the command exited ${result.exitCode}.`,
138
- evidence,
139
- outDir
140
- )
141
- } catch (err) {
142
- return error(framework, scenarioName, err)
143
- }
144
- }
145
-
146
- function isInconclusiveCiWiringFailure (failure) {
147
- return INCONCLUSIVE_CI_WIRING_FAILURES.has(failure.kind)
148
- }
149
-
150
- function getCiWiringBaseEvidence ({ framework, manifest, basicResult, command }) {
151
- return {
152
- commandDescription: command.description,
153
- ciCommandCandidate: buildCiCommandCandidate(framework),
154
- ciWiring: framework.ciWiring,
155
- ciRemediation: buildCiRemediation(framework),
156
- nodeOptionsRemoval: findNodeOptionsRemoval(framework, manifest),
157
- existingDatadogInitScripts: findDatadogInitScripts(manifest, framework),
158
- lateInitialization: findLateInitialization(manifest, framework),
159
- directInitializationBasicReporting: summarizeBasicReportingResult(basicResult),
160
- }
10
+ const { environmentNamesEqual } = require('../environment')
11
+ const { parseLiteralEnvironmentPrefix } = require('../literal-environment')
12
+ const { fail, incomplete } = require('./helpers')
13
+
14
+ const MAX_CI_FILE_BYTES = 512 * 1024
15
+ const DYNAMIC_COMMAND_PATTERN = /[$`;&|\r\n]|%[^%\s]+%|![^!\s]+!/
16
+ const RUNNER_PATTERNS = {
17
+ cucumber:
18
+ /^(?:(?:[^\s]*[/\\])?node(?:\.exe)?\s+)?(?:[^\s]*[/\\])?cucumber(?:-js)?(?:\.js)?(?:\s|$)/,
19
+ cypress: /^(?:(?:[^\s]*[/\\])?node(?:\.exe)?\s+)?(?:[^\s]*[/\\])?cypress(?:\.js)?\s+run(?:\s|$)/,
20
+ jest: /^(?:(?:[^\s]*[/\\])?node(?:\.exe)?\s+)?(?:[^\s]*[/\\])?jest(?:\.js)?(?:\s|$)/,
21
+ mocha: /^(?:(?:[^\s]*[/\\])?node(?:\.exe)?\s+)?(?:[^\s]*[/\\])?mocha(?:\.js)?(?:\s|$)/,
22
+ playwright: /^(?:(?:[^\s]*[/\\])?node(?:\.exe)?\s+)?(?:[^\s]*[/\\])?playwright(?:\.js)?\s+test(?:\s|$)/,
23
+ vitest: /^(?:(?:[^\s]*[/\\])?node(?:\.exe)?\s+)?(?:[^\s]*[/\\])?vitest(?:\.js)?\s+(?:run|--run)(?:\s|$)/,
161
24
  }
162
25
 
163
- async function maybeRunInitializationProbe ({ command, framework, options, outDir, result, evidence }) {
164
- if (result.timedOut === true || evidence.commandFailure?.blockedByExecutionEnvironment ||
165
- evidence.commandFailure?.toolchainBlocked) return {}
166
- if (!commandOutputShowsTestsRan(evidence.commandOutputSummary)) return {}
167
- if (evidence.nodeOptionsRemoval) {
168
- return {
169
- summary: {
170
- ran: false,
171
- skippedBecauseConfigurationProvesRemoval: true,
172
- reason: `The package script expansion ${evidence.nodeOptionsRemoval.command} explicitly removes ` +
173
- 'NODE_OPTIONS before the test runner starts.',
174
- },
175
- }
176
- }
177
-
178
- try {
179
- return await runInitializationProbe({
180
- command,
181
- framework,
182
- options,
183
- outDir,
184
- })
185
- } catch (err) {
186
- return {
187
- summary: {
188
- ran: false,
189
- error: err && err.message ? err.message : String(err),
190
- },
191
- }
192
- }
193
- }
194
-
195
- function getMissingCiWiringCommandResult (framework, manifest) {
26
+ /**
27
+ * Audits only structurally anchored, literal CI evidence.
28
+ *
29
+ * @param {object} input audit inputs
30
+ * @param {object} input.manifest validation manifest
31
+ * @param {object} input.framework framework entry
32
+ * @returns {object} CI audit result
33
+ */
34
+ function runCiWiring ({ manifest, framework }) {
196
35
  const contradiction = getFrameworkCiDiscoveryContradiction(framework, manifest)
197
36
  if (contradiction) {
198
- return incomplete(framework, 'ci-wiring',
199
- `CI wiring was not replayed: ${contradiction.reason} No live CI-wiring conclusion was reached.`, {
200
- ciCommandCandidate: buildCiCommandCandidate(framework),
201
- ciWiring: framework.ciWiring,
202
- ciDiscovery: contradiction.ciDiscovery,
203
- recommendation: contradiction.recommendation,
204
- })
37
+ return getIncomplete(framework, contradiction.reason, {
38
+ ciDiscovery: contradiction.ciDiscovery,
39
+ recommendation: contradiction.recommendation,
40
+ })
205
41
  }
206
42
 
207
- const ciWiring = framework.ciWiring
208
- const diagnosis = ciWiring?.replayBlocker ||
209
- ciWiring?.diagnosis ||
210
- ciWiring?.reason ||
211
- 'No replayable CI wiring command was provided in the manifest.'
212
- const ciRemediation = buildCiRemediation(framework)
43
+ const ci = framework.ciWiring || {}
213
44
  const evidence = {
214
45
  ciCommandCandidate: buildCiCommandCandidate(framework),
215
- ciWiring,
216
- ciRemediation,
217
- recommendation: 'Resolve the recorded CI replay blocker, then add ciWiringCommand and rerun validation.',
46
+ conclusion: 'incomplete',
47
+ domain: 'ci_configuration',
48
+ evidenceStrength: 'unknown',
218
49
  }
219
-
220
- return incomplete(
221
- framework,
222
- 'ci-wiring',
223
- `CI wiring was not replayed: ${diagnosis} No live CI-wiring conclusion was reached.`,
224
- evidence
225
- )
226
- }
227
-
228
- function getCiWiringEventFailure ({ framework, result, evidence, basicResult }) {
229
- const localFailure = getMissingEventDiagnosis({ framework, result, evidence })
230
- const testsRan = commandOutputShowsTestsRan(evidence.commandOutputSummary)
231
-
232
- if (testsRan) {
233
- return {
234
- ...localFailure,
235
- kind: 'ci-wiring-no-test-optimization-events',
236
- summary: getCiWiringTestsRanSummary({ basicResult, evidence, framework }),
237
- recommendation: getCiWiringTestsRanRecommendation({ basicResult, evidence, framework }),
238
- }
50
+ const missing = getMissingReviewFields(ci)
51
+ if (missing.length > 0) {
52
+ return getIncomplete(
53
+ framework,
54
+ `The CI audit is incomplete because ${[
55
+ `it is missing ${missing.join(', ')}`,
56
+ ...(ci.reviewComplete === true ? [] : ['the review is not marked complete']),
57
+ ].join(' and ')}.`,
58
+ {
59
+ ...evidence,
60
+ recommendation: 'Identify one exact CI test job and resolve inherited configuration and wrappers. Leave the ' +
61
+ 'result incomplete when that cannot be proven statically.',
62
+ }
63
+ )
239
64
  }
240
65
 
241
- const commandFailure = evidence.commandFailure || summarizeCiCommandFailure(result, evidence)
242
- if (commandFailure.kind !== 'ci-wiring-command-result-unknown') {
243
- return {
244
- ...localFailure,
245
- kind: commandFailure.kind,
246
- missingLevels: localFailure.missingLevels,
247
- signals: commandFailure.signals,
248
- summary: commandFailure.summary,
249
- recommendation: commandFailure.recommendation,
250
- }
66
+ const source = readCiSource(ci.configFile)
67
+ if (!source) {
68
+ return getIncomplete(framework, 'The recorded CI configuration file is unavailable or too large to verify.', {
69
+ ...evidence,
70
+ recommendation: 'Regenerate the manifest from the current checkout and review the CI file again.',
71
+ })
251
72
  }
252
-
253
- return {
254
- ...localFailure,
255
- summary: 'The CI-shaped command did not emit Test Optimization events, and the validator could not determine ' +
256
- 'from its output whether tests ran. Review the recorded stdout/stderr artifacts for the selected CI step.',
257
- recommendation: 'Verify the selected CI step is the real test step, then rerun after making the command output ' +
258
- 'or preflight evidence identify the test runner result.',
73
+ const jobSource = getSelectedJobSource(source, ci)
74
+ if (!jobSource ||
75
+ !containsExecutionCommand(jobSource, ci.command) ||
76
+ (ci.step && !containsLiteral(jobSource, ci.step))) {
77
+ return getIncomplete(
78
+ framework,
79
+ 'The recorded command and step could not be bound structurally to the selected job in the checksum-bound ' +
80
+ 'CI file.',
81
+ {
82
+ ...evidence,
83
+ recommendation: 'Record the exact YAML job key, literal step, and command from one supported CI job. Leave ' +
84
+ 'the audit incomplete when that job structure cannot be verified.',
85
+ }
86
+ )
259
87
  }
260
- }
261
88
 
262
- function getCiWiringTestsRanSummary ({ basicResult, evidence, framework }) {
263
- if (evidence.nodeOptionsRemoval) {
264
- return getNodeOptionsRemovalDiagnosis({ basicResult, evidence, framework })
89
+ const command = ci.command.trim()
90
+ const resolution = getRunnerResolution(command, framework, ci)
91
+ const normalizedSource = jobSource.replaceAll('\\', '/')
92
+ const hasInitialization = /dd-trace\/ci\/init(?:\.js)?\b/.test(normalizedSource)
93
+ const matrixRelevant = matrixAffectsCiFacts(jobSource, command)
94
+ const unresolved = classifyUnresolved(ci, resolution, matrixRelevant, jobSource)
95
+ const initialization = getInitializationFact(ci, hasInitialization)
96
+ const ciFacts = {
97
+ initialization,
98
+ matrix: {
99
+ status: matrixRelevant ? 'affects_relevant_configuration' : 'not_relevant_to_ci_facts',
100
+ },
101
+ runnerInvocation: {
102
+ ...(resolution.commandPath ? { commandPath: resolution.commandPath } : {}),
103
+ ...(resolution.lifecycleScripts?.length > 0
104
+ ? { lifecycleScripts: resolution.lifecycleScripts }
105
+ : {}),
106
+ ...(resolution.reason ? { reason: resolution.reason } : {}),
107
+ ...(resolution.resolvedCommand ? { resolvedCommand: resolution.resolvedCommand } : {}),
108
+ source: resolution.source,
109
+ status: resolution.status,
110
+ },
111
+ transport: getTransportFact(ci, jobSource),
112
+ unresolved,
113
+ }
114
+ evidence.ciFacts = ciFacts
115
+
116
+ const effectiveNodeOptions = resolution.status === 'confirmed'
117
+ ? getEffectiveNodeOptionsOverride(resolution.commandPath)
118
+ : undefined
119
+ const remediation = buildCiRemediation(framework)
120
+ if (resolution.status === 'confirmed' &&
121
+ effectiveNodeOptions !== undefined &&
122
+ !/dd-trace[\\/]ci[\\/]init(?:\.js)?\b/.test(effectiveNodeOptions) &&
123
+ unresolved.relevant.length === 0) {
124
+ return getFailure(
125
+ framework,
126
+ 'The statically resolved CI test path overrides NODE_OPTIONS without the dd-trace/ci/init preload, so Test ' +
127
+ 'Optimization is not initialized in the final runner.',
128
+ {
129
+ ...evidence,
130
+ ciRemediation: remediation,
131
+ conclusion: 'confirmed_misconfigured',
132
+ evidenceStrength: 'confirmed_static',
133
+ recommendation: remediation.summary,
134
+ }
135
+ )
265
136
  }
266
137
 
267
- const summary = 'The test command used by the CI job was identified and ran tests. When it ran with only the ' +
268
- 'environment and setup described by the CI job, no Test Optimization events reached the offline event artifact.'
269
- const probeSummary = getInitializationProbeSummary(evidence.initializationProbe, framework)
270
- const lateInitializationSummary = getLateInitializationSummary(evidence.lateInitialization)
271
-
272
- if (basicResult?.status === 'pass') {
273
- return `${summary}${lateInitializationSummary} ` +
274
- 'The same selected test command ' +
275
- 'reported test data when the ' +
276
- 'validator supplied the ' +
277
- 'required Datadog initialization directly, so this repository can report when dd-trace is initialized ' +
278
- `correctly.${probeSummary}`
138
+ if (initialization.status === 'missing' &&
139
+ resolution.status === 'confirmed' &&
140
+ unresolved.relevant.length === 0) {
141
+ return getFailure(
142
+ framework,
143
+ 'The checksum-bound CI job reaches the selected test framework through a bounded static command path, but ' +
144
+ 'does not configure the dd-trace/ci/init preload. Test Optimization is not initialized in that job.',
145
+ {
146
+ ...evidence,
147
+ ciRemediation: remediation,
148
+ conclusion: 'confirmed_misconfigured',
149
+ evidenceStrength: 'confirmed_static',
150
+ recommendation: remediation.summary,
151
+ }
152
+ )
279
153
  }
280
154
 
281
- return `${summary}${lateInitializationSummary}${probeSummary}`
282
- }
283
-
284
- function getCiWiringTestsRanRecommendation ({ basicResult, evidence, framework }) {
285
- const existingInitScripts = evidence.existingDatadogInitScripts || []
286
- const lateInitialization = evidence.lateInitialization || []
287
- const probeReachedTestRunner = evidence.initializationProbe?.ran === true &&
288
- evidence.initializationProbe.reachedTestRunnerProcess === true
289
- const nodeOptionsRemoval = evidence.nodeOptionsRemoval
290
- let recommendation
291
-
292
- if (nodeOptionsRemoval) {
293
- const source = nodeOptionsRemoval.scriptName && nodeOptionsRemoval.packageJson
294
- ? `Script \`${nodeOptionsRemoval.scriptName}\` in \`${nodeOptionsRemoval.packageJson}\``
295
- : 'The package script'
296
- recommendation = `${source} clears NODE_OPTIONS before the test runner starts. Remove the empty ` +
297
- '`NODE_OPTIONS=` assignment, or pass the CI-provided `-r dd-trace/ci/init` preload to the next command.'
298
- } else if (lateInitialization.length > 0) {
299
- const setupFiles = lateInitialization.map(finding => `\`${finding.setupFile}\``).join(', ')
300
- recommendation = `Move Test Optimization initialization out of Vitest setup file ${setupFiles}. ` +
301
- 'Vitest setup files run after the test runner starts, which is too late for dd-trace to instrument the ' +
302
- 'runner. Set `NODE_OPTIONS=-r dd-trace/ci/init` on the CI test command instead.'
303
- } else if (existingInitScripts.length > 0) {
304
- const scriptNames = existingInitScripts.map(script => `\`${script.name}\``).join(', ')
305
- recommendation = `The package already defines ${scriptNames} with the required ` +
306
- '`dd-trace/ci/init` preload. Update the identified CI test step to invoke that script, or copy its ' +
307
- '`NODE_OPTIONS` initialization into the CI test command.'
308
- } else if (probeReachedTestRunner) {
309
- recommendation = `${evidence.ciRemediation?.summary || buildCiRemediation(framework).summary} ` +
310
- 'The NODE_OPTIONS probe reached the test runner for this command shape, so no package-manager or wrapper ' +
311
- 'change is needed.'
312
- } else {
313
- recommendation = 'Verify that the CI workflow sets NODE_OPTIONS with dd-trace/ci/init for the final test ' +
314
- 'runner, and that any package manager, monorepo runner, or wrapper preserves it.'
155
+ if (resolution.status !== 'confirmed') {
156
+ const partial = initialization.status === 'missing'
157
+ ? 'The selected job has no visible dd-trace/ci/init preload. '
158
+ : ''
159
+ return getIncomplete(
160
+ framework,
161
+ `${partial}The CI audit remains incomplete because the selected command could not be resolved to the ` +
162
+ `${framework.framework} runner: ${resolution.reason}.`,
163
+ {
164
+ ...evidence,
165
+ recommendation: 'Resolve the remaining wrapper or dynamic command statically, or confirm the effective ' +
166
+ 'NODE_OPTIONS value in the final test process with DD_TRACE_DEBUG=1.',
167
+ }
168
+ )
315
169
  }
316
170
 
317
- if (basicResult?.status === 'pass' && !nodeOptionsRemoval && lateInitialization.length === 0 &&
318
- !probeReachedTestRunner) {
319
- return `${recommendation} Compare the passing direct-initialization command with the CI job command to find ` +
320
- 'where the Datadog setup differs.'
171
+ if (unresolved.relevant.length > 0) {
172
+ return getIncomplete(
173
+ framework,
174
+ 'The framework invocation was resolved statically, but relevant CI evidence remains unresolved: ' +
175
+ `${unresolved.relevant.join('; ')}.`,
176
+ {
177
+ ...evidence,
178
+ recommendation: 'Resolve only the listed configuration that can affect initialization, runner invocation, ' +
179
+ 'or transport. Unrelated runtime matrices do not need further analysis.',
180
+ }
181
+ )
321
182
  }
322
183
 
323
- return recommendation
324
- }
325
-
326
- function getNodeOptionsRemovalDiagnosis ({ basicResult, evidence, framework }) {
327
- const finding = evidence.nodeOptionsRemoval
328
- const frameworkName = getDisplayFrameworkName(framework.framework)
329
- const ciCommand = evidence.ciCommandCandidate?.command
330
- ? `When CI runs \`${evidence.ciCommandCandidate.command}\`, `
331
- : 'In the selected CI test job, '
332
- const source = finding.scriptName && finding.packageJson
333
- ? `script \`${finding.scriptName}\` in \`${finding.packageJson}\``
334
- : 'a package script'
335
- const directResult = basicResult?.status === 'pass'
336
- ? ` When the same ${frameworkName} test command runs with ` +
337
- '`NODE_OPTIONS=-r dd-trace/ci/init` supplied directly, it reports test data successfully.'
338
- : ''
339
-
340
- return 'The CI test command ran tests, but no Test Optimization events reached the offline event artifact. ' +
341
- `${ciCommand}` +
342
- `${source} expands to \`${finding.command}\`. The empty \`NODE_OPTIONS=\` assignment clears the Datadog ` +
343
- `preload before ${frameworkName} starts.${directResult}`
344
- }
345
-
346
- function findNodeOptionsRemoval (framework, manifest) {
347
- const commands = framework.ciWiring?.packageScriptExpansionChain || []
348
- for (const command of commands) {
349
- if (typeof command !== 'string') continue
350
- if (/(?:^|\s)NODE_OPTIONS\s*=\s*(?=\s|$)/.test(command) ||
351
- /(?:^|\s)unset\s+NODE_OPTIONS(?:\s|$)/.test(command) ||
352
- /(?:^|\s)env\s+-u\s+NODE_OPTIONS(?:\s|$)/.test(command)) {
353
- return {
354
- command,
355
- ...findPackageScriptSource(manifest, framework, command),
184
+ if (initialization.status !== 'configured') {
185
+ return getIncomplete(
186
+ framework,
187
+ 'Static evidence does not establish whether dd-trace/ci/init is configured for the selected test path.',
188
+ {
189
+ ...evidence,
190
+ recommendation: 'Record the literal NODE_OPTIONS configuration for this job or rerun the CI step with ' +
191
+ 'DD_TRACE_DEBUG=1.',
356
192
  }
357
- }
193
+ )
358
194
  }
359
- }
360
195
 
361
- function findPackageScriptSource (manifest, framework, command) {
362
- const roots = new Set([manifest?.repository?.root, framework.project?.root].filter(Boolean))
363
- for (const root of roots) {
364
- const packageJsonPath = path.join(root, 'package.json')
365
- let packageJson
366
- try {
367
- packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
368
- } catch {
369
- continue
370
- }
371
-
372
- for (const [scriptName, scriptCommand] of Object.entries(packageJson.scripts || {})) {
373
- if (scriptCommand === command) return { packageJson: packageJsonPath, scriptName }
374
- }
196
+ if (ci.transport?.mode === 'agentless' &&
197
+ /DD_CIVISIBILITY_AGENTLESS_ENABLED/.test(jobSource) &&
198
+ !/\b(?:DD_API_KEY|DATADOG_API_KEY)\b/.test(jobSource)) {
199
+ return getIncomplete(
200
+ framework,
201
+ 'The selected job visibly enables agentless reporting but has no Datadog API key reference in the ' +
202
+ 'checksum-bound CI file. The key may still be injected outside this file.',
203
+ {
204
+ ...evidence,
205
+ ciRemediation: remediation,
206
+ recommendation: 'Confirm that DD_API_KEY reaches this test job from the CI secret store.',
207
+ }
208
+ )
375
209
  }
376
- return {}
377
- }
378
210
 
379
- function getLateInitializationSummary (findings) {
380
- if (!Array.isArray(findings) || findings.length === 0) return ''
381
- const setupFiles = findings.map(finding => `\`${finding.setupFile}\``).join(', ')
382
- return ' Static configuration inspection found Test Optimization initialization in Vitest setup file ' +
383
- `${setupFiles}. Vitest loads setup files after the runner starts, so this initialization is too late to ` +
384
- 'instrument the test runner.'
211
+ return getIncomplete(
212
+ framework,
213
+ 'The selected CI job contains Test Optimization initialization, but static inspection cannot prove that the ' +
214
+ 'effective environment and reporting transport reach the final process at runtime.',
215
+ {
216
+ ...evidence,
217
+ conclusion: 'configured_propagation_unverified',
218
+ evidenceStrength: 'inferred_static',
219
+ recommendation: 'Rerun this exact CI step with DD_TRACE_DEBUG=1 and confirm initialization in the final test ' +
220
+ 'runner.',
221
+ }
222
+ )
385
223
  }
386
224
 
387
225
  /**
388
- * Finds package scripts that already set the required Test Optimization preload.
226
+ * Returns the final inline NODE_OPTIONS override along the resolved command path.
389
227
  *
390
- * @param {object|undefined} manifest normalized validation manifest
391
- * @param {object} framework manifest framework entry
392
- * @returns {{name: string, packageJson: string}[]} matching package scripts
228
+ * @param {string[]} commandPath selected command and expanded package scripts
229
+ * @returns {string|undefined} final literal override
393
230
  */
394
- function findDatadogInitScripts (manifest, framework) {
395
- const roots = new Set([framework.project?.root, manifest?.repository?.root].filter(Boolean))
396
- const scripts = []
397
-
398
- for (const root of roots) {
399
- let packageJson
400
- try {
401
- packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
402
- } catch {
403
- continue
404
- }
405
-
406
- for (const [name, command] of Object.entries(packageJson.scripts || {})) {
407
- if (typeof command !== 'string' || !/\bNODE_OPTIONS\s*=.*\bdd-trace\/ci\/init\b/.test(command)) {
408
- continue
409
- }
410
- scripts.push({
411
- name,
412
- packageJson: path.join(root, 'package.json'),
413
- })
231
+ function getEffectiveNodeOptionsOverride (commandPath = []) {
232
+ let value
233
+ for (const command of commandPath) {
234
+ for (const assignment of normalizeDirectCommand(command).assignments) {
235
+ if (environmentNamesEqual(assignment.name, 'NODE_OPTIONS')) value = assignment.value
414
236
  }
415
237
  }
416
-
417
- return scripts
238
+ return value
418
239
  }
419
240
 
420
- function summarizeCiCommandFailure (result, evidence) {
421
- const output = `${result.stdout || ''}\n${result.stderr || ''}`
422
- const testsRan = commandOutputShowsTestsRan(evidence.commandOutputSummary || [])
423
- const common = {
424
- exitCode: result.exitCode,
425
- stderrExcerpt: tailInterestingLines(result.stderr),
426
- stdoutExcerpt: tailInterestingLines(result.stdout),
427
- }
428
-
429
- if (testsRan) {
241
+ /**
242
+ * Resolves a direct runner or a bounded chain of local package scripts.
243
+ *
244
+ * @param {string} command selected CI command
245
+ * @param {object} framework framework manifest entry
246
+ * @param {object} ci selected CI evidence
247
+ * @returns {object} static runner resolution
248
+ */
249
+ function getRunnerResolution (command, framework, ci) {
250
+ if (getDirectRunner(command, framework.framework)) {
430
251
  return {
431
- ...common,
432
- kind: 'ci-wiring-command-result-unknown',
433
- signals: [],
434
- summary: 'The CI-shaped command ran tests; missing Test Optimization events are reported separately.',
435
- recommendation: 'Review CI wiring event failure evidence.',
252
+ commandPath: [command],
253
+ resolvedCommand: command,
254
+ source: 'direct_ci_command',
255
+ status: 'confirmed',
436
256
  }
437
257
  }
438
258
 
439
- const commandBlocker = getCommandBlocker(result)
440
- if (commandBlocker) return { ...common, ...commandBlocker }
441
-
442
- const preloadFailure = detectDatadogPreloadResolutionFailure(output)
443
- if (preloadFailure) {
259
+ if (!ci.workingDirectory ||
260
+ path.resolve(ci.workingDirectory) !== path.resolve(framework.project.root)) {
444
261
  return {
445
- ...common,
446
- kind: 'ci-wiring-preload-resolution-failed',
447
- signals: preloadFailure.signals,
448
- summary: 'The CI-shaped command failed before tests started because Node could not resolve the Test ' +
449
- 'Optimization preload `dd-trace/ci/init` from the command working directory.',
450
- recommendation: 'Make sure `dd-trace` is installed where the CI command starts, or run the CI command from ' +
451
- 'the package working directory that can resolve `dd-trace/ci/init`. After the preload resolves, rerun CI ' +
452
- 'wiring validation to check whether the required Datadog setup reaches the final test runner.',
262
+ reason: ci.workingDirectory
263
+ ? 'the selected working directory does not match the approval-bound project package'
264
+ : 'the selected package-script command has no approval-bound effective working directory',
265
+ source: 'unresolved_wrapper',
266
+ status: 'unresolved',
453
267
  }
454
268
  }
455
-
456
- if (result.timedOut === true) {
269
+ const scripts = readProjectScripts(framework.project.packageJson)
270
+ if (!scripts) {
457
271
  return {
458
- ...common,
459
- kind: 'ci-wiring-command-timed-out',
460
- signals: [],
461
- summary: 'The CI-shaped command timed out before the validator could observe Test Optimization events.',
462
- recommendation: 'Choose a smaller representative CI test command or record the setup needed to make the ' +
463
- 'selected command complete within the validation timeout.',
272
+ reason: 'the approval-bound project package.json is unavailable or invalid',
273
+ source: 'unresolved_wrapper',
274
+ status: 'unresolved',
464
275
  }
465
276
  }
466
-
467
- if (result.exitCode !== 0 && !testsRan) {
468
- const termination = Number.isInteger(result.exitCode) ? `exited ${result.exitCode}` : 'failed'
469
- const projectFilterMismatch = output.match(/No projects matched the filter\s+["']([^"']+)["']/i)
470
- if (projectFilterMismatch) {
471
- return {
472
- ...common,
473
- kind: 'ci-wiring-project-filter-mismatch',
474
- signals: [projectFilterMismatch[0]],
475
- summary: `The CI-shaped command ${termination} before tests because its added project filter ` +
476
- `\`${projectFilterMismatch[1]}\` is not exposed by the configuration loaded from the CI working ` +
477
- 'directory. No CI wiring conclusion was reached.',
478
- recommendation: 'Remove the invented project selector. Choose a representative test from a project the ' +
479
- 'original CI command actually loads, or mark CI replay unavailable when the real CI wrapper cannot be ' +
480
- 'focused safely.',
481
- }
482
- }
483
-
484
- if (/No test files found/i.test(output)) {
485
- return {
486
- ...common,
487
- kind: 'ci-wiring-test-filter-mismatch',
488
- signals: findInterestingLines(output, [/No test files found/, /filter:/, /include:/]),
489
- summary: `The CI-shaped command ${termination} before tests because its focused test filter matched no ` +
490
- 'files in the project configuration loaded by CI. No CI wiring conclusion was reached.',
491
- recommendation: 'Choose a real test included by the exact CI-loaded project and use it consistently for ' +
492
- 'Basic Reporting and CI wiring. If the CI command cannot be focused without changing its project, cwd, ' +
493
- 'or wrapper chain, mark CI replay unavailable.',
494
- }
495
- }
496
-
497
- const buildErrors = findInterestingLines(output, [
498
- /Cannot find module/,
499
- /Module not found/,
500
- /Error \[ERR_MODULE_NOT_FOUND\]/,
501
- /Could not resolve /,
502
- /command not found/,
503
- ])
504
-
277
+ const expansion = expandLocalPackageScripts(command, scripts)
278
+ if (expansion.error) {
505
279
  return {
506
- ...common,
507
- buildErrors,
508
- kind: 'ci-wiring-command-failed-before-tests',
509
- signals: buildErrors,
510
- summary: `The CI-shaped command ${termination} before the validator observed any tests running. ` +
511
- 'No CI wiring conclusion about Test Optimization initialization was reached for this command.',
512
- recommendation: 'Fix or document the command/setup failure first. CI wiring can only be interpreted after ' +
513
- 'the selected CI-shaped command reaches the test runner.',
280
+ lifecycleScripts: expansion.lifecycleScripts,
281
+ reason: expansion.error,
282
+ source: 'local_package_script',
283
+ status: 'unresolved',
514
284
  }
515
285
  }
516
-
517
- if (result.exitCode === 0 && !testsRan) {
286
+ const candidate = expansion.terminals.find(terminal => {
287
+ return getDirectRunner(terminal.command, framework.framework)
288
+ })
289
+ if (!candidate) {
518
290
  return {
519
- ...common,
520
- kind: 'ci-wiring-no-observed-tests',
521
- signals: [],
522
- summary: 'The CI-shaped command exited 0, but the validator did not observe test-runner output or Test ' +
523
- 'Optimization events.',
524
- recommendation: 'Verify that the selected CI step actually runs tests. If it is a wrapper with unusual ' +
525
- 'output, record preflight observedTestCount or choose a representative command whose output identifies ' +
526
- 'the test result.',
291
+ lifecycleScripts: expansion.lifecycleScripts,
292
+ reason: 'no bounded local package-script path reaches the selected framework runner',
293
+ source: 'unresolved_wrapper',
294
+ status: 'unresolved',
527
295
  }
528
296
  }
529
-
530
297
  return {
531
- ...common,
532
- kind: 'ci-wiring-command-result-unknown',
533
- signals: [],
534
- summary: 'The CI-shaped command result did not explain why Test Optimization events were missing.',
535
- recommendation: 'Review stdout, stderr, and debug lines for the selected CI-shaped command.',
298
+ commandPath: candidate.path,
299
+ lifecycleScripts: expansion.lifecycleScripts,
300
+ resolvedCommand: candidate.command,
301
+ source: 'local_package_script',
302
+ status: 'confirmed',
536
303
  }
537
304
  }
538
305
 
539
- function getComparableCiWiringPreflight (framework, command) {
540
- if (framework.ciWiringPreflight?.ran === true) {
541
- return {
542
- ...framework.ciWiringPreflight,
543
- source: 'ciWiringPreflight',
544
- }
545
- }
306
+ function readProjectScripts (filename) {
307
+ try {
308
+ const stat = fs.lstatSync(filename)
309
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_CI_FILE_BYTES) return
310
+ const packageJson = JSON.parse(fs.readFileSync(filename, 'utf8'))
311
+ if (!packageJson.scripts || typeof packageJson.scripts !== 'object' ||
312
+ Array.isArray(packageJson.scripts)) return {}
313
+ return packageJson.scripts
314
+ } catch {}
315
+ }
546
316
 
547
- if (commandsHaveSameExecutionShape(command, framework.existingTestCommand)) {
548
- return {
549
- ...framework.preflight,
550
- source: 'existingTestCommand',
551
- }
317
+ function getInitializationFact (ci, hasInitialization) {
318
+ if (ci.initialization?.status === 'configured' && hasInitialization) {
319
+ return { status: 'configured', evidence: ci.initialization.evidence }
320
+ }
321
+ if (ci.initialization?.status === 'not_configured' && !hasInitialization) {
322
+ return { status: 'missing', evidence: ci.initialization.evidence }
552
323
  }
553
-
554
324
  return {
555
- ran: false,
556
- reason: 'No dd-trace-less preflight result was recorded for the selected CI wiring command shape.',
325
+ status: 'unresolved',
326
+ reason: 'recorded initialization status and checksum-bound CI source do not establish the same conclusion',
557
327
  }
558
328
  }
559
329
 
560
- function commandsHaveSameExecutionShape (left, right) {
561
- if (!left || !right) return false
562
- if (left.cwd !== right.cwd) return false
563
- if (Boolean(left.usesShell) !== Boolean(right.usesShell)) return false
564
- return serializeCommand(left) === serializeCommand(right)
565
- }
566
-
567
- function detectDatadogPreloadResolutionFailure (output) {
568
- if (!/dd-trace(?:\/ci\/init)?/.test(output)) return null
569
- if (!/MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND|Cannot find module|Cannot find package/.test(output)) return null
570
-
571
- const signals = findInterestingLines(output, [
572
- /Cannot find module ['"]dd-trace\/ci\/init['"]/,
573
- /Cannot find package ['"]dd-trace['"]/,
574
- /Error \[ERR_MODULE_NOT_FOUND\].*dd-trace\/ci\/init/,
575
- /MODULE_NOT_FOUND/,
576
- /internal\/preload/,
577
- ], 8)
578
-
579
- return { signals }
580
- }
581
-
582
- function summarizeCiDebugSignals (result) {
583
- const output = `${result.stdout || ''}\n${result.stderr || ''}`
584
- const lines = findInterestingLines(output, [
585
- /dd-trace/i,
586
- /datadog/i,
587
- /ci visibility/i,
588
- /test optimization/i,
589
- /ECONNREFUSED/,
590
- /ECONNRESET/,
591
- /ETIMEDOUT/,
592
- /socket hang up/,
593
- /failed to send/i,
594
- /writer/i,
595
- ], 12)
596
-
597
- return {
598
- debugEnvEnabled: true,
599
- lines,
330
+ function getTransportFact (ci, jobSource) {
331
+ const mode = ci.transport?.mode
332
+ if (mode === 'agentless' &&
333
+ /DD_CIVISIBILITY_AGENTLESS_ENABLED/.test(jobSource) &&
334
+ !/\b(?:DD_API_KEY|DATADOG_API_KEY)\b/.test(jobSource)) {
335
+ return { status: 'credentials_unverified', mode }
600
336
  }
337
+ if (mode === 'agent' || mode === 'agentless') {
338
+ return { status: 'configured', mode, evidence: ci.transport.evidence }
339
+ }
340
+ if (mode === 'none') return { status: 'missing', mode }
341
+ return { status: 'unresolved', mode: mode || 'unknown' }
601
342
  }
602
343
 
603
- function summarizeBasicReportingResult (basicResult) {
604
- if (!basicResult) {
605
- return {
606
- ran: false,
607
- reason: 'Basic Reporting was not run before CI wiring.',
344
+ function classifyUnresolved (ci, resolution, matrixRelevant, jobSource) {
345
+ const relevant = []
346
+ const ignored = []
347
+ const unresolved = Array.isArray(ci.unresolved) ? ci.unresolved : []
348
+ const githubHosted = /[/\\]\.github[/\\]workflows[/\\]/.test(ci.configFile) &&
349
+ /^\s*runs-on:\s*(?:ubuntu|windows|macos)-/mi.test(jobSource)
350
+
351
+ for (const item of unresolved) {
352
+ const isMatrix = /\bmatrix\b/i.test(item)
353
+ const isResolvedPackagePath = resolution.source === 'local_package_script' &&
354
+ /\b(?:lifecycle|npm|package script|pnpm|pretest|posttest|yarn)\b/i.test(item)
355
+ const isAmbientGithubSettings = githubHosted &&
356
+ /\b(?:repository|organization|environment)[-\s,\w]*\b(?:secrets?|variables?)\b/i.test(item) &&
357
+ /\b(?:inject|inherit|outside)\b/i.test(item)
358
+ const isOtherJob = /^Other jobs?\b/i.test(item) ||
359
+ /\bsecond\b.*\bjob\b.*\bonly\b.*\bselected\b/i.test(item) ||
360
+ /\brelease\b.*\bcontains no test job\b/i.test(item)
361
+ if ((isMatrix && !matrixRelevant) ||
362
+ isResolvedPackagePath ||
363
+ isAmbientGithubSettings ||
364
+ isOtherJob) {
365
+ ignored.push(item)
366
+ } else {
367
+ relevant.push(item)
608
368
  }
609
369
  }
610
-
611
- return {
612
- ran: true,
613
- status: basicResult.status,
614
- diagnosis: basicResult.diagnosis,
370
+ if (ci.reviewComplete !== true && unresolved.length === 0) {
371
+ relevant.push('the CI evidence review is not marked complete')
615
372
  }
373
+ return { ignored, relevant }
616
374
  }
617
375
 
618
- function getInitializationProbeSummary (probe, framework) {
619
- if (!probe || probe.ran !== true) return ''
376
+ function matrixAffectsCiFacts (jobSource, command) {
377
+ const matrixReference = /\bmatrix\s*[.[]/
378
+ if (!matrixReference.test(jobSource)) return false
379
+ if (matrixReference.test(command)) return true
380
+ return jobSource.split(/\r?\n/).some(line => {
381
+ return matrixReference.test(line) &&
382
+ /\b(?:container|DD_[A-Z0-9_]+|DATADOG_[A-Z0-9_]+|NODE_OPTIONS|runs-on|shell|working-directory)\b/i.test(line)
383
+ })
384
+ }
620
385
 
621
- const frameworkName = getDisplayFrameworkName(framework.framework)
622
- if (!probe.reachedAnyNodeProcess) {
623
- return ' The initialization probe did not reach any Node.js process in the CI command.'
624
- }
386
+ /**
387
+ * Locates a direct runner only at the executable position after literal environment assignments.
388
+ *
389
+ * @param {string} command selected CI command
390
+ * @param {string} framework framework name
391
+ * @returns {{index: number}|undefined} direct runner location
392
+ */
393
+ function getDirectRunner (command, framework) {
394
+ if (DYNAMIC_COMMAND_PATTERN.test(command)) return
395
+ const { index, source } = normalizeDirectCommand(command)
396
+ if (!RUNNER_PATTERNS[framework]?.test(source)) return
397
+ return { index }
398
+ }
625
399
 
626
- if (probe.reachedTestRunnerProcess) {
627
- return ` The NODE_OPTIONS probe reached a ${frameworkName} process, so NODE_OPTIONS can reach the test ` +
628
- 'runner in this command shape; inspect whether the CI workflow actually configures the required Datadog ' +
629
- 'initialization and environment.'
630
- }
400
+ function normalizeDirectCommand (command) {
401
+ const prefix = parseLiteralEnvironmentPrefix(command)
402
+ const assignments = [...prefix.assignments]
403
+ let source = String(command).slice(prefix.length).replace(/^(?:c8|nyc)(?:\.cmd)?\s+/, '')
404
+ if (/^cross-env(?:\.cmd)?\s+/.test(source)) {
405
+ source = source.replace(/^cross-env(?:\.cmd)?\s+/, '')
406
+ const crossEnv = parseLiteralEnvironmentPrefix(source)
407
+ assignments.push(...crossEnv.assignments)
408
+ source = source.slice(crossEnv.length)
409
+ }
410
+ return { assignments, index: prefix.length, source }
411
+ }
631
412
 
632
- const wrappers = formatToolNames([...probe.wrapperSignals, ...probe.packageManagerSignals])
633
- if (wrappers) {
634
- return ` The NODE_OPTIONS probe reached ${wrappers}, but it did not appear to reach a ${frameworkName} ` +
635
- 'process. This usually means a package manager, monorepo runner, or wrapper removes NODE_OPTIONS before ' +
636
- 'the tests start.'
413
+ /**
414
+ * Returns the selected YAML job block, or undefined when structural binding is unsupported.
415
+ *
416
+ * @param {string} source CI configuration source
417
+ * @param {object} ci selected CI evidence
418
+ * @returns {string|undefined} selected job source
419
+ */
420
+ function getSelectedJobSource (source, ci) {
421
+ if (!/\.ya?ml$/i.test(String(ci.configFile || ''))) return
422
+ const lines = source.replaceAll('\r\n', '\n').split('\n')
423
+ const jobName = normalizeYamlKey(ci.job)
424
+ if (!jobName) return
425
+
426
+ const jobsIndex = lines.findIndex(line => /^jobs:\s*(?:#.*)?$/.test(line))
427
+ if (jobsIndex !== -1) {
428
+ const jobsEnd = findYamlBlockEnd(lines, jobsIndex, 0)
429
+ const entries = []
430
+ for (let index = jobsIndex + 1; index < jobsEnd; index++) {
431
+ const entry = getYamlKeyEntry(lines[index], index)
432
+ if (entry) entries.push(entry)
433
+ }
434
+ const jobIndent = Math.min(...entries.map(entry => entry.indent))
435
+ const selected = entries.find(entry => entry.indent === jobIndent && entry.key === jobName)
436
+ if (selected) return getYamlBlock(lines, selected.index, selected.indent)
437
+ return
637
438
  }
638
439
 
639
- return ` The NODE_OPTIONS probe reached a Node.js process, but it did not appear to reach a ${frameworkName} ` +
640
- 'process.'
440
+ if (!/^\.gitlab-ci\.ya?ml$/i.test(path.basename(ci.configFile))) return
441
+ const selected = lines
442
+ .map((line, index) => getYamlKeyEntry(line, index))
443
+ .find(entry => entry?.indent === 0 && entry.key === jobName)
444
+ if (selected) return getYamlBlock(lines, selected.index, selected.indent)
641
445
  }
642
446
 
643
- function getMonorepoFindings ({ framework, command, probe }) {
644
- const findings = []
645
- const commandText = [
646
- command.description,
647
- command.usesShell ? command.shellCommand : command.argv?.join(' '),
648
- framework.ciWiring?.diagnosis,
649
- ...(framework.ciWiring?.runnerToolChain || []),
650
- ...(framework.ciWiring?.toolChain || []),
651
- ...(framework.ciWiring?.commandChain || []),
652
- ].filter(Boolean).join('\n')
653
-
654
- if (/\bnx\b/i.test(commandText) || hasProbeTool(probe, 'nx')) {
655
- findings.push({
656
- id: 'nx-executor-env-forwarding',
657
- tool: 'nx',
658
- reason: 'Nx executors and wrapper scripts can sit between the CI command and the final test runner.',
659
- recommendation: 'Verify that NODE_OPTIONS and Datadog environment variables are preserved by every Nx ' +
660
- 'target, executor, and wrapper that spawns the test runner.',
661
- })
447
+ function getYamlKeyEntry (line, index) {
448
+ if (!line || /^\s*(?:#|$)/.test(line) || /^\s/.test(line) && line.includes('\t')) return
449
+ const match = /^(\s*)(?:"([^"]+)"|'([^']+)'|([^:#][^:]*)):\s*(?:#.*)?$/.exec(line)
450
+ if (!match) return
451
+ return {
452
+ indent: match[1].length,
453
+ index,
454
+ key: String(match[2] ?? match[3] ?? match[4]).trim(),
662
455
  }
456
+ }
663
457
 
664
- if (/\bturbo(?:repo)?\b/i.test(commandText) || hasProbeTool(probe, 'turbo')) {
665
- findings.push({
666
- id: 'turbo-env-pass-through',
667
- tool: 'turbo',
668
- reason: 'Turborepo can filter environment variables for tasks.',
669
- recommendation: 'Verify turbo.json pass-through settings preserve NODE_OPTIONS and required DD_* variables ' +
670
- 'for test tasks.',
671
- })
458
+ function normalizeYamlKey (value) {
459
+ const key = String(value || '').trim().replace(/:\s*$/, '').trim()
460
+ if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
461
+ return key.slice(1, -1)
672
462
  }
463
+ return key
464
+ }
673
465
 
674
- if (/\blage\b/i.test(commandText) || hasProbeTool(probe, 'lage')) {
675
- findings.push({
676
- id: 'lage-env-forwarding',
677
- tool: 'lage',
678
- reason: 'Lage can run package scripts through an intermediate task process.',
679
- recommendation: 'Verify the Lage task and any package script it invokes preserve NODE_OPTIONS and required ' +
680
- 'DD_* variables for the final test runner.',
681
- })
682
- }
466
+ function getYamlBlock (lines, start, indent) {
467
+ return lines.slice(start, findYamlBlockEnd(lines, start, indent)).join('\n')
468
+ }
683
469
 
684
- if (probe?.reachedAnyNodeProcess && !probe.reachedTestRunnerProcess && !findNodeOptionsRemoval(framework)) {
685
- findings.push({
686
- id: 'node-options-not-observed-in-test-runner',
687
- tool: 'node',
688
- reason: 'The NODE_OPTIONS probe reached an intermediate Node.js process but not the detected test runner.',
689
- recommendation: 'Trace the command chain from the CI step to the test runner and find where NODE_OPTIONS is ' +
690
- 'removed or replaced.',
691
- })
470
+ function findYamlBlockEnd (lines, start, indent) {
471
+ for (let index = start + 1; index < lines.length; index++) {
472
+ if (/^\s*(?:#|$)/.test(lines[index])) continue
473
+ const nextIndent = /^\s*/.exec(lines[index])[0].length
474
+ if (nextIndent <= indent) return index
692
475
  }
693
-
694
- return findings
476
+ return lines.length
695
477
  }
696
478
 
697
479
  /**
698
- * @param {object|undefined} probe
699
- * @param {string} name
480
+ * Returns missing fields required for a conclusive static audit.
481
+ *
482
+ * @param {object} ci CI evidence
483
+ * @returns {string[]} missing field labels
700
484
  */
701
- function hasProbeTool (probe, name) {
702
- const signals = [
703
- ...(probe?.wrapperSignals || []),
704
- ...(probe?.packageManagerSignals || []),
705
- ...(probe?.testRunnerSignals || []),
706
- ]
707
- return signals.some(signal => signal.name === name)
485
+ function getMissingReviewFields (ci) {
486
+ return [
487
+ ['CI file', ci.configFile],
488
+ ['job', ci.job],
489
+ ['exact command', ci.command],
490
+ ].filter(([, value]) => typeof value !== 'string' || !value.trim()).map(([label]) => label)
708
491
  }
709
492
 
710
493
  /**
711
- * @param {Array<{ name?: string }>} signals
494
+ * Reads one bounded regular CI file.
495
+ *
496
+ * @param {string} filename CI file
497
+ * @returns {string|undefined} source
712
498
  */
713
- function formatToolNames (signals) {
714
- const names = []
715
- const seen = new Set()
716
-
717
- for (const signal of signals) {
718
- if (!signal.name || seen.has(signal.name)) continue
719
- seen.add(signal.name)
720
- names.push(signal.name)
721
- }
722
-
723
- if (names.length === 0) return ''
724
- if (names.length === 1) return names[0]
725
- return `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`
499
+ function readCiSource (filename) {
500
+ try {
501
+ const stat = fs.lstatSync(filename)
502
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_CI_FILE_BYTES) return
503
+ return fs.readFileSync(filename, 'utf8')
504
+ } catch {}
726
505
  }
727
506
 
728
507
  /**
729
- * @param {string|undefined} frameworkName
508
+ * Checks whether recorded text appears literally after line-ending normalization.
509
+ *
510
+ * @param {string} source file source
511
+ * @param {string} value recorded value
512
+ * @returns {boolean} literal presence
730
513
  */
731
- function getDisplayFrameworkName (frameworkName) {
732
- return {
733
- cucumber: 'Cucumber',
734
- cypress: 'Cypress',
735
- jest: 'Jest',
736
- mocha: 'Mocha',
737
- playwright: 'Playwright',
738
- vitest: 'Vitest',
739
- }[frameworkName] || frameworkName || 'test runner'
514
+ function containsLiteral (source, value) {
515
+ const normalizedSource = source.replaceAll('\r\n', '\n')
516
+ const normalizedValue = String(value).replaceAll('\r\n', '\n').trim()
517
+ return normalizedValue !== '' && normalizedSource.includes(normalizedValue)
740
518
  }
741
519
 
742
- function commandOutputShowsTestsRan (lines) {
743
- return lines.some(line => {
744
- return /\b\d+\s+(?:passing|passed|failing|failed)\b/i.test(line) ||
745
- /\btests?\b.*\b(?:passed|failed)\b/i.test(line) ||
746
- /\bSuccessfully ran target\b.*\btest\b/i.test(line) ||
747
- /\bsuccess:\s*[1-9]\d*\b/i.test(line) ||
748
- /\bfailed:\s*[1-9]\d*\b/i.test(line) ||
749
- /\bTasks:\s*[1-9]\d*\s+successful\b/i.test(line)
520
+ function containsExecutionCommand (source, value) {
521
+ const command = String(value).replaceAll('\r\n', '\n').trim()
522
+ if (!command || command.includes('\n')) return false
523
+ return source.split(/\r?\n/).some(line => {
524
+ if (/^\s*#/.test(line)) return false
525
+ const match = /^\s*(?:-\s*)?(?:run|script):\s*(.*?)\s*$/.exec(line)
526
+ return match?.[1] === command
750
527
  })
751
528
  }
752
529
 
753
- function matchesPreflightExitCode (preflight, exitCode) {
754
- return preflight?.ran === true &&
755
- Number.isInteger(preflight.exitCode) &&
756
- preflight.exitCode === exitCode
530
+ /**
531
+ * Builds an incomplete CI result.
532
+ *
533
+ * @param {object} framework framework entry
534
+ * @param {string} diagnosis diagnosis
535
+ * @param {object} evidence evidence
536
+ * @returns {object} result
537
+ */
538
+ function getIncomplete (framework, diagnosis, evidence) {
539
+ return incomplete(framework, 'ci-wiring', diagnosis, {
540
+ ciWiring: framework.ciWiring,
541
+ ...evidence,
542
+ })
757
543
  }
758
544
 
759
- function summarizePreflight (preflight) {
760
- if (!preflight || preflight.ran !== true) {
761
- return {
762
- ran: false,
763
- reason: preflight?.reason || 'No dd-trace-less preflight result was recorded in the manifest.',
764
- }
765
- }
766
-
767
- return {
768
- ran: true,
769
- source: preflight.source,
770
- exitCode: preflight.exitCode,
771
- observedTestCount: preflight.observedTestCount,
772
- stdoutSummary: preflight.stdoutSummary,
773
- stderrSummary: preflight.stderrSummary,
774
- }
545
+ /**
546
+ * Builds a confirmed CI failure.
547
+ *
548
+ * @param {object} framework framework entry
549
+ * @param {string} diagnosis diagnosis
550
+ * @param {object} evidence evidence
551
+ * @returns {object} result
552
+ */
553
+ function getFailure (framework, diagnosis, evidence) {
554
+ return fail(framework, 'ci-wiring', diagnosis, {
555
+ ciWiring: framework.ciWiring,
556
+ ...evidence,
557
+ })
775
558
  }
776
559
 
777
- module.exports = {
778
- getCiWiringCommand,
779
- runCiWiring,
780
- }
560
+ module.exports = { runCiWiring }