UNPKG

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
"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 `<!DOCTYPE html> <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, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;'); } 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