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
ripple/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Ripple: offline column-level SQL lineage. See what breaks before you merge.
2
+
3
+ Library use starts here:
4
+
5
+ from ripple import LineageGraph, load_project
6
+
7
+ graph = LineageGraph.build(load_project("path/to/repo"))
8
+ graph.breaks("orders", "revenue")
9
+
10
+ Everything else is internal and may move between releases.
11
+ """
12
+
13
+ from ripple.graph import Edge, LineageGraph, UnknownTarget
14
+ from ripple.project import Model, Project, load_project
15
+
16
+ try:
17
+ from importlib.metadata import version
18
+
19
+ __version__ = version("ripple-sql")
20
+ except Exception: # not installed (e.g. running from a checkout)
21
+ __version__ = "0.0.0.dev"
22
+
23
+ __all__ = [
24
+ "Edge",
25
+ "LineageGraph",
26
+ "Model",
27
+ "Project",
28
+ "UnknownTarget",
29
+ "__version__",
30
+ "load_project",
31
+ ]
ripple/answer.py ADDED
@@ -0,0 +1,473 @@
1
+ """One answer, one shape, every door.
2
+
3
+ A `breaks` or `trace` result becomes nodes and links with a hop depth and a
4
+ trust label, plus the summary counts every surface prints. The CLI text,
5
+ the MCP payload, the pull-request comment, and the answer page all read
6
+ this shape, so they cannot disagree about what a change reaches.
7
+
8
+ Pure functions over the dicts the graph returns. Nothing here touches the
9
+ graph, files, or output formats.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ DASHBOARD_PREFIXES = ("metric:", "semantic:", "exposure:")
15
+ ASKED = "asked" # the column the question was about; depth 0 in either direction
16
+ PREVIEW_MODELS = 8
17
+ PREVIEW_COLUMNS = 5
18
+
19
+
20
+ KIND_WORDS = {"semantic": "semantic model", "metric": "metric", "exposure": "exposure"}
21
+
22
+
23
+ def node_kind(model: str) -> str:
24
+ return "dashboard" if model.startswith(DASHBOARD_PREFIXES) else "model"
25
+
26
+
27
+ def noun_for(mode: str) -> str:
28
+ """'model' is dbt's word; a plain SQL folder deserves a plain one."""
29
+ return "table" if mode == "sql-dir" else "model"
30
+
31
+
32
+ def count_hits(by_model: dict) -> tuple[int, int, int]:
33
+ """(model columns, models, dashboard numbers) from a graph's by_model.
34
+
35
+ The graph counts every hit as a column. A dashboard number is not one:
36
+ "19 columns in 4 models and 7 dashboard numbers" read as 26 things when
37
+ it was 12 columns plus the 7 numbers themselves."""
38
+ columns = models = dashboards = 0
39
+ for model, hits in by_model.items():
40
+ if node_kind(model) == "dashboard":
41
+ dashboards += 1
42
+ else:
43
+ models += 1
44
+ columns += len(hits)
45
+ return columns, models, dashboards
46
+
47
+
48
+ def where_phrase(models: int, dashboards: int, noun: str = "model") -> str:
49
+ parts = []
50
+ if models:
51
+ parts.append(_plural(models, noun))
52
+ if dashboards:
53
+ parts.append(_plural(dashboards, "dashboard number"))
54
+ return " and ".join(parts) if parts else f"0 {noun}s"
55
+
56
+
57
+ def _plural(n: int, word: str) -> str:
58
+ return f"{n} {word}" + ("" if n == 1 else "s")
59
+
60
+
61
+ def has_hits(answer: dict) -> bool:
62
+ """True when the change reaches anything: a column, a dashboard number, or a row filter."""
63
+ s = answer["summary"]
64
+ return bool(s["columns"] or s["dashboard_numbers"] or answer["row_level"])
65
+
66
+
67
+ def _node(model: str, column: str, depth: int, trust: str) -> dict:
68
+ return {
69
+ "id": f"{model}.{column}",
70
+ "model": model,
71
+ "column": column,
72
+ "kind": node_kind(model),
73
+ "depth": depth,
74
+ "trust": trust,
75
+ }
76
+
77
+
78
+ def _worse(a: str, b: str) -> str:
79
+ # graph.TRUST_ORDER reversed; a heuristic match is rated "moderate"
80
+ order = ("verified", "high_confidence", "moderate", "review_required")
81
+ return a if order.index(a) >= order.index(b) else b
82
+
83
+
84
+ def _merge(nodes: list[dict]) -> list[dict]:
85
+ """One node per column: the shortest path's depth, the worst path's trust."""
86
+ merged: dict[str, dict] = {}
87
+ for node in nodes:
88
+ seen = merged.get(node["id"])
89
+ if seen is None:
90
+ merged[node["id"]] = dict(node)
91
+ else:
92
+ seen["depth"] = min(seen["depth"], node["depth"])
93
+ seen["trust"] = _worse(seen["trust"], node["trust"])
94
+ return list(merged.values())
95
+
96
+
97
+ def _close_edges(nodes: list[dict], edges: list[dict], kind: str) -> list[dict]:
98
+ """A link may name a column the walk never listed: a wildcard, or a
99
+ column reached through one. Give it a node so no link dangles.
100
+
101
+ Links point away from the asked column on breaks and toward it on trace,
102
+ so which end sits one step nearer depends on the kind."""
103
+ ids = {n["id"] for n in nodes}
104
+ by_id = {n["id"]: n for n in nodes}
105
+ for edge in edges:
106
+ for end, other in (("src", "dst"), ("dst", "src")):
107
+ if edge[end] in ids:
108
+ continue
109
+ model, _, column = edge[end].rpartition(".")
110
+ neighbour = by_id.get(edge[other])
111
+ nearer = (end == "src") == (kind == "breaks")
112
+ depth = max(0, (neighbour["depth"] if neighbour else 1) + (-1 if nearer else 1))
113
+ node = _node(model, column, depth, "review_required")
114
+ node["kind"] = "unknown"
115
+ nodes.append(node)
116
+ ids.add(node["id"])
117
+ by_id[node["id"]] = node
118
+ return nodes
119
+
120
+
121
+ def breaks_answer(result: dict, noun: str = "model") -> dict:
122
+ """Everything downstream of one column, as nodes and links."""
123
+ src = result["source"]
124
+ asked = _node(src["model"], src["column"], 0, "verified")
125
+ asked["kind"] = ASKED
126
+ nodes = [asked]
127
+ edges: list[dict] = []
128
+ for hits in result["by_model"].values():
129
+ for hit in hits:
130
+ nodes.append(_node(hit["model"], hit["column"], hit["depth"], hit["trust"]))
131
+ edge = {
132
+ "src": hit["via"],
133
+ "dst": nodes[-1]["id"],
134
+ "trust": hit["edge_trust"],
135
+ "path_trust": hit["trust"],
136
+ }
137
+ if hit.get("reason"):
138
+ edge["reason"] = hit["reason"]
139
+ edges.append(edge)
140
+ nodes = _close_edges(_merge(nodes), edges, "breaks")
141
+ rows = [
142
+ {"model": r["model"], "uses": list(r.get("uses", [])), "trust": r.get("trust")}
143
+ for r in result.get("row_level_impact", [])
144
+ ]
145
+ columns, models, dashboards = count_hits(result["by_model"])
146
+ answer = {
147
+ "kind": "breaks",
148
+ "target": asked["id"],
149
+ "noun": noun,
150
+ "summary": {
151
+ "columns": columns,
152
+ "models": models,
153
+ "dashboard_numbers": dashboards,
154
+ "review": result["review_required"],
155
+ "row_level_models": len(rows),
156
+ "deepest_hops": max(n["depth"] for n in nodes),
157
+ "complete": result.get("complete", True),
158
+ },
159
+ "nodes": nodes,
160
+ "edges": edges,
161
+ "row_level": rows,
162
+ }
163
+ for key in ("truncated_at_depth", "also_matches"):
164
+ if result.get(key):
165
+ answer[key] = result[key]
166
+ return answer
167
+
168
+
169
+ def trace_answer(result: dict, noun: str = "model") -> dict:
170
+ """Where one column comes from, as nodes and links pointing downstream."""
171
+ tgt = result["target"]
172
+ asked = _node(tgt["model"], tgt["column"], 0, "verified")
173
+ asked["kind"] = ASKED
174
+ nodes = [asked]
175
+ edges: list[dict] = []
176
+ for hop in result["upstream"]:
177
+ nodes.append(_node(hop["model"], hop["column"], hop["depth"], hop["trust"]))
178
+ edge = {
179
+ "src": nodes[-1]["id"],
180
+ "dst": hop["feeds"],
181
+ "trust": hop["edge_trust"],
182
+ "path_trust": hop["trust"],
183
+ }
184
+ if hop.get("reason"):
185
+ edge["reason"] = hop["reason"]
186
+ edges.append(edge)
187
+ nodes = _close_edges(_merge(nodes), edges, "trace")
188
+ upstream = [n for n in nodes if n["depth"] > 0]
189
+ models = {n["model"] for n in upstream}
190
+ answer = {
191
+ "kind": "trace",
192
+ "target": asked["id"],
193
+ "noun": noun,
194
+ "summary": {
195
+ "columns": len(upstream),
196
+ "models": sum(1 for m in models if node_kind(m) == "model"),
197
+ "dashboard_numbers": sum(1 for m in models if node_kind(m) == "dashboard"),
198
+ "review": sum(1 for n in upstream if n["trust"] == "review_required"),
199
+ "row_level_models": 0,
200
+ "deepest_hops": max(n["depth"] for n in nodes),
201
+ "complete": result.get("complete", True),
202
+ },
203
+ "nodes": nodes,
204
+ "edges": edges,
205
+ "row_level": [],
206
+ }
207
+ if result.get("truncated_at_depth"):
208
+ answer["truncated_at_depth"] = result["truncated_at_depth"]
209
+ return answer
210
+
211
+
212
+ def ci_answers(report: dict, noun: str = "model") -> list[dict]:
213
+ """One breaks answer per changed column that reaches anything."""
214
+ answers = []
215
+ for finding in report["findings"]:
216
+ for col in finding.get("columns", []):
217
+ if not col.get("by_model") and not col.get("row_level_models"):
218
+ continue
219
+ rows = col.get("row_level_impact") or [
220
+ {"model": m} for m in col.get("row_level_models", [])
221
+ ]
222
+ result = {
223
+ "source": {"model": finding["model"], "column": col["column"]},
224
+ "impacted_columns": col["impacted_columns"],
225
+ "impacted_models": len(col["by_model"]),
226
+ "review_required": col["review_required"],
227
+ "by_model": col["by_model"],
228
+ "row_level_impact": rows,
229
+ "complete": not col.get("truncated"),
230
+ }
231
+ if col.get("truncated_at_depth"):
232
+ result["truncated_at_depth"] = col["truncated_at_depth"]
233
+ answer = breaks_answer(result, noun)
234
+ answer["change"] = col["state"]
235
+ answers.append(answer)
236
+ return answers
237
+
238
+
239
+ def headline_parts(answer: dict) -> tuple[str, str, str]:
240
+ """(count, where, target) so a surface can style them apart.
241
+
242
+ A metric is not a table; lumping dashboard numbers into the model count
243
+ made "6 models" both wrong for plain-SQL users and vague for dbt users."""
244
+ s = answer["summary"]
245
+ where = where_phrase(s["models"], s["dashboard_numbers"], answer["noun"])
246
+ return str(s["columns"]), where, answer["target"]
247
+
248
+
249
+ def headline(answer: dict) -> str:
250
+ count, where, target = headline_parts(answer)
251
+ if answer["kind"] == "trace":
252
+ return f"{target} comes from {count} columns in {where}"
253
+ return f"{count} columns in {where} affected by {target}"
254
+
255
+
256
+ def hit_hardest(answer: dict, limit: int = 3) -> list[tuple[str, int]]:
257
+ """The models with the most touched columns, worst first."""
258
+ counts: dict[str, int] = {}
259
+ for node in answer["nodes"]:
260
+ if node["depth"] > 0 and node["kind"] == "model":
261
+ counts[node["model"]] = counts.get(node["model"], 0) + 1
262
+ return sorted(counts.items(), key=lambda kv: -kv[1])[:limit]
263
+
264
+
265
+ def _groups(answer: dict) -> list[tuple[str, list[dict]]]:
266
+ """(model, nodes) in first-seen order; the asked column and stand-ins skipped."""
267
+ groups: dict[str, list[dict]] = {}
268
+ for node in answer["nodes"]:
269
+ if node["depth"] == 0 or node["kind"] == "unknown":
270
+ continue
271
+ groups.setdefault(node["model"], []).append(node)
272
+ return list(groups.items())
273
+
274
+
275
+ def row_impact_kind(row: dict) -> str:
276
+ """How a reader uses the changed column on rows: filter, join, or window.
277
+ A window key frames the window; saying it filters rows would be wrong."""
278
+ kinds = {u.rsplit(" (", 1)[-1].rstrip(")") for u in row.get("uses") or [] if " (" in u}
279
+ if kinds == {"window"}:
280
+ return "window"
281
+ if kinds == {"join"}:
282
+ return "join"
283
+ return "filter"
284
+
285
+
286
+ def _row_line(row: dict) -> list[tuple[str, str]]:
287
+ uses = row.get("uses") or []
288
+ used = uses[0].rsplit(" (", 1) if uses else []
289
+ how = used[1].rstrip(")") if len(used) > 1 else "filter"
290
+ note = (
291
+ "partitions or orders a window on it"
292
+ if row_impact_kind(row) == "window"
293
+ else f"filters rows on it ({how})"
294
+ )
295
+ segments = [(f" {row['model']} ", "plain"), (f"· {note}", "dim")]
296
+ if row.get("trust") == "review_required":
297
+ segments.append((" review", "warn"))
298
+ return segments
299
+
300
+
301
+ def _source_label(node: dict, via: dict[str, str]) -> str:
302
+ """The arrow names the source model when the column keeps its name, and
303
+ the source column only when the name changes. Fifteen of nineteen lines
304
+ on jaffle-shop said the same name twice."""
305
+ src = via.get(node["id"], "?")
306
+ src_model, _, src_column = src.rpartition(".")
307
+ return src_model if src_model and src_column == node["column"] else src
308
+
309
+
310
+ def _model_block(model: str, nodes: list[dict], via: dict[str, str], full: bool) -> list:
311
+ """One line per (source, trust) group. A wide model repeats the same
312
+ source for every column; without full, each line names the first few
313
+ columns and one dim line counts the rest."""
314
+ depth = min(n["depth"] for n in nodes)
315
+ lines = [[(f" {model} ", "plain"), (f"· {_plural(depth, 'step')}", "dim")]]
316
+ groups: dict[tuple[str, bool], list[str]] = {}
317
+ for node in nodes:
318
+ key = (_source_label(node, via), node["trust"] == "review_required")
319
+ groups.setdefault(key, []).append(node["column"])
320
+ items = list(groups.items())
321
+ shown = items if full else items[:PREVIEW_COLUMNS]
322
+ hidden = sum(len(columns) for _, columns in items[len(shown) :])
323
+ for (label, review), columns in shown:
324
+ named = columns if full else columns[:PREVIEW_COLUMNS]
325
+ hidden += len(columns) - len(named)
326
+ segments = [(f" {', '.join(named)} ", "plain"), (f"← {label}", "dim")]
327
+ if review:
328
+ segments.append((" needs review", "warn"))
329
+ lines.append(segments)
330
+ if hidden:
331
+ lines.append([(f" and {hidden} more (--full)", "dim")])
332
+ return lines
333
+
334
+
335
+ def _dashboard_lines(groups: list[tuple[str, list[dict]]], full: bool) -> list:
336
+ """Dashboard numbers in their own section, the kind as a word after the
337
+ name, so an analyst can skip it and an engineer knows which yml to open."""
338
+ entries: dict[tuple[str, str], dict] = {}
339
+ for model, nodes in groups:
340
+ kind, _, rest = model.partition(":")
341
+ if kind == "semantic":
342
+ entity, _, _ = rest.rpartition(".")
343
+ key = (entity or rest, kind)
344
+ columns = [n["column"] for n in nodes]
345
+ else:
346
+ key = (kind, kind)
347
+ columns = [rest]
348
+ entry = entries.setdefault(key, {"columns": [], "review": False})
349
+ entry["columns"].extend(columns)
350
+ entry["review"] |= any(n["trust"] == "review_required" for n in nodes)
351
+ lines: list = [[], [("Dashboard numbers", "plain")]]
352
+ items = list(entries.items())
353
+ shown = items if full else items[:PREVIEW_MODELS]
354
+ for (entity, kind), entry in shown:
355
+ names = ", ".join(entry["columns"])
356
+ text = (
357
+ f" {kind}s: {names}" if entity == kind else f" {entity} ({KIND_WORDS[kind]}): {names}"
358
+ )
359
+ segments = [(text, "plain")]
360
+ if entry["review"]:
361
+ segments.append((" needs review", "warn"))
362
+ lines.append(segments)
363
+ if len(items) > len(shown):
364
+ lines.append([(f" ...and {len(items) - len(shown)} more (--full)", "dim")])
365
+ return lines
366
+
367
+
368
+ def breaks_lines(answer: dict, full: bool = False) -> list[list[tuple[str, str]]]:
369
+ """The text every door prints, as lines of (text, role) segments.
370
+
371
+ Roles: plain, bold, dim, warn. A terminal styles them; a page or an
372
+ agent joins the text. A hub column can feed hundreds of models, so the
373
+ headline is the answer and the blocks are detail: counts and names
374
+ first, everything on full.
375
+ """
376
+ s, noun, target = answer["summary"], answer["noun"], answer["target"]
377
+ if not has_hits(answer):
378
+ return [[(f"Nothing downstream reads {target}.", "plain")]]
379
+ count, where, _ = headline_parts(answer)
380
+ lines = [
381
+ [
382
+ (count, "bold"),
383
+ (" columns in ", "plain"),
384
+ (where, "bold"),
385
+ (f" affected by {target}", "plain"),
386
+ ]
387
+ ]
388
+ if s["review"]:
389
+ lines.append([(f"{s['review']} of them need review (lineage uncertain)", "warn")])
390
+ groups = _groups(answer)
391
+ models = [(m, nodes) for m, nodes in groups if node_kind(m) == "model"]
392
+ dashboards = [(m, nodes) for m, nodes in groups if node_kind(m) == "dashboard"]
393
+ if len(models) > 1:
394
+ named = ", ".join(f"{m} ({n})" for m, n in hit_hardest(answer))
395
+ lines.append([(f"Hit hardest: {named}", "dim")])
396
+ lines.append([])
397
+ via: dict[str, str] = {}
398
+ for edge in answer["edges"]:
399
+ via.setdefault(edge["dst"], edge["src"])
400
+ shown = models if full else models[:PREVIEW_MODELS]
401
+ for model, nodes in shown:
402
+ lines.extend(_model_block(model, nodes, via, full))
403
+ if len(models) > len(shown):
404
+ lines.append([(f" ...and {len(models) - len(shown)} more {noun}s (--full)", "dim")])
405
+ if dashboards:
406
+ lines.extend(_dashboard_lines(dashboards, full))
407
+ rows = answer["row_level"]
408
+ shown_rows = rows if full else rows[:PREVIEW_MODELS]
409
+ lines.extend(_row_line(row) for row in shown_rows)
410
+ if len(rows) > len(shown_rows):
411
+ lines.append([(f" ...and {len(rows) - len(shown_rows)} more (--full)", "dim")])
412
+ if not s["complete"]:
413
+ # breaks_all reports an incomplete walk without saying at which depth
414
+ depth = answer.get("truncated_at_depth")
415
+ stopped = f"at depth {depth}" if depth else "at the depth cap"
416
+ lines.append([(f" stopped {stopped}; the full radius may be larger", "warn")])
417
+ lines.append([])
418
+ return lines
419
+
420
+
421
+ def trace_lines(answer: dict) -> list[list[tuple[str, str]]]:
422
+ """Where the asked column comes from, one line per hop, indented by depth."""
423
+ target = answer["target"]
424
+ by_id = {n["id"]: n for n in answer["nodes"]}
425
+ hops = [e for e in answer["edges"] if by_id.get(e["src"], {}).get("kind") != "unknown"]
426
+ if not hops:
427
+ return [[(f"No upstream lineage for {target}.", "plain")]]
428
+ lines = [[(f"{target} comes from:", "plain")]]
429
+ for edge in hops:
430
+ node = by_id[edge["src"]]
431
+ segments = [(" " * node["depth"] + edge["src"], "plain")]
432
+ if edge.get("path_trust", node["trust"]) == "review_required":
433
+ segments.append((" review", "warn"))
434
+ segments.append((" ", "plain"))
435
+ segments.append(("→ " + edge["dst"], "dim"))
436
+ lines.append(segments)
437
+ if answer.get("truncated_at_depth"):
438
+ lines.append(
439
+ [
440
+ (
441
+ f" stopped at depth {answer['truncated_at_depth']}; "
442
+ "the trail may go further back (--depth to follow it)",
443
+ "warn",
444
+ )
445
+ ]
446
+ )
447
+ return lines
448
+
449
+
450
+ def plain_text(lines: list[list[tuple[str, str]]]) -> str:
451
+ """The lines with no styling, one string, for a page or an agent."""
452
+ return "\n".join("".join(text for text, _ in segments) for segments in lines)
453
+
454
+
455
+ def attach(result: dict, kind: str, noun: str) -> dict:
456
+ """A graph result plus its contract (`answer`) and preview text (`text`),
457
+ the top-level counts aligned to the contract. Every other key stays.
458
+
459
+ The graph counts a dashboard number as a column; the contract does not,
460
+ so a payload carrying both used to disagree with its own text."""
461
+ if kind == "breaks":
462
+ answer = breaks_answer(result, noun)
463
+ text = plain_text(breaks_lines(answer))
464
+ else:
465
+ answer = trace_answer(result, noun)
466
+ text = plain_text(trace_lines(answer))
467
+ s = answer["summary"]
468
+ out = {**result, "answer": answer, "text": text}
469
+ out["impacted_columns"] = s["columns"]
470
+ out["dashboard_numbers"] = s["dashboard_numbers"]
471
+ if "impacted_models" in result:
472
+ out["impacted_models"] = s["models"]
473
+ return out