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.
- package/LICENSE +201 -0
- package/NOTICE +27 -0
- package/README.ar.md +110 -0
- package/README.bn.md +111 -0
- package/README.es.md +110 -0
- package/README.hi.md +110 -0
- package/README.ja.md +113 -0
- package/README.md +469 -0
- package/README.pa.md +114 -0
- package/README.pt.md +113 -0
- package/README.ru.md +1174 -0
- package/README.zh-CN.md +233 -0
- package/bin/cubest.js +28 -0
- package/cubest.py +1862 -0
- package/package.json +28 -0
- package/profiles/agents_inventory.yaml +15 -0
- package/profiles/api_routes.yaml +20 -0
- package/profiles/call_graph.yaml +19 -0
- package/profiles/code_atlas.yaml +33 -0
- package/profiles/code_stats.yaml +15 -0
- package/profiles/csv_analytics.yaml +31 -0
- package/profiles/disk_usage.yaml +29 -0
- package/profiles/doc_structure.yaml +16 -0
- package/profiles/file_tree.yaml +27 -0
- package/profiles/frontend_geoip.yaml +49 -0
- package/profiles/git_log_activity.yaml +29 -0
- package/profiles/imports.yaml +16 -0
- package/profiles/jsonl_events.yaml +19 -0
- package/profiles/k8s_resources.yaml +25 -0
- package/profiles/loc_counter.yaml +51 -0
- package/profiles/mr_impact.yaml +19 -0
- package/profiles/nginx_access.yaml +33 -0
- package/profiles/nginx_cdn_covers.yaml +32 -0
- package/profiles/openapi_endpoints.yaml +19 -0
- package/profiles/react_components.yaml +15 -0
- package/profiles/sdd_checklist.yaml +17 -0
- package/profiles/sdd_specs.yaml +19 -0
- package/profiles/seo_audit.yaml +31 -0
- package/profiles/seo_semantic_tree.yaml +17 -0
- package/profiles/sitemap_map.yaml +20 -0
- package/profiles/skills_inventory.yaml +17 -0
- package/profiles/spec_status.yaml +18 -0
- package/profiles/sql_functions.yaml +31 -0
- package/profiles/tech_debt.yaml +16 -0
- package/profiles/xml_tags.yaml +21 -0
- 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("&", "&").replace("<", "<")
|
|
449
|
+
.replace(">", ">").replace('"', """))
|
|
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("&", "&").replace("<", "<")
|
|
594
|
+
.replace(">", ">").replace('"', """))
|
|
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("<", "<")
|
|
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()
|