feat: implement all P1/P2/P3/P4 improvements from issue backlog
Some checks failed
CI / Check / Test (push) Failing after 1m33s
Architecture Docs / Generate diagrams (push) Successful in 3m21s

P1 correctness:
- filter test files by default (--include-tests to opt in)
- per-module diagrams show cross-module dependency arrows
- qualified type names (Module::TypeName) fix false edges from duplicate names

P2 output richness:
- method parameter types and return types in class diagrams (Rust + Python)
- Python pyproject.toml project analyzer (--level project for monorepos)

P3 unique value:
- boundary rules in archlens.toml ([rules] allow/deny, --strict enforcement)

P4 nice to have:
- dependency weight labels on module arrows (--no-weights to disable)
- --watch mode with 500ms debounce
- D2 renderer adapter (--format d2)
- interactive self-contained HTML viewer (--format html)
- git-aware incremental analysis (--since <ref>)
This commit is contained in:
2026-06-17 09:50:50 +02:00
parent 27197062eb
commit fdd85011a4
42 changed files with 2767 additions and 92 deletions

View File

@@ -0,0 +1,240 @@
use std::collections::HashMap;
use serde::Serialize;
use archlens_domain::{
CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile, ports::DiagramRenderer,
};
pub struct HtmlRenderer;
impl Default for HtmlRenderer {
fn default() -> Self {
Self::new()
}
}
impl HtmlRenderer {
pub fn new() -> Self {
Self
}
}
#[derive(Serialize)]
struct GraphData {
nodes: Vec<NodeData>,
edges: Vec<EdgeData>,
}
#[derive(Serialize)]
struct NodeData {
id: String,
label: String,
module: String,
kind: String,
fields: Vec<String>,
methods: Vec<String>,
}
#[derive(Serialize)]
struct EdgeData {
source: String,
target: String,
kind: String,
}
impl DiagramRenderer for HtmlRenderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError> {
// Build graph data
let mut id_map: HashMap<String, String> = HashMap::new();
let mut nodes = Vec::new();
for (i, el) in graph.elements().iter().enumerate() {
let id = format!("n{i}");
id_map.insert(el.qualified_name().to_string(), id.clone());
id_map.insert(el.name().to_string(), id.clone());
nodes.push(NodeData {
id,
label: el.name().to_string(),
module: el
.module()
.map(|m| m.as_str().to_string())
.unwrap_or_default(),
kind: format!("{:?}", el.kind()),
fields: el.fields().to_vec(),
methods: el.methods().to_vec(),
});
}
let edges = graph
.relationships()
.iter()
.filter(|r| r.kind() != RelationshipKind::Import)
.filter_map(|r| {
let src = id_map.get(r.source())?;
let tgt = id_map.get(r.target())?;
Some(EdgeData {
source: src.clone(),
target: tgt.clone(),
kind: format!("{:?}", r.kind()),
})
})
.collect();
let data = GraphData { nodes, edges };
let json =
serde_json::to_string(&data).map_err(|e| DomainError::ConfigError(e.to_string()))?;
let html = build_html(&json);
let file = RenderedFile::new("diagram.html", &html)?;
Ok(RenderOutput::single(file))
}
}
fn build_html(graph_json: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Architecture Diagram</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: sans-serif; background: #1a1a2e; color: #eee; display: flex; height: 100vh; }}
#sidebar {{ width: 280px; background: #16213e; padding: 1rem; overflow-y: auto; border-right: 1px solid #0f3460; }}
#cy {{ flex: 1; }}
h2 {{ color: #e94560; margin-bottom: 0.5rem; font-size: 1rem; }}
#detail {{ padding: 0.5rem 0; font-size: 0.85rem; }}
.member {{ padding: 2px 0; color: #aaa; }}
</style>
</head>
<body>
<div id="sidebar">
<h2>Architecture Diagram</h2>
<div id="detail"><p>Click a node to see details.</p></div>
</div>
<div id="cy"></div>
<script>
const GRAPH = {graph_json};
// Inline minimal Cytoscape-compatible renderer using Canvas API
(function() {{
const canvas = document.createElement('canvas');
const container = document.getElementById('cy');
canvas.style.width = '100%';
canvas.style.height = '100%';
container.appendChild(canvas);
const detail = document.getElementById('detail');
function resize() {{
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
draw();
}}
// Group nodes by module
const modules = {{}};
GRAPH.nodes.forEach(n => {{
const m = n.module || '(ungrouped)';
if (!modules[m]) modules[m] = [];
modules[m].push(n);
}});
// Layout: arrange modules in a grid, nodes within each module in a column
const positions = {{}};
const modNames = Object.keys(modules);
const cols = Math.ceil(Math.sqrt(modNames.length));
const cellW = 220, cellH = 200, pad = 60;
modNames.forEach((mod, mi) => {{
const col = mi % cols, row = Math.floor(mi / cols);
const bx = pad + col * (cellW + pad);
const by = pad + row * (cellH + pad);
modules[mod].forEach((n, ni) => {{
positions[n.id] = {{
x: bx + 20 + (ni % 2) * 90,
y: by + 30 + Math.floor(ni / 2) * 50
}};
}});
}});
let selected = null;
const nodeRadius = 18;
function draw() {{
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw module backgrounds
modNames.forEach((mod, mi) => {{
const col = mi % cols, row = Math.floor(mi / cols);
const bx = pad / 2 + col * (cellW + pad);
const by = pad / 2 + row * (cellH + pad);
ctx.fillStyle = 'rgba(15,52,96,0.4)';
ctx.fillRect(bx, by, cellW + pad / 2, cellH + pad / 2);
ctx.fillStyle = '#4fc3f7';
ctx.font = '11px sans-serif';
ctx.fillText(mod, bx + 6, by + 14);
}});
// Draw edges
GRAPH.edges.forEach(e => {{
const sp = positions[e.source], tp = positions[e.target];
if (!sp || !tp) return;
ctx.strokeStyle = e.kind === 'Inheritance' ? '#e94560' : '#aaa';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(sp.x, sp.y);
ctx.lineTo(tp.x, tp.y);
ctx.stroke();
}});
// Draw nodes
GRAPH.nodes.forEach(n => {{
const p = positions[n.id];
if (!p) return;
ctx.fillStyle = selected && selected.id === n.id ? '#e94560' : '#0f3460';
ctx.beginPath();
ctx.arc(p.x, p.y, nodeRadius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#eee';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(n.label.substring(0, 12), p.x, p.y + 4);
}});
}}
canvas.addEventListener('click', e => {{
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
selected = null;
for (const n of GRAPH.nodes) {{
const p = positions[n.id];
if (!p) continue;
const dx = mx - p.x, dy = my - p.y;
if (dx * dx + dy * dy < nodeRadius * nodeRadius) {{
selected = n;
break;
}}
}}
if (selected) {{
detail.innerHTML = `<strong>${{selected.label}}</strong><br><em>${{selected.module}}</em>` +
(selected.fields.length ? '<br><b>Fields:</b><br>' + selected.fields.map(f => `<div class="member">${{f}}</div>`).join('') : '') +
(selected.methods.length ? '<br><b>Methods:</b><br>' + selected.methods.map(m => `<div class="member">${{m}}</div>`).join('') : '');
}} else {{
detail.innerHTML = '<p>Click a node to see details.</p>';
}}
draw();
}});
window.addEventListener('resize', resize);
resize();
}})();
</script>
</body>
</html>"#,
graph_json = graph_json
)
}

View File

@@ -0,0 +1,2 @@
mod html_renderer;
pub use html_renderer::HtmlRenderer;