feat: structured lineage and column reach for dbt_lineage and impact_analysis
Issue for this PR
Stacked on #1637 (the dbt integration's structured dbt_lineage), so it merges after #1637. It no longer depends on #1622. The Claude Code plugin's lineage pane consumes this (AltimateAI/altimate-claude-plugin#14).
Type of change
- Bug fix
- New feature
- Refactor / code improvement
- Documentation
What does this PR do?
Gives clients that draw lineage structured data instead of text, and makes column impact trace real downstream SQL. On top of #1637: when the dbt integration answers, dbt_lineage keeps its model lineage as the structured result, adding the manifest graph on include_graph; the manifest fallback reports its own lineage fields.
-
dbt_lineagereports the model's upstream ids, column mappings and edges in its metadata. It finds a model by name, unique_id or project-relative SQL path, and a snapshot by name or id. Withinclude_graphit also returns the model's graph: everything upstream and downstream, up to 500 nodes each side (upTruncated/truncatedwhen a side is cut). This is for clients that cannot read a manifest over 4 MiB; over MCP the graph is left out of the structured result, so they read it throughaltimate tool run. -
impact_analysiswith a column now traces each downstream model's (and snapshot's) compiled SQL in dependency order, so renames are followed. It returnscolumn_reach:- certain readers: the model and the output columns the traced column feeds;
uncertain: models that name the column outside the select list, select*from a parent whose schema.yml does not list it, use a NATURAL JOIN or name it through Snowflake'sIDENTIFIER('…'), read a parent whose table name another parent shares, read an ephemeral parent, or sit downstream of one of these;untraced: no compiled SQL, or SQL the engine could not parse;- exposures, split into those on certain readers and
uncertain_exposures.
It never reports a confident zero for what the engine reports it cannot settle. One known gap: a parent's whole row passed as a value (
to_json(o)) gives no edge and is not caught (see Follow-ups). It also reportsdownstream,exposures, andcolumn_reach_error. The model can be given by unique_id, and downstream matches by exact id when one is given (versioned models, same names across packages). Thedialectoverride is honoured; without it, the dialect is detected from the manifest. -
MCP server: both tools declare an output schema and return those fields as structured content, with the text report as
report. Fordbt_lineage,reportis only the header, since the mappings travel as data. When the dbt integration answers, its model lineage is the structured result. The structured content is capped at 60 KB, measured as serialized JSON, so it stays under Claude Code's 25k-token MCP output limit; the dbt integration's report is sent as its header only, since its edges travel as data. Above that only the report is sent, markedtruncated, and the report itself is shortened if it is still too large. -
Adapter and manifest from the same project: when
manifest_pathnames a manifest outside the dbt integration's project (another folder, or a nested project inside it),dbt_lineageuses that manifest and says why, so one project's lineage is never mixed with another's graph. A manifest not written yet (an uncompiled project) is placed by its path, so the project's owntarget/manifest.jsonkeeps the integration's answer.
How did you verify your code works?
-
bun test test/altimate/column-reach.test.ts test/altimate/dbt-lineage-structured.test.ts test/altimate/tools/impact-analysis.test.ts test/altimate/impact-analysis.test.ts test/altimate/dbt-lineage-adapter.test.ts: 106 pass (withdbt-lineage-helpers.test.ts, 110 across the six files). The tests cover:- renames, filters, wildcard forms;
- ambiguous and qualified relations, quoted dotted and case-distinct columns;
- snapshots, ephemeral parents, uncompiled and unparseable SQL;
- versioned and cross-package ids, report sections, structured-result cap;
- the adapter path's model lineage and graph, a manifest from another or a nested project, and an uncompiled project's own manifest path;
- NATURAL JOINs,
IDENTIFIER()reads, a manifest path that is a link or runs through a file, and SQL paths spelled with./or backslashes; include_graphreturning a twelve-hop chain whole to its source, and the 500-node cut on each side;- review follow-ups: a negated operand is not
select *, column spellings that differ only by case are reported as uncertain, an emptycolumn_lineagefalls back tocolumn_dict, dollar-quoted literals are blanked,graph_errorexplains a missing graph, a model the engine traces only partly or finds no edges in is uncertain, never a confident non-reader, and dashboards on a metric are found through its semantic model.
-
ALTIMATE_MCP_E2E=1 bun test test/altimate/mcp-server/: 115 of 116 pass, including the structured output. The one failure,warehouse_list discovery stops starting calls once its deadline passes, comes from the base branch. It timed out in the full 16-minute run but passes alone at this PR's head and on the base branch, and this PR does not touchwarehouse_list. -
bun run typecheck: clean. The marker guard and tracker-leak check pass. -
Ran against a compiled dbt-duckdb demo project (20 models, 3 dashboard exposures) through
altimate tool runand the MCP server inside Claude Code.fct_orders.revenueis read by 7 downstream models and 3 dashboards, which matches the SQL by hand. -
Column reach over 300 downstream models of 41 columns each takes 548 ms.
-
Not from this PR: when these test files run together in one process, 41 MCP E2E tests and 2 runner tests fail. They fail the same way on the base branch and pass file by file.
-
Reviewed by Claude and Codex as separate reviewers, each run on its own, until both explicitly approved.
All tests
Every test this PR adds (57). It also extends existing tests: the adapter-project test in dbt-lineage-structured.test.ts and the structured-output checks in mcp-server/server.test.ts.
column-reach.test.ts: 45 tests
- columnReach
- follows a column through renames to the models and dashboards that read it
- an output alias or a comment naming the column is not a read
- a downstream model without compiled SQL is reported untraced, and what reads it only may read the column
- a model that only filters on the column passes nothing on to its children
- a join key another parent also documents is a possible read, not a dropped one
- a source and a model with the same table name: a qualified source column is not the model's
- a table name two parents share is ambiguous; a schema-qualified one is not
- a snapshot is traced and carries the column on
- a column no downstream model reads reaches nothing
- an unknown model or a missing manifest throws
- exposuresOn through metrics
- an exposure on a metric reaches the model under its semantic model
- exposuresOn
- lists exposures on any of the given nodes, by label when there is one
- parentsNamed
- a qualified name matches the parent at that qualification only; a bare name matches every parent that answers to it
- columnReach where the engine's schema falls short
- select * from a parent whose schema.yml does not list the column is a possible read, and its children may read it
- a dashboard downstream of a wildcard reader in any dialect form is reported, not dropped
- a column name with a dot, quoted, is matched whole
- selectsStar
- a CR-terminated line comment does not hide the SQL after it
- finds * and t.* in a select list, not multiplication or count(*)
- columnReach through an ephemeral parent
- a model reading an ephemeral parent is a possible read, never a silent zero
- columnReach with an ephemeral and a regular parent
- a traced edge from the regular parent does not hide what flows through the ephemeral one
- columnReach with columns that differ only by case
- "Revenue" is not revenue where the parent documents both
- columnReach case handling
- a spelling matching one documented column ignoring case reaches its readers
- a spelling matching two columns that differ only by case leaves both columns' readers uncertain, never certain
- with the spelling unsettled, a dashboard on the model itself is uncertain too
- columnReach with an empty column_lineage
- falls back to column_dict edges
- columnReach on a model the engine cannot fully trace
- no edge at all: SQL that names the column (a PIVOT) is uncertain and so is its child; SQL that never names it reads nothing
- errors beside edges: a reader stays reached but unsure, a non-reader is uncertain
- columnReach on SQL the engine cannot parse
- is untraced, and a dashboard on it is reported apart
- lineageGraph
- a twelve-hop chain comes back whole to its source; upstream past 500 nodes is cut and marked
- downstream past 500 nodes is cut and marked; downstream tests are still counted
- everything up and downstream, tests as ids, from dbt's child_map when present
- a node whose manifest relation_name is null has no relation
- structuredError
- passes a short message and caps a huge one
- a tool's own structured result sends the report's header, not its edge lists
- exposures_error reaches the structured fields
- structuredResult over the cap
- a report too big on its own is shortened too
- structuredError escaping
- the cap holds for the serialized result, whatever JSON escaping adds
- structuredResult
- passes the lineage fields with the report, and only the report when they are too big
- dbtLineage
- reports the model's upstream unique_ids, and the graph when asked
- columnReach with a NATURAL JOIN
- a NATURAL JOIN is a possible read, never a confident non-reader
- a model that reads the column through a NATURAL JOIN passes the doubt on to its children
- every NATURAL join form counts, whatever the quoting around it; aggregates under their own names do not
- a NATURAL JOIN model that names the column outside its select list also passes the doubt on
- columnReach through Snowflake IDENTIFIER()
- a column named as IDENTIFIER('…'), plain or escaped, is a possible read, and its child and dashboard are not dropped
- IDENTIFIER() with the column, quoted or qualified, or a bind variable counts; another column does not
dbt-lineage-helpers.test.ts: 1 test
- dbtLineage: error handling
- reports the model's name, not the path used to find it, when there is no compiled SQL
dbt-lineage-structured.test.ts: 2 tests
- dbt_lineage with the project's dbt adapter
- a manifest named outside the adapter's project wins over the adapter
- include_graph with no manifest to read says so in graph_error
impact-analysis.test.ts: 1 test
- impact_analysis: DAG traversal
- the structured downstream entries carry no path
tools/impact-analysis.test.ts: 8 tests
- findDownstream: depth
- a direct dependent also reachable through another dependent stays direct
- formatImpactReport: exposures
- says when exposures could not be read
- keeps a multi-line read error on one line
- lists a dashboard once when column reach already printed it
- formatImpactReport
- column-level impact names the column and the change
- column reach lists certain readers, possible readers, untraced models and exposures
- findDownstream with versioned models
- a versioned parent is matched by unique_id, whose last segment is the version
- with an exact id, another package's model of the same name is not matched
Follow-ups (not fixed here):
- On the adapter path, a model the adapter knows but an older
target/manifest.jsondoes not gets a graph node with no edges.dbt parsefixes it. impact_analysis's downstream traversal (from #350) scans every model per level (O(V·M)). An adjacency map would make it O(V + E). It also walks models only, so a snapshot and what sits below it are missing fromdownstream, the severity count andexposures, whilecolumn_reachtraces through snapshots. Walking the manifest'schild_mapover models and snapshots fixes both. This is pre-existing and unchanged here.- Whole-row reads (
to_json(o),md5(cast(o as varchar))): the engine reports no column edge, and a pattern can't tell a row source from a column (sum(x) as x,extract(year from d)), so these models are confident non-readers today. The engine reporting a whole-row reference would settle it. detectDialect(pre-existing) passessynapseandfabricthrough, which the engine rejects, so their models come back untraced (reported, never a false zero). Mapping both totsqlwould trace them.- Overlap with #1637: once dbt-integration ships
getModelLineage,impact_analysis's column reach can ask it first (its column edges across hops) and keep this PR's compiled-SQL tracer as the fallback for projects without the dbt integration. Today the tracer is the only path that works, since the integration is not available yet.
Cost side effects
None. No model calls or warehouse queries. Column reach parses compiled SQL locally, at most 300 models per call.
Screenshots / recordings
The plugin PR has screenshots of the pane that uses this data. A demo recording will follow.
Checklist
- I have tested my changes locally
- I have not included unrelated changes in this PR
- I checked for cost side effects (model calls, queries or cloud resources that grow with user data) and described them, or wrote "none"
🤖 Generated with Claude Code