@caddis/cli 0.4.73 → 0.4.75

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.
@@ -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())
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.159",
2
+ "poolVersion": "1.3.161",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.159",
4
+ "antigravity-plugin": "1.3.161",
5
5
  "antigravity-plugin-extras": "1.3.46",
6
6
  "codex": "unknown"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caddis/cli",
3
- "version": "0.4.73",
3
+ "version": "0.4.75",
4
4
  "description": "Install, update and diagnose the caddis dev harness across every coding agent you use (Claude Code, agy/Antigravity, and more).",
5
5
  "keywords": [
6
6
  "caddis",