From d4456f1ee9ddaee8f00a9cf601d5cb9368a217fc Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 5 Nov 2026 20:58:17 +0800 Subject: [PATCH 01/21] feat: an end-of-task column impact summary and an offline HTML report A task that changes a dbt model ends with one band: each changed column's downstream reach, read from a manifest copied at the task's start. Open report or /lineage-report opens the report. Edits no longer toast. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YNQjkaiykbC1WtASw6Yegs --- plugins/altimate-code/CHANGELOG.md | 8 + plugins/altimate-code/README.md | 3 +- .../altimate-code/hooks/impact-report.test.ts | 194 +++++++ plugins/altimate-code/hooks/impact-report.ts | 518 ++++++++++++++++++ plugins/altimate-code/hooks/integrations.tsx | 9 +- plugins/altimate-code/hooks/lineage.test.tsx | 74 ++- plugins/altimate-code/hooks/lineage.tsx | 57 +- .../altimate-code/hooks/task-impact.test.tsx | 261 +++++++++ plugins/altimate-code/hooks/task-impact.tsx | 343 ++++++++++++ plugins/altimate-code/types/index.d.ts | 32 +- 10 files changed, 1411 insertions(+), 88 deletions(-) create mode 100644 plugins/altimate-code/hooks/impact-report.test.ts create mode 100644 plugins/altimate-code/hooks/impact-report.ts create mode 100644 plugins/altimate-code/hooks/task-impact.test.tsx create mode 100644 plugins/altimate-code/hooks/task-impact.tsx diff --git a/plugins/altimate-code/CHANGELOG.md b/plugins/altimate-code/CHANGELOG.md index 21403c3..87c0c7e 100644 --- a/plugins/altimate-code/CHANGELOG.md +++ b/plugins/altimate-code/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this plugin will be documented in this file. +## [Unreleased] + +### Added +- End-of-task column impact: after a task that changed a dbt model, a band sums up each changed column's downstream reach from `impact_analysis`, and **Open report** or `/lineage-report` opens an offline HTML report with upstream and downstream lineage, two levels by default, and click-to-highlight paths. The task's start copies `target/manifest.json` aside, so the answer reflects the SQL before the task. + +### Changed +- The per-edit impact note no longer shows as a toast and band; it still goes to Claude's context with the edit's result. + ## [1.4.0] — 2026-10-28 ### Added diff --git a/plugins/altimate-code/README.md b/plugins/altimate-code/README.md index 25365d6..27d1254 100644 --- a/plugins/altimate-code/README.md +++ b/plugins/altimate-code/README.md @@ -107,7 +107,8 @@ The plugin's lineage pane follows the dbt model Claude reads or edits (by Read, - **What the answer is about.** Once the session has seen a dbt project, after each answer the models Claude named are listed under the header (IN THE ANSWER), each a press away. When Claude answered without reading any model that turn (from what it read earlier), the pane follows the first model it named, so the lineage shown matches the answer. - **Columns.** Each output column with its source columns and how it is derived, from altimate-code's `dbt_lineage`, read from the compiled manifest (`dbt compile`). The plugin's calls are read-only, so altimate-code never starts the project's dbt adapter for them. - **Column reach.** `c` picks a column; the pane shows the downstream models whose SQL reads it and the dashboards on them, from `impact_analysis` (it traces each downstream model's compiled SQL, renames included). Models that name the column only in a filter or join, or that could not be traced (no compiled SQL), are listed apart, and the count is then a lower bound. -- **Impact first.** Before an edit to a model's SQL lands, the plugin checks what reads the model and the reach of every column the edit touches (up to three, merged). It shows the result as a toast, adds it to Claude's context with the edit's result, and leaves a band above the prompt with the severity (impact_analysis' thresholds) and counts. +- **Impact first.** Before an edit to a model's SQL lands, the plugin checks what reads the model and the reach of every column the edit touches (up to three, merged), and adds it to Claude's context with the edit's result. You see the summary at the end of the task, not a note per edit. +- **End-of-task summary.** When a task (one prompt and Claude's work on it) changed a model, by an edit or a shell command git sees, a band above the prompt says what changed and how far it reaches: each changed column (up to three per model) with how it is derived, the columns, models and end columns downstream that carry its value, the models that may carry it and those cleared, next to the model-level count, and a line when some models could not be read. The trace reads a copy of `target/manifest.json` taken when the task started (`target/.altimate-lineage/manifest-before.json`), so a recompile mid-task does not change the answer. **Open report** (or `/lineage-report`) opens a full offline HTML report in the browser (`target/.altimate-lineage/report.html`): the change before and after, two levels upstream and downstream (more on request), and a click on any column lights up its path. - **Warehouse lineage.** When a connected MCP server offers Altimate's `table_lineage` (Snowflake) or `databricks_table_lineage`, the pane adds what the warehouse's query history shows for the model's table. The plugin calls a tool of that name on whichever connected server offers it, itself and without a permission prompt, and sends it the model's `database.schema.table`. It does so only when you ask for the model (`/lineage`, a press in the pane, or `r`), never when the pane follows one of Claude's reads. - **Claude's own lineage calls.** A `dbt_lineage` result is drawn as a list of edges colored by how each column is derived; ctrl+o keeps the original output under it. diff --git a/plugins/altimate-code/hooks/impact-report.test.ts b/plugins/altimate-code/hooks/impact-report.test.ts new file mode 100644 index 0000000..14855a9 --- /dev/null +++ b/plugins/altimate-code/hooks/impact-report.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, test } from 'claude-code/testing' + +import { + answerOf, + changedColumns, + type ColumnAnswer, + type ColumnTrace, + DEFAULT_LEVELS, + type ModelChange, + openerFor, + reachOf, + reportData, + reportHtml, + sectionOf, + summaryLines, + summaryOf, +} from './impact-report' + +// A synthetic project: orders → fct_orders.revenue → three hops of readers. +export const DOWN: ColumnAnswer = { + version: 1, + focus: { model: 'model.shop.fct_orders', column: 'revenue' }, + direction: 'downstream', + inputs: { manifest: { generated_at: '2026-01-01', older_than_sql: false }, sql: 'compiled_code', catalog: null, engine: 'core 1.0', workarounds: [] }, + counts: { columns: 4, models: 3, hops: 3, contracts: 1, ends: 2, may_carry_models: 1, cleared_models: 2, model_level: 6 }, + nodes: [ + { id: 'model.shop.fct_orders', name: 'fct_orders', folder: 'marts', materialized: 'table', contract: false, tests: 1, exposures: [] }, + { id: 'model.shop.fct_revenue_daily', name: 'fct_revenue_daily', folder: 'marts', materialized: 'table', contract: true, tests: 2, exposures: ['exec_revenue'] }, + { id: 'model.shop.rpt_finance', name: 'rpt_finance', folder: 'reports', materialized: 'view', contract: false, tests: 0, exposures: [] }, + { id: 'model.shop.rpt_board', name: 'rpt_board', folder: 'reports', materialized: 'view', contract: false, tests: 0, exposures: [] }, + { id: 'model.shop.rpt_misc', name: 'rpt_misc', folder: 'reports', materialized: 'view', contract: false, tests: 0, exposures: [] }, + { id: 'model.shop.rpt_broken', name: 'rpt_broken', folder: 'reports', materialized: 'view', contract: false, tests: 0, exposures: [] }, + ], + columns: [ + { model: 0, column: 'revenue', hop: 0 }, + { model: 1, column: 'daily_revenue', hop: 1, type: 'number' }, + { model: 1, column: 'revenue', hop: 1, end: true, how: '=', how_from: 'name' }, + { model: 2, column: 'weekly_revenue', hop: 2 }, + { model: 3, column: 'board_revenue', hop: 3, end: true }, + ], + edges: [ + [0, 1, 'Σ'], + [0, 2, '='], + [1, 3, 'ƒ'], + [3, 4, '~'], + ], + also_reads: [{ model: 4, columns: ['revenue'], downstream: 2 }], + unresolved: [{ model: 5, reason: 'parse error' }], + truncated: false, +} + +export const UP: ColumnAnswer = { + version: 1, + focus: { model: 'model.shop.fct_orders', column: 'revenue' }, + direction: 'upstream', + nodes: [ + { id: 'model.shop.fct_orders', name: 'fct_orders' }, + { id: 'model.shop.int_orders', name: 'int_orders' }, + { id: 'source.shop.raw.orders', name: 'orders' }, + ], + columns: [ + { model: 0, column: 'revenue', hop: 0 }, + { model: 1, column: 'amount', hop: 1 }, + { model: 2, column: 'amount_cents', hop: 2, end: true }, + ], + edges: [ + [1, 0, 'Σ'], + [2, 1, 'ƒ'], + ], +} + +export const CHANGE: ModelChange = { + model: 'fct_orders', + file: 'models/fct_orders.sql', + root: '/repo', + columns: [{ name: 'revenue', before: 'sum(amount) as revenue', after: 'sum(amount) - sum(refunds) as revenue' }], +} +const TRACE: ColumnTrace = { model: 'fct_orders', column: 'revenue', down: DOWN, up: UP, modelLevel: 6, failed: false } + +/** The data a report embeds, read back out of its page. */ +export function embedded(html: string): any { + const match = /' } + const html = reportHtml(reportData([hostile], [{ ...TRACE, model: hostile.model }], 'now', 'm')) + expect(html).not.toMatch(/(src|href)="(https?:)?\/\//) + expect(html).not.toContain(' + + + +` +} + +const REPORT_CSS = ` +:root{ + --bg:#f7f7f5;--panel:#ffffff;--ink:#1d1d1b;--muted:#6b6b66;--faint:#9a9a93;--line:#e4e3de;--line-strong:#cfcdc6; + --accent:#3b5bdb;--accent-soft:#e8edff;--focus:#fff4d6;--focus-line:#e6b84a; + --same:#2f7d4f;--same-bg:#e3f3e9;--ren:#2c62b8;--ren-bg:#e3ecfb;--calc:#a35c00;--calc-bg:#fbefdc;--agg:#7a3fb0;--agg-bg:#f0e6fa;--unk:#6b6b66;--unk-bg:#eeeeea; + --warn:#9a5b00;--warn-bg:#fff3dc;--del:#b42318;--del-bg:#fdecea;--add:#1f7a43;--add-bg:#e6f5ec; + --shadow:0 1px 2px rgba(0,0,0,.05),0 2px 8px rgba(0,0,0,.04); +} +@media (prefers-color-scheme:dark){:root{ + --bg:#151515;--panel:#1e1e1d;--ink:#ecebe6;--muted:#a3a29b;--faint:#76756f;--line:#2f2f2d;--line-strong:#454540; + --accent:#8ea6ff;--accent-soft:#25293b;--focus:#3a3220;--focus-line:#b48a2c; + --same:#6fcf97;--same-bg:#1c3326;--ren:#8fb6ff;--ren-bg:#1d2a40;--calc:#f0b35c;--calc-bg:#3a2a12;--agg:#c79bf2;--agg-bg:#2f2240;--unk:#a3a29b;--unk-bg:#2a2a28; + --warn:#f0b35c;--warn-bg:#352a15;--del:#ff8a80;--del-bg:#3a1f1d;--add:#7ddc9f;--add-bg:#1b3325; + --shadow:none; +}} +*{box-sizing:border-box} +html{-webkit-text-size-adjust:100%} +body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif} +code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;font-size:12.5px} +.wrap{max-width:1040px;margin:0 auto;padding:20px 16px 64px} +header.top{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 14px;margin-bottom:14px} +header.top h1{font-size:20px;margin:0;font-weight:650;letter-spacing:-.01em} +header.top .when{color:var(--muted);font-size:12.5px} +.panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow)} +.changes{padding:14px 16px;margin-bottom:14px} +.changes h2,.section h2{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:0 0 8px;font-weight:600} +.change-model{margin:8px 0 2px;font-weight:600;overflow-wrap:anywhere} +.change-model .file{color:var(--faint);font-weight:400;margin-left:6px;font-size:12px} +.diff{display:grid;grid-template-columns:auto 1fr;gap:3px 10px;margin:6px 0 10px;align-items:start} +.diff .col-name{font-weight:600;grid-row:span 2;padding-top:2px} +.diff .was,.diff .now{border-radius:6px;padding:3px 8px;overflow-wrap:anywhere;white-space:pre-wrap} +.diff .was{background:var(--del-bg);color:var(--del)} +.diff .now{background:var(--add-bg);color:var(--add)} +.diff .none{opacity:.7;font-style:italic} +.diff .same{grid-column:2;color:var(--muted);font-size:12.5px} +.tabs{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 12px} +.tab{appearance:none;border:1px solid var(--line-strong);background:var(--panel);color:var(--ink);border-radius:999px;padding:5px 12px;font:inherit;cursor:pointer} +.tab[aria-selected=true]{background:var(--accent);border-color:var(--accent);color:#fff} +@media (prefers-color-scheme:dark){.tab[aria-selected=true]{color:#111}} +.stats{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 10px} +.stat{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:6px 10px;min-width:92px} +.stat b{display:block;font-size:18px;line-height:1.2} +.stat span{color:var(--muted);font-size:12px} +.stat.dim b{color:var(--muted)} +.note{border-radius:8px;padding:8px 12px;margin:0 0 12px;font-size:13px} +.note.warn{background:var(--warn-bg);color:var(--warn)} +.note.ok{background:var(--same-bg);color:var(--same)} +.inputs{color:var(--muted);font-size:12px;margin:-4px 0 14px} +.legend{display:flex;flex-wrap:wrap;gap:4px 12px;color:var(--muted);font-size:12px;margin:0 0 14px} +.legend .g{margin-right:4px} +.section{margin:0 0 12px} +.section-head{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin:18px 0 8px} +.section-head h2{flex:1} +.section-head h2{margin:0} +.more{appearance:none;border:1px dashed var(--line-strong);background:transparent;color:var(--accent);border-radius:8px;padding:4px 10px;font:inherit;font-size:12.5px;cursor:pointer} +.more:hover{background:var(--accent-soft)} +.hop{margin:0 0 10px} +.hop-label{font-size:12px;color:var(--muted);margin:0 0 6px;display:flex;align-items:center;gap:8px} +.hop-label::after{content:"";flex:1;border-top:1px solid var(--line)} +.models{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:8px} +.model{padding:9px 10px 6px;transition:opacity .15s} +.model-head{display:flex;flex-wrap:wrap;align-items:center;gap:4px 6px;margin-bottom:4px} +.model-name{font-weight:600;overflow-wrap:anywhere} +.badge{font-size:11px;border-radius:4px;padding:0 5px;border:1px solid var(--line-strong);color:var(--muted);line-height:17px} +.badge.c{border-color:var(--accent);color:var(--accent);font-weight:700} +.badge.x{border-color:var(--agg);color:var(--agg)} +.model-meta{color:var(--faint);font-size:11.5px;margin-bottom:4px} +.col{display:flex;align-items:center;gap:7px;width:100%;appearance:none;border:0;background:transparent;color:inherit;font:inherit;text-align:left;padding:3px 4px;border-radius:6px;cursor:pointer} +.col:hover{background:var(--accent-soft)} +.col .name{overflow-wrap:anywhere;flex:1} +.col .type{color:var(--faint);font-size:11.5px} +.col .end{font-size:10.5px;color:var(--muted);border:1px solid var(--line-strong);border-radius:4px;padding:0 4px} +.g{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;border-radius:5px;font-weight:700;font-size:12.5px;flex:none} +.g.same{color:var(--same);background:var(--same-bg)} +.g.ren{color:var(--ren);background:var(--ren-bg)} +.g.calc{color:var(--calc);background:var(--calc-bg)} +.g.agg{color:var(--agg);background:var(--agg-bg)} +.g.unk{color:var(--unk);background:var(--unk-bg)} +.g.byname{outline:1.5px dashed currentColor;outline-offset:-1.5px;background:transparent} +.focus{padding:12px 14px;border:1.5px solid var(--focus-line);background:var(--focus);margin:6px 0} +.focus .label{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)} +.focus .title{font-size:17px;font-weight:650;margin:2px 0;display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.focus .col{width:auto;padding:2px 6px;display:inline-flex} +.empty{color:var(--muted);font-size:13px;padding:6px 2px} +details.fold{margin:8px 0;padding:0 12px} +details.fold summary{cursor:pointer;padding:10px 0;font-weight:600} +details.fold summary span{color:var(--muted);font-weight:400} +details.fold ul{margin:0 0 10px;padding-left:18px} +details.fold li{margin:3px 0} +details.fold .why{color:var(--muted)} +.pathbar{position:sticky;top:0;z-index:5;display:none;align-items:center;gap:10px;flex-wrap:wrap;background:var(--accent);color:#fff;padding:8px 14px;border-radius:0 0 10px 10px;margin:-20px 0 12px;font-size:13px} +@media (prefers-color-scheme:dark){.pathbar{color:#111}} +.pathbar button{appearance:none;border:1px solid currentColor;background:transparent;color:inherit;border-radius:6px;padding:2px 8px;font:inherit;cursor:pointer} +body.hl .pathbar{display:flex} +body.hl .col:not(.on){opacity:.28} +body.hl .model:not(.has-on){opacity:.45} +body.hl .col.on{background:var(--accent-soft)} +body.hl .col.sel{outline:2px solid var(--accent)} +.noscript{padding:16px} +@media (max-width:560px){ + .wrap{padding:14px 12px 48px} + header.top h1{font-size:17px} + .models{grid-template-columns:1fr} + .stat{min-width:0;flex:1 1 40%} +} +` + +const REPORT_JS = ` +(function(){ +var DATA=JSON.parse(document.getElementById('report-data').textContent); +var app=document.getElementById('app'); +var GLYPH={'=':'same','~':'ren','\\u0192':'calc','\\u03a3':'agg','?':'unk'}; +var HOW={'=':'same value','~':'renamed','\\u0192':'calculated','\\u03a3':'aggregated','?':'unknown'}; +var state={focus:0,levels:[],selected:null}; +DATA.focuses.forEach(function(){state.levels.push({up:DATA.defaultLevels,down:DATA.defaultLevels})}); +function el(tag,cls,text){var n=document.createElement(tag);if(cls)n.className=cls;if(text!=null)n.textContent=String(text);return n} +function plural(n,w){return n+' '+w+(n===1?'':'s')} +function glyph(g,byName){var s=el('span','g '+(GLYPH[g]||'unk')+(byName?' byname':''),g||'?');s.title=(HOW[g]||'unknown')+(byName?' (matched by name, not traced through SQL)':'');return s} +function graphOf(f){var fwd={},back={};f.edges.forEach(function(e){(fwd[e[0]]=fwd[e[0]]||[]).push(e[1]);(back[e[1]]=back[e[1]]||[]).push(e[0])});return{fwd:fwd,back:back}} +function walk(start,adj){var seen={},q=[start];seen[start]=1;while(q.length){var k=q.shift();(adj[k]||[]).forEach(function(n){if(!seen[n]){seen[n]=1;q.push(n)}})}return seen} +function pathOf(f,key){var g=graphOf(f),up=walk(key,g.back),down=walk(key,g.fwd),all={};Object.keys(up).forEach(function(k){all[k]=1});Object.keys(down).forEach(function(k){all[k]=1});return{all:all,up:Object.keys(up).length-1,down:Object.keys(down).length-1}} +function colButton(c){var b=el('button','col');b.type='button';b.dataset.key=c.key;b.appendChild(glyph(c.glyph,c.byName));b.appendChild(el('span','name mono',c.column));if(c.type)b.appendChild(el('span','type',c.type));if(c.end){var e=el('span','end','end');e.title='Nothing further downstream reads it';b.appendChild(e)}b.addEventListener('click',function(ev){ev.stopPropagation();select(c.key)});return b} +function modelCard(m){var card=el('div','panel model');var head=el('div','model-head');head.appendChild(el('span','model-name mono',m.name));if(m.contract){var c=el('span','badge c','C');c.title='Contract enforced: a change to this column can fail its build';head.appendChild(c)}if(m.tests){head.appendChild(el('span','badge',plural(m.tests,'test')))}m.exposures.forEach(function(x){var b=el('span','badge x','\\u25a3 '+x);b.title='Exposure (dashboard or app) on this model';head.appendChild(b)});card.appendChild(head);var meta=[m.folder,m.materialized].filter(Boolean).join(' \\u00b7 ');if(meta)card.appendChild(el('div','model-meta',meta));m.columns.forEach(function(c){card.appendChild(colButton(c))});return card} +function hopBlock(h,dir){var d=el('div','hop');var cols=h.models.reduce(function(n,m){return n+m.columns.length},0);d.appendChild(el('div','hop-label',(dir==='up'?'Reads from \\u00b7 ':'')+plural(h.hop,'hop')+' '+(dir==='up'?'up':'down')+' \\u00b7 '+plural(h.models.length,'model')+' \\u00b7 '+plural(cols,'column')));var grid=el('div','models');h.models.forEach(function(m){grid.appendChild(modelCard(m))});d.appendChild(grid);return d} +function section(f,i,dir){var sec=f[dir],lv=state.levels[i][dir],s=el('section','section');var head=el('div','section-head');head.appendChild(el('h2',null,dir==='up'?'Upstream: where it comes from':'Downstream: where it goes'));var total=sec.hops.length,hidden=sec.hops.filter(function(h){return h.hop>lv}).length;if(hidden>0){var more=el('button','more',hidden===1?'Show 1 more level':'Show more levels ('+hidden+' hidden)');more.type='button';more.addEventListener('click',function(){state.levels[i][dir]=lv+1;render()});head.appendChild(more);if(hidden>1){var all=el('button','more','Show all');all.type='button';all.addEventListener('click',function(){state.levels[i][dir]=Infinity;render()});head.appendChild(all)}}else if(total>DATA.defaultLevels&&lv>DATA.defaultLevels){var less=el('button','more','Back to '+DATA.defaultLevels+' levels');less.type='button';less.addEventListener('click',function(){state.levels[i][dir]=DATA.defaultLevels;render()});head.appendChild(less)}s.appendChild(head);if(sec.error){s.appendChild(el('div','empty',sec.error));return s}var shown=sec.hops.filter(function(h){return h.hop<=lv});if(!shown.length)s.appendChild(el('div','empty',dir==='up'?'Nothing upstream was traced for this column.':'No downstream model carries this column\\u2019s value.'));if(dir==='up')shown=shown.slice().reverse();shown.forEach(function(h){s.appendChild(hopBlock(h,dir))});if(sec.truncated)s.appendChild(el('div','note warn','The trace stopped at its limit: more may sit beyond the last level.'));return s} +function stat(n,label,dim){var d=el('div','stat'+(dim?' dim':''));d.appendChild(el('b',null,n==null?'\\u2013':n));d.appendChild(el('span',null,label));return d} +function fold(title,count,items){var d=el('details','panel fold');var s=el('summary',null,title+' ');s.appendChild(el('span',null,'('+(count==null?'not reported':count)+')'));d.appendChild(s);var ul=el('ul');items.forEach(function(it){ul.appendChild(it)});if(!items.length)ul.appendChild(el('li','why',count==null?'Not reported by this altimate-code build.':count?'Only the count is known.':'None.'));d.appendChild(ul);return d} +function li(name,rest){var l=el('li');l.appendChild(el('span','mono',name));if(rest){l.appendChild(document.createTextNode(' '));l.appendChild(el('span','why',rest))}return l} +function render(){ + var f=DATA.focuses[state.focus],i=state.focus;app.textContent=''; + var bar=el('div','pathbar');bar.id='pathbar';var wrap=el('div','wrap');wrap.appendChild(bar); + var top=el('header','top');top.appendChild(el('h1',null,DATA.title));top.appendChild(el('span','when','Generated '+DATA.generatedAt+' \\u00b7 downstream read from '+DATA.manifest));wrap.appendChild(top); + var ch=el('div','panel changes');ch.appendChild(el('h2',null,'What this task changed'));DATA.changes.forEach(function(c){var m=el('div','change-model mono',c.model);m.appendChild(el('span','file',c.file));ch.appendChild(m);if(!c.columns.length){ch.appendChild(el('div','empty','No output column changed (a filter or a join): only the model-level count applies.'));return}var d=el('div','diff');c.columns.forEach(function(col){d.appendChild(el('div','col-name mono',col.name));var was=el('div','was mono'+(col.before?'':' none'),col.before||'(not in the model before)');var now=el('div','now mono'+(col.after?'':' none'),col.after||'(removed)');if(col.before&&col.after&&col.before===col.after){d.appendChild(el('div','same','Expression unchanged; the column is named on a changed line.'));d.appendChild(el('div'))}else{d.appendChild(was);d.appendChild(now)}});ch.appendChild(d)});wrap.appendChild(ch); + if(DATA.focuses.length>1){var tabs=el('div','tabs');tabs.setAttribute('role','tablist');DATA.focuses.forEach(function(g,j){var t=el('button','tab',g.column?g.model+'.'+g.column:g.model+' (model)');t.type='button';t.setAttribute('role','tab');t.setAttribute('aria-selected',j===i?'true':'false');t.addEventListener('click',function(){state.focus=j;state.selected=null;render()});tabs.appendChild(t)});wrap.appendChild(tabs)} + var r=f.reach,stats=el('div','stats'); + if(r){stats.appendChild(stat(r.columns,'columns downstream'));stats.appendChild(stat(r.models,'models carry it'));stats.appendChild(stat(r.ends,'end columns'));stats.appendChild(stat(r.mayCarry,'may carry it',!r.mayCarry));stats.appendChild(stat(r.cleared,'cleared',r.cleared==null))} + stats.appendChild(stat(f.modelLevel,'downstream at model level',true));wrap.appendChild(stats); + if(r&&(r.unresolved||r.truncated))wrap.appendChild(el('div','note warn','\\u26a0 Lower bound: '+[r.unresolved?plural(r.unresolved,'model')+' could not be read':'',r.truncated?'the trace hit its limit':''].filter(Boolean).join('; ')+'. See \\u201cNot resolved\\u201d below.')); + else if(r)wrap.appendChild(el('div','note ok','\\u2713 Every downstream model was read.')); + var inp=[];if(f.inputs.engine)inp.push('engine '+f.inputs.engine);inp.push(f.inputs.catalog?'with catalog types':'no catalog: types from the manifest');if(f.inputs.olderThanSql)inp.push('the manifest is older than the SQL');f.inputs.workarounds.forEach(function(w){inp.push(w)});wrap.appendChild(el('div','inputs',inp.join(' \\u00b7 '))); + var lg=el('div','legend');['=','~','\\u0192','\\u03a3','?'].forEach(function(g){var s=el('span');s.appendChild(glyph(g,false));s.appendChild(document.createTextNode(HOW[g]));lg.appendChild(s)});var bn=el('span');bn.appendChild(glyph('=',true));bn.appendChild(document.createTextNode('matched by name'));lg.appendChild(bn);var cb=el('span');cb.appendChild(el('span','badge c','C'));cb.appendChild(document.createTextNode(' contract enforced'));lg.appendChild(cb);wrap.appendChild(lg); + wrap.appendChild(section(f,i,'up')); + var fc=el('div','panel focus');fc.appendChild(el('div','label','The changed column'));var ti=el('div','title');if(f.key){ti.appendChild(colButton({key:f.key,column:f.model+'.'+(f.column||''),glyph:f.glyph||'?',byName:f.byName,end:false,type:null}))}else ti.appendChild(el('span','mono',f.column?f.model+'.'+f.column:f.model));fc.appendChild(ti);if(f.glyph)var hw=HOW[f.glyph]||'unknown';fc.appendChild(el('div','empty',hw.charAt(0).toUpperCase()+hw.slice(1)+' from what it reads'+(f.byName?' (matched by name)':'')+'. Click any column to light up its path.'));wrap.appendChild(fc); + wrap.appendChild(section(f,i,'down')); + wrap.appendChild(fold('Also reads it, outside the select',f.alsoReads.length,f.alsoReads.map(function(a){return li(a.name,'reads '+a.columns.join(', ')+(a.downstream!=null?' \\u00b7 '+plural(a.downstream,'model')+' downstream of it':''))}))); + wrap.appendChild(fold('Not resolved',f.unresolved.length,f.unresolved.map(function(u){return li(u.name,u.reason+(u.downstream!=null?' \\u00b7 '+plural(u.downstream,'model')+' downstream of it':''))}))); + wrap.appendChild(fold('Cleared: downstream, but does not carry it',f.cleared.count,f.cleared.names.map(function(n){return li(n,'')}))); + app.appendChild(wrap);applyHighlight(); +} +function select(key){state.selected=state.selected===key?null:key;applyHighlight()} +function applyHighlight(){var f=DATA.focuses[state.focus],bar=document.getElementById('pathbar');var cols=document.querySelectorAll('.col');if(!state.selected){document.body.classList.remove('hl');cols.forEach(function(c){c.classList.remove('on','sel')});document.querySelectorAll('.model').forEach(function(m){m.classList.remove('has-on')});return} + var p=pathOf(f,state.selected);document.body.classList.add('hl');cols.forEach(function(c){var on=!!p.all[c.dataset.key];c.classList.toggle('on',on);c.classList.toggle('sel',c.dataset.key===state.selected)});document.querySelectorAll('.model').forEach(function(m){m.classList.toggle('has-on',!!m.querySelector('.col.on'))}); + var shown={};cols.forEach(function(c){shown[c.dataset.key]=1});var hidden=Object.keys(p.all).filter(function(k){return!shown[k]}).length; + bar.textContent='';bar.appendChild(el('span',null,'Path of '+state.selected.split('|').pop()+': '+plural(p.up,'column')+' upstream, '+plural(p.down,'column')+' downstream'+(hidden?' \\u00b7 '+hidden+' more on hidden levels':'')));var clr=el('button',null,'Clear');clr.type='button';clr.addEventListener('click',function(){select(state.selected)});bar.appendChild(clr)} +document.addEventListener('keydown',function(e){if(e.key==='Escape'&&state.selected)select(state.selected)}); +render(); +})(); +` + +/** The command that opens a file in the person's browser, per platform; null when none applies. */ +export type Platform = 'mac' | 'linux' | 'wsl' | 'windows' +export function openerFor(platform: Platform, path: string, wsl: { wslview: boolean; windowsPath?: string | null } = { wslview: false }): { argv: string[]; okExits: number[] } | null { + if (platform === 'mac') return { argv: ['open', path], okExits: [0] } + if (platform === 'linux') return { argv: ['xdg-open', path], okExits: [0] } + if (platform === 'windows') return { argv: ['cmd', '/c', 'start', '', path], okExits: [0] } + if (wsl.wslview) return { argv: ['wslview', path], okExits: [0] } + // explorer.exe exits 1 even when it opened the file. + return wsl.windowsPath ? { argv: ['explorer.exe', wsl.windowsPath], okExits: [0, 1] } : null +} diff --git a/plugins/altimate-code/hooks/integrations.tsx b/plugins/altimate-code/hooks/integrations.tsx index 256131b..198038a 100644 --- a/plugins/altimate-code/hooks/integrations.tsx +++ b/plugins/altimate-code/hooks/integrations.tsx @@ -3,7 +3,8 @@ import type { EngineInterface, Register } from 'claude-code' import type { Busy, Expanded, Form, IntegrationsStatus, Provider, ProviderId, WorkspaceChoice } from '../types' import { MISSING_BINARY, later, parseJson, runAltimate } from './altimate-cli' -import { lineageBand, registerLineage, startLineage } from './lineage' +import { registerLineage, startLineage } from './lineage' +import { registerTaskImpact, startTaskImpact, taskBand } from './task-impact' const PANE = 'altimate-integrations' const PANE_TITLE = 'Altimate integrations' @@ -560,6 +561,7 @@ export const register: Register = on => { }) // The lineage half failing to start must not stop this pane's own setup below. await startLineage($).catch(error => $.ui.log(`lineage-pane · could not start: ${String(error)}`)) + await startTaskImpact($).catch(error => $.ui.log(`lineage-summary · could not start: ${String(error)}`)) // A press that was running when an earlier session ended can never finish, so it must not block this one. await update($, busy, () => null) // A `-p` run or the SDK has nobody to show the band to, so it does not scan the machine. @@ -580,9 +582,9 @@ export const register: Register = on => { }) on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { - // One band per plugin: the lineage impact line rides under the integrations one, or alone. + // One band per plugin: the end-of-task lineage summary rides under the integrations one, or alone. if (e.props.hasSurvey) return next(e) - const lineage = await lineageBand($, $.ui.resolve(e)) + const lineage = await taskBand($, $.ui.resolve(e)) const pass = async () => lineage ?? next(e) const current = await read($, status) if (!current || (await read($, isBandHidden))) return pass() @@ -796,6 +798,7 @@ export const register: Register = on => { }) registerLineage(on) + registerTaskImpact(on) } // One row of buttons per provider, chosen by its state. Credentials are added diff --git a/plugins/altimate-code/hooks/lineage.test.tsx b/plugins/altimate-code/hooks/lineage.test.tsx index e8e18fb..31a9fc5 100644 --- a/plugins/altimate-code/hooks/lineage.test.tsx +++ b/plugins/altimate-code/hooks/lineage.test.tsx @@ -66,6 +66,8 @@ async function start($: any, on: any, options: Options = {}) { const clock = mock.clock(on) const calls: string[] = [] const toasts: string[] = [] + // The per-edit impact note: logged for the record, never a toast (the person sees the end-of-task summary). + const notes: string[] = [] const logs: string[] = [] const lists: string[] = [] on('command.register', () => ({ value: { command: 'lineage' } })) @@ -82,6 +84,7 @@ async function start($: any, on: any, options: Options = {}) { }) on('ui.log', (_: unknown, e: { text: string }) => { logs.push(e.text) + if (e.text.startsWith('◆ impact-first · ') && !e.text.startsWith('◆ impact-first · impact_analysis(')) notes.push(e.text) return { value: undefined } }) on('ui.render', ($$: any, e: any) => { @@ -188,7 +191,7 @@ async function start($: any, on: any, options: Options = {}) { }) await $.session.start({ cwd: ROOT, surface: 'terminal', isInteractive: true }) await clock.settle() - return { clock, calls, toasts, logs, lists } + return { clock, calls, toasts, notes, logs, lists } } test('reading a model points the pane at it: the manifest graph, then its columns from dbt_lineage', async ($, on) => { @@ -209,26 +212,23 @@ test('reading a model points the pane at it: the manifest graph, then its column await ui.unmount() }) -test('an edit naming a column is held until its reach is known, then the reach goes to Claude and the band', async ($, on) => { - const { clock, calls, toasts, logs } = await start($, on) +test('an edit naming a column is held until its reach is known, then the reach goes to Claude and the log, not the person', async ($, on) => { + const { clock, calls, toasts, notes, logs } = await start($, on) const edit = { tool: 'Edit' as const, file_path: FILE, old_string: 'sum(amount) as revenue', new_string: 'sum(amount) - sum(refunds) as revenue' } const result: any = await $.tool.call(edit) await clock.settle() // The reach is looked up before the edit runs. expect(calls.indexOf('mcp:impact_analysis:revenue')).toBeLessThan(calls.indexOf('tool:Edit')) - expect(toasts[0]).toBe('◆ impact-first · fct_orders.revenue is read by 2 models and 1 dashboard · Exec Revenue') + expect(notes[0]).toBe('◆ impact-first · fct_orders.revenue is read by 2 models and 1 dashboard · Exec Revenue') + expect(toasts).toEqual([]) expect(result.context?.[0]).toContain('read by 2 downstream models: fct_revenue_daily (revenue), rpt_finance_weekly (revenue)') expect(result.context?.[0]).toContain('dashboards on them: "Exec Revenue"') expect(logs).toContain("impact-first · added to Claude's context: lineage for fct_orders.revenue") + // The band waits for the end of the task (task-impact.tsx): nothing per edit. const band = await $.ui.mount({ ...BAND, surface: 'terminal' }) - expect(await band.find({ type: 'Text', text: 'fct_orders.revenue' })).toBeDefined() - expect(await band.find({ type: 'Text', text: 'MEDIUM' })).toBeDefined() - expect(await band.find({ type: 'Text', text: /direct 5 · transitive 1 · tests 1 · read by 2 models · 1 dashboards/ })).toBeDefined() - await band.press({ key: 'lineage-dismiss' }) - await clock.settle() - expect(await band.find({ key: 'lineage-open' })).toBeUndefined() + expect(await band.find({ type: 'Text', text: /fct_orders\.revenue/ })).toBeUndefined() await band.unmount() // The same column again adds nothing new to Claude's context. @@ -352,7 +352,7 @@ test('/lineage names a model and column; an unknown model says so', async ($, on }) test('a manifest over 4 MiB is read through altimate-code: the graph comes from dbt_lineage with include_graph', async ($, on) => { - const { clock, calls, toasts } = await start($, on, { size: 6 * 1024 * 1024 }) + const { clock, calls, notes } = await start($, on, { size: 6 * 1024 * 1024 }) await $.tool.call({ tool: 'Read', file_path: FILE }) await clock.settle() expect(calls).toEqual(['tool:Read', 'cli:dbt_lineage:graph']) @@ -365,7 +365,7 @@ test('a manifest over 4 MiB is read through altimate-code: the graph comes from // Impact-first works on the same path. await $.tool.call({ tool: 'Edit', file_path: FILE, old_string: 'x as revenue', new_string: 'y as revenue' }) await clock.settle() - expect(toasts[0]).toContain('fct_orders.revenue is read by 2 models') + expect(notes[0]).toContain('fct_orders.revenue is read by 2 models') }) test('a recompiled manifest is a new focus: the column lineage is read again', async ($, on) => { @@ -384,17 +384,14 @@ test('a recompiled manifest is a new focus: the column lineage is read again', a test('a subagent gets the lineage context the main agent already got, and an untraced model keeps the count a lower bound', async ($, on) => { const reach = { ...REACH, column_reach: { ...REACH.column_reach, untraced: [{ name: 'mart_cohort_ltv', reason: 'No compiled SQL found' }], uncertain: [] } } - const { clock, toasts } = await start($, on, { reach }) + const { clock, notes } = await start($, on, { reach }) const edit = { tool: 'Edit' as const, file_path: FILE, old_string: 'x as revenue', new_string: 'y as revenue' } const main: any = await $.tool.call(edit) const sub: any = await $.tool.call({ ...edit, agentId: 'sub-1' } as any) await clock.settle() - expect(toasts[0]).toContain('is read by at least 2 models') + expect(notes[0]).toContain('is read by at least 2 models') expect(main.context?.[0]).toContain('could not be traced: mart_cohort_ltv ("No compiled SQL found")') expect(sub.context?.[0]).toContain('read by at least 2 downstream models') - const band = await $.ui.mount({ ...BAND, surface: 'terminal' }) - expect(await band.find({ type: 'Text', text: /read by at least 2 models/ })).toBeDefined() - await band.unmount() }) test('a read Claude makes outside a dbt project stays quiet', async ($, on) => { @@ -444,10 +441,10 @@ test("a dbt_lineage result Claude's call got cut for size leaves the pane to fet test('dashboards on models that may read the column are counted and named', async ($, on) => { const reach = { ...REACH, column_reach: { ...REACH.column_reach, uncertain: [{ name: 'rpt_ops', reason: 'names it only outside its select list' }], uncertain_exposures: [{ name: 'ops', label: 'Ops Board' }] } } - const { clock, toasts } = await start($, on, { reach }) + const { clock, notes } = await start($, on, { reach }) const edit: any = await $.tool.call({ tool: 'Edit', file_path: FILE, old_string: 'x as revenue', new_string: 'y as revenue' }) await clock.settle() - expect(toasts[0]).toBe('◆ impact-first · fct_orders.revenue is read by at least 2 models and 2 dashboards · Exec Revenue · Ops Board') + expect(notes[0]).toBe('◆ impact-first · fct_orders.revenue is read by at least 2 models and 2 dashboards · Exec Revenue · Ops Board') expect(edit.context?.[0]).toContain('dashboards on models that may read it: "Ops Board"') }) @@ -465,28 +462,28 @@ test('/lineage on the same model with another column traces that column', async await ui.unmount() }) -test('the impact toast shows before the edit runs', async ($, on) => { - const { clock, toasts } = await start($, on, { toolResult: e => (e.tool === 'Edit' ? { toastsSoFar: toasts.length } : undefined) }) +test('the impact note is logged before the edit runs', async ($, on) => { + const { clock, notes } = await start($, on, { toolResult: e => (e.tool === 'Edit' ? { notesSoFar: notes.length } : undefined) }) const result: any = await $.tool.call({ tool: 'Edit', file_path: FILE, old_string: 'x as revenue', new_string: 'y as revenue' }) await clock.settle() - expect(result.result).toEqual({ toastsSoFar: 1 }) + expect(result.result).toEqual({ notesSoFar: 1 }) }) test('an edit touching two columns traces both and reports their combined reach', async ($, on) => { - const { clock, calls, toasts } = await start($, on) + const { clock, calls, notes } = await start($, on) await $.tool.call({ tool: 'Edit', file_path: FILE, old_string: 'i.revenue, i.order_id', new_string: 'coalesce(i.revenue, 0) as revenue, i.order_id + 0 as order_id' }) await clock.settle() expect(calls).toContain('mcp:impact_analysis:revenue') expect(calls).toContain('mcp:impact_analysis:order_id') - expect(toasts[0]).toContain('fct_orders.') - expect(toasts[0]).toContain('is read by 2 models and 1 dashboard') + expect(notes[0]).toContain('fct_orders.') + expect(notes[0]).toContain('is read by 2 models and 1 dashboard') }) test('a touched column whose check fails keeps the combined count a lower bound', async ($, on) => { - const { clock, toasts } = await start($, on, { failColumn: 'order_id' }) + const { clock, notes } = await start($, on, { failColumn: 'order_id' }) const edit: any = await $.tool.call({ tool: 'Edit', file_path: FILE, old_string: 'i.revenue, i.order_id', new_string: 'coalesce(i.revenue, 0) as revenue, i.order_id + 0 as order_id' }) await clock.settle() - expect(toasts[0]).toContain('fct_orders.revenue is read by at least 2 models') + expect(notes[0]).toContain('fct_orders.revenue is read by at least 2 models') expect(edit.context?.[0]).toContain('columns not checked (over the limit or no answer in time): order_id') }) @@ -500,7 +497,7 @@ test('a new unchecked column in the same turn still reaches Claude', async ($, o }) test('columns beyond the cap are named as unchecked and make the count a lower bound', async ($, on) => { - const { clock, calls, toasts } = await start($, on) + const { clock, calls, notes } = await start($, on) const edit: any = await $.tool.call({ tool: 'Edit', file_path: FILE, @@ -509,7 +506,7 @@ test('columns beyond the cap are named as unchecked and make the count a lower b }) await clock.settle() expect(calls.filter(call => call.startsWith('mcp:impact_analysis'))).toHaveLength(3) - expect(toasts[0]).toContain('is read by at least 2 models') + expect(notes[0]).toContain('is read by at least 2 models') expect(edit.context?.[0]).toMatch(/columns not checked \(over the limit or no answer in time\): \w+;/) }) @@ -1042,22 +1039,22 @@ test('an older column-reach answer does not replace the newer one', async ($, on await ui.unmount() }) -test('when the whole lookup runs out of time, a toast says the edit went ahead without lineage', async ($, on) => { - const { clock, toasts } = await start($, on, { holdFirstManifestRead: new Promise(() => {}) }) +test('when the whole lookup runs out of time, the log says the edit went ahead without lineage', async ($, on) => { + const { clock, notes } = await start($, on, { holdFirstManifestRead: new Promise(() => {}) }) const call = $.tool.call({ tool: 'Edit', file_path: FILE, old_string: 'x as revenue', new_string: 'y as revenue' }) await clock.advance(9_000) await call await clock.settle() - expect(toasts).toContain('◆ impact-first · the lineage lookup did not finish in time; the edit went ahead without it') + expect(notes).toContain('◆ impact-first · the lineage lookup did not finish in time; the edit went ahead without it') }) -test('a .sql file outside any dbt project gets no timeout toast', async ($, on) => { - const { clock, toasts } = await start($, on) +test('a .sql file outside any dbt project gets no timeout note', async ($, on) => { + const { clock, notes } = await start($, on) const call = $.tool.call({ tool: 'Edit', file_path: '/elsewhere/migrations/001.sql', old_string: 'a', new_string: 'b' }) await clock.advance(9_000) await call await clock.settle() - expect(toasts).toEqual([]) + expect(notes).toEqual([]) }) test("the pane reads the dbt integration's model lineage from altimate-code", async ($, on) => { @@ -1140,14 +1137,11 @@ test("project text in the impact note is one line per name, quoted, capped, and }) test('an unreadable dashboards section keeps the dashboard count a lower bound', async ($, on) => { - const { clock, toasts } = await start($, on, { reach: { ...REACH, exposures_error: 'exposures: bad yaml' } }) + const { clock, notes } = await start($, on, { reach: { ...REACH, exposures_error: 'exposures: bad yaml' } }) const edit: any = await $.tool.call({ tool: 'Edit', file_path: FILE, old_string: 'x as revenue', new_string: 'y as revenue' }) await clock.settle() - expect(toasts[0]).toBe('◆ impact-first · fct_orders.revenue is read by 2 models and at least 1 dashboard · Exec Revenue') + expect(notes[0]).toBe('◆ impact-first · fct_orders.revenue is read by 2 models and at least 1 dashboard · Exec Revenue') expect(edit.context?.[0]).toContain('the dashboards could not be read ("exposures: bad yaml")') - const band = await $.ui.mount({ ...BAND, surface: 'terminal' }) - expect(await band.find({ type: 'Text', text: /at least 1 dashboards/ })).toBeDefined() - await band.unmount() }) test('no column trace starts once the hold has run out', async ($, on) => { diff --git a/plugins/altimate-code/hooks/lineage.tsx b/plugins/altimate-code/hooks/lineage.tsx index 2e72a34..fb82e8e 100644 --- a/plugins/altimate-code/hooks/lineage.tsx +++ b/plugins/altimate-code/hooks/lineage.tsx @@ -1,5 +1,5 @@ import { atom, read, update } from 'claude-code' -import type { EngineInterface, On, RenderElement } from 'claude-code' +import type { EngineInterface, On } from 'claude-code' import type { AnswerModel, @@ -33,6 +33,7 @@ import { upstreamArt, warehouseEdges, } from './lineage-graph' +import { taskBeforeTool, taskTurnComplete, taskTurnStart } from './task-impact' const PANE = 'altimate-lineage' const PANE_TITLE = 'Altimate lineage' @@ -67,10 +68,8 @@ let revealFor: string | null = null const answerAtom = atom({ plugin: 'altimate-code', key: 'lineageAnswer' } as const, [] as AnswerModel[]) // Where the graph canvas is panned to: its first visible column; null centres it on the model. const panAtom = atom({ plugin: 'altimate-code', key: 'lineagePan' } as const, null as number | null) -const impactAtom = atom({ plugin: 'altimate-code', key: 'lineageImpact' } as const, null, { shape: 'lineage-impact-1' }) const messageAtom = atom({ plugin: 'altimate-code', key: 'lineageMessage' } as const, null as string | null) const loadingAtom = atom({ plugin: 'altimate-code', key: 'lineageLoading' } as const, false) -const bandHiddenAtom = atom({ plugin: 'altimate-code', key: 'isLineageBandHidden' } as const, false) // Caches, rebuilt from the manifest file after a reload. let manifestCache: { path: string; mtimeMs: number; graph: LineageGraph } | null = null @@ -135,7 +134,7 @@ const SEVERITY_COLOR: Record = { } /** Resolves with the work's value, or null once `ms` pass or it fails. */ -function within($: EngineInterface, ms: number, work: Promise): Promise { +export function within($: EngineInterface, ms: number, work: Promise): Promise { return new Promise(resolve => { let settled = false const settle = (value: T | null) => { @@ -167,7 +166,7 @@ const dirname = (path: string) => path.replace(/[\\/][^\\/]*$/, '') || path const relative = (root: string, path: string) => path.slice(root.length).replace(/^[\\/]+/, '').replace(/\\/g, '/') /** The dbt project above a file or folder: the closest folder holding dbt_project.yml. */ -async function projectRoot($: EngineInterface, start: string, isFile: boolean): Promise { +export async function projectRoot($: EngineInterface, start: string, isFile: boolean): Promise { let dir = isFile ? dirname(start) : start for (let i = 0; i < 40; i++) { if (await $.fs.exists(`${dir}/dbt_project.yml`).catch(() => false)) return dir @@ -406,7 +405,7 @@ function fileUriPath(uri: string): string { * `cliOnly` goes straight to the CLI, whose --json carries the whole metadata; a structured result * the server left out for size is fetched that way too. */ -async function runTool($: EngineInterface, name: string, args: Record, cliOnly = false, until?: number) { +export async function runTool($: EngineInterface, name: string, args: Record, cliOnly = false, until?: number) { // With a deadline, nothing starts once it has passed, and the CLI is stopped at it. const left = async () => { const ms = until === undefined ? TOOL_TIMEOUT_MS : until - (await $.clock.now()) @@ -857,7 +856,7 @@ function cut(text: string, max: number): string { /** * Before an edit to a model's SQL: what reads the model, and, when the edit changes one of its - * columns, which downstream models and dashboards read that column. Shown as a toast, then added to + * columns, which downstream models and dashboards read that column. Logged, then added to * Claude's context with the edit's result. */ async function impactFirst($: EngineInterface, e: any, file: string, deadline: number, progress: { project: boolean }): Promise { @@ -1012,36 +1011,6 @@ export async function startLineage($: EngineInterface) { type Ui = { Box: any; Button: any; Text: any } -/** The band line after Claude edits a model; drawn by the plugin's one band hook. */ -export async function lineageBand($: EngineInterface, ui: Ui): Promise { - const impact = await read($, impactAtom) - if (!impact || (await read($, bandHiddenAtom))) return null - const { Box, Button, Text } = ui - const target = impact.column ? `${impact.model}.${impact.column}` : impact.model - // One Text, so a narrow band wraps it as a line, not word by word in each piece. - return ( - - - ⚠ Impact - {target} - (modify) - - {impact.severity} - - - {' '}direct {impact.direct} · transitive {impact.transitive}{impact.truncated ? '+' : ''} · tests {impact.tests} - {impact.readers !== null ? ` · read by ${impact.unsettled ? 'at least ' : ''}${impact.readers} models` : ''} - {impact.exposures.length || impact.dashboardsUnsettled ? ` · ${impact.dashboardsUnsettled ? 'at least ' : ''}${impact.exposures.length} dashboards` : ''} - - - -