dependency-cruiser
Version:
Validate and visualize dependencies. With your rules. JavaScript, TypeScript, CoffeeScript. ES6, CommonJS, AMD.
279 lines (251 loc) • 7.9 kB
JavaScript
import { randomInt } from "node:crypto";
import { formatPercentage, formatViolation } from "./utl/index.mjs";
/** @import { IInspection, IInspectionType } from "./teamcity.types.ts" */
const CATEGORY = "dependency-cruiser";
const SEVERITY2TEAMCITY_SEVERITY = new Map([
["error", "ERROR"],
["warn", "WARNING"],
["info", "INFO"],
]);
const EOL = "\n";
/**
* 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()
.replaceAll("|", "||")
.replaceAll("\n", "|n")
.replaceAll("\r", "|r")
.replaceAll("[", "|[")
.replaceAll("]", "|]")
// next line
.replaceAll("\u0085", "|x")
// line separator
.replaceAll("\u2028", "|l")
// paragraph separator
.replaceAll("\u2029", "|p")
.replaceAll("'", "|'")
);
}
/**
* Returns a random flowId consisting of 10 numeric digits.
*
* TeamCity doesn't seem to have demands on the format (it's just a string),
* but this is what teamcity-service-messages used, so as
* per the rule of least surprise, this is what we use as well.
*
* @return {string} 10 random numeric digits
*/
function getRandomFlowId() {
const lFlowIdLength = 10;
// eslint-disable-next-line no-magic-numbers
const lFlowIdMax = 10 ** lFlowIdLength;
return randomInt(1, lFlowIdMax).toString().padStart(lFlowIdLength, "0");
}
/**
* Returns a timestamp in ISO format without the trailing 'Z'. TeamCity
* didn't use the trailing 'Z' (documented in the teamcity-service-messages
* source) - not sure whether still the case, but better safe than sorry.
*
* @returns {string} a timestamp in ISO format without the trailing 'Z'
*/
function getTimeStamp() {
return new Date().toISOString().slice(0, -1);
}
/**
* formats an inspection type TeamCity service message
* @param {IInspectionType} pInspectionTypeData
* @returns {string}
*/
function formatInspectionType(pInspectionTypeData) {
const lAttributes = [];
lAttributes.push(
`id='${pInspectionTypeData.id}'`,
`name='${pInspectionTypeData.name}'`,
`description='${escape(pInspectionTypeData.description)}'`,
`category='${pInspectionTypeData.category}'`,
`flowId='${pInspectionTypeData.flowId}'`,
`timestamp='${getTimeStamp()}'`,
);
return `##teamcity[inspectionType ${lAttributes.join(" ")}]`;
}
/**
* formats an inspection TeamCity service message
* @param {IInspection} pInspectionData
* @returns {string}
*/
function formatInspection(pInspectionData) {
const lAttributes = [];
lAttributes.push(
`typeId='${pInspectionData.typeId}'`,
`message='${escape(pInspectionData.message)}'`,
);
if (pInspectionData.file) {
lAttributes.push(`file='${pInspectionData.file}'`);
}
lAttributes.push(
`SEVERITY='${pInspectionData.SEVERITY}'`,
`flowId='${pInspectionData.flowId}'`,
`timestamp='${getTimeStamp()}'`,
);
return `##teamcity[inspection ${lAttributes.join(" ")}]`;
}
function severity2teamcitySeverity(pSeverity) {
return SEVERITY2TEAMCITY_SEVERITY.get(pSeverity) || "INFO";
}
function reportRules(pRules, pViolations, pFlowId) {
return pRules
.filter((pRule) =>
pViolations.some((pViolation) => pRule.name === pViolation.rule.name),
)
.map((pRule) =>
formatInspectionType({
id: pRule.name,
name: pRule.name,
description: pRule.comment || pRule.name,
category: CATEGORY,
flowId: pFlowId,
}),
);
}
function reportAllowedRule(pAllowedRule, pViolations, pFlowId) {
let lReturnValue = [];
if (
pAllowedRule.length > 0 &&
pViolations.some((pViolation) => pViolation.rule.name === "not-in-allowed")
) {
lReturnValue = formatInspectionType({
id: "not-in-allowed",
name: "not-in-allowed",
description: "dependency is not in the 'allowed' set of rules",
category: CATEGORY,
flowId: pFlowId,
});
}
return lReturnValue;
}
function reportIgnoredRules(pIgnoredCount, pFlowId) {
let lReturnValue = [];
if (pIgnoredCount > 0) {
lReturnValue = formatInspectionType({
id: "ignored-known-violations",
name: "ignored-known-violations",
description:
"some dependency violations were ignored; run with --no-ignore-known to see them",
category: CATEGORY,
flowId: pFlowId,
});
}
return lReturnValue;
}
function reportViolatedRules(
pRuleSetUsed,
pViolations,
pIgnoredCount,
pFlowId,
) {
return reportRules(pRuleSetUsed?.forbidden ?? [], pViolations, pFlowId)
.concat(
reportAllowedRule(pRuleSetUsed?.allowed ?? [], pViolations, pFlowId),
)
.concat(reportRules(pRuleSetUsed?.required ?? [], pViolations, pFlowId))
.concat(reportIgnoredRules(pIgnoredCount, pFlowId));
}
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, pFlowId) {
let lReturnValue = [];
if (pIgnoredCount > 0) {
lReturnValue = formatInspection({
typeId: "ignored-known-violations",
flowId: pFlowId,
message: `${pIgnoredCount} known violations ignored. Run with --no-ignore-known to see them.`,
SEVERITY: "WARNING",
});
}
return lReturnValue;
}
function reportViolations(pViolations, pIgnoredCount, pFlowId) {
return pViolations
.map((pViolation) =>
formatInspection({
typeId: pViolation.rule.name,
flowId: pFlowId,
message: bakeViolationMessage(pViolation),
file: pViolation.from,
SEVERITY: severity2teamcitySeverity(pViolation.rule.severity),
}),
)
.concat(reportIgnoredViolation(pIgnoredCount, pFlowId));
}
/**
* 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.mjs").ICruiseResult} pResults
* @returns {import("../../types/dependency-cruiser.mjs").IReporterOutput}
*/
// eslint-disable-next-line complexity
export default function teamcity(pResults) {
const lFlowId = 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, lFlowId)
.concat(reportViolations(lViolations, lIgnoredCount, lFlowId))
.reduce((pAll, pCurrent) => `${pAll}${pCurrent}${EOL}`, "") || EOL,
exitCode: pResults.summary.error,
};
}