archunit
Version:
ArchUnit TypeScript is an architecture testing library, to specify and assert architecture rules in your TypeScript app
534 lines (529 loc) • 19.5 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.GraphReporter = exports.ProjectGraphBuilder = exports.dependencyGraph = exports.projectGraph = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const minimatch_1 = require("minimatch");
const extraction_1 = require("../common/extraction");
const DEFAULT_TITLE = 'ArchUnitTS Dependency Graph';
const projectGraph = (tsConfigFilePath) => {
return new ProjectGraphBuilder(tsConfigFilePath);
};
exports.projectGraph = projectGraph;
exports.dependencyGraph = exports.projectGraph;
class ProjectGraphBuilder {
constructor(tsConfigFilePath, options = {}, checkOptions) {
this.tsConfigFilePath = tsConfigFilePath;
this.options = options;
this.checkOptions = checkOptions;
}
includeExternalDependencies() {
return this.withOptions({ includeExternalDependencies: true });
}
includeSelfDependencies() {
return this.withOptions({ includeSelfDependencies: true });
}
focusOn(pattern, depth = 1) {
return this.withOptions({ focus: pattern, focusDepth: depth });
}
reachableFrom(pattern) {
return this.withOptions({ reachableFrom: pattern });
}
dependentsOf(pattern) {
return this.withOptions({ dependentsOf: pattern });
}
collapseToFolderDepth(depth) {
return this.withOptions({
collapse: {
type: 'folder-depth',
depth,
},
});
}
collapseByPattern(pattern, replacement = '$1') {
return this.withOptions({
collapse: {
type: 'pattern',
pattern,
replacement,
},
});
}
titled(title) {
return this.withOptions({ title });
}
withCheckOptions(checkOptions) {
return new ProjectGraphBuilder(this.tsConfigFilePath, this.options, checkOptions);
}
async snapshot() {
const graph = await (0, extraction_1.extractGraph)(this.tsConfigFilePath, this.checkOptions);
return GraphReporter.createSnapshot(graph, this.options);
}
async toDOT() {
return GraphReporter.toDOT(await this.getGraph(), this.options);
}
async toMermaid() {
return GraphReporter.toMermaid(await this.getGraph(), this.options);
}
async toD2() {
return GraphReporter.toD2(await this.getGraph(), this.options);
}
async toCSV() {
return GraphReporter.toCSV(await this.getGraph(), this.options);
}
async toJSON() {
return GraphReporter.toJSON(await this.getGraph(), this.options);
}
async toHTML() {
return GraphReporter.toHTML(await this.getGraph(), this.options);
}
async exportAsDOT(outputPath) {
await GraphReporter.exportAsDOT(await this.getGraph(), outputPath, this.options);
}
async exportAsMermaid(outputPath) {
await GraphReporter.exportAsMermaid(await this.getGraph(), outputPath, this.options);
}
async exportAsD2(outputPath) {
await GraphReporter.exportAsD2(await this.getGraph(), outputPath, this.options);
}
async exportAsCSV(outputPath) {
await GraphReporter.exportAsCSV(await this.getGraph(), outputPath, this.options);
}
async exportAsJSON(outputPath) {
await GraphReporter.exportAsJSON(await this.getGraph(), outputPath, this.options);
}
async exportAsHTML(outputPath) {
await GraphReporter.exportAsHTML(await this.getGraph(), outputPath, this.options);
}
withOptions(options) {
return new ProjectGraphBuilder(this.tsConfigFilePath, { ...this.options, ...options }, this.checkOptions);
}
async getGraph() {
return (0, extraction_1.extractGraph)(this.tsConfigFilePath, this.checkOptions);
}
}
exports.ProjectGraphBuilder = ProjectGraphBuilder;
class GraphReporter {
static createSnapshot(graph, options = {}) {
const filteredByExternal = options.includeExternalDependencies
? graph
: graph.filter((edge) => !edge.external);
const selectedNodes = selectNodes(filteredByExternal, options);
const displayEdges = filteredByExternal.filter((edge) => {
if (!options.includeSelfDependencies && edge.source === edge.target) {
return false;
}
return selectedNodes.has(edge.source) && selectedNodes.has(edge.target);
});
const collapsedNodes = new Set();
for (const node of selectedNodes) {
collapsedNodes.add(collapseNode(node, options.collapse));
}
const edgeMap = new Map();
for (const edge of displayEdges) {
const source = collapseNode(edge.source, options.collapse);
const target = collapseNode(edge.target, options.collapse);
if (!options.includeSelfDependencies && source === target) {
continue;
}
const key = `${source}\u0000${target}`;
const existing = edgeMap.get(key);
if (existing) {
existing.count += 1;
existing.external = existing.external || edge.external;
existing.importKinds = uniqueSorted([
...existing.importKinds,
...edge.importKinds,
]);
}
else {
edgeMap.set(key, {
source,
target,
count: 1,
external: edge.external,
importKinds: uniqueSorted(edge.importKinds),
});
}
}
const edges = Array.from(edgeMap.values()).sort(compareEdges);
const edgeNodeLabels = edges.flatMap((edge) => [edge.source, edge.target]);
const labels = uniqueSorted([...collapsedNodes, ...edgeNodeLabels]);
const nodes = labels.map((label, index) => ({
id: `n${index}`,
label,
}));
return {
title: options.title || DEFAULT_TITLE,
nodes,
edges,
summary: {
nodeCount: nodes.length,
edgeCount: edges.length,
rawEdgeCount: displayEdges.length,
externalEdgeCount: displayEdges.filter((edge) => edge.external).length,
},
};
}
static toDOT(graph, options = {}) {
const snapshot = this.createSnapshot(graph, options);
const lines = ['digraph dependencies {', '\trankdir=LR;'];
for (const node of snapshot.nodes) {
lines.push(`\t${quoteDOT(node.label)};`);
}
for (const edge of snapshot.edges) {
const label = edge.count > 1 ? ` [label="${edge.count}"]` : '';
lines.push(`\t${quoteDOT(edge.source)} -> ${quoteDOT(edge.target)}${label};`);
}
lines.push('}');
return lines.join('\n');
}
static toMermaid(graph, options = {}) {
const snapshot = this.createSnapshot(graph, options);
const nodeIds = new Map(snapshot.nodes.map((node) => [node.label, node.id]));
const lines = ['flowchart LR'];
for (const node of snapshot.nodes) {
lines.push(` ${node.id}["${escapeMermaidLabel(node.label)}"]`);
}
for (const edge of snapshot.edges) {
const source = nodeIds.get(edge.source);
const target = nodeIds.get(edge.target);
if (!source || !target) {
continue;
}
const label = edge.count > 1 ? `|${edge.count}|` : '';
lines.push(` ${source} -->${label} ${target}`);
}
return lines.join('\n');
}
static toD2(graph, options = {}) {
const snapshot = this.createSnapshot(graph, options);
const lines = [`# ${snapshot.title}`];
for (const node of snapshot.nodes) {
lines.push(`${quoteD2(node.label)}`);
}
for (const edge of snapshot.edges) {
const label = edge.count > 1 ? `: ${quoteD2(String(edge.count))}` : '';
lines.push(`${quoteD2(edge.source)} -> ${quoteD2(edge.target)}${label}`);
}
return lines.join('\n');
}
static toCSV(graph, options = {}) {
const snapshot = this.createSnapshot(graph, options);
const rows = [['source', 'target', 'count', 'external', 'importKinds']];
for (const edge of snapshot.edges) {
rows.push([
edge.source,
edge.target,
String(edge.count),
String(edge.external),
edge.importKinds.join('|'),
]);
}
return rows.map((row) => row.map(escapeCSV).join(',')).join('\n');
}
static toJSON(graph, options = {}) {
return JSON.stringify(this.createSnapshot(graph, options), null, 2);
}
static toHTML(graph, options = {}) {
const snapshot = this.createSnapshot(graph, options);
const mermaid = this.toMermaid(graph, options);
const dot = this.toDOT(graph, options);
const d2 = this.toD2(graph, options);
return `
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHTML(snapshot.title)}</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; color: #1f2933; background: #f8fafc; }
header { background: #102a43; color: white; padding: 24px 32px; }
main { padding: 24px 32px; }
h1 { margin: 0 0 8px; font-size: 28px; }
h2 { margin-top: 32px; font-size: 20px; }
.summary { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
.metric { background: white; border: 1px solid #d9e2ec; border-radius: 6px; padding: 12px 16px; min-width: 140px; }
.metric strong { display: block; font-size: 24px; color: #102a43; }
table { border-collapse: collapse; width: 100%; background: white; border: 1px solid #d9e2ec; }
th, td { text-align: left; border-bottom: 1px solid #d9e2ec; padding: 8px 10px; vertical-align: top; }
th { background: #eef2f7; font-weight: 700; }
pre { background: #0b1220; color: #e6edf3; padding: 16px; border-radius: 6px; overflow: auto; }
pre.mermaid { background: white; color: #1f2933; border: 1px solid #d9e2ec; }
details { margin: 16px 0; }
summary { cursor: pointer; font-weight: 700; }
.empty { color: #627d98; background: white; border: 1px solid #d9e2ec; padding: 16px; border-radius: 6px; }
</style>
</head>
<body>
<header>
<h1>${escapeHTML(snapshot.title)}</h1>
<div>Generated by ArchUnitTS graph reporting</div>
</header>
<main>
<section class="summary">
<div class="metric"><strong>${snapshot.summary.nodeCount}</strong>Nodes</div>
<div class="metric"><strong>${snapshot.summary.edgeCount}</strong>Aggregated Edges</div>
<div class="metric"><strong>${snapshot.summary.rawEdgeCount}</strong>Raw Edges</div>
<div class="metric"><strong>${snapshot.summary.externalEdgeCount}</strong>External Edges</div>
</section>
<h2>Dependencies</h2>
${renderEdgeTable(snapshot)}
<h2>Mermaid Preview</h2>
<pre class="mermaid">${escapeHTML(mermaid)}</pre>
<details>
<summary>Mermaid Source</summary>
<pre>${escapeHTML(mermaid)}</pre>
</details>
<details>
<summary>DOT</summary>
<pre>${escapeHTML(dot)}</pre>
</details>
<details>
<summary>D2</summary>
<pre>${escapeHTML(d2)}</pre>
</details>
</main>
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: true, securityLevel: 'loose' });
</script>
</body>
</html>`;
}
static async exportAsDOT(graph, outputPath, options = {}) {
await writeReport(outputPath, this.toDOT(graph, options));
}
static async exportAsMermaid(graph, outputPath, options = {}) {
await writeReport(outputPath, this.toMermaid(graph, options));
}
static async exportAsD2(graph, outputPath, options = {}) {
await writeReport(outputPath, this.toD2(graph, options));
}
static async exportAsCSV(graph, outputPath, options = {}) {
await writeReport(outputPath, this.toCSV(graph, options));
}
static async exportAsJSON(graph, outputPath, options = {}) {
await writeReport(outputPath, this.toJSON(graph, options));
}
static async exportAsHTML(graph, outputPath, options = {}) {
await writeReport(outputPath, this.toHTML(graph, options));
}
}
exports.GraphReporter = GraphReporter;
function selectNodes(graph, options) {
const allNodes = new Set(graph.flatMap((edge) => [edge.source, edge.target]));
const hasQuery = options.focus || options.reachableFrom || options.dependentsOf;
if (!hasQuery) {
return allNodes;
}
const selected = new Set();
if (options.focus) {
for (const node of expandFocus(graph, options.focus, options.focusDepth ?? 1)) {
selected.add(node);
}
}
if (options.reachableFrom) {
for (const node of walkGraph(graph, options.reachableFrom, 'outgoing')) {
selected.add(node);
}
}
if (options.dependentsOf) {
for (const node of walkGraph(graph, options.dependentsOf, 'incoming')) {
selected.add(node);
}
}
return selected;
}
function expandFocus(graph, pattern, depth) {
const matcher = createMatcher(pattern);
const selected = new Set();
const queue = [];
for (const node of new Set(graph.flatMap((edge) => [edge.source, edge.target]))) {
if (matcher(node)) {
selected.add(node);
queue.push({ node, depth: 0 });
}
}
while (queue.length > 0) {
const current = queue.shift();
if (current.depth >= depth) {
continue;
}
for (const neighbor of neighborsOf(graph, current.node)) {
if (!selected.has(neighbor)) {
selected.add(neighbor);
queue.push({ node: neighbor, depth: current.depth + 1 });
}
}
}
return selected;
}
function walkGraph(graph, pattern, direction) {
const matcher = createMatcher(pattern);
const selected = new Set();
const queue = [];
const allNodes = new Set(graph.flatMap((edge) => [edge.source, edge.target]));
for (const node of allNodes) {
if (matcher(node)) {
selected.add(node);
queue.push(node);
}
}
while (queue.length > 0) {
const current = queue.shift();
const nextNodes = graph
.filter((edge) => direction === 'outgoing'
? edge.source === current
: edge.target === current)
.map((edge) => (direction === 'outgoing' ? edge.target : edge.source));
for (const next of nextNodes) {
if (!selected.has(next)) {
selected.add(next);
queue.push(next);
}
}
}
return selected;
}
function neighborsOf(graph, node) {
return graph.flatMap((edge) => {
if (edge.source === node && edge.target !== node) {
return [edge.target];
}
if (edge.target === node && edge.source !== node) {
return [edge.source];
}
return [];
});
}
function collapseNode(node, strategy) {
if (!strategy) {
return node;
}
const normalized = normalizeNode(node);
if (strategy.type === 'pattern') {
return normalized.replace(strategy.pattern, strategy.replacement);
}
const depth = Math.max(1, strategy.depth);
const parts = normalized.split('/').filter(Boolean);
if (parts.length <= 1) {
return normalized;
}
const folderParts = parts.slice(0, -1);
if (folderParts.length === 0) {
return normalized;
}
return folderParts.slice(0, depth).join('/');
}
function createMatcher(pattern) {
if (typeof pattern === 'string') {
return (node) => (0, minimatch_1.minimatch)(normalizeNode(node), pattern, { dot: true });
}
return (node) => {
pattern.lastIndex = 0;
return pattern.test(normalizeNode(node));
};
}
function normalizeNode(node) {
return node.replace(/\\/g, '/');
}
function uniqueSorted(values) {
return Array.from(new Set(values.map((value) => value.toString()))).sort();
}
function compareEdges(left, right) {
return (left.source.localeCompare(right.source) ||
left.target.localeCompare(right.target));
}
function quoteDOT(input) {
return `"${input.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
function quoteD2(input) {
return `"${input.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
function escapeMermaidLabel(input) {
return input.replace(/\\/g, '\\\\').replace(/"/g, '#quot;');
}
function escapeCSV(input) {
if (/[",\n\r]/.test(input)) {
return `"${input.replace(/"/g, '""')}"`;
}
return input;
}
function escapeHTML(input) {
return input
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function renderEdgeTable(snapshot) {
if (snapshot.edges.length === 0) {
return '<div class="empty">No dependency edges matched this graph query.</div>';
}
const rows = snapshot.edges
.map((edge) => `<tr>
<td>${escapeHTML(edge.source)}</td>
<td>${escapeHTML(edge.target)}</td>
<td>${edge.count}</td>
<td>${edge.external ? 'yes' : 'no'}</td>
<td>${escapeHTML(edge.importKinds.join(', '))}</td>
</tr>`)
.join('\n');
return `<table>
<thead>
<tr>
<th>Source</th>
<th>Target</th>
<th>Count</th>
<th>External</th>
<th>Import Kinds</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>`;
}
async function writeReport(outputPath, content) {
const dir = path.dirname(outputPath);
if (dir && dir !== '.' && !fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(outputPath, content, 'utf8');
}
//# sourceMappingURL=graph-reporter.js.map