ripple-sql 0.1.0__py3-none-any.whl

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 (72) hide show
  1. ripple/__init__.py +31 -0
  2. ripple/answer.py +473 -0
  3. ripple/answer_page.py +214 -0
  4. ripple/cache.py +80 -0
  5. ripple/ci.py +422 -0
  6. ripple/ci_signature.py +374 -0
  7. ripple/cli.py +733 -0
  8. ripple/doctor.py +225 -0
  9. ripple/engine/__init__.py +111 -0
  10. ripple/engine/budget.py +86 -0
  11. ripple/engine/column_lineage.py +112 -0
  12. ripple/engine/column_ref.py +818 -0
  13. ripple/engine/cte_tracing.py +1309 -0
  14. ripple/engine/dependencies.py +466 -0
  15. ripple/engine/dialect.py +132 -0
  16. ripple/engine/dispatch.py +12 -0
  17. ripple/engine/extraction.py +27 -0
  18. ripple/engine/jinja.py +282 -0
  19. ripple/engine/json_sources.py +241 -0
  20. ripple/engine/macro_source.py +127 -0
  21. ripple/engine/pipeline.py +265 -0
  22. ripple/engine/preprocess.py +174 -0
  23. ripple/engine/safe_gen.py +21 -0
  24. ripple/engine/schema_qualification.py +151 -0
  25. ripple/engine/scope.py +488 -0
  26. ripple/engine/select_sources.py +1038 -0
  27. ripple/engine/sql_script.py +729 -0
  28. ripple/engine/statement.py +449 -0
  29. ripple/engine/tech_debt.py +169 -0
  30. ripple/engine/tsql_catalog.py +83 -0
  31. ripple/engine/tsql_scalar_vars.py +248 -0
  32. ripple/engine/tsql_tvf.py +653 -0
  33. ripple/engine/tsql_xml.py +97 -0
  34. ripple/engine/types.py +167 -0
  35. ripple/engine/unused_deps.py +555 -0
  36. ripple/engine/validation.py +158 -0
  37. ripple/graph.py +1499 -0
  38. ripple/home.py +232 -0
  39. ripple/loaders/__init__.py +7 -0
  40. ripple/loaders/dbt.py +359 -0
  41. ripple/loaders/dbt_config.py +339 -0
  42. ripple/loaders/identity.py +328 -0
  43. ripple/loaders/sidecar.py +65 -0
  44. ripple/loaders/sqldir.py +262 -0
  45. ripple/loaders/types.py +197 -0
  46. ripple/lookml.py +163 -0
  47. ripple/mcp_server.py +600 -0
  48. ripple/names.py +40 -0
  49. ripple/project.py +167 -0
  50. ripple/py.typed +0 -0
  51. ripple/render.py +426 -0
  52. ripple/render_shims.py +209 -0
  53. ripple/schemas.py +155 -0
  54. ripple/semantic.py +232 -0
  55. ripple/server.py +184 -0
  56. ripple/sourcefiles.py +64 -0
  57. ripple/star_resolution.py +100 -0
  58. ripple/static/answer.css +146 -0
  59. ripple/static/answer.html +358 -0
  60. ripple/static/answer_twin.js +299 -0
  61. ripple/static/explore.js +133 -0
  62. ripple/usage/__init__.py +18 -0
  63. ripple/usage/cli.py +78 -0
  64. ripple/usage/collect.py +315 -0
  65. ripple/usage/discover.py +190 -0
  66. ripple/usage/ingest.py +414 -0
  67. ripple/usage/report.py +131 -0
  68. ripple_sql-0.1.0.dist-info/METADATA +285 -0
  69. ripple_sql-0.1.0.dist-info/RECORD +72 -0
  70. ripple_sql-0.1.0.dist-info/WHEEL +4 -0
  71. ripple_sql-0.1.0.dist-info/entry_points.txt +3 -0
  72. ripple_sql-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,299 @@
1
+ // The pure half of the answer page: the text twin of ripple.answer and the
2
+ // graph twin of graph.breaks and graph.trace. No DOM. answer_page.py inlines
3
+ // this file into answer.html at render, and the tests run it under node.
4
+ // TEXT TWIN START
5
+ // A port of ripple.answer.breaks_lines and trace_lines. Pure functions, no DOM.
6
+ // tests/test_answer_page.py runs this block under node and compares it to Python.
7
+ var KIND_WORDS = {semantic: 'semantic model', metric: 'metric', exposure: 'exposure'};
8
+ function plural(n, w){ return n + ' ' + w + (n === 1 ? '' : 's'); }
9
+ function isDash(model){ return /^(metric|semantic|exposure):/.test(model); }
10
+ function headline(a){
11
+ var s = a.summary, parts = [];
12
+ if (s.models) parts.push(plural(s.models, a.noun));
13
+ if (s.dashboard_numbers) parts.push(plural(s.dashboard_numbers, 'dashboard number'));
14
+ var where = parts.length ? parts.join(' and ') : '0 ' + a.noun + 's';
15
+ return a.kind === 'trace'
16
+ ? a.target + ' comes from ' + s.columns + ' columns in ' + where
17
+ : s.columns + ' columns in ' + where + ' affected by ' + a.target;
18
+ }
19
+ function hasHits(a){ var s = a.summary; return !!(s.columns || s.dashboard_numbers || (a.row_level && a.row_level.length)); }
20
+ function breaksText(a){
21
+ var s = a.summary, out = [];
22
+ if (!hasHits(a)) return 'Nothing downstream reads ' + a.target + '.';
23
+ out.push(headline(a));
24
+ if (s.review) out.push(s.review + ' of them need review (lineage uncertain)');
25
+ var groups = {}, order = [];
26
+ a.nodes.forEach(function(n){ if (n.depth === 0 || n.kind === 'unknown') return; if (!groups[n.model]){ groups[n.model] = []; order.push(n.model); } groups[n.model].push(n); });
27
+ var models = order.filter(function(m){ return !isDash(m); }), dashes = order.filter(isDash);
28
+ if (models.length > 1){
29
+ var worst = models.map(function(m){ return [m, groups[m].length]; }).sort(function(x, y){ return y[1] - x[1]; }).slice(0, 3);
30
+ out.push('Hit hardest: ' + worst.map(function(x){ return x[0] + ' (' + x[1] + ')'; }).join(', '));
31
+ }
32
+ out.push('');
33
+ var via = {}; a.edges.forEach(function(e){ if (!(e.dst in via)) via[e.dst] = e.src; });
34
+ models.forEach(function(m){
35
+ var ns = groups[m], d = Math.min.apply(null, ns.map(function(n){ return n.depth; }));
36
+ out.push(' ' + m + ' · ' + plural(d, 'step'));
37
+ var g = {}, go = [];
38
+ ns.forEach(function(n){
39
+ var src = via[n.id] || '?', i = src.lastIndexOf('.'), sm = src.slice(0, i), sc = src.slice(i + 1);
40
+ var label = (sm && sc === n.column) ? sm : src, review = n.trust === 'review_required';
41
+ var key = label + '|' + review;
42
+ if (!g[key]){ g[key] = {label: label, review: review, cols: []}; go.push(key); }
43
+ g[key].cols.push(n.column);
44
+ });
45
+ go.forEach(function(k){ var x = g[k]; out.push(' ' + x.cols.join(', ') + ' ← ' + x.label + (x.review ? ' needs review' : '')); });
46
+ });
47
+ if (dashes.length){
48
+ out.push(''); out.push('Dashboard numbers');
49
+ var ent = {}, eo = [];
50
+ dashes.forEach(function(m){
51
+ var kind = m.split(':')[0], rest = m.slice(kind.length + 1), entity, cols;
52
+ if (kind === 'semantic'){ var i = rest.lastIndexOf('.'); entity = i > 0 ? rest.slice(0, i) : rest; cols = groups[m].map(function(n){ return n.column; }); }
53
+ else { entity = kind; cols = [rest]; }
54
+ var key = entity + '|' + kind;
55
+ if (!ent[key]){ ent[key] = {entity: entity, kind: kind, cols: [], review: false}; eo.push(key); }
56
+ ent[key].cols = ent[key].cols.concat(cols);
57
+ if (groups[m].some(function(n){ return n.trust === 'review_required'; })) ent[key].review = true;
58
+ });
59
+ eo.forEach(function(k){
60
+ var x = ent[k], line = x.entity === x.kind ? ' ' + x.kind + 's: ' + x.cols.join(', ') : ' ' + x.entity + ' (' + KIND_WORDS[x.kind] + '): ' + x.cols.join(', ');
61
+ out.push(line + (x.review ? ' needs review' : ''));
62
+ });
63
+ }
64
+ a.row_level.forEach(function(r){
65
+ var uses = r.uses || [], first = uses.length ? uses[0] : '', m = first.match(/\((\w+)\)$/);
66
+ var kinds = {}; uses.forEach(function(u){ var mm = u.match(/\((\w+)\)$/); if (mm) kinds[mm[1]] = 1; });
67
+ var note = (Object.keys(kinds).length === 1 && kinds.window) ? 'partitions or orders a window on it' : 'filters rows on it (' + (m ? m[1] : 'filter') + ')';
68
+ out.push(' ' + r.model + ' · ' + note + (r.trust === 'review_required' ? ' needs review' : ''));
69
+ });
70
+ if (!s.complete){ var cap = a.truncated_at_depth; out.push(' stopped ' + (cap ? 'at depth ' + cap : 'at the depth cap') + '; the full radius may be larger'); }
71
+ out.push('');
72
+ return out.join('\n');
73
+ }
74
+ function traceText(a){
75
+ var byId = {}; a.nodes.forEach(function(n){ byId[n.id] = n; });
76
+ var hops = a.edges.filter(function(e){ return !(byId[e.src] && byId[e.src].kind === 'unknown'); });
77
+ if (!hops.length) return 'No upstream lineage for ' + a.target + '.';
78
+ var out = [a.target + ' comes from:'];
79
+ hops.forEach(function(e){
80
+ var n = byId[e.src], pad = ''; for (var i = 0; i < n.depth; i++) pad += ' ';
81
+ out.push(pad + e.src + ((e.path_trust || n.trust) === 'review_required' ? ' review' : '') + ' → ' + e.dst);
82
+ });
83
+ if (a.truncated_at_depth) out.push(' stopped at depth ' + a.truncated_at_depth + '; the trail may go further back (--depth to follow it)');
84
+ return out.join('\n');
85
+ }
86
+ // TEXT TWIN END
87
+
88
+ // GRAPH TWIN START
89
+ // A port of LineageGraph.breaks/trace and ripple.answer.breaks_answer/trace_answer
90
+ // over the compact export the page carries (answer_page.compact_edges). Pure functions, no DOM.
91
+ // tests/test_answer_page.py runs this block under node and compares it to Python.
92
+ var TRUST = {v: 'verified', h: 'high_confidence', m: 'moderate', r: 'review_required'};
93
+ var RANK = {verified: 0, high_confidence: 1, moderate: 2, review_required: 3};
94
+ var MAX_DEPTH = 25;
95
+ function worse(){ var w = arguments[0]; for (var i = 1; i < arguments.length; i++) if (RANK[arguments[i]] > RANK[w]) w = arguments[i]; return w; }
96
+ function cmp(a, b){ return a < b ? -1 : a > b ? 1 : 0; }
97
+ function splitId(id){ var i = id.lastIndexOf('.'); return i < 0 ? ['', id] : [id.slice(0, i), id.slice(i + 1)]; }
98
+ function buildIndex(G){
99
+ var index = {down: Object.create(null), downByModel: Object.create(null), up: Object.create(null), ids: Object.create(null), names: Object.create(null)};
100
+ var reasons = G.reasons || [];
101
+ // an export written before names travelled, and the change page, carry none
102
+ var given = G.names || {};
103
+ Object.keys(given).forEach(function(spelling){ index.names[spelling] = given[spelling]; });
104
+ G.edges.forEach(function(row){
105
+ var s = splitId(row[0]), d = splitId(row[1]);
106
+ var e = {src: row[0], dst: row[1], srcModel: s[0], srcColumn: s[1], dstModel: d[0], dstColumn: d[1],
107
+ trust: TRUST[row[2]] || 'review_required', kind: row[3] || 'value',
108
+ reason: row.length > 4 ? (reasons[row[4]] || '') : ''};
109
+ index.ids[e.src] = 1; index.ids[e.dst] = 1;
110
+ (index.down[e.src] = index.down[e.src] || []).push(e);
111
+ (index.downByModel[e.srcModel] = index.downByModel[e.srcModel] || []).push(e);
112
+ (index.up[e.dst] = index.up[e.dst] || []).push(e);
113
+ });
114
+ (G.columns || []).forEach(function(id){ index.ids[id] = 1; });
115
+ return index;
116
+ }
117
+ // graph._askable_name over the export: the shortest dotted spelling that means
118
+ // only this model, so an ambiguity names models the reader can actually type
119
+ function askableSpelling(names, canonical){
120
+ var best = null;
121
+ for (var spelling in names){
122
+ var means = names[spelling];
123
+ if (means.length !== 1 || means[0] !== canonical || spelling.indexOf('.') < 0) continue;
124
+ if (best === null || spelling.length < best.length) best = spelling;
125
+ }
126
+ return best === null ? canonical : best;
127
+ }
128
+ // the one place a typed name becomes an id, a port of graph._require_target.
129
+ // Returns {id: ...} or {error: ..., candidates: [...]}; the page must take
130
+ // every spelling the command takes, and refuse the ones it refuses
131
+ function resolveTarget(index, typed){
132
+ var asked = String(typed === null || typed === undefined ? '' : typed).trim();
133
+ if (!asked) return {error: 'Ask for a column as model.column'};
134
+ var parts = splitId(asked), model = parts[0], column = parts[1];
135
+ var names = index.names || {}, canonical = model ? names[model.toLowerCase()] : null, also = [];
136
+ if (canonical && canonical.length > 1){
137
+ // a spelling that IS one model's own name is that model, even when
138
+ // another model carries it as an alias
139
+ var exact = canonical.filter(function(c){ return c.toLowerCase() === model.toLowerCase(); });
140
+ if (exact.length === 1){
141
+ also = canonical.filter(function(c){ return c !== exact[0]; })
142
+ .map(function(c){ return askableSpelling(names, c); }).sort(cmp);
143
+ canonical = exact;
144
+ }
145
+ }
146
+ if (canonical && canonical.length > 1){
147
+ return {
148
+ error: "'" + model + "' names " + canonical.length + ' different models; ask by full name',
149
+ candidates: canonical.map(function(c){ return askableSpelling(names, c); }).sort(cmp)
150
+ };
151
+ }
152
+ var tries = canonical ? [canonical[0] + '.' + column, canonical[0] + '.' + column.toLowerCase()] : [];
153
+ // the command lowercases a spelling before looking it up, so the page must
154
+ tries.push(asked, model + '.' + column.toLowerCase(), model.toLowerCase() + '.' + column.toLowerCase());
155
+ for (var i = 0; i < tries.length; i++){
156
+ if (index.ids[tries[i]]) return also.length ? {id: tries[i], alsoMatches: also} : {id: tries[i]};
157
+ }
158
+ return {error: 'No column named ' + asked + ' in this graph'};
159
+ }
160
+ function mkNode(model, column, depth, trust){
161
+ return {id: model + '.' + column, model: model, column: column, kind: isDash(model) ? 'dashboard' : 'model', depth: depth, trust: trust};
162
+ }
163
+ function deepest(nodes){ return Math.max.apply(null, nodes.map(function(n){ return n.depth; })); }
164
+ function mergeNodes(nodes){
165
+ var byId = Object.create(null), out = [];
166
+ nodes.forEach(function(n){
167
+ var seen = byId[n.id];
168
+ if (!seen){ seen = byId[n.id] = {id: n.id, model: n.model, column: n.column, kind: n.kind, depth: n.depth, trust: n.trust}; out.push(seen); return; }
169
+ seen.depth = Math.min(seen.depth, n.depth); seen.trust = worse(seen.trust, n.trust);
170
+ });
171
+ return out;
172
+ }
173
+ // a link may name a column the walk never listed (a wildcard, or a column reached
174
+ // through one); it gets a stand-in so nothing dangles. Links point away from the
175
+ // asked column on breaks and toward it on trace, so which end sits one step nearer depends on the kind
176
+ function closeEdges(nodes, edges, kind){
177
+ var byId = Object.create(null); nodes.forEach(function(n){ byId[n.id] = n; });
178
+ edges.forEach(function(e){
179
+ [['src', 'dst'], ['dst', 'src']].forEach(function(pair){
180
+ var id = e[pair[0]]; if (byId[id]) return;
181
+ var neighbour = byId[e[pair[1]]], parts = splitId(id), nearer = (pair[0] === 'src') === (kind === 'breaks');
182
+ var n = mkNode(parts[0], parts[1], Math.max(0, (neighbour ? neighbour.depth : 1) + (nearer ? -1 : 1)), 'review_required');
183
+ n.kind = 'unknown'; nodes.push(n); byId[id] = n;
184
+ });
185
+ });
186
+ return nodes;
187
+ }
188
+ // a node is expanded again when a worse path reaches it, so a downgrade
189
+ // propagates past a convergence point (each node expands at most RANK-many times)
190
+ function walkDown(index, target){
191
+ var expandedAt = Object.create(null), hits = Object.create(null), order = [], rows = Object.create(null), truncated = false;
192
+ var frontier = [[target, 0, 'verified']];
193
+ for (var head = 0; head < frontier.length; head++){
194
+ var node = frontier[head][0], depth = frontier[head][1], pathTrust = frontier[head][2], previous = expandedAt[node];
195
+ if (previous !== undefined && RANK[pathTrust] <= RANK[previous]) continue;
196
+ if (depth >= MAX_DEPTH){ truncated = true; continue; }
197
+ expandedAt[node] = pathTrust;
198
+ var parts = splitId(node), nodeModel = parts[0], nodeColumn = parts[1];
199
+ var outgoing = nodeColumn === '*'
200
+ ? (index.downByModel[nodeModel] || [])
201
+ : (index.down[node] || []).concat(index.down[nodeModel + '.*'] || []);
202
+ outgoing.forEach(function(e){
203
+ var star = e.srcColumn === '*' || e.dstColumn === '*' || nodeColumn === '*';
204
+ var trust = star ? worse(pathTrust, e.trust, 'review_required') : worse(pathTrust, e.trust);
205
+ if (e.kind === 'filter' || e.kind === 'join' || e.kind === 'window'){
206
+ var row = rows[e.dstModel] || (rows[e.dstModel] = {model: e.dstModel, uses: [], trust: trust});
207
+ row.uses.push(e.src + ' (' + e.kind + ')'); row.trust = worse(row.trust, trust);
208
+ return;
209
+ }
210
+ var hit = hits[e.dst];
211
+ if (!hit){ hits[e.dst] = {model: e.dstModel, column: e.dstColumn, via: e.src, trust: trust, edgeTrust: e.trust, reason: e.reason, depth: depth + 1}; order.push(e.dst); }
212
+ else hit.trust = worse(hit.trust, trust);
213
+ if (e.dstColumn.charAt(0) !== '(') frontier.push([e.dst, depth + 1, trust]);
214
+ });
215
+ }
216
+ // graph.breaks' key: depth, edge trust (verified first), model; the sort is stable so
217
+ // same-model hits keep discovery order, which is export order on both sides
218
+ var list = order.map(function(id){ return hits[id]; });
219
+ list.sort(function(a, b){ return a.depth - b.depth || RANK[a.edgeTrust] - RANK[b.edgeTrust] || cmp(a.model, b.model); });
220
+ var byModel = Object.create(null), modelOrder = [];
221
+ list.forEach(function(h){ if (!byModel[h.model]){ byModel[h.model] = []; modelOrder.push(h.model); } byModel[h.model].push(h); });
222
+ return {byModel: byModel, modelOrder: modelOrder, rows: rows, truncated: truncated};
223
+ }
224
+ function computeBreaks(index, target, noun){
225
+ var found = resolveTarget(index, target);
226
+ if (!found.id) return null;
227
+ var w = walkDown(index, found.id), t = splitId(found.id);
228
+ var asked = mkNode(t[0], t[1], 0, 'verified'); asked.kind = 'asked';
229
+ var nodes = [asked], edges = [], columns = 0, models = 0, dashboards = 0, review = 0;
230
+ w.modelOrder.forEach(function(m){
231
+ var hs = w.byModel[m];
232
+ if (isDash(m)) dashboards += 1; else { models += 1; columns += hs.length; }
233
+ hs.forEach(function(h){
234
+ var n = mkNode(h.model, h.column, h.depth, h.trust); nodes.push(n);
235
+ var edge = {src: h.via, dst: n.id, trust: h.edgeTrust, path_trust: h.trust};
236
+ if (h.reason) edge.reason = h.reason;
237
+ edges.push(edge);
238
+ if (h.trust === 'review_required') review += 1;
239
+ });
240
+ });
241
+ nodes = closeEdges(mergeNodes(nodes), edges, 'breaks');
242
+ var rows = Object.keys(w.rows).sort(cmp).map(function(m){ var r = w.rows[m]; return {model: r.model, uses: r.uses.slice(), trust: r.trust}; });
243
+ var a = {kind: 'breaks', target: asked.id, noun: noun,
244
+ summary: {columns: columns, models: models, dashboard_numbers: dashboards, review: review,
245
+ row_level_models: rows.length, deepest_hops: deepest(nodes), complete: !w.truncated},
246
+ nodes: nodes, edges: edges, row_level: rows};
247
+ if (w.truncated) a.truncated_at_depth = MAX_DEPTH;
248
+ if (found.alsoMatches) a.also_matches = found.alsoMatches;
249
+ return a;
250
+ }
251
+ function walkUp(index, target){
252
+ var expandedAt = Object.create(null), hops = Object.create(null), order = [], truncated = false;
253
+ var frontier = [[target, 0, 'verified']];
254
+ for (var head = 0; head < frontier.length; head++){
255
+ var node = frontier[head][0], depth = frontier[head][1], pathTrust = frontier[head][2], previous = expandedAt[node];
256
+ if (previous !== undefined && RANK[pathTrust] <= RANK[previous]) continue;
257
+ if (depth >= MAX_DEPTH){ truncated = true; continue; }
258
+ expandedAt[node] = pathTrust;
259
+ var parts = splitId(node), incoming = index.up[node] || [];
260
+ if (parts[1] !== '*') incoming = incoming.concat(index.up[parts[0] + '.*'] || []);
261
+ incoming.forEach(function(e){
262
+ if (e.kind !== 'value') return;
263
+ var trust = worse(pathTrust, e.trust), key = e.src + ' > ' + e.dst, hop = hops[key];
264
+ if (!hop){ hops[key] = {model: e.srcModel, column: e.srcColumn, feeds: e.dst, trust: trust, edgeTrust: e.trust, reason: e.reason, depth: depth + 1}; order.push(key); }
265
+ else hop.trust = worse(hop.trust, trust);
266
+ frontier.push([e.src, depth + 1, trust]);
267
+ });
268
+ }
269
+ var list = order.map(function(k){ return hops[k]; });
270
+ list.sort(function(a, b){ return a.depth - b.depth || cmp(a.model, b.model) || cmp(a.column, b.column); });
271
+ return {hops: list, truncated: truncated};
272
+ }
273
+ function computeTrace(index, target, noun){
274
+ var found = resolveTarget(index, target);
275
+ if (!found.id) return null;
276
+ var w = walkUp(index, found.id), t = splitId(found.id);
277
+ var asked = mkNode(t[0], t[1], 0, 'verified'); asked.kind = 'asked';
278
+ var nodes = [asked], edges = [];
279
+ w.hops.forEach(function(h){
280
+ var n = mkNode(h.model, h.column, h.depth, h.trust); nodes.push(n);
281
+ var edge = {src: n.id, dst: h.feeds, trust: h.edgeTrust, path_trust: h.trust};
282
+ if (h.reason) edge.reason = h.reason;
283
+ edges.push(edge);
284
+ });
285
+ nodes = closeEdges(mergeNodes(nodes), edges, 'trace');
286
+ var upstream = nodes.filter(function(n){ return n.depth > 0; }), models = Object.create(null);
287
+ upstream.forEach(function(n){ models[n.model] = 1; });
288
+ var names = Object.keys(models);
289
+ var a = {kind: 'trace', target: asked.id, noun: noun,
290
+ summary: {columns: upstream.length,
291
+ models: names.filter(function(m){ return !isDash(m); }).length,
292
+ dashboard_numbers: names.filter(function(m){ return isDash(m); }).length,
293
+ review: upstream.filter(function(n){ return n.trust === 'review_required'; }).length,
294
+ row_level_models: 0, deepest_hops: deepest(nodes), complete: !w.truncated},
295
+ nodes: nodes, edges: edges, row_level: []};
296
+ if (w.truncated) a.truncated_at_depth = MAX_DEPTH;
297
+ return a;
298
+ }
299
+ // GRAPH TWIN END
@@ -0,0 +1,133 @@
1
+ // EXPLORE START
2
+ // The map: every model in its layer, the model-level links between them,
3
+ // and a model's columns when it is clicked. Pure DOM over the map the
4
+ // server embeds; nothing here fetches. Hover or focus a model to light its
5
+ // whole chain, upstream and down. Past a cap, links draw only for the lit
6
+ // chain so a large repo stays readable.
7
+ var MAP_PER_LAYER = 40, MAP_WIRE_CAP = 400, MAP_COLUMNS = 60;
8
+
9
+ function renderMap(root, map, opts){
10
+ var inner = root.querySelector('.board-inner'), svg = root.querySelector('.wires');
11
+ var esc = opts.esc, many = map.links.length > MAP_WIRE_CAP;
12
+ var ups = {}, downs = {}, chips = {}, lit = null;
13
+ map.links.forEach(function(l){
14
+ (ups[l.dst] = ups[l.dst] || []).push(l.src);
15
+ (downs[l.src] = downs[l.src] || []).push(l.dst);
16
+ });
17
+ function el(tag, cls){ var e = document.createElement(tag); if (cls) e.className = cls; return e; }
18
+ function label(m){
19
+ if (m.kind !== 'dashboard') return esc(m.id);
20
+ var kind = m.id.split(':')[0];
21
+ return esc(m.id.slice(kind.length + 1)) + ' <small>' + esc(kind) + '</small>';
22
+ }
23
+ function chain(id){
24
+ var seen = {}; seen[id] = true;
25
+ [ups, downs].forEach(function(dir){
26
+ var queue = [id];
27
+ while (queue.length){
28
+ var cur = queue.shift();
29
+ (dir[cur] || []).forEach(function(n){ if (!seen[n]){ seen[n] = true; queue.push(n); } });
30
+ }
31
+ });
32
+ return seen;
33
+ }
34
+ function paths(){ return Array.prototype.slice.call(svg.querySelectorAll('path')); }
35
+
36
+ var layers = [];
37
+ map.models.forEach(function(m){ (layers[m.layer] = layers[m.layer] || []).push(m); });
38
+ layers.forEach(function(models, d){
39
+ if (!models) return;
40
+ var lvl = el('div', 'lvl'), head = el('div', 'lhead');
41
+ var dash = models.every(function(m){ return m.kind === 'dashboard'; });
42
+ head.textContent = (d === 0 ? 'Sources' : dash ? 'Dashboards' : 'Layer ' + d) + ' · ' + models.length;
43
+ lvl.appendChild(head);
44
+ models.forEach(function(m, i){
45
+ var g = el('div', 'grp');
46
+ if (i >= MAP_PER_LAYER) g.hidden = true;
47
+ var broken = m.status && m.status !== 'ok' && m.status !== 'star_only';
48
+ var b = el('button', 'mdl' + (m.kind === 'source' ? ' src' : '') + (m.kind === 'dashboard' ? ' bi' : '') + (broken ? ' r' : ''));
49
+ b.type = 'button'; b.dataset.id = m.id; b.setAttribute('aria-expanded', 'false');
50
+ b.innerHTML = label(m) + (m.columns.length ? ' <small>' + m.columns.length + '</small>' : '');
51
+ var reads = (ups[m.id] || []).length, feeds = (downs[m.id] || []).length;
52
+ b.title = (m.path || m.id) + ' · reads ' + reads + ', feeds ' + feeds + (broken ? ' · ' + m.status.replace('_', ' ') : '');
53
+ b.addEventListener('click', function(){ toggle(m, g, b); });
54
+ b.addEventListener('mouseenter', function(){ focus(m.id); });
55
+ b.addEventListener('focus', function(){ focus(m.id); });
56
+ b.addEventListener('mouseleave', unfocus);
57
+ b.addEventListener('blur', unfocus);
58
+ g.appendChild(b); chips[m.id] = b; lvl.appendChild(g);
59
+ });
60
+ if (models.length > MAP_PER_LAYER){
61
+ var more = el('button', 'more-chips'); more.type = 'button';
62
+ more.textContent = '+ ' + (models.length - MAP_PER_LAYER) + ' more';
63
+ more.addEventListener('click', function(){
64
+ Array.prototype.slice.call(lvl.querySelectorAll('.grp')).forEach(function(x){ x.hidden = false; });
65
+ more.remove(); draw();
66
+ });
67
+ lvl.appendChild(more);
68
+ }
69
+ inner.appendChild(lvl);
70
+ });
71
+
72
+ function toggle(m, g, b){
73
+ var open = g.querySelector('.cols');
74
+ if (open){ open.remove(); b.setAttribute('aria-expanded', 'false'); draw(); return; }
75
+ var cols = el('div', 'cols');
76
+ m.columns.slice(0, MAP_COLUMNS).forEach(function(c){
77
+ var s = el('button', 'node in'); s.type = 'button'; s.textContent = c;
78
+ s.title = 'ask about ' + m.id + '.' + c;
79
+ s.addEventListener('click', function(){ opts.onAsk(m.id + '.' + c); });
80
+ cols.appendChild(s);
81
+ });
82
+ var note = null;
83
+ if (m.columns.length > MAP_COLUMNS) note = '+ ' + (m.columns.length - MAP_COLUMNS) + ' more, ask by name';
84
+ else if (!m.columns.length) note = m.kind === 'source' ? 'columns unknown, see Coverage' : 'no columns known';
85
+ if (note){ var n = el('span', 'gname'); n.textContent = note; cols.appendChild(n); }
86
+ g.appendChild(cols); b.setAttribute('aria-expanded', 'true'); draw();
87
+ }
88
+ function focus(id){
89
+ lit = chain(id); root.classList.add('focus');
90
+ Object.keys(chips).forEach(function(k){ chips[k].classList.toggle('on', !!lit[k]); });
91
+ if (many) draw();
92
+ else paths().forEach(function(p){ p.classList.toggle('on', !!(lit[p.dataset.from] && lit[p.dataset.to])); });
93
+ }
94
+ function unfocus(){
95
+ lit = null; root.classList.remove('focus');
96
+ Object.keys(chips).forEach(function(k){ chips[k].classList.remove('on'); });
97
+ if (many) draw();
98
+ else paths().forEach(function(p){ p.classList.remove('on'); });
99
+ }
100
+ function draw(){
101
+ svg.innerHTML = '';
102
+ if (root.hidden || window.innerWidth <= 680 || (many && !lit)) return;
103
+ var base = inner.getBoundingClientRect();
104
+ svg.setAttribute('width', inner.scrollWidth); svg.setAttribute('height', inner.scrollHeight);
105
+ map.links.forEach(function(l){
106
+ if (many && !(lit[l.src] && lit[l.dst])) return;
107
+ var from = chips[l.src], to = chips[l.dst];
108
+ if (!from || !to || from.parentNode.hidden || to.parentNode.hidden) return;
109
+ var ra = from.getBoundingClientRect(), rb = to.getBoundingClientRect();
110
+ var x1 = ra.right - base.left, y1 = ra.top + ra.height / 2 - base.top;
111
+ var x2 = rb.left - base.left, y2 = rb.top + rb.height / 2 - base.top;
112
+ if (x2 < x1){ x1 = ra.left - base.left; x2 = rb.right - base.left; }
113
+ var dx = Math.max(18, Math.abs(x2 - x1) / 2);
114
+ var p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
115
+ p.setAttribute('d', 'M' + x1 + ' ' + y1 + ' C' + (x1 + dx) + ' ' + y1 + ' ' + (x2 - dx) + ' ' + y2 + ' ' + x2 + ' ' + y2);
116
+ p.dataset.from = l.src; p.dataset.to = l.dst;
117
+ var cls = (l.review ? 'r ' : '') + (lit && lit[l.src] && lit[l.dst] ? 'on' : '');
118
+ if (cls.trim()) p.setAttribute('class', cls.trim());
119
+ svg.appendChild(p);
120
+ });
121
+ }
122
+ function filter(text){
123
+ text = (text || '').toLowerCase();
124
+ root.classList.toggle('filter', !!text);
125
+ map.models.forEach(function(m){
126
+ var hit = !!text && (m.id.toLowerCase().indexOf(text) >= 0 ||
127
+ m.columns.some(function(c){ return (m.id + '.' + c).toLowerCase().indexOf(text) >= 0; }));
128
+ chips[m.id].classList.toggle('hit', hit);
129
+ });
130
+ }
131
+ return {draw: draw, filter: filter, many: many};
132
+ }
133
+ // EXPLORE END
@@ -0,0 +1,18 @@
1
+ """Query-history usage: what actually ran, held to the same no-guessing bar.
2
+
3
+ Files say what is supposed to happen; the warehouse's query log says what
4
+ did. This package ingests a hand-carried export of that log and aggregates
5
+ it against the project's models. Two rules are load-bearing:
6
+
7
+ - The word "unused" is banned from every surface this package feeds. A short
8
+ export cannot tell a quarterly job from a dead one, so the only honest
9
+ claim is "not seen in this window", window printed.
10
+ - Raw query text is never persisted and never returned over MCP. The store
11
+ holds per-table counts only, so nothing sensitive can leak into a commit
12
+ or an AI conversation later.
13
+ """
14
+
15
+ from ripple.usage.ingest import ingest_file, store_path
16
+ from ripple.usage.report import render, render_empty
17
+
18
+ __all__ = ["ingest_file", "render", "render_empty", "store_path"]
ripple/usage/cli.py ADDED
@@ -0,0 +1,78 @@
1
+ """CLI surface for collect: render the capability table, run one collect.
2
+
3
+ Thin over collect.py so the MCP tool and this command share every rule.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+
12
+
13
+ def render_capabilities(caps: dict) -> str:
14
+ lines = ["Warehouse CLIs on this machine:", ""]
15
+ sf = caps["snowflake"]
16
+ status = sf["cli"] if sf["cli_found"] else f"{sf['cli']} not installed"
17
+ lines.append(f" snowflake {status}")
18
+ if sf["connections"]:
19
+ marked = [
20
+ name + (" (default)" if name == sf["default"] else "") for name in sf["connections"]
21
+ ]
22
+ lines.append(f" connections: {', '.join(marked)}")
23
+ if sf["browser_sso"]:
24
+ lines.append(
25
+ f" browser sign-in: {', '.join(sf['browser_sso'])} "
26
+ "(a browser window will open)"
27
+ )
28
+ bq = caps["bigquery"]
29
+ status = bq["cli"] if bq["cli_found"] else f"{bq['cli']} not installed"
30
+ lines.append(f" bigquery {status}")
31
+ for config in bq["configurations"]:
32
+ active = " (active)" if config["name"] == bq["active"] else ""
33
+ project = config["project"] or "no project set"
34
+ lines.append(f" {config['name']}: {project}{active}")
35
+ db = caps["databricks"]
36
+ status = db["cli"] if db["cli_found"] else f"{db['cli']} not installed"
37
+ lines.append(f" databricks {status}")
38
+ if db["profiles"]:
39
+ named = [p["name"] + (f" ({p['host']})" if p.get("host") else "") for p in db["profiles"]]
40
+ lines.append(f" profiles: {', '.join(named)}")
41
+ lines.append("")
42
+ lines.append("try: ripple collect-usage snowflake (your own queries, last 7 days)")
43
+ lines.append("The CLI runs with the login you already have; Ripple never sees a credential.")
44
+ return "\n".join(lines)
45
+
46
+
47
+ def run_collect(args, load_graph) -> None:
48
+ from ripple.usage import collect as collect_mod
49
+ from ripple.usage.discover import capabilities
50
+
51
+ if not args.platform:
52
+ caps = capabilities()
53
+ print(json.dumps(caps, indent=2) if args.json else render_capabilities(caps))
54
+ return
55
+
56
+ from ripple.project import find_project_root
57
+ from ripple.usage.ingest import write_store
58
+ from ripple.usage.report import render
59
+
60
+ project, _ = load_graph(args)
61
+ try:
62
+ store = collect_mod.collect(
63
+ args.platform,
64
+ [m.name for m in project.models],
65
+ connection=args.connection,
66
+ days=args.days,
67
+ scope=args.scope,
68
+ region=args.region,
69
+ timeout=args.timeout,
70
+ model_aliases={m.name: set(m.aliases) for m in project.models},
71
+ )
72
+ except collect_mod.CollectError as e:
73
+ sys.exit(str(e))
74
+ write_store(find_project_root(Path(args.path)), store)
75
+ if args.json:
76
+ print(json.dumps(store, indent=2))
77
+ return
78
+ print(render(store))