dependency-cruiser
Version:
Validate and visualize dependencies. With your rules. JavaScript, TypeScript, CoffeeScript. ES6, CommonJS, AMD.
261 lines (230 loc) • 7.66 kB
JavaScript
import { randomInt } from "node:crypto";
import memoize, { memoizeClear } from "memoize";
import { formatPercentage, formatViolation } from "./utl/index.mjs";
/**
* Escape string for TeamCity output.
* @see https://confluence.jetbrains.com/display/TCD65/Build+Script+Interaction+with+TeamCity#BuildScriptInteractionwithTeamCity-servMsgsServiceMessages
* Copied from https://github.com/pifantastic/teamcity-service-messages/blob/master/lib/message.js#L72
*
* @param {String} pMessageString the string to escape
* @return {String}
*/
function escape(pMessageString) {
if (pMessageString === null) {
return "";
}
return (
pMessageString
.toString()
.replace(/\|/g, "||")
.replace(/\n/g, "|n")
.replace(/\r/g, "|r")
.replace(/\[/g, "|[")
.replace(/\]/g, "|]")
// next line
.replace(/\u0085/g, "|x")
// line separator
.replace(/\u2028/g, "|l")
// paragraph separator
.replace(/\u2029/g, "|p")
.replace(/'/g, "|'")
);
}
/**
* Returns a random flowId consisting of 10 numeric digits. TeamCity doesn't
* currently seem to have demands on the format (it's just a string as far
* as I can tell), but this is what teamcity-service-messages used, so as
* per the rule of least surprise, this is what we use.
*
* @return {string} a random flowId consisting of 10 numeric digits
*/
function getRandomFlowIdBare() {
const lFlowIdLength = 10;
// eslint-disable-next-line no-magic-numbers
const lFlowIdMax = 10 ** lFlowIdLength;
return randomInt(1, lFlowIdMax).toString().padStart(lFlowIdLength, "0");
}
const getRandomFlowId = memoize(getRandomFlowIdBare);
/**
* Returns a timestamp in ISO format without the trailing 'Z'. It used to be
* an issue with TeamCity that it didn't use the trailing 'Z' (this is
* documented in the teamcity-service-messages source code) - not sure whether
* this is still the case, but this is what we do to be on the safe side.
*
* @returns {string} a timestamp in ISO format without the trailing 'Z'
*/
function getTimeStamp() {
return new Date().toISOString().slice(0, -1);
}
function inspectionType(pData) {
const lAttributes = [];
lAttributes.push(
`id='${pData.id}'`,
`name='${pData.name}'`,
`description='${escape(pData.description)}'`,
`category='${pData.category}'`,
`flowId='${getRandomFlowId()}'`,
`timestamp='${getTimeStamp()}'`,
);
return `##teamcity[inspectionType ${lAttributes.join(" ")}]`;
}
function inspection(pData) {
const lAttributes = [];
lAttributes.push(
`typeId='${pData.typeId}'`,
`message='${escape(pData.message)}'`,
);
if (pData.file) {
lAttributes.push(`file='${pData.file}'`);
}
lAttributes.push(
`SEVERITY='${pData.SEVERITY}'`,
`flowId='${getRandomFlowId()}'`,
`timestamp='${getTimeStamp()}'`,
);
return `##teamcity[inspection ${lAttributes.join(" ")}]`;
}
const CATEGORY = "dependency-cruiser";
const SEVERITY2TEAMCITY_SEVERITY = new Map([
["error", "ERROR"],
["warn", "WARNING"],
["info", "INFO"],
]);
const EOL = "\n";
function severity2teamcitySeverity(pSeverity) {
return SEVERITY2TEAMCITY_SEVERITY.get(pSeverity) || "INFO";
}
function reportRules(pRules, pViolations) {
return pRules
.filter((pRule) =>
pViolations.some((pViolation) => pRule.name === pViolation.rule.name),
)
.map((pRule) =>
inspectionType({
id: pRule.name,
name: pRule.name,
description: pRule.comment || pRule.name,
category: CATEGORY,
}),
);
}
function reportAllowedRule(pAllowedRule, pViolations) {
let lReturnValue = [];
if (
pAllowedRule.length > 0 &&
pViolations.some((pViolation) => pViolation.rule.name === "not-in-allowed")
) {
lReturnValue = inspectionType({
id: "not-in-allowed",
name: "not-in-allowed",
description: "dependency is not in the 'allowed' set of rules",
category: CATEGORY,
});
}
return lReturnValue;
}
function reportIgnoredRules(pIgnoredCount) {
let lReturnValue = [];
if (pIgnoredCount > 0) {
lReturnValue = inspectionType({
id: "ignored-known-violations",
name: "ignored-known-violations",
description:
"some dependency violations were ignored; run with --no-ignore-known to see them",
category: CATEGORY,
});
}
return lReturnValue;
}
function reportViolatedRules(pRuleSetUsed, pViolations, pIgnoredCount) {
return reportRules(pRuleSetUsed?.forbidden ?? [], pViolations)
.concat(reportAllowedRule(pRuleSetUsed?.allowed ?? [], pViolations))
.concat(reportRules(pRuleSetUsed?.required ?? [], pViolations))
.concat(reportIgnoredRules(pIgnoredCount));
}
function formatModuleViolation(pViolation) {
return pViolation.from;
}
function formatDependencyViolation(pViolation) {
return `${pViolation.from} -> ${pViolation.to}`;
}
function formatCycleViolation(pViolation) {
return `${pViolation.from} -> ${pViolation.cycle
.map(({ name }) => name)
.join(" -> ")}`;
}
function formatReachabilityViolation(pViolation) {
return `${formatDependencyViolation(pViolation)} ${pViolation.via
.map(({ name }) => name)
.join(" -> ")}`;
}
function formatInstabilityViolation(pViolation) {
return `${formatDependencyViolation(
pViolation,
)} (instability: ${formatPercentage(
pViolation.metrics.from.instability,
)} -> ${formatPercentage(pViolation.metrics.to.instability)})`;
}
function bakeViolationMessage(pViolation) {
const lViolationType2Formatter = {
module: formatModuleViolation,
dependency: formatDependencyViolation,
cycle: formatCycleViolation,
reachability: formatReachabilityViolation,
instability: formatInstabilityViolation,
};
return formatViolation(
pViolation,
lViolationType2Formatter,
formatDependencyViolation,
);
}
function reportIgnoredViolation(pIgnoredCount) {
let lReturnValue = [];
if (pIgnoredCount > 0) {
lReturnValue = inspection({
typeId: "ignored-known-violations",
message: `${pIgnoredCount} known violations ignored. Run with --no-ignore-known to see them.`,
SEVERITY: "WARNING",
});
}
return lReturnValue;
}
function reportViolations(pViolations, pIgnoredCount) {
return pViolations
.map((pViolation) =>
inspection({
typeId: pViolation.rule.name,
message: bakeViolationMessage(pViolation),
file: pViolation.from,
SEVERITY: severity2teamcitySeverity(pViolation.rule.severity),
}),
)
.concat(reportIgnoredViolation(pIgnoredCount));
}
/**
* Returns a bunch of TeamCity service messages:
* - for each violated rule in the passed results: an `inspectionType` with the
* name and comment of that rule
* - for each violation in the passed results: an `inspection` with the
* violated rule name and the tos and froms
*
* @param {import("../../types/dependency-cruiser.js").ICruiseResult} pResults
* @returns {import("../../types/dependency-cruiser.js").IReporterOutput}
*/
// eslint-disable-next-line complexity
export default function teamcity(pResults) {
memoizeClear(getRandomFlowId);
const lRuleSet = pResults?.summary?.ruleSetUsed ?? [];
const lViolations = (pResults?.summary?.violations ?? []).filter(
(pViolation) => pViolation.rule.severity !== "ignore",
);
const lIgnoredCount = pResults?.summary?.ignore ?? 0;
return {
output:
reportViolatedRules(lRuleSet, lViolations, lIgnoredCount)
.concat(reportViolations(lViolations, lIgnoredCount))
.reduce((pAll, pCurrent) => `${pAll}${pCurrent}\n`, "") || EOL,
exitCode: pResults.summary.error,
};
}