Skip to content

Commit 69c0812

Browse files
authored
Merge pull request #4 from akhundMurad/feat/history
feat(cli): add `history trends` with ASCII charts and optional matplotlib export
2 parents 08b6d76 + a4aeaa4 commit 69c0812

10 files changed

Lines changed: 1598 additions & 2 deletions

File tree

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<p align="center">
2-
<img src="assets/logo-ascii.png" alt="Pacta" width="400">
2+
<img src="https://raw.githubusercontent.com/akhundMurad/pacta/main/assets/logo-ascii.png" alt="Pacta" width="400">
33
</p>
44

55
<p align="center">
@@ -46,6 +46,7 @@ Codebases rot. The "clean architecture" you designed becomes spaghetti after a f
4646
- **Baseline mode** — fail only on *new* violations, not legacy debt
4747
- **Snapshots** — version your architecture like code
4848
- **History tracking** — view architecture evolution over time
49+
- **Trend analysis** — track violations, nodes, edges over time with charts
4950

5051
## Quick example
5152

@@ -121,10 +122,20 @@ a1b2c3d4 2025-01-22 abc1234 main 42 nodes 87 edges 2 violations (latest)
121122
e5f6g7h8 2025-01-20 def5678 main 42 nodes 85 edges 4 violations
122123
...
123124
125+
# View trends over time
126+
$ pacta history trends . --metric violations
127+
128+
# Export chart as image (requires pacta[viz])
129+
$ pacta history trends . --output trends.png
130+
124131
# Export for external tools
125132
pacta history export --format json > history.json
126133
```
127134

135+
<p align="center">
136+
<img src="https://raw.githubusercontent.com/akhundMurad/pacta/main/assets/trends-example.png" alt="Trends Chart" width="600">
137+
</p>
138+
128139
## Docs
129140

130141
- [CLI Reference](https://akhundmurad.github.io/pacta/cli/)
@@ -135,6 +146,7 @@ pacta history export --format json > history.json
135146

136147
- [x] Open-source CLI and analysis engine
137148
- [x] Content-addressed snapshots with history tracking
149+
- [x] Trend analysis with chart export
138150
- [ ] Architecture visualization (Mermaid, D2)
139151
- [ ] Health metrics (drift score, instability)
140152
- [ ] Proprietary hosted service with:

assets/trends-example.png

68.4 KB
Loading

docs/cli.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,95 @@ pacta history export . -o export.json
206206
}
207207
```
208208

209+
## history trends
210+
211+
Show metric trends over time with ASCII charts or export as images.
212+
213+
```bash
214+
pacta history trends [PATH] [OPTIONS]
215+
```
216+
217+
**Options:**
218+
219+
| Option | Default | Description |
220+
|--------|---------|-------------|
221+
| `--metric {violations,nodes,edges,density}` | `violations` | Metric to track |
222+
| `--last N` | - | Show only last N entries |
223+
| `--since DATE` | - | Show entries since date (ISO-8601) |
224+
| `--branch NAME` | - | Filter by branch name |
225+
| `--width N` | `60` | Chart width in characters |
226+
| `--format {text,json}` | `text` | Output format |
227+
| `-o, --output FILE` | - | Export chart as image (PNG/SVG) |
228+
229+
**Metrics:**
230+
231+
| Metric | Description |
232+
|--------|-------------|
233+
| `violations` | Total violation count (default) |
234+
| `nodes` | Architecture component count |
235+
| `edges` | Dependency count |
236+
| `density` | Coupling ratio (edges/nodes) |
237+
238+
**Examples:**
239+
240+
```bash
241+
# Show violation trends (default)
242+
pacta history trends .
243+
244+
# Show node count trends
245+
pacta history trends . --metric nodes
246+
247+
# Show density trends (edges/nodes ratio)
248+
pacta history trends . --metric density
249+
250+
# Filter by branch and limit
251+
pacta history trends . --branch main --last 10
252+
253+
# JSON output for scripting
254+
pacta history trends . --format json
255+
256+
# Export as PNG image (requires pacta[viz])
257+
pacta history trends . --output trends.png
258+
259+
# Export as SVG for docs/presentations
260+
pacta history trends . --metric violations --output violations.svg
261+
```
262+
263+
**Image Export:**
264+
265+
To export charts as images, install the visualization extras:
266+
267+
```bash
268+
pip install pacta[viz]
269+
```
270+
271+
This adds matplotlib support for PNG, SVG, and PDF export.
272+
273+
![Trends Example](https://raw.githubusercontent.com/akhundMurad/pacta/main/assets/trends-example.png)
274+
275+
**Example output:**
276+
277+
```
278+
Violations Trend (5 entries)
279+
============================
280+
281+
5 | *
282+
4 | * *
283+
3 | *
284+
2 | *
285+
1 |
286+
0 |
287+
+--------------------
288+
Jan 15 Jan 22
289+
290+
Trend: Improving (-3 over period)
291+
First: 4 violations (Jan 15)
292+
Last: 2 violations (Jan 22)
293+
294+
Average: 3 violations
295+
Min: 2, Max: 5
296+
```
297+
209298
## Exit Codes
210299

211300
| Code | Meaning |

pacta/cli/_ascii_chart.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
def render_line_chart(
2+
values: list[float],
3+
labels: list[str],
4+
*,
5+
width: int = 60,
6+
height: int = 10,
7+
title: str | None = None,
8+
) -> str:
9+
"""
10+
Render a simple ASCII line chart.
11+
12+
Args:
13+
values: Y-axis values (one per data point)
14+
labels: X-axis labels (one per data point)
15+
width: Chart width in characters
16+
height: Chart height in lines
17+
title: Optional chart title
18+
19+
Returns:
20+
ASCII chart as a string
21+
"""
22+
if not values:
23+
return "No data to display."
24+
25+
lines: list[str] = []
26+
27+
# Title
28+
if title:
29+
lines.append(title)
30+
lines.append("=" * len(title))
31+
lines.append("")
32+
33+
# Calculate scale
34+
min_val = min(values)
35+
max_val = max(values)
36+
37+
# Handle edge case where all values are the same
38+
if min_val == max_val:
39+
min_val = 0 if max_val > 0 else max_val - 1
40+
if max_val == 0:
41+
max_val = 1
42+
43+
val_range = max_val - min_val
44+
45+
# Y-axis label width (for padding)
46+
y_label_width = max(len(f"{max_val:.0f}"), len(f"{min_val:.0f}")) + 1
47+
48+
# Calculate chart area dimensions
49+
chart_width = min(width - y_label_width - 3, len(values) * 3)
50+
chart_width = max(chart_width, len(values)) # At least 1 char per point
51+
52+
# Map values to chart positions (column indices)
53+
if len(values) == 1:
54+
x_positions = [chart_width // 2]
55+
else:
56+
x_positions = [int((i / (len(values) - 1)) * (chart_width - 1)) for i in range(len(values))]
57+
58+
# Map values to row indices (0 = bottom, height-1 = top)
59+
def value_to_row(v: float) -> int:
60+
if val_range == 0:
61+
return height // 2
62+
normalized = (v - min_val) / val_range
63+
return int(normalized * (height - 1))
64+
65+
# Build the chart grid
66+
grid: list[list[str]] = [[" " for _ in range(chart_width)] for _ in range(height)]
67+
68+
# Plot points
69+
for i, v in enumerate(values):
70+
row = value_to_row(v)
71+
col = x_positions[i]
72+
if 0 <= row < height and 0 <= col < chart_width:
73+
grid[row][col] = "\u25cf" # Filled circle
74+
75+
# Connect points with lines (optional - use dashes for now)
76+
for i in range(len(values) - 1):
77+
row1 = value_to_row(values[i])
78+
row2 = value_to_row(values[i + 1])
79+
col1 = x_positions[i]
80+
col2 = x_positions[i + 1]
81+
82+
# Simple horizontal connection if on same row
83+
if row1 == row2 and col2 - col1 > 1:
84+
for c in range(col1 + 1, col2):
85+
if grid[row1][c] == " ":
86+
grid[row1][c] = "-"
87+
88+
# Render with Y-axis labels
89+
for row_idx in range(height - 1, -1, -1):
90+
# Show label at top, middle, and bottom
91+
if row_idx == height - 1:
92+
label = f"{max_val:>{y_label_width}.0f}"
93+
elif row_idx == 0:
94+
label = f"{min_val:>{y_label_width}.0f}"
95+
elif row_idx == height // 2:
96+
mid_val = (max_val + min_val) / 2
97+
label = f"{mid_val:>{y_label_width}.0f}"
98+
else:
99+
label = " " * y_label_width
100+
101+
# Y-axis line character
102+
if row_idx == 0:
103+
axis_char = "\u2514" # Bottom-left corner
104+
else:
105+
axis_char = "\u2502" # Vertical line
106+
107+
lines.append(f"{label} {axis_char}{''.join(grid[row_idx])}")
108+
109+
# X-axis
110+
lines.append(" " * (y_label_width + 1) + "\u2514" + "\u2500" * chart_width)
111+
112+
# X-axis labels (simplified - show first and last)
113+
if labels:
114+
label_line = " " * (y_label_width + 2)
115+
if len(labels) == 1:
116+
label_line += labels[0].center(chart_width)
117+
else:
118+
first_label = labels[0][:10]
119+
last_label = labels[-1][:10]
120+
spacing = chart_width - len(first_label) - len(last_label)
121+
if spacing > 0:
122+
label_line += first_label + " " * spacing + last_label
123+
else:
124+
label_line += first_label
125+
lines.append(label_line)
126+
127+
return "\n".join(lines)
128+
129+
130+
def render_trend_summary(
131+
values: list[float],
132+
labels: list[str],
133+
metric_name: str,
134+
) -> str:
135+
"""
136+
Render a summary of the trend.
137+
138+
Args:
139+
values: The data values
140+
labels: The date labels
141+
metric_name: Name of the metric being tracked
142+
143+
Returns:
144+
Summary text
145+
"""
146+
if not values:
147+
return "No data available."
148+
149+
lines: list[str] = []
150+
lines.append("")
151+
152+
first_val = values[0]
153+
last_val = values[-1]
154+
diff = last_val - first_val
155+
156+
# Trend direction
157+
if diff < 0:
158+
trend_icon = "\u2193" # Down arrow
159+
trend_word = "Improving" if metric_name == "violations" else "Decreasing"
160+
elif diff > 0:
161+
trend_icon = "\u2191" # Up arrow
162+
trend_word = "Worsening" if metric_name == "violations" else "Increasing"
163+
else:
164+
trend_icon = "\u2192" # Right arrow
165+
trend_word = "Stable"
166+
167+
diff_str = f"{diff:+.2f}" if isinstance(diff, float) and not diff.is_integer() else f"{diff:+.0f}"
168+
lines.append(f"Trend: {trend_icon} {trend_word} ({diff_str} over period)")
169+
170+
# First and last values
171+
first_str = (
172+
f"{first_val:.2f}" if isinstance(first_val, float) and not first_val.is_integer() else f"{first_val:.0f}"
173+
)
174+
last_str = f"{last_val:.2f}" if isinstance(last_val, float) and not last_val.is_integer() else f"{last_val:.0f}"
175+
176+
first_label = labels[0] if labels else "start"
177+
last_label = labels[-1] if labels else "end"
178+
179+
unit = _metric_unit(metric_name)
180+
lines.append(f"First: {first_str} {unit} ({first_label})")
181+
lines.append(f"Last: {last_str} {unit} ({last_label})")
182+
183+
# Statistics
184+
avg_val = float(sum(values) / len(values))
185+
max_val = max(values)
186+
min_val = min(values)
187+
188+
lines.append("")
189+
avg_str = f"{avg_val:.2f}" if not avg_val.is_integer() else f"{avg_val:.0f}"
190+
lines.append(f"Average: {avg_str} {unit}")
191+
lines.append(f"Min: {min_val:.0f}, Max: {max_val:.0f}")
192+
193+
return "\n".join(lines)
194+
195+
196+
def _metric_unit(metric_name: str) -> str:
197+
"""Get the display unit for a metric."""
198+
units = {
199+
"violations": "violations",
200+
"nodes": "nodes",
201+
"edges": "edges",
202+
"density": "ratio",
203+
}
204+
return units.get(metric_name, "")

0 commit comments

Comments
 (0)