UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

472 lines (420 loc) 16.6 kB
'use strict' const fs = require('node:fs') const path = require('node:path') const MAX_CONFIG_BYTES = 512 * 1024 const MAX_STATIC_CONFIG_ARRAY_BYTES = 4096 const MAX_STATIC_CONFIG_PATTERNS = 32 const MAX_STATIC_CONFIG_PATTERN_BYTES = 256 const JEST_LOCAL_PATH_PATTERN = /(['"])(<rootDir>\/[^'"\r\n]+)\1/g const JEST_ROOT_DIR_PATTERN = /\brootDir\s*:\s*(['"])([^'"]+)\1/ const TYPECHECK_ENABLED_PATTERN = /typecheck\s*:\s*\{[\s\S]{0,2000}?enabled\s*:\s*true/ const VITEST_CONFIG_FILENAMES = [ 'vitest.config.js', 'vitest.config.mjs', 'vitest.config.cjs', 'vitest.config.ts', 'vitest.config.mts', 'vitest.config.cts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.ts', 'vite.config.mts', 'vite.config.cts', ] /** * Returns why a planned command is unsuitable for deterministic validation. * * @param {object} input suitability input * @param {object} input.command manifest command * @param {object} input.framework manifest framework * @param {string} input.label planned command label * @param {string} input.repositoryRoot repository root * @returns {string|undefined} suitability error */ function getCommandSuitabilityError ({ command, framework, label, repositoryRoot }) { const yarnError = getRepositoryYarnError(command, repositoryRoot) if (yarnError) return yarnError if (framework.framework === 'jest') { const missingInputError = getJestMissingLocalInputError(framework) if (missingInputError) return missingInputError } if (framework.framework === 'vitest' && (label === 'the selected test command' || label.includes('advanced-feature'))) { if (label.includes('advanced-feature')) { const generatedPathError = getVitestGeneratedPathError(command, framework, repositoryRoot) if (generatedPathError) return generatedPathError } return getVitestTypecheckError(command, label.includes('advanced-feature'), repositoryRoot) } } /** * Returns an error for an exact local file referenced by Jest config but absent before execution. * * @param {object} framework manifest framework entry * @returns {string|undefined} suitability error */ function getJestMissingLocalInputError (framework) { for (const configFile of framework.project?.configFiles || []) { const config = readConfig(configFile) if (!config) continue const rootDirMatch = config.match(JEST_ROOT_DIR_PATTERN) const rootDir = rootDirMatch ? path.resolve(path.dirname(configFile), rootDirMatch[2]) : path.dirname(configFile) for (const match of config.matchAll(JEST_LOCAL_PATH_PATTERN)) { const configuredPath = match[2] if (/[$*?{}[\]]/.test(configuredPath) || !/\.(?:[cm]?[jt]sx?|json)$/.test(configuredPath)) continue const localPath = path.resolve(rootDir, configuredPath.slice('<rootDir>/'.length)) if (fs.existsSync(localPath) || isProducedBySetup(framework, localPath)) continue return `uses Jest config ${configFile}, which references missing local input ${localPath}. ` + 'Choose a representative whose config inputs already exist, or declare the reviewed setup command and ' + 'its output path before marking this framework runnable.' } } } /** * Checks whether an approved setup command declares the missing input as an output. * * @param {object} framework manifest framework entry * @param {string} localPath missing local input * @returns {boolean} whether setup declares the input */ function isProducedBySetup (framework, localPath) { for (const command of framework.setup?.commands || []) { for (const outputPath of command.outputPaths || []) { const relative = path.relative(path.resolve(outputPath), localPath) if (relative === '' || (relative && !relative.startsWith('..') && !path.isAbsolute(relative))) return true } } return false } /** * @param {{ usesShell?: boolean, argv?: string[] }} command * @param {string} repositoryRoot */ function getRepositoryYarnError (command, repositoryRoot) { if (command.usesShell || path.basename(command.argv?.[0] || '') !== 'yarn') return const releaseDirectory = path.join(repositoryRoot, '.yarn', 'releases') let releases try { releases = fs.readdirSync(releaseDirectory) .filter(filename => /^yarn-[^/]+\.cjs$/.test(filename)) .sort() } catch {} if (releases?.length > 0) { const release = path.posix.join('.yarn', 'releases', releases.at(-1)) return `uses bare "yarn", but this repository pins ${release}. Use the structured command ` + `argv [process.execPath, "${release}", ...] so validation does not depend on an ambient Yarn shim.` } const packageManager = getRepositoryPackageManager(repositoryRoot) const match = /^yarn@(\d+)(?:\.|$)/.exec(packageManager || '') if (match && Number(match[1]) > 1) { return `uses bare "yarn", but package.json requires ${packageManager}. Use an explicit Corepack command ` + '`argv ["corepack", "yarn", ...]` or the repository-configured `yarnPath` so validation cannot resolve an ' + 'incompatible ambient Yarn version.' } } /** * Reads the package-manager requirement declared by the repository root. * * @param {string} repositoryRoot repository root * @returns {string|undefined} declared package-manager requirement */ function getRepositoryPackageManager (repositoryRoot) { const packageJson = readRepositoryConfig(path.join(repositoryRoot, 'package.json'), repositoryRoot) if (!packageJson) return try { const value = JSON.parse(packageJson).packageManager return typeof value === 'string' ? value : undefined } catch {} } function getVitestTypecheckError (command, generatedTest, repositoryRoot) { const commandText = command.usesShell ? command.shellCommand || '' : (command.argv || []).join(' ') if (/(^|\s)--typecheck\.enabled=false(?:\s|$)/.test(commandText)) return if (/(^|\s)--typecheck(?:\s|$|=)/.test(commandText)) { return getTypecheckError('runs Vitest with --typecheck', generatedTest) } const configFile = getVitestConfigFile(command, repositoryRoot) if (!configFile || !configEnablesTypecheck(configFile, repositoryRoot)) return return getTypecheckError(`uses typecheck-enabled Vitest config ${configFile}`, generatedTest) } function getTypecheckError (prefix, generatedTest) { if (generatedTest) { return `${prefix} for generated runtime tests. Typecheck projects can count each generated test twice; use ` + 'an existing typecheck-disabled config, or create a declared temporary config for advanced checks.' } return `${prefix} for the selected direct test command. Typecheck can duplicate runtime tests and make ` + 'unrelated source errors fail Basic Reporting; use an existing runtime-only config or append ' + '`--typecheck.enabled=false`.' } function getVitestGeneratedPathError (command, framework, repositoryRoot) { const configFile = getVitestConfigFile(command, repositoryRoot) if (!configFile) return const config = readRepositoryConfig(configFile, repositoryRoot) if (!config) return const includes = getLiteralTestConfigPatterns(config, 'include') const excludes = getLiteralTestConfigPatterns(config, 'exclude') if (includes.length === 0 && excludes.length === 0) return for (const file of framework.generatedTestStrategy?.files || []) { const relative = path.relative(path.dirname(configFile), file.path).split(path.sep).join('/') if (relative === '..' || relative.startsWith('../') || path.isAbsolute(relative)) continue if (includes.length > 0 && !includes.some(pattern => matchesGlob(relative, pattern))) { return `uses temporary test path ${file.path}, which does not match the literal test.include patterns in ` + `${configFile}: ${includes.join(', ')}. Choose a temporary test path accepted by the selected Vitest ` + 'config ' + 'before asking for approval.' } if (excludes.some(pattern => matchesGlob(relative, pattern))) { return `uses temporary test path ${file.path}, which matches a literal test.exclude pattern in ` + `${configFile}. Choose a temporary test path accepted by the selected Vitest config before asking for ` + 'approval.' } } } function getLiteralTestConfigPatterns (config, property) { const candidates = [] for (const objectStart of getLiteralObjectStarts(config, 'test')) { const patterns = getDirectLiteralArrayPatterns(config, objectStart, property) if (patterns.length > 0) candidates.push(patterns) } if (candidates.length === 0) return [] const first = JSON.stringify(candidates[0]) return candidates.every(candidate => JSON.stringify(candidate) === first) ? candidates[0] : [] } function getLiteralObjectStarts (config, property) { const starts = [] visitConfigSource(config, ({ index }) => { if (!isPropertyAt(config, index, property)) return const match = new RegExp(String.raw`^${property}\s*:\s*\{`).exec(config.slice(index)) if (match) starts.push(index + match[0].lastIndexOf('{')) }) return starts } function getDirectLiteralArrayPatterns (config, objectStart, property) { let patterns = [] visitConfigSource(config, ({ index, depth }) => { if (patterns.length > 0 || depth !== 1 || !isPropertyAt(config, index, property)) return const match = new RegExp(String.raw`^${property}\s*:\s*\[`).exec(config.slice(index)) if (match) patterns = readLiteralStringArray(config, index + match[0].length) }, objectStart) return patterns } function readLiteralStringArray (config, offset) { const patterns = [] let quote = '' let value = '' let closed = false const end = Math.min(config.length, offset + MAX_STATIC_CONFIG_ARRAY_BYTES) for (let index = offset; index < end; index++) { const character = config[index] if (!quote) { if (character === ']') { closed = true break } if (character === '"' || character === '\'') { quote = character value = '' } else if (character !== ',' && !/\s/.test(character)) { return [] } continue } if (character === quote) { if (value.length > MAX_STATIC_CONFIG_PATTERN_BYTES || patterns.length === MAX_STATIC_CONFIG_PATTERNS) { return [] } patterns.push(value) quote = '' } else if (character.charCodeAt(0) === 92) { return [] } else if (character === '\r' || character === '\n') { return [] } else { value += character } } return closed && !quote ? patterns : [] } function visitConfigSource (config, visitor, objectStart = -1) { let blockComment = false let depth = objectStart === -1 ? 0 : 1 let lineComment = false let quote = '' const start = objectStart === -1 ? 0 : objectStart + 1 for (let index = start; index < config.length; index++) { const character = config[index] const next = config[index + 1] if (lineComment) { if (character === '\n') lineComment = false continue } if (blockComment) { if (character === '*' && next === '/') { blockComment = false index++ } continue } if (quote) { if (character.charCodeAt(0) === 92) { index++ } else if (character === quote) { quote = '' } continue } if (character === '/' && next === '/') { lineComment = true index++ continue } if (character === '/' && next === '*') { blockComment = true index++ continue } if (character === '"' || character === '\'' || character === '`') { quote = character continue } if (character === '{') { depth++ continue } if (character === '}') { depth-- if (objectStart !== -1 && depth === 0) break continue } visitor({ index, depth }) } } function isPropertyAt (config, index, property) { if (!config.startsWith(property, index)) return false const before = config[index - 1] const after = config[index + property.length] return !isIdentifierCharacter(before) && !isIdentifierCharacter(after) } function isIdentifierCharacter (character) { return Boolean(character && /[A-Za-z0-9_$]/.test(character)) } function matchesGlob (filename, pattern) { const normalized = pattern.replace(/^\.\//, '') let source = '^' let index = 0 while (index < normalized.length) { const character = normalized[index] if (character === '*' && normalized[index + 1] === '*') { if (normalized[index + 2] === '/') { source += '(?:[^/]+/)*' index += 3 } else { source += '.*' index += 2 } continue } else if (character === '*') { source += '[^/]*' } else if (character === '?' && normalized[index + 1] === '(') { const end = normalized.indexOf(')', index + 2) const value = end === -1 ? '' : normalized.slice(index + 2, end) if (/^[A-Za-z0-9._-]+$/.test(value)) { source += `(?:${escapeRegex(value)})?` index = end + 1 continue } else { source += '[^/]' } } else if (character === '?') { source += '[^/]' } else if (character === '[') { const end = normalized.indexOf(']', index + 1) const value = end === -1 ? '' : normalized.slice(index + 1, end) if (/^!?[A-Za-z0-9_-]+$/.test(value)) { source += `[${value.startsWith('!') ? `^${value.slice(1)}` : value}]` index = end + 1 continue } else { source += String.raw`\[` } } else if (character === '{') { const end = normalized.indexOf('}', index + 1) const values = end === -1 ? [] : normalized.slice(index + 1, end).split(',') if (values.length > 1 && values.every(value => /^[A-Za-z0-9._-]+$/.test(value))) { source += `(?:${values.map(escapeRegex).join('|')})` index = end + 1 continue } else { source += String.raw`\{` } } else { source += escapeRegex(character) } index++ } return new RegExp(`${source}$`).test(filename) } function escapeRegex (value) { return value.replaceAll(/[\\^$.*+?()[\]{}|]/g, String.raw`\$&`) } function getVitestConfigFile (command, repositoryRoot) { if (command.usesShell) { const match = String(command.shellCommand || '').match(/(?:^|\s)--config(?:=|\s+)([^\s]+)/) return match ? path.resolve(command.cwd, unquote(match[1])) : undefined } const argv = command.argv || [] for (let index = 0; index < argv.length; index++) { if (argv[index] === '--config' && argv[index + 1]) return path.resolve(command.cwd, argv[index + 1]) if (argv[index].startsWith('--config=')) return path.resolve(command.cwd, argv[index].slice('--config='.length)) } for (const filename of VITEST_CONFIG_FILENAMES) { const configFile = path.resolve(command.cwd, filename) if (isRepositoryFile(configFile, repositoryRoot)) return configFile } } function isRepositoryFile (filename, repositoryRoot) { return Boolean(getRepositoryFile(filename, repositoryRoot)) } function getRepositoryFile (filename, repositoryRoot) { try { const entry = fs.lstatSync(filename) if (!entry.isFile() && !entry.isSymbolicLink()) return const physicalRoot = fs.realpathSync(repositoryRoot) const physicalFilename = fs.realpathSync(filename) const relative = path.relative(physicalRoot, physicalFilename) if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return const stat = fs.statSync(physicalFilename) if (!stat.isFile()) return return { filename: physicalFilename, stat } } catch {} } function configEnablesTypecheck (filename, repositoryRoot) { const config = readRepositoryConfig(filename, repositoryRoot) return config ? TYPECHECK_ENABLED_PATTERN.test(config) : false } function readRepositoryConfig (filename, repositoryRoot) { const file = getRepositoryFile(filename, repositoryRoot) if (!file || file.stat.size > MAX_CONFIG_BYTES) return try { return fs.readFileSync(file.filename, 'utf8') } catch {} } /** * Reads a bounded test-runner config file. * * @param {string} filename config path * @returns {string|undefined} config text */ function readConfig (filename) { try { const stat = fs.statSync(filename) if (!stat.isFile() || stat.size > MAX_CONFIG_BYTES) return return fs.readFileSync(filename, 'utf8') } catch {} } function unquote (value) { return value.replaceAll(/^['"]|['"]$/g, '') } module.exports = { getCommandSuitabilityError }