dd-trace 5.116.0 → 5.118.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/ci/diagnose.js +162 -21
  2. package/ci/init.js +0 -1
  3. package/ci/runbook.md +187 -180
  4. package/ci/test-optimization-validation/approval-artifacts.js +3 -1
  5. package/ci/test-optimization-validation/approval.js +52 -15
  6. package/ci/test-optimization-validation/ci-command-candidate.js +26 -32
  7. package/ci/test-optimization-validation/ci-discovery.js +1 -1
  8. package/ci/test-optimization-validation/ci-package-scripts.js +141 -0
  9. package/ci/test-optimization-validation/ci-remediation.js +112 -39
  10. package/ci/test-optimization-validation/cli.js +516 -681
  11. package/ci/test-optimization-validation/command-blocker.js +188 -33
  12. package/ci/test-optimization-validation/command-output-policy.js +3 -26
  13. package/ci/test-optimization-validation/command-runner.js +189 -245
  14. package/ci/test-optimization-validation/environment.js +90 -0
  15. package/ci/test-optimization-validation/executable.js +159 -388
  16. package/ci/test-optimization-validation/framework-adapters/cucumber.js +119 -0
  17. package/ci/test-optimization-validation/framework-adapters/cypress.js +107 -0
  18. package/ci/test-optimization-validation/framework-adapters/playwright.js +181 -0
  19. package/ci/test-optimization-validation/generated-files.js +75 -13
  20. package/ci/test-optimization-validation/generated-test-contract.js +283 -0
  21. package/ci/test-optimization-validation/generated-verifier.js +49 -16
  22. package/ci/test-optimization-validation/literal-environment.js +57 -0
  23. package/ci/test-optimization-validation/manifest-loader.js +2 -22
  24. package/ci/test-optimization-validation/manifest-scaffold.js +816 -515
  25. package/ci/test-optimization-validation/manifest-schema.js +494 -702
  26. package/ci/test-optimization-validation/offline-fixtures.js +4 -1
  27. package/ci/test-optimization-validation/plan-writer.js +243 -984
  28. package/ci/test-optimization-validation/preflight-runner.js +49 -55
  29. package/ci/test-optimization-validation/redaction.js +2 -1
  30. package/ci/test-optimization-validation/report-writer.js +391 -1357
  31. package/ci/test-optimization-validation/result-semantics.js +86 -0
  32. package/ci/test-optimization-validation/runner-command.js +249 -0
  33. package/ci/test-optimization-validation/runner-contract.js +664 -0
  34. package/ci/test-optimization-validation/scenarios/auto-test-retries.js +29 -3
  35. package/ci/test-optimization-validation/scenarios/basic-reporting.js +240 -543
  36. package/ci/test-optimization-validation/scenarios/ci-wiring.js +450 -670
  37. package/ci/test-optimization-validation/scenarios/early-flake-detection.js +4 -3
  38. package/ci/test-optimization-validation/scenarios/helpers.js +67 -9
  39. package/ci/test-optimization-validation/scenarios/test-management.js +4 -3
  40. package/ci/test-optimization-validation/source-text.js +87 -0
  41. package/ci/test-optimization-validation/test-output.js +12 -22
  42. package/ext/tags.d.ts +1 -0
  43. package/index.d.ts +7 -0
  44. package/package.json +3 -3
  45. package/packages/datadog-instrumentations/src/cucumber.js +11 -12
  46. package/packages/datadog-instrumentations/src/helpers/channel.js +74 -0
  47. package/packages/datadog-instrumentations/src/helpers/hooks.js +2 -0
  48. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/index.js +1 -0
  49. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/openai-agents.js +31 -0
  50. package/packages/datadog-instrumentations/src/jest.js +18 -17
  51. package/packages/datadog-instrumentations/src/mocha/main.js +79 -30
  52. package/packages/datadog-instrumentations/src/openai-agents.js +159 -0
  53. package/packages/datadog-instrumentations/src/playwright.js +3 -24
  54. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +1 -1
  55. package/packages/datadog-instrumentations/src/vitest-main.js +14 -26
  56. package/packages/datadog-instrumentations/src/vitest-util.js +0 -7
  57. package/packages/datadog-instrumentations/src/vitest-worker.js +5 -8
  58. package/packages/datadog-plugin-cucumber/src/index.js +2 -1
  59. package/packages/datadog-plugin-mocha/src/index.js +2 -1
  60. package/packages/datadog-plugin-openai-agents/src/index.js +74 -0
  61. package/packages/datadog-plugin-openai-agents/src/integration.js +503 -0
  62. package/packages/datadog-plugin-openai-agents/src/processor.js +108 -0
  63. package/packages/datadog-plugin-openai-agents/src/util.js +60 -0
  64. package/packages/datadog-plugin-playwright/src/index.js +4 -1
  65. package/packages/datadog-plugin-vitest/src/index.js +5 -5
  66. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +48 -49
  67. package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +161 -33
  68. package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache-schema.js +6 -4
  69. package/packages/dd-trace/src/config/generated-config-types.d.ts +2 -0
  70. package/packages/dd-trace/src/config/supported-configurations.json +7 -0
  71. package/packages/dd-trace/src/encode/span-stats.js +7 -1
  72. package/packages/dd-trace/src/exporter.js +16 -9
  73. package/packages/dd-trace/src/llmobs/index.js +9 -1
  74. package/packages/dd-trace/src/llmobs/plugins/ai/util.js +104 -22
  75. package/packages/dd-trace/src/llmobs/plugins/openai/index.js +5 -9
  76. package/packages/dd-trace/src/llmobs/plugins/openai/utils.js +20 -0
  77. package/packages/dd-trace/src/llmobs/plugins/openai-agents/utils.js +321 -0
  78. package/packages/dd-trace/src/llmobs/sdk.js +2 -1
  79. package/packages/dd-trace/src/llmobs/span_processor.js +7 -2
  80. package/packages/dd-trace/src/llmobs/tagger.js +23 -3
  81. package/packages/dd-trace/src/llmobs/util.js +56 -3
  82. package/packages/dd-trace/src/openfeature/agentless_configuration_source.js +3 -6
  83. package/packages/dd-trace/src/openfeature/configuration_source.js +5 -8
  84. package/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_transformer.js +5 -1
  85. package/packages/dd-trace/src/plugins/ci_plugin.js +9 -3
  86. package/packages/dd-trace/src/plugins/index.js +1 -0
  87. package/packages/dd-trace/src/plugins/outbound.js +4 -1
  88. package/packages/dd-trace/src/profiling/profilers/space.js +1 -1
  89. package/packages/dd-trace/src/profiling/profilers/wall.js +1 -0
  90. package/packages/dd-trace/src/span_processor.js +1 -1
  91. package/packages/dd-trace/src/span_stats.js +22 -4
  92. package/ci/test-optimization-validation/command-suitability.js +0 -471
  93. package/ci/test-optimization-validation/init-probe-preload.js +0 -163
  94. package/ci/test-optimization-validation/init-probe.js +0 -246
  95. package/ci/test-optimization-validation/late-initialization.js +0 -63
  96. package/ci/test-optimization-validation/local-command.js +0 -163
  97. package/ci/test-optimization-validation/setup-runner.js +0 -97
  98. package/ci/test-optimization-validation-manifest.schema.json +0 -1
@@ -1,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,905 @@ 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
329
+ return {
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
+ ],
344
+ }
345
+ }
346
+
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
195
359
  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'}.`],
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.`],
201
370
  }
202
371
  }
203
372
 
204
- function buildExistingCommand ({
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)
309
480
  }
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
- }
481
+ if (framework === 'playwright') {
482
+ const configFile = playwright.getGeneratedConfigPath(testDirectory)
483
+ files.push({ path: configFile, contentLines: playwright.getGeneratedConfigContent().split('\n') })
484
+ cleanupPaths.push(configFile)
485
+ }
486
+ if (['jest', 'mocha', 'vitest'].includes(framework)) {
487
+ cleanupPaths.push(getGeneratedRetryStatePath(framework, scenarios[1].testIdentities[0].file))
321
488
  }
322
489
 
323
490
  return {
324
- exactFilename: undefined,
325
- fileExtension: getTestExtension(representative),
326
- testDirectory: path.dirname(representative),
491
+ cleanupPaths,
492
+ fileExtension: extension,
493
+ files,
494
+ moduleSystem,
495
+ reason: 'Validator-owned direct-runner recipes.',
496
+ scenarios,
497
+ status: 'planned',
498
+ testDirectory,
327
499
  }
328
500
  }
329
501
 
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
- ]
502
+ /**
503
+ * Selects one representative framework-owned test file.
504
+ *
505
+ * @param {string[]} files bounded project files
506
+ * @param {string} framework framework name
507
+ * @param {string} projectRoot project root
508
+ * @param {string} packageName project package name
509
+ * @param {boolean} allowDirectoryConvention whether a literal runner selector chose this root
510
+ * @returns {{path: string, requiresExternalService: boolean, requiresLocalSocket: boolean}|undefined} selected test
511
+ */
512
+ function selectRepresentativeTest (files, framework, projectRoot, packageName, allowDirectoryConvention) {
513
+ const candidates = []
514
+ for (const filename of files) {
515
+ const source = readText(filename)
516
+ if (source === undefined ||
517
+ !isTestFile(filename, source, framework, projectRoot, allowDirectoryConvention) ||
518
+ hasConflictingFramework(source, framework)) continue
519
+ if (framework === 'cucumber'
520
+ ? cucumber.getScenarioCount(source) === 0
521
+ : getStaticTestCount(source) === 0) continue
522
+ if (!hasFrameworkOwnership(filename, source, framework, packageName)) continue
523
+ candidates.push({
524
+ path: filename,
525
+ rank: getTestRank(filename, source, projectRoot),
526
+ requiresExternalService: framework === 'cypress' && CYPRESS_LOCAL_ORIGIN_PATTERN.test(source),
527
+ requiresLocalSocket: LOCAL_SOCKET_PATTERN.test(source),
528
+ })
529
+ }
530
+ candidates.sort((left, right) => {
531
+ return Number(left.requiresExternalService) - Number(right.requiresExternalService) ||
532
+ left.rank - right.rank ||
533
+ left.path.localeCompare(right.path)
534
+ })
535
+ return candidates[0]
536
+ }
339
537
 
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
- }
538
+ /**
539
+ * Reports whether a file follows a selected framework's test convention.
540
+ *
541
+ * @param {string} filename candidate file
542
+ * @param {string} source candidate source
543
+ * @param {string} framework framework name
544
+ * @param {string} projectRoot project root
545
+ * @param {boolean} allowDirectoryConvention whether a literal runner selector chose this root
546
+ * @returns {boolean} whether the file is a candidate
547
+ */
548
+ function isTestFile (filename, source, framework, projectRoot, allowDirectoryConvention) {
549
+ const basename = path.basename(filename)
550
+ const normalized = filename.replaceAll('\\', '/')
551
+ if (normalized.includes('/dd-test-optimization-validation-')) return false
552
+ if (TYPE_ONLY_TEST_PATTERN.test(basename) || TYPE_ONLY_DIRECTORY_PATTERN.test(normalized)) return false
553
+ if (framework === 'cucumber') return cucumber.isTestFile(filename)
554
+ if (framework === 'cypress') return cypress.isTestFile(basename, path.dirname(filename), projectRoot)
555
+ if (framework === 'playwright') return playwright.isTestFile(basename, path.dirname(filename), projectRoot)
556
+ if (TEST_FILE_PATTERN.test(basename)) return true
557
+ const directories = [
558
+ path.basename(projectRoot),
559
+ ...path.relative(projectRoot, path.dirname(filename)).split(path.sep),
560
+ ]
561
+ const inTestDirectory = directories.some(directory => {
562
+ return ['__tests__', 'spec', 'test', 'tests'].includes(directory)
350
563
  })
564
+ if (BARE_TEST_FILE_PATTERN.test(basename)) {
565
+ return inTestDirectory || hasExplicitFrameworkImport(source, framework)
566
+ }
567
+ return allowDirectoryConvention && inTestDirectory && /\.[cm]?[jt]sx?$/.test(basename)
351
568
  }
352
569
 
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'")
570
+ /**
571
+ * Reports whether an unconventional test file imports the selected framework directly.
572
+ *
573
+ * @param {string} source candidate source
574
+ * @param {string} framework framework name
575
+ * @returns {boolean} whether ownership is explicit
576
+ */
577
+ function hasExplicitFrameworkImport (source, framework) {
578
+ if (framework === 'vitest') {
579
+ return /(?:from\s+|require\s*\(\s*)['"]vitest['"]/.test(source)
357
580
  }
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
- }
581
+ if (framework === 'jest') {
582
+ return /(?:from\s+|require\s*\(\s*)['"]@jest\/globals['"]/.test(source)
373
583
  }
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,
584
+ if (framework === 'mocha') {
585
+ return /(?:from\s+|require\s*\(\s*)['"]mocha['"]/.test(source)
417
586
  }
587
+ return false
418
588
  }
419
589
 
420
590
  /**
421
- * Adds a single test file selection to a detected project command.
591
+ * Conservatively associates a test source with one framework.
422
592
  *
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
593
+ * @param {string} filename candidate filename
594
+ * @param {string} source file source
595
+ * @param {string} framework framework name
596
+ * @param {string} packageName project package name
597
+ * @returns {boolean} whether ownership is plausible
430
598
  */
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,
599
+ function hasFrameworkOwnership (filename, source, framework, packageName) {
600
+ const normalized = filename.replaceAll('\\', '/').toLowerCase()
601
+ if (framework === 'cucumber') return /^\s*(?:Feature|Rule):/m.test(source)
602
+ if (framework === 'cypress') return /\.cy\./.test(filename) || normalized.includes('/cypress/')
603
+ if (framework === 'playwright') {
604
+ return /@playwright\/test/.test(source) || normalized.includes('/playwright/') ||
605
+ normalized.includes('/e2e/')
447
606
  }
607
+ if (framework === 'vitest') return /\bvitest\b/.test(source) || /\b(?:describe|it|test)\s*\(/.test(source)
608
+ if (framework === 'jest') return /\bjest\b/.test(source) || /\b(?:describe|it|test)\s*\(/.test(source)
609
+ if (framework === 'mocha') {
610
+ return /\bmocha\b/.test(source) || /\bdescribe\s*\(/.test(source) ||
611
+ packageName === 'mocha'
612
+ }
613
+ return false
448
614
  }
449
615
 
450
616
  /**
451
- * Returns framework arguments that select exactly one test file.
617
+ * Rejects files visibly owned by another supported runner.
452
618
  *
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
619
+ * @param {string} source candidate source
620
+ * @param {string} framework selected framework
621
+ * @returns {boolean} whether source ownership conflicts
458
622
  */
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
- ]
623
+ function hasConflictingFramework (source, framework) {
624
+ const markers = {
625
+ cypress: /\bcy\.(?:visit|request|get)\b|from\s+['"]cypress/,
626
+ jest: /@jest\/globals|\bjest\.(?:mock|fn|spyOn)\b/,
627
+ 'node:test': /(?:from\s+|require\s*\(\s*)['"]node:test['"]/,
628
+ playwright: /@playwright\/test/,
629
+ vitest: /from\s+['"]vitest['"]|require\s*\(\s*['"]vitest['"]\s*\)|\bvi\./,
468
630
  }
469
- return [filename, ...(framework === 'vitest' && moduleSystem === 'commonjs' ? ['--globals'] : [])]
631
+ return Object.entries(markers).some(([name, pattern]) => name !== framework && pattern.test(source))
470
632
  }
471
633
 
472
634
  /**
473
- * Resolves an installed framework executable without making the whole scaffold fail when a nested package only
474
- * declares the dependency.
635
+ * Ranks simpler unit tests ahead of integration and service-dependent tests.
475
636
  *
476
- * @param {string} framework detected framework
477
- * @param {string} projectRoot detected project root
478
- * @returns {string|undefined} resolved executable path
637
+ * @param {string} filename candidate filename
638
+ * @param {string} source candidate source
639
+ * @param {string} projectRoot project root
640
+ * @returns {number} lower-is-better rank
479
641
  */
480
- function tryResolveRunner (framework, projectRoot) {
481
- try {
482
- return resolveRunner(framework, projectRoot)
483
- } catch {}
642
+ function getTestRank (filename, source, projectRoot) {
643
+ const relative = path.relative(projectRoot, filename).replaceAll('\\', '/').toLowerCase()
644
+ let rank = relative.split('/').length
645
+ if (/(?:^|\/)(?:test|tests|__tests__|spec)\//.test(relative)) rank -= 20
646
+ if (/(?:^|[._/-])unit(?:[._/-]|$)/.test(relative)) rank -= 15
647
+ if (/(?:e2e|integration|acceptance|browser|conformance|fixtures?)/.test(relative)) rank += 20
648
+ if (LOCAL_SOCKET_PATTERN.test(source)) rank += 30
649
+ rank += Math.min(getStaticTestCount(source), 50)
650
+ return rank
484
651
  }
485
652
 
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)
653
+ /**
654
+ * Counts static test declarations for preferring small representative files.
655
+ *
656
+ * @param {string} source test source
657
+ * @returns {number} approximate test declaration count
658
+ */
659
+ function getStaticTestCount (source) {
660
+ return [...source.matchAll(/\b(?:it|test)((?:\.[A-Za-z]+)*)\s*\(/g)]
661
+ .filter(match => !/\.(?:skip|todo)\b/.test(match[1]))
662
+ .length
492
663
  }
493
664
 
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]
665
+ /**
666
+ * Narrows a repository root to the conventional package matching its identity.
667
+ *
668
+ * @param {string} projectRoot detected project root
669
+ * @param {string} repositoryRoot repository root
670
+ * @returns {string} preferred representative root
671
+ */
672
+ function findPreferredRepresentativeRoot (projectRoot, repositoryRoot) {
673
+ if (path.resolve(projectRoot) !== path.resolve(repositoryRoot)) return projectRoot
674
+ const repositoryName = normalizeProjectIdentity(path.basename(repositoryRoot))
675
+
676
+ for (const containerName of ['packages', 'pkgs', 'modules']) {
677
+ const container = path.join(repositoryRoot, containerName)
678
+ for (const entry of readDirectoryEntries(container)) {
679
+ if (!entry.isDirectory()) continue
680
+ const candidate = path.join(container, entry.name)
681
+ const packageJson = readJson(path.join(candidate, 'package.json'))
682
+ if (normalizeProjectIdentity(packageJson?.name || entry.name) === repositoryName) return candidate
683
+ }
498
684
  }
499
- return [packageManager, 'run', scriptName]
685
+ return projectRoot
500
686
  }
501
687
 
502
688
  /**
503
- * Finds the package script whose value produced the detected framework command.
689
+ * Normalizes repository and package names for exact identity comparison.
504
690
  *
505
- * @param {object} packageJson project package metadata
506
- * @param {string} command detected framework command
507
- * @returns {string|undefined} package script name
691
+ * @param {string} value package identity
692
+ * @returns {string} normalized identity
508
693
  */
509
- function getPackageScriptName (packageJson, command) {
510
- return Object.entries(packageJson.scripts || {}).find(([, value]) => value === command)?.[0]
694
+ function normalizeProjectIdentity (value) {
695
+ const unscoped = String(value || '').toLowerCase().replace(/^@[^/]+\//, '')
696
+ return unscoped.replaceAll(/[^a-z0-9]+/g, '').replace(/js$/, '')
511
697
  }
512
698
 
513
699
  /**
514
- * Reports whether a package script invokes the framework binary without a repository wrapper.
700
+ * Collects bounded regular project files without following symbolic links.
515
701
  *
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
702
+ * @param {string} root project root
703
+ * @returns {string[]} absolute files
519
704
  */
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()
705
+ function collectProjectFiles (root) {
706
+ const files = []
707
+ const pending = [root]
535
708
  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
709
+
710
+ while (pending.length > 0 && visited < MAX_DISCOVERY_ENTRIES) {
711
+ const directory = pending.shift()
712
+ for (const entry of readDirectoryEntries(directory)) {
713
+ if (++visited > MAX_DISCOVERY_ENTRIES) break
547
714
  const filename = path.join(directory, entry.name)
715
+ if (entry.isSymbolicLink()) continue
548
716
  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)
717
+ if (!SKIPPED_DIRECTORIES.has(entry.name)) pending.push(filename)
718
+ } else if (entry.isFile()) {
719
+ files.push(filename)
557
720
  }
558
721
  }
559
722
  }
723
+ return files
724
+ }
560
725
 
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]
726
+ /**
727
+ * Resolves a framework package executable only inside the repository.
728
+ *
729
+ * @param {string} framework framework name
730
+ * @param {string} projectRoot project root
731
+ * @param {string} repositoryRoot repository root
732
+ * @returns {string|undefined} physical runner path
733
+ */
734
+ function resolveRunner (framework, projectRoot, repositoryRoot) {
735
+ const packageName = getRunnerPackageName(framework)
736
+ const packageRoot = findPackageRoot(packageName, projectRoot, repositoryRoot)
737
+ if (!packageRoot) return
738
+
739
+ const packageJson = readJson(path.join(packageRoot, 'package.json'))
740
+ const binName = framework === 'cucumber' ? 'cucumber-js' : framework === 'playwright' ? 'playwright' : framework
741
+ const bin = typeof packageJson?.bin === 'string' ? packageJson.bin : packageJson?.bin?.[binName]
742
+ if (typeof bin !== 'string') return
743
+
744
+ try {
745
+ const runner = fs.realpathSync(path.resolve(packageRoot, bin))
746
+ return isPathInside(fs.realpathSync(repositoryRoot), runner) && fs.statSync(runner).isFile()
747
+ ? runner
748
+ : undefined
749
+ } catch {}
569
750
  }
570
751
 
571
752
  /**
572
- * Ranks established test directories ahead of source-adjacent test-looking files.
753
+ * Finds an installed package or a framework source checkout.
573
754
  *
574
- * @param {string} filename candidate test file
575
- * @param {string} root detected project root
576
- * @returns {number} directory preference rank
755
+ * @param {string} packageName runner package name
756
+ * @param {string} projectRoot project root
757
+ * @param {string} repositoryRoot repository root
758
+ * @returns {string|undefined} physical package root
577
759
  */
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
760
+ function findPackageRoot (packageName, projectRoot, repositoryRoot) {
761
+ const ownPackage = readJson(path.join(projectRoot, 'package.json'))
762
+ if (ownPackage?.name === packageName) return projectRoot
763
+
764
+ let directory = projectRoot
765
+ while (isPathInside(repositoryRoot, directory)) {
766
+ const candidate = path.join(directory, 'node_modules', ...packageName.split('/'))
767
+ try {
768
+ const physical = fs.realpathSync(candidate)
769
+ if (isPathInside(fs.realpathSync(repositoryRoot), physical)) return physical
770
+ } catch {}
771
+ if (directory === repositoryRoot) break
772
+ directory = path.dirname(directory)
773
+ }
774
+ }
775
+
776
+ /**
777
+ * Returns a runner package name.
778
+ *
779
+ * @param {string} framework framework name
780
+ * @returns {string} package name
781
+ */
782
+ function getRunnerPackageName (framework) {
783
+ return {
784
+ cucumber: '@cucumber/cucumber',
785
+ playwright: '@playwright/test',
786
+ }[framework] || framework
787
+ }
788
+
789
+ /**
790
+ * Returns a generated-test location that follows the representative discovery convention.
791
+ *
792
+ * @param {string} framework framework name
793
+ * @param {string} representative representative test
794
+ * @param {string} projectRoot detected project root
795
+ * @returns {{exactFilename: string|undefined, fileExtension: string, testDirectory: string}} convention
796
+ */
797
+ function getGeneratedTestConvention (framework, representative, projectRoot) {
798
+ const basename = path.basename(representative)
799
+ if (/^test\.[cm]?[jt]s$/.test(basename)) {
800
+ const representativeDirectory = path.dirname(representative)
801
+ return {
802
+ exactFilename: basename,
803
+ fileExtension: path.extname(basename),
804
+ testDirectory: representativeDirectory === projectRoot
805
+ ? projectRoot
806
+ : path.dirname(representativeDirectory),
807
+ }
808
+ }
809
+ return {
810
+ exactFilename: undefined,
811
+ fileExtension: getTestExtension(framework, representative),
812
+ testDirectory: path.dirname(representative),
813
+ }
583
814
  }
584
815
 
585
816
  /**
586
- * Ranks conventional project areas ahead of auxiliary repository trees.
817
+ * Returns the complete suffix needed for framework discovery.
587
818
  *
588
- * @param {string} filename candidate test file
589
- * @param {string} root detected project root
590
- * @returns {number} project area preference rank
819
+ * @param {string} framework framework name
820
+ * @param {string} representative representative test
821
+ * @returns {string} generated test suffix
591
822
  */
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
823
+ function getTestExtension (framework, representative) {
824
+ if (framework === 'cucumber') return '.feature'
825
+ if (framework === 'cypress') return cypress.getTestExtension(representative)
826
+ if (framework === 'playwright') return playwright.getTestExtension(representative)
827
+ const match = /((?:[.-](?:test|spec))\.[cm]?[jt]sx?)$/.exec(representative)
828
+ if (match) return match[1]
829
+ const moduleExtension = /\.((?:[cm]js|[cm]ts))$/.exec(representative)?.[1]
830
+ return moduleExtension ? `.test.${moduleExtension}` : '.test.js'
595
831
  }
596
832
 
597
833
  /**
598
- * Ranks independently tested nested packages behind tests owned by the detected root command.
834
+ * Determines the generated JavaScript module system.
599
835
  *
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
836
+ * @param {string} representative representative test
837
+ * @param {string} projectRoot project root
838
+ * @returns {'commonjs'|'esm'} module system
604
839
  */
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)
840
+ function getModuleSystem (representative, projectRoot) {
841
+ if (/\.(?:mjs|mts)$/.test(representative)) return 'esm'
842
+ if (/\.(?:cjs|cts)$/.test(representative)) return 'commonjs'
609
843
 
844
+ let directory = path.dirname(representative)
845
+ while (isPathInside(projectRoot, directory)) {
610
846
  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
- }
847
+ if (packageJson) return packageJson.type === 'module' ? 'esm' : 'commonjs'
848
+ if (directory === projectRoot) break
616
849
  directory = path.dirname(directory)
617
850
  }
618
- return 0
851
+ return 'commonjs'
619
852
  }
620
853
 
621
- function getTestExtension (filename) {
622
- const match = /((?:\.test|\.spec)\.(?:[cm]?[jt]s|[jt]sx))$/.exec(filename)
623
- return match?.[1] || '.test.js'
854
+ /**
855
+ * Discovers bounded CI configuration paths without interpreting them.
856
+ *
857
+ * @param {string} root repository root
858
+ * @returns {object} CI discovery data
859
+ */
860
+ function discoverCiFiles (root) {
861
+ const found = []
862
+ for (const relativePath of CI_PATHS) {
863
+ const absolutePath = path.join(root, relativePath)
864
+ try {
865
+ const stat = fs.lstatSync(absolutePath)
866
+ if (stat.isSymbolicLink()) continue
867
+ if (stat.isFile()) {
868
+ found.push(relativePath)
869
+ } else if (stat.isDirectory()) {
870
+ for (const entry of readDirectoryEntries(absolutePath).slice(0, MAX_CI_FILES)) {
871
+ if (entry.isFile() && /\.ya?ml$/i.test(entry.name)) found.push(path.join(relativePath, entry.name))
872
+ }
873
+ }
874
+ } catch {}
875
+ }
876
+ const normalized = found.map(filename => filename.split(path.sep).join('/')).sort()
877
+ return {
878
+ found: normalized,
879
+ reviewRequired: normalized.length > 0,
880
+ reviewTargets: rankCiReviewTargets(normalized).slice(0, MAX_CI_REVIEW_TARGETS),
881
+ searched: CI_PATHS,
882
+ }
883
+ }
884
+
885
+ /**
886
+ * Ranks likely test workflows first.
887
+ *
888
+ * @param {string[]} files discovered CI paths
889
+ * @returns {string[]} ranked paths
890
+ */
891
+ function rankCiReviewTargets (files) {
892
+ return [...files].sort((left, right) => getCiRank(left) - getCiRank(right) || left.localeCompare(right))
893
+ }
894
+
895
+ /**
896
+ * Returns a lower-is-better CI review rank.
897
+ *
898
+ * @param {string} filename CI path
899
+ * @returns {number} rank
900
+ */
901
+ function getCiRank (filename) {
902
+ const value = filename.toLowerCase()
903
+ if (/(?:test|ci|build|unit|integration)/.test(value)) return 0
904
+ return 10
905
+ }
906
+
907
+ /**
908
+ * Finds the package manifest that owns a detection.
909
+ *
910
+ * @param {string} repositoryRoot repository root
911
+ * @param {string|undefined} location detected relative path
912
+ * @returns {{json: object, path: string}} package metadata
913
+ */
914
+ function getDetectionPackageJson (repositoryRoot, location) {
915
+ const candidate = typeof location === 'string' && path.basename(location) === 'package.json'
916
+ ? path.resolve(repositoryRoot, location)
917
+ : path.join(repositoryRoot, 'package.json')
918
+ const json = readJson(candidate)
919
+ if (json) return { json, path: candidate }
920
+ return {
921
+ json: readJson(path.join(repositoryRoot, 'package.json')) || {},
922
+ path: path.join(repositoryRoot, 'package.json'),
923
+ }
624
924
  }
625
925
 
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 []
926
+ /**
927
+ * Safely reads a bounded directory.
928
+ *
929
+ * @param {string} directory directory path
930
+ * @returns {fs.Dirent[]} entries
931
+ */
932
+ function readDirectoryEntries (directory) {
633
933
  try {
634
- return fs.readdirSync(root).filter(filename => patterns.test(filename)).map(filename => path.join(root, filename))
934
+ return fs.readdirSync(directory, { withFileTypes: true }).slice(0, MAX_DIRECTORY_ENTRIES)
635
935
  } catch {
636
936
  return []
637
937
  }
638
938
  }
639
939
 
640
940
  /**
641
- * Resolves the package.json associated with a framework detection.
941
+ * Reads bounded UTF-8 source.
642
942
  *
643
- * @param {string} repositoryRoot repository root
644
- * @param {string[]} locations detected evidence paths
645
- * @returns {string} absolute package.json path
943
+ * @param {string} filename file path
944
+ * @returns {string|undefined} source
646
945
  */
647
- function getDetectionPackageJson (repositoryRoot, locations = []) {
648
- const location = locations.find(value => path.basename(value) === 'package.json')
649
- return path.resolve(repositoryRoot, location || 'package.json')
946
+ function readText (filename) {
947
+ try {
948
+ const stat = fs.lstatSync(filename)
949
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_FILE_BYTES) return
950
+ return fs.readFileSync(filename, 'utf8')
951
+ } catch {}
650
952
  }
651
953
 
652
954
  /**
653
- * Resolves a detected runner version without executing repository code.
955
+ * Reads JSON without throwing.
654
956
  *
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
957
+ * @param {string} filename JSON path
958
+ * @returns {object|undefined} parsed object
659
959
  */
660
- function getInstalledFrameworkVersion (framework, projectRoot, packageJson) {
661
- if (framework === 'node-test') return process.version
960
+ function readJson (filename) {
961
+ const source = readText(filename)
962
+ if (source === undefined) return
662
963
  try {
663
- const filename = require.resolve(`${framework}/package.json`, { paths: [projectRoot] })
664
- return readJson(filename)?.version || null
964
+ return JSON.parse(source)
665
965
  } catch {}
966
+ }
666
967
 
667
- for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
668
- if (typeof packageJson[field]?.[framework] === 'string') return packageJson[field][framework]
669
- }
670
- return null
968
+ /**
969
+ * Returns whether a framework target was selected.
970
+ *
971
+ * @param {string} framework framework id
972
+ * @param {Set<string>} selected selected ids
973
+ * @returns {boolean} selection result
974
+ */
975
+ function isSelected (framework, selected) {
976
+ return selected.size === 0 || selected.has(framework)
671
977
  }
672
978
 
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
- }
979
+ /**
980
+ * Returns a stable project identifier.
981
+ *
982
+ * @param {object} packageJson package metadata
983
+ * @param {string} projectRoot project root
984
+ * @param {string} repositoryRoot repository root
985
+ * @returns {string} project id
986
+ */
987
+ function getProjectId (packageJson, projectRoot, repositoryRoot) {
988
+ const value = packageJson.name || path.relative(repositoryRoot, projectRoot) || 'root'
989
+ return value.replace(/^@/, '').replaceAll(/[^A-Za-z0-9_.-]+/g, '-')
694
990
  }
695
991
 
992
+ /**
993
+ * Detects package-manager metadata for reporting only.
994
+ *
995
+ * @param {string} root repository root
996
+ * @returns {string} package manager
997
+ */
696
998
  function detectPackageManager (root) {
697
999
  if (fs.existsSync(path.join(root, 'pnpm-lock.yaml'))) return 'pnpm'
698
1000
  if (fs.existsSync(path.join(root, 'yarn.lock'))) return 'yarn'
699
- return 'npm'
1001
+ if (fs.existsSync(path.join(root, 'package-lock.json'))) return 'npm'
1002
+ return 'unknown'
700
1003
  }
701
1004
 
1005
+ /**
1006
+ * Detects workspace metadata for reporting only.
1007
+ *
1008
+ * @param {string} root repository root
1009
+ * @returns {string} workspace manager
1010
+ */
702
1011
  function detectWorkspaceManager (root) {
703
1012
  if (fs.existsSync(path.join(root, 'pnpm-workspace.yaml'))) return 'pnpm'
704
1013
  const packageJson = readJson(path.join(root, 'package.json'))
705
1014
  return packageJson?.workspaces ? detectPackageManager(root) : 'none'
706
1015
  }
707
1016
 
1017
+ /**
1018
+ * Returns a manifest operating-system name.
1019
+ *
1020
+ * @param {string} platform Node.js platform
1021
+ * @returns {string} OS name
1022
+ */
708
1023
  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, '-')
1024
+ return platform === 'win32' ? 'windows' : platform
725
1025
  }
726
1026
 
1027
+ /**
1028
+ * Checks lexical path containment.
1029
+ *
1030
+ * @param {string} root root path
1031
+ * @param {string} filename candidate path
1032
+ * @returns {boolean} whether the candidate is contained
1033
+ */
727
1034
  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 {}
1035
+ const relative = path.relative(path.resolve(root), path.resolve(filename))
1036
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
736
1037
  }
737
1038
 
738
1039
  module.exports = { createManifestScaffold }