ln-644-dependency-graph-auditor
Original:🇺🇸 English
Translated
L3 Worker. Builds module dependency graph, detects transitive cycles (DFS), validates boundary rules (forbidden/allowed/required), calculates coupling metrics (Ca/Ce/I, CCD/NCCD). Adaptive architecture detection: custom rules > docs > auto-detect. Supports hybrid architectures.
7installs
Added on
NPX Install
npx skill4agent add levnikolaevich/claude-code-skills ln-644-dependency-graph-auditorTags
Translated version includes tags in frontmatterSKILL.md Content
View Translation Comparison →Paths: File paths (,shared/,references/) are relative to skills repo root. If not found at CWD, locate this SKILL.md directory and go up one level for repo root.../ln-*
Dependency Graph Auditor
L3 Worker that builds and analyzes the module dependency graph to enforce architectural boundaries.
Purpose & Scope
- Worker in ln-640 coordinator pipeline - invoked by ln-640-pattern-evolution-auditor
- Build module dependency graph from import statements (Python, TS/JS, C#, Java)
- Detect circular dependencies: pairwise (HIGH) + transitive via DFS (CRITICAL)
- Validate boundary rules: forbidden, allowed, required (per dependency-cruiser pattern)
- Calculate Robert C. Martin metrics (Ca, Ce, Instability) + Lakos aggregate (CCD, NCCD)
- Validate Stable Dependencies Principle (SDP)
- Support baseline/freeze for incremental legacy adoption (per ArchUnit FreezingArchRule)
- Adaptive: 3-tier architecture detection — custom rules > docs > auto-detect
Out of Scope (owned by other workers):
- I/O isolation violations (grep-based) -> ln-642-layer-boundary-auditor
- API contract violations -> ln-643-api-contract-auditor
- Code duplication -> ln-623-code-principles-auditor
Input (from ln-640)
- architecture_path: string # Path to docs/architecture.md
- codebase_root: string # Root directory to scan
# Domain-aware (optional, from coordinator)
- domain_mode: "global" | "domain-aware" # Default: "global"
- current_domain: string # e.g., "users", "billing" (only if domain-aware)
- scan_path: string # e.g., "src/users/" (only if domain-aware)
# Baseline (optional)
- update_baseline: boolean # If true, save current state as baselineWhen domain_mode="domain-aware": Use instead of for all Grep/Glob operations. Tag all findings with field.
scan_pathcodebase_rootdomainWorkflow
Phase 1: Discover Architecture (Adaptive)
MANDATORY READ: Load — use 3-Tier Priority Chain, Architecture Presets, Auto-Detection Heuristics.
references/dependency_rules.mdArchitecture detection uses 3-tier priority — explicit config wins over docs, docs win over auto-detection:
# Priority 1: Explicit project config
IF docs/project/dependency_rules.yaml exists:
Load custom rules (modules, forbidden, allowed, required)
SKIP preset detection
# Priority 2: Architecture documentation
ELIF docs/architecture.md exists:
Read Section 4.2 (modules, layers, architecture_type)
Read Section 6.4 (boundary rules, if defined)
Map documented layers to presets from dependency_rules.md
Apply preset rules, override with explicit rules from Section 6.4
# Priority 3: Auto-detection from directory structure
ELSE:
scan_root = scan_path IF domain_mode == "domain-aware" ELSE codebase_root
Run structure heuristics:
signals = {}
IF Glob("**/domain/**") AND Glob("**/infrastructure/**"):
signals["clean"] = HIGH
IF Glob("**/controllers/**") AND Glob("**/services/**") AND Glob("**/repositories/**"):
signals["layered"] = HIGH
IF Glob("**/features/*/") with internal structure:
signals["vertical"] = HIGH
IF Glob("**/adapters/**") AND Glob("**/ports/**"):
signals["hexagonal"] = HIGH
IF Glob("**/views/**") AND Glob("**/models/**"):
signals["mvc"] = HIGH
IF len(signals) == 0:
architecture_mode = "custom"
confidence = "LOW"
# Only check cycles + metrics, no boundary presets
ELIF len(signals) == 1:
architecture_mode = signals.keys()[0]
confidence = signals.values()[0]
Apply matching preset from dependency_rules.md
ELSE:
architecture_mode = "hybrid"
confidence = "MEDIUM"
# Identify zones, apply different presets per zone (see dependency_rules.md Hybrid section)
FOR EACH detected_style IN signals:
zone_path = identify_zone(detected_style)
zone_preset = load_preset(detected_style)
zones.append({path: zone_path, preset: zone_preset})
Add cross-zone rules: inner zones accessible, outer zones forbidden to depend on innerPhase 2: Build Dependency Graph
MANDATORY READ: Load — use Language Detection, Import Grep Patterns, Module Resolution Algorithm, Exclusion Lists.
references/import_patterns.mdscan_root = scan_path IF domain_mode == "domain-aware" ELSE codebase_root
# Step 1: Detect primary language
tech_stack = Read(docs/project/tech_stack.md) IF exists
ELSE detect from file extensions: Glob("**/*.py", "**/*.ts", "**/*.cs", "**/*.java", root=scan_root)
# Step 2: Extract imports per language
FOR EACH source_file IN Glob(language_glob_pattern, root=scan_root):
imports = []
# Python
IF language == "python":
from_imports = Grep("^from\s+([\w.]+)\s+import", source_file)
plain_imports = Grep("^import\s+([\w.]+)", source_file)
imports = from_imports + plain_imports
# TypeScript / JavaScript
ELIF language == "typescript" OR language == "javascript":
es6_imports = Grep("import\s+.*\s+from\s+['\"]([^'\"]+)['\"]", source_file)
require_imports = Grep("require\(['\"]([^'\"]+)['\"]\)", source_file)
imports = es6_imports + require_imports
# C#
ELIF language == "csharp":
using_imports = Grep("^using\s+([\w.]+);", source_file)
imports = using_imports
# Java
ELIF language == "java":
java_imports = Grep("^import\s+([\w.]+);", source_file)
imports = java_imports
# Step 3: Filter internal only (per import_patterns.md Exclusion Lists)
internal_imports = filter_internal(imports, scan_root)
# Step 4: Resolve to modules
FOR EACH imp IN internal_imports:
source_module = resolve_module(source_file, scan_root)
target_module = resolve_module(imp, scan_root)
IF source_module != target_module:
graph[source_module].add(target_module)Phase 3: Detect Cycles (ADP)
Per Robert C. Martin (Clean Architecture Ch14): "Allow no cycles in the component dependency graph."
# Pairwise cycles (A <-> B)
FOR EACH (A, B) WHERE B IN graph[A] AND A IN graph[B]:
cycles.append({
type: "pairwise",
path: [A, B, A],
severity: "HIGH",
fix: suggest_cycle_fix(A, B)
})
# Transitive cycles via DFS (A -> B -> C -> A)
visited = {}
rec_stack = {}
FUNCTION dfs(node, path):
visited[node] = true
rec_stack[node] = true
FOR EACH neighbor IN graph[node]:
IF NOT visited[neighbor]:
dfs(neighbor, path + [node])
ELIF rec_stack[neighbor]:
cycle_path = extract_cycle(path + [node], neighbor)
IF len(cycle_path) > 2: # Skip pairwise (already detected)
cycles.append({
type: "transitive",
path: cycle_path,
severity: "CRITICAL",
fix: suggest_cycle_fix_transitive(cycle_path)
})
rec_stack[node] = false
FOR EACH module IN graph:
IF NOT visited[module]:
dfs(module, [])
# Folder-level cycles (per dependency-cruiser pattern)
folder_graph = collapse_to_folders(graph)
Repeat DFS on folder_graph for folder-level cyclesCycle-breaking recommendations (from Clean Architecture Ch14):
- DIP — extract interface in depended-upon module, implement in depending module
- Extract Shared Component — move shared code to new module both depend on
- Domain Events / Message Bus — for cross-domain cycles, decouple via async communication
Phase 4: Validate Boundary Rules
# Load rules from Phase 1 discovery
# rules = {forbidden: [], allowed: [], required: []}
# Check FORBIDDEN rules
FOR EACH rule IN rules.forbidden:
FOR EACH edge (source -> target) IN graph:
IF matches(source, rule.from) AND matches(target, rule.to):
IF rule.cross AND same_group(source, target):
CONTINUE # cross=true means only cross-group violations
boundary_violations.append({
rule_type: "forbidden",
from: source,
to: target,
file: get_import_location(source, target),
severity: rule.severity,
reason: rule.reason
})
# Check ALLOWED rules (whitelist mode)
IF rules.allowed.length > 0:
FOR EACH edge (source -> target) IN graph:
allowed = false
FOR EACH rule IN rules.allowed:
IF matches(source, rule.from) AND matches(target, rule.to):
allowed = true
BREAK
IF NOT allowed:
boundary_violations.append({
rule_type: "not_in_allowed",
from: source,
to: target,
file: get_import_location(source, target),
severity: "MEDIUM",
reason: "Dependency not in allowed list"
})
# Check REQUIRED rules
FOR EACH rule IN rules.required:
FOR EACH module IN graph WHERE matches(module, rule.module):
has_required = false
FOR EACH dep IN graph[module]:
IF matches(dep, rule.must_depend_on):
has_required = true
BREAK
IF NOT has_required:
boundary_violations.append({
rule_type: "required_missing",
module: module,
missing: rule.must_depend_on,
severity: "MEDIUM",
reason: rule.reason
})Phase 5: Calculate Graph Metrics
MANDATORY READ: Load — use Metric Definitions, Thresholds per Layer, SDP Algorithm, Lakos Formulas.
references/graph_metrics.md# Per-module metrics (Robert C. Martin)
FOR EACH module IN graph:
Ce = len(graph[module]) # Efferent: outgoing
Ca = count(m for m in graph if module in graph[m]) # Afferent: incoming
I = Ce / (Ca + Ce) IF (Ca + Ce) > 0 ELSE 0 # Instability
metrics[module] = {Ca, Ce, I}
# SDP validation (Stable Dependencies Principle)
FOR EACH edge (A -> B) IN graph:
IF metrics[A].I < metrics[B].I:
# Stable module depends on less stable module — SDP violation
sdp_violations.append({
from: A, to: B,
I_from: metrics[A].I, I_to: metrics[B].I,
severity: "HIGH"
})
# Threshold checks (per graph_metrics.md, considering detected layer)
FOR EACH module IN metrics:
layer = get_layer(module) # From Phase 1 discovery
thresholds = get_thresholds(layer) # From graph_metrics.md
IF metrics[module].I > thresholds.max_instability:
findings.append({severity: thresholds.severity, issue: f"{module} instability {I} exceeds {thresholds.max_instability}"})
IF metrics[module].Ce > thresholds.max_ce:
findings.append({severity: "MEDIUM", issue: f"{module} efferent coupling {Ce} exceeds {thresholds.max_ce}"})
# Lakos aggregate metrics
CCD = 0
FOR EACH module IN graph:
DependsOn = count_transitive_deps(module, graph) + 1 # Including self
CCD += DependsOn
N = len(graph)
CCD_balanced = N * log2(N) # CCD of balanced binary tree with N nodes
NCCD = CCD / CCD_balanced IF CCD_balanced > 0 ELSE 0
IF NCCD > 1.5:
findings.append({severity: "MEDIUM", issue: f"Graph complexity (NCCD={NCCD:.2f}) exceeds balanced tree threshold (1.5)"})Phase 6: Baseline Support
Inspired by ArchUnit FreezingArchRule — enables incremental adoption in legacy projects.
baseline_path = docs/project/dependency_baseline.json
IF file_exists(baseline_path):
known = load_json(baseline_path)
current = serialize_violations(cycles + boundary_violations + sdp_violations)
new_violations = current - known
resolved_violations = known - current
# Report only NEW violations as findings
active_findings = new_violations
baseline_info = {new: len(new_violations), resolved: len(resolved_violations), frozen: len(known - resolved_violations)}
IF input.update_baseline == true:
save_json(baseline_path, current)
ELSE:
# First run — report all
active_findings = all_violations
baseline_info = {new: len(all_violations), resolved: 0, frozen: 0}
# Suggest: output note "Run with update_baseline=true to freeze current violations"Phase 7: Score + Return
MANDATORY READ: Load for unified scoring formula.
shared/references/audit_scoring.mdpenalty = (critical * 2.0) + (high * 1.0) + (medium * 0.5) + (low * 0.2)
score = max(0, 10 - penalty)Note: When baseline is active, penalty is calculated from only (new violations), not frozen ones.
active_findingsjson
{
"category": "Dependency Graph",
"score": 6.5,
"total_issues": 8,
"critical": 1, "high": 3, "medium": 3, "low": 1,
"architecture": {
"detected": "hybrid",
"confidence": "MEDIUM",
"zones": [
{"path": "src/core/", "preset": "layered"},
{"path": "src/features/", "preset": "vertical"}
]
},
"graph_stats": {
"modules_analyzed": 12,
"edges": 34,
"cycles_detected": 2,
"ccd": 42,
"nccd": 1.3
},
"cycles": [
{
"type": "transitive",
"path": ["auth", "billing", "notify", "auth"],
"severity": "CRITICAL",
"fix": "Apply DIP: extract interface in auth, implement in notify"
}
],
"boundary_violations": [
{
"rule_type": "forbidden",
"from": "domain",
"to": "infrastructure",
"file": "domain/user.py:12",
"severity": "CRITICAL",
"reason": "Domain must not depend on infrastructure"
}
],
"sdp_violations": [
{
"from": "domain",
"to": "utils",
"I_from": 0.2,
"I_to": 0.8,
"severity": "HIGH"
}
],
"metrics": {
"users": {"Ca": 3, "Ce": 5, "I": 0.625},
"billing": {"Ca": 1, "Ce": 7, "I": 0.875}
},
"baseline": {"new": 3, "resolved": 1, "frozen": 4},
"findings": [],
"domain": "users",
"scan_path": "src/users/"
}Critical Rules
- Adaptive architecture — never assume one style; detect from project structure or docs
- 3-tier priority — custom rules > architecture.md > auto-detection
- Hybrid support — projects mix styles; apply different presets per zone
- Custom = safe mode — if no pattern detected, only check cycles + metrics (no false boundary violations)
- Internal only — exclude stdlib, third-party from graph (only project modules)
- Baseline mode — when baseline exists, report only NEW violations
- Cycle fixes — always provide actionable recommendation (DIP, Extract Shared, Domain Events)
- File + line — always provide exact import location for violations
Definition of Done
- Architecture discovered (adaptive 3-tier detection applied)
- Dependency graph built from import statements (internal modules only)
- Circular dependencies detected (pairwise + transitive DFS + folder-level)
- Boundary rules validated (forbidden + allowed + required)
- Metrics calculated (Ca, Ce, I per module + CCD, NCCD aggregate)
- SDP validated (stable modules not depending on unstable)
- Baseline applied if exists (only new violations reported)
- If domain-aware: all Grep/Glob scoped to scan_path, findings tagged with domain
- Score calculated per audit_scoring.md
- Result returned to coordinator
Reference Files
- Boundary rules & presets:
references/dependency_rules.md - Metrics & thresholds:
references/graph_metrics.md - Import patterns:
references/import_patterns.md - Scoring algorithm:
shared/references/audit_scoring.md
Version: 1.0.0
Last Updated: 2026-02-11