cubest 0.1.0

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.
Files changed (46) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +27 -0
  3. package/README.ar.md +110 -0
  4. package/README.bn.md +111 -0
  5. package/README.es.md +110 -0
  6. package/README.hi.md +110 -0
  7. package/README.ja.md +113 -0
  8. package/README.md +469 -0
  9. package/README.pa.md +114 -0
  10. package/README.pt.md +113 -0
  11. package/README.ru.md +1174 -0
  12. package/README.zh-CN.md +233 -0
  13. package/bin/cubest.js +28 -0
  14. package/cubest.py +1862 -0
  15. package/package.json +28 -0
  16. package/profiles/agents_inventory.yaml +15 -0
  17. package/profiles/api_routes.yaml +20 -0
  18. package/profiles/call_graph.yaml +19 -0
  19. package/profiles/code_atlas.yaml +33 -0
  20. package/profiles/code_stats.yaml +15 -0
  21. package/profiles/csv_analytics.yaml +31 -0
  22. package/profiles/disk_usage.yaml +29 -0
  23. package/profiles/doc_structure.yaml +16 -0
  24. package/profiles/file_tree.yaml +27 -0
  25. package/profiles/frontend_geoip.yaml +49 -0
  26. package/profiles/git_log_activity.yaml +29 -0
  27. package/profiles/imports.yaml +16 -0
  28. package/profiles/jsonl_events.yaml +19 -0
  29. package/profiles/k8s_resources.yaml +25 -0
  30. package/profiles/loc_counter.yaml +51 -0
  31. package/profiles/mr_impact.yaml +19 -0
  32. package/profiles/nginx_access.yaml +33 -0
  33. package/profiles/nginx_cdn_covers.yaml +32 -0
  34. package/profiles/openapi_endpoints.yaml +19 -0
  35. package/profiles/react_components.yaml +15 -0
  36. package/profiles/sdd_checklist.yaml +17 -0
  37. package/profiles/sdd_specs.yaml +19 -0
  38. package/profiles/seo_audit.yaml +31 -0
  39. package/profiles/seo_semantic_tree.yaml +17 -0
  40. package/profiles/sitemap_map.yaml +20 -0
  41. package/profiles/skills_inventory.yaml +17 -0
  42. package/profiles/spec_status.yaml +18 -0
  43. package/profiles/sql_functions.yaml +31 -0
  44. package/profiles/tech_debt.yaml +16 -0
  45. package/profiles/xml_tags.yaml +21 -0
  46. package/profiles/yaml_keys.yaml +19 -0
package/cubest.py ADDED
@@ -0,0 +1,1862 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ cubest.py — Single-pass OLAP indexer for Claude Code skill.
4
+
5
+ Scans files (plain or .gz, streaming or batch), extracts records via regex or
6
+ presets, aggregates them into an in-memory hierarchical cube, and prints a
7
+ compact tree / breadcrumb list / CSV / JSON. Designed to replace long chains
8
+ of `grep`+`cat` and to keep large-scan output token-cheap.
9
+
10
+ Usage:
11
+ python cubest.py --profile file_tree .
12
+ python cubest.py --profile api_routes ./src
13
+ python cubest.py --profile nginx_access /var/log/nginx/access.log.gz
14
+ python cubest.py --profile - < profile.yaml ./src
15
+ python cubest.py --profile '{"dimensions":["kind"],...}' ./src
16
+ """
17
+
18
+ import os
19
+ import re
20
+ import io
21
+ import csv as _csv_lib
22
+ import sys
23
+ import gzip
24
+ import json
25
+ import random
26
+ import argparse
27
+ from pathlib import Path
28
+ from fnmatch import fnmatch
29
+ from typing import Any, Dict, List, Optional, Iterable
30
+
31
+ try:
32
+ import yaml
33
+ HAS_YAML = True
34
+ except ImportError:
35
+ HAS_YAML = False
36
+
37
+ # Optional exact-percentile backend. When installed (`pip install tdigest`)
38
+ # it replaces reservoir sampling for p50/p90/p95/p99 measures — a lot more
39
+ # accurate on long-tailed distributions at the cost of an extra dependency.
40
+ try:
41
+ from tdigest import TDigest as _TDigest
42
+ HAS_TDIGEST = True
43
+ except ImportError:
44
+ HAS_TDIGEST = False
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # OLAP cube
49
+ # ---------------------------------------------------------------------------
50
+
51
+ _MEASURE_TYPE_Q = {"p50": 0.5, "p90": 0.9, "p95": 0.95, "p99": 0.99}
52
+
53
+
54
+ class _Reservoir:
55
+ """Fixed-size reservoir sampling (Vitter's algorithm R) → memory O(k).
56
+
57
+ Approximate quantiles via sort of the buffer. Rollup: proportional
58
+ resample of the two parent buffers so total size stays bounded.
59
+ """
60
+ __slots__ = ("k", "n", "buf")
61
+
62
+ def __init__(self, k: int = 128, buf=None, n: int = 0):
63
+ self.k = k
64
+ self.n = n
65
+ self.buf = buf if buf is not None else []
66
+
67
+ def add(self, v):
68
+ self.n += 1
69
+ if len(self.buf) < self.k:
70
+ self.buf.append(v)
71
+ else:
72
+ i = random.randint(0, self.n - 1)
73
+ if i < self.k:
74
+ self.buf[i] = v
75
+
76
+ def quantile(self, q: float) -> float:
77
+ if not self.buf:
78
+ return 0.0
79
+ s = sorted(self.buf)
80
+ if len(s) == 1:
81
+ return s[0]
82
+ pos = q * (len(s) - 1)
83
+ lo = int(pos)
84
+ hi = min(lo + 1, len(s) - 1)
85
+ frac = pos - lo
86
+ return s[lo] * (1 - frac) + s[hi] * frac
87
+
88
+ @classmethod
89
+ def merge(cls, a, b, k: int):
90
+ """Weighted-proportional merge — approximate but bounded."""
91
+ if a is None:
92
+ return b
93
+ if b is None:
94
+ return a
95
+ total = a.n + b.n
96
+ if total == 0:
97
+ return cls(k)
98
+ if len(a.buf) + len(b.buf) <= k:
99
+ return cls(k, list(a.buf) + list(b.buf), total)
100
+ from_a = min(len(a.buf), max(1, int(round(k * a.n / total))))
101
+ from_b = min(len(b.buf), k - from_a)
102
+ buf = random.sample(a.buf, from_a) + random.sample(b.buf, from_b)
103
+ return cls(k, buf, total)
104
+
105
+
106
+ class OlapCube:
107
+ """In-memory OLAP cube with hierarchical dimensions."""
108
+
109
+ def __init__(self, dimensions: List[str], measures: List[Dict]):
110
+ self.dimensions = dimensions
111
+ self.measures = measures
112
+ self.data: Dict = {}
113
+
114
+ def add(self, record: Dict):
115
+ node = self.data
116
+ rget = record.get
117
+ for dim in self.dimensions:
118
+ val = rget(dim)
119
+ if val is None and not dim.startswith("_"):
120
+ val = rget("_" + dim)
121
+ if val is None or val == "":
122
+ val = "---"
123
+ child = node.get(val)
124
+ if child is None:
125
+ child = {"_meta": {"count": 0}}
126
+ node[val] = child
127
+ meta = child["_meta"]
128
+ else:
129
+ meta = child.setdefault("_meta", {"count": 0})
130
+ meta["count"] += 1
131
+ node = child
132
+
133
+ for m in self.measures:
134
+ mtype = m.get("type", "count")
135
+ mname = m["name"]
136
+ if mtype == "count":
137
+ node[mname] = node.get(mname, 0) + 1
138
+ elif mtype == "sum":
139
+ field = m.get("field", mname)
140
+ node[mname] = node.get(mname, 0) + _num(rget(field, 0))
141
+ elif mtype == "avg":
142
+ field = m.get("field", mname)
143
+ meta_avg = node.setdefault("_meta_avg", {})
144
+ sum_key = mname + "_sum"
145
+ new_sum = meta_avg.get(sum_key, 0) + _num(rget(field, 0))
146
+ meta_avg[sum_key] = new_sum
147
+ node[mname] = new_sum / node["_meta"]["count"]
148
+ elif mtype in ("min", "max"):
149
+ field = m.get("field", mname)
150
+ v = _num(rget(field, 0))
151
+ cur = node.get(mname)
152
+ if cur is None:
153
+ node[mname] = v
154
+ else:
155
+ node[mname] = v if (mtype == "min") == (v < cur) else cur
156
+ elif mtype in ("p50", "p90", "p95", "p99", "percentile"):
157
+ field = m.get("field", mname)
158
+ v = _num(rget(field, 0))
159
+ k = m.get("sample_size", 128)
160
+ res_key = "_res_" + mname
161
+ res = node.get(res_key)
162
+ if res is None:
163
+ res = _Reservoir(k)
164
+ node[res_key] = res
165
+ res.add(v)
166
+ q = m.get("q") or _MEASURE_TYPE_Q.get(mtype, 0.5)
167
+ node[mname] = res.quantile(q)
168
+
169
+ def _rollup(self, node: Dict) -> Dict:
170
+ result = {"_total": node.get("_meta", {}).get("count", 0)}
171
+ is_leaf = not any(
172
+ isinstance(v, dict) and k not in ("_meta", "_meta_avg")
173
+ for k, v in node.items()
174
+ )
175
+
176
+ if is_leaf:
177
+ for m in self.measures:
178
+ mname = m["name"]
179
+ if mname in node:
180
+ result[mname] = node[mname]
181
+ if m.get("type") == "avg":
182
+ sum_key = f"{mname}_sum"
183
+ result[f"_{mname}_sum"] = node.get("_meta_avg", {}).get(sum_key, 0)
184
+ if m.get("type") in ("p50", "p90", "p95", "p99", "percentile"):
185
+ res = node.get("_res_" + mname)
186
+ if res is not None:
187
+ result["_res_" + mname] = res
188
+ else:
189
+ for key, child in node.items():
190
+ if not isinstance(child, dict) or key in ("_meta", "_meta_avg"):
191
+ continue
192
+ child_res = self._rollup(child)
193
+ for m in self.measures:
194
+ mname = m["name"]
195
+ mtype = m.get("type", "count")
196
+ if mname not in child_res:
197
+ continue
198
+ if mtype in ("count", "sum"):
199
+ result[mname] = result.get(mname, 0) + child_res[mname]
200
+ elif mtype == "avg":
201
+ c_sum = child_res.get(f"_{mname}_sum", 0)
202
+ p_sum = result.get(f"_{mname}_sum", 0)
203
+ result[f"_{mname}_sum"] = p_sum + c_sum
204
+ elif mtype == "min":
205
+ result[mname] = child_res[mname] if mname not in result \
206
+ else min(result[mname], child_res[mname])
207
+ elif mtype == "max":
208
+ result[mname] = child_res[mname] if mname not in result \
209
+ else max(result[mname], child_res[mname])
210
+ elif mtype in ("p50", "p90", "p95", "p99", "percentile"):
211
+ k = m.get("sample_size", 128)
212
+ res_key = "_res_" + mname
213
+ child_res_obj = child_res.get(res_key)
214
+ merged = _Reservoir.merge(
215
+ result.get(res_key), child_res_obj, k)
216
+ if merged is not None:
217
+ result[res_key] = merged
218
+ q = m.get("q") or _MEASURE_TYPE_Q.get(mtype, 0.5)
219
+ result[mname] = merged.quantile(q)
220
+
221
+ total = result["_total"]
222
+ for m in self.measures:
223
+ if m.get("type") == "avg":
224
+ sum_key = f"_{m['name']}_sum"
225
+ if sum_key in result and total > 0:
226
+ result[m["name"]] = result[sum_key] / total
227
+ return result
228
+
229
+ # ---- formatters ------------------------------------------------------
230
+
231
+ def _fmt_measures(self, values: Dict, human_bytes: bool) -> List[str]:
232
+ out = []
233
+ for m in self.measures:
234
+ mname = m["name"]
235
+ if mname not in values:
236
+ continue
237
+ val = values[mname]
238
+ mtype = m.get("type", "count")
239
+ field = m.get("field", mname)
240
+ if human_bytes and (field == "size" or "byte" in mname.lower()):
241
+ out.append(f"{mname}={_hbytes(val)}")
242
+ elif mtype == "avg" or mtype in ("p50", "p90", "p95", "p99", "percentile"):
243
+ out.append(f"{mname}={val:.3f}")
244
+ elif mtype == "sum":
245
+ out.append(f"{mname}={val:.1f}")
246
+ else:
247
+ out.append(f"{mname}={int(val)}" if not isinstance(val, float)
248
+ else f"{mname}={val:g}")
249
+ return out
250
+
251
+ def _format_tree(self, node: Dict, depth: int, top_n: Optional[int],
252
+ min_count: Optional[int], human_bytes: bool,
253
+ max_depth: Optional[int], lines: List[str]):
254
+ if max_depth is not None and depth >= max_depth:
255
+ return
256
+ children = []
257
+ for k, v in node.items():
258
+ if k in ("_meta", "_meta_avg") or not isinstance(v, dict):
259
+ continue
260
+ cnt = v.get("_meta", {}).get("count", 0)
261
+ if min_count is not None and cnt < min_count:
262
+ continue
263
+ children.append((k, v, cnt))
264
+ children.sort(key=lambda x: -x[2])
265
+ if top_n is not None:
266
+ children = children[:top_n]
267
+
268
+ indent = " " * depth
269
+ for key, child, count in children:
270
+ rolled = self._rollup(child)
271
+ m_strs = self._fmt_measures(rolled, human_bytes)
272
+ if m_strs:
273
+ lines.append(f"{indent}{key}\t{count}\t{', '.join(m_strs)}")
274
+ else:
275
+ lines.append(f"{indent}{key}\t{count}")
276
+ self._format_tree(child, depth + 1, top_n, min_count, human_bytes,
277
+ max_depth, lines)
278
+
279
+ def format_tree(self, top_n=None, min_count=None, human_bytes=False,
280
+ max_depth=None, max_lines=None) -> str:
281
+ lines: List[str] = []
282
+ self._format_tree(self.data, 0, top_n, min_count, human_bytes,
283
+ max_depth, lines)
284
+ return _clip(lines, max_lines)
285
+
286
+ def format_flat(self, top_n=None, min_count=None, human_bytes=False,
287
+ max_lines=None, sep=">") -> str:
288
+ """Breadcrumb rows: dim1>dim2>...>dimN <TAB> count <TAB> measures."""
289
+ rows: List[tuple] = []
290
+
291
+ def walk(node: Dict, path: List[str]):
292
+ children = [(k, v) for k, v in node.items()
293
+ if isinstance(v, dict) and k not in ("_meta", "_meta_avg")]
294
+ if not children:
295
+ cnt = node.get("_meta", {}).get("count", 0)
296
+ if min_count is not None and cnt < min_count:
297
+ return
298
+ m_strs = self._fmt_measures(self._rollup(node), human_bytes)
299
+ rows.append((path, cnt, m_strs))
300
+ return
301
+ children.sort(key=lambda x: -x[1].get("_meta", {}).get("count", 0))
302
+ if top_n is not None:
303
+ children = children[:top_n]
304
+ for k, v in children:
305
+ walk(v, path + [str(k)])
306
+
307
+ walk(self.data, [])
308
+ rows.sort(key=lambda r: -r[1])
309
+ lines = []
310
+ for path, cnt, m_strs in rows:
311
+ key = sep.join(path) if path else "(root)"
312
+ suffix = "\t" + ", ".join(m_strs) if m_strs else ""
313
+ lines.append(f"{key}\t{cnt}{suffix}")
314
+ return _clip(lines, max_lines)
315
+
316
+ def format_compact(self, max_lines=None) -> str:
317
+ def count_items(node):
318
+ if "_meta" in node:
319
+ return node["_meta"]["count"]
320
+ return sum(count_items(v) for v in node.values() if isinstance(v, dict))
321
+ items = []
322
+ for k, v in self.data.items():
323
+ if not isinstance(v, dict) or k in ("_meta", "_meta_avg"):
324
+ continue
325
+ items.append((k, count_items(v)))
326
+ items.sort(key=lambda x: x[1], reverse=True)
327
+ lines = [f"{k}: {v}" for k, v in items]
328
+ return _clip(lines, max_lines)
329
+
330
+ def format_csv(self, human_bytes=False, max_lines=None) -> str:
331
+ headers = list(self.dimensions) + ["count"] + [m["name"] for m in self.measures]
332
+ rows = [",".join(headers)]
333
+
334
+ def walk(node, path):
335
+ children = [(k, v) for k, v in node.items()
336
+ if isinstance(v, dict) and k not in ("_meta", "_meta_avg")]
337
+ if not children:
338
+ cnt = node.get("_meta", {}).get("count", 0)
339
+ rolled = self._rollup(node)
340
+ cells = list(path) + [str(cnt)]
341
+ for m in self.measures:
342
+ v = rolled.get(m["name"], "")
343
+ if isinstance(v, float):
344
+ v = f"{v:.4f}"
345
+ cells.append(str(v))
346
+ rows.append(",".join(_csv(c) for c in cells))
347
+ return
348
+ for k, v in children:
349
+ walk(v, path + [str(k)])
350
+
351
+ walk(self.data, [])
352
+ if max_lines is not None and len(rows) - 1 > max_lines:
353
+ truncated = len(rows) - 1 - max_lines
354
+ rows = rows[:1 + max_lines] + [f"# (+{truncated} more)"]
355
+ return "\n".join(rows)
356
+
357
+ def format_json(self) -> str:
358
+ return json.dumps(self.data, indent=2, ensure_ascii=False, default=str)
359
+
360
+ def _iter_edges(self, min_count=None):
361
+ """Yield ((src, dst), weight) pairs for the first two dimensions.
362
+
363
+ Every leaf whose path is >= 2 becomes an edge; deeper dimensions are
364
+ collapsed into the count."""
365
+ edges: Dict[tuple, int] = {}
366
+
367
+ def walk(node, path):
368
+ children = [(k, v) for k, v in node.items()
369
+ if isinstance(v, dict) and k not in ("_meta", "_meta_avg")]
370
+ if not children:
371
+ if len(path) >= 2:
372
+ cnt = node.get("_meta", {}).get("count", 0)
373
+ if min_count is None or cnt >= min_count:
374
+ edges[(str(path[0]), str(path[1]))] = \
375
+ edges.get((str(path[0]), str(path[1])), 0) + cnt
376
+ return
377
+ for k, v in children:
378
+ walk(v, path + [k])
379
+
380
+ walk(self.data, [])
381
+ return edges
382
+
383
+ def format_mermaid(self, top_n=None, min_count=None, direction="LR") -> str:
384
+ """Mermaid flowchart syntax. Works with 2 dimensions treated as edges."""
385
+ if len(self.dimensions) < 2:
386
+ return "%% mermaid output requires at least 2 dimensions"
387
+ edges = self._iter_edges(min_count=min_count)
388
+ ranked = sorted(edges.items(), key=lambda kv: -kv[1])
389
+ if top_n is not None:
390
+ ranked = ranked[:top_n]
391
+ lines = [f"flowchart {direction}"]
392
+ # mermaid identifiers must be safe; encode via node_N mapping
393
+ node_id: Dict[str, str] = {}
394
+
395
+ def nid(name: str) -> str:
396
+ if name not in node_id:
397
+ node_id[name] = f"n{len(node_id)}"
398
+ return node_id[name]
399
+
400
+ for (s, d), w in ranked:
401
+ si, di = nid(s), nid(d)
402
+ sl = s.replace('"', '\\"'); dl = d.replace('"', '\\"')
403
+ lines.append(f' {si}["{sl}"] -->|{w}| {di}["{dl}"]')
404
+ return "\n".join(lines)
405
+
406
+ def format_plantuml(self, top_n=None, min_count=None) -> str:
407
+ """PlantUML component-style diagram from the first two dimensions."""
408
+ if len(self.dimensions) < 2:
409
+ return "' plantuml output requires at least 2 dimensions"
410
+ edges = self._iter_edges(min_count=min_count)
411
+ ranked = sorted(edges.items(), key=lambda kv: -kv[1])
412
+ if top_n is not None:
413
+ ranked = ranked[:top_n]
414
+ nodes = set()
415
+ for (s, d) in [kv[0] for kv in ranked]:
416
+ nodes.add(s); nodes.add(d)
417
+ node_id: Dict[str, str] = {}
418
+ for n in sorted(nodes):
419
+ node_id[n] = f"N{len(node_id)}"
420
+ lines = ["@startuml", "skinparam componentStyle rectangle", ""]
421
+ for n in sorted(nodes):
422
+ safe = n.replace('"', '\\"')
423
+ lines.append(f'component "{safe}" as {node_id[n]}')
424
+ lines.append("")
425
+ for (s, d), w in ranked:
426
+ lines.append(f'{node_id[s]} --> {node_id[d]} : {w}')
427
+ lines.append("@enduml")
428
+ return "\n".join(lines)
429
+
430
+ def format_drawio(self, top_n=None, min_count=None) -> str:
431
+ """Minimal draw.io / diagrams.net XML — importable via File → Import.
432
+
433
+ Layout is naive (grid), positions can be re-flowed in-app with Ctrl-L.
434
+ """
435
+ if len(self.dimensions) < 2:
436
+ return "<!-- drawio output requires at least 2 dimensions -->"
437
+ edges = self._iter_edges(min_count=min_count)
438
+ ranked = sorted(edges.items(), key=lambda kv: -kv[1])
439
+ if top_n is not None:
440
+ ranked = ranked[:top_n]
441
+ nodes = set()
442
+ for (s, d) in [kv[0] for kv in ranked]:
443
+ nodes.add(s); nodes.add(d)
444
+ nodes_sorted = sorted(nodes)
445
+ node_id = {n: f"n{i+2}" for i, n in enumerate(nodes_sorted)}
446
+
447
+ def esc(s: str) -> str:
448
+ return (s.replace("&", "&amp;").replace("<", "&lt;")
449
+ .replace(">", "&gt;").replace('"', "&quot;"))
450
+
451
+ cells = ['<mxCell id="0"/>', '<mxCell id="1" parent="0"/>']
452
+ cols = max(1, int(len(nodes_sorted) ** 0.5))
453
+ for i, n in enumerate(nodes_sorted):
454
+ x = (i % cols) * 180
455
+ y = (i // cols) * 100
456
+ cells.append(
457
+ f'<mxCell id="{node_id[n]}" value="{esc(n)}" style="rounded=1;whiteSpace=wrap;" '
458
+ f'vertex="1" parent="1"><mxGeometry x="{x}" y="{y}" width="140" height="40" as="geometry"/></mxCell>'
459
+ )
460
+ for j, ((s, d), w) in enumerate(ranked):
461
+ eid = f"e{j+1}"
462
+ cells.append(
463
+ f'<mxCell id="{eid}" style="endArrow=block;html=1;" edge="1" parent="1" '
464
+ f'source="{node_id[s]}" target="{node_id[d]}" value="{w}">'
465
+ f'<mxGeometry relative="1" as="geometry"/></mxCell>'
466
+ )
467
+ body = "\n ".join(cells)
468
+ return (
469
+ '<mxfile host="app.diagrams.net">\n'
470
+ ' <diagram name="cubest" id="dyn">\n'
471
+ ' <mxGraphModel dx="800" dy="600" grid="1" gridSize="10" arrows="1" '
472
+ 'fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100">\n'
473
+ ' <root>\n'
474
+ f' {body}\n'
475
+ ' </root>\n'
476
+ ' </mxGraphModel>\n'
477
+ ' </diagram>\n'
478
+ '</mxfile>'
479
+ )
480
+
481
+ # ---- echarts (HTML with CDN) ----------------------------------------
482
+
483
+ def _to_hierarchy(self, min_count=None, max_children=None) -> List[Dict]:
484
+ """Convert cube to ECharts nested `{name, value, children}` array."""
485
+ def build(node, name):
486
+ children = []
487
+ for k, v in node.items():
488
+ if not isinstance(v, dict) or k in ("_meta", "_meta_avg"):
489
+ continue
490
+ child = build(v, str(k))
491
+ if min_count is not None and child["value"] < min_count:
492
+ continue
493
+ children.append(child)
494
+ children.sort(key=lambda c: -c["value"])
495
+ if max_children is not None:
496
+ children = children[:max_children]
497
+ value = (node.get("_meta", {}).get("count", 0)
498
+ if not children else sum(c["value"] for c in children))
499
+ out = {"name": name, "value": value}
500
+ if children:
501
+ out["children"] = children
502
+ return out
503
+
504
+ roots = []
505
+ for k, v in self.data.items():
506
+ if not isinstance(v, dict) or k in ("_meta", "_meta_avg"):
507
+ continue
508
+ roots.append(build(v, str(k)))
509
+ roots.sort(key=lambda c: -c["value"])
510
+ if max_children is not None:
511
+ roots = roots[:max_children]
512
+ return roots
513
+
514
+ def _to_edges_nodes(self, top_n=None, min_count=None):
515
+ """For 2-dim cubes: return (nodes[], links[]) suitable for sankey/graph."""
516
+ edges = self._iter_edges(min_count=min_count)
517
+ ranked = sorted(edges.items(), key=lambda kv: -kv[1])
518
+ if top_n is not None:
519
+ ranked = ranked[:top_n]
520
+ nodes_set = set()
521
+ for (s, d), _ in ranked:
522
+ nodes_set.add(s); nodes_set.add(d)
523
+ # Sankey chokes on cycles — break by suffixing dst when equal to src
524
+ cleaned = []
525
+ for (s, d), w in ranked:
526
+ if s == d:
527
+ d = d + " "
528
+ nodes_set.add(d)
529
+ cleaned.append((s, d, w))
530
+ return (
531
+ [{"name": n} for n in sorted(nodes_set)],
532
+ [{"source": s, "target": d, "value": w} for (s, d, w) in cleaned],
533
+ )
534
+
535
+ def _to_bar(self, top_n=None):
536
+ """Flat one-level breakdown for a bar chart."""
537
+ items = []
538
+ for k, v in self.data.items():
539
+ if not isinstance(v, dict) or k in ("_meta", "_meta_avg"):
540
+ continue
541
+ items.append((str(k), v.get("_meta", {}).get("count", 0)))
542
+ items.sort(key=lambda x: -x[1])
543
+ if top_n is not None:
544
+ items = items[:top_n]
545
+ return [x[0] for x in items], [x[1] for x in items]
546
+
547
+ def _auto_chart_type(self) -> str:
548
+ """Pick a sensible default chart type based on cube shape."""
549
+ n_dims = len(self.dimensions)
550
+ n_top_keys = sum(1 for k, v in self.data.items()
551
+ if isinstance(v, dict) and k not in ("_meta", "_meta_avg"))
552
+ if n_dims == 1:
553
+ return "bar"
554
+ if n_dims == 2:
555
+ # Many-to-many edges → sankey; balanced hierarchy → treemap
556
+ return "sankey" if n_top_keys > 5 else "treemap"
557
+ # ≥3 dims: sunburst for compactness, tree for deep hierarchies
558
+ return "sunburst" if n_dims <= 4 else "tree"
559
+
560
+ def format_echarts(self, chart_type: str = "auto", title: str = "cubest",
561
+ top_n=None, min_count=None) -> str:
562
+ """Standalone HTML with ECharts CDN and inline data.
563
+
564
+ chart_type: auto | sunburst | treemap | sankey | graph | bar | dashboard
565
+ `dashboard` renders all applicable views side-by-side.
566
+ """
567
+ if chart_type == "auto":
568
+ chart_type = self._auto_chart_type()
569
+
570
+ # Pre-build every dataset the client might need — cheap and lets us
571
+ # ship "dashboard" mode without extra work.
572
+ hier = self._to_hierarchy(min_count=min_count, max_children=top_n)
573
+ nodes, links = self._to_edges_nodes(top_n=top_n, min_count=min_count) \
574
+ if len(self.dimensions) >= 2 else ([], [])
575
+ bar_x, bar_y = self._to_bar(top_n=top_n)
576
+
577
+ payload = {
578
+ "hierarchy": hier,
579
+ "nodes": nodes,
580
+ "links": links,
581
+ "bar_x": bar_x,
582
+ "bar_y": bar_y,
583
+ "dimensions": list(self.dimensions),
584
+ "measures": [m["name"] for m in self.measures],
585
+ "chart_type": chart_type,
586
+ "title": title,
587
+ }
588
+ return _echarts_html(payload)
589
+
590
+ def format_xml(self) -> str:
591
+ """Generic XML dump of the cube (structure-agnostic)."""
592
+ def esc(s: str) -> str:
593
+ return (s.replace("&", "&amp;").replace("<", "&lt;")
594
+ .replace(">", "&gt;").replace('"', "&quot;"))
595
+
596
+ def emit(node, name, depth):
597
+ pad = " " * depth
598
+ if isinstance(node, dict):
599
+ meta = node.get("_meta", {})
600
+ cnt = meta.get("count", 0) if isinstance(meta, dict) else 0
601
+ attrs = f' count="{cnt}"' if cnt else ""
602
+ # scalar measures as attributes
603
+ for k, v in node.items():
604
+ if k.startswith("_") or isinstance(v, dict):
605
+ continue
606
+ attrs += f' {k}="{esc(str(v))}"'
607
+ lines = [f"{pad}<{name}{attrs}>"]
608
+ for k, v in node.items():
609
+ if k.startswith("_") or not isinstance(v, dict):
610
+ continue
611
+ lines.append(emit(v, f"item key=\"{esc(str(k))}\"", depth + 1))
612
+ lines.append(f"{pad}</{name.split()[0]}>")
613
+ return "\n".join(lines)
614
+ return f"{pad}<{name}>{esc(str(node))}</{name.split()[0]}>"
615
+
616
+ return '<?xml version="1.0" encoding="UTF-8"?>\n' + emit(self.data, "cube", 0)
617
+
618
+ def format_dot(self, top_n=None, min_count=None) -> str:
619
+ """GraphViz DOT: expects exactly two dimensions treated as (src, dst).
620
+
621
+ Emits a `digraph`; edge weight = leaf count. Nodes are auto-declared.
622
+ """
623
+ if len(self.dimensions) < 2:
624
+ return "// dot output requires at least 2 dimensions (src, dst)"
625
+ lines = ["digraph G {",
626
+ " rankdir=LR;",
627
+ " node [shape=box, fontsize=10];"]
628
+ edges: Dict[tuple, int] = {}
629
+
630
+ def walk(node, path):
631
+ children = [(k, v) for k, v in node.items()
632
+ if isinstance(v, dict) and k not in ("_meta", "_meta_avg")]
633
+ if not children:
634
+ if len(path) >= 2:
635
+ src, dst = str(path[0]), str(path[1])
636
+ cnt = node.get("_meta", {}).get("count", 0)
637
+ if min_count is None or cnt >= min_count:
638
+ edges[(src, dst)] = edges.get((src, dst), 0) + cnt
639
+ return
640
+ for k, v in children:
641
+ walk(v, path + [k])
642
+
643
+ walk(self.data, [])
644
+ nodes = set()
645
+ for (s, d) in edges:
646
+ nodes.add(s); nodes.add(d)
647
+ for n in sorted(nodes):
648
+ safe = n.replace('"', '\\"')
649
+ lines.append(f' "{safe}";')
650
+ ranked = sorted(edges.items(), key=lambda kv: -kv[1])
651
+ if top_n is not None:
652
+ ranked = ranked[:top_n]
653
+ for (s, d), w in ranked:
654
+ ss = s.replace('"', '\\"'); ds = d.replace('"', '\\"')
655
+ lines.append(f' "{ss}" -> "{ds}" [label="{w}", weight={w}];')
656
+ lines.append("}")
657
+ return "\n".join(lines)
658
+
659
+ def format_yaml(self) -> str:
660
+ if HAS_YAML:
661
+ return yaml.safe_dump(self._as_plain(self.data), sort_keys=False,
662
+ allow_unicode=True, default_flow_style=False)
663
+ # fallback: minimal indented yaml
664
+ def dump(d, indent=0):
665
+ lines = []
666
+ pad = " " * indent
667
+ if isinstance(d, dict):
668
+ for k, v in d.items():
669
+ if isinstance(v, dict):
670
+ lines.append(f"{pad}{k}:")
671
+ lines.append(dump(v, indent + 1))
672
+ else:
673
+ lines.append(f"{pad}{k}: {v}")
674
+ return "\n".join(l for l in lines if l)
675
+ return dump(self._as_plain(self.data))
676
+
677
+ @staticmethod
678
+ def _as_plain(node):
679
+ if isinstance(node, dict):
680
+ return {k: OlapCube._as_plain(v) for k, v in node.items()
681
+ if k not in ("_meta_avg",)}
682
+ return node
683
+
684
+ def format_md_table(self, human_bytes=False, max_lines=None) -> str:
685
+ """Markdown table with one row per leaf record."""
686
+ cols = list(self.dimensions) + ["count"] + [m["name"] for m in self.measures]
687
+ rows = ["| " + " | ".join(cols) + " |",
688
+ "|" + "|".join(["---"] * len(cols)) + "|"]
689
+
690
+ def walk(node, path):
691
+ children = [(k, v) for k, v in node.items()
692
+ if isinstance(v, dict) and k not in ("_meta", "_meta_avg")]
693
+ if not children:
694
+ cnt = node.get("_meta", {}).get("count", 0)
695
+ rolled = self._rollup(node)
696
+ cells = list(path) + [str(cnt)]
697
+ for m in self.measures:
698
+ v = rolled.get(m["name"], "")
699
+ if isinstance(v, float):
700
+ v = f"{v:.3f}"
701
+ cells.append(str(v))
702
+ rows.append("| " + " | ".join(cells) + " |")
703
+ return
704
+ for k, v in children:
705
+ walk(v, path + [str(k)])
706
+
707
+ walk(self.data, [])
708
+ if max_lines is not None and len(rows) - 2 > max_lines:
709
+ truncated = len(rows) - 2 - max_lines
710
+ rows = rows[:2 + max_lines] + [f"| … | +{truncated} more |" + " |" * (len(cols) - 2)]
711
+ return "\n".join(rows)
712
+
713
+
714
+ ECHARTS_CDN = "https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"
715
+
716
+
717
+ def _echarts_html(payload: Dict) -> str:
718
+ """Build a standalone HTML document that renders the cube via ECharts.
719
+
720
+ Multiple chart types are shipped inline; user picks via top-nav. The
721
+ `chart_type` field in payload seeds the initial view.
722
+ """
723
+ import json as _json
724
+ data_json = _json.dumps(payload, ensure_ascii=False, default=str)
725
+ title = payload["title"].replace("<", "&lt;")
726
+ dims_label = " × ".join(payload["dimensions"]) or "—"
727
+ return f"""<!DOCTYPE html>
728
+ <html lang="en">
729
+ <head>
730
+ <meta charset="utf-8">
731
+ <title>cubest · {title}</title>
732
+ <meta name="viewport" content="width=device-width,initial-scale=1">
733
+ <script src="{ECHARTS_CDN}"></script>
734
+ <style>
735
+ html,body {{ margin:0; padding:0; font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif; background:#fafafa; color:#222; }}
736
+ header {{ padding:10px 16px; background:#1f2937; color:#f9fafb; display:flex; align-items:center; gap:16px; flex-wrap:wrap; }}
737
+ header h1 {{ font-size:15px; margin:0; font-weight:600; }}
738
+ header .dims {{ font-size:12px; opacity:.8; }}
739
+ nav {{ margin-left:auto; display:flex; gap:6px; flex-wrap:wrap; }}
740
+ nav button {{ background:#374151; color:#f9fafb; border:none; padding:6px 12px; border-radius:4px; font-size:13px; cursor:pointer; }}
741
+ nav button.active {{ background:#3b82f6; }}
742
+ nav button:disabled {{ opacity:.35; cursor:not-allowed; }}
743
+ #chart {{ width:100vw; height:calc(100vh - 48px); }}
744
+ footer {{ position:fixed; bottom:6px; right:12px; font-size:11px; color:#9ca3af; }}
745
+ </style>
746
+ </head>
747
+ <body>
748
+ <header>
749
+ <h1>cubest · {title}</h1>
750
+ <span class="dims">dimensions: {dims_label}</span>
751
+ <nav id="nav"></nav>
752
+ </header>
753
+ <div id="chart"></div>
754
+ <footer>rendered offline · data embedded · <a href="https://echarts.apache.org/" style="color:#9ca3af">Apache ECharts 5</a></footer>
755
+ <script>
756
+ const PAYLOAD = {data_json};
757
+ const el = document.getElementById('chart');
758
+ const chart = echarts.init(el);
759
+ window.addEventListener('resize', () => chart.resize());
760
+
761
+ const AVAIL = {{
762
+ sunburst: PAYLOAD.hierarchy.length > 0 && PAYLOAD.dimensions.length >= 2,
763
+ tree: PAYLOAD.hierarchy.length > 0,
764
+ treemap: PAYLOAD.hierarchy.length > 0,
765
+ sankey: PAYLOAD.links.length > 0,
766
+ graph: PAYLOAD.links.length > 0,
767
+ bar: PAYLOAD.bar_x.length > 0,
768
+ }};
769
+
770
+ function optSunburst() {{
771
+ return {{
772
+ tooltip: {{ trigger: 'item', formatter: '{{b}}<br/>value: {{c}}' }},
773
+ series: [{{
774
+ type: 'sunburst',
775
+ data: PAYLOAD.hierarchy,
776
+ radius: [0, '95%'],
777
+ sort: (a, b) => (b.value||0) - (a.value||0),
778
+ label: {{ rotate: 'radial', minAngle: 5 }},
779
+ levels: [{{}}, {{itemStyle:{{borderWidth:2}}}}, {{itemStyle:{{borderWidth:1}}}}, {{itemStyle:{{borderWidth:1}}}}],
780
+ emphasis: {{ focus: 'ancestor' }},
781
+ }}]
782
+ }};
783
+ }}
784
+
785
+ function optTreemap() {{
786
+ return {{
787
+ tooltip: {{ trigger: 'item', formatter: p => `${{p.treePathInfo.map(n=>n.name).join(' › ')}}<br/>value: ${{p.value}}` }},
788
+ series: [{{
789
+ type: 'treemap',
790
+ data: PAYLOAD.hierarchy,
791
+ leafDepth: 3,
792
+ roam: true,
793
+ breadcrumb: {{ show: true }},
794
+ label: {{ show: true, formatter: '{{b}}' }},
795
+ upperLabel: {{ show: true, height: 20 }},
796
+ levels: [
797
+ {{ itemStyle: {{ borderColor:'#fff', borderWidth:2, gapWidth:2 }} }},
798
+ {{ itemStyle: {{ borderColor:'#e5e7eb', borderWidth:1, gapWidth:1 }} }},
799
+ {{ colorSaturation: [0.35, 0.6], itemStyle:{{ borderWidth:1, gapWidth:1 }} }},
800
+ ],
801
+ }}]
802
+ }};
803
+ }}
804
+
805
+ function optSankey() {{
806
+ return {{
807
+ tooltip: {{ trigger: 'item' }},
808
+ series: [{{
809
+ type: 'sankey',
810
+ data: PAYLOAD.nodes,
811
+ links: PAYLOAD.links,
812
+ emphasis: {{ focus: 'adjacency' }},
813
+ lineStyle: {{ color: 'gradient', curveness: 0.5 }},
814
+ label: {{ fontSize: 11 }},
815
+ nodeAlign: 'left',
816
+ }}]
817
+ }};
818
+ }}
819
+
820
+ function optGraph() {{
821
+ const nodes = PAYLOAD.nodes.map(n => ({{
822
+ name: n.name,
823
+ symbolSize: 10 + Math.min(40, (PAYLOAD.links.filter(l => l.source===n.name || l.target===n.name).length)*2),
824
+ draggable: true,
825
+ label: {{ show: true, fontSize: 10 }},
826
+ }}));
827
+ return {{
828
+ tooltip: {{ trigger: 'item' }},
829
+ series: [{{
830
+ type: 'graph',
831
+ layout: 'force',
832
+ data: nodes,
833
+ links: PAYLOAD.links,
834
+ roam: true,
835
+ force: {{ repulsion: 220, edgeLength: 90, gravity: 0.05 }},
836
+ lineStyle: {{ curveness: 0.15, opacity: 0.65 }},
837
+ emphasis: {{ focus: 'adjacency' }},
838
+ }}]
839
+ }};
840
+ }}
841
+
842
+ function optBar() {{
843
+ return {{
844
+ tooltip: {{ trigger: 'axis' }},
845
+ grid: {{ left: 60, right: 30, top: 20, bottom: 100 }},
846
+ xAxis: {{ type: 'category', data: PAYLOAD.bar_x, axisLabel: {{ rotate: 40, interval: 0 }} }},
847
+ yAxis: {{ type: 'value' }},
848
+ series: [{{ type: 'bar', data: PAYLOAD.bar_y, itemStyle: {{ color: '#3b82f6' }} }}],
849
+ }};
850
+ }}
851
+
852
+ function optTree() {{
853
+ // ECharts `tree` expects a single root — wrap the hierarchy under a synthetic node.
854
+ const roots = PAYLOAD.hierarchy;
855
+ const root = roots.length === 1 ? roots[0] : {{ name: PAYLOAD.title || 'root', children: roots }};
856
+ return {{
857
+ tooltip: {{ trigger: 'item', formatter: '{{b}}<br/>value: {{c}}' }},
858
+ series: [{{
859
+ type: 'tree',
860
+ data: [root],
861
+ top: '2%', bottom: '2%', left: '10%', right: '15%',
862
+ symbolSize: 8,
863
+ orient: 'LR',
864
+ layout: 'orthogonal',
865
+ initialTreeDepth: 2,
866
+ roam: true,
867
+ expandAndCollapse: true,
868
+ label: {{ position: 'left', verticalAlign: 'middle', align: 'right', fontSize: 11 }},
869
+ leaves: {{ label: {{ position: 'right', verticalAlign: 'middle', align: 'left' }} }},
870
+ emphasis: {{ focus: 'descendant' }},
871
+ }}]
872
+ }};
873
+ }}
874
+
875
+ const VIEWS = {{
876
+ sunburst: {{ label: 'Sunburst', build: optSunburst }},
877
+ tree: {{ label: 'Tree', build: optTree }},
878
+ treemap: {{ label: 'Treemap', build: optTreemap }},
879
+ sankey: {{ label: 'Sankey', build: optSankey }},
880
+ graph: {{ label: 'Force graph', build: optGraph }},
881
+ bar: {{ label: 'Bar', build: optBar }},
882
+ }};
883
+
884
+ const nav = document.getElementById('nav');
885
+ Object.entries(VIEWS).forEach(([k, v]) => {{
886
+ const b = document.createElement('button');
887
+ b.textContent = v.label;
888
+ b.dataset.type = k;
889
+ b.disabled = !AVAIL[k];
890
+ b.addEventListener('click', () => render(k));
891
+ nav.appendChild(b);
892
+ }});
893
+
894
+ function render(type) {{
895
+ chart.clear();
896
+ chart.setOption(VIEWS[type].build());
897
+ [...nav.querySelectorAll('button')].forEach(b => b.classList.toggle('active', b.dataset.type === type));
898
+ }}
899
+
900
+ let initial = PAYLOAD.chart_type;
901
+ if (!AVAIL[initial]) initial = Object.keys(AVAIL).find(k => AVAIL[k]) || 'bar';
902
+ render(initial);
903
+ </script>
904
+ </body>
905
+ </html>
906
+ """
907
+
908
+
909
+ def _clip(lines: List[str], max_lines: Optional[int]) -> str:
910
+ if not lines:
911
+ return "(no data)"
912
+ if max_lines is not None and len(lines) > max_lines:
913
+ truncated = len(lines) - max_lines
914
+ lines = lines[:max_lines] + [f"… (+{truncated} more)"]
915
+ return "\n".join(lines)
916
+
917
+
918
+ # ---------------------------------------------------------------------------
919
+ # Extractor
920
+ # ---------------------------------------------------------------------------
921
+
922
+ class Extractor:
923
+ def __init__(self, rules: List[Dict]):
924
+ self.rules = rules
925
+ self._compiled: List[Any] = []
926
+ for r in rules:
927
+ rtype = r.get("type", "regex")
928
+ if rtype == "regex":
929
+ flags = 0
930
+ if r.get("multiline", True):
931
+ flags |= re.MULTILINE
932
+ if r.get("ignorecase", False):
933
+ flags |= re.IGNORECASE
934
+ self._compiled.append(re.compile(r.get("pattern", ""), flags))
935
+ elif rtype == "lines":
936
+ self._compiled.append(re.compile(r.get("pattern", ".*")))
937
+ else:
938
+ self._compiled.append(None)
939
+
940
+ def extract(self, content: str, filepath: str) -> List[Dict]:
941
+ records: List[Dict] = []
942
+ for rule, compiled in zip(self.rules, self._compiled):
943
+ rtype = rule.get("type", "regex")
944
+ if rtype == "regex":
945
+ for m in compiled.finditer(content):
946
+ rec = {"_file": filepath, "_line": content[:m.start()].count("\n") + 1}
947
+ rec.update(m.groupdict())
948
+ _coerce(rec)
949
+ records.append(rec)
950
+ elif rtype == "preset":
951
+ records.extend(self._preset_content(rule, content, filepath))
952
+ elif rtype == "lines":
953
+ for i, line in enumerate(content.splitlines(), 1):
954
+ m = compiled.match(line)
955
+ if m:
956
+ rec = {"_file": filepath, "_line": i}
957
+ rec.update(m.groupdict())
958
+ _coerce(rec)
959
+ records.append(rec)
960
+ return records
961
+
962
+ def extract_line(self, line: str, filepath: str, lineno: int) -> List[Dict]:
963
+ records: List[Dict] = []
964
+ for rule, compiled in zip(self.rules, self._compiled):
965
+ rtype = rule.get("type", "regex")
966
+ if rtype == "regex":
967
+ for m in compiled.finditer(line):
968
+ rec = {"_file": filepath, "_line": lineno}
969
+ rec.update(m.groupdict())
970
+ _coerce(rec)
971
+ records.append(rec)
972
+ elif rtype == "lines":
973
+ m = compiled.match(line)
974
+ if m:
975
+ rec = {"_file": filepath, "_line": lineno}
976
+ rec.update(m.groupdict())
977
+ _coerce(rec)
978
+ records.append(rec)
979
+ elif rtype == "preset":
980
+ preset = rule.get("preset", "lines")
981
+ if preset == "paths":
982
+ if lineno == 0:
983
+ records.append(_path_record(filepath, rule))
984
+ elif preset == "headers":
985
+ if line.startswith("#"):
986
+ level = len(line) - len(line.lstrip("#"))
987
+ records.append({"_file": filepath, "_line": lineno,
988
+ "level": level,
989
+ "title": line.lstrip("# ").strip()})
990
+ elif preset == "lines":
991
+ p = Path(filepath)
992
+ stripped = line.strip()
993
+ records.append({"_file": filepath, "_line": lineno,
994
+ "line": line, "length": len(line),
995
+ "ext": p.suffix.lstrip("."),
996
+ "top": p.parts[0] if len(p.parts) > 1 else "",
997
+ "name": p.name,
998
+ "blank": 1 if not stripped else 0,
999
+ "comment": 1 if stripped.startswith(("#", "//", "--", "/*", "*")) else 0})
1000
+ elif preset == "funcs":
1001
+ for rec in _extract_funcs(line, filepath):
1002
+ rec["_line"] = lineno
1003
+ records.append(rec)
1004
+ elif preset == "md_checklist":
1005
+ m = re.match(r'^\s*-\s*\[([xX ])\]\s*(.*)', line)
1006
+ if m:
1007
+ state = "done" if m.group(1).strip().lower() == "x" else "todo"
1008
+ records.append({"_file": filepath, "_line": lineno,
1009
+ "state": state, "title": m.group(2).strip()})
1010
+ # md_frontmatter is batch-only (needs whole file); silently ignored here.
1011
+ return records
1012
+
1013
+ def _preset_content(self, rule: Dict, content: str, filepath: str) -> List[Dict]:
1014
+ preset = rule.get("preset", "lines")
1015
+ out: List[Dict] = []
1016
+ if preset == "paths":
1017
+ out.append(_path_record(filepath, rule))
1018
+ elif preset == "headers":
1019
+ for i, line in enumerate(content.splitlines(), 1):
1020
+ if line.startswith("#"):
1021
+ level = len(line) - len(line.lstrip("#"))
1022
+ out.append({"_file": filepath, "_line": i,
1023
+ "level": level, "title": line.lstrip("# ").strip()})
1024
+ elif preset == "lines":
1025
+ p = Path(filepath)
1026
+ ext = p.suffix.lstrip(".")
1027
+ top = p.parts[0] if len(p.parts) > 1 else ""
1028
+ for i, line in enumerate(content.splitlines(), 1):
1029
+ stripped = line.strip()
1030
+ out.append({"_file": filepath, "_line": i,
1031
+ "line": line, "length": len(line),
1032
+ "ext": ext, "top": top, "name": p.name,
1033
+ "blank": 1 if not stripped else 0,
1034
+ "comment": 1 if stripped.startswith(("#", "//", "--", "/*", "*")) else 0})
1035
+ elif preset == "funcs":
1036
+ out.extend(_extract_funcs(content, filepath))
1037
+ elif preset == "calls":
1038
+ out.extend(_extract_calls(content, filepath))
1039
+ elif preset == "md_checklist":
1040
+ for i, line in enumerate(content.splitlines(), 1):
1041
+ m = re.match(r'^\s*-\s*\[([xX ])\]\s*(.*)', line)
1042
+ if m:
1043
+ state = "done" if m.group(1).strip().lower() == "x" else "todo"
1044
+ out.append({"_file": filepath, "_line": i,
1045
+ "state": state, "title": m.group(2).strip()})
1046
+ elif preset == "md_frontmatter":
1047
+ m = re.match(r'\A---\r?\n([\s\S]{0,8000}?)\r?\n---', content)
1048
+ if m:
1049
+ rec = {"_file": filepath, "_line": 1}
1050
+ for ln in m.group(1).splitlines():
1051
+ km = re.match(r'^([a-zA-Z_][\w-]*)\s*:\s*(.+?)\s*$', ln)
1052
+ if km:
1053
+ rec[km.group(1).lower()] = km.group(2).strip('"\'')
1054
+ out.append(rec)
1055
+ elif preset in ("csv", "tsv"):
1056
+ out.extend(_extract_csv(content, filepath, rule, preset))
1057
+ elif preset == "html_meta":
1058
+ out.extend(_extract_html_meta(content, filepath))
1059
+ elif preset == "html_headings":
1060
+ out.extend(_extract_html_headings(content, filepath))
1061
+ elif preset == "sitemap":
1062
+ out.extend(_extract_sitemap(content, filepath))
1063
+ return out
1064
+
1065
+
1066
+ # Language table for multi-language func detection. Each entry: (lang, regex,
1067
+ # kind_group_or_literal, name_group). Regex matches on lstripped line.
1068
+ _FUNC_PATTERNS = [
1069
+ (".py", re.compile(r'^(?P<kind>async\s+def|def|class)\s+(?P<name>\w+)\s*[\(:]')),
1070
+ (".pyi", re.compile(r'^(?P<kind>async\s+def|def|class)\s+(?P<name>\w+)\s*[\(:]')),
1071
+ (".js", re.compile(r'^(?:export\s+(?:default\s+)?)?(?P<kind>async\s+function|function|class)\s+(?P<name>\w+)')),
1072
+ (".ts", re.compile(r'^(?:export\s+(?:default\s+)?)?(?P<kind>async\s+function|function|class|interface|type|enum)\s+(?P<name>\w+)')),
1073
+ (".jsx", re.compile(r'^(?:export\s+(?:default\s+)?)?(?P<kind>function|class|const|let|var)\s+(?P<name>[A-Z]\w*)')),
1074
+ (".tsx", re.compile(r'^(?:export\s+(?:default\s+)?)?(?P<kind>function|class|const|let|var)\s+(?P<name>[A-Z]\w*)')),
1075
+ (".go", re.compile(r'^(?P<kind>func|type|interface|struct)\s+(?:\([^)]*\)\s+)?(?P<name>\w+)')),
1076
+ (".rs", re.compile(r'^(?:pub\s+)?(?P<kind>fn|struct|enum|trait|impl|type|mod)\s+(?P<name>\w+)')),
1077
+ (".java", re.compile(r'^(?:public|private|protected|static|final|\s)*(?P<kind>class|interface|enum|record|[\w<>\[\]]+)\s+(?P<name>\w+)\s*\(')),
1078
+ (".c", re.compile(r'^(?:static\s+|inline\s+)*[\w\*\s]+?(?P<kind>\w+)?\s*\**\s*(?P<name>\w+)\s*\([^;]*\)\s*\{?$')),
1079
+ (".cpp", re.compile(r'^(?:static\s+|inline\s+)*[\w:<>\*\s&]+?(?P<kind>\w+)?\s*\**\s*(?P<name>\w+)\s*\([^;]*\)\s*(?:const\s+)?\{?$')),
1080
+ (".rb", re.compile(r'^(?P<kind>def|class|module)\s+(?P<name>[\w.:!?=]+)')),
1081
+ (".php", re.compile(r'^(?:public\s+|private\s+|protected\s+|static\s+)*(?P<kind>function|class|interface|trait)\s+(?P<name>\w+)')),
1082
+ (".kt", re.compile(r'^(?:internal\s+|public\s+|private\s+)?(?P<kind>fun|class|object|interface)\s+(?P<name>\w+)')),
1083
+ (".swift",re.compile(r'^(?:public\s+|private\s+|internal\s+)?(?P<kind>func|class|struct|enum|protocol)\s+(?P<name>\w+)')),
1084
+ (".sh", re.compile(r'^(?:function\s+)?(?P<name>\w+)\s*\(\s*\)\s*\{')), # kind implicit
1085
+ ]
1086
+
1087
+ _INDENT_LANGS = (".py", ".pyi")
1088
+
1089
+
1090
+ def _extract_funcs(content: str, filepath: str) -> List[Dict]:
1091
+ ext = os.path.splitext(filepath)[1].lower()
1092
+ candidates = [rx for suf, rx in _FUNC_PATTERNS if suf == ext]
1093
+ if not candidates:
1094
+ # fallback for unknown extensions: python-style
1095
+ candidates = [_FUNC_PATTERNS[0][1]]
1096
+
1097
+ out: List[Dict] = []
1098
+ stack: List[tuple] = [] # (indent, name) — for python indent-based nesting
1099
+ py_like = ext in _INDENT_LANGS
1100
+ for i, line in enumerate(content.splitlines(), 1):
1101
+ stripped = line.lstrip()
1102
+ if not stripped:
1103
+ continue
1104
+ indent = len(line) - len(stripped)
1105
+ match = None
1106
+ for rx in candidates:
1107
+ match = rx.match(stripped)
1108
+ if match:
1109
+ break
1110
+ if not match:
1111
+ continue
1112
+ gd = match.groupdict()
1113
+ name = gd.get("name") or ""
1114
+ if not name:
1115
+ continue
1116
+ kind = gd.get("kind") or "func"
1117
+ if py_like:
1118
+ while stack and stack[-1][0] >= indent:
1119
+ stack.pop()
1120
+ parent = stack[-1][1] if stack else ""
1121
+ depth = len(stack)
1122
+ stack.append((indent, name))
1123
+ else:
1124
+ parent = ""
1125
+ depth = 0
1126
+ out.append({
1127
+ "_file": filepath, "_line": i,
1128
+ "kind": kind.strip(), "name": name,
1129
+ "parent": parent, "depth": depth,
1130
+ "lang": ext.lstrip("."),
1131
+ })
1132
+ return out
1133
+
1134
+
1135
+ def _extract_csv(content: str, filepath: str, rule: Dict, preset: str) -> List[Dict]:
1136
+ """Parse CSV/TSV using stdlib csv. Header row → field names.
1137
+
1138
+ Rule options:
1139
+ sep: "," (auto ',' for csv / '\\t' for tsv)
1140
+ header: true|false|[explicit,fields] — default true
1141
+ quotechar: '"'
1142
+ skip: 0 — extra rows to drop
1143
+ """
1144
+ sep = rule.get("sep", "\t" if preset == "tsv" else ",")
1145
+ quote = rule.get("quotechar", '"')
1146
+ skip = int(rule.get("skip", 0))
1147
+ header_cfg = rule.get("header", True)
1148
+
1149
+ reader = _csv_lib.reader(io.StringIO(content), delimiter=sep, quotechar=quote)
1150
+ rows = iter(reader)
1151
+ for _ in range(skip):
1152
+ try:
1153
+ next(rows)
1154
+ except StopIteration:
1155
+ return []
1156
+
1157
+ if header_cfg is True:
1158
+ try:
1159
+ raw_headers = next(rows)
1160
+ except StopIteration:
1161
+ return []
1162
+ headers = [_slug(h) for h in raw_headers]
1163
+ start_line = 2 + skip
1164
+ elif isinstance(header_cfg, list):
1165
+ headers = [_slug(h) for h in header_cfg]
1166
+ start_line = 1 + skip
1167
+ else:
1168
+ # No header — use col_0, col_1, ...
1169
+ first = None
1170
+ try:
1171
+ first = next(rows)
1172
+ except StopIteration:
1173
+ return []
1174
+ headers = [f"col_{i}" for i in range(len(first))]
1175
+ # need to inject `first` back
1176
+ rows = _chain_iter([first], rows)
1177
+ start_line = 1 + skip
1178
+
1179
+ out: List[Dict] = []
1180
+ for i, row in enumerate(rows, start_line):
1181
+ if not row:
1182
+ continue
1183
+ rec = {"_file": filepath, "_line": i}
1184
+ for h, v in zip(headers, row):
1185
+ rec[h] = v
1186
+ _coerce(rec)
1187
+ out.append(rec)
1188
+ return out
1189
+
1190
+
1191
+ def _slug(s: str) -> str:
1192
+ """Column-name normaliser: strip, lowercase, non-alnum → _."""
1193
+ s = s.strip().lower()
1194
+ return re.sub(r'[^a-z0-9_]+', '_', s).strip('_') or "col"
1195
+
1196
+
1197
+ def _chain_iter(*iters):
1198
+ for it in iters:
1199
+ for x in it:
1200
+ yield x
1201
+
1202
+
1203
+ # ---------------- SEO / HTML / sitemap extractors ----------------
1204
+ _TAG_STRIP = re.compile(r"<[^>]+>")
1205
+ _WS_RE = re.compile(r"\s+")
1206
+
1207
+
1208
+ def _text(html_fragment: str, limit: int = 300) -> str:
1209
+ txt = _TAG_STRIP.sub(" ", html_fragment or "")
1210
+ txt = _WS_RE.sub(" ", txt).strip()
1211
+ return txt[:limit]
1212
+
1213
+
1214
+ def _extract_html_meta(content: str, filepath: str) -> List[Dict]:
1215
+ """Return one record per HTML file with SEO-relevant meta fields.
1216
+
1217
+ Fields: title, description, keywords, canonical, robots,
1218
+ h1_count, h1_first, h2_count, og_title, og_description,
1219
+ twitter_card, lang, has_schema (JSON-LD).
1220
+ """
1221
+ rec: Dict[str, Any] = {"_file": filepath, "_line": 1}
1222
+ m = re.search(r"<title[^>]*>(.*?)</title>", content, re.I | re.S)
1223
+ rec["title"] = _text(m.group(1)) if m else ""
1224
+ rec["title_len"] = len(rec["title"])
1225
+
1226
+ def meta(name_attr: str, name_val: str) -> str:
1227
+ pat = rf'<meta\s+[^>]*{name_attr}\s*=\s*["\']{re.escape(name_val)}["\'][^>]*content\s*=\s*["\'](?P<v>[^"\']*)'
1228
+ m = re.search(pat, content, re.I)
1229
+ if m:
1230
+ return _text(m.group("v"), 500)
1231
+ pat2 = rf'<meta\s+[^>]*content\s*=\s*["\'](?P<v>[^"\']*)["\'][^>]*{name_attr}\s*=\s*["\']{re.escape(name_val)}'
1232
+ m = re.search(pat2, content, re.I)
1233
+ return _text(m.group("v"), 500) if m else ""
1234
+
1235
+ rec["description"] = meta("name", "description")
1236
+ rec["desc_len"] = len(rec["description"])
1237
+ rec["keywords"] = meta("name", "keywords")
1238
+ rec["robots"] = meta("name", "robots")
1239
+ rec["og_title"] = meta("property", "og:title")
1240
+ rec["og_description"] = meta("property", "og:description")
1241
+ rec["twitter_card"] = meta("name", "twitter:card")
1242
+
1243
+ m = re.search(r'<link\s+[^>]*rel\s*=\s*["\']canonical["\'][^>]*href\s*=\s*["\']([^"\']+)', content, re.I)
1244
+ rec["canonical"] = m.group(1) if m else ""
1245
+
1246
+ m = re.search(r'<html\s+[^>]*lang\s*=\s*["\']([^"\']+)', content, re.I)
1247
+ rec["lang"] = m.group(1) if m else ""
1248
+
1249
+ h1s = re.findall(r"<h1[^>]*>(.*?)</h1>", content, re.I | re.S)
1250
+ rec["h1_count"] = len(h1s)
1251
+ rec["h1_first"] = _text(h1s[0]) if h1s else ""
1252
+ rec["h2_count"] = len(re.findall(r"<h2[^>]*>", content, re.I))
1253
+ rec["h3_count"] = len(re.findall(r"<h3[^>]*>", content, re.I))
1254
+ rec["has_schema"] = 1 if 'application/ld+json' in content.lower() else 0
1255
+
1256
+ return [rec]
1257
+
1258
+
1259
+ def _extract_html_headings(content: str, filepath: str) -> List[Dict]:
1260
+ """Emit one record per heading with `level` (1..6), `title`, `_file`."""
1261
+ out: List[Dict] = []
1262
+ for m in re.finditer(r"<h(?P<level>[1-6])[^>]*>(?P<body>.*?)</h(?P=level)>",
1263
+ content, re.I | re.S):
1264
+ out.append({
1265
+ "_file": filepath, "_line": content[:m.start()].count("\n") + 1,
1266
+ "level": int(m.group("level")),
1267
+ "title": _text(m.group("body")),
1268
+ })
1269
+ return out
1270
+
1271
+
1272
+ def _extract_sitemap(content: str, filepath: str) -> List[Dict]:
1273
+ """Parse sitemap.xml (single or index): one record per <url>/<sitemap>."""
1274
+ out: List[Dict] = []
1275
+ for m in re.finditer(r"<url>(?P<body>.*?)</url>", content, re.I | re.S):
1276
+ body = m.group("body")
1277
+ loc = re.search(r"<loc>(.*?)</loc>", body, re.I | re.S)
1278
+ pri = re.search(r"<priority>(.*?)</priority>", body, re.I | re.S)
1279
+ lm = re.search(r"<lastmod>(.*?)</lastmod>", body, re.I | re.S)
1280
+ chg = re.search(r"<changefreq>(.*?)</changefreq>", body, re.I | re.S)
1281
+ url = _text(loc.group(1)) if loc else ""
1282
+ try:
1283
+ path = re.sub(r"^https?://[^/]+", "", url)
1284
+ except Exception:
1285
+ path = url
1286
+ parts = [p for p in path.split("/") if p]
1287
+ rec = {
1288
+ "_file": filepath,
1289
+ "_line": content[:m.start()].count("\n") + 1,
1290
+ "url": url,
1291
+ "path": path,
1292
+ "host": (re.match(r"https?://([^/]+)", url) or [None, ""])[1] if url else "",
1293
+ "priority": _text(pri.group(1)) if pri else "",
1294
+ "lastmod": _text(lm.group(1)) if lm else "",
1295
+ "changefreq": _text(chg.group(1)) if chg else "",
1296
+ "depth": len(parts),
1297
+ "section_1": parts[0] if len(parts) >= 1 else "",
1298
+ "section_2": parts[1] if len(parts) >= 2 else "",
1299
+ "section_3": parts[2] if len(parts) >= 3 else "",
1300
+ }
1301
+ _coerce(rec)
1302
+ out.append(rec)
1303
+ if not out:
1304
+ # sitemap-index case
1305
+ for m in re.finditer(r"<sitemap>(?P<body>.*?)</sitemap>", content, re.I | re.S):
1306
+ loc = re.search(r"<loc>(.*?)</loc>", m.group("body"), re.I | re.S)
1307
+ if loc:
1308
+ out.append({"_file": filepath,
1309
+ "_line": content[:m.start()].count("\n") + 1,
1310
+ "url": _text(loc.group(1)), "kind": "index"})
1311
+ return out
1312
+
1313
+
1314
+ _CALL_RE = re.compile(r'\b(?P<callee>[A-Za-z_][\w]*)\s*\(')
1315
+ _CALL_STOPWORDS = {
1316
+ "if", "for", "while", "return", "print", "yield", "raise", "with",
1317
+ "assert", "elif", "not", "and", "or", "in", "is", "lambda", "await",
1318
+ "async", "def", "class", "self", "cls", "super", "int", "str", "float",
1319
+ "list", "dict", "set", "tuple", "bool", "range", "len", "type",
1320
+ "isinstance", "getattr", "setattr", "hasattr", "print", "open",
1321
+ "function", "var", "let", "const", "new", "typeof", "instanceof",
1322
+ }
1323
+
1324
+
1325
+ def _extract_calls(content: str, filepath: str) -> List[Dict]:
1326
+ """For python-like sources: emit (caller → callee) pairs based on indent
1327
+ stack. Skips built-in-ish tokens to reduce noise. Best-effort — for
1328
+ exact analysis use tree-sitter-based tools (e.g. Graphify)."""
1329
+ ext = os.path.splitext(filepath)[1].lower()
1330
+ py_like = ext in _INDENT_LANGS
1331
+ out: List[Dict] = []
1332
+ stack: List[tuple] = [] # (indent, name)
1333
+ current_caller = ""
1334
+ def_re = re.compile(r'^(?:async\s+def|def|class)\s+(?P<name>\w+)\s*[\(:]')
1335
+
1336
+ for i, line in enumerate(content.splitlines(), 1):
1337
+ stripped = line.lstrip()
1338
+ if not stripped or stripped.startswith(("#", "//")):
1339
+ continue
1340
+ indent = len(line) - len(stripped)
1341
+ if py_like:
1342
+ while stack and stack[-1][0] >= indent:
1343
+ stack.pop()
1344
+ current_caller = stack[-1][1] if stack else "<module>"
1345
+ dm = def_re.match(stripped)
1346
+ if dm:
1347
+ stack.append((indent, dm.group("name")))
1348
+ current_caller = dm.group("name")
1349
+ continue
1350
+ else:
1351
+ dm = def_re.match(stripped)
1352
+ if dm:
1353
+ current_caller = dm.group("name")
1354
+ continue
1355
+ for cm in _CALL_RE.finditer(stripped):
1356
+ callee = cm.group("callee")
1357
+ if callee in _CALL_STOPWORDS:
1358
+ continue
1359
+ out.append({"_file": filepath, "_line": i,
1360
+ "caller": current_caller or "<module>",
1361
+ "callee": callee, "lang": ext.lstrip(".")})
1362
+ return out
1363
+
1364
+
1365
+ def _path_record(filepath: str, rule: Dict) -> Dict:
1366
+ p = Path(filepath)
1367
+ parts = p.parts
1368
+ parent = str(p.parent).replace(os.sep, "/")
1369
+ if parent in ("", "."):
1370
+ parent = "/"
1371
+ abspath = rule.get("_abspath", filepath)
1372
+ try:
1373
+ size = os.path.getsize(abspath)
1374
+ except OSError:
1375
+ size = 0
1376
+ rec = {
1377
+ "_file": filepath, "_line": 0,
1378
+ "dir": parent, "basename": p.stem, "name": p.name,
1379
+ "ext": p.suffix.lstrip("."),
1380
+ "depth": len(parts) - 1,
1381
+ "top": parts[0] if len(parts) > 1 else "",
1382
+ "size": size,
1383
+ }
1384
+ # path prefixes for depth-N grouping (path_1 = "a", path_2 = "a/b", ...)
1385
+ # Only directory prefixes; the file itself is excluded.
1386
+ dir_parts = parts[:-1]
1387
+ for i in range(1, 6):
1388
+ rec[f"path_{i}"] = "/".join(dir_parts[:i]) if len(dir_parts) >= i else ""
1389
+ return rec
1390
+
1391
+
1392
+ def _coerce(rec: Dict):
1393
+ for k, v in list(rec.items()):
1394
+ if isinstance(v, str):
1395
+ if v.isdigit() or (v.startswith("-") and v[1:].isdigit()):
1396
+ rec[k] = int(v)
1397
+ elif re.match(r'^-?\d+\.\d+$', v):
1398
+ rec[k] = float(v)
1399
+
1400
+
1401
+ def _num(v):
1402
+ if isinstance(v, (int, float)):
1403
+ return v
1404
+ try:
1405
+ s = str(v)
1406
+ return float(s) if "." in s else int(s)
1407
+ except (ValueError, TypeError):
1408
+ return 0
1409
+
1410
+
1411
+ def _hbytes(n: float) -> str:
1412
+ n = float(n)
1413
+ for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
1414
+ if n < 1024:
1415
+ return f"{int(n)}B" if unit == "B" else f"{n:.1f}{unit}"
1416
+ n /= 1024
1417
+ return f"{n:.1f}PiB"
1418
+
1419
+
1420
+ def _csv(v: str) -> str:
1421
+ s = str(v)
1422
+ if any(c in s for c in ',"\n'):
1423
+ return '"' + s.replace('"', '""') + '"'
1424
+ return s
1425
+
1426
+
1427
+ # ---------------------------------------------------------------------------
1428
+ # Scan / filtering
1429
+ # ---------------------------------------------------------------------------
1430
+
1431
+ def _match_pattern(rel: str, name: str, pattern: str) -> bool:
1432
+ if not pattern:
1433
+ return False
1434
+ if pattern.startswith("/"):
1435
+ pat = pattern[1:]
1436
+ return fnmatch(rel, pat) or rel == pat
1437
+ if pattern.endswith("/"):
1438
+ dp = pattern[:-1]
1439
+ segs = rel.split("/")
1440
+ return any(fnmatch(seg, dp) for seg in segs[:-1]) if len(segs) > 1 else False
1441
+ if "**" in pattern:
1442
+ collapsed = re.sub(r"\*\*/?", "*", pattern)
1443
+ if fnmatch(rel, collapsed):
1444
+ return True
1445
+ if fnmatch(name, pattern):
1446
+ return True
1447
+ if "/" in pattern and fnmatch(rel, pattern):
1448
+ return True
1449
+ return pattern in rel
1450
+
1451
+
1452
+ def _split_patterns(patterns: List[str]):
1453
+ positive, negated = [], []
1454
+ for p in patterns:
1455
+ if not p:
1456
+ continue
1457
+ if p.startswith("!"):
1458
+ negated.append(p[1:])
1459
+ else:
1460
+ positive.append(p)
1461
+ return positive, negated
1462
+
1463
+
1464
+ def should_scan(path: Path, root: Path, include: List[str], exclude: List[str]) -> bool:
1465
+ try:
1466
+ rel = str(path.relative_to(root)).replace(os.sep, "/")
1467
+ except ValueError:
1468
+ rel = str(path).replace(os.sep, "/")
1469
+ name = path.name
1470
+
1471
+ inc_pos, inc_neg = _split_patterns(include)
1472
+ exc_pos, exc_neg = _split_patterns(exclude)
1473
+
1474
+ for e in exc_pos:
1475
+ if _match_pattern(rel, name, e):
1476
+ if any(_match_pattern(rel, name, n) for n in exc_neg):
1477
+ break
1478
+ return False
1479
+
1480
+ if not inc_pos:
1481
+ return True
1482
+ for i in inc_pos:
1483
+ if _match_pattern(rel, name, i):
1484
+ if any(_match_pattern(rel, name, n) for n in inc_neg):
1485
+ return False
1486
+ return True
1487
+ return False
1488
+
1489
+
1490
+ def dir_pruned(dirpath: Path, root: Path, exclude: List[str]) -> bool:
1491
+ try:
1492
+ rel = str(dirpath.relative_to(root)).replace(os.sep, "/")
1493
+ except ValueError:
1494
+ return False
1495
+ name = dirpath.name
1496
+ exc_pos, exc_neg = _split_patterns(exclude)
1497
+ if exc_neg:
1498
+ return False
1499
+ for e in exc_pos:
1500
+ if e.endswith("/"):
1501
+ if fnmatch(name, e[:-1]):
1502
+ return True
1503
+ elif "/" not in e and not any(c in e for c in "*?["):
1504
+ if name == e:
1505
+ return True
1506
+ elif _match_pattern(rel + "/", name, e):
1507
+ return True
1508
+ return False
1509
+
1510
+
1511
+ # ---------------------------------------------------------------------------
1512
+ # File IO
1513
+ # ---------------------------------------------------------------------------
1514
+
1515
+ STREAM_THRESHOLD = 10 * 1024 * 1024 # 10 MiB
1516
+
1517
+
1518
+ def open_text(path: Path):
1519
+ if path.suffix == ".gz":
1520
+ return gzip.open(path, mode="rt", encoding="utf-8", errors="ignore")
1521
+ return open(path, mode="r", encoding="utf-8", errors="ignore")
1522
+
1523
+
1524
+ def should_stream(path: Path, forced: Optional[bool]) -> bool:
1525
+ if forced is not None:
1526
+ return forced
1527
+ if path.suffix == ".gz":
1528
+ return True
1529
+ try:
1530
+ return path.stat().st_size > STREAM_THRESHOLD
1531
+ except OSError:
1532
+ return False
1533
+
1534
+
1535
+ def iter_files(root: Path, include: List[str], exclude: List[str],
1536
+ files_from: Optional[Iterable[str]] = None) -> Iterable[Path]:
1537
+ if files_from is not None:
1538
+ # Explicit file list — used for MR/PR workflows via `git diff --name-only`.
1539
+ for raw in files_from:
1540
+ raw = raw.strip()
1541
+ if not raw or raw.startswith("#"):
1542
+ continue
1543
+ p = Path(raw)
1544
+ if not p.is_absolute():
1545
+ p = root / p
1546
+ if p.is_file() and should_scan(p, root, include, exclude):
1547
+ yield p
1548
+ return
1549
+ if root.is_file():
1550
+ yield root
1551
+ return
1552
+ for dirpath, dirnames, filenames in os.walk(root):
1553
+ dp = Path(dirpath)
1554
+ dirnames[:] = sorted(
1555
+ d for d in dirnames if not dir_pruned(dp / d, root, exclude)
1556
+ )
1557
+ for fname in sorted(filenames):
1558
+ path = dp / fname
1559
+ if should_scan(path, root, include, exclude):
1560
+ yield path
1561
+
1562
+
1563
+ def content_ok(path: Path, must, must_not, sample_bytes: Optional[int]) -> bool:
1564
+ """Check that file contents satisfy must-have / must-not regexes."""
1565
+ if not must and not must_not:
1566
+ return True
1567
+ try:
1568
+ with open_text(path) as fh:
1569
+ data = fh.read(sample_bytes) if sample_bytes else fh.read()
1570
+ except Exception:
1571
+ return False
1572
+ for p in must:
1573
+ if not p.search(data):
1574
+ return False
1575
+ for p in must_not:
1576
+ if p.search(data):
1577
+ return False
1578
+ return True
1579
+
1580
+
1581
+ # ---------------------------------------------------------------------------
1582
+ # Filters
1583
+ # ---------------------------------------------------------------------------
1584
+
1585
+ _SAFE_BUILTINS = {
1586
+ "len": len, "min": min, "max": max, "abs": abs, "int": int,
1587
+ "float": float, "str": str, "bool": bool, "any": any, "all": all,
1588
+ "sum": sum, "sorted": sorted, "round": round,
1589
+ }
1590
+
1591
+
1592
+ def passes_filters(record: Dict, filters: List[str]) -> bool:
1593
+ env = {k: v for k, v in record.items() if isinstance(v, (str, int, float, bool))}
1594
+ for expr in filters:
1595
+ try:
1596
+ if not eval(expr, {"__builtins__": _SAFE_BUILTINS}, env):
1597
+ return False
1598
+ except Exception:
1599
+ return False
1600
+ return True
1601
+
1602
+
1603
+ # ---------------------------------------------------------------------------
1604
+ # Profile loading
1605
+ # ---------------------------------------------------------------------------
1606
+
1607
+ def load_profile(arg: str, profiles_dir: Path) -> Dict:
1608
+ if arg == "-":
1609
+ return parse_profile(sys.stdin.read())
1610
+ # If arg looks like inline JSON/YAML (starts with { or contains newline),
1611
+ # skip path lookup to avoid ENAMETOOLONG on giant one-liners.
1612
+ inline = arg.startswith("{") or "\n" in arg
1613
+ if not inline:
1614
+ for ext in (".yaml", ".yml", ".json", ""):
1615
+ built_in = profiles_dir / f"{arg}{ext}"
1616
+ try:
1617
+ if built_in.exists():
1618
+ return parse_profile(built_in.read_text(encoding="utf-8"))
1619
+ except OSError:
1620
+ pass
1621
+ try:
1622
+ p = Path(arg)
1623
+ if p.exists():
1624
+ return parse_profile(p.read_text(encoding="utf-8"))
1625
+ except OSError:
1626
+ pass
1627
+ try:
1628
+ return json.loads(arg)
1629
+ except json.JSONDecodeError:
1630
+ pass
1631
+ if HAS_YAML:
1632
+ try:
1633
+ return yaml.safe_load(arg)
1634
+ except Exception:
1635
+ pass
1636
+ raise ValueError(f"Cannot parse profile: {arg[:120]}...")
1637
+
1638
+
1639
+ def parse_profile(text: str) -> Dict:
1640
+ text = text.strip()
1641
+ if text.startswith("{"):
1642
+ return json.loads(text)
1643
+ if HAS_YAML:
1644
+ return yaml.safe_load(text)
1645
+ raise RuntimeError("YAML not installed, but profile is not JSON")
1646
+
1647
+
1648
+ # ---------------------------------------------------------------------------
1649
+ # Runner
1650
+ # ---------------------------------------------------------------------------
1651
+
1652
+ def run(profile: Dict, root: Path):
1653
+ scanner = profile.get("scan", {})
1654
+ rules = profile.get("extract", [])
1655
+ extractor = Extractor(rules)
1656
+ cube = OlapCube(
1657
+ dimensions=profile.get("dimensions", ["_file"]),
1658
+ measures=profile.get("measures", [{"name": "count", "type": "count"}])
1659
+ )
1660
+
1661
+ include = scanner.get("include", ["*"])
1662
+ exclude = scanner.get("exclude", [
1663
+ ".git/", "node_modules/", "__pycache__/", "*.pyc", ".claude/",
1664
+ "venv/", ".venv/", "*.lock", "dist/", "build/"
1665
+ ])
1666
+ stream_forced = scanner.get("stream")
1667
+ content_must = [re.compile(p) for p in scanner.get("content_match", [])]
1668
+ content_not = [re.compile(p) for p in scanner.get("content_not", [])]
1669
+ content_sample = scanner.get("content_scan_bytes")
1670
+
1671
+ only_paths_preset = bool(rules) and all(
1672
+ r.get("type") == "preset" and r.get("preset") == "paths" for r in rules
1673
+ )
1674
+ has_csv_preset = any(
1675
+ r.get("type") == "preset" and r.get("preset") in ("csv", "tsv")
1676
+ for r in rules
1677
+ )
1678
+ if has_csv_preset:
1679
+ # CSV/TSV need the whole content in one shot (header parsing is stateful)
1680
+ stream_forced = False
1681
+
1682
+ filters = profile.get("filters", [])
1683
+ root = root.resolve()
1684
+ file_count = 0
1685
+ record_count = 0
1686
+
1687
+ files_from = profile.get("_files_from")
1688
+ for path in iter_files(root, include, exclude, files_from=files_from):
1689
+ if root.is_file() and path == root:
1690
+ rel = root.name
1691
+ else:
1692
+ try:
1693
+ rel = str(path.relative_to(root)).replace(os.sep, "/")
1694
+ except ValueError:
1695
+ rel = str(path)
1696
+
1697
+ if (content_must or content_not) and not content_ok(
1698
+ path, content_must, content_not, content_sample):
1699
+ continue
1700
+
1701
+ for r in rules:
1702
+ if r.get("type") == "preset" and r.get("preset") == "paths":
1703
+ r["_abspath"] = str(path)
1704
+
1705
+ if only_paths_preset:
1706
+ file_count += 1
1707
+ for rec in extractor.extract("", rel):
1708
+ if passes_filters(rec, filters):
1709
+ cube.add(rec)
1710
+ record_count += 1
1711
+ continue
1712
+
1713
+ stream = should_stream(path, stream_forced)
1714
+ try:
1715
+ if stream:
1716
+ with open_text(path) as fh:
1717
+ file_count += 1
1718
+ for i, line in enumerate(fh, 1):
1719
+ line = line.rstrip("\n")
1720
+ for rec in extractor.extract_line(line, rel, i):
1721
+ if passes_filters(rec, filters):
1722
+ cube.add(rec)
1723
+ record_count += 1
1724
+ else:
1725
+ with open_text(path) as fh:
1726
+ text = fh.read()
1727
+ file_count += 1
1728
+ for rec in extractor.extract(text, rel):
1729
+ if passes_filters(rec, filters):
1730
+ cube.add(rec)
1731
+ record_count += 1
1732
+ except Exception as e:
1733
+ if profile.get("verbose"):
1734
+ print(f"# skip {rel}: {e}", file=sys.stderr)
1735
+ continue
1736
+
1737
+ out = profile.get("output", {})
1738
+ fmt = out.get("format", "tree")
1739
+ top_n = out.get("top_n")
1740
+ min_count = out.get("min_count")
1741
+ human_bytes = bool(out.get("human_bytes", False))
1742
+ max_lines = out.get("max_lines")
1743
+ max_depth = out.get("max_depth")
1744
+
1745
+ if fmt == "json":
1746
+ print(cube.format_json())
1747
+ elif fmt == "compact":
1748
+ print(cube.format_compact(max_lines=max_lines))
1749
+ elif fmt == "flat":
1750
+ print(cube.format_flat(top_n=top_n, min_count=min_count,
1751
+ human_bytes=human_bytes, max_lines=max_lines))
1752
+ elif fmt == "csv":
1753
+ print(cube.format_csv(human_bytes=human_bytes, max_lines=max_lines))
1754
+ elif fmt in ("md", "md_table", "markdown"):
1755
+ print(cube.format_md_table(human_bytes=human_bytes, max_lines=max_lines))
1756
+ elif fmt in ("yaml", "yml"):
1757
+ print(cube.format_yaml())
1758
+ elif fmt in ("dot", "graphviz"):
1759
+ print(cube.format_dot(top_n=top_n, min_count=min_count))
1760
+ elif fmt in ("mermaid", "mmd"):
1761
+ print(cube.format_mermaid(top_n=top_n, min_count=min_count,
1762
+ direction=out.get("direction", "LR")))
1763
+ elif fmt in ("plantuml", "puml"):
1764
+ print(cube.format_plantuml(top_n=top_n, min_count=min_count))
1765
+ elif fmt in ("drawio", "diagrams"):
1766
+ print(cube.format_drawio(top_n=top_n, min_count=min_count))
1767
+ elif fmt == "xml":
1768
+ print(cube.format_xml())
1769
+ elif fmt in ("echarts", "html"):
1770
+ title = out.get("title") or profile.get("name") or "cubest"
1771
+ chart_type = out.get("chart_type", "auto")
1772
+ print(cube.format_echarts(chart_type=chart_type, title=title,
1773
+ top_n=top_n, min_count=min_count))
1774
+ else:
1775
+ print(cube.format_tree(top_n=top_n, min_count=min_count,
1776
+ human_bytes=human_bytes, max_depth=max_depth,
1777
+ max_lines=max_lines))
1778
+
1779
+ if profile.get("verbose"):
1780
+ print(f"\n# scanned {file_count} files, {record_count} records",
1781
+ file=sys.stderr)
1782
+
1783
+
1784
+ def get_profiles_dir() -> Path:
1785
+ script_dir = Path(__file__).parent.resolve()
1786
+ p = script_dir / "profiles"
1787
+ return p if p.exists() else script_dir
1788
+
1789
+
1790
+ def diff_cubes(a_path: str, b_path: str) -> str:
1791
+ """Compare two cubest JSON outputs, print leaves that changed.
1792
+
1793
+ Output: markdown table `path | before | after | delta`. Useful in CI
1794
+ to catch regressions like "TODO count grew on this PR".
1795
+ """
1796
+ a = json.loads(Path(a_path).read_text())
1797
+ b = json.loads(Path(b_path).read_text())
1798
+
1799
+ def flatten(node, prefix=""):
1800
+ out = {}
1801
+ for k, v in node.items():
1802
+ if k in ("_meta", "_meta_avg") or not isinstance(v, dict):
1803
+ continue
1804
+ key = f"{prefix}>{k}" if prefix else str(k)
1805
+ has_children = any(
1806
+ isinstance(x, dict) and n not in ("_meta", "_meta_avg")
1807
+ for n, x in v.items()
1808
+ )
1809
+ if has_children:
1810
+ out.update(flatten(v, key))
1811
+ else:
1812
+ out[key] = v.get("_meta", {}).get("count", 0)
1813
+ return out
1814
+
1815
+ fa, fb = flatten(a), flatten(b)
1816
+ keys = sorted(set(fa) | set(fb))
1817
+ lines = ["| path | before | after | delta |", "|---|---:|---:|---:|"]
1818
+ for k in keys:
1819
+ va, vb = fa.get(k, 0), fb.get(k, 0)
1820
+ if va != vb:
1821
+ lines.append(f"| {k} | {va} | {vb} | {vb-va:+d} |")
1822
+ return "\n".join(lines) if len(lines) > 2 else "no changes"
1823
+
1824
+
1825
+ def main():
1826
+ parser = argparse.ArgumentParser(description="cubest — single-pass OLAP aggregator")
1827
+ parser.add_argument("--profile", "-p", default="file_tree",
1828
+ help="Built-in name, path, inline JSON/YAML, or '-' for stdin")
1829
+ parser.add_argument("path", nargs="?", default=".",
1830
+ help="Directory or single file (auto-detects .gz)")
1831
+ parser.add_argument("--verbose", "-v", action="store_true")
1832
+ parser.add_argument("--files-from", "-F", default=None,
1833
+ help="File with one path per line ('-' for stdin). "
1834
+ "Ideal for MR/PR workflows: "
1835
+ "`git diff --name-only origin/main | cubest -F - -p loc_counter`")
1836
+ parser.add_argument("--diff", nargs=2, metavar=("BEFORE", "AFTER"),
1837
+ help="Compare two cubest JSON outputs — print leaves that "
1838
+ "changed as a markdown table (for CI regressions).")
1839
+ args = parser.parse_args()
1840
+
1841
+ if args.diff:
1842
+ print(diff_cubes(*args.diff))
1843
+ return
1844
+
1845
+ profiles_dir = get_profiles_dir()
1846
+ try:
1847
+ profile = load_profile(args.profile, profiles_dir)
1848
+ except Exception as e:
1849
+ print(f"Error loading profile: {e}", file=sys.stderr)
1850
+ sys.exit(1)
1851
+ if args.verbose:
1852
+ profile["verbose"] = True
1853
+ if args.files_from:
1854
+ if args.files_from == "-":
1855
+ profile["_files_from"] = list(sys.stdin)
1856
+ else:
1857
+ profile["_files_from"] = Path(args.files_from).read_text().splitlines()
1858
+ run(profile, Path(args.path))
1859
+
1860
+
1861
+ if __name__ == "__main__":
1862
+ main()