dd-trace 6.3.0 → 6.5.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 (207) hide show
  1. package/README.electron.md +7 -0
  2. package/README.md +17 -0
  3. package/ci/diagnose.js +2100 -0
  4. package/ci/init.js +23 -2
  5. package/ci/runbook.md +198 -0
  6. package/ci/test-optimization-validation/approval-artifacts.js +143 -0
  7. package/ci/test-optimization-validation/approval.js +299 -0
  8. package/ci/test-optimization-validation/artifact-id.js +20 -0
  9. package/ci/test-optimization-validation/bounded-json.js +121 -0
  10. package/ci/test-optimization-validation/ci-command-candidate.js +83 -0
  11. package/ci/test-optimization-validation/ci-discovery.js +181 -0
  12. package/ci/test-optimization-validation/ci-remediation.js +210 -0
  13. package/ci/test-optimization-validation/cli.js +903 -0
  14. package/ci/test-optimization-validation/command-blocker.js +81 -0
  15. package/ci/test-optimization-validation/command-output-policy.js +206 -0
  16. package/ci/test-optimization-validation/command-runner.js +707 -0
  17. package/ci/test-optimization-validation/command-suitability.js +471 -0
  18. package/ci/test-optimization-validation/executable-approval.js +48 -0
  19. package/ci/test-optimization-validation/executable.js +480 -0
  20. package/ci/test-optimization-validation/generated-file-policy.js +63 -0
  21. package/ci/test-optimization-validation/generated-files.js +351 -0
  22. package/ci/test-optimization-validation/generated-verifier.js +196 -0
  23. package/ci/test-optimization-validation/init-probe-preload.js +163 -0
  24. package/ci/test-optimization-validation/init-probe.js +246 -0
  25. package/ci/test-optimization-validation/late-initialization.js +63 -0
  26. package/ci/test-optimization-validation/local-command.js +163 -0
  27. package/ci/test-optimization-validation/manifest-loader.js +133 -0
  28. package/ci/test-optimization-validation/manifest-scaffold.js +738 -0
  29. package/ci/test-optimization-validation/manifest-schema.js +862 -0
  30. package/ci/test-optimization-validation/offline-fixtures.js +327 -0
  31. package/ci/test-optimization-validation/offline-output.js +406 -0
  32. package/ci/test-optimization-validation/payload-normalizer.js +72 -0
  33. package/ci/test-optimization-validation/plan-writer.js +1120 -0
  34. package/ci/test-optimization-validation/preflight-runner.js +114 -0
  35. package/ci/test-optimization-validation/redaction.js +331 -0
  36. package/ci/test-optimization-validation/report-writer.js +1508 -0
  37. package/ci/test-optimization-validation/safe-files.js +203 -0
  38. package/ci/test-optimization-validation/scenarios/auto-test-retries.js +159 -0
  39. package/ci/test-optimization-validation/scenarios/basic-reporting.js +657 -0
  40. package/ci/test-optimization-validation/scenarios/ci-wiring.js +780 -0
  41. package/ci/test-optimization-validation/scenarios/early-flake-detection.js +141 -0
  42. package/ci/test-optimization-validation/scenarios/helpers.js +539 -0
  43. package/ci/test-optimization-validation/scenarios/test-management.js +218 -0
  44. package/ci/test-optimization-validation/setup-runner.js +97 -0
  45. package/ci/test-optimization-validation/static-diagnosis.js +159 -0
  46. package/ci/test-optimization-validation/test-output.js +129 -0
  47. package/ci/test-optimization-validation-manifest.schema.json +1 -0
  48. package/ci/validate-test-optimization.js +3 -0
  49. package/ext/exporters.js +1 -0
  50. package/index.d.ts +121 -8
  51. package/index.electron.js +3 -0
  52. package/init.js +18 -0
  53. package/initialize.mjs +1 -1
  54. package/loader-hook.mjs +28 -18
  55. package/openfeature.d.ts +1 -0
  56. package/openfeature.js +4 -0
  57. package/package.json +15 -8
  58. package/packages/datadog-instrumentations/src/ai.js +37 -26
  59. package/packages/datadog-instrumentations/src/child_process.js +1 -1
  60. package/packages/datadog-instrumentations/src/claude-agent-sdk.js +1 -1
  61. package/packages/datadog-instrumentations/src/cucumber-worker-threads.js +9 -3
  62. package/packages/datadog-instrumentations/src/cucumber.js +24 -10
  63. package/packages/datadog-instrumentations/src/cypress-config.js +398 -52
  64. package/packages/datadog-instrumentations/src/express.js +20 -2
  65. package/packages/datadog-instrumentations/src/fastify.js +7 -11
  66. package/packages/datadog-instrumentations/src/grpc/server.js +18 -0
  67. package/packages/datadog-instrumentations/src/helpers/hook.js +27 -6
  68. package/packages/datadog-instrumentations/src/helpers/optional-peer-bundler.js +8 -8
  69. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/playwright.js +28 -2
  70. package/packages/datadog-instrumentations/src/helpers/router-helper.js +8 -8
  71. package/packages/datadog-instrumentations/src/http2/server.js +143 -17
  72. package/packages/datadog-instrumentations/src/jest.js +36 -4
  73. package/packages/datadog-instrumentations/src/mariadb.js +1 -1
  74. package/packages/datadog-instrumentations/src/mocha/main.js +0 -2
  75. package/packages/datadog-instrumentations/src/mocha/utils.js +0 -4
  76. package/packages/datadog-instrumentations/src/mongodb.js +3 -3
  77. package/packages/datadog-instrumentations/src/mongoose.js +3 -3
  78. package/packages/datadog-instrumentations/src/mquery.js +2 -2
  79. package/packages/datadog-instrumentations/src/mysql.js +1 -1
  80. package/packages/datadog-instrumentations/src/next.js +24 -0
  81. package/packages/datadog-instrumentations/src/nyc.js +0 -2
  82. package/packages/datadog-instrumentations/src/oracledb.js +2 -2
  83. package/packages/datadog-instrumentations/src/pg.js +2 -2
  84. package/packages/datadog-instrumentations/src/playwright.js +159 -22
  85. package/packages/datadog-instrumentations/src/process.js +3 -0
  86. package/packages/datadog-instrumentations/src/router.js +191 -106
  87. package/packages/datadog-instrumentations/src/selenium.js +52 -26
  88. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +2 -2
  89. package/packages/datadog-instrumentations/src/vitest-main.js +0 -2
  90. package/packages/datadog-instrumentations/src/ws.js +2 -1
  91. package/packages/datadog-plugin-aws-durable-execution-sdk-js/src/context.js +2 -2
  92. package/packages/datadog-plugin-aws-durable-execution-sdk-js/src/util.js +2 -1
  93. package/packages/datadog-plugin-aws-sdk/src/services/kinesis.js +41 -16
  94. package/packages/datadog-plugin-aws-sdk/src/services/sns.js +21 -21
  95. package/packages/datadog-plugin-aws-sdk/src/services/sqs.js +14 -8
  96. package/packages/datadog-plugin-aws-sdk/src/util.js +2 -24
  97. package/packages/datadog-plugin-cucumber/src/index.js +1 -1
  98. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +24 -62
  99. package/packages/datadog-plugin-cypress/src/index.js +6 -1
  100. package/packages/datadog-plugin-cypress/src/support.js +92 -26
  101. package/packages/datadog-plugin-electron/src/ipc.js +1 -1
  102. package/packages/datadog-plugin-graphql/src/execute.js +177 -59
  103. package/packages/datadog-plugin-graphql/src/parse.js +22 -1
  104. package/packages/datadog-plugin-graphql/src/request.js +32 -1
  105. package/packages/datadog-plugin-graphql/src/utils.js +42 -0
  106. package/packages/datadog-plugin-graphql/src/validate.js +15 -3
  107. package/packages/datadog-plugin-http2/src/client.js +1 -1
  108. package/packages/datadog-plugin-http2/src/server.js +38 -6
  109. package/packages/datadog-plugin-jest/src/util.js +21 -6
  110. package/packages/datadog-plugin-langchain/src/handlers/embedding.js +2 -1
  111. package/packages/datadog-plugin-langchain/src/handlers/language_models.js +2 -1
  112. package/packages/datadog-plugin-langchain/src/tokens.js +2 -2
  113. package/packages/datadog-plugin-playwright/src/index.js +177 -64
  114. package/packages/datadog-plugin-router/src/index.js +11 -2
  115. package/packages/datadog-plugin-selenium/src/index.js +5 -36
  116. package/packages/datadog-plugin-vitest/src/index.js +4 -2
  117. package/packages/datadog-plugin-ws/src/close.js +2 -1
  118. package/packages/datadog-plugin-ws/src/producer.js +2 -1
  119. package/packages/datadog-plugin-ws/src/receiver.js +2 -1
  120. package/packages/datadog-plugin-ws/src/server.js +2 -1
  121. package/packages/dd-trace/index.electron.js +3 -0
  122. package/packages/dd-trace/index.js +2 -37
  123. package/packages/dd-trace/src/aiguard/sdk.js +1 -1
  124. package/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js +2 -2
  125. package/packages/dd-trace/src/appsec/iast/telemetry/span-tags.js +3 -1
  126. package/packages/dd-trace/src/azure_metadata.js +2 -1
  127. package/packages/dd-trace/src/bootstrap.js +39 -0
  128. package/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/index.js +93 -28
  129. package/packages/dd-trace/src/ci-visibility/early-flake-detection/get-known-tests.js +1 -1
  130. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/index.js +160 -0
  131. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/msgpack-to-json.js +288 -0
  132. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/payload-projection.js +84 -0
  133. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/sink.js +369 -0
  134. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/writer.js +60 -0
  135. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +55 -5
  136. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/index.js +22 -6
  137. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/writer.js +5 -0
  138. package/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js +62 -7
  139. package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +9 -2
  140. package/packages/dd-trace/src/ci-visibility/requests/upload-coverage-report.js +6 -2
  141. package/packages/dd-trace/src/ci-visibility/rum.js +7 -0
  142. package/packages/dd-trace/src/ci-visibility/test-api-manual/test-api-manual-plugin.js +4 -0
  143. package/packages/dd-trace/src/ci-visibility/test-management/get-test-management-tests.js +1 -1
  144. package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache-schema.js +137 -9
  145. package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache.js +173 -9
  146. package/packages/dd-trace/src/ci-visibility/test-screenshot.js +90 -0
  147. package/packages/dd-trace/src/config/generated-config-types.d.ts +20 -0
  148. package/packages/dd-trace/src/config/helper.js +1 -0
  149. package/packages/dd-trace/src/config/index.js +10 -0
  150. package/packages/dd-trace/src/config/supported-configurations.json +83 -0
  151. package/packages/dd-trace/src/constants.js +7 -0
  152. package/packages/dd-trace/src/datastreams/pathway.js +6 -4
  153. package/packages/dd-trace/src/exporter.js +2 -0
  154. package/packages/dd-trace/src/exporters/common/client-library-headers.js +21 -0
  155. package/packages/dd-trace/src/exporters/common/request.js +59 -30
  156. package/packages/dd-trace/src/exporters/common/url.js +15 -1
  157. package/packages/dd-trace/src/feature-registry.js +29 -0
  158. package/packages/dd-trace/src/llmobs/constants/tags.js +1 -0
  159. package/packages/dd-trace/src/llmobs/experiments/client.js +113 -0
  160. package/packages/dd-trace/src/llmobs/experiments/dataset.js +154 -0
  161. package/packages/dd-trace/src/llmobs/experiments/experiment.js +283 -0
  162. package/packages/dd-trace/src/llmobs/experiments/index.js +152 -0
  163. package/packages/dd-trace/src/llmobs/experiments/noop.js +30 -0
  164. package/packages/dd-trace/src/llmobs/experiments/result.js +34 -0
  165. package/packages/dd-trace/src/llmobs/index.js +10 -2
  166. package/packages/dd-trace/src/llmobs/noop.js +6 -0
  167. package/packages/dd-trace/src/llmobs/plugins/ai/vercelTelemetry.js +20 -3
  168. package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +2 -2
  169. package/packages/dd-trace/src/llmobs/plugins/langgraph/index.js +1 -1
  170. package/packages/dd-trace/src/llmobs/plugins/openai/constants.js +8 -0
  171. package/packages/dd-trace/src/llmobs/plugins/openai/index.js +48 -14
  172. package/packages/dd-trace/src/llmobs/plugins/openai/utils.js +43 -0
  173. package/packages/dd-trace/src/llmobs/sdk.js +17 -0
  174. package/packages/dd-trace/src/llmobs/tagger.js +79 -7
  175. package/packages/dd-trace/src/llmobs/telemetry.js +2 -1
  176. package/packages/dd-trace/src/llmobs/util.js +32 -0
  177. package/packages/dd-trace/src/noop/proxy.js +5 -4
  178. package/packages/dd-trace/src/openfeature/agentless_configuration_source.js +322 -0
  179. package/packages/dd-trace/src/openfeature/configuration_source.js +90 -0
  180. package/packages/dd-trace/src/openfeature/flagging_provider.js +14 -23
  181. package/packages/dd-trace/src/openfeature/index.js +0 -2
  182. package/packages/dd-trace/src/openfeature/noop.js +1 -28
  183. package/packages/dd-trace/src/openfeature/register.js +35 -0
  184. package/packages/dd-trace/src/openfeature/remote_config.js +15 -18
  185. package/packages/dd-trace/src/openfeature/require-provider.js +18 -0
  186. package/packages/dd-trace/src/opentelemetry/span-ending-hook.js +10 -0
  187. package/packages/dd-trace/src/opentelemetry/span.js +5 -0
  188. package/packages/dd-trace/src/opentracing/propagation/log.js +11 -2
  189. package/packages/dd-trace/src/opentracing/propagation/text_map.js +80 -14
  190. package/packages/dd-trace/src/opentracing/propagation/text_map_dsm.js +10 -1
  191. package/packages/dd-trace/src/opentracing/tracer.js +7 -1
  192. package/packages/dd-trace/src/plugins/ci_plugin.js +20 -6
  193. package/packages/dd-trace/src/plugins/util/git.js +3 -2
  194. package/packages/dd-trace/src/plugins/util/inferred_proxy.js +29 -5
  195. package/packages/dd-trace/src/plugins/util/test.js +65 -8
  196. package/packages/dd-trace/src/plugins/util/web.js +12 -0
  197. package/packages/dd-trace/src/proxy.js +99 -10
  198. package/packages/dd-trace/src/span_processor.js +5 -0
  199. package/packages/dd-trace/src/standalone/index.js +0 -36
  200. package/packages/dd-trace/src/telemetry/send-data.js +2 -2
  201. package/packages/dd-trace/src/util.js +26 -0
  202. package/vendor/dist/@apm-js-collab/code-transformer/index.js +1 -1
  203. package/vendor/dist/@datadog/sketches-js/index.js +1 -1
  204. package/vendor/dist/protobufjs/index.js +1 -1
  205. package/vendor/dist/protobufjs/minimal/index.js +1 -1
  206. package/vendor/dist/shell-quote/index.js +1 -1
  207. package/packages/datadog-instrumentations/src/helpers/require-optional-peer.js +0 -17
package/ci/diagnose.js ADDED
@@ -0,0 +1,2100 @@
1
+ 'use strict'
2
+
3
+ /* eslint-disable eslint-rules/eslint-process-env */
4
+
5
+ const fs = require('node:fs')
6
+ const path = require('node:path')
7
+ const { execFileSync } = require('node:child_process')
8
+
9
+ const satisfies = require('../vendor/dist/semifies')
10
+ const { DD_MAJOR, VERSION } = require('../version')
11
+
12
+ const MAX_TEXT_FILE_SIZE = 512 * 1024
13
+ const MAX_SCANNED_FILES = 1500
14
+ const MAX_SCANNED_TEXT_BYTES = 32 * 1024 * 1024
15
+
16
+ const PACKAGE_SECTIONS = [
17
+ 'dependencies',
18
+ 'devDependencies',
19
+ 'optionalDependencies',
20
+ 'peerDependencies',
21
+ ]
22
+
23
+ const SKIPPED_DIRECTORIES = new Set([
24
+ '.cache',
25
+ '.git',
26
+ '.hg',
27
+ '.next',
28
+ '.nuxt',
29
+ '.output',
30
+ '.parcel-cache',
31
+ '.serverless',
32
+ '.svn',
33
+ '.turbo',
34
+ '.yarn',
35
+ 'build',
36
+ 'coverage',
37
+ 'dist',
38
+ 'node_modules',
39
+ 'out',
40
+ 'target',
41
+ 'tmp',
42
+ 'vendor',
43
+ ])
44
+
45
+ const SKIPPED_FILES = new Set([
46
+ 'ci/diagnose.js',
47
+ ])
48
+
49
+ const TEXT_EXTENSIONS = new Set([
50
+ '.cjs',
51
+ '.cts',
52
+ '.js',
53
+ '.json',
54
+ '.mjs',
55
+ '.mts',
56
+ '.sh',
57
+ '.ts',
58
+ '.tsx',
59
+ '.yaml',
60
+ '.yml',
61
+ ])
62
+
63
+ const TEXT_FILE_NAMES = new Set([
64
+ 'Dockerfile',
65
+ 'Jenkinsfile',
66
+ 'Makefile',
67
+ 'docker-compose.yml',
68
+ 'docker-compose.yaml',
69
+ 'package.json',
70
+ ])
71
+
72
+ const NODE_OPTIONS_RE = /\bNODE_OPTIONS\b/
73
+ const INIT_PRELOAD_TARGET =
74
+ String.raw`(?:dd-trace\/ci\/init|(?:[^\s'"]*[\/\\])?node_modules[\/\\]dd-trace[\/\\]ci[\/\\]init|\.\/ci\/init)`
75
+ const INIT_PRELOAD_RE =
76
+ new RegExp(String.raw`(?:^|[\s='"])(?:-r|--require)(?:=|\s+)['"]?${INIT_PRELOAD_TARGET}(?:\.js)?['"]?(?=$|\s|["'])`)
77
+ const REGISTER_PRELOAD_RE =
78
+ /(?:^|[\s='"])(?:--import|-r|--require)(?:=|\s+)['"]?dd-trace\/register(?:\.js)?['"]?(?=$|\s|["'])/
79
+ const WRONG_INIT_RE = /dd-trace\/(?:init|initialize\.mjs)\b|require\(['"]dd-trace['"]\)\.init\s*\(/
80
+ const DIRECT_CI_INIT_RE = /(?:require\(|import\s+)['"]dd-trace\/ci\/init(?:\.js)?['"]/
81
+ const CI_DISABLED_RE = /DD_CIVISIBILITY_ENABLED["'\s:=]+(?:false|0)\b/i
82
+ const ITR_DISABLED_RE = /DD_CIVISIBILITY_ITR_ENABLED["'\s:=]+(?:false|0)\b/i
83
+ const GIT_UPLOAD_DISABLED_RE = /DD_CIVISIBILITY_GIT_UPLOAD_ENABLED["'\s:=]+(?:false|0)\b/i
84
+ const AGENTLESS_ENABLED_RE = /DD_CIVISIBILITY_AGENTLESS_ENABLED["'\s:=]+(?:true|1)\b/i
85
+ const API_KEY_RE = /\b(?:DD_API_KEY|DATADOG_API_KEY)\b/
86
+ const SERVICE_RE = /\bDD_SERVICE\b/
87
+ const OTEL_OTLP_RE = /OTEL_TRACES_EXPORTER["'\s:=]+otlp\b/i
88
+ const WATCH_MODE_RE = /(?:^|\s)(?:watch|--watch|--watchAll)(?!(?:=false)(?:\s|$))(?:\s|=|$)/
89
+
90
+ const CYPRESS_MANUAL_PLUGIN_RE = /dd-trace\/ci\/cypress\/(?:plugin|after-run|after-spec)\b/
91
+ const CYPRESS_SUPPORT_RE = /dd-trace\/ci\/cypress\/support\b/
92
+ const CYPRESS_SUPPORT_DISABLED_RE = /supportFile\s*:\s*false|"supportFile"\s*:\s*false/
93
+ const CUCUMBER_PARALLEL_RE =
94
+ /\bcucumber(?:-js)?\b[\s\S]{0,200}\s--parallel\b|--parallel\b[\s\S]{0,200}\bcucumber(?:-js)?\b/
95
+ const JEST_FORCE_EXIT_RE = /\bforceExit\s*:\s*true\b|--forceExit\b|"forceExit"\s*:\s*true/
96
+ const JEST_JASMINE_RE = /jest-jasmine2/
97
+
98
+ const CURRENT_ENV_PROVIDER_KEYS = [
99
+ 'GITHUB_ACTIONS',
100
+ 'GITLAB_CI',
101
+ 'CIRCLECI',
102
+ 'JENKINS_URL',
103
+ 'BUILDKITE',
104
+ 'TRAVIS',
105
+ 'TF_BUILD',
106
+ 'BITBUCKET_BUILD_NUMBER',
107
+ 'DRONE',
108
+ 'TEAMCITY_VERSION',
109
+ ]
110
+
111
+ /**
112
+ * Builds the framework support table for the tracer major version that is running this script.
113
+ *
114
+ * @param {number} ddMajor dd-trace major version
115
+ * @returns {Array<object>} supported framework definitions
116
+ */
117
+ function getFrameworkDefinitions (ddMajor) {
118
+ return [
119
+ {
120
+ id: 'jest',
121
+ name: 'Jest',
122
+ packages: ['jest', '@jest/core'],
123
+ commandPatterns: [/\bjest\b/],
124
+ configPatterns: [/^jest\.config\./, /^config-jest\./],
125
+ supportedRange: ddMajor >= 6 ? '>=28.0.0' : '>=24.8.0',
126
+ recommendation: ddMajor >= 6
127
+ ? 'Upgrade Jest to >=28.0.0, or use dd-trace v5 for older Jest versions.'
128
+ : 'Upgrade Jest to >=24.8.0.',
129
+ },
130
+ {
131
+ id: 'mocha',
132
+ name: 'Mocha',
133
+ packages: ['mocha'],
134
+ commandPatterns: [/\bmocha\b/],
135
+ configPatterns: [/^\.mocharc\./],
136
+ supportedRange: ddMajor >= 6 ? '>=8.0.0' : '>=5.2.0',
137
+ recommendation: ddMajor >= 6
138
+ ? 'Upgrade Mocha to >=8.0.0, or use dd-trace v5 for older Mocha versions.'
139
+ : 'Upgrade Mocha to >=5.2.0.',
140
+ notes: [
141
+ 'Impacted tests are detected at suite level for Mocha.',
142
+ ],
143
+ },
144
+ {
145
+ id: 'cucumber',
146
+ name: 'Cucumber',
147
+ packages: ['@cucumber/cucumber'],
148
+ commandPatterns: [/\bcucumber-js\b/, /\bcucumber\b/],
149
+ configPatterns: [/^cucumber\./],
150
+ supportedRange: '>=7.0.0',
151
+ recommendation: 'Upgrade @cucumber/cucumber to >=7.0.0.',
152
+ },
153
+ {
154
+ id: 'cypress',
155
+ name: 'Cypress',
156
+ packages: ['cypress'],
157
+ commandPatterns: [/\bcypress\s+(?:run|open)\b/],
158
+ configPatterns: [/^cypress\.config\./, /^cypress\.json$/],
159
+ supportedRange: ddMajor >= 6 ? '>=12.0.0' : '>=6.7.0',
160
+ autoInstrumentationRange: ddMajor >= 6 ? '>=12.0.0' : '>=10.2.0',
161
+ recommendation: ddMajor >= 6
162
+ ? 'Upgrade Cypress to >=12.0.0, or use dd-trace v5 for older Cypress versions.'
163
+ : 'Upgrade Cypress to >=6.7.0.',
164
+ },
165
+ {
166
+ id: 'playwright',
167
+ name: 'Playwright',
168
+ packages: ['@playwright/test'],
169
+ commandPatterns: [/\bplaywright\s+test\b/],
170
+ configPatterns: [/^playwright\.config\./],
171
+ supportedRange: ddMajor >= 6 ? '>=1.38.0' : '>=1.18.0',
172
+ recommendation: ddMajor >= 6
173
+ ? 'Upgrade Playwright to >=1.38.0, or use dd-trace v5 for older Playwright versions.'
174
+ : 'Upgrade Playwright to >=1.18.0.',
175
+ notes: [
176
+ 'Test Impact Analysis suite skipping is not supported for Playwright.',
177
+ 'Impacted tests are detected at suite level for Playwright.',
178
+ ],
179
+ },
180
+ {
181
+ id: 'vitest',
182
+ name: 'Vitest',
183
+ packages: ['vitest'],
184
+ commandPatterns: [/\bvitest\b/],
185
+ configPatterns: [/^vitest\.config\./, /^vite\.config\./],
186
+ supportedRange: '>=1.6.0',
187
+ recommendation: 'Upgrade Vitest to >=1.6.0.',
188
+ notes: [
189
+ 'Test Impact Analysis suite skipping is not supported for Vitest.',
190
+ 'Impacted tests are detected at suite level for Vitest.',
191
+ ],
192
+ esmInitialization: true,
193
+ },
194
+ ]
195
+ }
196
+
197
+ const UNSUPPORTED_FRAMEWORKS = [
198
+ {
199
+ id: 'node-test',
200
+ name: 'Node.js test runner',
201
+ packages: [],
202
+ commandPatterns: [/\bnode\s+--test\b/, /\bnode\s+--experimental-test-coverage\b/],
203
+ },
204
+ { id: 'ava', name: 'AVA', packages: ['ava'], commandPatterns: [/\bava\b/] },
205
+ { id: 'tap', name: 'tap', packages: ['tap'], commandPatterns: [/\btap\b/] },
206
+ { id: 'jasmine', name: 'Jasmine', packages: ['jasmine'], commandPatterns: [/\bjasmine\b/] },
207
+ { id: 'karma', name: 'Karma', packages: ['karma'], commandPatterns: [/\bkarma\b/] },
208
+ { id: 'uvu', name: 'uvu', packages: ['uvu'], commandPatterns: [/\buvu\b/] },
209
+ {
210
+ id: 'testcafe',
211
+ name: 'TestCafe',
212
+ packages: ['testcafe'],
213
+ commandPatterns: [/\btestcafe\b/],
214
+ },
215
+ ]
216
+
217
+ /**
218
+ * Runs all static checks for a repository.
219
+ *
220
+ * @param {object} [options] diagnosis options
221
+ * @param {string} [options.root] repository path to inspect
222
+ * @param {NodeJS.ProcessEnv} [options.env] environment to inspect
223
+ * @param {Function} [options.execFile] command runner used for git checks
224
+ * @param {string} [options.gitExecutable] trusted git executable used for git checks
225
+ * @param {number} [options.maxFiles] maximum number of text files to scan
226
+ * @param {number} [options.maxTotalBytes] maximum aggregate bytes of text files to scan
227
+ * @param {string[]} [options.excludePaths] repository paths to exclude from the text scan
228
+ * @returns {object} diagnosis report
229
+ */
230
+ function runDiagnosis (options = {}) {
231
+ const root = path.resolve(options.root || process.cwd())
232
+ const physicalRoot = getPhysicalRoot(root)
233
+ const env = options.env || process.env
234
+ const execFile = options.execFile || execFileSync
235
+ const maxFiles = options.maxFiles || MAX_SCANNED_FILES
236
+ const maxTotalBytes = options.maxTotalBytes || MAX_SCANNED_TEXT_BYTES
237
+ const results = []
238
+ const files = collectTextFiles(root, physicalRoot, maxFiles, options.excludePaths)
239
+ const textFiles = readTextFiles(root, physicalRoot, files, maxTotalBytes)
240
+ const truncatedFileScan = files.truncated || textFiles.truncated
241
+ const manifests = readPackageManifests(root, textFiles)
242
+ const rootManifest = manifests.find(manifest => manifest.relativePath === 'package.json')
243
+ const rootPackageJsonState = getRootPackageJsonState(root, physicalRoot)
244
+ const scripts = collectScripts(manifests)
245
+ const workflowFiles = textFiles.filter(file => isWorkflowFile(file.relativePath))
246
+ const definitions = getFrameworkDefinitions(DD_MAJOR)
247
+ const supportedFrameworks = detectSupportedFrameworks(root, definitions, manifests, scripts, textFiles)
248
+ const eligibleFrameworks = getEligibleFrameworks(supportedFrameworks)
249
+ const unsupportedFrameworks = detectUnsupportedFrameworks(UNSUPPORTED_FRAMEWORKS, manifests, scripts)
250
+ const evidence = collectEvidence(textFiles, env)
251
+
252
+ checkPackageManifest(results, rootManifest, rootPackageJsonState)
253
+ checkDdTraceDependency(results, manifests, { root, truncatedFileScan, rootPackageJsonState })
254
+ checkSupportedFrameworks(results, supportedFrameworks)
255
+ checkUnsupportedFrameworks(results, unsupportedFrameworks, supportedFrameworks)
256
+ checkInitialization(results, supportedFrameworks, evidence, env)
257
+ checkFrameworkConfiguration(results, supportedFrameworks, evidence, textFiles, manifests)
258
+ checkCiConfiguration(results, workflowFiles, evidence, env)
259
+ const gitExecutable = options.gitExecutable || (options.execFile ? 'git' : findTrustedGitExecutable())
260
+ checkGit(results, root, env, execFile, gitExecutable)
261
+ checkCurrentEnvironment(results, env, evidence)
262
+
263
+ return {
264
+ root,
265
+ ddTraceVersion: VERSION,
266
+ ddTraceMajor: DD_MAJOR,
267
+ scannedFileCount: textFiles.length,
268
+ truncatedFileScan,
269
+ supportedFrameworks: supportedFrameworks.map(serializeSupportedFramework),
270
+ eligibleFrameworks: eligibleFrameworks.map(serializeEligibleFramework),
271
+ unsupportedFrameworks: unsupportedFrameworks.map(serializeUnsupportedFramework),
272
+ results,
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Adds a normalized result to the list.
278
+ *
279
+ * @param {Array<object>} results mutable result list
280
+ * @param {string} status result status
281
+ * @param {string} title short title
282
+ * @param {string} message result details
283
+ * @param {object} [extra] optional fields
284
+ */
285
+ function addResult (results, status, title, message, extra = {}) {
286
+ results.push({
287
+ status,
288
+ title,
289
+ message,
290
+ ...extra,
291
+ })
292
+ }
293
+
294
+ /**
295
+ * Reads package.json files collected by the repository scan.
296
+ *
297
+ * @param {string} root repository root
298
+ * @param {Array<object>} textFiles scanned text files
299
+ * @returns {Array<object>} parsed package manifests
300
+ */
301
+ function readPackageManifests (root, textFiles) {
302
+ const manifests = []
303
+
304
+ for (const file of textFiles) {
305
+ if (path.basename(file.relativePath) !== 'package.json') continue
306
+
307
+ const json = parseJson(file.content)
308
+ if (!json) continue
309
+
310
+ manifests.push({
311
+ path: path.join(root, file.relativePath),
312
+ relativePath: file.relativePath,
313
+ json,
314
+ })
315
+ }
316
+
317
+ return manifests
318
+ }
319
+
320
+ /**
321
+ * Checks whether the repository root has a package.json independently from the text-file scan.
322
+ *
323
+ * @param {string} root repository root
324
+ * @param {string|undefined} physicalRoot physical repository root
325
+ * @returns {{ exists: boolean }} root package.json state
326
+ */
327
+ function getRootPackageJsonState (root, physicalRoot) {
328
+ return {
329
+ exists: Boolean(getSafeScannedFile(root, physicalRoot, 'package.json')),
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Checks that a root package.json exists.
335
+ *
336
+ * @param {Array<object>} results mutable result list
337
+ * @param {object|undefined} rootManifest parsed root package manifest
338
+ * @param {object} rootPackageJsonState root package.json filesystem state
339
+ */
340
+ function checkPackageManifest (results, rootManifest, rootPackageJsonState) {
341
+ if (rootManifest) {
342
+ addResult(results, 'ok', 'Root package.json found', 'Dependency and script checks can inspect package metadata.')
343
+ return
344
+ }
345
+
346
+ if (rootPackageJsonState.exists) {
347
+ addResult(
348
+ results,
349
+ 'warning',
350
+ 'Root package.json not determined',
351
+ 'A root package.json file exists, but static diagnosis could not parse it as scanned text.',
352
+ {
353
+ recommendation:
354
+ 'Check whether package.json is readable UTF-8 JSON and smaller than the static diagnosis file limit.',
355
+ }
356
+ )
357
+ return
358
+ }
359
+
360
+ addResult(
361
+ results,
362
+ 'warning',
363
+ 'No root package.json found',
364
+ 'The diagnosis could not inspect root dependencies or package scripts.',
365
+ { recommendation: 'Run this script from the JavaScript repository root, or pass --path <repository>.' }
366
+ )
367
+ }
368
+
369
+ /**
370
+ * Checks whether dd-trace is declared in repository manifests.
371
+ *
372
+ * @param {Array<object>} results mutable result list
373
+ * @param {Array<object>} manifests package manifests
374
+ * @param {object} options dependency check options
375
+ * @param {string} [options.root] repository root
376
+ * @param {boolean} [options.truncatedFileScan] whether static file discovery was truncated
377
+ * @param {object} [options.rootPackageJsonState] root package manifest state
378
+ */
379
+ function checkDdTraceDependency (results, manifests, options = {}) {
380
+ const entries = findDependencyEntries(manifests, ['dd-trace'])
381
+
382
+ if (entries.length) {
383
+ addResult(
384
+ results,
385
+ 'ok',
386
+ 'dd-trace dependency found',
387
+ `Detected dd-trace in ${formatLocations(entries.map(entry => entry.relativePath))}.`
388
+ )
389
+ return
390
+ }
391
+
392
+ const installedPackageJson = options.root && path.join(options.root, 'node_modules', 'dd-trace', 'package.json')
393
+ let installed = false
394
+ try {
395
+ installed = installedPackageJson && fs.statSync(installedPackageJson).isFile()
396
+ } catch {}
397
+ if (installed) {
398
+ addResult(
399
+ results,
400
+ 'ok',
401
+ 'dd-trace package installed',
402
+ 'Detected an installed dd-trace package even though it was not declared in the scanned package manifests.'
403
+ )
404
+ return
405
+ }
406
+
407
+ if (options.truncatedFileScan || (options.rootPackageJsonState?.exists && !hasParsedRootManifest(manifests))) {
408
+ addResult(
409
+ results,
410
+ 'warning',
411
+ 'dd-trace dependency not determined',
412
+ 'Static diagnosis did not confirm a dd-trace dependency, but the file scan was incomplete or root package ' +
413
+ 'metadata could not be parsed.',
414
+ { recommendation: 'Inspect the package that runs tests and install dd-trace there if it is absent.' }
415
+ )
416
+ return
417
+ }
418
+
419
+ addResult(
420
+ results,
421
+ 'warning',
422
+ 'dd-trace dependency not found in package.json',
423
+ 'The script did not find dd-trace in dependencies or devDependencies.',
424
+ { recommendation: 'Install dd-trace in the project that runs the tests.' }
425
+ )
426
+ }
427
+
428
+ /**
429
+ * Checks whether static diagnosis parsed the repository root package manifest.
430
+ *
431
+ * @param {Array<object>} manifests parsed package manifests
432
+ * @returns {boolean} true when the root package.json was parsed
433
+ */
434
+ function hasParsedRootManifest (manifests) {
435
+ return manifests.some(manifest => manifest.relativePath === 'package.json')
436
+ }
437
+
438
+ /**
439
+ * Checks supported framework detections and versions.
440
+ *
441
+ * @param {Array<object>} results mutable result list
442
+ * @param {Array<object>} frameworks detected supported frameworks
443
+ */
444
+ function checkSupportedFrameworks (results, frameworks) {
445
+ if (!frameworks.length) {
446
+ addResult(
447
+ results,
448
+ 'warning',
449
+ 'No supported test framework detected',
450
+ 'No supported Test Optimization framework was found in dependencies, scripts, or config files.',
451
+ {
452
+ recommendation:
453
+ 'Use Jest, Mocha, Cucumber, Cypress, Playwright, or Vitest with a supported version.',
454
+ }
455
+ )
456
+ return
457
+ }
458
+
459
+ for (const framework of frameworks) {
460
+ if (!framework.versionDetections.length) {
461
+ addResult(
462
+ results,
463
+ 'warning',
464
+ `${framework.name} detected but version is unknown`,
465
+ `${framework.name} appears in scripts or config, but no package version could be determined.`,
466
+ {
467
+ locations: framework.locations,
468
+ recommendation:
469
+ `Ensure ${framework.packages.join(' or ')} is installed and matches ${framework.supportedRange}.`,
470
+ }
471
+ )
472
+ }
473
+
474
+ for (const detection of framework.versionDetections) {
475
+ if (!detection.version) {
476
+ addResult(
477
+ results,
478
+ 'warning',
479
+ `${framework.name} version could not be determined`,
480
+ `Detected ${detection.packageName}@${detection.rawVersion}, but the version is not statically comparable.`,
481
+ {
482
+ locations: [detection.relativePath],
483
+ recommendation: `Verify ${framework.name} satisfies ${framework.supportedRange}.`,
484
+ }
485
+ )
486
+ continue
487
+ }
488
+
489
+ const status = satisfies(detection.version, framework.supportedRange) ? 'ok' : 'error'
490
+ const source = detection.source === 'installed' ? 'installed package' : 'package manifest'
491
+ addResult(
492
+ results,
493
+ status,
494
+ `${framework.name} ${detection.version} ${status === 'ok' ? 'is supported' : 'is not supported'}`,
495
+ `Detected ${detection.packageName}@${detection.rawVersion} from ${source}; supported range is ` +
496
+ `${framework.supportedRange}.`,
497
+ {
498
+ locations: detection.relativePath ? [detection.relativePath] : undefined,
499
+ recommendation: status === 'error' ? framework.recommendation : undefined,
500
+ }
501
+ )
502
+ }
503
+
504
+ for (const note of framework.notes || []) {
505
+ addResult(results, 'info', `${framework.name} capability note`, note)
506
+ }
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Checks unsupported framework detections.
512
+ *
513
+ * @param {Array<object>} results mutable result list
514
+ * @param {Array<object>} unsupported detected unsupported frameworks
515
+ * @param {Array<object>} supported detected supported frameworks
516
+ */
517
+ function checkUnsupportedFrameworks (results, unsupported, supported) {
518
+ for (const framework of unsupported) {
519
+ const status = supported.length ? 'warning' : 'error'
520
+ addResult(
521
+ results,
522
+ status,
523
+ `${framework.name} is not supported by Test Optimization`,
524
+ `${framework.name} was detected in dependencies or test scripts.`,
525
+ {
526
+ locations: framework.locations,
527
+ recommendation:
528
+ 'Use a supported JavaScript test framework for automatic Test Optimization instrumentation.',
529
+ }
530
+ )
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Checks Test Optimization initialization.
536
+ *
537
+ * @param {Array<object>} results mutable result list
538
+ * @param {Array<object>} frameworks detected supported frameworks
539
+ * @param {object} evidence repository evidence
540
+ * @param {NodeJS.ProcessEnv} env environment
541
+ */
542
+ function checkInitialization (results, frameworks, evidence, env) {
543
+ if (!frameworks.length) return
544
+
545
+ const hasCiInit = evidence.hasCiInit || hasCiInitInNodeOptions(env.NODE_OPTIONS)
546
+ const hasCypressOnly = frameworks.length === 1 && frameworks[0].id === 'cypress'
547
+ const hasCypressManualPlugin = evidence.cypressManualPluginLocations.length > 0
548
+
549
+ if (hasCiInit) {
550
+ addResult(
551
+ results,
552
+ 'ok',
553
+ 'Test Optimization initialization found',
554
+ 'Found dd-trace/ci/init preloaded through NODE_OPTIONS in repository files or the current environment.',
555
+ { locations: evidence.ciInitLocations }
556
+ )
557
+ } else if (hasCypressOnly && hasCypressManualPlugin) {
558
+ addResult(
559
+ results,
560
+ 'ok',
561
+ 'Cypress manual plugin initialization found',
562
+ 'Found the Cypress-specific dd-trace Test Optimization plugin setup.',
563
+ { locations: evidence.cypressManualPluginLocations }
564
+ )
565
+ } else {
566
+ addResult(
567
+ results,
568
+ 'error',
569
+ 'Missing Test Optimization initialization',
570
+ 'No NODE_OPTIONS preload for dd-trace/ci/init was found in repository files or the current environment.',
571
+ {
572
+ recommendation:
573
+ 'Run tests with NODE_OPTIONS="-r dd-trace/ci/init". For ESM test runners, also include ' +
574
+ '--import dd-trace/register.js.',
575
+ }
576
+ )
577
+ }
578
+
579
+ if (evidence.directCiInitLocations.length) {
580
+ addResult(
581
+ results,
582
+ 'error',
583
+ 'Test Optimization initialization is imported directly',
584
+ 'The diagnosis found require("dd-trace/ci/init") or import "dd-trace/ci/init". ' +
585
+ 'That does not preload the tracer early enough for Test Optimization setup.',
586
+ {
587
+ locations: evidence.directCiInitLocations,
588
+ recommendation: 'Set NODE_OPTIONS="-r dd-trace/ci/init" on the test process instead.',
589
+ }
590
+ )
591
+ }
592
+
593
+ if (evidence.wrongInitLocations.length) {
594
+ addResult(
595
+ results,
596
+ 'error',
597
+ 'Plain dd-trace initialization found in test setup',
598
+ 'The diagnosis found dd-trace/init, dd-trace/initialize.mjs, or require("dd-trace").init(). ' +
599
+ 'That does not initialize the tracer in Test Optimization mode.',
600
+ {
601
+ locations: evidence.wrongInitLocations,
602
+ recommendation: 'Use dd-trace/ci/init for test commands instead of the plain tracing initializer.',
603
+ }
604
+ )
605
+ }
606
+
607
+ if (frameworks.some(framework => framework.esmInitialization) && hasCiInit && !evidence.hasRegister &&
608
+ !hasRegisterInNodeOptions(env.NODE_OPTIONS)) {
609
+ addResult(
610
+ results,
611
+ 'warning',
612
+ 'ESM loader registration not found',
613
+ 'Vitest and other ESM-heavy test runners often need dd-trace/register.js before dd-trace/ci/init.',
614
+ {
615
+ recommendation:
616
+ 'Use NODE_OPTIONS="--import dd-trace/register.js -r dd-trace/ci/init" for ESM test runs.',
617
+ }
618
+ )
619
+ }
620
+ }
621
+
622
+ /**
623
+ * Checks framework-specific configuration pitfalls.
624
+ *
625
+ * @param {Array<object>} results mutable result list
626
+ * @param {Array<object>} frameworks detected supported frameworks
627
+ * @param {object} evidence repository evidence
628
+ * @param {Array<object>} textFiles scanned text files
629
+ * @param {Array<object>} manifests package manifests
630
+ */
631
+ function checkFrameworkConfiguration (results, frameworks, evidence, textFiles, manifests) {
632
+ if (hasFramework(frameworks, 'vitest') && !hasFramework(frameworks, 'playwright') &&
633
+ findDependencyEntries(manifests, ['playwright']).length > 0) {
634
+ addResult(
635
+ results,
636
+ 'info',
637
+ 'Playwright package is not a Playwright Test runner',
638
+ 'The repository uses Vitest and has the playwright package, but no @playwright/test runner was detected. ' +
639
+ 'Treat Playwright as Vitest browser-provider infrastructure, not as another test framework.',
640
+ {}
641
+ )
642
+ }
643
+
644
+ if (hasFramework(frameworks, 'cypress')) {
645
+ checkCypressConfiguration(results, evidence)
646
+ }
647
+
648
+ if (hasFramework(frameworks, 'jest')) {
649
+ const jestLocations = findLocations(textFiles, JEST_FORCE_EXIT_RE)
650
+ if (jestLocations.length) {
651
+ addResult(
652
+ results,
653
+ 'warning',
654
+ 'Jest forceExit can drop Test Optimization data',
655
+ 'Jest\'s forceExit option can terminate before dd-trace flushes all test data.',
656
+ {
657
+ locations: jestLocations,
658
+ recommendation: 'Remove --forceExit or forceExit: true from Jest configuration when possible.',
659
+ }
660
+ )
661
+ }
662
+
663
+ const jasmineLocations = findLocations(textFiles, JEST_JASMINE_RE)
664
+ if (jasmineLocations.length) {
665
+ addResult(
666
+ results,
667
+ 'info',
668
+ 'Jest is configured with jest-jasmine2',
669
+ 'dd-trace can avoid crashing with jest-jasmine2, but jest-circus is the better-supported runner.',
670
+ {
671
+ locations: jasmineLocations,
672
+ recommendation: 'Prefer the default jest-circus runner on supported Jest versions.',
673
+ }
674
+ )
675
+ }
676
+
677
+ const tsConfigLocations = findJestTypescriptConfigLocations(textFiles)
678
+ const hasTsNode = findDependencyEntries(manifests, ['ts-node']).length > 0
679
+ if (tsConfigLocations.length && !hasTsNode) {
680
+ addResult(
681
+ results,
682
+ 'warning',
683
+ 'Jest TypeScript config may need ts-node',
684
+ 'Jest loads TypeScript configuration files before test transforms. Without ts-node or an equivalent ' +
685
+ 'precompiled config, the selected command can fail before collecting tests.',
686
+ {
687
+ locations: tsConfigLocations,
688
+ recommendation:
689
+ 'Install ts-node for the diagnostic run, or use a temporary JSON/CommonJS Jest config generated ' +
690
+ 'from the repository config.',
691
+ }
692
+ )
693
+ }
694
+ }
695
+
696
+ if (hasFramework(frameworks, 'cucumber')) {
697
+ const cucumber = frameworks.find(framework => framework.id === 'cucumber')
698
+ const parallelLocations = findLocations(textFiles, CUCUMBER_PARALLEL_RE)
699
+ const hasOldParallel = cucumber.versionDetections.some(detection =>
700
+ detection.version && !satisfies(detection.version, '>=11.0.0')
701
+ )
702
+
703
+ if (parallelLocations.length && hasOldParallel) {
704
+ addResult(
705
+ results,
706
+ 'warning',
707
+ 'Cucumber parallel mode has feature limits before version 11',
708
+ 'Some Test Optimization features for Cucumber parallel mode require @cucumber/cucumber >=11.0.0.',
709
+ {
710
+ locations: parallelLocations,
711
+ recommendation: 'Upgrade @cucumber/cucumber to >=11.0.0 when using --parallel.',
712
+ }
713
+ )
714
+ }
715
+ }
716
+ }
717
+
718
+ /**
719
+ * Checks Cypress-specific setup.
720
+ *
721
+ * @param {Array<object>} results mutable result list
722
+ * @param {object} evidence repository evidence
723
+ */
724
+ function checkCypressConfiguration (results, evidence) {
725
+ if (evidence.cypressSupportDisabledLocations.length) {
726
+ addResult(
727
+ results,
728
+ 'warning',
729
+ 'Cypress support file is disabled',
730
+ 'Cypress browser-side hooks cannot be injected when supportFile is false.',
731
+ {
732
+ locations: evidence.cypressSupportDisabledLocations,
733
+ recommendation: 'Use a Cypress support file, or manually require dd-trace/ci/cypress/support.',
734
+ }
735
+ )
736
+ return
737
+ }
738
+
739
+ if (evidence.cypressSupportLocations.length) {
740
+ addResult(
741
+ results,
742
+ 'ok',
743
+ 'Cypress support hook found',
744
+ 'Found dd-trace/ci/cypress/support in the repository.',
745
+ { locations: evidence.cypressSupportLocations }
746
+ )
747
+ } else {
748
+ addResult(
749
+ results,
750
+ 'info',
751
+ 'Cypress support hook not explicitly configured',
752
+ 'For supported Cypress versions, dd-trace can inject a temporary support wrapper when dd-trace/ci/init is used.',
753
+ {
754
+ recommendation:
755
+ 'If browser-side test events are missing, add require("dd-trace/ci/cypress/support") to the ' +
756
+ 'Cypress support file.',
757
+ }
758
+ )
759
+ }
760
+ }
761
+
762
+ /**
763
+ * Checks static CI workflow files.
764
+ *
765
+ * @param {Array<object>} results mutable result list
766
+ * @param {Array<object>} workflowFiles scanned CI workflow files
767
+ * @param {object} evidence repository evidence
768
+ * @param {NodeJS.ProcessEnv} env environment
769
+ */
770
+ function checkCiConfiguration (results, workflowFiles, evidence, env) {
771
+ if (!workflowFiles.length) {
772
+ addResult(
773
+ results,
774
+ 'info',
775
+ 'No CI workflow files found',
776
+ 'The diagnosis did not find common CI configuration files to inspect.'
777
+ )
778
+ return
779
+ }
780
+
781
+ addResult(
782
+ results,
783
+ 'ok',
784
+ 'CI workflow files found',
785
+ `Inspected ${workflowFiles.length} CI workflow file(s).`,
786
+ { locations: workflowFiles.map(file => file.relativePath) }
787
+ )
788
+
789
+ if (!evidence.hasCiInit && !hasCiInitInNodeOptions(env.NODE_OPTIONS)) {
790
+ addResult(
791
+ results,
792
+ 'warning',
793
+ 'CI workflows do not show Test Optimization initialization',
794
+ 'No CI workflow file shows NODE_OPTIONS preloading dd-trace/ci/init.',
795
+ {
796
+ recommendation:
797
+ 'Set NODE_OPTIONS="-r dd-trace/ci/init" in the CI job that runs the supported JavaScript test framework.',
798
+ }
799
+ )
800
+ }
801
+
802
+ const shallowGithubLocations = []
803
+ const containerGithubLocations = []
804
+
805
+ for (const file of workflowFiles) {
806
+ if (!file.relativePath.startsWith('.github/workflows/')) continue
807
+
808
+ if (/actions\/checkout/.test(file.content) && !/fetch-depth\s*:\s*0\b/.test(file.content)) {
809
+ shallowGithubLocations.push(file.relativePath)
810
+ }
811
+
812
+ if (/\bcontainer\s*:/.test(file.content) && !/safe\.directory/.test(file.content)) {
813
+ containerGithubLocations.push(file.relativePath)
814
+ }
815
+ }
816
+
817
+ if (shallowGithubLocations.length) {
818
+ addResult(
819
+ results,
820
+ 'warning',
821
+ 'GitHub Actions checkout may be shallow',
822
+ 'actions/checkout defaults to a shallow checkout, which can limit git metadata and impacted-test detection.',
823
+ {
824
+ locations: shallowGithubLocations,
825
+ recommendation: 'Set fetch-depth: 0 for the checkout step, or keep git unshallowing enabled.',
826
+ }
827
+ )
828
+ }
829
+
830
+ if (containerGithubLocations.length) {
831
+ addResult(
832
+ results,
833
+ 'info',
834
+ 'Containerized GitHub jobs may need Git safe.directory',
835
+ 'Git can reject metadata commands in containerized jobs when checkout ownership differs from the container user.',
836
+ {
837
+ locations: containerGithubLocations,
838
+ recommendation: 'Run git config --global --add safe.directory "$GITHUB_WORKSPACE" when needed.',
839
+ }
840
+ )
841
+ }
842
+
843
+ if (evidence.hasAgentlessEnabled && !evidence.hasApiKey && !env.DD_API_KEY && !env.DATADOG_API_KEY) {
844
+ addResult(
845
+ results,
846
+ 'warning',
847
+ 'Agentless mode is enabled but no API key reference was found',
848
+ 'DD_CIVISIBILITY_AGENTLESS_ENABLED requires DD_API_KEY or DATADOG_API_KEY at runtime.',
849
+ {
850
+ recommendation: 'Provide DD_API_KEY or DATADOG_API_KEY as a CI secret in the test job.',
851
+ }
852
+ )
853
+ }
854
+
855
+ if (evidence.gitStrategyNoneLocations.length) {
856
+ addResult(
857
+ results,
858
+ 'warning',
859
+ 'CI configuration disables git checkout',
860
+ 'Git metadata extraction cannot work when the CI job does not check out the repository.',
861
+ {
862
+ locations: evidence.gitStrategyNoneLocations,
863
+ recommendation: 'Enable repository checkout for Test Optimization jobs.',
864
+ }
865
+ )
866
+ }
867
+ }
868
+
869
+ /**
870
+ * Checks local git availability and repository metadata.
871
+ *
872
+ * @param {Array<object>} results mutable result list
873
+ * @param {string} root repository root
874
+ * @param {NodeJS.ProcessEnv} env environment
875
+ * @param {Function} execFile command runner
876
+ * @param {string|undefined} gitExecutable trusted git executable
877
+ */
878
+ function checkGit (results, root, env, execFile, gitExecutable) {
879
+ const gitEnv = getGitEnvironment(gitExecutable, env)
880
+ if (!canRunGit(execFile, root, gitExecutable, gitEnv)) {
881
+ addResult(
882
+ results,
883
+ 'error',
884
+ 'git executable is not available',
885
+ 'Test Optimization uses git to extract repository metadata and impacted files.',
886
+ { recommendation: 'Install git in the CI image or runner that executes tests.' }
887
+ )
888
+ return
889
+ }
890
+
891
+ addResult(results, 'ok', 'git executable found', 'The current environment can execute git.')
892
+
893
+ const insideWorktree = runGit(execFile, root, gitExecutable, gitEnv, ['rev-parse', '--is-inside-work-tree'])
894
+ if (insideWorktree !== 'true') {
895
+ addResult(
896
+ results,
897
+ 'warning',
898
+ 'Current path is not inside a git worktree',
899
+ 'Git metadata extraction needs the checked-out repository.',
900
+ { recommendation: 'Run the test command from inside the checked-out repository.' }
901
+ )
902
+ return
903
+ }
904
+
905
+ const head = runGit(execFile, root, gitExecutable, gitEnv, ['rev-parse', 'HEAD'])
906
+ const remote = runGit(execFile, root, gitExecutable, gitEnv, ['config', '--get', 'remote.origin.url'])
907
+ const branch = runGit(execFile, root, gitExecutable, gitEnv, ['branch', '--show-current'])
908
+ const shallow = runGit(execFile, root, gitExecutable, gitEnv, ['rev-parse', '--is-shallow-repository'])
909
+
910
+ if (head) {
911
+ addResult(results, 'ok', 'git commit SHA detected', `Current HEAD is ${head.slice(0, 12)}.`)
912
+ } else {
913
+ addResult(
914
+ results,
915
+ 'warning',
916
+ 'git commit SHA could not be detected',
917
+ 'The diagnosis could not read git rev-parse HEAD.',
918
+ { recommendation: 'Ensure the CI checkout includes a valid git repository.' }
919
+ )
920
+ }
921
+
922
+ if (!remote) {
923
+ addResult(
924
+ results,
925
+ 'warning',
926
+ 'git remote origin is not configured',
927
+ 'Repository URL metadata may be missing.',
928
+ { recommendation: 'Configure remote.origin.url or provide DD_GIT_REPOSITORY_URL.' }
929
+ )
930
+ }
931
+
932
+ if (!branch && !hasBranchMetadata(env)) {
933
+ addResult(
934
+ results,
935
+ 'warning',
936
+ 'git branch metadata could not be detected',
937
+ 'The checkout appears detached and no CI branch metadata was found in the current environment.',
938
+ { recommendation: 'Provide branch metadata through CI provider variables or DD_GIT_BRANCH.' }
939
+ )
940
+ }
941
+
942
+ if (shallow === 'true') {
943
+ addResult(
944
+ results,
945
+ 'warning',
946
+ 'Repository is shallow',
947
+ 'A shallow repository can limit metadata upload and impacted-test detection.',
948
+ { recommendation: 'Use a full checkout, or keep DD_CIVISIBILITY_GIT_UNSHALLOW_ENABLED enabled.' }
949
+ )
950
+ }
951
+ }
952
+
953
+ /**
954
+ * Checks current environment variables relevant to Test Optimization.
955
+ *
956
+ * @param {Array<object>} results mutable result list
957
+ * @param {NodeJS.ProcessEnv} env environment
958
+ * @param {object} evidence repository evidence
959
+ */
960
+ function checkCurrentEnvironment (results, env, evidence) {
961
+ if (isFalseLike(env.DD_CIVISIBILITY_ENABLED) || evidence.hasCiVisibilityDisabled) {
962
+ addResult(
963
+ results,
964
+ 'error',
965
+ 'Test Optimization is explicitly disabled',
966
+ 'DD_CIVISIBILITY_ENABLED is set to false or 0.',
967
+ { recommendation: 'Remove DD_CIVISIBILITY_ENABLED=false from the test job.' }
968
+ )
969
+ }
970
+
971
+ if (isFalseLike(env.DD_CIVISIBILITY_ITR_ENABLED) || evidence.hasItrDisabled) {
972
+ addResult(
973
+ results,
974
+ 'warning',
975
+ 'Test Impact Analysis is disabled',
976
+ 'DD_CIVISIBILITY_ITR_ENABLED is set to false or 0.',
977
+ { recommendation: 'Remove DD_CIVISIBILITY_ITR_ENABLED=false if suite skipping should be enabled.' }
978
+ )
979
+ }
980
+
981
+ if (isFalseLike(env.DD_CIVISIBILITY_GIT_UPLOAD_ENABLED) || evidence.hasGitUploadDisabled) {
982
+ addResult(
983
+ results,
984
+ 'warning',
985
+ 'Git metadata upload is disabled',
986
+ 'DD_CIVISIBILITY_GIT_UPLOAD_ENABLED is set to false or 0.',
987
+ { recommendation: 'Remove DD_CIVISIBILITY_GIT_UPLOAD_ENABLED=false unless this is intentional.' }
988
+ )
989
+ }
990
+
991
+ if (isTrueLike(env.DD_CIVISIBILITY_AGENTLESS_ENABLED) && !env.DD_API_KEY && !env.DATADOG_API_KEY) {
992
+ addResult(
993
+ results,
994
+ 'error',
995
+ 'Agentless mode is missing an API key',
996
+ 'The current environment has DD_CIVISIBILITY_AGENTLESS_ENABLED set, but no DD_API_KEY or DATADOG_API_KEY.',
997
+ { recommendation: 'Set DD_API_KEY or DATADOG_API_KEY in the CI job.' }
998
+ )
999
+ }
1000
+
1001
+ if (!env.DD_SERVICE && !evidence.hasService) {
1002
+ addResult(
1003
+ results,
1004
+ 'warning',
1005
+ 'DD_SERVICE was not found',
1006
+ 'A missing service name makes Test Optimization data harder to find and group.',
1007
+ { recommendation: 'Set DD_SERVICE in the test job.' }
1008
+ )
1009
+ }
1010
+
1011
+ if ((env.OTEL_TRACES_EXPORTER || '').toLowerCase() === 'otlp' || evidence.hasOtelOtlpExporter) {
1012
+ addResult(
1013
+ results,
1014
+ 'warning',
1015
+ 'OTEL_TRACES_EXPORTER=otlp was found',
1016
+ 'An OTLP traces exporter can interfere with dd-trace test payloads in instrumented shells.',
1017
+ { recommendation: 'Unset OTEL_TRACES_EXPORTER for dd-trace Test Optimization jobs.' }
1018
+ )
1019
+ }
1020
+
1021
+ if (env.CI) {
1022
+ checkCurrentCiMetadata(results, env)
1023
+ } else {
1024
+ addResult(
1025
+ results,
1026
+ 'info',
1027
+ 'Current process is not running in CI',
1028
+ 'Runtime CI metadata checks were skipped because CI is not set in the current environment.'
1029
+ )
1030
+ }
1031
+ }
1032
+
1033
+ /**
1034
+ * Checks current CI provider metadata.
1035
+ *
1036
+ * @param {Array<object>} results mutable result list
1037
+ * @param {NodeJS.ProcessEnv} env environment
1038
+ */
1039
+ function checkCurrentCiMetadata (results, env) {
1040
+ const providerDetected = CURRENT_ENV_PROVIDER_KEYS.some(key => env[key])
1041
+ if (!providerDetected) {
1042
+ addResult(
1043
+ results,
1044
+ 'warning',
1045
+ 'Current CI provider is not recognized',
1046
+ 'The current environment has CI set, but no known CI provider variables were found.',
1047
+ { recommendation: 'Provide CI and git metadata with DD_GIT_* variables if the provider is custom.' }
1048
+ )
1049
+ }
1050
+
1051
+ if (!hasShaMetadata(env)) {
1052
+ addResult(
1053
+ results,
1054
+ 'warning',
1055
+ 'Current CI commit SHA metadata is missing',
1056
+ 'The current CI environment does not expose a recognized commit SHA variable.',
1057
+ { recommendation: 'Provide DD_GIT_COMMIT_SHA when the CI provider does not expose one.' }
1058
+ )
1059
+ }
1060
+
1061
+ if (!hasBranchMetadata(env)) {
1062
+ addResult(
1063
+ results,
1064
+ 'warning',
1065
+ 'Current CI branch metadata is missing',
1066
+ 'The current CI environment does not expose a recognized branch or tag variable.',
1067
+ { recommendation: 'Provide DD_GIT_BRANCH or DD_GIT_TAG when the CI provider does not expose one.' }
1068
+ )
1069
+ }
1070
+ }
1071
+
1072
+ /**
1073
+ * Collects package scripts from all manifests.
1074
+ *
1075
+ * @param {Array<object>} manifests package manifests
1076
+ * @returns {Array<object>} scripts
1077
+ */
1078
+ function collectScripts (manifests) {
1079
+ const scripts = []
1080
+
1081
+ for (const manifest of manifests) {
1082
+ const manifestScripts = manifest.json.scripts || {}
1083
+ for (const [name, command] of Object.entries(manifestScripts)) {
1084
+ if (typeof command !== 'string') continue
1085
+
1086
+ scripts.push({
1087
+ name,
1088
+ command,
1089
+ relativePath: manifest.relativePath,
1090
+ })
1091
+ }
1092
+ }
1093
+
1094
+ return scripts
1095
+ }
1096
+
1097
+ /**
1098
+ * Detects supported test frameworks.
1099
+ *
1100
+ * @param {string} root repository root
1101
+ * @param {Array<object>} definitions framework definitions
1102
+ * @param {Array<object>} manifests package manifests
1103
+ * @param {Array<object>} scripts package scripts
1104
+ * @param {Array<object>} textFiles scanned text files
1105
+ * @returns {Array<object>} detected supported frameworks
1106
+ */
1107
+ function detectSupportedFrameworks (root, definitions, manifests, scripts, textFiles) {
1108
+ const frameworks = []
1109
+
1110
+ for (const definition of definitions) {
1111
+ const dependencyEntries = findDependencyEntries(manifests, definition.packages)
1112
+ const scriptMatches = findScriptMatches(scripts, definition.commandPatterns)
1113
+ const configMatches = findConfigMatches(textFiles, definition.configPatterns)
1114
+
1115
+ if (!dependencyEntries.length && !scriptMatches.length && !configMatches.length) continue
1116
+
1117
+ frameworks.push({
1118
+ ...definition,
1119
+ dependencyEntries,
1120
+ scriptMatches,
1121
+ configMatches,
1122
+ locations: unique([
1123
+ ...dependencyEntries.map(entry => entry.relativePath),
1124
+ ...scriptMatches.map(script => script.relativePath),
1125
+ ...configMatches,
1126
+ ]),
1127
+ versionDetections: getVersionDetections(root, definition.packages, dependencyEntries),
1128
+ })
1129
+ }
1130
+
1131
+ return frameworks
1132
+ }
1133
+
1134
+ /**
1135
+ * Gets frameworks that are eligible live-validation candidates.
1136
+ *
1137
+ * @param {Array<object>} frameworks detected supported frameworks
1138
+ * @returns {Array<object>} eligible frameworks with eligibility details
1139
+ */
1140
+ function getEligibleFrameworks (frameworks) {
1141
+ const eligible = []
1142
+
1143
+ for (const framework of frameworks) {
1144
+ const command = getEligibleCommandMatch(framework)
1145
+ const version = getSupportedVersionDetection(framework, command?.relativePath)
1146
+ const reasons = []
1147
+
1148
+ if (!version) reasons.push(`No statically supported ${framework.name} version was found.`)
1149
+ if (!command) reasons.push(`No eligible ${framework.name} test command was found.`)
1150
+
1151
+ if (!version || !command) continue
1152
+
1153
+ eligible.push({
1154
+ ...framework,
1155
+ eligibleCommand: command,
1156
+ eligibleVersion: version,
1157
+ eligibility: {
1158
+ command: command.command,
1159
+ commandLocation: command.relativePath,
1160
+ version: version.version,
1161
+ versionLocation: version.relativePath,
1162
+ },
1163
+ ineligibleReasons: reasons,
1164
+ })
1165
+ }
1166
+
1167
+ return eligible
1168
+ }
1169
+
1170
+ /**
1171
+ * Gets the first supported version detection for a framework.
1172
+ *
1173
+ * @param {object} framework detected framework
1174
+ * @param {string} [relativePath] package manifest that owns the selected command
1175
+ * @returns {object|undefined} supported version detection
1176
+ */
1177
+ function getSupportedVersionDetection (framework, relativePath) {
1178
+ const detections = framework.versionDetections || []
1179
+ const localDetections = relativePath
1180
+ ? detections.filter(detection => detection.relativePath === relativePath)
1181
+ : []
1182
+ const candidates = localDetections.length > 0 ? localDetections : detections
1183
+
1184
+ for (const detection of candidates) {
1185
+ if (detection.version && satisfies(detection.version, framework.supportedRange)) return detection
1186
+ }
1187
+ }
1188
+
1189
+ /**
1190
+ * Gets an eligible script command for a framework.
1191
+ *
1192
+ * @param {object} framework detected framework
1193
+ * @returns {object|undefined} eligible command match
1194
+ */
1195
+ function getEligibleCommandMatch (framework) {
1196
+ const scriptMatches = framework.scriptMatches || []
1197
+
1198
+ for (const script of scriptMatches) {
1199
+ if (isIneligibleFrameworkCommand(framework.id, script.command)) continue
1200
+ return script
1201
+ }
1202
+
1203
+ if (scriptMatches.length > 0) return
1204
+
1205
+ if (framework.id === 'jest' || framework.id === 'mocha' || framework.id === 'vitest') {
1206
+ return framework.dependencyEntries?.[0] && {
1207
+ command: `direct ${framework.id} binary`,
1208
+ relativePath: framework.dependencyEntries[0].relativePath,
1209
+ }
1210
+ }
1211
+ }
1212
+
1213
+ /**
1214
+ * Checks whether a framework command is ineligible for live validation.
1215
+ *
1216
+ * @param {string} frameworkId framework id
1217
+ * @param {string} command package script command
1218
+ * @returns {boolean} whether the command is ineligible
1219
+ */
1220
+ function isIneligibleFrameworkCommand (frameworkId, command) {
1221
+ if (frameworkId === 'vitest' && /\bvitest\s+bench\b/.test(command)) return true
1222
+ if (WATCH_MODE_RE.test(command)) return true
1223
+
1224
+ return false
1225
+ }
1226
+
1227
+ /**
1228
+ * Detects unsupported test frameworks.
1229
+ *
1230
+ * @param {Array<object>} definitions unsupported framework definitions
1231
+ * @param {Array<object>} manifests package manifests
1232
+ * @param {Array<object>} scripts package scripts
1233
+ * @returns {Array<object>} detected unsupported frameworks
1234
+ */
1235
+ function detectUnsupportedFrameworks (definitions, manifests, scripts) {
1236
+ const frameworks = []
1237
+
1238
+ for (const definition of definitions) {
1239
+ const dependencyEntries = findDependencyEntries(manifests, definition.packages)
1240
+ const scriptMatches = findScriptMatches(scripts, definition.commandPatterns)
1241
+
1242
+ if (!dependencyEntries.length && !scriptMatches.length) continue
1243
+
1244
+ frameworks.push({
1245
+ ...definition,
1246
+ locations: unique([
1247
+ ...dependencyEntries.map(entry => entry.relativePath),
1248
+ ...scriptMatches.map(script => script.relativePath),
1249
+ ]),
1250
+ })
1251
+ }
1252
+
1253
+ return frameworks
1254
+ }
1255
+
1256
+ /**
1257
+ * Collects useful boolean evidence from scanned files and environment.
1258
+ *
1259
+ * @param {Array<object>} textFiles scanned text files
1260
+ * @param {NodeJS.ProcessEnv} env environment
1261
+ * @returns {object} evidence object
1262
+ */
1263
+ function collectEvidence (textFiles, env) {
1264
+ const ciInitLocations = findNodeOptionsPreloadLocations(textFiles, INIT_PRELOAD_RE)
1265
+ const registerLocations = findNodeOptionsPreloadLocations(textFiles, REGISTER_PRELOAD_RE)
1266
+
1267
+ return {
1268
+ ciInitLocations,
1269
+ directCiInitLocations: findLocations(textFiles, DIRECT_CI_INIT_RE),
1270
+ wrongInitLocations: findLocations(textFiles.filter(isTestSetupOrCiFile), WRONG_INIT_RE),
1271
+ cypressManualPluginLocations: findLocations(textFiles, CYPRESS_MANUAL_PLUGIN_RE),
1272
+ cypressSupportLocations: findLocations(textFiles, CYPRESS_SUPPORT_RE),
1273
+ cypressSupportDisabledLocations: findLocations(textFiles, CYPRESS_SUPPORT_DISABLED_RE),
1274
+ gitStrategyNoneLocations: findLocations(textFiles, /GIT_STRATEGY\s*:\s*none\b/i),
1275
+ hasCiInit: ciInitLocations.length > 0,
1276
+ hasRegister: registerLocations.length > 0,
1277
+ hasCiVisibilityDisabled: findLocations(textFiles, CI_DISABLED_RE).length > 0,
1278
+ hasItrDisabled: findLocations(textFiles, ITR_DISABLED_RE).length > 0,
1279
+ hasGitUploadDisabled: findLocations(textFiles, GIT_UPLOAD_DISABLED_RE).length > 0,
1280
+ hasAgentlessEnabled: findLocations(textFiles, AGENTLESS_ENABLED_RE).length > 0 ||
1281
+ isTrueLike(env.DD_CIVISIBILITY_AGENTLESS_ENABLED),
1282
+ hasApiKey: findLocations(textFiles, API_KEY_RE).length > 0,
1283
+ hasService: findLocations(textFiles, SERVICE_RE).length > 0,
1284
+ hasOtelOtlpExporter: findLocations(textFiles, OTEL_OTLP_RE).length > 0,
1285
+ }
1286
+ }
1287
+
1288
+ /**
1289
+ * Finds dependency entries in package manifests.
1290
+ *
1291
+ * @param {Array<object>} manifests package manifests
1292
+ * @param {string[]} packageNames package names
1293
+ * @returns {Array<object>} matching dependency entries
1294
+ */
1295
+ function findDependencyEntries (manifests, packageNames) {
1296
+ const entries = []
1297
+ const packageSet = new Set(packageNames)
1298
+
1299
+ for (const manifest of manifests) {
1300
+ for (const section of PACKAGE_SECTIONS) {
1301
+ const dependencies = manifest.json[section]
1302
+ if (!dependencies) continue
1303
+
1304
+ for (const [name, range] of Object.entries(dependencies)) {
1305
+ if (!packageSet.has(name)) continue
1306
+
1307
+ entries.push({
1308
+ packageName: name,
1309
+ rawVersion: String(range),
1310
+ section,
1311
+ relativePath: manifest.relativePath,
1312
+ })
1313
+ }
1314
+ }
1315
+ }
1316
+
1317
+ return entries
1318
+ }
1319
+
1320
+ /**
1321
+ * Resolves framework package versions from installed modules and manifest ranges.
1322
+ *
1323
+ * @param {string} root repository root
1324
+ * @param {string[]} packageNames package names
1325
+ * @param {Array<object>} dependencyEntries dependency entries
1326
+ * @returns {Array<object>} version detections
1327
+ */
1328
+ function getVersionDetections (root, packageNames, dependencyEntries) {
1329
+ const detections = []
1330
+
1331
+ for (const entry of dependencyEntries) {
1332
+ const packageRoot = path.dirname(path.resolve(root, entry.relativePath))
1333
+ const installedVersion = getInstalledPackageVersion(root, entry.packageName, packageRoot)
1334
+ detections.push({
1335
+ packageName: entry.packageName,
1336
+ rawVersion: installedVersion || entry.rawVersion,
1337
+ version: installedVersion || coerceVersion(entry.rawVersion),
1338
+ relativePath: entry.relativePath,
1339
+ source: installedVersion ? 'installed' : 'manifest',
1340
+ })
1341
+ }
1342
+
1343
+ if (detections.length > 0) return uniqueVersionDetections(detections)
1344
+
1345
+ for (const packageName of packageNames) {
1346
+ const version = getInstalledPackageVersion(root, packageName, root)
1347
+ if (!version) continue
1348
+
1349
+ detections.push({
1350
+ packageName,
1351
+ rawVersion: version,
1352
+ version,
1353
+ source: 'installed',
1354
+ })
1355
+ }
1356
+
1357
+ return uniqueVersionDetections(detections)
1358
+ }
1359
+
1360
+ /**
1361
+ * Reads an installed package version from node_modules.
1362
+ *
1363
+ * @param {string} root repository root
1364
+ * @param {string} packageName package name
1365
+ * @param {string} packageRoot package directory where resolution begins
1366
+ * @returns {string|undefined} installed version
1367
+ */
1368
+ function getInstalledPackageVersion (root, packageName, packageRoot) {
1369
+ const repositoryRoot = path.resolve(root)
1370
+ let directory = path.resolve(packageRoot)
1371
+ if (!isPathInside(repositoryRoot, directory)) return
1372
+
1373
+ while (isPathInside(repositoryRoot, directory)) {
1374
+ const packageJsonPath = path.join(directory, 'node_modules', ...packageName.split('/'), 'package.json')
1375
+ const json = readJsonFile(packageJsonPath)
1376
+ if (typeof json?.version === 'string') return json.version
1377
+ if (directory === repositoryRoot) return
1378
+
1379
+ const parent = path.dirname(directory)
1380
+ if (parent === directory) return
1381
+ directory = parent
1382
+ }
1383
+ }
1384
+
1385
+ /**
1386
+ * Finds scripts matching any pattern.
1387
+ *
1388
+ * @param {Array<object>} scripts package scripts
1389
+ * @param {RegExp[]} patterns command patterns
1390
+ * @returns {Array<object>} matching scripts
1391
+ */
1392
+ function findScriptMatches (scripts, patterns) {
1393
+ const matches = []
1394
+
1395
+ for (const script of scripts) {
1396
+ if (!/test|spec|e2e|integration|unit/i.test(script.name) &&
1397
+ !patterns.some(pattern => pattern.test(script.command))) {
1398
+ continue
1399
+ }
1400
+
1401
+ if (patterns.some(pattern => pattern.test(script.command))) {
1402
+ matches.push(script)
1403
+ }
1404
+ }
1405
+
1406
+ return matches
1407
+ }
1408
+
1409
+ /**
1410
+ * Finds scanned config files that match definition patterns.
1411
+ *
1412
+ * @param {Array<object>} textFiles scanned text files
1413
+ * @param {RegExp[]} patterns filename patterns
1414
+ * @returns {string[]} matching relative paths
1415
+ */
1416
+ function findConfigMatches (textFiles, patterns) {
1417
+ const matches = []
1418
+
1419
+ for (const file of textFiles) {
1420
+ const basename = path.basename(file.relativePath)
1421
+ if (patterns.some(pattern => pattern.test(basename))) {
1422
+ matches.push(file.relativePath)
1423
+ }
1424
+ }
1425
+
1426
+ return matches
1427
+ }
1428
+
1429
+ /**
1430
+ * Finds Jest TypeScript config files.
1431
+ *
1432
+ * @param {Array<object>} textFiles scanned text files
1433
+ * @returns {string[]} matching relative paths
1434
+ */
1435
+ function findJestTypescriptConfigLocations (textFiles) {
1436
+ return textFiles
1437
+ .map(file => file.relativePath)
1438
+ .filter(relativePath => /(?:^|\/)jest\.config\.(?:ts|mts|cts)$/.test(relativePath))
1439
+ }
1440
+
1441
+ /**
1442
+ * Finds files where NODE_OPTIONS appears close to a supported Node preload option.
1443
+ *
1444
+ * @param {Array<object>} textFiles scanned text files
1445
+ * @param {RegExp} preloadPattern Node preload option pattern
1446
+ * @returns {string[]} matching relative paths
1447
+ */
1448
+ function findNodeOptionsPreloadLocations (textFiles, preloadPattern) {
1449
+ const locations = []
1450
+
1451
+ for (const file of textFiles) {
1452
+ const lines = file.content.split(/\r?\n/)
1453
+ for (let i = 0; i < lines.length; i++) {
1454
+ if (!NODE_OPTIONS_RE.test(lines[i])) continue
1455
+
1456
+ const window = lines.slice(i, i + 4).join('\n')
1457
+ preloadPattern.lastIndex = 0
1458
+ if (preloadPattern.test(window)) {
1459
+ locations.push(file.relativePath)
1460
+ break
1461
+ }
1462
+ }
1463
+ }
1464
+
1465
+ return unique(locations)
1466
+ }
1467
+
1468
+ /**
1469
+ * Finds files whose content matches a pattern.
1470
+ *
1471
+ * @param {Array<object>} textFiles scanned text files
1472
+ * @param {RegExp} pattern content pattern
1473
+ * @returns {string[]} matching relative paths
1474
+ */
1475
+ function findLocations (textFiles, pattern) {
1476
+ const locations = []
1477
+
1478
+ for (const file of textFiles) {
1479
+ pattern.lastIndex = 0
1480
+ if (pattern.test(file.content)) {
1481
+ locations.push(file.relativePath)
1482
+ }
1483
+ }
1484
+
1485
+ return unique(locations)
1486
+ }
1487
+
1488
+ /**
1489
+ * Collects text-like repository files.
1490
+ *
1491
+ * @param {string} root repository root
1492
+ * @param {string|undefined} physicalRoot physical repository root
1493
+ * @param {number} maxFiles maximum number of files
1494
+ * @param {string[]} [excludePaths] repository paths excluded from the scan
1495
+ * @returns {Array<string> & {truncated?: boolean}} relative paths
1496
+ */
1497
+ function collectTextFiles (root, physicalRoot, maxFiles, excludePaths = []) {
1498
+ const files = []
1499
+ const seen = new Set()
1500
+ const exclusions = getScanExclusions(root, excludePaths)
1501
+ files.truncated = false
1502
+
1503
+ addTextFileIfPresent('package.json')
1504
+
1505
+ function walk (dir, relativeDir) {
1506
+ if (files.length >= maxFiles) {
1507
+ files.truncated = true
1508
+ return
1509
+ }
1510
+
1511
+ let entries
1512
+ try {
1513
+ entries = fs.readdirSync(dir, { withFileTypes: true })
1514
+ } catch {
1515
+ return
1516
+ }
1517
+
1518
+ entries.sort((a, b) => a.name.localeCompare(b.name))
1519
+
1520
+ for (const entry of entries) {
1521
+ if (files.length >= maxFiles) {
1522
+ files.truncated = true
1523
+ return
1524
+ }
1525
+
1526
+ const relativePath = relativeDir ? path.join(relativeDir, entry.name) : entry.name
1527
+ const absolutePath = path.join(root, relativePath)
1528
+ if (isScanPathExcluded(relativePath, exclusions)) continue
1529
+
1530
+ if (entry.isDirectory()) {
1531
+ if (!SKIPPED_DIRECTORIES.has(entry.name)) {
1532
+ walk(absolutePath, relativePath)
1533
+ }
1534
+ continue
1535
+ }
1536
+
1537
+ if (entry.isFile() && isTextFileName(entry.name)) {
1538
+ addTextFile(relativePath)
1539
+ }
1540
+ }
1541
+ }
1542
+
1543
+ function addTextFileIfPresent (relativePath) {
1544
+ if (!getSafeScannedFile(root, physicalRoot, relativePath)) return
1545
+ addTextFile(relativePath)
1546
+ }
1547
+
1548
+ function addTextFile (relativePath) {
1549
+ const normalizedPath = normalizeRelativePath(relativePath)
1550
+ if (seen.has(normalizedPath) || SKIPPED_FILES.has(normalizedPath)) return
1551
+
1552
+ if (files.length >= maxFiles) {
1553
+ files.truncated = true
1554
+ return
1555
+ }
1556
+
1557
+ seen.add(normalizedPath)
1558
+ files.push(relativePath)
1559
+ }
1560
+
1561
+ walk(root, '')
1562
+ return files
1563
+ }
1564
+
1565
+ /**
1566
+ * Normalizes caller-provided scan exclusions to repository-relative paths.
1567
+ *
1568
+ * @param {string} root repository root
1569
+ * @param {string[]} excludePaths paths to exclude
1570
+ * @returns {Set<string>} normalized repository-relative exclusions
1571
+ */
1572
+ function getScanExclusions (root, excludePaths) {
1573
+ const exclusions = new Set()
1574
+ for (const candidate of excludePaths) {
1575
+ if (typeof candidate !== 'string') continue
1576
+ const relative = path.relative(root, path.resolve(root, candidate))
1577
+ if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) continue
1578
+ exclusions.add(normalizeRelativePath(relative))
1579
+ }
1580
+ return exclusions
1581
+ }
1582
+
1583
+ /**
1584
+ * Checks whether a scanned path is inside a caller-provided exclusion.
1585
+ *
1586
+ * @param {string} relativePath repository-relative path
1587
+ * @param {Set<string>} exclusions normalized exclusions
1588
+ * @returns {boolean} whether the path must be skipped
1589
+ */
1590
+ function isScanPathExcluded (relativePath, exclusions) {
1591
+ const normalizedPath = normalizeRelativePath(relativePath)
1592
+ for (const exclusion of exclusions) {
1593
+ if (normalizedPath === exclusion || normalizedPath.startsWith(`${exclusion}/`)) return true
1594
+ }
1595
+ return false
1596
+ }
1597
+
1598
+ /**
1599
+ * Reads scanned text files within the size limit.
1600
+ *
1601
+ * @param {string} root repository root
1602
+ * @param {string|undefined} physicalRoot physical repository root
1603
+ * @param {string[]} files relative file paths
1604
+ * @param {number} maxTotalBytes maximum aggregate bytes to retain
1605
+ * @returns {Array<object>} text file records
1606
+ */
1607
+ function readTextFiles (root, physicalRoot, files, maxTotalBytes) {
1608
+ const textFiles = []
1609
+ let totalBytes = 0
1610
+ textFiles.truncated = false
1611
+
1612
+ for (const relativePath of files) {
1613
+ const file = getSafeScannedFile(root, physicalRoot, relativePath)
1614
+ if (!file) continue
1615
+
1616
+ if (file.stat.size > MAX_TEXT_FILE_SIZE) continue
1617
+ if (totalBytes + file.stat.size > maxTotalBytes) {
1618
+ textFiles.truncated = true
1619
+ break
1620
+ }
1621
+
1622
+ try {
1623
+ const content = fs.readFileSync(file.filename, 'utf8')
1624
+ const contentBytes = Buffer.byteLength(content)
1625
+ if (contentBytes > MAX_TEXT_FILE_SIZE) continue
1626
+ if (totalBytes + contentBytes > maxTotalBytes) {
1627
+ textFiles.truncated = true
1628
+ break
1629
+ }
1630
+ textFiles.push({
1631
+ relativePath: normalizeRelativePath(relativePath),
1632
+ content,
1633
+ })
1634
+ totalBytes += contentBytes
1635
+ } catch {
1636
+ // Ignore files that cannot be read as UTF-8.
1637
+ }
1638
+ }
1639
+
1640
+ return textFiles
1641
+ }
1642
+
1643
+ /**
1644
+ * Resolves the physical repository root used to constrain scanned file reads.
1645
+ *
1646
+ * @param {string} root repository root
1647
+ * @returns {string|undefined} physical directory path
1648
+ */
1649
+ function getPhysicalRoot (root) {
1650
+ try {
1651
+ const physicalRoot = fs.realpathSync(root)
1652
+ return fs.statSync(physicalRoot).isDirectory() ? physicalRoot : undefined
1653
+ } catch {}
1654
+ }
1655
+
1656
+ /**
1657
+ * Resolves one regular non-symlink file without escaping the physical repository root.
1658
+ *
1659
+ * @param {string} root lexical repository root
1660
+ * @param {string|undefined} physicalRoot physical repository root
1661
+ * @param {string} relativePath repository-relative candidate
1662
+ * @returns {{filename: string, stat: fs.Stats}|undefined} safe file information
1663
+ */
1664
+ function getSafeScannedFile (root, physicalRoot, relativePath) {
1665
+ if (!physicalRoot) return
1666
+
1667
+ const lexicalRoot = path.resolve(root)
1668
+ const absolutePath = path.resolve(lexicalRoot, relativePath)
1669
+ if (!isPathInside(lexicalRoot, absolutePath)) return
1670
+
1671
+ try {
1672
+ const entry = fs.lstatSync(absolutePath)
1673
+ if (!entry.isFile() || entry.isSymbolicLink()) return
1674
+
1675
+ const filename = fs.realpathSync(absolutePath)
1676
+ if (!isPathInside(physicalRoot, filename)) return
1677
+
1678
+ const stat = fs.statSync(filename)
1679
+ return stat.isFile() ? { filename, stat } : undefined
1680
+ } catch {}
1681
+ }
1682
+
1683
+ /**
1684
+ * Checks lexical or physical path containment.
1685
+ *
1686
+ * @param {string} root allowed root
1687
+ * @param {string} filename candidate path
1688
+ * @returns {boolean} whether the candidate is inside the root
1689
+ */
1690
+ function isPathInside (root, filename) {
1691
+ const relative = path.relative(root, filename)
1692
+ return relative === '' || (relative && !relative.startsWith('..') && !path.isAbsolute(relative))
1693
+ }
1694
+
1695
+ /**
1696
+ * Checks whether a filename should be scanned as text.
1697
+ *
1698
+ * @param {string} name filename
1699
+ * @returns {boolean} true if the file is text-like
1700
+ */
1701
+ function isTextFileName (name) {
1702
+ return TEXT_FILE_NAMES.has(name) || TEXT_EXTENSIONS.has(path.extname(name))
1703
+ }
1704
+
1705
+ /**
1706
+ * Checks whether a file is a common CI workflow file.
1707
+ *
1708
+ * @param {string} relativePath relative path
1709
+ * @returns {boolean} true if the path is a workflow file
1710
+ */
1711
+ function isWorkflowFile (relativePath) {
1712
+ return relativePath.startsWith('.github/workflows/') ||
1713
+ relativePath === '.gitlab-ci.yml' ||
1714
+ relativePath === '.gitlab-ci.yaml' ||
1715
+ relativePath === 'bitbucket-pipelines.yml' ||
1716
+ relativePath === 'bitbucket-pipelines.yaml' ||
1717
+ relativePath === 'azure-pipelines.yml' ||
1718
+ relativePath === 'azure-pipelines.yaml' ||
1719
+ /^\.azure-pipelines\/.+\.ya?ml$/.test(relativePath) ||
1720
+ relativePath === 'Jenkinsfile' ||
1721
+ relativePath === '.circleci/config.yml' ||
1722
+ relativePath === '.circleci/config.yaml' ||
1723
+ relativePath === '.buildkite/pipeline.yml' ||
1724
+ relativePath === '.buildkite/pipeline.yaml'
1725
+ }
1726
+
1727
+ /**
1728
+ * Checks whether a scanned file is likely to configure test process initialization.
1729
+ *
1730
+ * @param {object} file scanned text file
1731
+ * @returns {boolean} true when plain dd-trace init should be treated as test setup evidence
1732
+ */
1733
+ function isTestSetupOrCiFile (file) {
1734
+ const relativePath = file.relativePath
1735
+ const basename = path.basename(relativePath)
1736
+
1737
+ if (isWorkflowFile(relativePath)) return true
1738
+ if (basename === 'package.json') return true
1739
+ if (/^(?:jest|config-jest|vitest|vite|playwright|cypress|cucumber)\.config\./.test(basename)) return true
1740
+ if (/^\.mocharc\./.test(basename)) return true
1741
+ if (basename === 'cypress.json') return true
1742
+ if (/(?:setup|bootstrap)/i.test(basename)) return true
1743
+ if (relativePath.startsWith('cypress/support/')) return true
1744
+
1745
+ return false
1746
+ }
1747
+
1748
+ /**
1749
+ * Runs git --version to check availability.
1750
+ *
1751
+ * @param {Function} execFile command runner
1752
+ * @param {string} root repository root
1753
+ * @param {string|undefined} gitExecutable trusted git executable
1754
+ * @param {NodeJS.ProcessEnv} env credential-free git environment
1755
+ * @returns {boolean} true if git runs
1756
+ */
1757
+ function canRunGit (execFile, root, gitExecutable, env) {
1758
+ if (!gitExecutable) return false
1759
+
1760
+ try {
1761
+ execFile(gitExecutable, ['--version'], { cwd: root, env, stdio: 'pipe', timeout: 2000 })
1762
+ return true
1763
+ } catch {
1764
+ return false
1765
+ }
1766
+ }
1767
+
1768
+ /**
1769
+ * Runs a git command and trims output.
1770
+ *
1771
+ * @param {Function} execFile command runner
1772
+ * @param {string} root repository root
1773
+ * @param {string} gitExecutable trusted git executable
1774
+ * @param {NodeJS.ProcessEnv} env credential-free git environment
1775
+ * @param {string[]} args git arguments
1776
+ * @returns {string} command output
1777
+ */
1778
+ function runGit (execFile, root, gitExecutable, env, args) {
1779
+ try {
1780
+ return String(execFile(gitExecutable, args, { cwd: root, env, stdio: 'pipe', timeout: 2000 })).trim()
1781
+ } catch {
1782
+ return ''
1783
+ }
1784
+ }
1785
+
1786
+ /**
1787
+ * Resolves git without trusting repository-controlled PATH entries.
1788
+ *
1789
+ * @returns {string|undefined} trusted absolute git path
1790
+ */
1791
+ function findTrustedGitExecutable () {
1792
+ const candidates = process.platform === 'win32'
1793
+ ? [
1794
+ String.raw`C:\Program Files\Git\cmd\git.exe`,
1795
+ String.raw`C:\Program Files\Git\bin\git.exe`,
1796
+ String.raw`C:\Program Files (x86)\Git\cmd\git.exe`,
1797
+ String.raw`C:\Program Files (x86)\Git\bin\git.exe`,
1798
+ ]
1799
+ : ['/usr/bin/git', '/usr/local/bin/git', '/opt/homebrew/bin/git']
1800
+
1801
+ for (const candidate of candidates) {
1802
+ let resolved
1803
+ try {
1804
+ resolved = fs.realpathSync(candidate)
1805
+ fs.accessSync(resolved, fs.constants.X_OK)
1806
+ } catch {
1807
+ continue
1808
+ }
1809
+
1810
+ return resolved
1811
+ }
1812
+ }
1813
+
1814
+ /**
1815
+ * Creates the minimal environment needed by read-only local git metadata commands.
1816
+ *
1817
+ * @param {string|undefined} gitExecutable trusted git executable
1818
+ * @param {NodeJS.ProcessEnv} sourceEnv source environment
1819
+ * @returns {NodeJS.ProcessEnv} credential-free git environment
1820
+ */
1821
+ function getGitEnvironment (gitExecutable, sourceEnv) {
1822
+ const env = {
1823
+ GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
1824
+ GIT_CONFIG_NOSYSTEM: '1',
1825
+ GIT_OPTIONAL_LOCKS: '0',
1826
+ GIT_TERMINAL_PROMPT: '0',
1827
+ }
1828
+
1829
+ for (const name of ['COMSPEC', 'ComSpec', 'PATHEXT', 'SystemRoot', 'WINDIR', 'windir']) {
1830
+ if (sourceEnv[name] !== undefined) env[name] = sourceEnv[name]
1831
+ }
1832
+ if (gitExecutable && path.isAbsolute(gitExecutable)) env.PATH = path.dirname(gitExecutable)
1833
+ return env
1834
+ }
1835
+
1836
+ /**
1837
+ * Reads a JSON file.
1838
+ *
1839
+ * @param {string} filePath absolute file path
1840
+ * @returns {object|undefined} parsed JSON
1841
+ */
1842
+ function readJsonFile (filePath) {
1843
+ try {
1844
+ return parseJson(fs.readFileSync(filePath, 'utf8'))
1845
+ } catch {
1846
+ // File is absent or unreadable.
1847
+ }
1848
+ }
1849
+
1850
+ /**
1851
+ * Parses JSON safely.
1852
+ *
1853
+ * @param {string} content JSON content
1854
+ * @returns {object|undefined} parsed JSON
1855
+ */
1856
+ function parseJson (content) {
1857
+ try {
1858
+ return JSON.parse(content)
1859
+ } catch {
1860
+ // Invalid JSON is reported by the checks that depend on it.
1861
+ }
1862
+ }
1863
+
1864
+ /**
1865
+ * Extracts a comparable semantic version from a package range.
1866
+ *
1867
+ * @param {string} rawVersion raw package version or range
1868
+ * @returns {string|undefined} comparable version
1869
+ */
1870
+ function coerceVersion (rawVersion) {
1871
+ const aliasedVersion = rawVersion.match(/^npm:[^@]+@(.+)$/)?.[1] || rawVersion
1872
+ if (isAmbiguousRange(aliasedVersion)) return
1873
+
1874
+ const match = aliasedVersion.match(/\d+(?:\.\d+){0,2}/)
1875
+ if (!match) return
1876
+
1877
+ const parts = match[0].split('.')
1878
+ while (parts.length < 3) {
1879
+ parts.push('0')
1880
+ }
1881
+ return parts.slice(0, 3).join('.')
1882
+ }
1883
+
1884
+ /**
1885
+ * Checks whether a manifest dependency range cannot be represented by one comparable version.
1886
+ *
1887
+ * @param {string} rawVersion package manifest version/range
1888
+ * @returns {boolean} true when static diagnosis should ask the user to verify the range
1889
+ */
1890
+ function isAmbiguousRange (rawVersion) {
1891
+ const version = String(rawVersion || '').trim()
1892
+ if (!version) return true
1893
+ if (version.includes('||')) return true
1894
+ return /<=?\s*\d/.test(version)
1895
+ }
1896
+
1897
+ /**
1898
+ * Checks whether a framework id is present.
1899
+ *
1900
+ * @param {Array<object>} frameworks detected frameworks
1901
+ * @param {string} id framework id
1902
+ * @returns {boolean} true if present
1903
+ */
1904
+ function hasFramework (frameworks, id) {
1905
+ return frameworks.some(framework => framework.id === id)
1906
+ }
1907
+
1908
+ /**
1909
+ * Checks whether NODE_OPTIONS preloads dd-trace/ci/init.
1910
+ *
1911
+ * @param {string|undefined} nodeOptions NODE_OPTIONS value
1912
+ * @returns {boolean} true if dd-trace/ci/init is present
1913
+ */
1914
+ function hasCiInitInNodeOptions (nodeOptions) {
1915
+ return !!nodeOptions && INIT_PRELOAD_RE.test(nodeOptions)
1916
+ }
1917
+
1918
+ /**
1919
+ * Checks whether NODE_OPTIONS preloads dd-trace/register.js.
1920
+ *
1921
+ * @param {string|undefined} nodeOptions NODE_OPTIONS value
1922
+ * @returns {boolean} true if dd-trace/register.js is present
1923
+ */
1924
+ function hasRegisterInNodeOptions (nodeOptions) {
1925
+ return !!nodeOptions && REGISTER_PRELOAD_RE.test(nodeOptions)
1926
+ }
1927
+
1928
+ /**
1929
+ * Checks whether environment contains branch or tag metadata.
1930
+ *
1931
+ * @param {NodeJS.ProcessEnv} env environment
1932
+ * @returns {boolean} true if branch metadata exists
1933
+ */
1934
+ function hasBranchMetadata (env) {
1935
+ return !!(
1936
+ env.DD_GIT_BRANCH ||
1937
+ env.DD_GIT_TAG ||
1938
+ env.GITHUB_HEAD_REF ||
1939
+ env.GITHUB_REF_NAME ||
1940
+ env.CI_COMMIT_REF_NAME ||
1941
+ env.CIRCLE_BRANCH ||
1942
+ env.GIT_BRANCH ||
1943
+ env.BUILDKITE_BRANCH ||
1944
+ env.TRAVIS_BRANCH ||
1945
+ env.BITBUCKET_BRANCH ||
1946
+ env.DRONE_BRANCH ||
1947
+ env.BUDDY_EXECUTION_BRANCH ||
1948
+ env.BITRISE_GIT_BRANCH
1949
+ )
1950
+ }
1951
+
1952
+ /**
1953
+ * Checks whether environment contains commit SHA metadata.
1954
+ *
1955
+ * @param {NodeJS.ProcessEnv} env environment
1956
+ * @returns {boolean} true if SHA metadata exists
1957
+ */
1958
+ function hasShaMetadata (env) {
1959
+ return !!(
1960
+ env.DD_GIT_COMMIT_SHA ||
1961
+ env.GITHUB_SHA ||
1962
+ env.CI_COMMIT_SHA ||
1963
+ env.CIRCLE_SHA1 ||
1964
+ env.GIT_COMMIT ||
1965
+ env.BUILDKITE_COMMIT ||
1966
+ env.TRAVIS_COMMIT ||
1967
+ env.BITBUCKET_COMMIT ||
1968
+ env.DRONE_COMMIT ||
1969
+ env.BUDDY_EXECUTION_REVISION
1970
+ )
1971
+ }
1972
+
1973
+ /**
1974
+ * Checks truthy string env values.
1975
+ *
1976
+ * @param {string|undefined} value value to inspect
1977
+ * @returns {boolean} true if value is true-like
1978
+ */
1979
+ function isTrueLike (value) {
1980
+ return /^(?:1|true)$/i.test(String(value || ''))
1981
+ }
1982
+
1983
+ /**
1984
+ * Checks false-like string env values.
1985
+ *
1986
+ * @param {string|undefined} value value to inspect
1987
+ * @returns {boolean} true if value is false-like
1988
+ */
1989
+ function isFalseLike (value) {
1990
+ return /^(?:0|false)$/i.test(String(value || ''))
1991
+ }
1992
+
1993
+ /**
1994
+ * Formats a list of locations for text output.
1995
+ *
1996
+ * @param {string[]} locations relative paths
1997
+ * @returns {string} formatted locations
1998
+ */
1999
+ function formatLocations (locations) {
2000
+ const uniqueLocations = unique(locations).slice(0, 5)
2001
+ const suffix = locations.length > uniqueLocations.length
2002
+ ? `, and ${locations.length - uniqueLocations.length} more`
2003
+ : ''
2004
+ return uniqueLocations.join(', ') + suffix
2005
+ }
2006
+
2007
+ /**
2008
+ * Serializes a supported framework for JSON output.
2009
+ *
2010
+ * @param {object} framework detected framework
2011
+ * @returns {object} serializable framework summary
2012
+ */
2013
+ function serializeSupportedFramework (framework) {
2014
+ return {
2015
+ id: framework.id,
2016
+ name: framework.name,
2017
+ packages: framework.packages,
2018
+ supportedRange: framework.supportedRange,
2019
+ locations: framework.locations,
2020
+ versionDetections: framework.versionDetections,
2021
+ }
2022
+ }
2023
+
2024
+ /**
2025
+ * Serializes an eligible framework for JSON output.
2026
+ *
2027
+ * @param {object} framework eligible framework
2028
+ * @returns {object} serializable eligible framework summary
2029
+ */
2030
+ function serializeEligibleFramework (framework) {
2031
+ return {
2032
+ id: framework.id,
2033
+ name: framework.name,
2034
+ command: framework.eligibility.command,
2035
+ commandLocation: framework.eligibility.commandLocation,
2036
+ supportedRange: framework.supportedRange,
2037
+ version: framework.eligibility.version,
2038
+ versionLocation: framework.eligibility.versionLocation,
2039
+ }
2040
+ }
2041
+
2042
+ /**
2043
+ * Serializes an unsupported framework for JSON output.
2044
+ *
2045
+ * @param {object} framework detected unsupported framework
2046
+ * @returns {object} serializable framework summary
2047
+ */
2048
+ function serializeUnsupportedFramework (framework) {
2049
+ return {
2050
+ id: framework.id,
2051
+ name: framework.name,
2052
+ locations: framework.locations,
2053
+ }
2054
+ }
2055
+
2056
+ /**
2057
+ * Deduplicates values.
2058
+ *
2059
+ * @param {Array<string>} values values
2060
+ * @returns {string[]} unique values
2061
+ */
2062
+ function unique (values) {
2063
+ return [...new Set(values.filter(Boolean))]
2064
+ }
2065
+
2066
+ /**
2067
+ * Deduplicates version detections.
2068
+ *
2069
+ * @param {Array<object>} detections version detections
2070
+ * @returns {Array<object>} unique detections
2071
+ */
2072
+ function uniqueVersionDetections (detections) {
2073
+ const seen = new Set()
2074
+ const uniqueDetections = []
2075
+
2076
+ for (const detection of detections) {
2077
+ const key = `${detection.packageName}:${detection.rawVersion}:${detection.relativePath || ''}`
2078
+ if (seen.has(key)) continue
2079
+
2080
+ seen.add(key)
2081
+ uniqueDetections.push(detection)
2082
+ }
2083
+
2084
+ return uniqueDetections
2085
+ }
2086
+
2087
+ /**
2088
+ * Normalizes relative paths to POSIX separators for stable output.
2089
+ *
2090
+ * @param {string} relativePath relative path
2091
+ * @returns {string} normalized path
2092
+ */
2093
+ function normalizeRelativePath (relativePath) {
2094
+ return relativePath.split(path.sep).join('/')
2095
+ }
2096
+
2097
+ module.exports = {
2098
+ getFrameworkDefinitions,
2099
+ runDiagnosis,
2100
+ }