Files
ECC/scripts/lib/harness-audit-consumer.js

442 lines
13 KiB
JavaScript

'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const TOML = require('@iarna/toml');
const IGNORED_SCAN_DIRS = new Set([
'.git',
'.eggs',
'.mypy_cache',
'.nox',
'.pytest_cache',
'.ruff_cache',
'.tox',
'.venv',
'__pycache__',
'__pypackages__',
'build',
'dist',
'env',
'node_modules',
'site-packages',
'venv',
'vendor',
]);
function safeRead(filePath) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch (_error) {
return '';
}
}
function walkFiles(rootDir, relativeRoots, predicate) {
const projectRoot = fs.realpathSync(path.resolve(rootDir));
function visit(current) {
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch (_error) {
return [];
}
return entries.flatMap(entry => {
const nextPath = path.join(current, entry.name);
if (entry.isDirectory()) {
return IGNORED_SCAN_DIRS.has(entry.name) ? [] : visit(nextPath);
}
return entry.isFile() && predicate(entry.name, nextPath) ? [nextPath] : [];
});
}
const files = relativeRoots.flatMap(relativeRoot => {
const scanRoot = path.resolve(projectRoot, relativeRoot);
const relative = path.relative(projectRoot, scanRoot);
const segments = relative.split(path.sep).filter(Boolean);
if (
relative === '..' ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative) ||
segments.some(segment => IGNORED_SCAN_DIRS.has(segment)) ||
!fs.existsSync(scanRoot)
) {
return [];
}
try {
const realRoot = fs.realpathSync(scanRoot);
const realRelative = path.relative(projectRoot, realRoot);
if (
realRelative === '..' ||
realRelative.startsWith(`..${path.sep}`) ||
path.isAbsolute(realRelative)
) {
return [];
}
} catch (_error) {
return [];
}
if (fs.statSync(scanRoot).isFile()) {
return predicate(path.basename(scanRoot), scanRoot) ? [scanRoot] : [];
}
return visit(scanRoot);
});
return new Set(files);
}
function parseIniOption(text, sectionName, optionName) {
if (!text) return [];
const sectionPattern = new RegExp(
`^\\s*\\[${sectionName.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')}\\]\\s*$`,
'i'
);
const lines = text.split(/\r?\n/);
let inSection = false;
let collecting = false;
let values = [];
for (const line of lines) {
if (/^\s*\[.*]\s*$/.test(line)) {
inSection = sectionPattern.test(line);
collecting = false;
continue;
}
if (!inSection || /^\s*[#;]/.test(line)) continue;
const match = line.match(new RegExp(`^\\s*${optionName}\\s*=\\s*(.*)$`, 'i'));
if (match) {
collecting = true;
values = [...values, match[1]];
continue;
}
if (collecting && /^\s+\S/.test(line)) {
values = [...values, line.trim()];
} else if (line.trim()) {
collecting = false;
}
}
return values
.flatMap(value => value.match(/"[^"]*"|'[^']*'|\S+/g) || [])
.map(value => value.replace(/^['"]|['"]$/g, '').trim())
.filter(Boolean);
}
function normalizeTomlList(value) {
if (Array.isArray(value)) {
return value.filter(item => typeof item === 'string' && item.trim());
}
return typeof value === 'string' ? value.split(/\s+/) : [];
}
function getActivePytestConfig(rootDir) {
for (const fileName of ['pytest.toml', '.pytest.toml']) {
const configPath = path.join(rootDir, fileName);
if (!fs.existsSync(configPath)) continue;
const text = safeRead(configPath);
try {
const config = TOML.parse(text)?.pytest || {};
return {
paths: normalizeTomlList(config.testpaths),
pythonFiles: normalizeTomlList(config.python_files),
};
} catch (_error) {
return null;
}
}
for (const fileName of ['pytest.ini', '.pytest.ini']) {
const configPath = path.join(rootDir, fileName);
if (!fs.existsSync(configPath)) continue;
const text = safeRead(configPath);
return {
paths: parseIniOption(text, 'pytest', 'testpaths'),
pythonFiles: parseIniOption(text, 'pytest', 'python_files'),
};
}
const pyproject = safeRead(path.join(rootDir, 'pyproject.toml'));
if (/^\s*\[tool\.pytest(?:\.ini_options)?]\s*$/m.test(pyproject)) {
try {
const pytest = TOML.parse(pyproject)?.tool?.pytest || {};
const config = pytest.ini_options || pytest;
return {
paths: normalizeTomlList(config.testpaths),
pythonFiles: normalizeTomlList(config.python_files),
};
} catch (_error) {
return null;
}
}
const tox = safeRead(path.join(rootDir, 'tox.ini'));
if (/^\s*\[pytest]\s*$/m.test(tox)) {
return {
paths: parseIniOption(tox, 'pytest', 'testpaths'),
pythonFiles: parseIniOption(tox, 'pytest', 'python_files'),
};
}
const setupCfg = safeRead(path.join(rootDir, 'setup.cfg'));
if (/^\s*\[tool:pytest]\s*$/m.test(setupCfg)) {
return {
paths: parseIniOption(setupCfg, 'tool:pytest', 'testpaths'),
pythonFiles: parseIniOption(setupCfg, 'tool:pytest', 'python_files'),
};
}
return null;
}
function hasGlobSyntax(value) {
return /[*?[]/.test(value);
}
function getGlobScanRoot(pattern) {
const segments = pattern.replace(/\\/g, '/').split('/');
const staticSegments = segments.slice(0, segments.findIndex(hasGlobSyntax));
return staticSegments.join('/') || '.';
}
function matchesGlobPath(relativePath, pattern) {
const candidate = relativePath.split(path.sep).join('/');
const normalized = pattern.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '');
let matcher;
try {
matcher = new RegExp(`^${globToRegex(normalized)}$`);
} catch (_error) {
return false;
}
const segments = candidate.split('/');
return segments.some((_, index) => matcher.test(segments.slice(0, index + 1).join('/')));
}
function globMatchesAnyPath(rootDir, scanRoot, pattern) {
const projectRoot = fs.realpathSync(rootDir);
const absoluteRoot = path.resolve(projectRoot, scanRoot);
if (!fs.existsSync(absoluteRoot)) return false;
try {
const realRoot = fs.realpathSync(absoluteRoot);
const relative = path.relative(projectRoot, realRoot);
if (
relative === '..' ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative)
) {
return false;
}
} catch (_error) {
return false;
}
function visit(current) {
const relative = path.relative(projectRoot, current);
if (relative && matchesGlobPath(relative, pattern)) return true;
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch (_error) {
return false;
}
return entries.some(entry => {
if (entry.isSymbolicLink()) return false;
if (entry.isDirectory() && IGNORED_SCAN_DIRS.has(entry.name)) return false;
return visit(path.join(current, entry.name));
});
}
return visit(absoluteRoot);
}
function countPythonTestFiles(rootDir) {
const config = getActivePytestConfig(rootDir);
const configured = config?.paths || [];
const patterns = config?.pythonFiles?.length > 0
? config.pythonFiles
: ['test_*.py', '*_test.py'];
const isPythonTestFile = fileName => patterns.some(pattern =>
matchesGlobPath(fileName, pattern)
);
let roots = configured.length > 0 ? configured : ['.'];
const directRoots = roots.filter(value => !hasGlobSyntax(value));
let files = walkFiles(rootDir, directRoots, isPythonTestFile);
let configuredPathMatched = directRoots.some(relativeRoot => {
const candidate = path.resolve(fs.realpathSync(rootDir), relativeRoot);
const relative = path.relative(fs.realpathSync(rootDir), candidate);
return relative !== '..' &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative) &&
fs.existsSync(candidate);
});
for (const pattern of roots.filter(hasGlobSyntax)) {
const scanRoot = getGlobScanRoot(pattern);
const normalizedPattern = pattern.replace(/\\/g, '/').replace(/\/$/, '');
const recursiveRootMatch = normalizedPattern.endsWith('/**') &&
fs.existsSync(path.resolve(fs.realpathSync(rootDir), scanRoot));
const matching = walkFiles(
rootDir,
[scanRoot],
(fileName, filePath) => isPythonTestFile(fileName) &&
matchesGlobPath(path.relative(fs.realpathSync(rootDir), filePath), pattern)
);
const matchingPaths = walkFiles(
rootDir,
[scanRoot],
(_fileName, filePath) => matchesGlobPath(
path.relative(fs.realpathSync(rootDir), filePath),
pattern
)
);
configuredPathMatched = configuredPathMatched ||
recursiveRootMatch ||
matchingPaths.size > 0 ||
globMatchesAnyPath(rootDir, scanRoot, pattern);
files = new Set([...files, ...matching]);
}
if (configured.length > 0 && !configuredPathMatched) {
roots = ['.'];
files = walkFiles(rootDir, roots, isPythonTestFile);
}
return files.size;
}
function toxInvokesPytest(text) {
if (!text) return false;
const lines = text.split(/\r?\n/);
let inTestEnv = false;
let collectingCommands = false;
for (const line of lines) {
const section = line.match(/^\s*\[([^\]]+)]\s*$/);
if (section) {
inTestEnv = /^testenv(?::|$)/i.test(section[1]);
collectingCommands = false;
continue;
}
if (!inTestEnv || /^\s*[#;]/.test(line)) continue;
const command = line.match(/^\s*commands\s*=\s*(.*)$/i);
if (command) {
collectingCommands = true;
if (command[1].trim().split(/\s+/).some(token => token === 'pytest')) return true;
continue;
}
if (collectingCommands && /^\s+\S/.test(line)) {
if (line.trim().split(/\s+/).some(token => token === 'pytest')) return true;
} else if (line.trim()) {
collectingCommands = false;
}
}
return false;
}
function hasPythonTestSuite(rootDir, pythonTestCount = countPythonTestFiles(rootDir)) {
return Boolean(getActivePytestConfig(rootDir)) ||
pythonTestCount > 0 ||
walkFiles(rootDir, ['.'], fileName => fileName === 'conftest.py').size > 0 ||
toxInvokesPytest(safeRead(path.join(rootDir, 'tox.ini')));
}
function globToRegex(pattern) {
let output = '';
for (let index = 0; index < pattern.length; index += 1) {
const char = pattern[index];
if (char === '*' && pattern[index + 1] === '*') {
if (pattern[index + 2] === '/') {
output += '(?:.*/)?';
index += 2;
} else {
output += '.*';
index += 1;
}
} else if (char === '*') {
output += '[^/]*';
} else if (char === '?') {
output += '[^/]';
} else if (char === '[') {
const closing = pattern.indexOf(']', index + 1);
if (closing > index + 1) {
let content = pattern.slice(index + 1, closing);
const negated = content.startsWith('!') || content.startsWith('^');
if (negated) content = content.slice(1);
output += `[${negated ? '^' : ''}${content.replace(/\\/g, '\\\\')}]`;
index = closing;
} else {
output += '\\[';
}
} else {
output += char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
}
return output;
}
function gitignoreIgnoresEnvFiles(text) {
if (typeof text !== 'string' || !text.trim()) return false;
const candidates = [
'.env',
'.env.local',
'.env.production',
'.env/example',
'config/.env',
'secrets.env',
];
let tempRoot;
try {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-gitignore-'));
const repoRoot = path.join(tempRoot, 'repo');
const templateRoot = path.join(tempRoot, 'template');
const globalExcludesPath = path.join(tempRoot, 'global-excludes');
fs.mkdirSync(repoRoot);
fs.mkdirSync(templateRoot);
fs.writeFileSync(globalExcludesPath, '');
fs.writeFileSync(path.join(repoRoot, '.gitignore'), text);
const init = spawnSync('git', ['init', '--quiet', `--template=${templateRoot}`, repoRoot], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 5000,
});
if (init.error || init.status !== 0) {
process.stderr.write('[harness-audit] Unable to initialize isolated gitignore check\n');
return false;
}
const result = spawnSync(
'git',
['-C', repoRoot, '-c', `core.excludesFile=${globalExcludesPath}`, 'check-ignore', '--no-index', '--stdin'],
{
input: `${candidates.join('\n')}\n`,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000,
}
);
if (result.error || ![0, 1].includes(result.status)) {
process.stderr.write('[harness-audit] Unable to evaluate gitignore rules with git\n');
return false;
}
return result.status === 0 && Boolean(result.stdout.trim());
} catch (_error) {
process.stderr.write('[harness-audit] Unable to prepare isolated gitignore check\n');
return false;
} finally {
if (tempRoot) fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
module.exports = {
countPythonTestFiles,
gitignoreIgnoresEnvFiles,
hasPythonTestSuite,
};