@caddis/cli 0.4.73 → 0.4.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin/skills/coordinate/SKILL.md +4 -1
- package/bundles/antigravity-plugin/skills/flow-chart-from-counts/SKILL.md +193 -0
- package/bundles/antigravity-plugin/skills/flow-chart-from-counts/assets/viewer-template.html +190 -0
- package/bundles/antigravity-plugin/skills/flow-chart-from-counts/references/edge-list-sql.md +105 -0
- package/bundles/antigravity-plugin/skills/flow-chart-from-counts/scripts/check-render.mjs +139 -0
- package/bundles/antigravity-plugin/skills/flow-chart-from-counts/scripts/edges_to_mermaid.py +418 -0
- package/bundles/codex/.codex/skills/media/flow-chart-from-counts/SKILL.md +193 -0
- package/bundles/codex/.codex/skills/media/flow-chart-from-counts/assets/viewer-template.html +190 -0
- package/bundles/codex/.codex/skills/media/flow-chart-from-counts/references/edge-list-sql.md +105 -0
- package/bundles/codex/.codex/skills/media/flow-chart-from-counts/scripts/check-render.mjs +139 -0
- package/bundles/codex/.codex/skills/media/flow-chart-from-counts/scripts/edges_to_mermaid.py +418 -0
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Render check for a flow chart page made with the flow-chart-from-counts skill.
|
|
2
|
+
//
|
|
3
|
+
// Opens the page headless, waits for every .fv chart to draw, and fails when a chart has no SVG,
|
|
4
|
+
// shows an error message, has fewer nodes than --min-nodes, or the page throws. It saves one
|
|
5
|
+
// screenshot per chart and one of the first chart in full screen, then prints a JSON report.
|
|
6
|
+
// Node overlaps and edge labels that sit on a box are reported as warnings: open the screenshots
|
|
7
|
+
// to judge them, because some overlaps are harmless.
|
|
8
|
+
//
|
|
9
|
+
// Usage (run from a folder whose node_modules resolves "playwright"):
|
|
10
|
+
// node <skill folder>/scripts/check-render.mjs page.html --out shots [--channel msedge]
|
|
11
|
+
// [--min-nodes 3] [--width 1280] [--height 900] [--timeout 30000]
|
|
12
|
+
//
|
|
13
|
+
// Exit code: 0 pass, 1 a check failed, 2 bad arguments or Playwright not found.
|
|
14
|
+
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { pathToFileURL } from 'node:url';
|
|
19
|
+
|
|
20
|
+
function parseArgs(argv) {
|
|
21
|
+
const opts = { out: 'shots', channel: '', minNodes: 3, width: 1280, height: 900, timeout: 30000 };
|
|
22
|
+
const rest = [];
|
|
23
|
+
for (let i = 0; i < argv.length; i++) {
|
|
24
|
+
const a = argv[i];
|
|
25
|
+
const next = () => { if (i + 1 >= argv.length) throw new Error(`${a} needs a value`); return argv[++i]; };
|
|
26
|
+
if (a === '--out') opts.out = next();
|
|
27
|
+
else if (a === '--channel') opts.channel = next();
|
|
28
|
+
else if (a === '--min-nodes') opts.minNodes = Number(next());
|
|
29
|
+
else if (a === '--width') opts.width = Number(next());
|
|
30
|
+
else if (a === '--height') opts.height = Number(next());
|
|
31
|
+
else if (a === '--timeout') opts.timeout = Number(next());
|
|
32
|
+
else if (a.startsWith('--')) throw new Error(`unknown option ${a}`);
|
|
33
|
+
else rest.push(a);
|
|
34
|
+
}
|
|
35
|
+
if (rest.length !== 1) throw new Error('give exactly one page path');
|
|
36
|
+
opts.page = path.resolve(rest[0]);
|
|
37
|
+
return opts;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let opts;
|
|
41
|
+
try { opts = parseArgs(process.argv.slice(2)); }
|
|
42
|
+
catch (e) { console.error(`error: ${e.message}`); process.exit(2); }
|
|
43
|
+
|
|
44
|
+
if (!existsSync(opts.page)) {
|
|
45
|
+
console.error(`error: page file not found: ${opts.page}`);
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let chromium;
|
|
50
|
+
try {
|
|
51
|
+
// Resolve playwright from the current folder, not from this script's folder (the skill folder has none).
|
|
52
|
+
({ chromium } = createRequire(path.join(process.cwd(), 'noop.js'))('playwright'));
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.error('error: playwright not found from the current folder. Run this from a folder whose node_modules has playwright.');
|
|
55
|
+
process.exit(2);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
mkdirSync(opts.out, { recursive: true });
|
|
59
|
+
const browser = await chromium.launch(opts.channel ? { channel: opts.channel } : {});
|
|
60
|
+
const failures = [];
|
|
61
|
+
const warnings = [];
|
|
62
|
+
try {
|
|
63
|
+
const page = await browser.newPage({ viewport: { width: opts.width, height: opts.height } });
|
|
64
|
+
const pageErrors = [];
|
|
65
|
+
page.on('pageerror', (e) => pageErrors.push(e.message));
|
|
66
|
+
await page.goto(pathToFileURL(opts.page).href);
|
|
67
|
+
|
|
68
|
+
const total = await page.locator('.fv[data-src]').count();
|
|
69
|
+
if (total === 0) failures.push('no .fv[data-src] chart blocks on the page');
|
|
70
|
+
|
|
71
|
+
// Wait until every chart has an SVG or a message that is not the loading text.
|
|
72
|
+
await page.waitForFunction(() => [...document.querySelectorAll('.fv[data-src]')].every((fv) => {
|
|
73
|
+
const st = fv.querySelector('.fv-stage');
|
|
74
|
+
const p = st && st.querySelector(':scope > p');
|
|
75
|
+
return st && (st.querySelector('svg') || (p && !/Drawing/.test(p.textContent)));
|
|
76
|
+
}), null, { timeout: opts.timeout }).catch(() => failures.push(`charts not drawn within ${opts.timeout} ms`));
|
|
77
|
+
|
|
78
|
+
const report = await page.evaluate(() => {
|
|
79
|
+
const rect = (el) => el.getBoundingClientRect();
|
|
80
|
+
const overlap = (a, b) => !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top);
|
|
81
|
+
return [...document.querySelectorAll('.fv[data-src]')].map((fv) => {
|
|
82
|
+
const stage = fv.querySelector('.fv-stage');
|
|
83
|
+
const svg = stage.querySelector('svg');
|
|
84
|
+
const msg = stage.querySelector(':scope > p');
|
|
85
|
+
const nodes = svg ? [...svg.querySelectorAll('g.node')] : [];
|
|
86
|
+
const boxes = nodes.map((n) => rect(n));
|
|
87
|
+
let nodeOverlaps = 0;
|
|
88
|
+
for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) if (overlap(boxes[i], boxes[j])) nodeOverlaps++;
|
|
89
|
+
const labels = svg ? [...svg.querySelectorAll('g.edgeLabel')].filter((l) => l.textContent.trim()) : [];
|
|
90
|
+
let labelsOnBoxes = 0;
|
|
91
|
+
for (const l of labels) { const r = rect(l); if (r.width && boxes.some((b) => overlap(r, b))) labelsOnBoxes++; }
|
|
92
|
+
const text = svg ? svg.textContent : '';
|
|
93
|
+
return {
|
|
94
|
+
src: fv.dataset.src,
|
|
95
|
+
title: fv.dataset.title || '',
|
|
96
|
+
sub: fv.dataset.sub || '',
|
|
97
|
+
svg: !!svg,
|
|
98
|
+
nodes: nodes.length,
|
|
99
|
+
edges: svg ? svg.querySelectorAll('.edgePaths path, path.flowchart-link').length : 0,
|
|
100
|
+
edgeLabels: labels.length,
|
|
101
|
+
nodeOverlaps,
|
|
102
|
+
labelsOnBoxes,
|
|
103
|
+
errorText: (msg && msg.textContent) || (/Syntax error|Parse error/i.test(text) ? 'Mermaid error text in the SVG' : ''),
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
for (const c of report) {
|
|
109
|
+
if (!c.svg) failures.push(`${c.src}: no SVG (${c.errorText || 'no message'})`);
|
|
110
|
+
else if (c.errorText) failures.push(`${c.src}: ${c.errorText}`);
|
|
111
|
+
if (c.svg && c.nodes < opts.minNodes) failures.push(`${c.src}: ${c.nodes} nodes, fewer than --min-nodes ${opts.minNodes}`);
|
|
112
|
+
if (!/\b(UTC|GMT|BST|IST|CET|CEST|EET|EEST|WET|WEST|EST|EDT|CST|CDT|MST|MDT|PST|PDT|JST|AEST|AEDT)\b|time ?zone|[+-]\d{2}:?\d{2}\b/i.test(`${c.title} ${c.sub}`))
|
|
113
|
+
warnings.push(`${c.src}: the title has no time-zone text (add the zone to data-title or data-sub)`);
|
|
114
|
+
if (c.nodeOverlaps) warnings.push(`${c.src}: ${c.nodeOverlaps} pairs of boxes overlap`);
|
|
115
|
+
if (c.labelsOnBoxes) warnings.push(`${c.src}: ${c.labelsOnBoxes} arrow labels sit on a box`);
|
|
116
|
+
}
|
|
117
|
+
for (const e of pageErrors) failures.push(`page error: ${e}`);
|
|
118
|
+
|
|
119
|
+
const charts = page.locator('.fv[data-src]');
|
|
120
|
+
for (let i = 0; i < total; i++) {
|
|
121
|
+
await charts.nth(i).screenshot({ path: path.join(opts.out, `chart-${i + 1}.png`) });
|
|
122
|
+
}
|
|
123
|
+
if (total > 0) {
|
|
124
|
+
const fullBtn = charts.nth(0).locator('button[data-z="full"]');
|
|
125
|
+
if (await fullBtn.count()) {
|
|
126
|
+
await fullBtn.click();
|
|
127
|
+
await page.waitForTimeout(300);
|
|
128
|
+
await page.screenshot({ path: path.join(opts.out, 'chart-1-full.png') });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
console.log(JSON.stringify({ page: opts.page, charts: report, failures, warnings, shots: path.resolve(opts.out) }, null, 2));
|
|
132
|
+
} finally {
|
|
133
|
+
await browser.close();
|
|
134
|
+
}
|
|
135
|
+
if (failures.length) {
|
|
136
|
+
console.error(`FAIL: ${failures.length} check(s) failed`);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
console.error(`PASS${warnings.length ? ` with ${warnings.length} warning(s): open the screenshots` : ''}`);
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Turn a from,to,count edge list into a first-draft Mermaid flowchart.
|
|
3
|
+
|
|
4
|
+
The draft applies the count and layout rules of the flow-chart-from-counts skill:
|
|
5
|
+
|
|
6
|
+
- counts go in quoted brackets on arrows: |"(1,234)"|
|
|
7
|
+
- paths under --min are not drawn; the note names how many and how big they were
|
|
8
|
+
- the chart is drawn as a TREE: each box keeps one parent edge (the biggest path into it); every other
|
|
9
|
+
path into it becomes a small dashed stub under its own source, "to X (n)". Shared targets are what
|
|
10
|
+
make the layout cross lines. --no-tree keeps shared targets as real edges.
|
|
11
|
+
- each source that ends gets its own end box, so no long lines cross the chart to one shared box
|
|
12
|
+
- a loop back to an earlier step becomes a "back to X (n)" stub, not an edge
|
|
13
|
+
- edges and nodes are written largest first: the layout takes that as its first guess for left to right
|
|
14
|
+
- duplicate from,to rows are added together into one path
|
|
15
|
+
- node types become classDefs named t_<type> (they never clash with the built-in classes endn, stub and
|
|
16
|
+
startn); --in-scope types get a thick border, --optional types a dashed one
|
|
17
|
+
|
|
18
|
+
It is a draft. Read the rendered chart and fix the layout by hand (SKILL.md, "Layout fixes").
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
python edges_to_mermaid.py edges.csv [--nodes nodes.csv] [--min 25] [--in-scope T1,T2]
|
|
22
|
+
[--optional T3] [--direction TD] [--end-label "Ends"] [--no-tree]
|
|
23
|
+
[--out chart.mmd] [--strict]
|
|
24
|
+
|
|
25
|
+
edges.csv columns: from,to,count, where count is DISTINCT cases per pair (also accepted: from_step,
|
|
26
|
+
to_step,case_count). Do not feed it path occurrences (COUNT(*)): see references/edge-list-sql.md.
|
|
27
|
+
nodes.csv columns: name,type[,count]; count is the distinct cases in that box.
|
|
28
|
+
The Mermaid source goes to --out FILE (written as UTF-8) or, without --out, to stdout (also UTF-8).
|
|
29
|
+
The note on hidden paths and any warning go to stderr. With --strict, any self-check warning gives exit 1
|
|
30
|
+
(the chart is still written). Exit codes: 0 ok, 1 strict warning, 2 bad input.
|
|
31
|
+
Standard library only.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import argparse
|
|
37
|
+
import csv
|
|
38
|
+
import hashlib
|
|
39
|
+
import re
|
|
40
|
+
import sys
|
|
41
|
+
from collections import defaultdict
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
START = "START"
|
|
46
|
+
END = "END"
|
|
47
|
+
|
|
48
|
+
# Light fills with dark text: they keep contrast on a light canvas in both page themes.
|
|
49
|
+
PALETTE = [
|
|
50
|
+
("#dbe4ee", "#475569"),
|
|
51
|
+
("#bbf7d0", "#15803d"),
|
|
52
|
+
("#f6e7d6", "#b45309"),
|
|
53
|
+
("#e9d5ff", "#7e22ce"),
|
|
54
|
+
("#fde68a", "#a16207"),
|
|
55
|
+
("#cffafe", "#0e7490"),
|
|
56
|
+
("#fecdd3", "#be123c"),
|
|
57
|
+
]
|
|
58
|
+
TEXT = "#1c1917"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Edge:
|
|
63
|
+
src: str
|
|
64
|
+
dst: str
|
|
65
|
+
count: int
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Result:
|
|
70
|
+
mermaid: str
|
|
71
|
+
hidden_paths: int
|
|
72
|
+
hidden_total: int
|
|
73
|
+
note: str
|
|
74
|
+
warnings: list[str] = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def node_id(name: str) -> str:
|
|
78
|
+
"""A stable Mermaid id: a readable slug plus a 6-character hash.
|
|
79
|
+
|
|
80
|
+
A collision is very unlikely but not impossible. convert() checks for one and raises ValueError.
|
|
81
|
+
"""
|
|
82
|
+
if name == START:
|
|
83
|
+
return "start"
|
|
84
|
+
slug = re.sub(r"[^A-Za-z0-9]+", "_", name).strip("_").lower()[:24] or "node"
|
|
85
|
+
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:6]
|
|
86
|
+
return f"n_{slug}_{digest}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _label(text: str) -> str:
|
|
90
|
+
"""Escape a name for a quoted Mermaid label. '#' goes first: it starts every entity code."""
|
|
91
|
+
text = " ".join(text.splitlines())
|
|
92
|
+
for raw, code in (("#", "#35;"), ('"', "#quot;"), ("<", "#lt;"), (">", "#gt;"), ("[", "#91;"), ("]", "#93;"), ("&", "#amp;")):
|
|
93
|
+
text = text.replace(raw, code)
|
|
94
|
+
return text
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def merge_edges(edges: list[Edge]) -> list[Edge]:
|
|
98
|
+
"""Add the counts of duplicate (from, to) pairs. First-seen order is kept."""
|
|
99
|
+
total: dict[tuple[str, str], int] = {}
|
|
100
|
+
for e in edges:
|
|
101
|
+
total[(e.src, e.dst)] = total.get((e.src, e.dst), 0) + e.count
|
|
102
|
+
return [Edge(a, b, n) for (a, b), n in total.items()]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _n(count: int) -> str:
|
|
106
|
+
return f"{count:,}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _order_sources(edges: list[Edge]) -> list[str]:
|
|
110
|
+
"""Sources in breadth-first order from START, then any left over, so the text reads top-down."""
|
|
111
|
+
out: dict[str, list[Edge]] = defaultdict(list)
|
|
112
|
+
for e in edges:
|
|
113
|
+
out[e.src].append(e)
|
|
114
|
+
roots = [START] if START in out else sorted({e.src for e in edges} - {e.dst for e in edges})
|
|
115
|
+
seen: list[str] = []
|
|
116
|
+
queue = list(roots)
|
|
117
|
+
while queue:
|
|
118
|
+
cur = queue.pop(0)
|
|
119
|
+
if cur in seen or cur == END:
|
|
120
|
+
continue
|
|
121
|
+
seen.append(cur)
|
|
122
|
+
queue.extend(e.dst for e in sorted(out.get(cur, []), key=lambda x: -x.count))
|
|
123
|
+
seen += sorted(s for s in out if s not in seen)
|
|
124
|
+
return [s for s in seen if s in out]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _back_edges(edges: list[Edge], roots: list[str]) -> set[Edge]:
|
|
128
|
+
"""Edges that point to a step still open on the depth-first path: the loop-backs."""
|
|
129
|
+
out: dict[str, list[Edge]] = defaultdict(list)
|
|
130
|
+
for e in edges:
|
|
131
|
+
out[e.src].append(e)
|
|
132
|
+
back: set[Edge] = set()
|
|
133
|
+
state: dict[str, int] = {} # 1 = on the path, 2 = done
|
|
134
|
+
|
|
135
|
+
def visit(node: str) -> None:
|
|
136
|
+
state[node] = 1
|
|
137
|
+
for e in sorted(out.get(node, []), key=lambda x: -x.count):
|
|
138
|
+
if e.dst == END:
|
|
139
|
+
continue
|
|
140
|
+
if state.get(e.dst) == 1:
|
|
141
|
+
back.add(e)
|
|
142
|
+
elif e.dst not in state:
|
|
143
|
+
visit(e.dst)
|
|
144
|
+
state[node] = 2
|
|
145
|
+
|
|
146
|
+
for r in roots:
|
|
147
|
+
if r not in state:
|
|
148
|
+
visit(r)
|
|
149
|
+
for s in list(out):
|
|
150
|
+
if s not in state:
|
|
151
|
+
visit(s)
|
|
152
|
+
return back
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _reachable(edges: list[Edge], roots: list[str]) -> set[str]:
|
|
156
|
+
out: dict[str, list[str]] = defaultdict(list)
|
|
157
|
+
for e in edges:
|
|
158
|
+
out[e.src].append(e.dst)
|
|
159
|
+
seen: set[str] = set()
|
|
160
|
+
stack = list(roots)
|
|
161
|
+
while stack:
|
|
162
|
+
cur = stack.pop()
|
|
163
|
+
if cur in seen:
|
|
164
|
+
continue
|
|
165
|
+
seen.add(cur)
|
|
166
|
+
stack.extend(out.get(cur, []))
|
|
167
|
+
return seen
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def convert(
|
|
171
|
+
edges: list[Edge],
|
|
172
|
+
min_count: int = 1,
|
|
173
|
+
box_counts: dict[str, int] | None = None,
|
|
174
|
+
node_types: dict[str, str] | None = None,
|
|
175
|
+
in_scope: set[str] | frozenset[str] = frozenset(),
|
|
176
|
+
optional: set[str] | frozenset[str] = frozenset(),
|
|
177
|
+
direction: str = "TD",
|
|
178
|
+
end_label: str = "Ends",
|
|
179
|
+
tree: bool = True,
|
|
180
|
+
) -> Result:
|
|
181
|
+
box_counts = box_counts or {}
|
|
182
|
+
node_types = node_types or {}
|
|
183
|
+
edges = merge_edges(edges)
|
|
184
|
+
seen_ids: dict[str, str] = {}
|
|
185
|
+
for name in {n for e in edges for n in (e.src, e.dst) if n != END}:
|
|
186
|
+
other = seen_ids.setdefault(node_id(name), name)
|
|
187
|
+
if other != name:
|
|
188
|
+
raise ValueError(f"{other!r} and {name!r} would get the same id; rename one of them")
|
|
189
|
+
|
|
190
|
+
small = [e for e in edges if e.count < min_count]
|
|
191
|
+
kept = [e for e in edges if e.count >= min_count]
|
|
192
|
+
has_start = any(e.src == START for e in edges)
|
|
193
|
+
roots = [START] if has_start else sorted({e.src for e in edges} - {e.dst for e in edges})
|
|
194
|
+
live = _reachable(kept, roots)
|
|
195
|
+
orphaned = [e for e in kept if e.src not in live]
|
|
196
|
+
kept = [e for e in kept if e.src in live]
|
|
197
|
+
|
|
198
|
+
back = _back_edges(kept, roots)
|
|
199
|
+
# Tree mode: the biggest forward path into a box is its one parent edge; the rest become stubs.
|
|
200
|
+
stubbed: set[Edge] = set()
|
|
201
|
+
if tree:
|
|
202
|
+
into: dict[str, list[Edge]] = defaultdict(list)
|
|
203
|
+
for e in kept:
|
|
204
|
+
if e.dst != END and e not in back:
|
|
205
|
+
into[e.dst].append(e)
|
|
206
|
+
for incoming in into.values():
|
|
207
|
+
incoming.sort(key=lambda x: (-x.count, x.src))
|
|
208
|
+
stubbed.update(incoming[1:])
|
|
209
|
+
layout = [e for e in kept if e not in back and e not in stubbed]
|
|
210
|
+
lines = [f"flowchart {direction}"]
|
|
211
|
+
|
|
212
|
+
# Declare nodes in the same largest-first order as the edges: Mermaid places the first-declared
|
|
213
|
+
# child of a rank on the left, so declaration order is part of the layout.
|
|
214
|
+
names: list[str] = []
|
|
215
|
+
ordered = sorted(layout, key=lambda x: -x.count)
|
|
216
|
+
for src in _order_sources(layout):
|
|
217
|
+
for n in [src] + [e.dst for e in ordered if e.src == src]:
|
|
218
|
+
if n != END and n not in names:
|
|
219
|
+
names.append(n)
|
|
220
|
+
for e in kept:
|
|
221
|
+
for n in (e.src, e.dst):
|
|
222
|
+
if n != END and n not in names:
|
|
223
|
+
names.append(n)
|
|
224
|
+
start_total = sum(e.count for e in kept if e.src == START) + sum(e.count for e in small if e.src == START)
|
|
225
|
+
for name in names:
|
|
226
|
+
if name == START:
|
|
227
|
+
lines.append(f' start(["Start<br/>({_n(start_total)})"])')
|
|
228
|
+
continue
|
|
229
|
+
text = _label(name)
|
|
230
|
+
if name in box_counts:
|
|
231
|
+
text += f"<br/>({_n(box_counts[name])})"
|
|
232
|
+
lines.append(f' {node_id(name)}["{text}"]')
|
|
233
|
+
|
|
234
|
+
by_src: dict[str, list[Edge]] = defaultdict(list)
|
|
235
|
+
for e in kept:
|
|
236
|
+
by_src[e.src].append(e)
|
|
237
|
+
end_ids: list[str] = []
|
|
238
|
+
stub_ids: list[str] = []
|
|
239
|
+
sources = _order_sources(layout)
|
|
240
|
+
sources += [s for s in _order_sources(kept) if s not in sources]
|
|
241
|
+
for src in sources:
|
|
242
|
+
sid = node_id(src)
|
|
243
|
+
for e in sorted(by_src[src], key=lambda x: -x.count):
|
|
244
|
+
arrow = f'{sid} -->|"({_n(e.count)})"|'
|
|
245
|
+
if e.dst == END:
|
|
246
|
+
eid = f"end_{sid}"
|
|
247
|
+
end_ids.append(eid)
|
|
248
|
+
lines.append(f' {arrow} {eid}(["{_label(end_label)}"])')
|
|
249
|
+
elif e in back or e in stubbed:
|
|
250
|
+
word = "back to" if e in back else "to"
|
|
251
|
+
bid = f"{word.split()[0]}_{sid}_{node_id(e.dst)}"
|
|
252
|
+
stub_ids.append(bid)
|
|
253
|
+
lines.append(f' {sid} --> {bid}(["{word} {_label(e.dst)} ({_n(e.count)})"])')
|
|
254
|
+
else:
|
|
255
|
+
lines.append(f" {arrow} {node_id(e.dst)}")
|
|
256
|
+
|
|
257
|
+
types = []
|
|
258
|
+
for name in names:
|
|
259
|
+
t = node_types.get(name)
|
|
260
|
+
if t and t not in types:
|
|
261
|
+
types.append(t)
|
|
262
|
+
for i, t in enumerate(types):
|
|
263
|
+
fill, stroke = PALETTE[i % len(PALETTE)]
|
|
264
|
+
extra = ""
|
|
265
|
+
if t in in_scope:
|
|
266
|
+
extra = ",stroke-width:3px"
|
|
267
|
+
elif t in optional:
|
|
268
|
+
extra = ",stroke-width:2px,stroke-dasharray:6 4"
|
|
269
|
+
lines.append(f" classDef t_{t} fill:{fill},stroke:{stroke},color:{TEXT}{extra};")
|
|
270
|
+
if end_ids:
|
|
271
|
+
lines.append(f" classDef endn fill:#eceae6,stroke:#78716c,color:{TEXT};")
|
|
272
|
+
if stub_ids:
|
|
273
|
+
lines.append(" classDef stub fill:#ffffff,stroke:#a8a29e,stroke-dasharray:3 3,color:#57534e;")
|
|
274
|
+
if has_start and START in names:
|
|
275
|
+
lines.append(f" classDef startn fill:#ffffff,stroke:#57534e,stroke-width:2px,color:{TEXT};")
|
|
276
|
+
for t in types:
|
|
277
|
+
ids = [node_id(n) for n in names if node_types.get(n) == t]
|
|
278
|
+
lines.append(f" class {','.join(ids)} t_{t};")
|
|
279
|
+
if end_ids:
|
|
280
|
+
lines.append(f" class {','.join(end_ids)} endn;")
|
|
281
|
+
if stub_ids:
|
|
282
|
+
lines.append(f" class {','.join(stub_ids)} stub;")
|
|
283
|
+
if has_start and START in names:
|
|
284
|
+
lines.append(" class start startn;")
|
|
285
|
+
|
|
286
|
+
hidden_total = sum(e.count for e in small)
|
|
287
|
+
note_parts = []
|
|
288
|
+
if small:
|
|
289
|
+
note_parts.append(
|
|
290
|
+
f"Not drawn: {len(small)} paths with fewer than {_n(min_count)} each "
|
|
291
|
+
f"({_n(hidden_total)} in total): "
|
|
292
|
+
+ "; ".join(f"{e.src} to {e.dst} ({_n(e.count)})" for e in sorted(small, key=lambda x: -x.count))
|
|
293
|
+
+ "."
|
|
294
|
+
)
|
|
295
|
+
if orphaned:
|
|
296
|
+
note_parts.append(f"Also not drawn: {len(orphaned)} paths that start from a step that is not drawn.")
|
|
297
|
+
note = " ".join(note_parts)
|
|
298
|
+
if note:
|
|
299
|
+
lines.append(f" %% {note}")
|
|
300
|
+
# Self-check: an arrow counts distinct cases, so it can never be larger than the box at either end.
|
|
301
|
+
# When it is, the edge list counts path occurrences (COUNT(*)), not cases.
|
|
302
|
+
warnings = []
|
|
303
|
+
for e in kept:
|
|
304
|
+
for end in (e.src, e.dst):
|
|
305
|
+
if end in box_counts and e.count > box_counts[end]:
|
|
306
|
+
warnings.append(
|
|
307
|
+
f"{e.src} to {e.dst} ({_n(e.count)}) is larger than the box {end} ({_n(box_counts[end])}): "
|
|
308
|
+
"the edge list counts path occurrences, not distinct cases"
|
|
309
|
+
)
|
|
310
|
+
return Result("\n".join(lines) + "\n", len(small), hidden_total, note, warnings)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def build(edges: list[Edge], **kwargs) -> str:
|
|
314
|
+
return convert(edges, **kwargs).mermaid
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _col(row: dict[str, str], *names: str, where: str = "csv") -> str:
|
|
318
|
+
lower = {k.strip().lower(): v for k, v in row.items() if k}
|
|
319
|
+
for n in names:
|
|
320
|
+
if n in lower:
|
|
321
|
+
if lower[n] is None:
|
|
322
|
+
raise ValueError(f"{where}: the row has no value for {n!r} (the row is shorter than the header)")
|
|
323
|
+
return lower[n].strip()
|
|
324
|
+
raise ValueError(f"{where}: missing column, expected one of {names}; got {sorted(lower)}")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def read_edges(path: Path) -> list[Edge]:
|
|
328
|
+
edges = []
|
|
329
|
+
with path.open(encoding="utf-8-sig", newline="") as fh:
|
|
330
|
+
for line_no, row in enumerate(csv.DictReader(fh), start=2):
|
|
331
|
+
where = f"{path}:{line_no}"
|
|
332
|
+
raw = _col(row, "count", "case_count", "n", where=where)
|
|
333
|
+
try:
|
|
334
|
+
count = int(raw.replace(",", ""))
|
|
335
|
+
except ValueError as exc:
|
|
336
|
+
raise ValueError(f"{path}:{line_no}: count is not a whole number: {raw!r}") from exc
|
|
337
|
+
edges.append(
|
|
338
|
+
Edge(_col(row, "from", "from_step", "src", where=where), _col(row, "to", "to_step", "dst", where=where), count)
|
|
339
|
+
)
|
|
340
|
+
return merge_edges(edges)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def read_nodes(path: Path) -> tuple[dict[str, str], dict[str, int]]:
|
|
344
|
+
types: dict[str, str] = {}
|
|
345
|
+
counts: dict[str, int] = {}
|
|
346
|
+
with path.open(encoding="utf-8-sig", newline="") as fh:
|
|
347
|
+
for line_no, row in enumerate(csv.DictReader(fh), start=2):
|
|
348
|
+
where = f"{path}:{line_no}"
|
|
349
|
+
name = _col(row, "name", "step", "node", where=where)
|
|
350
|
+
t = _col(row, "type", "kind", where=where)
|
|
351
|
+
if t:
|
|
352
|
+
types[name] = re.sub(r"[^A-Za-z0-9_]", "_", t)
|
|
353
|
+
lower = {k.strip().lower(): v for k, v in row.items() if k}
|
|
354
|
+
raw = (lower.get("count") or lower.get("case_count") or "").strip()
|
|
355
|
+
if raw:
|
|
356
|
+
try:
|
|
357
|
+
counts[name] = int(raw.replace(",", ""))
|
|
358
|
+
except ValueError as exc:
|
|
359
|
+
raise ValueError(f"{where}: count is not a whole number: {raw!r}") from exc
|
|
360
|
+
return types, counts
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def main(argv: list[str] | None = None) -> int:
|
|
364
|
+
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
365
|
+
ap.add_argument("edges", type=Path)
|
|
366
|
+
ap.add_argument("--nodes", type=Path)
|
|
367
|
+
ap.add_argument("--min", type=int, default=1, dest="min_count")
|
|
368
|
+
ap.add_argument("--in-scope", default="")
|
|
369
|
+
ap.add_argument("--optional", default="")
|
|
370
|
+
ap.add_argument("--direction", default="TD", choices=["TD", "LR"])
|
|
371
|
+
ap.add_argument("--end-label", default="Ends")
|
|
372
|
+
ap.add_argument("--no-tree", action="store_true", help="keep shared targets as real edges, no stubs")
|
|
373
|
+
ap.add_argument("--out", type=Path, help="write the Mermaid source to this file as UTF-8 (default: stdout)")
|
|
374
|
+
ap.add_argument("--strict", action="store_true", help="exit 1 when the self-check finds an arrow larger than a box")
|
|
375
|
+
args = ap.parse_args(argv)
|
|
376
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
377
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
378
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
379
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
edges = read_edges(args.edges)
|
|
383
|
+
types, counts = read_nodes(args.nodes) if args.nodes else ({}, {})
|
|
384
|
+
except (OSError, ValueError) as exc:
|
|
385
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
386
|
+
return 2
|
|
387
|
+
split = lambda s: {x.strip() for x in s.split(",") if x.strip()}
|
|
388
|
+
try:
|
|
389
|
+
result = convert(
|
|
390
|
+
edges,
|
|
391
|
+
min_count=args.min_count,
|
|
392
|
+
box_counts=counts,
|
|
393
|
+
node_types=types,
|
|
394
|
+
in_scope=split(args.in_scope),
|
|
395
|
+
optional=split(args.optional),
|
|
396
|
+
direction=args.direction,
|
|
397
|
+
end_label=args.end_label,
|
|
398
|
+
tree=not args.no_tree,
|
|
399
|
+
)
|
|
400
|
+
if args.out:
|
|
401
|
+
args.out.write_text(result.mermaid, encoding="utf-8", newline="\n")
|
|
402
|
+
else:
|
|
403
|
+
sys.stdout.write(result.mermaid)
|
|
404
|
+
except (OSError, ValueError) as exc:
|
|
405
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
406
|
+
return 2
|
|
407
|
+
if result.note:
|
|
408
|
+
print(result.note, file=sys.stderr)
|
|
409
|
+
for w in result.warnings:
|
|
410
|
+
print(f"warning: {w}", file=sys.stderr)
|
|
411
|
+
if args.strict and result.warnings:
|
|
412
|
+
print(f"error: --strict: {len(result.warnings)} self-check warning(s)", file=sys.stderr)
|
|
413
|
+
return 1
|
|
414
|
+
return 0
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
if __name__ == "__main__":
|
|
418
|
+
raise SystemExit(main())
|