Skip to content

feat: dbt lineage pane beside the chat (#14, onto main) - #15

Merged
hkaltai merged 54 commits into
mainfrom
feat/plugin-altimate-code-integration
Nov 3, 2026
Merged

hkaltai merged 54 commits into
mainfrom
feat/plugin-altimate-code-integration

Conversation

@hkaltai

@hkaltai hkaltai commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Brings #14 to main. #14 was stacked on #12, and #12 merged into main first, so #14 merged into feat/one-click-local-integrations-sync (merge commit 20c6e2d) instead of main. This PR carries the same code to main. The branch is #14's own, at its approved head 397a435, and its tree is identical to that merge. The diff is #14's 10 files only (+5,560 / −43), and it merges cleanly. Review and approvals: #14.

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 (structured dbt_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.

  • Pane (/lineage <model>[.column]): follows the model Claude reads, edits, or names in its own dbt_lineage call.
    • Graph view (default), like dbt-lineage-viewer: the model's whole lineage as boxes and arrows (every level up and down, every model, every name in full, dashboards marked ◆) drawn once as a canvas the pane is a window onto. Scroll it with the mouse, touchpad or arrows; pan with h/l (or ◀ ▶); f brings 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.
    • What the answer is about: once the session has seen a dbt project, after each answer the pane lists the dbt models Claude named (IN THE ANSWER), each a press away; when Claude answered without reading a model that turn, the pane follows the first model it named, so the lineage matches the answer.
    • Views for big projects (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).
    • The pane keeps the model's whole upstream (up to 500 models), so a 12-hop chain shows back to its sources.
    • No manifest: a project without target/manifest.json still gets its graph, read from the models' ref() and source() calls the way dbt renders them (Jinja only, SQL comments included, versions, snapshots, Python models). Columns, dashboards and column reach then need dbt parse.
    • Follows indirect reads: a shell command that finds the model (find … -name x.sql, a cd first) 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.
    • Each output column with its sources and lens type.
    • The column box: "read by N models · M dashboards". It lists models that may read the column and models that could not be traced, and the count becomes "at least N" when there are any.
    • Keys: v switches view, c picks the next column, r refreshes, a asks Claude; in the ends and list views u/d unfold upstream/downstream. Pressing a model follows it.
  • Same model only: columns from Claude's own dbt_lineage call are reused only when the answer's unique_id is the followed model's, never for a same-named model of another project.
  • Graph data: read from target/manifest.json with no model calls. A manifest over 4 MiB is read through dbt_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 first: before an Edit, Write or MultiEdit of a model's SQL runs, the plugin traces every column the edit touches (up to 3, in parallel) and merges their reach, holding the edit for at most 8.5 s. A column whose check fails or times out is named, and the count becomes a lower bound. No trace starts once the hold is over, the CLI run stops at the deadline, and an edit made while the same column's trace runs shares it. It shows a toast, adds the lineage to Claude's context once per turn per agent (each project name quoted and capped, the note at most 4 KB and framed as data), and leaves an impact band above the prompt. The band shows impact_analysis severity and the direct, transitive, test and reader counts.
  • Claude's own dbt_lineage results: drawn as a list of edges colored by lens. ctrl+o keeps the raw output under it.
  • Desktop: an SVG graph.
  • Warehouse lineage: when a connected MCP server offers table_lineage or databricks_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.
  • Calls: go to the plugin's MCP server with no permission prompt. A result the server cut for size, or a disconnected server, falls back to altimate tool run --json.
  • Composition: the engine allows one hooks module per plugin and one unmatched hook per event, so integrations.tsx registers the lineage command, hooks and band line. runAltimate, parseJson and later move to hooks/altimate-cli.ts, shared by both halves. The CLI falls through to altimate-code only when the altimate binary is missing, not when it times out.

Test plan

  • claude plugin validate plugins/altimate-code passes.
  • claude plugin test plugins/altimate-code passes: 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:
    • following, the large-manifest path, recompiles, stale-focus guards;
    • impact-first ordering, context de-duplication, lower bounds;
    • CLI fallback, capped results, column switching, warehouse lineage, the result redraw;
    • the dbt integration's model lineage, same-named models of other projects, unreadable and failed CLI answers;
    • the layout: layering, whole names on an unbounded canvas, panning slices, folder order, a 500-model lineage in under 300 ms, and that no drawn line joins two models that are not linked;
    • the SQL-file graph (refs in comments, quoted text, versions, snapshots, Python, YAML forms, model-paths outside the project), shell reads, monorepo lookup, and presses staying in the project shown;
    • the impact note's quoting and size cap, no trace after the deadline, shared traces, an MCP call that never answers, an unreadable dashboards section, warehouse lineage only for models the person asked for (presses included), model-paths: ['.'], and no folder walk in a session that has seen no dbt project.
  • Reviewed by Claude and Codex as separate reviewers, each run on its own, until both explicitly approved.
  • Shell tests (6/6) and pytest (17/17) pass.
  • Ran live in Claude Code 2.1.316, using the dev altimate-code build from #1635, on a compiled dbt-duckdb demo project (shopwise: 20 models, 3 dashboards; synthetic seed data):
    • the pane follows Read, shell reads and Claude's own dbt_lineage calls;
    • /lineage fct_orders.revenue shows 7 models and 3 dashboards;
    • an edit to revenue shows the toast before it lands, and Claude's answer used the added lineage (it named the 7 readers and the 3 dashboards);
    • Claude's dbt_lineage result is redrawn.
  • Ran live on a large real project (altimate-ingestion's Snowflake dbt project: 230 models, 60 sources, chains of 12 hops, a 7.6 MB manifest read through altimate-code in about 2 s). For a mart 12 hops from its sources, folders reads sources (26) → staging (18) → intermediate (49) · 7 levels → marts (9) → mart, and ends only lists the real sources first.
  • Ran live from altimate-ingestion's repo root on a project with no manifest (transform-dbt, 50 models): /lineage stg_dbt_artifact_records draws its graph from the SQL files.
  • Warehouse lineage against a real Altimate SaaS MCP server. It is covered only by a stub, since no SaaS server is connected in this environment.

All tests

Every test this PR adds: 120. With #12's 19 integrations tests the suite has 139. claude plugin test reports 157 passes because lineage.test.tsx imports its manifest fixture from lineage-graph.test.ts, which runs that file's 18 tests a second time.

lineage-graph.test.ts: 18 tests
  • upstream brackets each parent’s parents into it, and the parents into the model
  • downstream shows three children with what reads them, the rest folded until expanded
  • impact counts over models and snapshots, with impact_analysis' severity thresholds
  • a snapshot is followable like a model
  • dbt parent_map and child_map are used when present, and a model is found by path or name
  • the subgraph keeps the whole upstream, everything downstream and the tests
  • dbt_lineage text parses into the same columns as its structured metadata
  • column reach, warehouse records and edited columns read as the pane needs them
  • the desktop SVG names every drawn node and escapes markup
  • select items map each output name to its expression
  • an explicit null relation_name (an ephemeral model) has no warehouse relation
  • the dbt integration's model lineage reads as the focus model's columns, directly or inside metadata
  • graphFromSql reads ref() and source() the way dbt renders them
  • projectLayout reads the name and folder lists of dbt_project.yml, with dbt defaults
  • the subgraph keeps 500 ancestors, nearest first, and marks the cut
  • graphFromSql counts only real calls: not quoted text, Python comments, raw blocks or commented-out snapshots
  • projectLayout reads block lists with comments, blank lines and CRLF
  • graphFromSql reads materialized from the config dbt renders, not a commented-out one
lineage-layout.test.ts: 30 tests
  • graphView layers by distance and skips tests
  • graphView folds past the cap into one more box with the shared folder
  • graphView does not fold a single leftover and says no folder when they differ
  • graphView orders boxes by their neighbours nearer the focus
  • graphView keeps reach hits when capping and sets tones
  • endsView lists the roots and leaves with the models between
  • endsView folds ends past the cap
  • foldersView groups by folder and side
  • drawLayered draws a three-model chain
  • drawLayered joins a fork and a merge with junctions; lines into one box share a channel
  • drawLayered drops far layers to fit and narrows a single layer
  • drawLayered label cells carry the node and styles
  • endsView marks an upstream end with a prefix only when the ancestry was cut, and never a source
  • an exposure takes its tone from the reach, and a more box the strongest of what it folds
  • foldersView orders groups by the group graph and marks truncated counts
  • foldersView falls back to dbt's layer order when folders feed each other
  • drawLayered cuts a long label in the middle and keeps look-alike names apart
  • drawLayered gives every source in a gap its own channel
  • drawStack stacks the folders top to bottom with an arrow between, and wraps a layer past the width
  • drawLayered never draws an unbroken line between two models that are not linked
  • drawLayered wraps a long underscore name onto two lines in full
  • drawLayered boxes share a height and a name past two lines ends with a middle cut
  • more boxes carry the side they fold so a press can open them
  • dropping levels for long names keeps the model's own parents and children
  • a name that two lines can hold is split hard rather than cut in the middle
  • drawLayered with an unbounded width draws every level and every name whole
  • sliceRows cuts a segment at both edges and keeps its style and node
  • columnOf finds where the focus box starts
  • drawLayered draws a 500-node lineage with an unbounded width in well under 300 ms
  • drawLayered rows on a small fork and merge are exactly these
lineage.test.tsx: 72 tests
  • reading a model points the pane at it: the manifest graph, then its columns from dbt_lineage
  • an edit naming a column is held until its reach is known, then the reach goes to Claude and the band
  • [c] picks a column and shows its reach; [d] unfolds downstream; a model in the graph is followed on press
  • the graph view draws boxes a press follows; [v] cycles the views and [l] the levels; a narrow pane gets the lists
  • without the MCP server the CLI answers, and dbt_lineage text from an older build still parses
  • a connected SaaS table_lineage tool adds what the warehouse has observed, once the person asks for the model
  • Claude's own dbt_lineage result is drawn as a colored edge list, with the original under it on ctrl+o
  • /lineage names a model and column; an unknown model says so
  • a manifest over 4 MiB is read through altimate-code: the graph comes from dbt_lineage with include_graph
  • a recompiled manifest is a new focus: the column lineage is read again
  • a subagent gets the lineage context the main agent already got, and an untraced model keeps the count a lower bound
  • a read Claude makes outside a dbt project stays quiet
  • a result the MCP server left out for size is fetched again through the CLI
  • following Claude's own dbt_lineage call uses the manifest it named
  • a dbt_lineage result Claude's call got cut for size leaves the pane to fetch the columns itself
  • dashboards on models that may read the column are counted and named
  • /lineage on the same model with another column traces that column
  • the impact toast shows before the edit runs
  • an edit touching two columns traces both and reports their combined reach
  • a touched column whose check fails keeps the combined count a lower bound
  • a new unchecked column in the same turn still reaches Claude
  • columns beyond the cap are named as unchecked and make the count a lower bound
  • /lineage takes a unique_id or a path whole, and only a trailing column off a model name
  • a dbt_lineage text without lineage is not cached as an empty lineage
  • Claude's truncated dbt_lineage result is not preloaded as the whole lineage
  • Claude's whole dbt_lineage result for model.shop.fct_orders preloads the pane
  • Claude's whole dbt_lineage result for model.other.fct_orders is another project's model: not preloaded
  • an answer from altimate-code too large to read says so, rather than that the model isn't there
  • altimate-code's own failure on a large manifest is shown as a failure, not as no such model
  • with no manifest, the graph comes from the SQL files' ref() and source() calls
  • an edit that changes a ref redraws the graph read from the SQL files
  • a listing of many SQL files does not move the pane; a command that names a file wins over its output
  • a press in a project other than the session's stays in the project shown
  • a shell read that finds the file by name (find -name x.sql; cat $f) follows that model
  • in the ends view, pressing a "+N more" box unfolds that side
  • the graph is one canvas with every level and whole names; h and l pan it, f centres the model
  • every parent is drawn, no "+N more", each followable after panning to it
  • model-paths spelled models/ still matches a read, and a cd on a later line of a command is followed
  • a model folder named like a build folder (models/build/) is still read
  • a shell read whose first candidate loses to a newer read does not take the pane back
  • an answer that names models lists them, and the pane follows the first when Claude read none of them
  • an answer after Claude read a model leaves the pane on what it read
  • a read still loading when the answer ends wins over the models the answer names
  • an answer still being handled when a newer turn starts never writes its models or moves the pane
  • a backticked path in the answer names its model, underscore or not
  • an answer's follow still loading when the next turn starts leaves the pane alone
  • a subagent's answer, or one naming the model shown, does not move the pane
  • the person's press still loading when the answer ends wins over the answer's models
  • a newer turn arriving while the answer's own model lookup runs keeps the answer out
  • a follow left running by the last conversation does not hold up the next one's answers
  • from a monorepo root, an answer naming another project's model finds it, and its listed entry still opens after the pane moved
  • a follow started before the conversation ended never claims the new conversation's pane
  • a failing follow started after a good one does not cancel it
  • an older follow that resolves after a newer one does not overwrite it
  • /lineage with no column clears the selected column
  • an older column-reach answer does not replace the newer one
  • when the whole lookup runs out of time, a toast says the edit went ahead without lineage
  • a .sql file outside any dbt project gets no timeout toast
  • the pane reads the dbt integration's model lineage from altimate-code
  • Claude's dbt_lineage trace from the dbt integration counts the edges on other models, and a column trace is not kept as the model's columns
  • an answer in a session that has seen no dbt project never walks its folder
  • project text in the impact note is one line per name, quoted, capped, and framed as data
  • an unreadable dashboards section keeps the dashboard count a lower bound
  • no column trace starts once the hold has run out
  • two edits of one column while its trace runs share that trace
  • a model folder outside the project (../..) is never walked
  • a press on a graph box asks for warehouse lineage; the pane following Claude's read does not
  • model-paths ['.'] reads the project's own folder, not its builds or packages
  • the impact note stays under 4 KB in bytes with non-ASCII names
  • a cut impact note never ends inside an escape
  • bare /lineage asks for warehouse lineage on the model shown
  • an MCP trace that never answers is given up on, so the next edit of that column traces again

🤖 Generated with Claude Code

hkaltai and others added 30 commits November 2, 2026 15:27
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>
hkaltai and others added 24 commits November 3, 2026 11:30
…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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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()!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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' }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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($)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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] ?? ''

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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` : ''}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.` })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kilo-code-bot

kilo-code-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Code Review Summary

Status: 11 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 7
SUGGESTION 4
Issue Details (click to expand)

WARNING

File Line Issue
plugins/altimate-code/hooks/lineage-layout.test.ts 378 Assertion `real.has(...)
plugins/altimate-code/hooks/lineage-layout.ts 582 return plan()! can still be null; order.length then throws in the render hook
plugins/altimate-code/hooks/lineage.tsx 449 Exit code 0 with empty/unparsable CLI stdout is classified ok, so a broken CLI is reported as "no model"
plugins/altimate-code/hooks/lineage.tsx 416 Deadline is checked after mcpServer(); a connect that never settles bypasses the timeout
plugins/altimate-code/hooks/lineage-graph.ts 420 parseDbtLineage drops the ID: line, so text-only answers lose unique_id and the "same model only" guard degrades to name matching
plugins/altimate-code/hooks/lineage-graph.ts 234 findModel is not version-aware; versioned models (shared name and file path) resolve to an arbitrary version
plugins/altimate-code/hooks/lineage-graph.ts 496 ASCII-only IDENT matching means quoted/spaced/non-ASCII column names are never detected as touched, silently skipping column reach

SUGGESTION

File Line Issue
plugins/altimate-code/hooks/lineage.tsx 1304 Column switch does not clear a previous impact_analysis failed message
plugins/altimate-code/hooks/lineage.tsx 1034 Renders "at least 0 dashboards" when the dashboard read failed (same expression at line 1535)
plugins/altimate-code/hooks/lineage.tsx 1601 Repo-controlled model/column names interpolated raw into the "ask Claude" prompt, unlike the quoted impact note
plugins/altimate-code/hooks/lineage.test.tsx 4 Importing MANIFEST from ./lineage-graph.test re-registers that module's 18 top-level tests
Files Reviewed (10 files)
  • plugins/altimate-code/README.md - 0 issues
  • plugins/altimate-code/hooks/altimate-cli.ts - 0 issues
  • plugins/altimate-code/hooks/integrations.tsx - 0 issues
  • plugins/altimate-code/hooks/lineage-graph.test.ts - 0 issues
  • plugins/altimate-code/hooks/lineage-graph.ts - 3 issues
  • plugins/altimate-code/hooks/lineage-layout.test.ts - 1 issue
  • plugins/altimate-code/hooks/lineage-layout.ts - 1 issue
  • plugins/altimate-code/hooks/lineage.test.tsx - 1 issue
  • plugins/altimate-code/hooks/lineage.tsx - 5 issues
  • plugins/altimate-code/types/index.d.ts - 0 issues

Fix these issues in Kilo Cloud


Reviewed by deepseek-v4.1-flash-1017 · Input: 99.8K · Output: 29.2K · Cached: 2M

@hkaltai
hkaltai merged commit 6cc6452 into main Nov 3, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants