Files
ccs/scripts/maintainability-baseline.js
T

310 lines
8.1 KiB
JavaScript

#!/usr/bin/env node
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const PROJECT_ROOT = path.resolve(__dirname, '..');
const SRC_DIR = path.join(PROJECT_ROOT, 'src');
const DEFAULT_BASELINE_PATH = path.join(
PROJECT_ROOT,
'docs',
'metrics',
'maintainability-baseline.json'
);
const TYPESCRIPT_EXTENSIONS = new Set(['.ts', '.tsx', '.cts', '.mts']);
const LARGE_FILE_THRESHOLD_LOC = 350;
const FS_SYNC_APIS = [
'accessSync',
'appendFileSync',
'chmodSync',
'chownSync',
'closeSync',
'copyFileSync',
'cpSync',
'existsSync',
'fchmodSync',
'fchownSync',
'fdatasyncSync',
'fstatSync',
'fsyncSync',
'ftruncateSync',
'futimesSync',
'lchmodSync',
'lchownSync',
'linkSync',
'lstatSync',
'lutimesSync',
'mkdirSync',
'mkdtempSync',
'openSync',
'opendirSync',
'readFileSync',
'readdirSync',
'readlinkSync',
'readSync',
'realpathSync',
'renameSync',
'rmSync',
'rmdirSync',
'statSync',
'symlinkSync',
'truncateSync',
'unlinkSync',
'utimesSync',
'writeFileSync',
'writeSync',
'writevSync',
];
const PROCESS_EXIT_PATTERN = /\bprocess\s*\.\s*exit\b/g;
const FS_SYNC_PATTERN = new RegExp(`\\b(?:${FS_SYNC_APIS.join('|')})\\b`, 'g');
function printUsage() {
console.log(
[
'Usage:',
' node scripts/maintainability-baseline.js',
' node scripts/maintainability-baseline.js --out [path]',
' node scripts/maintainability-baseline.js --check [path]',
'',
'Defaults:',
` baseline path: ${path.relative(PROJECT_ROOT, DEFAULT_BASELINE_PATH)}`,
].join('\n')
);
}
function parseArgs(argv) {
const options = {
outPath: null,
checkPath: null,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--help' || arg === '-h') {
printUsage();
process.exit(0);
}
if (arg === '--out' || arg === '--write') {
const nextArg = argv[index + 1];
if (nextArg && !nextArg.startsWith('--')) {
options.outPath = nextArg;
index += 1;
} else {
options.outPath = path.relative(process.cwd(), DEFAULT_BASELINE_PATH);
}
continue;
}
if (arg === '--check') {
const nextArg = argv[index + 1];
if (nextArg && !nextArg.startsWith('--')) {
options.checkPath = nextArg;
index += 1;
} else {
options.checkPath = path.relative(process.cwd(), DEFAULT_BASELINE_PATH);
}
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
return options;
}
function collectTrackedFilesFromGit() {
let output;
try {
output = execFileSync('git', ['ls-files', '-z', '--', 'src'], {
cwd: PROJECT_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
} catch {
throw new Error(
'Unable to enumerate tracked files via git. Run this command from a git checkout with git installed.'
);
}
if (!output) {
return [];
}
return output
.split('\0')
.filter(Boolean)
.sort((left, right) => left.localeCompare(right))
.map(relativePath => path.resolve(PROJECT_ROOT, relativePath))
.filter(filePath => {
const relativeToSrc = path.relative(SRC_DIR, filePath);
if (relativeToSrc.startsWith('..') || path.isAbsolute(relativeToSrc)) {
return false;
}
const stats = fs.statSync(filePath);
if (!stats.isFile()) {
throw new Error(`Tracked path is not a file: ${path.relative(PROJECT_ROOT, filePath)}`);
}
return true;
});
}
function collectFilesInSrc() {
return collectTrackedFilesFromGit();
}
function countLines(content) {
if (content.length === 0) {
return 0;
}
const lineBreakMatches = content.match(/\r\n|\n|\r/g);
const lineBreakCount = lineBreakMatches ? lineBreakMatches.length : 0;
const endsWithLineBreak = content.endsWith('\n') || content.endsWith('\r');
return endsWithLineBreak ? lineBreakCount : lineBreakCount + 1;
}
function countMatches(content, pattern) {
const matches = content.match(pattern);
return matches ? matches.length : 0;
}
function collectMetrics() {
if (!fs.existsSync(SRC_DIR)) {
throw new Error(`Directory not found: ${SRC_DIR}`);
}
const files = collectFilesInSrc();
let typeScriptFileCount = 0;
let locInSrc = 0;
let processExitReferenceCount = 0;
let synchronousFsApiReferenceCount = 0;
let largeFileCountOver350Loc = 0;
for (const filePath of files) {
const content = fs.readFileSync(filePath, 'utf8');
const loc = countLines(content);
const extension = path.extname(filePath).toLowerCase();
const isTypeScriptFile = TYPESCRIPT_EXTENSIONS.has(extension);
locInSrc += loc;
processExitReferenceCount += countMatches(content, PROCESS_EXIT_PATTERN);
synchronousFsApiReferenceCount += countMatches(content, FS_SYNC_PATTERN);
if (isTypeScriptFile) {
typeScriptFileCount += 1;
if (loc > LARGE_FILE_THRESHOLD_LOC) {
largeFileCountOver350Loc += 1;
}
}
}
return {
sourceDirectory: 'src',
largeFileThresholdLoc: LARGE_FILE_THRESHOLD_LOC,
typeScriptFileCount,
locInSrc,
processExitReferenceCount,
synchronousFsApiReferenceCount,
largeFileCountOver350Loc,
};
}
function writeMetrics(outPath, metrics) {
const resolvedOutPath = path.resolve(process.cwd(), outPath);
fs.mkdirSync(path.dirname(resolvedOutPath), { recursive: true });
fs.writeFileSync(resolvedOutPath, `${JSON.stringify(metrics, null, 2)}\n`, 'utf8');
}
function runCheck(checkPath, currentMetrics) {
const resolvedCheckPath = path.resolve(process.cwd(), checkPath);
const baselineContent = fs.readFileSync(resolvedCheckPath, 'utf8');
const baselineMetrics = JSON.parse(baselineContent);
if (baselineMetrics.sourceDirectory !== currentMetrics.sourceDirectory) {
throw new Error(
`Baseline sourceDirectory mismatch: expected "${currentMetrics.sourceDirectory}", got "${baselineMetrics.sourceDirectory}"`
);
}
if (baselineMetrics.largeFileThresholdLoc !== currentMetrics.largeFileThresholdLoc) {
throw new Error(
`Baseline largeFileThresholdLoc mismatch: expected ${currentMetrics.largeFileThresholdLoc}, got ${baselineMetrics.largeFileThresholdLoc}`
);
}
const gatedKeys = [
'processExitReferenceCount',
'synchronousFsApiReferenceCount',
'largeFileCountOver350Loc',
];
const violations = [];
for (const key of gatedKeys) {
if (typeof baselineMetrics[key] !== 'number') {
throw new Error(`Baseline is missing numeric metric: ${key}`);
}
if (currentMetrics[key] > baselineMetrics[key]) {
violations.push({
metric: key,
baseline: baselineMetrics[key],
current: currentMetrics[key],
});
}
}
return {
gate: 'maintainability-baseline',
baselinePath: path.relative(PROJECT_ROOT, resolvedCheckPath),
passed: violations.length === 0,
comparedMetrics: gatedKeys,
baseline: {
typeScriptFileCount: baselineMetrics.typeScriptFileCount,
locInSrc: baselineMetrics.locInSrc,
processExitReferenceCount: baselineMetrics.processExitReferenceCount,
synchronousFsApiReferenceCount: baselineMetrics.synchronousFsApiReferenceCount,
largeFileCountOver350Loc: baselineMetrics.largeFileCountOver350Loc,
},
current: {
typeScriptFileCount: currentMetrics.typeScriptFileCount,
locInSrc: currentMetrics.locInSrc,
processExitReferenceCount: currentMetrics.processExitReferenceCount,
synchronousFsApiReferenceCount: currentMetrics.synchronousFsApiReferenceCount,
largeFileCountOver350Loc: currentMetrics.largeFileCountOver350Loc,
},
violations,
};
}
function main() {
const options = parseArgs(process.argv.slice(2));
const metrics = collectMetrics();
if (options.outPath) {
writeMetrics(options.outPath, metrics);
}
if (options.checkPath) {
const checkResult = runCheck(options.checkPath, metrics);
console.log(JSON.stringify(checkResult, null, 2));
if (!checkResult.passed) {
process.exit(1);
}
return;
}
console.log(JSON.stringify(metrics, null, 2));
}
main();