mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 20:25:26 +02:00
441 lines
13 KiB
JavaScript
441 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.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,
|
|
};
|