Skip to content

Instantly share code, notes, and snippets.

@mikaelvesavuori
Last active August 28, 2023 16:47
Show Gist options
  • Save mikaelvesavuori/5c6dab7afd951222b9dbebac1e609c77 to your computer and use it in GitHub Desktop.
Save mikaelvesavuori/5c6dab7afd951222b9dbebac1e609c77 to your computer and use it in GitHub Desktop.
Calculate coupling metrics and aggregated metrics from TS files.
import { readFileSync, readdirSync, statSync, writeFileSync } from 'fs';
import path from 'path';
// Use this before running folder metrics
interface CouplingResult {
abstractions: number;
abstractness: number;
afferent: number;
concretions: number;
distance: number;
efferent: number;
imports: string[];
instability: number;
}
const IMPORT_REGEX = /^\s*import\s+.*from\s+['"]([^'"]+)['"]/;
const ABSTRACTION_REGEX = /(abstract class \w* {|type \w* = {|interface \w* {)/;
const CONCRETION_REGEX = /const \w* = \(.*|function \w*\(.*\).* {|(?<!abstract\s)class \w* {/;
function getFiles(folderPath: string): string[] {
const tsFiles: string[] = [];
function traverseDirectory(currentPath: string): void {
const files = readdirSync(currentPath);
files.forEach((file: any) => {
const filePath = path.join(currentPath, file);
const stats = statSync(filePath);
if (stats.isFile() && path.extname(file) === '.ts') tsFiles.push(filePath);
else if (stats.isDirectory()) traverseDirectory(filePath);
});
}
traverseDirectory(folderPath);
return tsFiles;
}
function resolveImportPath(currentModulePath: string, importPath: string): string {
if (importPath.startsWith('.') || importPath.startsWith('..'))
return path.resolve(path.dirname(currentModulePath), importPath);
return importPath;
}
function calculateCoupling(files: string[]): Record<string, CouplingResult> {
const root = path.basename(path.resolve());
const results: Record<string, CouplingResult> = {};
// All files need a base object before the next step
files.forEach((filePath) => {
const fixedFileName = getCleanedDirectory(filePath, root);
results[fixedFileName] = createBaseResults(filePath);
});
// Go through all imports, if any, and calculate values
files.forEach((filePath) => {
const fixedFileName = getCleanedDirectory(filePath, root);
const imports = results[fixedFileName].imports;
if (imports.length > 0) {
imports.forEach((importPath) => {
const fixedImportName = getCleanedDirectory(flattenImport(importPath), root);
const importReference = results[fixedImportName];
if (importReference) {
importReference.afferent++;
importReference.instability = calculateInstability(importReference);
importReference.abstractness = calculateAbstractness(importReference);
importReference.distance = calculateDistanceFromMainSequence(importReference);
}
});
}
});
// Clean up import paths before presenting
files.forEach((filePath) => {
const fixedFileName = getCleanedDirectory(filePath, root);
const imports = results[fixedFileName].imports;
if (imports.length > 0) {
const fixedImports = imports
.map((importPath) => getCleanedDirectory(importPath, root))
.filter((importPath) => importPath);
results[fixedFileName].imports = fixedImports;
}
});
return results;
}
function flattenImport(importPath: string) {
return importPath.replace('~src', `${process.cwd()}/src`);
}
function getCleanedDirectory(filePath: string, root: string): string {
return filePath.split(root)[1];
}
function createBaseResults(filePath: string): CouplingResult {
const { imports, concretions, abstractions } = extractData(filePath);
return {
afferent: 0,
efferent: imports.length,
abstractness: 0,
instability: 0,
distance: 0,
imports,
concretions,
abstractions
};
}
function calculateInstability(item: CouplingResult): number {
return parseFloat((item.efferent / (item.efferent + item.afferent)).toFixed(2));
}
function calculateAbstractness(item: CouplingResult): number {
if (item.abstractions && item.concretions === 0) return item.abstractions;
if (!item.abstractions && item.concretions === 0) return 0;
return parseFloat((item.abstractions / item.concretions).toFixed(2));
}
function calculateDistanceFromMainSequence(item: CouplingResult): number {
return parseFloat(Math.abs(item.abstractness + item.instability - 1).toFixed(2));
}
function extractData(filePath: string): Record<string, any> {
const lines = readFileSync(filePath, 'utf-8').split('\n');
const imports: string[] = [];
let abstractions = 0;
let concretions = 0;
lines.forEach((line: any) => {
const importMatch = line.match(IMPORT_REGEX);
if (importMatch) imports.push(resolveImportPath(filePath, importMatch[1] + '.ts'));
const abstractionMatch = line.match(ABSTRACTION_REGEX);
if (abstractionMatch && !startsWithSpace(abstractionMatch.input || '')) abstractions++;
const concretionMatch = line.match(CONCRETION_REGEX);
if (concretionMatch && !startsWithSpace(concretionMatch.input || '')) concretions++;
});
return { imports, concretions, abstractions };
}
function startsWithSpace(str: string): boolean {
return str.startsWith(' ');
}
function main() {
const defaultPath = path.resolve('src');
const tsFiles = getFiles(defaultPath);
const coupling = calculateCoupling(tsFiles);
/* Example:
const coupling = {
'/bin/contracts/ApiResponse.ts': {
afferent: 3,
efferent: 0,
abstractness: 2,
instability: 0,
distance: 1,
imports: [],
concretions: 0,
abstractions: 2
}
};
*/
writeFileSync('coupling.json', JSON.stringify(coupling, null, 2));
for (const module in coupling) {
console.table([
{
Module: module,
Afferent: coupling[module].afferent,
Efferent: coupling[module].efferent,
Instability: coupling[module].instability,
Abstractness: coupling[module].abstractness,
Distance: coupling[module].distance
}
]);
}
}
main();
import { readFileSync, writeFileSync } from 'fs';
// Use the output from calculate-coupling.ts here (coupling.json)
const data = readFileSync('coupling.json', 'utf-8');
const inputData = JSON.parse(data);
interface FileMetrics {
abstractions: number;
abstractness: number;
afferent: number;
concretions: number;
distance: number;
efferent: number;
imports: string[];
instability: number;
}
interface FolderMetrics {
abstractions: number;
afferent: number;
concretions: number;
efferent: number;
loc: number;
}
interface AggregatedFolderMetrics {
[directoryPath: string]: FolderMetrics;
}
interface InputData {
[filePath: string]: FileMetrics;
}
interface ResultEntry {
abstractions: number;
afferent: number;
concretions: number;
directoryPath: string;
efferent: number;
loc: number;
percent: number;
}
interface Results {
abstractions: string;
afferent: string;
concretions: string;
efferent: string;
loc: string;
results: ResultEntry[];
totalLinesOfCode: number;
}
/**
*
*/
function countLinesOfCode(filePath: string): number {
const fullPath = `${process.cwd()}${filePath}`;
const content = readFileSync(fullPath, 'utf-8');
const lines = content.split('\n');
return lines.length;
}
/**
*
*/
function calculateMetrics(data: InputData): Results {
const aggregatedMetrics: AggregatedFolderMetrics = {};
let totalLoc = 0;
for (const filePath in data) {
const directoryPath = filePath.split('/').slice(0, -1).join('/');
const metrics: FileMetrics = data[filePath];
const base = createBaseObject();
const fileLoc = countLinesOfCode(filePath);
totalLoc += fileLoc;
aggregatedMetrics[directoryPath] = aggregatedMetrics[directoryPath] || base;
const directoryData = aggregatedMetrics[directoryPath];
directoryData.afferent += metrics.afferent;
directoryData.efferent += metrics.efferent;
directoryData.abstractions += metrics.abstractions;
directoryData.concretions += metrics.concretions;
directoryData.loc += fileLoc;
}
const result: ResultEntry[] = Object.entries(aggregatedMetrics).map(
([directoryPath, directoryData]) => {
const { afferent, efferent, abstractions, concretions, loc } = directoryData;
const percent = parseFloat(((loc / totalLoc) * 100).toFixed(2));
return {
directoryPath,
afferent,
efferent,
abstractions,
concretions,
loc,
percent
};
}
);
return {
results: result,
totalLinesOfCode: totalLoc,
loc: maxBy('loc', result),
afferent: maxBy('abstractions', result),
efferent: maxBy('concretions', result),
abstractions: maxBy('abstractions', result),
concretions: maxBy('concretions', result)
};
}
const createBaseObject = () => ({
afferent: 0,
efferent: 0,
abstractions: 0,
concretions: 0,
loc: 0
});
const maxBy = (property: keyof ResultEntry, result: ResultEntry[]) =>
result.reduce((max, entry) => (entry[property] > max[property] ? entry : max), result[0])
.directoryPath;
const results = calculateMetrics(inputData);
writeFileSync('folder-metrics.json', JSON.stringify(results, null, 2));
console.log('Results:', results);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment