Repository navigation
feat: dbt lineage pane beside the chat (#14, onto main) - #15
Conversation
The pane draws the model's graph from target/manifest.json: two levels upstream, everything downstream with the dashboards on it, each output column's sources from dbt_lineage, and a column's reach from impact_analysis. Before an edit to a model's SQL lands, the plugin looks up what reads the model and the edited column, shows it as a toast, adds it to Claude's context and leaves an impact band above the prompt. Claude's own dbt_lineage results are drawn as colored edge lists, and a connected table_lineage MCP tool adds warehouse lineage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Large manifests get their graph from altimate-code's dbt_lineage with include_graph; follows keep the followed project and manifest; column reach counts become lower bounds when models are unsettled; the lineage note is given once per turn and per agent; the CLI helpers are shared with the integrations pane. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Picking one column from an edit's raw dbt SQL guessed wrong on a two-line rewrite. Up to three touched columns are now traced in parallel and their reach merged; a column whose check fails keeps the count a lower bound and is named in Claude's context. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reach keyed by id Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ears the column Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…altimate-code-integration The shared CLI helper takes the base branch's rule: only a binary that cannot start falls through to the next name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… it ran out of time Column checks get the time left before a shared deadline, so a slow graph lookup leaves them unchecked instead of losing the note; when the lookup in a dbt project still does not finish, a toast says the edit went ahead without lineage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
altimate-code's dbt_lineage now answers from the project's dbt integration when it can, with that model lineage as its structured result. The pane takes the focus model's column edges and parents from it, keeps the last transform step as the expression, does not keep a one-column trace as the model's columns, and the tool-result row counts the edges on other models of the trace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d says when altimate-code's answer could not be read Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… instead of saying the model is missing Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y and folder views for big projects Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ream levels, real origins first, folders top to bottom in dbt layer order Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ked; ends fold each side on its own key Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… shell reads that find a model by name, and projects below a monorepo root Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…test rule, the drawing kept between redraws, the ancestor cap tested Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d the SQL-file graph reads refs the way dbt renders them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…el's own levels, and the SQL reader counts only real calls Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…strings in the SQL-file reader Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…en the levels do not fit across the pane Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…followable, beside its side of the graph Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, cd on any line, a skipped redirect target, rendered config only, and a shared model-or-column lookup Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… lanes, each link entering its own column; names hard-split before any cut Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ame in full, panned with h and l like dbt-lineage-viewer Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… from what is shown, a stale shell read never takes the pane back, and big canvases draw from sparse grids Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n panned away, and a shell read rechecks for a newer read before each candidate Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e window's rows cached between redraws, and findModel keeps its doc Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… first when Claude read none that turn Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g wins over the answer's models Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… model, follow/followOnce keep their own docs, the newer-turn guard tested Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s-sync' into feat/plugin-altimate-code-integration
…ls in the shown manifest or below a monorepo root, and never moves the pane off a named model, a press or a loading read Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e, and the person's presses count like Claude's reads Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e shown project, a listed model keeps its project, and the lookup race is tested Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ugh altimate-code too Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…om an ended conversation never claims the pane, and the monorepo lookup is tested Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… deadline and are shared, answers walk no folder until a dbt project is seen, and model folders stay inside the project - impact note: each name one line, quoted unless plain, lists capped, the note capped at 4 KB and framed as data - no trace starts once the hold is over; the CLI run stops at the deadline; a running trace or include_graph run is shared - projectsBelow is a bounded, cached walk, and turn.complete uses it only after a dbt project was seen - model-paths that are absolute or climb out of the project are not walked; SQL files are read 32 at a time - exposures_error keeps the dashboard count a lower bound - the warehouse table_lineage call runs only for a model the person asked for - runAltimate defaults to 90 s again; startLineage failing does not stop the integrations setup - the README says columns come from the compiled manifest (read-only calls never start the dbt adapter) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s included, checked just before the call; deadlines hold through connect and fallback - a press, /lineage or r marks the model asked for; the call checks the focus right before it is sent - runTool takes an absolute deadline and starts nothing past it, MCP or CLI - the note escapes non-ASCII, so its 4000-character cap is a byte cap - model-paths ['.'] walks the project folder, skipping its builds and packages - a follow from an ended conversation does not mark a dbt project seen - the folder cache is cleared only on refresh and dbt_project.yml edits - an unreadable dashboards section bounds the dashboard count, not the model count Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the band says when the dashboard count is a lower bound Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… asks for the model shown - runTool bounds $.mcp.call by the time left, then tries the CLI, so a stuck call never holds a shared trace - /lineage with no model marks the shown model as asked for, so warehouse lineage loads as the README says - comment and README wording match the code (cache invalidation, impact-first traces every touched column) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| const real = new Set(pairs.map(([a, b]) => `${a}>${b}`)) | ||
| for (const [from, to] of straightRuns(drawn)) { | ||
| if (to.startsWith('+')) continue | ||
| expect(real.has(`${from}>${to}`) || `${from}>${to}`).toBe(true) |
There was a problem hiding this comment.
WARNING: This assertion can never fail, so the test does not check its own claim
The right operand of || is a non-empty template string, which is always truthy, so toBe(true) passes for any drawn run. The invariant the test names - no line joins two models that are not linked - is therefore unverified.
| expect(real.has(`${from}>${to}`) || `${from}>${to}`).toBe(true) | |
| expect(real.has(`${from}>${to}`)).toBe(true) |
| const order = plan() | ||
| if (order) return order | ||
| for (const box of layers[index + 1] ?? []) spots.get(box.key)!.y += 2 | ||
| return plan()! |
There was a problem hiding this comment.
WARNING: plan()! can still return null, and its result is dereferenced as an array
plan() returns null when its Kahn ordering gets stuck (line 573). The retry after nudging the target layer down two rows asserts non-null, but the second call can also return null; orders[index] is then undefined and line 599's order.length throws inside the pane's render hook. Fall back to an empty order (or skip that gap) when the retry is null.
| return { metadata: null as any, text: `${name}'s answer was larger than the 4 MiB a plugin can read`, ok: false } | ||
| } | ||
| const data = parseJson<{ status?: string; output?: string; metadata?: unknown }>(result.stdout) | ||
| return { metadata: (data?.metadata ?? null) as any, text: data?.output ?? '', ok: result.exitCode === 0 && data?.status !== 'error' } |
There was a problem hiding this comment.
WARNING: A CLI run with exit code 0 and empty or unparsable stdout is reported as success
When parseJson(result.stdout) returns null (empty output, or logs only), data?.status !== 'error' evaluates to true, so ok becomes true although there is no answer. graphFromTools only reports a failure when !result.ok (line 478), so it falls through, caches null for the key (line 484), and the caller shows 'No dbt model matches' - the exact outcome the comment at lines 476-477 says must not happen. Require a parsed answer, e.g. ok: result.exitCode === 0 && !!data && data.status !== 'error'.
| if (ms <= 0) throw new Error('out of time') | ||
| return ms | ||
| } | ||
| const server = cliOnly ? null : await mcpServer($) |
There was a problem hiding this comment.
WARNING: The deadline is only evaluated after the MCP connect, so a connect that never settles bypasses it
await mcpServer($) runs before const ms = await left(). mcpServer wraps $.mcp.connect in .catch(), which handles a rejection but not a call that never settles - the same hazard the HEAD commit fixed for $.mcp.call. For callers with no outer race (loadDetails, loadReach) the 60 s TOOL_TIMEOUT_MS therefore starts only after the connect returns, and loadingAtom can stay true indefinitely. Evaluate the deadline first or wrap the connect in within($, ...).
| if (edge) edges.push(edge) | ||
| } | ||
| } | ||
| const model = /^Model: (.+)$/m.exec(text)?.[1] ?? '' |
There was a problem hiding this comment.
WARNING: The text parser drops the ID: line, so a text-only dbt_lineage answer loses its unique_id
parseDbtLineage reads only Model: ... and passes model_name to columnsFromMetadata, leaving id undefined even though the format carries the model's unique_id (the fixture at lineage-graph.test.ts:139 includes ID: model.shop.fct_orders). Callers then fall back to columns.model === node.name (lineage.tsx:687), which cannot tell same-named models of different projects apart - the case the pane's 'same model only' guard exists to prevent. Parse ID: into model_unique_id.
| const nowCount = counts(after) | ||
| const shift = (column: string) => Math.abs((nowCount.get(column.toLowerCase()) ?? 0) - (wasCount.get(column.toLowerCase()) ?? 0)) | ||
| const rank = (column: string) => (rewritten.has(column.toLowerCase()) ? 1000 : 0) + shift(column) | ||
| return columns.filter(column => named.has(column.toLowerCase()) || rewritten.has(column.toLowerCase())).sort((a, b) => rank(b) - rank(a)) |
There was a problem hiding this comment.
WARNING: Columns with quoted, spaced or non-ASCII names can never be detected as touched
Both halves of this match are restricted to ASCII identifiers: named comes from IDENT = /[A-Za-z_][A-Za-z0-9_]*/g (line 460), and selectItems names an item only with [A-Za-z_]\w*. For a legal dbt column such as a quoted Total Revenue, order-id, or a Unicode name, neither named nor rewritten ever holds the full column name, so touchedColumns reports it untouched; when that leaves no column at all, impactFirst skips the column trace (line 897) and the edit's column reach is silently omitted.
| const counts = impactOf(graph, focus.model) | ||
| const columnNames = columns?.columns.length ? columns.columns.map(one => one.name) : node.columns | ||
| const lensOf = (name: string) => columns?.edges.find(edge => edge.target === name && edge.lineageType === 'direct')?.lens ?? null | ||
| const selectColumn = (name: string | null) => |
There was a problem hiding this comment.
SUGGESTION: Switching columns keeps an earlier reach failure on screen
loadReach writes messageAtom only on failure (line 752), and selectColumn clears reachAtom but not messageAtom. After one column's impact_analysis fails, selecting a column whose trace succeeds still renders the old 'impact_analysis failed: ...' warning at line 1410 under the new column.
| <Text dimColor> | ||
| {' '}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` : ''} |
There was a problem hiding this comment.
SUGGESTION: 'at least 0 dashboards' when the dashboard read failed
dashboardsUnsettled is set exactly when exposuresError is present (line 966). When the dashboards could not be read and exposures is consequently empty, this renders '· at least 0 dashboards'. Require impact.exposures.length > 0 before adding the 'at least' prefix; the same expression appears in the pane at line 1535.
| label="ask Claude" | ||
| onPress={() => | ||
| later($, async () => { | ||
| await $.prompt.submit({ text: `Explain the lineage of ${node.name}${column ? `.${column}` : ''} and what a change to it would affect.` }) |
There was a problem hiding this comment.
SUGGESTION: Repository-controlled names are interpolated into the submitted prompt unquoted
The impact note deliberately runs project text through quoted() and frames it as data because the repository controls it, but this path puts node.name and column raw into $.prompt.submit. A model or column name containing newlines or instruction text then becomes part of the user's own message when they press a. Reuse quoted() here, as the note path does.
| import { expect, mock, test } from 'claude-code/testing' | ||
|
|
||
| import { buildGraph, subgraph } from './lineage-graph' | ||
| import { MANIFEST } from './lineage-graph.test' |
There was a problem hiding this comment.
SUGGESTION: Importing MANIFEST from ./lineage-graph.test re-registers that module's tests
lineage-graph.test.ts calls test(...) at module scope (18 cases), so importing it here runs those registrations again in this file's context. Move the fixture to a plain module (for example lineage-fixtures.ts) and import it from both test files.
Code Review SummaryStatus: 11 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (10 files)
Fix these issues in Kilo Cloud Reviewed by deepseek-v4.1-flash-1017 · Input: 99.8K · Output: 29.2K · Cached: 2M |
Summary
Adds a dbt lineage pane to the plugin's hooks module. It follows the model Claude reads or edits, and it checks a change's downstream reach before the edit lands. Builds on #12 (already on
main); needs AltimateAI/altimate-code#1637 (structureddbt_lineage) and AltimateAI/altimate-code#1635 (stacked on it: manifest fallback fields, column reach, large manifests). The plugin's calls are read-only, so altimate-code answers them from the compiled manifest and never starts the project's dbt adapter; the pane also reads the integration's model-lineage shape when a result carries one./lineage <model>[.column]): follows the model Claude reads, edits, or names in its owndbt_lineagecall.h/l(or ◀ ▶);fbrings the model back into view. The pane opens on the model. Nothing is folded or cut. With a column picked, the models and dashboards that read it are highlighted and the rest dimmed.v): ends only (the sources and dashboards the model sits between, with "via N"); folders (sources → staging → intermediate → marts → dashboards, top to bottom with counts and level spans: a source-to-mart chain on one screen); list (the earlier text view, also used in a pane under 48 columns).target/manifest.jsonstill gets its graph, read from the models'ref()andsource()calls the way dbt renders them (Jinja only, SQL comments included, versions, snapshots, Python models). Columns, dashboards and column reach then needdbt parse.find … -name x.sql, acdfirst) moves the pane; a listing of many files does not. From a monorepo's root,/lineage <model>finds the dbt project below it that has the model.vswitches view,cpicks the next column,rrefreshes,aasks Claude; in the ends and list viewsu/dunfold upstream/downstream. Pressing a model follows it.dbt_lineagecall are reused only when the answer's unique_id is the followed model's, never for a same-named model of another project.target/manifest.jsonwith no model calls. A manifest over 4 MiB is read throughdbt_lineage include_graph(CLI) and cached per revision. If altimate-code fails or its answer is too large to read, the pane says so rather than reporting that the model is missing.impact_analysisseverity and the direct, transitive, test and reader counts.dbt_lineageresults: drawn as a list of edges colored by lens. ctrl+o keeps the raw output under it.table_lineageordatabricks_table_lineage, the pane adds what the warehouse has observed for the model's table. The plugin calls it itself, without a prompt, and sends the table's name, only for a model the person asked for (/lineage, a press,r), never on a follow of Claude's reads. The README says so.altimate tool run --json.integrations.tsxregisters the lineage command, hooks and band line.runAltimate,parseJsonandlatermove tohooks/altimate-cli.ts, shared by both halves. The CLI falls through toaltimate-codeonly when thealtimatebinary is missing, not when it times out.Test plan
claude plugin validate plugins/altimate-codepasses.claude plugin test plugins/altimate-codepasses: 157 runs of 139 tests (120 added here, listed below, and feat: one-click GitHub, Jira and Linear credentials pane #12's 19 integrations tests; see the note under All tests). They cover:model-paths: ['.'], and no folder walk in a session that has seen no dbt project.dbt_lineagecalls;/lineage fct_orders.revenueshows 7 models and 3 dashboards;revenueshows the toast before it lands, and Claude's answer used the added lineage (it named the 7 readers and the 3 dashboards);dbt_lineageresult is redrawn.sources (26) → staging (18) → intermediate (49) · 7 levels → marts (9) → mart, and ends only lists the real sources first.transform-dbt, 50 models):/lineage stg_dbt_artifact_recordsdraws its graph from the SQL files.All tests
Every test this PR adds: 120. With #12's 19 integrations tests the suite has 139.
claude plugin testreports 157 passes becauselineage.test.tsximports its manifest fixture fromlineage-graph.test.ts, which runs that file's 18 tests a second time.lineage-graph.test.ts: 18 testslineage-layout.test.ts: 30 testslineage.test.tsx: 72 testsmodels/still matches a read, and a cd on a later line of a command is followed../..) is never walked🤖 Generated with Claude Code