dd-trace 6.6.0 → 6.8.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 (209) 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 +8 -31
  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 +84 -22
  20. package/ci/test-optimization-validation/generated-test-contract.js +281 -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 +815 -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/offline-output.js +6 -6
  28. package/ci/test-optimization-validation/plan-writer.js +243 -984
  29. package/ci/test-optimization-validation/preflight-runner.js +49 -55
  30. package/ci/test-optimization-validation/redaction.js +2 -1
  31. package/ci/test-optimization-validation/report-writer.js +391 -1357
  32. package/ci/test-optimization-validation/result-semantics.js +86 -0
  33. package/ci/test-optimization-validation/runner-command.js +249 -0
  34. package/ci/test-optimization-validation/runner-contract.js +664 -0
  35. package/ci/test-optimization-validation/safe-files.js +4 -4
  36. package/ci/test-optimization-validation/scenarios/auto-test-retries.js +29 -3
  37. package/ci/test-optimization-validation/scenarios/basic-reporting.js +240 -543
  38. package/ci/test-optimization-validation/scenarios/ci-wiring.js +450 -670
  39. package/ci/test-optimization-validation/scenarios/early-flake-detection.js +4 -3
  40. package/ci/test-optimization-validation/scenarios/helpers.js +67 -9
  41. package/ci/test-optimization-validation/scenarios/test-management.js +6 -5
  42. package/ci/test-optimization-validation/source-text.js +87 -0
  43. package/ci/test-optimization-validation/test-output.js +12 -22
  44. package/ci/vitest-no-worker-init-setup.mjs +348 -51
  45. package/ext/tags.d.ts +1 -0
  46. package/index.d.ts +84 -13
  47. package/package.json +10 -4
  48. package/packages/datadog-core/src/utils/src/kebabcase.js +8 -2
  49. package/packages/datadog-esbuild/index.js +4 -2
  50. package/packages/datadog-esbuild/src/utils.js +1 -1
  51. package/packages/datadog-instrumentations/src/claude-agent-sdk.js +4 -4
  52. package/packages/datadog-instrumentations/src/cucumber.js +9 -6
  53. package/packages/datadog-instrumentations/src/helpers/extract-package-and-module-path.js +10 -0
  54. package/packages/datadog-instrumentations/src/helpers/hook.js +19 -2
  55. package/packages/datadog-instrumentations/src/helpers/hooks.js +4 -0
  56. package/packages/datadog-instrumentations/src/helpers/register.js +9 -3
  57. package/packages/datadog-instrumentations/src/helpers/rewriter/index.js +1 -0
  58. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/index.js +2 -0
  59. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/openai-agents.js +31 -0
  60. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js +57 -0
  61. package/packages/datadog-instrumentations/src/helpers/rewriter/transforms.js +50 -18
  62. package/packages/datadog-instrumentations/src/helpers/router-helper.js +1 -1
  63. package/packages/datadog-instrumentations/src/helpers/shared-utils.js +39 -0
  64. package/packages/datadog-instrumentations/src/http/client.js +1 -3
  65. package/packages/datadog-instrumentations/src/jest.js +8 -14
  66. package/packages/datadog-instrumentations/src/mocha/common.js +1 -3
  67. package/packages/datadog-instrumentations/src/mocha/main.js +2 -42
  68. package/packages/datadog-instrumentations/src/mocha/utils.js +68 -2
  69. package/packages/datadog-instrumentations/src/mocha/webdriverio-protocol.js +19 -0
  70. package/packages/datadog-instrumentations/src/mocha/worker.js +446 -42
  71. package/packages/datadog-instrumentations/src/moleculer/client.js +1 -1
  72. package/packages/datadog-instrumentations/src/openai-agents.js +159 -0
  73. package/packages/datadog-instrumentations/src/playwright.js +3 -2
  74. package/packages/datadog-instrumentations/src/rhea.js +1 -1
  75. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +525 -56
  76. package/packages/datadog-instrumentations/src/vitest-main.js +90 -10
  77. package/packages/datadog-instrumentations/src/webdriverio.js +914 -0
  78. package/packages/datadog-plugin-aerospike/src/index.js +1 -3
  79. package/packages/datadog-plugin-amqplib/src/client.js +1 -1
  80. package/packages/datadog-plugin-amqplib/src/producer.js +1 -1
  81. package/packages/datadog-plugin-aws-sdk/src/services/eventbridge.js +2 -2
  82. package/packages/datadog-plugin-aws-sdk/src/services/stepfunctions.js +2 -2
  83. package/packages/datadog-plugin-azure-event-hubs/src/producer.js +1 -3
  84. package/packages/datadog-plugin-azure-service-bus/src/producer.js +5 -3
  85. package/packages/datadog-plugin-bullmq/src/producer.js +3 -3
  86. package/packages/datadog-plugin-child_process/src/index.js +3 -3
  87. package/packages/datadog-plugin-child_process/src/scrub-cmd-params.js +5 -8
  88. package/packages/datadog-plugin-cypress/src/source-map-utils.js +1 -1
  89. package/packages/datadog-plugin-fastify/src/tracing.js +1 -1
  90. package/packages/datadog-plugin-fs/src/index.js +0 -4
  91. package/packages/datadog-plugin-google-cloud-pubsub/src/consumer.js +14 -7
  92. package/packages/datadog-plugin-google-cloud-pubsub/src/producer.js +19 -10
  93. package/packages/datadog-plugin-graphql/src/execute.js +1 -0
  94. package/packages/datadog-plugin-graphql/src/tools/signature.js +1 -1
  95. package/packages/datadog-plugin-http/src/client.js +2 -1
  96. package/packages/datadog-plugin-jest/src/index.js +2 -3
  97. package/packages/datadog-plugin-jest/src/util.js +2 -1
  98. package/packages/datadog-plugin-langchain/src/tokens.js +2 -2
  99. package/packages/datadog-plugin-mocha/src/index.js +25 -6
  100. package/packages/datadog-plugin-openai-agents/src/index.js +74 -0
  101. package/packages/datadog-plugin-openai-agents/src/integration.js +503 -0
  102. package/packages/datadog-plugin-openai-agents/src/processor.js +108 -0
  103. package/packages/datadog-plugin-openai-agents/src/util.js +60 -0
  104. package/packages/datadog-plugin-prisma/src/index.js +5 -1
  105. package/packages/datadog-plugin-rhea/src/consumer.js +16 -13
  106. package/packages/datadog-plugin-rhea/src/producer.js +1 -1
  107. package/packages/datadog-plugin-undici/src/index.js +2 -1
  108. package/packages/datadog-plugin-vitest/src/index.js +84 -11
  109. package/packages/datadog-webpack/index.js +2 -2
  110. package/packages/dd-trace/src/appsec/downstream_requests.js +1 -1
  111. package/packages/dd-trace/src/appsec/iast/analyzers/hardcoded-base-analyzer.js +1 -1
  112. package/packages/dd-trace/src/appsec/iast/analyzers/hardcoded-password-analyzer.js +1 -1
  113. package/packages/dd-trace/src/appsec/iast/analyzers/sql-injection-analyzer.js +1 -1
  114. package/packages/dd-trace/src/appsec/iast/taint-tracking/taint-tracking-impl.js +1 -3
  115. package/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/utils.js +1 -1
  116. package/packages/dd-trace/src/appsec/iast/vulnerability-reporter.js +2 -2
  117. package/packages/dd-trace/src/appsec/sdk/track_event.js +1 -1
  118. package/packages/dd-trace/src/appsec/waf/waf_manager.js +1 -1
  119. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/sink.js +7 -6
  120. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +50 -51
  121. package/packages/dd-trace/src/ci-visibility/exporters/git/git_metadata.js +2 -1
  122. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/index.js +4 -0
  123. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/webdriverio.js +26 -0
  124. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/writer.js +9 -1
  125. package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +161 -33
  126. package/packages/dd-trace/src/ci-visibility/requests/request.js +3 -3
  127. package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache-schema.js +7 -5
  128. package/packages/dd-trace/src/config/defaults.js +14 -6
  129. package/packages/dd-trace/src/config/generated-config-types.d.ts +4 -2
  130. package/packages/dd-trace/src/config/index.js +15 -0
  131. package/packages/dd-trace/src/config/parsers.js +19 -1
  132. package/packages/dd-trace/src/config/supported-configurations.json +12 -6
  133. package/packages/dd-trace/src/datastreams/encoding.js +3 -4
  134. package/packages/dd-trace/src/debugger/devtools_client/log.js +2 -1
  135. package/packages/dd-trace/src/debugger/devtools_client/probe_sampler.js +1 -4
  136. package/packages/dd-trace/src/debugger/devtools_client/send.js +1 -2
  137. package/packages/dd-trace/src/debugger/devtools_client/snapshot/index.js +2 -2
  138. package/packages/dd-trace/src/debugger/devtools_client/snapshot/redaction.js +16 -2
  139. package/packages/dd-trace/src/encode/span-stats.js +7 -1
  140. package/packages/dd-trace/src/encode/tags-processors.js +2 -2
  141. package/packages/dd-trace/src/exporter.js +16 -9
  142. package/packages/dd-trace/src/exporters/common/url.js +3 -5
  143. package/packages/dd-trace/src/exporters/electron/index.js +4 -1
  144. package/packages/dd-trace/src/external-logger/src/index.js +1 -2
  145. package/packages/dd-trace/src/flare/index.js +2 -2
  146. package/packages/dd-trace/src/id.js +1 -1
  147. package/packages/dd-trace/src/llmobs/constants/tags.js +1 -1
  148. package/packages/dd-trace/src/llmobs/experiments/client.js +4 -2
  149. package/packages/dd-trace/src/llmobs/experiments/dataset.js +66 -14
  150. package/packages/dd-trace/src/llmobs/experiments/experiment.js +289 -95
  151. package/packages/dd-trace/src/llmobs/experiments/index.js +51 -12
  152. package/packages/dd-trace/src/llmobs/experiments/noop.js +95 -10
  153. package/packages/dd-trace/src/llmobs/experiments/result.js +13 -2
  154. package/packages/dd-trace/src/llmobs/experiments/util.js +218 -0
  155. package/packages/dd-trace/src/llmobs/index.js +9 -1
  156. package/packages/dd-trace/src/llmobs/plugins/ai/util.js +104 -22
  157. package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +1 -1
  158. package/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js +1 -1
  159. package/packages/dd-trace/src/llmobs/plugins/genai/index.js +1 -1
  160. package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/index.js +1 -1
  161. package/packages/dd-trace/src/llmobs/plugins/openai/index.js +5 -9
  162. package/packages/dd-trace/src/llmobs/plugins/openai/utils.js +20 -0
  163. package/packages/dd-trace/src/llmobs/plugins/openai-agents/utils.js +321 -0
  164. package/packages/dd-trace/src/llmobs/sdk.js +6 -12
  165. package/packages/dd-trace/src/llmobs/span_processor.js +14 -7
  166. package/packages/dd-trace/src/llmobs/tagger.js +24 -5
  167. package/packages/dd-trace/src/llmobs/util.js +56 -3
  168. package/packages/dd-trace/src/llmobs/writers/spans.js +12 -6
  169. package/packages/dd-trace/src/noop/proxy.js +2 -2
  170. package/packages/dd-trace/src/openfeature/agentless_configuration_source.js +3 -6
  171. package/packages/dd-trace/src/openfeature/configuration_source.js +5 -8
  172. package/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_transformer.js +5 -1
  173. package/packages/dd-trace/src/opentelemetry/metrics/otlp_transformer.js +1 -1
  174. package/packages/dd-trace/src/opentelemetry/metrics/periodic_metric_reader.js +2 -1
  175. package/packages/dd-trace/src/opentracing/span.js +1 -1
  176. package/packages/dd-trace/src/opentracing/span_context.js +1 -1
  177. package/packages/dd-trace/src/plugins/ci_plugin.js +114 -23
  178. package/packages/dd-trace/src/plugins/index.js +2 -0
  179. package/packages/dd-trace/src/plugins/outbound.js +4 -1
  180. package/packages/dd-trace/src/plugins/plugin.js +1 -1
  181. package/packages/dd-trace/src/plugins/tracing.js +1 -0
  182. package/packages/dd-trace/src/plugins/util/ci.js +7 -6
  183. package/packages/dd-trace/src/plugins/util/git.js +1 -1
  184. package/packages/dd-trace/src/plugins/util/http-otel-semantics.js +1 -1
  185. package/packages/dd-trace/src/plugins/util/llm.js +2 -2
  186. package/packages/dd-trace/src/plugins/util/tags.js +2 -0
  187. package/packages/dd-trace/src/plugins/util/test.js +38 -12
  188. package/packages/dd-trace/src/plugins/util/url.js +5 -5
  189. package/packages/dd-trace/src/plugins/util/user-provided-git.js +1 -1
  190. package/packages/dd-trace/src/plugins/util/web.js +1 -1
  191. package/packages/dd-trace/src/priority_sampler.js +6 -3
  192. package/packages/dd-trace/src/profiling/profilers/poisson.js +1 -1
  193. package/packages/dd-trace/src/profiling/profilers/shared.js +2 -2
  194. package/packages/dd-trace/src/ritm.js +5 -20
  195. package/packages/dd-trace/src/span_processor.js +1 -1
  196. package/packages/dd-trace/src/span_stats.js +22 -4
  197. package/packages/dd-trace/src/startup-log.js +3 -0
  198. package/packages/dd-trace/src/telemetry/logs/log-collector.js +3 -1
  199. package/packages/dd-trace/src/util.js +16 -0
  200. package/vendor/dist/@apm-js-collab/code-transformer/index.js +12 -12
  201. package/vendor/dist/pprof-format/index.js +1 -1
  202. package/vendor/dist/source-map/index.js +1 -1
  203. package/ci/test-optimization-validation/command-suitability.js +0 -471
  204. package/ci/test-optimization-validation/init-probe-preload.js +0 -163
  205. package/ci/test-optimization-validation/init-probe.js +0 -246
  206. package/ci/test-optimization-validation/late-initialization.js +0 -63
  207. package/ci/test-optimization-validation/local-command.js +0 -163
  208. package/ci/test-optimization-validation/setup-runner.js +0 -97
  209. package/ci/test-optimization-validation-manifest.schema.json +0 -1
@@ -1,14 +1,44 @@
1
1
  'use strict'
2
2
 
3
- /* eslint-disable eslint-rules/eslint-process-env */
4
-
5
3
  const fs = require('node:fs')
6
4
  const path = require('node:path')
7
5
 
8
6
  const { runDiagnosis } = require('../diagnose')
7
+ const cucumber = require('./framework-adapters/cucumber')
8
+ const cypress = require('./framework-adapters/cypress')
9
+ const playwright = require('./framework-adapters/playwright')
10
+ const {
11
+ GENERATED_SCENARIOS,
12
+ getGeneratedRetryStatePath,
13
+ getGeneratedTestContent,
14
+ } = require('./generated-test-contract')
9
15
  const { validateManifest } = require('./manifest-schema')
16
+ const {
17
+ getProjectNodeRunner,
18
+ getRunnerContract,
19
+ getRunnerSearchRoots,
20
+ } = require('./runner-contract')
10
21
 
11
- const SUPPORTED_SCAFFOLD_FRAMEWORKS = new Set(['jest', 'mocha', 'vitest'])
22
+ const SUPPORTED_FRAMEWORKS = new Set(['cucumber', 'cypress', 'jest', 'mocha', 'playwright', 'vitest'])
23
+ const MAX_DISCOVERY_ENTRIES = 5000
24
+ const MAX_DIRECTORY_ENTRIES = 1024
25
+ const MAX_FILE_BYTES = 512 * 1024
26
+ const MAX_CI_FILES = 256
27
+ const MAX_CI_REVIEW_TARGETS = 3
28
+ const SKIPPED_DIRECTORIES = new Set([
29
+ '.git',
30
+ '.hg',
31
+ '.svn',
32
+ '.yarn',
33
+ 'build',
34
+ 'coverage',
35
+ 'dd-test-optimization-validation-results',
36
+ 'dist',
37
+ 'node_modules',
38
+ 'out',
39
+ 'target',
40
+ 'vendor',
41
+ ])
12
42
  const CI_PATHS = [
13
43
  '.github/workflows',
14
44
  '.gitlab-ci.yml',
@@ -18,9 +48,43 @@ const CI_PATHS = [
18
48
  'azure-pipelines.yml',
19
49
  'Jenkinsfile',
20
50
  ]
51
+ const CONFIG_PATTERNS = {
52
+ cucumber: cucumber.CONFIG_PATTERN,
53
+ cypress: cypress.CONFIG_PATTERN,
54
+ jest: /^(?:jest|config-jest)\.config\.(?:[cm]?[jt]s|json)$/,
55
+ mocha: /^\.mocharc\.(?:json|ya?ml|[cm]?js)$/,
56
+ playwright: playwright.CONFIG_PATTERN,
57
+ vitest: /^(?:vite|vitest)\.config\.[cm]?[jt]s$/,
58
+ }
59
+ const JS_CONFIG_EXTENSIONS = ['js', 'cjs', 'mjs', 'ts', 'cts', 'mts']
60
+ const IMPLICIT_CONFIG_FILENAMES = {
61
+ cucumber: ['json', 'yaml', 'yml', ...JS_CONFIG_EXTENSIONS].map(extension => `cucumber.${extension}`),
62
+ cypress: ['json', ...JS_CONFIG_EXTENSIONS].flatMap(extension => [
63
+ `cypress.${extension}`,
64
+ `cypress.config.${extension}`,
65
+ ]),
66
+ jest: ['json', ...JS_CONFIG_EXTENSIONS].flatMap(extension => [
67
+ `jest.config.${extension}`,
68
+ `config-jest.config.${extension}`,
69
+ ]),
70
+ mocha: ['json', 'yaml', 'yml', ...JS_CONFIG_EXTENSIONS].map(extension => `.mocharc.${extension}`),
71
+ playwright: JS_CONFIG_EXTENSIONS.map(extension => `playwright.config.${extension}`),
72
+ vitest: JS_CONFIG_EXTENSIONS.flatMap(extension => [
73
+ `vite.config.${extension}`,
74
+ `vitest.config.${extension}`,
75
+ ]),
76
+ }
77
+ const TEST_FILE_PATTERN = /^.+[._-](?:test|spec)\.[cm]?[jt]sx?$/
78
+ const BARE_TEST_FILE_PATTERN = /^test\.[cm]?[jt]sx?$/
79
+ const TYPE_ONLY_TEST_PATTERN = /\.(?:test|spec)-d\.[cm]?tsx?$|\.d\.[cm]?ts$/i
80
+ const TYPE_ONLY_DIRECTORY_PATTERN = /(?:^|\/)(?:type[-_]?tests?|typetests?|test-dts?)(?:\/|$)/i
81
+ const LOCAL_SOCKET_PATTERN =
82
+ /(?:\bcreateServer\s*\(|\.listen\s*\(|\b(?:localhost|127\.0\.0\.1)\b|\bcy\.(?:visit|request)\s*\()/
83
+ const CYPRESS_LOCAL_ORIGIN_PATTERN =
84
+ /\bcy\.(?:visit|request)\s*\([\s\S]{0,512}\b(?:https?:\/\/)?(?:localhost|127\.0\.0\.1)(?=[:/'"`\s}])/i
21
85
 
22
86
  /**
23
- * Creates a schema-valid starting manifest without executing project code.
87
+ * Creates a schema-valid data-only manifest without executing project code.
24
88
  *
25
89
  * @param {object} input scaffold inputs
26
90
  * @param {string} input.root repository root
@@ -28,39 +92,37 @@ const CI_PATHS = [
28
92
  * @returns {object} validation manifest scaffold
29
93
  */
30
94
  function createManifestScaffold ({ root, frameworks = new Set() }) {
31
- const repositoryRoot = path.resolve(root)
95
+ const repositoryRoot = fs.realpathSync(path.resolve(root))
32
96
  const diagnosis = runDiagnosis({ root: repositoryRoot, env: {} })
33
- const selected = diagnosis.eligibleFrameworks.filter(framework => {
34
- return frameworks.size === 0 || frameworks.has(framework.id) || frameworks.has(framework.id.split(':')[0])
97
+ const ciDiscovery = discoverCiFiles(repositoryRoot)
98
+ const eligible = diagnosis.eligibleFrameworks.filter(detection => isSelected(detection.id, frameworks))
99
+ const eligibleKinds = new Set(eligible.map(detection => detection.id))
100
+ const detectedOnly = diagnosis.supportedFrameworks.filter(detection => {
101
+ return !eligibleKinds.has(detection.id) && isSelected(detection.id, frameworks)
35
102
  })
36
- const unsupported = diagnosis.unsupportedFrameworks.filter(framework => {
37
- return frameworks.size === 0 || frameworks.has(framework.id) || frameworks.has(framework.id.split(':')[0])
38
- })
39
- if (selected.length === 0 && unsupported.length === 0) {
103
+ const unsupported = diagnosis.unsupportedFrameworks.filter(detection => isSelected(detection.id, frameworks))
104
+
105
+ if (eligible.length === 0 && detectedOnly.length === 0 && unsupported.length === 0) {
40
106
  throw new Error('No test framework was detected for manifest scaffolding.')
41
107
  }
42
108
 
43
109
  const manifest = {
44
- schemaVersion: '1.0',
110
+ schemaVersion: '2.0',
45
111
  generatedAt: new Date().toISOString(),
46
112
  repository: {
47
113
  root: repositoryRoot,
48
- gitRemote: null,
49
- gitSha: null,
50
114
  packageManager: detectPackageManager(repositoryRoot),
51
115
  workspaceManager: detectWorkspaceManager(repositoryRoot),
52
116
  },
53
117
  environment: {
54
- os: getManifestOs(process.platform),
55
- shell: process.env.SHELL || null,
56
118
  nodeVersion: process.version,
57
- requiredEnvVars: [],
58
- safeEnv: {},
119
+ os: getManifestOs(process.platform),
59
120
  },
60
- ciDiscovery: discoverCiFiles(repositoryRoot),
121
+ ciDiscovery,
61
122
  frameworks: [
62
- ...selected.map(framework => buildFrameworkScaffold(repositoryRoot, framework)),
63
- ...unsupported.map(framework => buildUnsupportedFrameworkScaffold(repositoryRoot, framework)),
123
+ ...eligible.map(detection => buildFramework(repositoryRoot, detection, ciDiscovery)),
124
+ ...detectedOnly.map(detection => buildDetectedOnlyFramework(repositoryRoot, detection, ciDiscovery)),
125
+ ...unsupported.map(detection => buildUnsupportedFramework(repositoryRoot, detection, ciDiscovery)),
64
126
  ],
65
127
  omitted: [],
66
128
  }
@@ -73,666 +135,904 @@ function createManifestScaffold ({ root, frameworks = new Set() }) {
73
135
  }
74
136
 
75
137
  /**
76
- * Builds a diagnostic-only entry for a detected runner the validator cannot execute.
138
+ * Builds one runnable direct-runner framework or a precise setup blocker.
77
139
  *
78
140
  * @param {string} repositoryRoot repository root
79
- * @param {object} detection static framework detection
80
- * @returns {object} non-runnable framework manifest entry
141
+ * @param {object} detection eligible framework detection
142
+ * @param {object} ciDiscovery bounded CI discovery result
143
+ * @returns {object} framework manifest entry
81
144
  */
82
- function buildUnsupportedFrameworkScaffold (repositoryRoot, detection) {
83
- const packageJsonPath = getDetectionPackageJson(repositoryRoot, detection.locations)
84
- const projectRoot = path.dirname(packageJsonPath)
85
- const packageJson = readJson(packageJsonPath) || {}
86
- const framework = detection.id === 'node-test' ? 'node:test' : detection.id
87
-
88
- return {
89
- id: `${detection.id}:${getProjectIdentifier(packageJson, projectRoot, repositoryRoot)}`,
90
- framework,
91
- frameworkVersion: getInstalledFrameworkVersion(detection.id, projectRoot, packageJson),
92
- language: 'unknown',
93
- status: 'unsupported_by_validator',
94
- supportLevel: 'detected_only',
95
- project: getProject({ packageJson, packageJsonPath, projectRoot, repositoryRoot, framework }),
96
- notes: [
97
- `${detection.name} was detected at ${detection.locations.join(', ') || 'an unknown location'}, but is not ` +
98
- 'supported by this Test Optimization validator.',
99
- ],
100
- }
101
- }
102
-
103
- function buildFrameworkScaffold (repositoryRoot, detection) {
104
- const packageJsonPath = path.resolve(repositoryRoot, detection.commandLocation || 'package.json')
105
- const projectRoot = path.dirname(packageJsonPath)
106
- const packageJson = readJson(packageJsonPath) || {}
145
+ function buildFramework (repositoryRoot, detection, ciDiscovery) {
107
146
  const framework = detection.id
147
+ const packageJson = getDetectionPackageJson(repositoryRoot, detection.commandLocation)
148
+ const projectRoot = path.dirname(packageJson.path)
149
+ const base = getFrameworkBase({
150
+ ciDiscovery,
151
+ detection,
152
+ framework,
153
+ packageJson,
154
+ projectRoot,
155
+ repositoryRoot,
156
+ })
108
157
 
109
- if (!SUPPORTED_SCAFFOLD_FRAMEWORKS.has(framework)) {
158
+ if (!SUPPORTED_FRAMEWORKS.has(framework)) {
110
159
  return {
111
- id: `${framework}:${getProjectIdentifier(packageJson, projectRoot, repositoryRoot)}`,
112
- framework,
113
- frameworkVersion: detection.version,
114
- status: 'detected_not_runnable',
115
- project: getProject({ packageJson, packageJsonPath, projectRoot, repositoryRoot, framework }),
116
- notes: [`${detection.name} was detected, but automatic generated-test scaffolding is not available.`],
160
+ ...base,
161
+ status: 'unsupported_by_validator',
162
+ notes: [`${detection.name} is supported by dd-trace, but this validator has no direct-runner adapter.`],
117
163
  }
118
164
  }
119
165
 
120
- const runner = tryResolveRunner(framework, projectRoot)
166
+ const projectRunner = getProjectNodeRunner(detection.command, projectRoot, repositoryRoot)
167
+ const runner = projectRunner || resolveRunner(framework, projectRoot, repositoryRoot)
121
168
  if (!runner) {
122
169
  return {
123
- id: `${framework}:${getProjectIdentifier(packageJson, projectRoot, repositoryRoot)}`,
124
- framework,
125
- frameworkVersion: detection.version,
170
+ ...base,
126
171
  status: 'requires_manual_setup',
127
- project: getProject({ packageJson, packageJsonPath, projectRoot, repositoryRoot, framework }),
128
172
  notes: [
129
- `${detection.name} was detected, but its executable package could not be resolved from ` +
130
- `${path.relative(repositoryRoot, projectRoot) || 'the repository root'}.`,
173
+ `${detection.name} is detected, but its repository-contained executable is unavailable. Install this ` +
174
+ 'package normally, then create a fresh validation plan.',
131
175
  ],
132
176
  }
133
177
  }
134
- const scriptName = getPackageScriptName(packageJson, detection.command)
135
- const preserveProjectWrapper = Boolean(scriptName) && !usesBareFrameworkRunner(detection.command, framework)
136
- const baseCommand = buildExistingCommand({
178
+
179
+ const runnerContract = getRunnerContract(framework, detection.command, projectRoot, repositoryRoot)
180
+ if (runnerContract.error) {
181
+ return {
182
+ ...base,
183
+ status: 'requires_manual_setup',
184
+ notes: [
185
+ `${detection.name} runner configuration was not retained because ${runnerContract.error}. ` +
186
+ 'Choose a representative test command manually or adjust the project setup, then create a fresh plan.',
187
+ ],
188
+ }
189
+ }
190
+ const commandRoots = getRunnerSearchRoots(framework, detection.command, projectRoot, repositoryRoot)
191
+ const representativeRoot = commandRoots[0] || findPreferredRepresentativeRoot(projectRoot, repositoryRoot)
192
+ const representativePackage = readJson(path.join(representativeRoot, 'package.json')) || packageJson.json
193
+ const projectFiles = collectProjectFiles(projectRoot)
194
+ const candidateFiles = representativeRoot === projectRoot
195
+ ? projectFiles
196
+ : collectProjectFiles(representativeRoot)
197
+ const candidate = selectRepresentativeTest(
198
+ candidateFiles,
137
199
  framework,
138
- projectRoot,
139
- repositoryRoot,
140
- runner,
141
- scriptName,
142
- preserveProjectWrapper,
143
- })
144
- const representative = findRepresentativeTestFile(projectRoot)
145
- const command = representative
146
- ? buildFocusedCommand(baseCommand, framework, representative, Boolean(scriptName), preserveProjectWrapper)
147
- : baseCommand
200
+ representativeRoot,
201
+ representativePackage.name,
202
+ commandRoots.length > 0
203
+ )
204
+ if (!candidate) {
205
+ return {
206
+ ...base,
207
+ status: 'requires_manual_setup',
208
+ notes: [
209
+ `No single ${detection.name} test file could be selected confidently. Choose a normal framework-owned ` +
210
+ 'test file or validate this framework manually.',
211
+ ],
212
+ }
213
+ }
214
+ if (candidate.requiresExternalService) {
215
+ return {
216
+ ...base,
217
+ status: 'requires_manual_setup',
218
+ notes: [
219
+ `No self-contained ${detection.name} spec could be selected. The available spec accesses a localhost ` +
220
+ 'application that discovery will not start. Start the application through the project\'s normal setup and ' +
221
+ 'validate Cypress manually, or add a self-contained spec before creating a fresh plan.',
222
+ ],
223
+ }
224
+ }
148
225
 
149
226
  const generatedTestStrategy = buildGeneratedTestStrategy({
150
- baseCommand: preserveProjectWrapper ? baseCommand : undefined,
151
227
  framework,
152
- packageJson,
153
228
  projectRoot,
154
- representative,
155
- runner,
229
+ representative: candidate.path,
156
230
  })
231
+ const configFiles = [...new Set([
232
+ ...runnerContract.inputFiles,
233
+ ...getImplicitConfigFiles(framework, projectRoot, repositoryRoot),
234
+ ...(representativeRoot === projectRoot
235
+ ? []
236
+ : getImplicitConfigFiles(framework, representativeRoot, repositoryRoot)),
237
+ ...projectFiles.filter(filename => CONFIG_PATTERNS[framework]?.test(path.basename(filename))).slice(0, 5),
238
+ ])]
239
+ if (configFiles.length > 20) {
240
+ return {
241
+ ...base,
242
+ status: 'requires_manual_setup',
243
+ notes: [
244
+ `${detection.name} loads more configuration files than the validator can approval-bind safely. ` +
245
+ 'Use a narrower project setup before creating a fresh plan.',
246
+ ],
247
+ }
248
+ }
249
+ const runnerDescription = projectRunner ? 'repository test wrapper' : `installed ${framework} runner`
250
+ const browserRequired = framework === 'cypress' ||
251
+ framework === 'playwright' ||
252
+ (framework === 'vitest' && runnerContract.runnerArgs.includes('--browser'))
157
253
 
158
254
  return {
159
- id: `${framework}:${getProjectIdentifier(packageJson, projectRoot, repositoryRoot)}`,
160
- framework,
161
- frameworkVersion: detection.version,
162
- language: /\.tsx?$/.test(generatedTestStrategy.fileExtension) ? 'typescript' : 'javascript',
255
+ ...base,
256
+ browserRequired,
257
+ language: /\.[cm]?tsx?$/.test(candidate.path) ? 'typescript' : 'javascript',
258
+ localSocketRequired: candidate.requiresLocalSocket,
163
259
  status: 'runnable',
164
- supportLevel: 'validator_supported',
165
- project: getProject({ packageJson, packageJsonPath, projectRoot, repositoryRoot, framework }),
166
- setup: { commands: [], services: [] },
167
- existingTestCommand: command,
168
- preflight: { status: 'pending', maxTestCount: 50 },
169
- ciWiring: {
170
- status: 'unknown',
171
- replayability: 'not_replayable',
172
- replayBlocker: 'CI command selection has not been completed. Inspect the discovered CI configuration and ' +
173
- 'replace this with a concrete technical blocker only when the selected test command cannot be replayed.',
174
- diagnosis: 'Select one replayable CI test step and record its exact command and environment before live CI ' +
175
- 'wiring validation.',
176
- initialization: {
177
- status: 'unknown',
178
- evidence: [],
179
- },
260
+ supportLevel: 'validator_direct_runner',
261
+ project: {
262
+ ...base.project,
263
+ configFiles,
180
264
  },
265
+ validation: {
266
+ environment: runnerContract.environment,
267
+ ...(runnerContract.omittedOptions?.length > 0
268
+ ? { omittedRunnerOptions: runnerContract.omittedOptions }
269
+ : {}),
270
+ requiredEnvVars: [],
271
+ runner,
272
+ runnerArgs: runnerContract.runnerArgs,
273
+ selectorScope: projectRunner ? 'instrumented_event_identity' : 'bounded_direct_runner',
274
+ testFile: candidate.path,
275
+ timeoutMs: browserRequired ? 300_000 : 180_000,
276
+ },
277
+ preflight: { status: 'pending' },
181
278
  generatedTestStrategy,
182
279
  notes: [
183
- representative
184
- ? `Generated by --init-manifest using representative test ${path.relative(repositoryRoot, representative)}.`
185
- : 'Generated by --init-manifest. Narrow existingTestCommand if the detected command runs a broad suite.',
186
- preserveProjectWrapper
187
- ? `Basic Reporting preserves package script ${scriptName} because it uses a custom test wrapper.`
188
- : `Basic Reporting invokes the installed ${framework} runner directly; record the CI wrapper separately.`,
189
- 'CI command selection still requires repository-specific evidence.',
280
+ `Basic Reporting will invoke the ${runnerDescription} ` +
281
+ 'directly for ' +
282
+ `${path.relative(repositoryRoot, candidate.path)}.`,
283
+ 'The validator retained only allowlisted runner configuration from the detected package script.',
284
+ ...(projectRunner
285
+ ? [
286
+ 'Basic Reporting will remain incomplete unless captured test events identify only the approved ' +
287
+ 'representative file.',
288
+ ]
289
+ : []),
290
+ ...(candidate.requiresLocalSocket
291
+ ? [
292
+ 'The selected test appears to require localhost. A restricted sandbox may leave local validation ' +
293
+ 'incomplete.',
294
+ ]
295
+ : []),
190
296
  ],
191
297
  }
192
298
  }
193
299
 
194
- function getProject ({ packageJson, packageJsonPath, projectRoot, repositoryRoot, framework }) {
300
+ function getImplicitConfigFiles (framework, projectRoot, repositoryRoot) {
301
+ const files = []
302
+ for (const basename of IMPLICIT_CONFIG_FILENAMES[framework] || []) {
303
+ const filename = path.join(projectRoot, basename)
304
+ try {
305
+ const stat = fs.lstatSync(filename)
306
+ const physical = fs.realpathSync(filename)
307
+ if (stat.isFile() && !stat.isSymbolicLink() &&
308
+ fs.statSync(physical).isFile() &&
309
+ isPathInside(fs.realpathSync(repositoryRoot), physical)) files.push(physical)
310
+ } catch {}
311
+ }
312
+ return files
313
+ }
314
+
315
+ /**
316
+ * Builds a diagnostic-only supported framework entry.
317
+ *
318
+ * @param {string} repositoryRoot repository root
319
+ * @param {object} detection supported framework detection
320
+ * @param {object} ciDiscovery bounded CI discovery result
321
+ * @returns {object} framework manifest entry
322
+ */
323
+ function buildDetectedOnlyFramework (repositoryRoot, detection, ciDiscovery) {
324
+ const packageJson = getDetectionPackageJson(repositoryRoot, detection.locations?.[0])
325
+ const projectRoot = path.dirname(packageJson.path)
326
+ const version = detection.supportedVersion?.version ||
327
+ detection.versionDetections?.[0]?.version ||
328
+ detection.versionDetections?.[0]?.rawVersion
195
329
  return {
196
- name: packageJson.name || getProjectIdentifier(packageJson, projectRoot, repositoryRoot),
197
- root: projectRoot,
198
- packageJson: packageJsonPath,
199
- configFiles: findConfigFiles(projectRoot, framework),
200
- evidence: [`Detected ${framework} from ${path.relative(repositoryRoot, packageJsonPath) || 'package.json'}.`],
330
+ ...getFrameworkBase({
331
+ ciDiscovery,
332
+ detection: { ...detection, version },
333
+ framework: detection.id,
334
+ packageJson,
335
+ projectRoot,
336
+ repositoryRoot,
337
+ }),
338
+ status: 'detected_not_runnable',
339
+ notes: [
340
+ detection.supportedVersion
341
+ ? `A supported ${detection.name} installation was detected, but no project-local validation target was found.`
342
+ : `${detection.name} was detected, but no supported installed version was confirmed.`,
343
+ ],
201
344
  }
202
345
  }
203
346
 
204
- function buildExistingCommand ({
347
+ /**
348
+ * Builds an unsupported framework entry.
349
+ *
350
+ * @param {string} repositoryRoot repository root
351
+ * @param {object} detection unsupported framework detection
352
+ * @param {object} ciDiscovery bounded CI discovery result
353
+ * @returns {object} framework manifest entry
354
+ */
355
+ function buildUnsupportedFramework (repositoryRoot, detection, ciDiscovery) {
356
+ const packageJson = getDetectionPackageJson(repositoryRoot, detection.locations?.[0])
357
+ const projectRoot = path.dirname(packageJson.path)
358
+ const framework = detection.id === 'node-test' ? 'node:test' : detection.id
359
+ return {
360
+ ...getFrameworkBase({
361
+ ciDiscovery,
362
+ detection,
363
+ framework,
364
+ packageJson,
365
+ projectRoot,
366
+ repositoryRoot,
367
+ }),
368
+ status: 'unsupported_by_validator',
369
+ notes: [`${detection.name} is not supported by this Test Optimization validator.`],
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Returns fields shared by runnable and diagnostic framework entries.
375
+ *
376
+ * @param {object} input framework inputs
377
+ * @param {object} input.ciDiscovery bounded CI discovery
378
+ * @param {object} input.detection framework detection
379
+ * @param {string} input.framework framework name
380
+ * @param {object} input.packageJson package metadata
381
+ * @param {string} input.projectRoot project root
382
+ * @param {string} input.repositoryRoot repository root
383
+ * @returns {object} shared framework fields
384
+ */
385
+ function getFrameworkBase ({
386
+ ciDiscovery,
387
+ detection,
205
388
  framework,
389
+ packageJson,
206
390
  projectRoot,
207
391
  repositoryRoot,
208
- runner,
209
- scriptName,
210
- preserveProjectWrapper,
211
392
  }) {
212
- const packageManager = preserveProjectWrapper ? detectPackageManager(repositoryRoot) : undefined
213
- const argv = preserveProjectWrapper
214
- ? getPackageScriptArgv(packageManager, scriptName, repositoryRoot)
215
- : getDirectRunnerArgv(framework, runner)
216
393
  return {
217
- description: preserveProjectWrapper
218
- ? `Detected custom package script ${scriptName}`
219
- : `Direct installed ${framework} runner for local capability validation`,
220
- cwd: projectRoot,
221
- argv,
222
- env: {},
223
- requiredEnvVars: [],
224
- timeoutMs: 300_000,
225
- usesShell: false,
394
+ id: `${framework}:${getProjectId(packageJson.json, projectRoot, repositoryRoot)}`,
395
+ framework,
396
+ frameworkVersion: detection.version || detection.supportedVersion?.version || null,
397
+ language: 'unknown',
398
+ supportLevel: 'detected_only',
399
+ project: {
400
+ configFiles: [],
401
+ name: packageJson.json.name || path.basename(projectRoot) || 'root',
402
+ packageJson: packageJson.path,
403
+ root: projectRoot,
404
+ },
405
+ ciWiring: buildCiWiring(ciDiscovery),
226
406
  }
227
407
  }
228
408
 
229
- function buildGeneratedTestStrategy ({ baseCommand, framework, packageJson, projectRoot, representative, runner }) {
230
- const convention = getGeneratedTestConvention(representative, projectRoot)
231
- const packageType = getNearestPackageType(convention.testDirectory, projectRoot, packageJson.type)
232
- const moduleSystem = getGeneratedModuleSystem(framework, convention.fileExtension, packageType)
233
- const definitions = getGeneratedDefinitions({ framework, convention, moduleSystem })
234
-
409
+ /**
410
+ * Builds inert CI review fields. The agent may populate only this evidence section.
411
+ *
412
+ * @param {object} ciDiscovery bounded CI discovery result
413
+ * @returns {object} CI evidence scaffold
414
+ */
415
+ function buildCiWiring (ciDiscovery) {
416
+ const unresolved = ciDiscovery.reviewRequired
417
+ ? ['The exact CI test job and its effective environment have not been reviewed.']
418
+ : ['No supported CI configuration file was found by bounded discovery.']
235
419
  return {
236
- status: 'planned',
237
- reason: 'Standard isolated scenarios generated by the validator manifest scaffold.',
238
- adapter: framework,
239
- testDirectory: convention.testDirectory,
240
- moduleSystem,
241
- fileExtension: convention.fileExtension,
242
- supportsFocusedSingleFileRun: true,
243
- usesMultipleFiles: true,
244
- files: definitions.map(definition => ({
245
- path: definition.file,
246
- role: 'test',
247
- contentLines: definition.content.split('\n'),
248
- })),
249
- scenarios: definitions.map(definition => ({
250
- id: definition.id,
251
- purpose: definition.purpose,
252
- runCommand: baseCommand
253
- ? buildFocusedCommand(baseCommand, framework, definition.file, true, true, moduleSystem)
254
- : buildGeneratedRunCommand(framework, projectRoot, definition.file, runner, moduleSystem),
255
- expectedWithoutDatadog: {
256
- exitCode: definition.id === 'atr-fail-once' ? 1 : 0,
257
- observedTestCount: 1,
258
- },
259
- testIdentities: [{
260
- suite: null,
261
- name: definition.testName,
262
- file: definition.file,
263
- parameters: null,
264
- }],
265
- })),
266
- cleanupPaths: [
267
- ...definitions.map(definition => definition.file),
268
- path.join(path.dirname(definitions.find(definition => definition.id === 'atr-fail-once').file),
269
- '.dd-test-optimization-validation-atr-state'),
270
- ],
420
+ command: null,
421
+ configFile: null,
422
+ initialization: { evidence: [], status: 'unknown' },
423
+ job: null,
424
+ reviewComplete: false,
425
+ step: null,
426
+ transport: { evidence: [], mode: 'unknown' },
427
+ unresolved,
428
+ workingDirectory: null,
271
429
  }
272
430
  }
273
431
 
274
- function getGeneratedModuleSystem (framework, fileExtension, packageType) {
275
- if (/\.(?:cjs|cts)$/.test(fileExtension)) return 'commonjs'
276
- if (framework === 'vitest' || /\.(?:mjs|mts)$/.test(fileExtension)) return 'esm'
277
- return packageType === 'module' ? 'esm' : 'commonjs'
278
- }
279
-
280
432
  /**
281
- * Returns the package module type that applies to a generated test directory.
433
+ * Builds canonical generated test data without embedding executable commands.
282
434
  *
283
- * @param {string} testDirectory generated test directory
284
- * @param {string} projectRoot detected project root
285
- * @param {string|undefined} fallbackType detected project package type
286
- * @returns {string|undefined} nearest package module type
435
+ * @param {object} input generated-test inputs
436
+ * @param {string} input.framework framework name
437
+ * @param {string} input.projectRoot project root
438
+ * @param {string} input.representative representative test file
439
+ * @returns {object} generated test strategy
287
440
  */
288
- function getNearestPackageType (testDirectory, projectRoot, fallbackType) {
289
- const root = path.resolve(projectRoot)
290
- let directory = path.resolve(testDirectory)
441
+ function buildGeneratedTestStrategy ({ framework, projectRoot, representative }) {
442
+ const convention = getGeneratedTestConvention(framework, representative, projectRoot)
443
+ const testDirectory = convention.testDirectory
444
+ const extension = convention.fileExtension
445
+ const moduleSystem = getModuleSystem(representative, projectRoot)
446
+ const files = []
447
+ const scenarios = []
448
+ const cleanupPaths = []
291
449
 
292
- while (directory === root || isPathInside(root, directory)) {
293
- const packageJson = readJson(path.join(directory, 'package.json'))
294
- if (typeof packageJson?.type === 'string') return packageJson.type
295
- if (directory === root) break
296
- directory = path.dirname(directory)
450
+ for (const [id, scenario] of Object.entries(GENERATED_SCENARIOS)) {
451
+ const prefix = `dd-test-optimization-validation-${framework}-${id}`
452
+ const filename = convention.exactFilename
453
+ ? path.join(testDirectory, prefix, convention.exactFilename)
454
+ : path.join(testDirectory, `${prefix}${extension}`)
455
+ const content = getGeneratedTestContent({
456
+ framework,
457
+ moduleSystem,
458
+ scenarioId: id,
459
+ stateFile: ['cucumber', 'cypress', 'playwright'].includes(framework)
460
+ ? undefined
461
+ : getGeneratedRetryStatePath(framework, filename),
462
+ })
463
+ files.push({ path: filename, contentLines: content.split('\n') })
464
+ cleanupPaths.push(filename)
465
+ scenarios.push({
466
+ id,
467
+ expectedWithoutDatadog: scenario.expectedWithoutDatadog,
468
+ testIdentities: [{
469
+ file: filename,
470
+ name: scenario.testName,
471
+ suite: scenario.suiteName,
472
+ }],
473
+ })
297
474
  }
298
475
 
299
- return fallbackType
300
- }
301
-
302
- function getGeneratedTestConvention (representative, projectRoot) {
303
- if (!representative) {
304
- return {
305
- exactFilename: undefined,
306
- fileExtension: '.test.js',
307
- testDirectory: path.join(projectRoot, 'test'),
308
- }
476
+ if (framework === 'cucumber') {
477
+ const stepsFile = cucumber.getGeneratedStepsPath(testDirectory)
478
+ files.push({ path: stepsFile, contentLines: cucumber.getGeneratedStepsContent().split('\n') })
479
+ cleanupPaths.push(stepsFile)
480
+ } else if (framework === 'playwright') {
481
+ const configFile = playwright.getGeneratedConfigPath(testDirectory)
482
+ files.push({ path: configFile, contentLines: playwright.getGeneratedConfigContent().split('\n') })
483
+ cleanupPaths.push(configFile)
309
484
  }
310
-
311
- const basename = path.basename(representative)
312
- if (/^test\.[cm]?[jt]s$/.test(basename)) {
313
- const representativeDirectory = path.dirname(representative)
314
- return {
315
- exactFilename: basename,
316
- fileExtension: path.extname(basename),
317
- testDirectory: representativeDirectory === projectRoot
318
- ? projectRoot
319
- : path.dirname(representativeDirectory),
320
- }
485
+ if (['jest', 'mocha', 'vitest'].includes(framework)) {
486
+ cleanupPaths.push(getGeneratedRetryStatePath(framework, scenarios[1].testIdentities[0].file))
321
487
  }
322
488
 
323
489
  return {
324
- exactFilename: undefined,
325
- fileExtension: getTestExtension(representative),
326
- testDirectory: path.dirname(representative),
490
+ cleanupPaths,
491
+ fileExtension: extension,
492
+ files,
493
+ moduleSystem,
494
+ reason: 'Validator-owned direct-runner recipes.',
495
+ scenarios,
496
+ status: 'planned',
497
+ testDirectory,
327
498
  }
328
499
  }
329
500
 
330
- function getGeneratedDefinitions ({ framework, convention, moduleSystem }) {
331
- const stateFileExpression = moduleSystem === 'esm'
332
- ? "join(dirname(fileURLToPath(import.meta.url)), '.dd-test-optimization-validation-atr-state')"
333
- : "path.join(__dirname, '.dd-test-optimization-validation-atr-state')"
334
- const definitions = [
335
- { id: 'basic-pass', purpose: 'basic_reporting|efd_candidate', testName: 'basic-pass' },
336
- { id: 'atr-fail-once', purpose: 'auto_test_retries_candidate', testName: 'atr-fail-once' },
337
- { id: 'test-management-target', purpose: 'test_management_candidate', testName: 'test-management-target' },
338
- ]
501
+ /**
502
+ * Selects one representative framework-owned test file.
503
+ *
504
+ * @param {string[]} files bounded project files
505
+ * @param {string} framework framework name
506
+ * @param {string} projectRoot project root
507
+ * @param {string} packageName project package name
508
+ * @param {boolean} allowDirectoryConvention whether a literal runner selector chose this root
509
+ * @returns {{path: string, requiresExternalService: boolean, requiresLocalSocket: boolean}|undefined} selected test
510
+ */
511
+ function selectRepresentativeTest (files, framework, projectRoot, packageName, allowDirectoryConvention) {
512
+ const candidates = []
513
+ for (const filename of files) {
514
+ const source = readText(filename)
515
+ if (source === undefined ||
516
+ !isTestFile(filename, source, framework, projectRoot, allowDirectoryConvention) ||
517
+ hasConflictingFramework(source, framework)) continue
518
+ if (framework === 'cucumber'
519
+ ? cucumber.getScenarioCount(source) === 0
520
+ : getStaticTestCount(source) === 0) continue
521
+ if (!hasFrameworkOwnership(filename, source, framework, packageName)) continue
522
+ candidates.push({
523
+ path: filename,
524
+ rank: getTestRank(filename, source, projectRoot),
525
+ requiresExternalService: framework === 'cypress' && CYPRESS_LOCAL_ORIGIN_PATTERN.test(source),
526
+ requiresLocalSocket: LOCAL_SOCKET_PATTERN.test(source),
527
+ })
528
+ }
529
+ candidates.sort((left, right) => {
530
+ return Number(left.requiresExternalService) - Number(right.requiresExternalService) ||
531
+ left.rank - right.rank ||
532
+ left.path.localeCompare(right.path)
533
+ })
534
+ return candidates[0]
535
+ }
339
536
 
340
- return definitions.map(definition => {
341
- const prefix = `dd-test-optimization-validation-${definition.id}`
342
- const filename = convention.exactFilename
343
- ? path.join(prefix, convention.exactFilename)
344
- : `${prefix}${convention.fileExtension}`
345
- return {
346
- ...definition,
347
- file: path.join(convention.testDirectory, filename),
348
- content: getGeneratedTestContent({ framework, definition, moduleSystem, stateFileExpression }),
349
- }
537
+ /**
538
+ * Reports whether a file follows a selected framework's test convention.
539
+ *
540
+ * @param {string} filename candidate file
541
+ * @param {string} source candidate source
542
+ * @param {string} framework framework name
543
+ * @param {string} projectRoot project root
544
+ * @param {boolean} allowDirectoryConvention whether a literal runner selector chose this root
545
+ * @returns {boolean} whether the file is a candidate
546
+ */
547
+ function isTestFile (filename, source, framework, projectRoot, allowDirectoryConvention) {
548
+ const basename = path.basename(filename)
549
+ const normalized = filename.replaceAll('\\', '/')
550
+ if (normalized.includes('/dd-test-optimization-validation-')) return false
551
+ if (TYPE_ONLY_TEST_PATTERN.test(basename) || TYPE_ONLY_DIRECTORY_PATTERN.test(normalized)) return false
552
+ if (framework === 'cucumber') return cucumber.isTestFile(filename)
553
+ if (framework === 'cypress') return cypress.isTestFile(basename, path.dirname(filename), projectRoot)
554
+ if (framework === 'playwright') return playwright.isTestFile(basename, path.dirname(filename), projectRoot)
555
+ if (TEST_FILE_PATTERN.test(basename)) return true
556
+ const directories = [
557
+ path.basename(projectRoot),
558
+ ...path.relative(projectRoot, path.dirname(filename)).split(path.sep),
559
+ ]
560
+ const inTestDirectory = directories.some(directory => {
561
+ return ['__tests__', 'spec', 'test', 'tests'].includes(directory)
350
562
  })
563
+ if (BARE_TEST_FILE_PATTERN.test(basename)) {
564
+ return inTestDirectory || hasExplicitFrameworkImport(source, framework)
565
+ }
566
+ return allowDirectoryConvention && inTestDirectory && /\.[cm]?[jt]sx?$/.test(basename)
351
567
  }
352
568
 
353
- function getGeneratedTestContent ({ framework, definition, moduleSystem, stateFileExpression }) {
354
- const imports = []
355
- if (framework === 'vitest' && moduleSystem === 'esm') {
356
- imports.push("import { describe, expect, it } from 'vitest'")
569
+ /**
570
+ * Reports whether an unconventional test file imports the selected framework directly.
571
+ *
572
+ * @param {string} source candidate source
573
+ * @param {string} framework framework name
574
+ * @returns {boolean} whether ownership is explicit
575
+ */
576
+ function hasExplicitFrameworkImport (source, framework) {
577
+ if (framework === 'vitest') {
578
+ return /(?:from\s+|require\s*\(\s*)['"]vitest['"]/.test(source)
357
579
  }
358
- if (framework === 'mocha') {
359
- imports.push(moduleSystem === 'esm'
360
- ? "import assert from 'node:assert/strict'"
361
- : "const assert = require('node:assert/strict')")
362
- }
363
- if (definition.id === 'atr-fail-once') {
364
- if (moduleSystem === 'esm') {
365
- imports.push(
366
- "import { existsSync, writeFileSync } from 'node:fs'",
367
- "import { dirname, join } from 'node:path'",
368
- "import { fileURLToPath } from 'node:url'"
369
- )
370
- } else {
371
- imports.push("const fs = require('node:fs')", "const path = require('node:path')")
372
- }
580
+ if (framework === 'jest') {
581
+ return /(?:from\s+|require\s*\(\s*)['"]@jest\/globals['"]/.test(source)
373
582
  }
374
- const assertion = framework === 'mocha' ? 'assert.equal(1, 1)' : 'expect(true).toBe(true)'
375
- const testFunction = framework === 'jest' ? 'test' : 'it'
376
- const body = definition.id === 'atr-fail-once'
377
- ? getAtrBody({ moduleSystem, stateFileExpression, assertion })
378
- : ` ${assertion}`
379
-
380
- return [
381
- ...imports,
382
- imports.length > 0 ? '' : undefined,
383
- "describe('dd-test-optimization-validation', () => {",
384
- ` ${testFunction}('${definition.testName}', () => {`,
385
- body,
386
- ' })',
387
- '})',
388
- ].filter(line => line !== undefined).join('\n')
389
- }
390
-
391
- function getAtrBody ({ moduleSystem, stateFileExpression, assertion }) {
392
- const exists = moduleSystem === 'esm' ? 'existsSync' : 'fs.existsSync'
393
- const write = moduleSystem === 'esm' ? 'writeFileSync' : 'fs.writeFileSync'
394
- return [
395
- ` const stateFile = ${stateFileExpression}`,
396
- ` if (!${exists}(stateFile)) {`,
397
- ` ${write}(stateFile, 'failed-once')`,
398
- " throw new Error('dd-test-optimization-validation atr first failure')",
399
- ' }',
400
- ` ${assertion}`,
401
- ].join('\n')
402
- }
403
-
404
- function buildGeneratedRunCommand (framework, projectRoot, filename, runner, moduleSystem) {
405
- const args = {
406
- jest: ['--runTestsByPath', filename, '--runInBand', '--silent', '--no-watchman'],
407
- mocha: ['--reporter', 'spec', filename],
408
- vitest: ['run', filename, ...(moduleSystem === 'commonjs' ? ['--globals'] : [])],
409
- }[framework]
410
- return {
411
- cwd: projectRoot,
412
- argv: [process.execPath, runner, ...args],
413
- env: {},
414
- requiredEnvVars: [],
415
- timeoutMs: 300_000,
416
- usesShell: false,
583
+ if (framework === 'mocha') {
584
+ return /(?:from\s+|require\s*\(\s*)['"]mocha['"]/.test(source)
417
585
  }
586
+ return false
418
587
  }
419
588
 
420
589
  /**
421
- * Adds a single test file selection to a detected project command.
590
+ * Conservatively associates a test source with one framework.
422
591
  *
423
- * @param {object} baseCommand detected project command
424
- * @param {string} framework detected test framework
425
- * @param {string} filename selected test file
426
- * @param {boolean} packageScript whether the command invokes a package script
427
- * @param {boolean} preserveDefaultReporter whether a repository wrapper owns reporter selection
428
- * @param {string} [moduleSystem] generated test module system
429
- * @returns {object} focused project command
592
+ * @param {string} filename candidate filename
593
+ * @param {string} source file source
594
+ * @param {string} framework framework name
595
+ * @param {string} packageName project package name
596
+ * @returns {boolean} whether ownership is plausible
430
597
  */
431
- function buildFocusedCommand (
432
- baseCommand,
433
- framework,
434
- filename,
435
- packageScript,
436
- preserveDefaultReporter,
437
- moduleSystem
438
- ) {
439
- const argv = [...baseCommand.argv]
440
- if (packageScript && path.basename(argv[0]).toLowerCase() === 'npm') argv.push('--')
441
- argv.push(...getFocusedTestArgs(framework, filename, preserveDefaultReporter, moduleSystem))
442
-
443
- return {
444
- ...baseCommand,
445
- description: `${baseCommand.description} targeting ${path.basename(filename)}`,
446
- argv,
598
+ function hasFrameworkOwnership (filename, source, framework, packageName) {
599
+ const normalized = filename.replaceAll('\\', '/').toLowerCase()
600
+ if (framework === 'cucumber') return /^\s*(?:Feature|Rule):/m.test(source)
601
+ if (framework === 'cypress') return /\.cy\./.test(filename) || normalized.includes('/cypress/')
602
+ if (framework === 'playwright') {
603
+ return /@playwright\/test/.test(source) || normalized.includes('/playwright/') ||
604
+ normalized.includes('/e2e/')
447
605
  }
606
+ if (framework === 'vitest') return /\bvitest\b/.test(source) || /\b(?:describe|it|test)\s*\(/.test(source)
607
+ if (framework === 'jest') return /\bjest\b/.test(source) || /\b(?:describe|it|test)\s*\(/.test(source)
608
+ if (framework === 'mocha') {
609
+ return /\bmocha\b/.test(source) || /\bdescribe\s*\(/.test(source) ||
610
+ packageName === 'mocha'
611
+ }
612
+ return false
448
613
  }
449
614
 
450
615
  /**
451
- * Returns framework arguments that select exactly one test file.
616
+ * Rejects files visibly owned by another supported runner.
452
617
  *
453
- * @param {string} framework detected test framework
454
- * @param {string} filename selected test file
455
- * @param {boolean} preserveDefaultReporter whether a repository wrapper owns reporter selection
456
- * @param {string} [moduleSystem] generated test module system
457
- * @returns {string[]} focused test arguments
618
+ * @param {string} source candidate source
619
+ * @param {string} framework selected framework
620
+ * @returns {boolean} whether source ownership conflicts
458
621
  */
459
- function getFocusedTestArgs (framework, filename, preserveDefaultReporter, moduleSystem) {
460
- if (framework === 'jest') {
461
- return [
462
- '--runTestsByPath',
463
- filename,
464
- '--runInBand',
465
- ...(preserveDefaultReporter ? [] : ['--silent']),
466
- '--no-watchman',
467
- ]
622
+ function hasConflictingFramework (source, framework) {
623
+ const markers = {
624
+ cypress: /\bcy\.(?:visit|request|get)\b|from\s+['"]cypress/,
625
+ jest: /@jest\/globals|\bjest\.(?:mock|fn|spyOn)\b/,
626
+ 'node:test': /(?:from\s+|require\s*\(\s*)['"]node:test['"]/,
627
+ playwright: /@playwright\/test/,
628
+ vitest: /from\s+['"]vitest['"]|require\s*\(\s*['"]vitest['"]\s*\)|\bvi\./,
468
629
  }
469
- return [filename, ...(framework === 'vitest' && moduleSystem === 'commonjs' ? ['--globals'] : [])]
630
+ return Object.entries(markers).some(([name, pattern]) => name !== framework && pattern.test(source))
470
631
  }
471
632
 
472
633
  /**
473
- * Resolves an installed framework executable without making the whole scaffold fail when a nested package only
474
- * declares the dependency.
634
+ * Ranks simpler unit tests ahead of integration and service-dependent tests.
475
635
  *
476
- * @param {string} framework detected framework
477
- * @param {string} projectRoot detected project root
478
- * @returns {string|undefined} resolved executable path
636
+ * @param {string} filename candidate filename
637
+ * @param {string} source candidate source
638
+ * @param {string} projectRoot project root
639
+ * @returns {number} lower-is-better rank
479
640
  */
480
- function tryResolveRunner (framework, projectRoot) {
481
- try {
482
- return resolveRunner(framework, projectRoot)
483
- } catch {}
641
+ function getTestRank (filename, source, projectRoot) {
642
+ const relative = path.relative(projectRoot, filename).replaceAll('\\', '/').toLowerCase()
643
+ let rank = relative.split('/').length
644
+ if (/(?:^|\/)(?:test|tests|__tests__|spec)\//.test(relative)) rank -= 20
645
+ if (/(?:^|[._/-])unit(?:[._/-]|$)/.test(relative)) rank -= 15
646
+ if (/(?:e2e|integration|acceptance|browser|conformance|fixtures?)/.test(relative)) rank += 20
647
+ if (LOCAL_SOCKET_PATTERN.test(source)) rank += 30
648
+ rank += Math.min(getStaticTestCount(source), 50)
649
+ return rank
484
650
  }
485
651
 
486
- function resolveRunner (framework, projectRoot) {
487
- const packageName = framework === 'jest' ? 'jest' : framework
488
- const packageJsonPath = require.resolve(`${packageName}/package.json`, { paths: [projectRoot] })
489
- const packageJson = readJson(packageJsonPath)
490
- const bin = typeof packageJson.bin === 'string' ? packageJson.bin : packageJson.bin[packageName]
491
- return path.resolve(path.dirname(packageJsonPath), bin)
652
+ /**
653
+ * Counts static test declarations for preferring small representative files.
654
+ *
655
+ * @param {string} source test source
656
+ * @returns {number} approximate test declaration count
657
+ */
658
+ function getStaticTestCount (source) {
659
+ return [...source.matchAll(/\b(?:it|test)((?:\.[A-Za-z]+)*)\s*\(/g)]
660
+ .filter(match => !/\.(?:skip|todo)\b/.test(match[1]))
661
+ .length
492
662
  }
493
663
 
494
- function getPackageScriptArgv (packageManager, scriptName, repositoryRoot) {
495
- if (packageManager === 'yarn') {
496
- const release = findYarnRelease(repositoryRoot)
497
- return release ? [process.execPath, release, 'run', scriptName] : ['yarn', 'run', scriptName]
664
+ /**
665
+ * Narrows a repository root to the conventional package matching its identity.
666
+ *
667
+ * @param {string} projectRoot detected project root
668
+ * @param {string} repositoryRoot repository root
669
+ * @returns {string} preferred representative root
670
+ */
671
+ function findPreferredRepresentativeRoot (projectRoot, repositoryRoot) {
672
+ if (path.resolve(projectRoot) !== path.resolve(repositoryRoot)) return projectRoot
673
+ const repositoryName = normalizeProjectIdentity(path.basename(repositoryRoot))
674
+
675
+ for (const containerName of ['packages', 'pkgs', 'modules']) {
676
+ const container = path.join(repositoryRoot, containerName)
677
+ for (const entry of readDirectoryEntries(container)) {
678
+ if (!entry.isDirectory()) continue
679
+ const candidate = path.join(container, entry.name)
680
+ const packageJson = readJson(path.join(candidate, 'package.json'))
681
+ if (normalizeProjectIdentity(packageJson?.name || entry.name) === repositoryName) return candidate
682
+ }
498
683
  }
499
- return [packageManager, 'run', scriptName]
684
+ return projectRoot
500
685
  }
501
686
 
502
687
  /**
503
- * Finds the package script whose value produced the detected framework command.
688
+ * Normalizes repository and package names for exact identity comparison.
504
689
  *
505
- * @param {object} packageJson project package metadata
506
- * @param {string} command detected framework command
507
- * @returns {string|undefined} package script name
690
+ * @param {string} value package identity
691
+ * @returns {string} normalized identity
508
692
  */
509
- function getPackageScriptName (packageJson, command) {
510
- return Object.entries(packageJson.scripts || {}).find(([, value]) => value === command)?.[0]
693
+ function normalizeProjectIdentity (value) {
694
+ const unscoped = String(value || '').toLowerCase().replace(/^@[^/]+\//, '')
695
+ return unscoped.replaceAll(/[^a-z0-9]+/g, '').replace(/js$/, '')
511
696
  }
512
697
 
513
698
  /**
514
- * Reports whether a package script invokes the framework binary without a repository wrapper.
699
+ * Collects bounded regular project files without following symbolic links.
515
700
  *
516
- * @param {string} command detected framework command
517
- * @param {string} framework detected test framework
518
- * @returns {boolean} whether the command starts with the framework binary
701
+ * @param {string} root project root
702
+ * @returns {string[]} absolute files
519
703
  */
520
- function usesBareFrameworkRunner (command, framework) {
521
- const tokens = String(command || '').trim().split(/\s+/)
522
- const executable = path.basename(tokens[0] || '').replace(/\.cmd$/i, '').toLowerCase()
523
- if (executable !== framework) return false
524
- return tokens.length === 1 || framework === 'vitest' && tokens.length === 2 && tokens[1] === 'run'
525
- }
526
-
527
- function getDirectRunnerArgv (framework, runner) {
528
- return [process.execPath, runner, ...(framework === 'vitest' ? ['run'] : [])]
529
- }
530
-
531
- function findRepresentativeTestFile (root) {
532
- const stack = [root]
533
- const candidates = []
534
- const packageRanks = new Map()
704
+ function collectProjectFiles (root) {
705
+ const files = []
706
+ const pending = [root]
535
707
  let visited = 0
536
- while (stack.length > 0 && visited < 5000) {
537
- const directory = stack.pop()
538
- let entries
539
- try {
540
- entries = fs.readdirSync(directory, { withFileTypes: true })
541
- .sort((left, right) => left.name.localeCompare(right.name))
542
- } catch {
543
- continue
544
- }
545
- for (const entry of entries) {
546
- if (['.git', 'node_modules', 'coverage', 'dist', 'build'].includes(entry.name)) continue
708
+
709
+ while (pending.length > 0 && visited < MAX_DISCOVERY_ENTRIES) {
710
+ const directory = pending.shift()
711
+ for (const entry of readDirectoryEntries(directory)) {
712
+ if (++visited > MAX_DISCOVERY_ENTRIES) break
547
713
  const filename = path.join(directory, entry.name)
714
+ if (entry.isSymbolicLink()) continue
548
715
  if (entry.isDirectory()) {
549
- stack.push(filename)
550
- continue
551
- }
552
- visited++
553
- const inTestsDirectory = path.relative(root, directory).split(path.sep).includes('__tests__')
554
- if (/^(?:test\.(?:[cm]?[jt]s|[jt]sx)|.+[.-](?:test|spec)\.(?:[cm]?[jt]s|[jt]sx))$/.test(entry.name) ||
555
- (inTestsDirectory && /\.(?:[cm]?[jt]s|[jt]sx)$/.test(entry.name))) {
556
- candidates.push(filename)
716
+ if (!SKIPPED_DIRECTORIES.has(entry.name)) pending.push(filename)
717
+ } else if (entry.isFile()) {
718
+ files.push(filename)
557
719
  }
558
720
  }
559
721
  }
722
+ return files
723
+ }
560
724
 
561
- candidates.sort((left, right) => {
562
- return getTestDirectoryRank(left, root) - getTestDirectoryRank(right, root) ||
563
- getTestAreaRank(left, root) - getTestAreaRank(right, root) ||
564
- getIndependentTestProjectRank(left, root, packageRanks) -
565
- getIndependentTestProjectRank(right, root, packageRanks) ||
566
- left.localeCompare(right)
567
- })
568
- return candidates[0]
725
+ /**
726
+ * Resolves a framework package executable only inside the repository.
727
+ *
728
+ * @param {string} framework framework name
729
+ * @param {string} projectRoot project root
730
+ * @param {string} repositoryRoot repository root
731
+ * @returns {string|undefined} physical runner path
732
+ */
733
+ function resolveRunner (framework, projectRoot, repositoryRoot) {
734
+ const packageName = getRunnerPackageName(framework)
735
+ const packageRoot = findPackageRoot(packageName, projectRoot, repositoryRoot)
736
+ if (!packageRoot) return
737
+
738
+ const packageJson = readJson(path.join(packageRoot, 'package.json'))
739
+ const binName = framework === 'cucumber' ? 'cucumber-js' : framework === 'playwright' ? 'playwright' : framework
740
+ const bin = typeof packageJson?.bin === 'string' ? packageJson.bin : packageJson?.bin?.[binName]
741
+ if (typeof bin !== 'string') return
742
+
743
+ try {
744
+ const runner = fs.realpathSync(path.resolve(packageRoot, bin))
745
+ return isPathInside(fs.realpathSync(repositoryRoot), runner) && fs.statSync(runner).isFile()
746
+ ? runner
747
+ : undefined
748
+ } catch {}
569
749
  }
570
750
 
571
751
  /**
572
- * Ranks established test directories ahead of source-adjacent test-looking files.
752
+ * Finds an installed package or a framework source checkout.
573
753
  *
574
- * @param {string} filename candidate test file
575
- * @param {string} root detected project root
576
- * @returns {number} directory preference rank
754
+ * @param {string} packageName runner package name
755
+ * @param {string} projectRoot project root
756
+ * @param {string} repositoryRoot repository root
757
+ * @returns {string|undefined} physical package root
577
758
  */
578
- function getTestDirectoryRank (filename, root) {
579
- const directories = path.relative(root, path.dirname(filename)).split(path.sep)
580
- if (directories.includes('__tests__')) return 0
581
- if (directories.some(directory => directory === 'test' || directory === 'tests')) return 1
582
- return 2
759
+ function findPackageRoot (packageName, projectRoot, repositoryRoot) {
760
+ const ownPackage = readJson(path.join(projectRoot, 'package.json'))
761
+ if (ownPackage?.name === packageName) return projectRoot
762
+
763
+ let directory = projectRoot
764
+ while (isPathInside(repositoryRoot, directory)) {
765
+ const candidate = path.join(directory, 'node_modules', ...packageName.split('/'))
766
+ try {
767
+ const physical = fs.realpathSync(candidate)
768
+ if (isPathInside(fs.realpathSync(repositoryRoot), physical)) return physical
769
+ } catch {}
770
+ if (directory === repositoryRoot) break
771
+ directory = path.dirname(directory)
772
+ }
773
+ }
774
+
775
+ /**
776
+ * Returns a runner package name.
777
+ *
778
+ * @param {string} framework framework name
779
+ * @returns {string} package name
780
+ */
781
+ function getRunnerPackageName (framework) {
782
+ return {
783
+ cucumber: '@cucumber/cucumber',
784
+ playwright: '@playwright/test',
785
+ }[framework] || framework
786
+ }
787
+
788
+ /**
789
+ * Returns a generated-test location that follows the representative discovery convention.
790
+ *
791
+ * @param {string} framework framework name
792
+ * @param {string} representative representative test
793
+ * @param {string} projectRoot detected project root
794
+ * @returns {{exactFilename: string|undefined, fileExtension: string, testDirectory: string}} convention
795
+ */
796
+ function getGeneratedTestConvention (framework, representative, projectRoot) {
797
+ const basename = path.basename(representative)
798
+ if (/^test\.[cm]?[jt]s$/.test(basename)) {
799
+ const representativeDirectory = path.dirname(representative)
800
+ return {
801
+ exactFilename: basename,
802
+ fileExtension: path.extname(basename),
803
+ testDirectory: representativeDirectory === projectRoot
804
+ ? projectRoot
805
+ : path.dirname(representativeDirectory),
806
+ }
807
+ }
808
+ return {
809
+ exactFilename: undefined,
810
+ fileExtension: getTestExtension(framework, representative),
811
+ testDirectory: path.dirname(representative),
812
+ }
583
813
  }
584
814
 
585
815
  /**
586
- * Ranks conventional project areas ahead of auxiliary repository trees.
816
+ * Returns the complete suffix needed for framework discovery.
587
817
  *
588
- * @param {string} filename candidate test file
589
- * @param {string} root detected project root
590
- * @returns {number} project area preference rank
818
+ * @param {string} framework framework name
819
+ * @param {string} representative representative test
820
+ * @returns {string} generated test suffix
591
821
  */
592
- function getTestAreaRank (filename, root) {
593
- const [topLevelDirectory] = path.relative(root, filename).split(path.sep)
594
- return ['packages', 'src', 'test', 'tests'].includes(topLevelDirectory) ? 0 : 1
822
+ function getTestExtension (framework, representative) {
823
+ if (framework === 'cucumber') return '.feature'
824
+ if (framework === 'cypress') return cypress.getTestExtension(representative)
825
+ if (framework === 'playwright') return playwright.getTestExtension(representative)
826
+ const match = /((?:[.-](?:test|spec))\.[cm]?[jt]sx?)$/.exec(representative)
827
+ if (match) return match[1]
828
+ const moduleExtension = /\.((?:[cm]js|[cm]ts))$/.exec(representative)?.[1]
829
+ return moduleExtension ? `.test.${moduleExtension}` : '.test.js'
595
830
  }
596
831
 
597
832
  /**
598
- * Ranks independently tested nested packages behind tests owned by the detected root command.
833
+ * Determines the generated JavaScript module system.
599
834
  *
600
- * @param {string} filename candidate test file
601
- * @param {string} root detected project root
602
- * @param {Map<string, number>} cache package-directory rank cache
603
- * @returns {number} independent test project rank
835
+ * @param {string} representative representative test
836
+ * @param {string} projectRoot project root
837
+ * @returns {'commonjs'|'esm'} module system
604
838
  */
605
- function getIndependentTestProjectRank (filename, root, cache) {
606
- let directory = path.dirname(filename)
607
- while (directory !== root && directory.startsWith(`${root}${path.sep}`)) {
608
- if (cache.has(directory)) return cache.get(directory)
839
+ function getModuleSystem (representative, projectRoot) {
840
+ if (/\.(?:mjs|mts)$/.test(representative)) return 'esm'
841
+ if (/\.(?:cjs|cts)$/.test(representative)) return 'commonjs'
609
842
 
843
+ let directory = path.dirname(representative)
844
+ while (isPathInside(projectRoot, directory)) {
610
845
  const packageJson = readJson(path.join(directory, 'package.json'))
611
- if (packageJson) {
612
- const rank = typeof packageJson.scripts?.test === 'string' ? 1 : 0
613
- cache.set(directory, rank)
614
- return rank
615
- }
846
+ if (packageJson) return packageJson.type === 'module' ? 'esm' : 'commonjs'
847
+ if (directory === projectRoot) break
616
848
  directory = path.dirname(directory)
617
849
  }
618
- return 0
850
+ return 'commonjs'
619
851
  }
620
852
 
621
- function getTestExtension (filename) {
622
- const match = /((?:\.test|\.spec)\.(?:[cm]?[jt]s|[jt]sx))$/.exec(filename)
623
- return match?.[1] || '.test.js'
853
+ /**
854
+ * Discovers bounded CI configuration paths without interpreting them.
855
+ *
856
+ * @param {string} root repository root
857
+ * @returns {object} CI discovery data
858
+ */
859
+ function discoverCiFiles (root) {
860
+ const found = []
861
+ for (const relativePath of CI_PATHS) {
862
+ const absolutePath = path.join(root, relativePath)
863
+ try {
864
+ const stat = fs.lstatSync(absolutePath)
865
+ if (stat.isSymbolicLink()) continue
866
+ if (stat.isFile()) {
867
+ found.push(relativePath)
868
+ } else if (stat.isDirectory()) {
869
+ for (const entry of readDirectoryEntries(absolutePath).slice(0, MAX_CI_FILES)) {
870
+ if (entry.isFile() && /\.ya?ml$/i.test(entry.name)) found.push(path.join(relativePath, entry.name))
871
+ }
872
+ }
873
+ } catch {}
874
+ }
875
+ const normalized = found.map(filename => filename.split(path.sep).join('/')).sort()
876
+ return {
877
+ found: normalized,
878
+ reviewRequired: normalized.length > 0,
879
+ reviewTargets: rankCiReviewTargets(normalized).slice(0, MAX_CI_REVIEW_TARGETS),
880
+ searched: CI_PATHS,
881
+ }
882
+ }
883
+
884
+ /**
885
+ * Ranks likely test workflows first.
886
+ *
887
+ * @param {string[]} files discovered CI paths
888
+ * @returns {string[]} ranked paths
889
+ */
890
+ function rankCiReviewTargets (files) {
891
+ return [...files].sort((left, right) => getCiRank(left) - getCiRank(right) || left.localeCompare(right))
892
+ }
893
+
894
+ /**
895
+ * Returns a lower-is-better CI review rank.
896
+ *
897
+ * @param {string} filename CI path
898
+ * @returns {number} rank
899
+ */
900
+ function getCiRank (filename) {
901
+ const value = filename.toLowerCase()
902
+ if (/(?:test|ci|build|unit|integration)/.test(value)) return 0
903
+ return 10
904
+ }
905
+
906
+ /**
907
+ * Finds the package manifest that owns a detection.
908
+ *
909
+ * @param {string} repositoryRoot repository root
910
+ * @param {string|undefined} location detected relative path
911
+ * @returns {{json: object, path: string}} package metadata
912
+ */
913
+ function getDetectionPackageJson (repositoryRoot, location) {
914
+ const candidate = typeof location === 'string' && path.basename(location) === 'package.json'
915
+ ? path.resolve(repositoryRoot, location)
916
+ : path.join(repositoryRoot, 'package.json')
917
+ const json = readJson(candidate)
918
+ if (json) return { json, path: candidate }
919
+ return {
920
+ json: readJson(path.join(repositoryRoot, 'package.json')) || {},
921
+ path: path.join(repositoryRoot, 'package.json'),
922
+ }
624
923
  }
625
924
 
626
- function findConfigFiles (root, framework) {
627
- const patterns = {
628
- jest: /^jest\.config\./,
629
- mocha: /^\.mocharc\./,
630
- vitest: /^(?:vite|vitest)\.config\./,
631
- }[framework]
632
- if (!patterns) return []
925
+ /**
926
+ * Safely reads a bounded directory.
927
+ *
928
+ * @param {string} directory directory path
929
+ * @returns {fs.Dirent[]} entries
930
+ */
931
+ function readDirectoryEntries (directory) {
633
932
  try {
634
- return fs.readdirSync(root).filter(filename => patterns.test(filename)).map(filename => path.join(root, filename))
933
+ return fs.readdirSync(directory, { withFileTypes: true }).slice(0, MAX_DIRECTORY_ENTRIES)
635
934
  } catch {
636
935
  return []
637
936
  }
638
937
  }
639
938
 
640
939
  /**
641
- * Resolves the package.json associated with a framework detection.
940
+ * Reads bounded UTF-8 source.
642
941
  *
643
- * @param {string} repositoryRoot repository root
644
- * @param {string[]} locations detected evidence paths
645
- * @returns {string} absolute package.json path
942
+ * @param {string} filename file path
943
+ * @returns {string|undefined} source
646
944
  */
647
- function getDetectionPackageJson (repositoryRoot, locations = []) {
648
- const location = locations.find(value => path.basename(value) === 'package.json')
649
- return path.resolve(repositoryRoot, location || 'package.json')
945
+ function readText (filename) {
946
+ try {
947
+ const stat = fs.lstatSync(filename)
948
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_FILE_BYTES) return
949
+ return fs.readFileSync(filename, 'utf8')
950
+ } catch {}
650
951
  }
651
952
 
652
953
  /**
653
- * Resolves a detected runner version without executing repository code.
954
+ * Reads JSON without throwing.
654
955
  *
655
- * @param {string} framework detected framework package name
656
- * @param {string} projectRoot detected project root
657
- * @param {object} packageJson detected project package.json
658
- * @returns {string|null} installed or declared framework version
956
+ * @param {string} filename JSON path
957
+ * @returns {object|undefined} parsed object
659
958
  */
660
- function getInstalledFrameworkVersion (framework, projectRoot, packageJson) {
661
- if (framework === 'node-test') return process.version
959
+ function readJson (filename) {
960
+ const source = readText(filename)
961
+ if (source === undefined) return
662
962
  try {
663
- const filename = require.resolve(`${framework}/package.json`, { paths: [projectRoot] })
664
- return readJson(filename)?.version || null
963
+ return JSON.parse(source)
665
964
  } catch {}
965
+ }
666
966
 
667
- for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
668
- if (typeof packageJson[field]?.[framework] === 'string') return packageJson[field][framework]
669
- }
670
- return null
967
+ /**
968
+ * Returns whether a framework target was selected.
969
+ *
970
+ * @param {string} framework framework id
971
+ * @param {Set<string>} selected selected ids
972
+ * @returns {boolean} selection result
973
+ */
974
+ function isSelected (framework, selected) {
975
+ return selected.size === 0 || selected.has(framework)
671
976
  }
672
977
 
673
- function discoverCiFiles (root) {
674
- const found = []
675
- for (const relativePath of CI_PATHS) {
676
- const filename = path.join(root, relativePath)
677
- if (!fs.existsSync(filename)) continue
678
- const stat = fs.statSync(filename)
679
- if (stat.isDirectory()) {
680
- for (const entry of fs.readdirSync(filename).sort()) {
681
- if (/\.ya?ml$/.test(entry)) found.push(path.posix.join(relativePath, entry))
682
- }
683
- } else {
684
- found.push(relativePath)
685
- }
686
- }
687
- return {
688
- searched: [...CI_PATHS],
689
- found,
690
- method: 'deterministic-known-ci-paths',
691
- warnings: [],
692
- notes: ['Generated by --init-manifest; select and record one replayable CI test step before live validation.'],
693
- }
978
+ /**
979
+ * Returns a stable project identifier.
980
+ *
981
+ * @param {object} packageJson package metadata
982
+ * @param {string} projectRoot project root
983
+ * @param {string} repositoryRoot repository root
984
+ * @returns {string} project id
985
+ */
986
+ function getProjectId (packageJson, projectRoot, repositoryRoot) {
987
+ const value = packageJson.name || path.relative(repositoryRoot, projectRoot) || 'root'
988
+ return value.replace(/^@/, '').replaceAll(/[^A-Za-z0-9_.-]+/g, '-')
694
989
  }
695
990
 
991
+ /**
992
+ * Detects package-manager metadata for reporting only.
993
+ *
994
+ * @param {string} root repository root
995
+ * @returns {string} package manager
996
+ */
696
997
  function detectPackageManager (root) {
697
998
  if (fs.existsSync(path.join(root, 'pnpm-lock.yaml'))) return 'pnpm'
698
999
  if (fs.existsSync(path.join(root, 'yarn.lock'))) return 'yarn'
699
- return 'npm'
1000
+ if (fs.existsSync(path.join(root, 'package-lock.json'))) return 'npm'
1001
+ return 'unknown'
700
1002
  }
701
1003
 
1004
+ /**
1005
+ * Detects workspace metadata for reporting only.
1006
+ *
1007
+ * @param {string} root repository root
1008
+ * @returns {string} workspace manager
1009
+ */
702
1010
  function detectWorkspaceManager (root) {
703
1011
  if (fs.existsSync(path.join(root, 'pnpm-workspace.yaml'))) return 'pnpm'
704
1012
  const packageJson = readJson(path.join(root, 'package.json'))
705
1013
  return packageJson?.workspaces ? detectPackageManager(root) : 'none'
706
1014
  }
707
1015
 
1016
+ /**
1017
+ * Returns a manifest operating-system name.
1018
+ *
1019
+ * @param {string} platform Node.js platform
1020
+ * @returns {string} OS name
1021
+ */
708
1022
  function getManifestOs (platform) {
709
- if (platform === 'win32') return 'windows'
710
- if (platform === 'darwin' || platform === 'linux') return platform
711
- return 'unknown'
712
- }
713
-
714
- function findYarnRelease (root) {
715
- const directory = path.join(root, '.yarn', 'releases')
716
- try {
717
- const release = fs.readdirSync(directory).find(filename => /^yarn-.+\.cjs$/.test(filename))
718
- return release && path.join(directory, release)
719
- } catch {}
720
- }
721
-
722
- function getProjectIdentifier (packageJson, projectRoot, repositoryRoot) {
723
- if (packageJson.name) return packageJson.name.replaceAll(/[^A-Za-z0-9._-]+/g, '-')
724
- return (path.relative(repositoryRoot, projectRoot) || 'root').replaceAll(path.sep, '-')
1023
+ return platform === 'win32' ? 'windows' : platform
725
1024
  }
726
1025
 
1026
+ /**
1027
+ * Checks lexical path containment.
1028
+ *
1029
+ * @param {string} root root path
1030
+ * @param {string} filename candidate path
1031
+ * @returns {boolean} whether the candidate is contained
1032
+ */
727
1033
  function isPathInside (root, filename) {
728
- const relative = path.relative(root, filename)
729
- return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)
730
- }
731
-
732
- function readJson (filename) {
733
- try {
734
- return JSON.parse(fs.readFileSync(filename, 'utf8'))
735
- } catch {}
1034
+ const relative = path.relative(path.resolve(root), path.resolve(filename))
1035
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
736
1036
  }
737
1037
 
738
1038
  module.exports = { createManifestScaffold }