From 60c1e7290420c4c1214dc968a6fcc3ae2c2fb1d4 Mon Sep 17 00:00:00 2001 From: millareskenneth Date: Sun, 7 Jun 2026 23:56:29 +0800 Subject: [PATCH] feat: implement architecture validation and update roadmap - Add src/validator.ts to enforce layer-based dependency rules - Add --rules CLI flag for automated architecture governance - Integrate violations into terminal output and interactive HTML report - Add unit tests for the architecture validation engine - Update README.md and project-description.md to reflect project progress --- README.md | 4 +- project-description.md | 23 +- src/analyze.ts | 18 + src/cli.ts | 9 +- src/graph.ts | 14 +- src/html-report.ts | 907 +++++++++++++++++++++-------------------- src/index.ts | 4 +- src/renderer.ts | 19 + src/validator.test.ts | 107 +++++ src/validator.ts | 95 +++++ 10 files changed, 751 insertions(+), 449 deletions(-) create mode 100644 src/validator.test.ts create mode 100644 src/validator.ts diff --git a/README.md b/README.md index eae8536..fd4faea 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,8 @@ TypeScript is supported first because the TypeScript Compiler API provides relia ## Roadmap - [x] **Phase 1 — MVP** · File scanning, AST parsing, dependency extraction, terminal output -- [ ] **Phase 2 — Interactive Visualization** · React Flow integration, search, zoom/pan, node inspection -- [ ] **Phase 3 — Advanced Analysis** · Circular dependency detection, dead code detection, impact analysis +- [x] **Phase 2 — Interactive Visualization** · Cytoscape.js integration, search, zoom/pan, node inspection, impact analysis +- [ ] **Phase 3 — Advanced Analysis** · Circular dependency detection, dead code detection, architecture validation - [ ] **Phase 4 — Ecosystem** · VS Code extension, GitHub Action, CI/CD integration --- diff --git a/project-description.md b/project-description.md index 74bdadf..b3b461b 100644 --- a/project-description.md +++ b/project-description.md @@ -253,10 +253,10 @@ Phase 1 — MVPPhase 1 — MVP GoalGoal Dependency extraction and CLI output. ## Features: -File scanning -AST parsing -Dependency extraction -Terminal visualization +[x] File scanning +[x] AST parsing +[x] Dependency extraction +[x] Terminal visualization ## Estimated Duration: 4 weeks @@ -264,17 +264,18 @@ Phase 2 — Interactive VisualizationPhase 2 — Interactive Visualization GoalGoal Generate interactive HTML reports. ## Features: -React Flow integration -Search functionality -Zoom and pan -Node inspection +[x] Cytoscape.js integration +[x] Search functionality +[x] Zoom and pan +[x] Node inspection +[x] Impact analysis ## Estimated Duration: 4 weeks Phase 3 — Advanced AnalysisPhase 3 — Advanced Analysis ## Features: -Circular dependency detection -Dead code detection -Impact analysis +[x] Circular dependency detection +[x] Dead code detection +[x] Architecture validation ## Estimated Duration: 4 weeks Phase 4 — EcosystemPhase 4 — Ecosystem diff --git a/src/analyze.ts b/src/analyze.ts index 8b13594..03ba3fe 100644 --- a/src/analyze.ts +++ b/src/analyze.ts @@ -1,9 +1,12 @@ import { resolve } from 'node:path' +import { readFile } from 'node:fs/promises' import { scanFiles } from './scanner.js' import { parseImports } from './parser.js' import { extractDependencies } from './extractor.js' import { buildGraph } from './graph.js' +import { validateArchitecture } from './validator.js' import type { DependencyGraph } from './graph.js' +import type { ArchitectureRules } from './validator.js' export interface AnalyzeOptions { /** Additional directories to ignore during file scanning */ @@ -19,6 +22,8 @@ export interface AnalyzeOptions { * patterns you provide in `entryPatterns` are active. */ replaceDefaultEntryPatterns?: boolean + /** Path to architectural rules JSON file */ + rulesPath?: string } /** @@ -27,6 +32,7 @@ export interface AnalyzeOptions { * 2. Parse imports from each file * 3. Resolve relative imports to absolute paths * 4. Build and classify the dependency graph + * 5. Validate architecture (optional) */ export async function analyze(directory: string, options: AnalyzeOptions = {}): Promise { const rootDir = resolve(directory) @@ -44,5 +50,17 @@ export async function analyze(directory: string, options: AnalyzeOptions = {}): replaceDefaultEntryPatterns: options.replaceDefaultEntryPatterns, }) + // Architecture Validation + if (options.rulesPath) { + try { + const content = await readFile(resolve(options.rulesPath), 'utf8') + const rules = JSON.parse(content) as ArchitectureRules + graph.violations = validateArchitecture(graph, rules) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + throw new Error(`Failed to load or parse architecture rules from "${options.rulesPath}": ${msg}`) + } + } + return graph } diff --git a/src/cli.ts b/src/cli.ts index 839ec0a..56f277c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,6 +21,7 @@ program .option('--port ', 'port for the live server (default: 4242)', '4242') .option('--dead-code', 'report dead code (unreferenced + isolated files), exit 1 if any found') .option('--impact ', 'show all files that depend on and all files it depends on') + .option('--rules ', 'path to architecture rules JSON file') .option( '--entry ', 'comma-separated glob patterns that mark extra entry points (merged with built-in defaults)', @@ -41,6 +42,7 @@ program port?: string deadCode?: boolean impact?: string + rules?: string entry?: string showEntryPatterns?: boolean showExternal?: boolean @@ -73,7 +75,11 @@ program : [] try { - const graph = await analyze(directory, { ignore, entryPatterns }) + const graph = await analyze(directory, { + ignore, + entryPatterns, + rulesPath: options.rules, + }) if (options.save) { const { htmlFile, jsonFile } = await generateHtmlReport(graph, { outDir: options.outDir }) @@ -101,6 +107,7 @@ program renderImpact(result) } else { renderToTerminal(graph, { showExternal: options.showExternal }) + if (graph.violations.length > 0) process.exit(1) } } catch (err) { const message = err instanceof Error ? err.message : String(err) diff --git a/src/graph.ts b/src/graph.ts index fc96a18..d3c3dcd 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -36,6 +36,16 @@ export interface UnreferencedFile { matchedPattern?: string } +export interface ArchitectureViolation { + from: string // absolute path + fromLabel: string // relative path + to: string // absolute path + toLabel: string // relative path + fromLayer: string + toLayer: string + ruleType: 'disallow' | 'allowOnly' +} + export interface DependencyGraph { nodes: Map edges: GraphEdge[] @@ -47,6 +57,8 @@ export interface DependencyGraph { unreferencedFiles: UnreferencedFile[] /** Circular dependency chains */ cycles: string[][] + /** Architecture rule violations */ + violations: ArchitectureViolation[] /** * The entry-point glob patterns that were used during classification. * Included so callers can display which patterns are active. @@ -208,7 +220,7 @@ export function buildGraph( const cycles = detectCycles(nodes, edges) - return { nodes, edges, unreferencedFiles, cycles, entryPatterns: activePatterns } + return { nodes, edges, unreferencedFiles, cycles, violations: [], entryPatterns: activePatterns } } // --------------------------------------------------------------------------- diff --git a/src/html-report.ts b/src/html-report.ts index bcb9d51..c0ede25 100644 --- a/src/html-report.ts +++ b/src/html-report.ts @@ -1,6 +1,6 @@ import { mkdir, writeFile } from 'node:fs/promises' import { resolve, join } from 'node:path' -import type { DependencyGraph } from './graph.js' +import type { DependencyGraph, ArchitectureViolation } from './graph.js' export interface HtmlReportOptions { outDir?: string @@ -28,6 +28,7 @@ interface SerializedGraph { edges: Array<{ from: string; to: string; specifier: string }> unreferencedFiles: SerializedUnreferencedFile[] cycles: string[][] + violations: ArchitectureViolation[] } export function serializeGraph(graph: DependencyGraph): SerializedGraph { @@ -42,6 +43,7 @@ export function serializeGraph(graph: DependencyGraph): SerializedGraph { id: f.id, label: f.label, kind: f.kind, matchedPattern: f.matchedPattern, })), cycles: graph.cycles, + violations: graph.violations, } } @@ -127,13 +129,14 @@ export function buildHtml(title: string, graphJson: string): string { .dep-item { font-size: 12px; padding: 3px 0; color: #94a3b8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .dep-item span { color: #e2e8f0; } .dep-section h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: #64748b; margin: 8px 0 4px; } - /* list sections (dead code, cycles, entries) */ + /* list sections (dead code, cycles, entries, violations) */ .list-section { display: flex; flex-direction: column; gap: 5px; } .list-item { font-size: 11px; border-radius: 6px; padding: 5px 8px; line-height: 1.5; word-break: break-all; } .list-item-dead-unreferenced { background: #1e0a3d; border: 1px solid #5b21b6; } .list-item-dead-isolated { background: #2d0a0a; border: 1px solid #7f1d1d; } .list-item-entry { background: #0a1f0f; border: 1px solid #166534; color: #86efac; } .list-item-cycle { background: #2d0a0a; border: 1px solid #7f1d1d; } + .list-item-violation { background: #2d0a0a; border: 1px solid #7f1d1d; } .item-tag { font-size: 10px; opacity: 0.55; margin-left: 6px; } #btn-reset { padding: 7px 12px; border-radius: 6px; border: 1px solid #3a3f5c; @@ -153,491 +156,529 @@ export function buildHtml(title: string, graphJson: string): string { -
-

codeviz

- - 0 files - 0 deps - 0 dead - 0 entries - 0 cycles -
- -
-
- -
- - -
-

Summary

-
Files scanned
-
Internal deps
-
Entry points
-
Dead code
-
Circular deps
-
-
normal
-
entry
-
unreferenced
-
isolated
-
cycle
-
npm
-
-
- - -
-
-
+
+

${escHtml(title)}

+ + + + + + + +
-
- - - +
+
+ +
+ + +
+

Summary

+
Files scanned
+
Internal deps
+
Entry points
+
Dead code
+
Circular deps
+
Violations
+
+
normal
+
entry
+
unreferenced
+
isolated
+
cycle/violation
+
npm
+
-
-

Files that will break if this one changes

-
+ +
+
+
+ +
+ + + +
+ +
+

Files that will break if this one changes

+
+
+
+

Files this transitively imports

+
+
+
+

Imports directly

+

Imported directly by

+
-
-

Files this transitively imports

-
+ + + -
-

Imports directly

-

Imported directly by

+ + + -
- - + + - - + + - - - -
-
-
+
- + // ── Helpers ─────────────────────────────────────────────────────────── + function escHtml(s) { + if (!s) return ''; + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + })(); + `; } function escHtml(s: string): string { - return s.replace(/&/g, '&').replace(//g, '>') + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - +/** + * Generates the HTML report and writes it to disk. + */ export async function generateHtmlReport( graph: DependencyGraph, options: HtmlReportOptions = {}, ): Promise { const outDir = resolve(options.outDir ?? 'report') - const title = options.title ?? 'codeviz — Dependency Graph' + const title = options.title ?? 'codeviz — Dependency Graph' await mkdir(outDir, { recursive: true }) - const graphJson = JSON.stringify(serializeGraph(graph), null, 2) - const jsonFile = join(outDir, 'graph.json') - const htmlFile = join(outDir, 'index.html') + const graphJson = JSON.stringify(serializeGraph(graph)) + const html = buildHtml(title, graphJson) + + const htmlFile = join(outDir, 'index.html') + const jsonFile = join(outDir, 'graph.json') + await writeFile(htmlFile, html, 'utf8') await writeFile(jsonFile, graphJson, 'utf8') - await writeFile(htmlFile, buildHtml(title, graphJson), 'utf8') return { htmlFile, jsonFile } } diff --git a/src/index.ts b/src/index.ts index 1f2c3fc..23143c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,13 +9,15 @@ export { renderToTerminal, renderImpact } from './renderer.js' export { generateHtmlReport } from './html-report.js' export { serveGraph } from './serve.js' export { computeImpact, resolveTargetInGraph } from './impact.js' +export { validateArchitecture } from './validator.js' export type { AnalyzeOptions } from './analyze.js' export type { ScanOptions } from './scanner.js' export type { ImportRecord } from './parser.js' export type { Dependency } from './extractor.js' -export type { DependencyGraph, GraphNode, GraphEdge, UnreferencedFile, UnreferencedKind, BuildGraphOptions } from './graph.js' +export type { DependencyGraph, GraphNode, GraphEdge, UnreferencedFile, UnreferencedKind, BuildGraphOptions, ArchitectureViolation } from './graph.js' export type { RenderOptions } from './renderer.js' export type { HtmlReportOptions, HtmlReportResult } from './html-report.js' export type { ServeOptions } from './serve.js' export type { ImpactResult, ImpactFile } from './impact.js' +export type { ArchitectureRules, ArchitectureRule } from './validator.js' diff --git a/src/renderer.ts b/src/renderer.ts index 50beb96..66a4116 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -64,6 +64,7 @@ export function renderToTerminal(graph: DependencyGraph, options: RenderOptions console.log(` ${cyan('Entry points')} ${entryFiles.length}`) console.log(` ${cyan('Dead code')} ${deadFiles.length === 0 ? green('none') : red(String(deadFiles.length))}`) console.log(` ${cyan('Circular deps')} ${graph.cycles.length === 0 ? green('none') : red(String(graph.cycles.length))}`) + console.log(` ${cyan('Arch violations')} ${graph.violations.length === 0 ? green('none') : red(String(graph.violations.length))}`) // ── Dead-code-only mode ──────────────────────────────────────────────────── if (deadCodeOnly) { @@ -136,6 +137,24 @@ export function renderToTerminal(graph: DependencyGraph, options: RenderOptions console.log(` ${green('✓')} No circular dependencies detected.`) } + // ── Architecture violations ──────────────────────────────────────────────── + if (graph.violations.length > 0) { + console.log('') + console.log(bold(' Architecture Violations')) + console.log(hr()) + for (const v of graph.violations) { + const ruleDesc = v.ruleType === 'disallow' + ? `not allowed to depend on layer ${bold(v.toLayer)}` + : `only allowed to depend on specific layers, but found ${bold(v.toLayer)}` + + console.log(` ${red('✖')} ${cyan(v.fromLabel)} (${v.fromLayer})`) + console.log(` ${gray('└─')} ${ruleDesc}: ${red(v.toLabel)}`) + } + } else { + console.log('') + console.log(` ${green('✓')} No architecture violations detected.`) + } + console.log('') console.log(hr('═')) console.log('') diff --git a/src/validator.test.ts b/src/validator.test.ts new file mode 100644 index 0000000..874632c --- /dev/null +++ b/src/validator.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest' +import { buildGraph } from './graph.js' +import { validateArchitecture } from './validator.js' +import type { Dependency } from './extractor.js' + +describe('validateArchitecture', () => { + const rootDir = '/root' + const allFiles = [ + '/root/src/ui/Button.ts', + '/root/src/ui/Modal.ts', + '/root/src/api/client.ts', + '/root/src/api/auth.ts', + '/root/src/utils/logger.ts', + ] + + const deps: Dependency[] = [ + { from: '/root/src/ui/Button.ts', to: '/root/src/api/client.ts', specifier: '../api/client', external: false }, + { from: '/root/src/api/client.ts', to: '/root/src/utils/logger.ts', specifier: '../utils/logger', external: false }, + ] + + const graph = buildGraph(deps, allFiles, rootDir) + + it('should detect disallowed dependencies', () => { + const rules = { + layers: { + ui: 'src/ui/**', + api: 'src/api/**', + }, + rules: [ + { from: 'ui', disallow: ['api'] }, + ], + } + + const violations = validateArchitecture(graph, rules) + expect(violations).toHaveLength(1) + expect(violations[0]).toMatchObject({ + fromLayer: 'ui', + toLayer: 'api', + ruleType: 'disallow', + fromLabel: 'src/ui/Button.ts', + toLabel: 'src/api/client.ts', + }) + }) + + it('should detect violations of allowOnly rules', () => { + const rules = { + layers: { + api: 'src/api/**', + utils: 'src/utils/**', + ui: 'src/ui/**', + }, + rules: [ + { from: 'api', allowOnly: ['ui'] }, // api is NOT allowed to depend on utils + ], + } + + const violations = validateArchitecture(graph, rules) + expect(violations).toHaveLength(1) + expect(violations[0]).toMatchObject({ + fromLayer: 'api', + toLayer: 'utils', + ruleType: 'allowOnly', + }) + }) + + it('should ignore dependencies to external packages', () => { + const depsWithExternal: Dependency[] = [ + ...deps, + { from: '/root/src/ui/Button.ts', to: undefined, specifier: 'react', external: true }, + ] + const g = buildGraph(depsWithExternal, allFiles, rootDir) + + const rules = { + layers: { + ui: 'src/ui/**', + api: 'src/api/**', + }, + rules: [ + { from: 'ui', disallow: ['api'] }, + ], + } + + const violations = validateArchitecture(g, rules) + // Should still only have 1 violation (to api), ignoring 'react' + expect(violations).toHaveLength(1) + expect(violations[0].toLayer).toBe('api') + }) + + it('should support array of patterns for layers', () => { + const rules = { + layers: { + source: ['src/ui/**', 'src/api/**'], + utils: 'src/utils/**', + }, + rules: [ + { from: 'source', disallow: ['utils'] }, + ], + } + + const violations = validateArchitecture(graph, rules) + // src/ui/Button -> src/api/client (ignored, both in 'source') + // src/api/client -> src/utils/logger (violation) + expect(violations).toHaveLength(1) + expect(violations[0].fromLayer).toBe('source') + expect(violations[0].toLayer).toBe('utils') + }) +}) diff --git a/src/validator.ts b/src/validator.ts new file mode 100644 index 0000000..e6229da --- /dev/null +++ b/src/validator.ts @@ -0,0 +1,95 @@ +import { minimatch } from 'minimatch' +import type { DependencyGraph, ArchitectureViolation } from './graph.js' + +export interface ArchitectureRules { + /** + * Defines logical layers as glob patterns. + * Key: layer name, Value: glob pattern (or array of glob patterns) + */ + layers: Record + /** + * Rules that prohibit dependencies between layers. + */ + rules: ArchitectureRule[] +} + +export interface ArchitectureRule { + /** The source layer name */ + from: string + /** Layer names that 'from' is NOT allowed to depend on */ + disallow?: string[] + /** Layer names that 'from' is ONLY allowed to depend on (exclusive) */ + allowOnly?: string[] +} + +/** + * Validates a dependency graph against architectural rules. + */ +export function validateArchitecture( + graph: DependencyGraph, + rules: ArchitectureRules, +): ArchitectureViolation[] { + const violations: ArchitectureViolation[] = [] + + // 1. Map every node to a layer (if any) + const nodeToLayer = new Map() + + for (const [id, node] of graph.nodes) { + if (node.external) continue + + for (const [layerName, pattern] of Object.entries(rules.layers)) { + const patterns = Array.isArray(pattern) ? pattern : [pattern] + const isMatch = patterns.some((p) => minimatch(node.label, p, { dot: true })) + + if (isMatch) { + // If a node matches multiple layers, the first one wins (or we could support multiple) + nodeToLayer.set(id, layerName) + break + } + } + } + + // 2. Check edges + for (const edge of graph.edges) { + if (edge.to.startsWith('external:')) continue + + const fromLayer = nodeToLayer.get(edge.from) + const toLayer = nodeToLayer.get(edge.to) + + // Only validate if both ends belong to a defined layer + if (!fromLayer || !toLayer) continue + + // Find rules for the 'from' layer + const layerRules = rules.rules.filter((r) => r.from === fromLayer) + + for (const rule of layerRules) { + // Check disallow + if (rule.disallow && rule.disallow.includes(toLayer)) { + violations.push({ + from: edge.from, + fromLabel: graph.nodes.get(edge.from)?.label ?? edge.from, + to: edge.to, + toLabel: graph.nodes.get(edge.to)?.label ?? edge.to, + fromLayer, + toLayer, + ruleType: 'disallow', + }) + } + + // Check allowOnly + if (rule.allowOnly && !rule.allowOnly.includes(toLayer) && fromLayer !== toLayer) { + violations.push({ + from: edge.from, + fromLabel: graph.nodes.get(edge.from)?.label ?? edge.from, + to: edge.to, + toLabel: graph.nodes.get(edge.to)?.label ?? edge.to, + fromLayer, + toLayer, + ruleType: 'allowOnly', + }) + } + } + } + + return violations +}