Skip to content

feat: structured lineage and column reach for dbt_lineage and impact_analysis - #1635

Merged
hkaltai merged 38 commits into
feat/dbt-lineage-structured-resultfrom
feat/plugin-altimate-code-integration
Nov 4, 2026
Merged

hkaltai merged 38 commits into
feat/dbt-lineage-structured-resultfrom
feat/plugin-altimate-code-integration

Conversation

@hkaltai

@hkaltai hkaltai commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

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_lineage reports 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. With include_graph it also returns the model's graph: everything upstream and downstream, up to 500 nodes each side (upTruncated/truncated when 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 through altimate tool run.

  • impact_analysis with a column now traces each downstream model's (and snapshot's) compiled SQL in dependency order, so renames are followed. It returns column_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's IDENTIFIER('…'), 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 reports downstream, exposures, and column_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). The dialect override 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. For dbt_lineage, report is 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, marked truncated, and the report itself is shortened if it is still too large.

  • Adapter and manifest from the same project: when manifest_path names a manifest outside the dbt integration's project (another folder, or a nested project inside it), dbt_lineage uses 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 own target/manifest.json keeps 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 (with dbt-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_graph returning 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 empty column_lineage falls back to column_dict, dollar-quoted literals are blanked, graph_error explains 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 touch warehouse_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 run and the MCP server inside Claude Code. fct_orders.revenue is 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.json does not gets a graph node with no edges. dbt parse fixes it.
  • impact_analysis's downstream traversal (from Add impact analysis, training import tools, and CI validation features #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 from downstream, the severity count and exposures, while column_reach traces through snapshots. Walking the manifest's child_map over 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) passes synapse and fabric through, which the engine rejects, so their models come back untraced (reported, never a false zero). Mapping both to tsql would trace them.
  • Overlap with feat(dbt_lineage): return the dbt integration's model lineage as structured content #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

@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 3fe5c76a-2c38-457d-b561-83a4b0944482

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Tip

🎯 Customize your risk assessments

Guide how CodeRabbit assesses merge risk and security architecture risk. Highlight sensitive changes and explain your security boundaries using Merge Risk Instructions and Security Risk Instructions in your review settings. Security architecture risk assessments require the Advanced plan or above.

✅ Automatic approvals, with your rules

Let CodeRabbit approve pull requests once required threads are resolved, pre-merge checks pass, and your risk limits are met. Enable Automatic approvals in review settings, with overrides for teams or file paths.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

6 issues found across 10 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/opencode/src/altimate/native/dbt/lineage.ts">

<violation number="1" location="packages/opencode/src/altimate/native/dbt/lineage.ts:62">
P2: MCP drops the graph returned by `include_graph`: its structured-field allow-list omits `graph`, and the text report does not contain it. Add `graph` to the MCP structured fields so lineage viewers can receive the requested graph.</violation>
</file>

<file name="packages/opencode/src/altimate/tools/impact-analysis.ts">

<violation number="1" location="packages/opencode/src/altimate/tools/impact-analysis.ts:70">
P2: `impact_analysis` still traverses only `manifest.models`, so downstream snapshots disappear from the reported blast radius and affected tests. Include `manifest.snapshots` in this traversal so models feeding snapshots are not reported as unaffected.</violation>
</file>

<file name="packages/opencode/src/altimate/native/dbt/graph.ts">

<violation number="1" location="packages/opencode/src/altimate/native/dbt/graph.ts:30">
P2: The 500-node cap counts only non-test downstream additions, while the target, upstream nodes, and test IDs are added outside that budget. Enforce the limit against every emitted graph node, or document and expose a separate downstream-model limit.</violation>
</file>

<file name="packages/opencode/src/altimate/native/dbt/column-reach.ts">

<violation number="1" location="packages/opencode/src/altimate/native/dbt/column-reach.ts:253">
P2: Duplicate bare names are treated as ambiguous even when the compiled SQL qualifies the relation. Base this decision on the qualified lineage candidates (or the SQL relation), otherwise definite readers are downgraded to uncertain.</violation>

<violation number="2" location="packages/opencode/src/altimate/native/dbt/column-reach.ts:255">
P3: The wildcard fallback is relation-blind and reports false possible readers when `*` belongs to another parent. Associate the wildcard with its source relation before applying the undocumented-column check.</violation>

<violation number="3" location="packages/opencode/src/altimate/native/dbt/column-reach.ts:278">
P2: Indirect lineage edges are counted as certain readers even though this branch deliberately excludes them from `carried`. Set `reads` only for non-indirect edges, so indirect-only dependencies fall through to the uncertain-reader handling.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

const uniqueId =
getUniqueId(nodes, params.model) ??
(nodes[params.model] ? params.model : (byPath?.[0] ?? Object.keys(nodes).find((id) => nodes[id] === modelNode)))
const graph = params.include_graph && uniqueId ? { graph: lineageGraph(manifest, uniqueId) } : {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: MCP drops the graph returned by include_graph: its structured-field allow-list omits graph, and the text report does not contain it. Add graph to the MCP structured fields so lineage viewers can receive the requested graph.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/altimate/native/dbt/lineage.ts, line 62:

<comment>MCP drops the graph returned by `include_graph`: its structured-field allow-list omits `graph`, and the text report does not contain it. Add `graph` to the MCP structured fields so lineage viewers can receive the requested graph.</comment>

<file context>
@@ -35,16 +42,29 @@ export function dbtLineage(params: DbtLineageParams): DbtLineageResult {
+  const uniqueId =
+    getUniqueId(nodes, params.model) ??
+    (nodes[params.model] ? params.model : (byPath?.[0] ?? Object.keys(nodes).find((id) => nodes[id] === modelNode)))
+  const graph = params.include_graph && uniqueId ? { graph: lineageGraph(manifest, uniqueId) } : {}
 
   // Extract compiled SQL
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not changed: the graph is deliberately CLI-only (altimate tool run dbt_lineage --json with include_graph) and kept off the MCP allow-list, since a 500-node graph would be sent to the model as structured content. Clarified in graph.ts and the tool description.

ambiguous = true
continue
}
reads = 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.

P2: Indirect lineage edges are counted as certain readers even though this branch deliberately excludes them from carried. Set reads only for non-indirect edges, so indirect-only dependencies fall through to the uncertain-reader handling.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/altimate/native/dbt/column-reach.ts, line 278:

<comment>Indirect lineage edges are counted as certain readers even though this branch deliberately excludes them from `carried`. Set `reads` only for non-indirect edges, so indirect-only dependencies fall through to the uncertain-reader handling.</comment>

<file context>
@@ -0,0 +1,336 @@
+        ambiguous = true
+        continue
+      }
+      reads = true
+      if (edge.lineage_type !== "indirect" && typeof edge.target === "string") {
+        carried.add(edge.target.toLowerCase())
</file context>
Suggested change
reads = true
if (edge.lineage_type !== "indirect") reads = true

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not changed: an indirect edge (filter, join key, group by) is a real read of the column, so the model is a certain reader; it just contributes no carried output column. A filter-only model is covered by an existing test.

}
const collides = parents.some((p) => allParents.some((other) => other !== p && bareName(other) === bareName(p)))
const wildcard =
selectsStar(sqlText) && parents.some((p) => [...tracked.get(p)!].some((col) => !documentedColumns(lookup(p)).has(col)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The wildcard fallback is relation-blind and reports false possible readers when * belongs to another parent. Associate the wildcard with its source relation before applying the undocumented-column check.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/altimate/native/dbt/column-reach.ts, line 255:

<comment>The wildcard fallback is relation-blind and reports false possible readers when `*` belongs to another parent. Associate the wildcard with its source relation before applying the undocumented-column check.</comment>

<file context>
@@ -0,0 +1,336 @@
+    }
+    const collides = parents.some((p) => allParents.some((other) => other !== p && bareName(other) === bareName(p)))
+    const wildcard =
+      selectsStar(sqlText) && parents.some((p) => [...tracked.get(p)!].some((col) => !documentedColumns(lookup(p)).has(col)))
+    if (collides || wildcard) {
+      unsure.add(id)
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not changed: a relation-blind wildcard check can only produce a 'may read' entry (never a certain reader) and errs toward reporting; associating * with its relation would need a SQL parser here, and a missed reader is worse than an extra possible one.

// (already truncated) text (dbt_lineage: its header, its mappings travel as data), only the lineage
// fields are passed, and the whole is capped.
const STRUCTURED_FIELDS: Record<string, readonly string[]> = {
dbt_lineage: ["model_name", "model_unique_id", "confidence", "confidence_factors", "upstream", "columns", "edges", "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]: include_graph is a no-op for MCP clients

STRUCTURED_FIELDS.dbt_lineage omits graph, so structuredResult drops it, and the text report does not render it either (formatDbtLineage in tools/dbt-lineage.ts only prints mappings and edges). A client that calls dbt_lineage with include_graph: true therefore receives the graph nowhere: not in structuredContent, not in the text. That contradicts the stated purpose in native/dbt/graph.ts ("clients that cannot read a large manifest ... get it from dbt_lineage with include_graph") and the PR description. Add "graph" to the field list — the 100 KB fallback already covers an oversized graph — or render it in the report.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not changed: the graph is CLI-only by design (see the reply on the other include_graph thread); clients that need it use altimate tool run dbt_lineage --json. graph.ts now says so.

name,
description: descriptions[name] ?? tool.description,
inputSchema: tool.jsonSchema as McpTool["inputSchema"],
...(STRUCTURED_TOOLS.has(name) && { outputSchema: STRUCTURED_OUTPUT_SCHEMA }),

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]: Declaring outputSchema makes the error path non-conformant

MCP requires a tool that declares an output schema to return structuredContent that conforms to it. The catch block below (and the early workspace.execute return) still answers with text only, so argument-validation failures or adapter errors for dbt_lineage/impact_analysis produce a result with no structuredContent at all. Emitting structuredContent: { report: message } in that path keeps every response of these two tools valid.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: the catch path in server.ts now returns structuredContent { report } for dbt_lineage and impact_analysis, so every response of those tools matches the declared output schema.

// The engine answers SQL it cannot parse with `errors` and no edges rather than throwing.
const parseErrors: string[] = Array.isArray(lineage?.errors) ? lineage.errors.map(String) : []
const noEdges = !Array.isArray(lineage?.column_lineage) || lineage.column_lineage.length === 0
if ((result.confidence === "low" && !Array.isArray(lineage?.column_lineage)) || (parseErrors.length > 0 && noEdges)) {

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]: This duplicates unparsed() in native/dbt/lineage.ts:12

dbtLineage already sets confidence: "low" for exactly the two cases checked here (engine error with no column_lineage, or errors with no edges), so result.confidence === "low" is equivalent and keeps one source of truth. As written, the two copies of the parse-error rule can drift (e.g. if unparsed changes or the engine starts returning errors alongside edges). Prefer reusing the helper or the confidence flag.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: column-reach.ts now relies on dbtLineage's confidence === 'low' (engine error or unparseable SQL) instead of re-deriving the parse-error rule.

}

// Exposures on anything affected
if (data.exposures && data.exposures.length > 0) {

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]: Exposures are listed twice when a column is traced

reach.exposures (printed at line 283) is a subset of data.exposures here: exposuresOn(affectedModelIds) covers the target and every downstream model, while column reach only narrows them to certain readers. Every dashboard therefore appears once in the Column Reach block and again under Exposures (N). Skip the entries already printed by columnReach, or omit this section when a columnReach block was shown.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: formatImpactReport omits exposures already printed in the Column Reach block and labels the rest 'Other exposures' (impact-analysis.ts); test added.

transitive_count: transitive.length,
test_count: affectedTestCount,
column_impact: columnImpact.length,
column_impact: reach ? reach.models.length : 0,

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]: column_impact silently changes meaning

Previously this metadata field was the count of the target model's output columns fed by the column (from lineage.check on SELECT * FROM <model>); now it is the number of downstream models that read it. Same key, different unit — traces or clients that surface column_impact will misreport it. Rename it (e.g. column_reader_count) or keep the old field beside the new one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: renamed to column_reader_count (see the reply on the other column_impact thread); the old key is gone since it had no consumers.

graph.children[one] = (children[one] ?? []).filter((other) => {
if (keep.has(other)) return true
if (!isTest(other)) return false
graph.nodes[other] ??= { ...nodeOf(other, lookup(manifest, other)), type: "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]: Test nodes get inconsistent graph entries

Tests are inserted into graph.nodes and into a parent's children here, but they are never added to keep, so no graph.parents[testId] or graph.children[testId] entry is created. A consumer that indexes graph.parents[node.id] for every node in graph.nodes (the type declares both as Record<string, string[]> with an entry per node) gets undefined. Either create the entries for tests or document that they appear only as children.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: test nodes now have parents and children entries in the graph (graph.ts); test added.

@kilo-code-bot

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

Copy link
Copy Markdown

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Both suggestions from the previous review are addressed in this increment:

  • formatImpactReport now marks every count (models, exposures, uncertain, untraced) as a lower bound when reach stopped early, and the "Other exposures" header states that some entries may not have been checked instead of claiming they were.
  • column-reach.ts computes the parents' tracked-column union once and reuses it for both namesThroughIdentifier and namesColumn; no mutation between the two uses can change the value, so behavior is unchanged.
Files Reviewed (3 files)
  • packages/opencode/src/altimate/native/dbt/column-reach.ts - no issues (hoist is behavior-preserving: tracked is only mutated for the node itself, never a parent)
  • packages/opencode/src/altimate/tools/impact-analysis.ts - no issues (truncation lowers bounds on all four counts; header note is accurate)
  • packages/opencode/test/altimate/tools/impact-analysis.test.ts - no issues (new assertion covers the note; Exposures ( negative assertion still matches only the capitalize-first title)
Previous Review Summaries (12 snapshots, latest commit 98c582c)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 98c582c)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

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

SUGGESTION

File Line Issue
packages/opencode/tools/impact-analysis.ts 309 When column reach stopped early, the "Other exposures" header says every listed exposure went unchecked, but the list also holds exposures on models that were traced before the cut and found not to read the column. Keep the plain label (the reach line already says "stopped early") and drop the doubled parenthetical.
packages/opencode/src/altimate/native/dbt/column-reach.ts 392 parents.flatMap((parent) => [...tracked.get(parent)!]) is computed here and again unchanged in the named expression below; hoist it once so the two scans over the same parents cannot drift.
Files Reviewed (5 files)
  • packages/opencode/src/altimate/native/dbt/column-reach.ts - 1 suggestion (namesThroughIdentifier: bounded, errs toward doubt, no false confident reader; only the duplicated parent-column union)
  • packages/opencode/tools/impact-analysis.ts - 1 suggestion (truncated lower-bound counts and "at least" wording are correct; only the "Other exposures" header overstates)
  • packages/opencode/src/altimate/tools/dbt-lineage.ts - no new issues (parameter description only in the incremental diff)
  • packages/opencode/test/altimate/column-reach.test.ts - no issues (IDENTIFIER cases match the implementation; temp dirs cleaned per test)
  • packages/opencode/test/altimate/tools/impact-analysis.test.ts - no issues (expected string matches the truncated output)

Fix these issues in Kilo Cloud

Previous review (commit 315685b)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

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

WARNING

File Line Issue
packages/opencode/test/altimate/dbt-lineage-structured.test.ts 232 The new graph assertion cannot pass: manifestGraph reads the raw manifest_path through loadRawManifest (existsSync/realpathSync follow the out link, then ..), so it opens other/target/manifest.json — the file the test just made invalid — and graph_error is its JSON parse error. The adapter only answers because manifestOutsideAdapterProject collapses .. lexically with path.resolve first, the opposite of Filesystem.realLocation's symlink-aware order. Resolve the manifest path once with the reader's semantics and use it for both the placement check and the graph.
Files Reviewed (5 files)
  • packages/opencode/src/altimate/native/dbt/column-reach.ts - no issues (whitespace/trailing-comma only; the downstream gap-3 trailing-comma change is formatting)
  • packages/opencode/src/altimate/native/dbt/graph.ts - no issues (formatting only)
  • packages/opencode/src/altimate/native/dbt/lineage.ts - no issues (previous suggestion fixed: the requested path is normalized once and an empty request still matches no node)
  • packages/opencode/test/altimate/column-reach.test.ts - no issues (formatting only)
  • packages/opencode/test/altimate/dbt-lineage-structured.test.ts - 1 warning

Fix these issues in Kilo Cloud

Previous review (commit 6d71706)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

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

SUGGESTION

File Line Issue
packages/opencode/src/altimate/native/dbt/lineage.ts 50 The new empty-path guard calls filePath(params.model) inside the find predicate, normalizing the requested model twice per node; hoist the normalized path (and the empty check) out of the scan
packages/opencode/test/altimate/dbt-lineage-structured.test.ts 228 The out/.. case only proves what the placement helper decides: both manifests are identical empty JSON and include_graph is not requested, so the test cannot detect the reader opening the other project's manifest — give other distinguishable content and assert the graph comes from mine
Files Reviewed (5 files)
  • packages/opencode/src/altimate/native/dbt/column-reach.ts - no issues (the 10,000-character NATURAL window is still bounded by the 100-occurrence cap; the widened slice only costs work on crafted input with dozens of natural tokens)
  • packages/opencode/src/altimate/native/dbt/lineage.ts - 1 suggestion
  • packages/opencode/src/altimate/tools/dbt-lineage.ts - no issues (the .. collapse now matches loadRawManifest's realpathSync, as this repo's containsReal/realLocation docs describe; counting only followed links still terminates, since climbing stops at the root)
  • packages/opencode/test/altimate/column-reach.test.ts - no issues (the backtick-alias, count_big, count_if and empty-path cases genuinely fail without their fixes)
  • packages/opencode/test/altimate/dbt-lineage-structured.test.ts - 1 suggestion

Fix these issues in Kilo Cloud

Previous review (commit 299bdf0)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

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

WARNING

File Line Issue
packages/opencode/src/altimate/tools/dbt-lineage.ts 143 path.resolve collapses .. before symlinks are resolved, so a manifest outside the project can be accepted as the project's own and its graph read; Filesystem.realLocation already implements the kernel-order walk and removes the duplicated resolver

SUGGESTION

File Line Issue
packages/opencode/src/altimate/native/dbt/column-reach.ts 129 The count_big(*)/count_if(*) exemption and the backtick output-alias skip (line 154) have no regression test
Files Reviewed (6 files)
  • packages/opencode/src/altimate/native/dbt/column-reach.ts - 1 suggestion (NATURAL JOIN detection and its placement in the reach walk verified: doubtful readers never become certain non-readers, and the doubt is passed to children)
  • packages/opencode/src/altimate/native/dbt/lineage.ts - no issues (path normalization for ./ and Windows separators is consistent with the manifest's original_file_path)
  • packages/opencode/src/altimate/tools/dbt-lineage.ts - 1 warning
  • packages/opencode/src/altimate/tools/impact-analysis.ts - no issues (unique_id is only added when present, and the MCP output schema does not require it)
  • packages/opencode/test/altimate/column-reach.test.ts - no issues (new NATURAL JOIN cases and their fixtures checked)
  • packages/opencode/test/altimate/dbt-lineage-structured.test.ts - no issues (uncompiled-project, stray-outside and symlink cases checked)

Previous review (commit f8fb664)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (1 file)
  • packages/opencode/src/altimate/mcp-server/server.ts - incremental change removes the non-declared structuredContent passthrough branch and its now-unused structured local; this resolves the previously carried finding (the branch was unreachable because dbt_lineage is in STRUCTURED_TOOLS). Verified metadata.structuredContent still reaches structuredResult for declared tools, no remaining reference to the removed variable, and no test asserted the passthrough behavior.

Previous review (commit 353523d)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

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

SUGGESTION

File Line Issue
packages/opencode/src/altimate/mcp-server/server.ts 233 Carried (still present): the non-declared structuredContent passthrough is unreachable — the only producer of metadata.structuredContent is tools/dbt-lineage.ts:77, and dbt_lineage is in STRUCTURED_TOOLS — so the !STRUCTURED_TOOLS.has(...) branch, its cap, and its test coverage are dead.
Files Reviewed (1 file)
  • packages/opencode/src/altimate/mcp-server/server.ts - incremental change is Prettier re-indentation only of the existing passthrough condition (no behavior change); the carried finding above is re-verified against current HEAD.

Fix these issues in Kilo Cloud

Previous review (commit e80cf4c)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

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

SUGGESTION

File Line Issue
packages/opencode/src/altimate/mcp-server/server.ts 233 Carried (still present): the non-declared structuredContent passthrough is unreachable — the only producer of metadata.structuredContent is tools/dbt-lineage.ts:77, and dbt_lineage is in STRUCTURED_TOOLS — so the !STRUCTURED_TOOLS.has(...) branch, its cap, and its test coverage are dead.
Files Reviewed (2 files)
  • packages/opencode/src/altimate/native/dbt/column-reach.ts - incremental change verified: hoisting named is behavior-preserving (parents is filtered to tracked.has(p), so no missing-key throw), and the two prior suggestions on this file are fixed
  • packages/opencode/test/altimate/column-reach.test.ts - incremental change verified: the assertion that rpt_ltv is absent from reach.models (certain readers) closes the prior gap

Fix these issues in Kilo Cloud

Previous review (commit fe673cf)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

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

SUGGESTION

File Line Issue
packages/opencode/src/altimate/native/dbt/column-reach.ts 328 New: the no-edge branch's predicate is exactly named.length > 0 for the named computed at line 333, so the same parent/column scan runs twice; hoist named and keep only the reason selection here.
packages/opencode/src/altimate/mcp-server/server.ts 234 Carried (still present): the non-declared structuredContent passthrough is unreachable — the only producer is dbt_lineage, which is in STRUCTURED_TOOLS — so the cap added here is dead and untested.
packages/opencode/test/altimate/column-reach.test.ts 394 New: the "reads nothing" assertion covers only untraced and uncertain, so a regression classifying rpt_ltv as a certain reader (models) would still pass; also assert it is absent from reach.models.
Files Reviewed (3 files)
  • packages/opencode/src/altimate/mcp-server/server.ts - 1 suggestion (carried)
  • packages/opencode/src/altimate/native/dbt/column-reach.ts - 1 suggestion
  • packages/opencode/test/altimate/column-reach.test.ts - 1 suggestion

Fix these issues in Kilo Cloud

Previous review (commit 7c89b71)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

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

SUGGESTION

File Line Issue
packages/opencode/src/altimate/native/dbt/column-reach.ts 327 New: a parsed model with no column edges (select 1, select count(*)) is recorded as untraced and added to unsure, so descendants that do not read the column are reported uncertain; the namesColumn check below already separates readers from non-readers, and the branch exceeds the documented untraced definition.
packages/opencode/src/altimate/mcp-server/server.ts 229 Carried (still present): the non-declared structuredContent passthrough is unreachable — the only producer is dbt_lineage, which is in STRUCTURED_TOOLS and takes the branch below — and would bypass the cap if a future tool used it.
Files Reviewed (5 files)
  • packages/opencode/src/altimate/mcp-server/server.ts - 1 suggestion (carried)
  • packages/opencode/src/altimate/native/dbt/column-reach.ts - 1 suggestion
  • packages/opencode/src/altimate/tools/dbt-lineage.ts - no issues
  • packages/opencode/test/altimate/column-reach.test.ts - no issues
  • packages/opencode/test/altimate/dbt-lineage-structured.test.ts - no issues

Fix these issues in Kilo Cloud

Previous review (commit 4ac010e)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

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

SUGGESTION

File Line Issue
packages/opencode/src/altimate/native/dbt/column-reach.ts 220 New: spellings.includes(column) is evaluated on both 219 and 220; hoist exact once and read unsettled/spelled off it so the spelling decision lives in one place.
packages/opencode/src/altimate/mcp-server/server.ts 226 Carried (still present): the non-declared structuredContent passthrough is unreachable — the only producer is dbt_lineage, which is in STRUCTURED_TOOLS and falls through to the capped branch below — and would bypass the 100 KB cap if a future tool used it.
Files Reviewed (8 files)
  • packages/opencode/src/altimate/native/dbt/column-reach.ts - 1 suggestion
  • packages/opencode/src/altimate/native/dbt/lineage.ts - no issues
  • packages/opencode/src/altimate/tools/dbt-lineage.ts - no issues
  • packages/opencode/src/altimate/tools/impact-analysis.ts - no issues
  • packages/opencode/src/altimate/mcp-server/server.ts - 1 suggestion (carried)
  • packages/opencode/test/altimate/column-reach.test.ts - no issues
  • packages/opencode/test/altimate/dbt-lineage-structured.test.ts - no issues
  • packages/opencode/test/altimate/impact-analysis.test.ts - no issues

Fix these issues in Kilo Cloud

Previous review (commit 9f92fa2)

Status: 12 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 11
Issue Details (click to expand)

WARNING

File Line Issue
packages/opencode/src/altimate/native/dbt/column-reach.ts 131 selectsStar treats revenue * -1 as a star select (signed right operand), downgrading a certain reader to uncertain and stopping propagation.

SUGGESTION

File Line Issue
packages/opencode/src/altimate/native/dbt/graph.ts 101 New: the shared walk refactor leaves downstream truncation (truncated: true and the counted gate for test nodes) with no test; only the upstream limit is covered by the new case.
packages/opencode/src/altimate/tools/dbt-lineage.ts 56 New: include_graph says "with exposures and tests", but upstream tests are deliberately skipped (graph.ts:88) and only the target's and downstream tests are added (graph.ts:108).
packages/opencode/src/altimate/native/dbt/column-reach.ts 207 trackedExact for the target is seeded from the caller's spelling; with case-colliding documented columns and a case-mismatched input every edge is dropped, silently losing the subtree.
packages/opencode/src/altimate/native/dbt/column-reach.ts 239 column_lineage: [] passes Array.isArray, so the column_dict fallback never runs and readers present only there are missed.
packages/opencode/src/altimate/native/dbt/column-reach.ts 301 The compiled SQL is read into sqlText at line 248 and into sql again in the same iteration; reuse one variable.
packages/opencode/src/altimate/native/dbt/column-reach.ts 105 The namesColumn JSDoc sits above stripSql, leaving namesColumn undocumented.
packages/opencode/src/altimate/native/dbt/lineage.ts 48 byPath scans every node on every dbtLineage call, including the ~300 calls made by columnReach; compute it lazily.
packages/opencode/src/altimate/native/dbt/lineage.ts 46 Two stacked comments restate the target-lookup rules.
packages/opencode/src/altimate/mcp-server/server.ts 94 The Column Mappings: trim silently fails when column_dict is absent, duplicating the edge text next to edges.
packages/opencode/src/altimate/mcp-server/server.ts 226 The non-declared structuredContent passthrough is dead after the new guard, and it would bypass the 100 KB cap.
packages/opencode/src/altimate/tools/impact-analysis.ts 76 ?? modelsByName.get(d.name)?.unique_id is unreachable, leaving modelsByName unused.
Files Reviewed (12 files)
  • packages/opencode/src/altimate/mcp-server/server.ts - 2 suggestions
  • packages/opencode/src/altimate/native/dbt/column-reach.ts - 1 warning, 3 suggestions
  • packages/opencode/src/altimate/native/dbt/graph.ts - 1 suggestion (new)
  • packages/opencode/src/altimate/native/dbt/lineage.ts - 2 suggestions
  • packages/opencode/src/altimate/native/types.ts - no issues
  • packages/opencode/src/altimate/tools/dbt-lineage.ts - 1 suggestion (new)
  • packages/opencode/src/altimate/tools/impact-analysis.ts - 1 suggestion
  • packages/opencode/test/altimate/column-reach.test.ts - no issues
  • packages/opencode/test/altimate/dbt-lineage-helpers.test.ts - no issues
  • packages/opencode/test/altimate/dbt-lineage-structured.test.ts - no issues
  • packages/opencode/test/altimate/mcp-server/server.test.ts - no issues
  • packages/opencode/test/altimate/tools/impact-analysis.test.ts - no issues

Fix these issues in Kilo Cloud

Previous review (commit 391fd7c)

Status: 9 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 8
Issue Details (click to expand)

WARNING

File Line Issue
packages/opencode/src/altimate/native/dbt/column-reach.ts 131 selectsStar treats revenue * -1 as a star select (signed right operand), downgrading a certain reader to uncertain and stopping propagation.

SUGGESTION

File Line Issue
packages/opencode/src/altimate/native/dbt/column-reach.ts 207 trackedExact for the target is seeded from the caller's spelling; with case-colliding documented columns and a case-mismatched input every edge is dropped, silently losing the subtree.
packages/opencode/src/altimate/native/dbt/column-reach.ts 239 column_lineage: [] passes Array.isArray, so the column_dict fallback never runs and readers present only there are missed.
packages/opencode/src/altimate/native/dbt/column-reach.ts 301 The compiled SQL is read into sqlText at line 248 and into sql again in the same iteration; reuse one variable.
packages/opencode/src/altimate/native/dbt/lineage.ts 48 byPath scans every node on every dbtLineage call, including the ~300 unique-id calls made by columnReach; compute it lazily.
packages/opencode/src/altimate/mcp-server/server.ts 94 The Column Mappings: trim silently fails when column_dict is absent, duplicating the edge text next to edges.
packages/opencode/src/altimate/mcp-server/server.ts 226 The non-declared structuredContent passthrough is dead after the new guard, and it would bypass the 100 KB cap.
packages/opencode/src/altimate/native/dbt/lineage.ts 46 Carried over: two stacked comments restate the target-lookup rules.
packages/opencode/src/altimate/native/dbt/column-reach.ts 105 Carried over: the namesColumn JSDoc sits above stripSql, leaving namesColumn undocumented.
packages/opencode/src/altimate/tools/impact-analysis.ts 76 Carried over: ?? modelsByName.get(d.name)?.unique_id is unreachable, leaving modelsByName unused.
Files Reviewed (12 files)
  • packages/opencode/src/altimate/mcp-server/server.ts - 2 suggestions
  • packages/opencode/src/altimate/native/dbt/column-reach.ts - 1 warning, 3 suggestions
  • packages/opencode/src/altimate/native/dbt/graph.ts - no issues
  • packages/opencode/src/altimate/native/dbt/lineage.ts - 2 suggestions (1 new)
  • packages/opencode/src/altimate/native/types.ts - no issues
  • packages/opencode/src/altimate/tools/dbt-lineage.ts - no issues
  • packages/opencode/src/altimate/tools/impact-analysis.ts - 1 suggestion (carried over)
  • packages/opencode/test/altimate/column-reach.test.ts - no issues
  • packages/opencode/test/altimate/dbt-lineage-helpers.test.ts - no issues
  • packages/opencode/test/altimate/dbt-lineage-structured.test.ts - no issues
  • packages/opencode/test/altimate/mcp-server/server.test.ts - no issues
  • packages/opencode/test/altimate/tools/impact-analysis.test.ts - no issues

Fix these issues in Kilo Cloud

Additional previous summary content was truncated to keep this comment within platform limits.


Reviewed by deepseek-v4.1-flash-1017 · Input: 51.7K · Output: 7.3K · Cached: 378K

Review guidance: REVIEW.md from base branch feat/dbt-lineage-structured-result (truncated)

@dev-punia-altimate dev-punia-altimate left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Code Review — OpenCodeReview (Gemini) — 12 finding(s)

  • 11 anchored to a line (posted inline when the comment stream is on)
  • 1 without a line anchor
All findings (full text)

1. packages/opencode/src/altimate/native/dbt/lineage.ts (L47)

[🔵 LOW] According to the code review checklist, the use of any type should be avoided. If it is necessary, please provide a comment explaining the reason. Consider replacing any with a more specific interface (e.g., { resource_type?: string, original_file_path?: string, name?: string }).

Please also address the Object.values<any> usage a few lines below.

2. packages/opencode/src/altimate/native/dbt/lineage.ts (L94)

[🔵 LOW] The code review checklist prohibits the use of nested ternary expressions. Please refactor this logic into an if-else statement before the return block or extract it into a helper function to improve readability.

3. packages/opencode/src/altimate/native/dbt/graph.ts (L32-L35)

[🔵 LOW] TypeScript Types: The project standards state to avoid using the any type, or if necessary, provide a comment explaining the reason. The manifest object and various node variables are typed as any across multiple functions (lookup, relationOf, nodeOf, mapsOf, and lineageGraph). Please either define an interface (or use an existing one if applicable, such as Record<string, unknown>) or add a comment explaining why any is needed (e.g., parsing a large, unstructured JSON object).

Suggested change:

// Using 'any' for the manifest and node variables as they represent large unstructured JSON objects from dbt.
function lookup(manifest: any, id: string): any {
  for (const group of GROUPS) if (manifest[group]?.[id]) return manifest[group][id]
  return undefined
}

4. packages/opencode/src/altimate/native/dbt/graph.ts (L101-L106)

[🟠 MEDIUM] Potential Null/Undefined Reference Issue: Test nodes are appended to graph.nodes but do not receive corresponding array initializations in graph.parents or graph.children. Since test nodes bypass the loop that initializes these relationships, downstream consumers iterating over graph.nodes may encounter runtime exceptions if they assume every node has defined parents or children arrays. Consider initializing them here.

Suggested change:

    graph.children[one] = (children[one] ?? []).filter((other) => {
      if (keep.has(other)) return true
      if (!isTest(other)) return false
      if (!graph.nodes[other]) {
        graph.nodes[other] = { ...nodeOf(other, lookup(manifest, other)), type: "test" }
        graph.parents[other] = []
        graph.children[other] = []
      }
      return true
    })

5. packages/opencode/src/altimate/native/dbt/graph.ts (L87-L90)

[🟠 MEDIUM] Performance Optimization: Inefficient loop termination for the downstream node limit. When count >= MAX_DOWNSTREAM, setting truncated = true and using continue only skips to the next iteration of the inner for loop, needlessly iterating through all remaining children without making meaningful progress. You can empty the queue and use break to exit the traversal entirely.

Suggested change:

      if (count >= MAX_DOWNSTREAM) {
        truncated = true
        queue.length = 0
        break
      }

6. packages/opencode/src/altimate/native/dbt/column-reach.ts (L57)

[🔵 LOW] Ternary Expressions & TypeScript Types:

  1. Nested ternary expressions are not allowed: Please refactor this into an if-else statement for better readability.
  2. Avoid using any type: Using (source as any) bypasses type safety without an explanatory comment. Please use type narrowing or define a proper interface.

Suggested change:

  let text: string | null = null
  if (typeof source === "string") {
    text = source
  } else if (typeof source === "object" && source !== null && "source" in source && typeof (source as Record<string, unknown>).source === "string") {
    text = (source as Record<string, unknown>).source as string
  }

7. packages/opencode/src/altimate/native/dbt/column-reach.ts (L67)

[🔵 LOW] TypeScript Types: Avoid using the any type for node and manifest arguments here and in other helper functions (caseCollides, documentedColumns, childMapOf). This weakens type safety, especially for complex structures like dbt manifests.
Please define proper TypeScript interfaces for the dbt manifest structures, or reuse existing types like DbtModelInfo from types.ts.

8. packages/opencode/src/altimate/native/dbt/column-reach.ts (L229)

[🟠 MEDIUM] Performance & Async Handling: dbtLineage is called synchronously in a loop that can iterate up to MAX_MODELS (300) times. Since dbtLineage performs heavy synchronous operations (such as SQL AST parsing and redundant file system checks via loadRawManifest), this will severely block the Node.js event loop and make the application unresponsive.

Suggestions:

  1. Make this function asynchronous and use await new Promise(r => setImmediate(r)) periodically to yield to the event loop.
  2. Consider modifying dbtLineage to accept an already parsed manifest object instead of reading from manifest_path repeatedly, which incurs fs.statSync overhead on every call.

9. packages/opencode/src/altimate/native/dbt/column-reach.ts (L306-L317)

[🔵 LOW] Ternary Expressions: Nested ternary expressions are not allowed as they severely impact readability. Please refactor this logic using if-else blocks to determine the reason string before pushing to the uncertain array.

Suggested change:

      let reason = "is downstream of a model that could not be traced or reads it ambiguously"
      if (ambiguous) {
        reason = "reads it through a table name more than one parent shares"
      } else if (named.length > 0) {
        reason = shared
          ? "names it outside its select list, or names another parent's column of the same name"
          : "names it only outside its select list (WHERE, JOIN, GROUP BY or HAVING)"
      }
      uncertain.push(entry(id, reason))

10. packages/opencode/src/altimate/native/dbt/column-reach.ts (L322-L325)

[🟠 MEDIUM] Performance Optimization: exposuresOn internally calls loadRawManifest(manifestPath). Although it has an internal memory cache, it still performs synchronous file system operations (fs.existsSync, fs.statSync) on every invocation.
Since the manifest is already parsed at the beginning of columnReach, it is highly recommended to refactor exposuresOn to accept the parsed manifest object to eliminate the redundant synchronous disk I/O.

11. packages/opencode/src/altimate/tools/impact-analysis.ts (L75-L78)

[🟠 MEDIUM] Since targetModel.unique_id can be undefined, undefined may be added to the affectedModelIds Set. This causes the Set to have the type Set<string | undefined>, which is later passed to exposuresOn(args.manifest_path, affectedModelIds) that expects an Iterable<string>. This mismatch can cause TypeScript compiler errors under strict settings, and is logically imprecise. It is recommended to filter out falsy values from the entire array before initializing the Set.

Suggested change:

      const affectedModelIds = new Set(
        [
          targetModel.unique_id,
          ...downstream.map((d) => d.unique_id ?? modelsByName.get(d.name)?.unique_id),
        ].filter((id): id is string => Boolean(id))
      )

12. packages/opencode/src/altimate/tools/impact-analysis.ts

[🔴 HIGH] Using a Depth-First Search (DFS) approach to calculate the depth of dependencies causes a bug: if a model is both a direct dependency and a transitive dependency, but the DFS reaches it via a longer path first, it will be added to the visited set and recorded with a depth > 1. Later, when evaluating direct dependencies, it will be skipped because it is already in visited. Consequently, direct dependencies might be incorrectly classified as 'transitive'. Switching to a Breadth-First Search (BFS) ensures that each model is visited first via its shortest path, correctly recording its minimum depth.

Suggested change:

  // A parent is matched by unique_id when known (a versioned model's id ends in its version, not its
  // name; another package may have a model of the same name), else by the last segment of each
  // dependency id.
  const queue = [{ name: targetName, id: targetId, depth: 1, path: [targetName] }]

  while (queue.length > 0) {
    const { name, id, depth, path } = queue.shift()!
    for (const model of models) {
      const key = model.unique_id ?? model.name
      if (visited.has(key)) continue
      const deps = model.depends_on.map((d) => d.split(".").pop())
      // An exact id decides alone: another package's model of the same name is not this one.
      if (id ? model.depends_on.includes(id) : deps.includes(name)) {
        visited.add(key)
        const newPath = [...path, model.name]
        results.push({
          name: model.name,
          ...(model.unique_id && { unique_id: model.unique_id }),
          depth,
          materialized: model.materialized,
          path: newPath,
        })
        queue.push({ name: model.name, id: model.unique_id, depth: depth + 1, path: newPath })
      }
    }
  }

const modelNode = findModel(nodes, params.model)
// Find target model by name or unique_id; a snapshot by its unique_id (it has compiled SQL too)
// A model by name or unique_id; a snapshot by unique_id; either by its project-relative file path.
const byPath = Object.entries<any>(nodes).find(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🔵 LOW] According to the code review checklist, the use of any type should be avoided. If it is necessary, please provide a comment explaining the reason. Consider replacing any with a more specific interface (e.g., { resource_type?: string, original_file_path?: string, name?: string }).

Please also address the Object.values<any> usage a few lines below.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declined: the manifest is untyped JSON, so lineage.ts keeps any like the surrounding manifest-reading code.

confidence_factors: rawLineage.error ? [String(rawLineage.error)] : [],
// The engine reports SQL it cannot parse in `errors` with no edges, rather than throwing.
confidence: rawLineage.error || unparsed(rawLineage) ? "low" : "high",
confidence_factors: rawLineage.error ? [String(rawLineage.error)] : unparsed(rawLineage) ? [String((rawLineage.errors as unknown[])[0])] : [],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🔵 LOW] The code review checklist prohibits the use of nested ternary expressions. Please refactor this logic into an if-else statement before the return block or extract it into a helper function to improve readability.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declined: the ternary follows the surrounding style in this file and is readable as written.

Comment on lines +32 to +35
function lookup(manifest: any, id: string): any {
for (const group of GROUPS) if (manifest[group]?.[id]) return manifest[group][id]
return undefined
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🔵 LOW] TypeScript Types: The project standards state to avoid using the any type, or if necessary, provide a comment explaining the reason. The manifest object and various node variables are typed as any across multiple functions (lookup, relationOf, nodeOf, mapsOf, and lineageGraph). Please either define an interface (or use an existing one if applicable, such as Record<string, unknown>) or add a comment explaining why any is needed (e.g., parsing a large, unstructured JSON object).

Suggested change:

Suggested change
function lookup(manifest: any, id: string): any {
for (const group of GROUPS) if (manifest[group]?.[id]) return manifest[group][id]
return undefined
}
// Using 'any' for the manifest and node variables as they represent large unstructured JSON objects from dbt.
function lookup(manifest: any, id: string): any {
for (const group of GROUPS) if (manifest[group]?.[id]) return manifest[group][id]
return undefined
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declined: dbt manifest nodes are untyped JSON and graph.ts follows the any convention of the other manifest helpers.

Comment on lines +101 to +106
graph.children[one] = (children[one] ?? []).filter((other) => {
if (keep.has(other)) return true
if (!isTest(other)) return false
graph.nodes[other] ??= { ...nodeOf(other, lookup(manifest, other)), type: "test" }
return true
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🟠 MEDIUM] Potential Null/Undefined Reference Issue: Test nodes are appended to graph.nodes but do not receive corresponding array initializations in graph.parents or graph.children. Since test nodes bypass the loop that initializes these relationships, downstream consumers iterating over graph.nodes may encounter runtime exceptions if they assume every node has defined parents or children arrays. Consider initializing them here.

Suggested change:

Suggested change
graph.children[one] = (children[one] ?? []).filter((other) => {
if (keep.has(other)) return true
if (!isTest(other)) return false
graph.nodes[other] ??= { ...nodeOf(other, lookup(manifest, other)), type: "test" }
return true
})
graph.children[one] = (children[one] ?? []).filter((other) => {
if (keep.has(other)) return true
if (!isTest(other)) return false
if (!graph.nodes[other]) {
graph.nodes[other] = { ...nodeOf(other, lookup(manifest, other)), type: "test" }
graph.parents[other] = []
graph.children[other] = []
}
return true
})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Already handled: test nodes get parents and children entries in the same block (graph.ts, the !graph.nodes[other] branch), so every node in graph.nodes has both.

Comment on lines +87 to +90
if (count >= MAX_DOWNSTREAM) {
truncated = true
continue
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🟠 MEDIUM] Performance Optimization: Inefficient loop termination for the downstream node limit. When count >= MAX_DOWNSTREAM, setting truncated = true and using continue only skips to the next iteration of the inner for loop, needlessly iterating through all remaining children without making meaningful progress. You can empty the queue and use break to exit the traversal entirely.

Suggested change:

Suggested change
if (count >= MAX_DOWNSTREAM) {
truncated = true
continue
}
if (count >= MAX_DOWNSTREAM) {
truncated = true
queue.length = 0
break
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in graph.ts: hitting MAX_DOWNSTREAM now empties the queue and breaks out of the traversal instead of looping over the remaining children. Behaviour is unchanged.

}

/** The ways a parent's relation can be written in a child's compiled SQL, most qualified first. */
function relationKeys(node: any): string[] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🔵 LOW] TypeScript Types: Avoid using the any type for node and manifest arguments here and in other helper functions (caseCollides, documentedColumns, childMapOf). This weakens type safety, especially for complex structures like dbt manifests.
Please define proper TypeScript interfaces for the dbt manifest structures, or reuse existing types like DbtModelInfo from types.ts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declined: manifest and node values are untyped JSON, so the helpers in column-reach.ts keep any like the existing manifest code.

break
}

const result = dbtLineage({ manifest_path: manifestPath, model: id, dialect })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🟠 MEDIUM] Performance & Async Handling: dbtLineage is called synchronously in a loop that can iterate up to MAX_MODELS (300) times. Since dbtLineage performs heavy synchronous operations (such as SQL AST parsing and redundant file system checks via loadRawManifest), this will severely block the Node.js event loop and make the application unresponsive.

Suggestions:

  1. Make this function asynchronous and use await new Promise(r => setImmediate(r)) periodically to yield to the event loop.
  2. Consider modifying dbtLineage to accept an already parsed manifest object instead of reading from manifest_path repeatedly, which incurs fs.statSync overhead on every call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declined: reach is capped at 300 models and measured at about 550 ms for 300 downstream models, and loadRawManifest is cached. An async rewrite would add complexity without a measured problem.

Comment on lines +306 to +317
uncertain.push(
entry(
id,
ambiguous
? "reads it through a table name more than one parent shares"
: named.length > 0
? shared
? "names it outside its select list, or names another parent's column of the same name"
: "names it only outside its select list (WHERE, JOIN, GROUP BY or HAVING)"
: "is downstream of a model that could not be traced or reads it ambiguously",
),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🔵 LOW] Ternary Expressions: Nested ternary expressions are not allowed as they severely impact readability. Please refactor this logic using if-else blocks to determine the reason string before pushing to the uncertain array.

Suggested change:

Suggested change
uncertain.push(
entry(
id,
ambiguous
? "reads it through a table name more than one parent shares"
: named.length > 0
? shared
? "names it outside its select list, or names another parent's column of the same name"
: "names it only outside its select list (WHERE, JOIN, GROUP BY or HAVING)"
: "is downstream of a model that could not be traced or reads it ambiguously",
),
)
let reason = "is downstream of a model that could not be traced or reads it ambiguously"
if (ambiguous) {
reason = "reads it through a table name more than one parent shares"
} else if (named.length > 0) {
reason = shared
? "names it outside its select list, or names another parent's column of the same name"
: "names it only outside its select list (WHERE, JOIN, GROUP BY or HAVING)"
}
uncertain.push(entry(id, reason))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declined: style only. The reason selection is readable as written and matches surrounding code.

Comment on lines +322 to +325
const certain = exposuresOn(manifestPath, reachedIds)
const maybe = exposuresOn(manifestPath, [...uncertain, ...untraced].map((m) => m.unique_id)).filter(
(e) => !certain.some((c) => c.name === e.name),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🟠 MEDIUM] Performance Optimization: exposuresOn internally calls loadRawManifest(manifestPath). Although it has an internal memory cache, it still performs synchronous file system operations (fs.existsSync, fs.statSync) on every invocation.
Since the manifest is already parsed at the beginning of columnReach, it is highly recommended to refactor exposuresOn to accept the parsed manifest object to eliminate the redundant synchronous disk I/O.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declined: loadRawManifest is cached by mtime, so the extra call is one stat. Threading the parsed manifest through is not worth the signature change at this size.

Comment on lines 75 to 78
const affectedModelIds = new Set([
targetModel.unique_id,
...downstream.map((d) => modelsByName.get(d.name)?.unique_id).filter(Boolean),
...downstream.map((d) => d.unique_id ?? modelsByName.get(d.name)?.unique_id).filter(Boolean),
])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🟠 MEDIUM] Since targetModel.unique_id can be undefined, undefined may be added to the affectedModelIds Set. This causes the Set to have the type Set<string | undefined>, which is later passed to exposuresOn(args.manifest_path, affectedModelIds) that expects an Iterable<string>. This mismatch can cause TypeScript compiler errors under strict settings, and is logically imprecise. It is recommended to filter out falsy values from the entire array before initializing the Set.

Suggested change:

Suggested change
const affectedModelIds = new Set([
targetModel.unique_id,
...downstream.map((d) => modelsByName.get(d.name)?.unique_id).filter(Boolean),
...downstream.map((d) => d.unique_id ?? modelsByName.get(d.name)?.unique_id).filter(Boolean),
])
const affectedModelIds = new Set(
[
targetModel.unique_id,
...downstream.map((d) => d.unique_id ?? modelsByName.get(d.name)?.unique_id),
].filter((id): id is string => Boolean(id))
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in impact-analysis.ts: undefined ids are filtered out before building affectedModelIds, so the Set is typed Set<string>.

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

1 similar comment
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 10 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

exposures,
...(reach && { column_reach: reach }),
...(reachError && { column_reach_error: reachError }),
...(exposuresError && { exposures_error: exposuresError }),

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]: exposures_error is not forwarded to MCP structured content

The new metadata field is added here, but STRUCTURED_FIELDS.impact_analysis in mcp-server/server.ts does not list it (it does list the analogous column_reach_error). structuredResult therefore drops exposures_error, so a client that reads the structured fields instead of report still sees exposures: [] with no failure signal and renders "no dashboards" — the exact reading this fix was meant to prevent (the text notice only survives because it is also embedded in report). Add "exposures_error" to the impact_analysis allow-list.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in server.ts: exposures_error is added to the impact_analysis structured fields, with a test.

content: [{ type: "text" as const, text: error instanceof Error ? error.message : String(error) }],
isError: true,
...(STRUCTURED_TOOLS.has(request.params.name) && {
structuredContent: { report: error instanceof Error ? error.message : String(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.

[SUGGESTION]: The error message is stringified twice

error instanceof Error ? error.message : String(error) is evaluated on line 199 and again here. Hoisting it into one const message = ... and reusing it for both the text content and the structured report keeps the two from drifting and removes the duplication introduced by this hunk.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in server.ts: the message is computed once as message and reused for the text and structured content.

@dev-punia-altimate dev-punia-altimate left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Code Review — OpenCodeReview (Gemini) — 2 finding(s)

  • 2 anchored to a line (posted inline when the comment stream is on)
  • 0 without a line anchor
All findings (full text)

1. packages/opencode/src/altimate/mcp-server/server.ts (L199-L203)

[🔵 LOW] This conditional expression for error message extraction is duplicated. Consider extracting it into a variable to avoid redundant evaluation and improve code readability.

Suggested change:

        content: [{ type: "text" as const, text: error instanceof Error ? error.message : String(error) }],
        isError: true,
        ...(STRUCTURED_TOOLS.has(request.params.name) && {
          structuredContent: { report: error instanceof Error ? error.message : String(error) },
        }),

2. packages/opencode/src/altimate/tools/impact-analysis.ts (L316-L319)

[🟠 MEDIUM] If the caught error contains multiline messages or stack traces (e.g., from parsing failures), appending it directly via string interpolation could break the visual layout and readability of this plain text report. Consider sanitizing the error message by replacing newlines with spaces.

Suggested change:

  if (data.exposuresError) {
    const sanitizedError = data.exposuresError.replace(/\r?\n/g, " ");
    lines.push(`Exposures: not read (${sanitizedError})`)
    lines.push("")
  }

Comment on lines +199 to +203
content: [{ type: "text" as const, text: error instanceof Error ? error.message : String(error) }],
isError: true,
...(STRUCTURED_TOOLS.has(request.params.name) && {
structuredContent: { report: error instanceof Error ? error.message : String(error) },
}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🔵 LOW] This conditional expression for error message extraction is duplicated. Consider extracting it into a variable to avoid redundant evaluation and improve code readability.

Suggested change:

Suggested change
content: [{ type: "text" as const, text: error instanceof Error ? error.message : String(error) }],
isError: true,
...(STRUCTURED_TOOLS.has(request.params.name) && {
structuredContent: { report: error instanceof Error ? error.message : String(error) },
}),
content: [{ type: "text" as const, text: error instanceof Error ? error.message : String(error) }],
isError: true,
...(STRUCTURED_TOOLS.has(request.params.name) && {
structuredContent: { report: error instanceof Error ? error.message : String(error) },
}),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in server.ts: the error message is hoisted into one message variable.

Comment on lines +316 to 319
if (data.exposuresError) {
lines.push(`Exposures: not read (${data.exposuresError})`)
lines.push("")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🟠 MEDIUM] If the caught error contains multiline messages or stack traces (e.g., from parsing failures), appending it directly via string interpolation could break the visual layout and readability of this plain text report. Consider sanitizing the error message by replacing newlines with spaces.

Suggested change:

Suggested change
if (data.exposuresError) {
lines.push(`Exposures: not read (${data.exposuresError})`)
lines.push("")
}
if (data.exposuresError) {
const sanitizedError = data.exposuresError.replace(/\r?\n/g, " ");
lines.push(`Exposures: not read (${sanitizedError})`)
lines.push("")
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in impact-analysis.ts: newlines in the exposures error are replaced with spaces in the report line. Test added.

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

1 similar comment
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

const reachedIds = new Set([targetId, ...reached.map((m) => m.unique_id)])
const certain = exposuresOn(manifestPath, reachedIds)
const maybe = exposuresOn(manifestPath, [...uncertain, ...untraced].map((m) => m.unique_id)).filter(
(e) => !certain.some((c) => (c.unique_id ?? c.name) === (e.unique_id ?? e.name)),

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]: The ?? c.name fallbacks are unreachable

ColumnReachExposure.unique_id is a required string (line 28) and exposuresOn always sets it, so both ?? c.name fallbacks are dead code. Comparing the ids directly keeps the dedupe honest and removes the suggestion that an exposure can lack an id.

Suggested change
(e) => !certain.some((c) => (c.unique_id ?? c.name) === (e.unique_id ?? e.name)),
(e) => !certain.some((c) => c.unique_id === e.unique_id),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: exposuresOn always sets unique_id, so the filter in column-reach.ts now compares unique_id directly without the name fallback.

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

1 similar comment
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@hkaltai
hkaltai force-pushed the feat/plugin-altimate-code-integration branch from b268877 to 3d30b5c Compare November 2, 2026 19:13
@hkaltai
hkaltai changed the base branch from feat-local-integrations-sync to feat/dbt-lineage-structured-result November 2, 2026 19:13
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

3 similar comments
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@hkaltai
hkaltai force-pushed the feat/plugin-altimate-code-integration branch from 3d30b5c to b7df342 Compare November 2, 2026 19:17
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

1 similar comment
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

// Find target model by name or unique_id
const modelNode = findModel(nodes, params.model)
// Find target model by name or unique_id; a snapshot by its unique_id (it has compiled SQL too)
// A model by name or unique_id; a snapshot by unique_id; either by its project-relative file path.

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: Two stacked comments say the same thing

// Find target model by name or unique_id; a snapshot by its unique_id (it has compiled SQL too) and the line below it restate the lookup rules twice — the first is a leftover from an earlier edit of this hunk. Keep one of them so the resolution order is stated once.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed the duplicate comment in dbtLineage (native/dbt/lineage.ts); the resolution order is now stated once.


const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")

/**

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: This JSDoc describes namesColumn, not stripSql

"Whether compiled SQL names the column anywhere but as an output alias… used only to mark a model as one that may read the column" belongs to namesColumn, but it sits above stripSql (which has its own one-line description on line 109). As written stripSql carries two unrelated descriptions and namesColumn has none. Move this block down to namesColumn.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Moved the JSDoc for namesColumn from stripSql onto namesColumn in column-reach.ts; stripSql has its own description.

...downstream.map((d) => modelsByName.get(d.name)?.unique_id).filter(Boolean),
])
const affectedModelIds = new Set(
[targetModel.unique_id, ...downstream.map((d) => d.unique_id ?? modelsByName.get(d.name)?.unique_id)].filter(

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: The modelsByName fallback is unreachable

findDownstream copies unique_id straight from the same manifest.models entries that build modelsByName, so when d.unique_id is missing the map's entry for that name lacks it too. With duplicate names across packages the map holds the last entry, so the fallback could only return another package's id if it ever ran. Dropping ?? modelsByName.get(d.name)?.unique_id also leaves modelsByName with no remaining use, so both can go.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed the modelsByName fallback and the map in impact-analysis.ts; affectedModelIds now uses each downstream entry's own unique_id.

…analysis

Builds on the dbt integration's structured dbt_lineage result.

- dbt_lineage takes a project-relative SQL path or a snapshot as well as
  a model, and with include_graph returns the model's graph from the
  manifest (two levels up, everything downstream with exposures and
  tests, capped at 500 nodes). The manifest fallback reports upstream
  ids, column mappings and edges as data.
- impact_analysis with a column traces each downstream model's and
  snapshot's compiled SQL in dependency order and reports column_reach:
  certain readers with the columns they carry, possible readers
  (filters and joins, select * over undocumented columns, shared table
  names, ephemeral parents), untraced models and the exposures on each.
  It matches models by unique_id and honours the dialect override.
- The MCP server declares an output schema for both tools. Structured
  content is the tool's own result when it builds one (the dbt
  integration's model lineage), else its lineage fields, always with the
  text report and capped at 100 KB, errors included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 5 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

…test proves which manifest the graph reads

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

1 similar comment
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

1 similar comment
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

await writeFile(path.join(other, "target", "manifest.json"), "{ not json")
const viaDots = await tool.execute({ model: "customers", manifest_path: `${mine}/out/../target/manifest.json`, include_graph: true }, ctx)
expect(viaDots.metadata.structuredContent).toEqual(TRACE)
expect(String(viaDots.metadata.graph_error ?? "")).not.toMatch(/json|parse/i)

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 cannot hold with the current code

manifestGraph (tools/dbt-lineage.ts:70) reads the manifest from the raw args.manifest_path through loadRawManifest, whose existsSync/realpathSync follow the out link and then .., so ${mine}/out/../target/manifest.json is other/target/manifest.json — the file just made invalid — and graph_error becomes its JSON parse error (/json|parse/i matches it). The adapter answers only because manifestOutsideAdapterProject collapses .. lexically with path.resolve before realpathSync (tools/dbt-lineage.ts:145), which is the opposite of the resolver this repo documents in Filesystem.realLocation (symlink first, then ..). The placement helper and the graph reader therefore disagree, so the test fails; if the manifest mtime cache ever hides the rewrite, it stops proving which file was read. Resolve the manifest path once, with the same symlink-aware .. semantics the reader uses, and use that one path for both the placement check and manifestGraph.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The assertion holds; this file passes locally at 315685b (16 pass). In loadRawManifest, existsSync only checks that the path exists. The file it reads is fs.realpathSync(manifestPath), and realpathSync collapses .. before it follows links.

  • I probed the same layout (mine/out -> other/deep, other/target/manifest.json invalid, mine/target/manifest.json = {}).
  • On mine/out/../target/manifest.json, Bun and Node both give: realpathSync -> mine/target/manifest.json, read through it -> {}. Only a raw readFileSync of the unresolved path returns { not json.
  • So the graph reader opens this project's manifest, and the placement check (path.resolve, then realpathSync) places it in the same file. That agreement is what this test locks in: if loadRawManifest ever read the raw path instead, graph_error would carry the parse error and the test would fail.
  • I tried Filesystem.realLocation in 5b67069, and it made the check disagree with this reader, so it was reverted in 6d71706.

hkaltai and others added 6 commits November 3, 2026 18:30
…, and a column reach that stopped early reports lower bounds

- IDENTIFIER('col') with a traced column, or a bind variable, makes the model uncertain and passes the doubt on, as a NATURAL JOIN does
- the text report says "at least" when column reach stopped early, and the exposures past the stop are unchecked, not non-readers
- the model parameter says a snapshot is found by name too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lookup is bounded like the NATURAL JOIN one

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a quoted name with a dot is never split

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the column, so no escape can hide one

Escapes (\x72), backslash-escaped quotes and other characters are not decoded; such a string marks
the model uncertain, which only adds doubt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ming another column is cleared

Concatenations, bind and session variables, functions and unterminated strings all count as possibly
naming the column, so no argument form can produce a confident zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

1 similar comment
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 5 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

const others = (data.exposures ?? []).filter((e) => !listed.has(e.unique_id))
if (others.length > 0) {
// When column reach stopped early, the rest are unchecked rather than known not to read the column.
lines.push(`${data.columnReach ? (data.columnReach.truncated ? "Other exposures (column reach stopped before checking them)" : "Other exposures") : "Exposures"} (${others.length})`)

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]: This header mislabels exposures column reach did check

When truncated is set, others is every exposure not listed by the column reach block, which includes exposures on models column reach did trace before the cut and found not to read the column. Those are known non-readers, so "column reach stopped before checking them" is wrong for them. The Column Reach line already ends with (stopped early); keeping the plain Other exposures label stays accurate for the whole list and avoids the doubled parenthetical (… (…) (N)).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in b84dd26 and a36f4be. You're right that some of them were checked. The header now says "Other exposures (N)", with ": column reach stopped early, so some of these may not have been checked" after the count, only when it stopped early. That keeps the doubled parenthetical out, and still doesn't present the exposures past the stop as known non-readers. The "(stopped early)" on the Column Reach line covers the counts above it, not this list.

// The column named as a string the engine cannot trace: its outputs are unknown, like a NATURAL JOIN's.
const viaIdentifier = namesThroughIdentifier(
sqlText,
parents.flatMap((parent) => [...tracked.get(parent)!]),

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]: The parent-column union is built twice

parents.flatMap((parent) => [...tracked.get(parent)!]) is computed here for namesThroughIdentifier and again, unchanged, in the named expression below (const named = parents.flatMap(...)). Hoisting it once (const trackedColumns = parents.flatMap(...)) and using it at both sites keeps the two scans over the same parents from drifting and removes the duplicate allocation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in b84dd26: trackedColumns is built once and used by both namesThroughIdentifier and named. Nothing writes a parent's entry between the two uses, so this is a pure refactor.

hkaltai and others added 2 commits November 3, 2026 19:00
…her exposures are not all called unchecked

- uncertain and untraced counts say "at least" too when column reach stopped early
- the other exposures' header says some of them were not checked, after the count
- the parents' traced columns are built once

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecked

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

1 similar comment
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@sahrizvi sahrizvi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review at a36f4be: approved. MAJOR 1 is fixed. I found no critical or major issues, and the open minors below don't block.

Earlier findings:

  • MAJOR 1 (an unreadable manifest_path threw away the adapter's answer): fixed.
    • A path that doesn't exist yet is placed through its nearest existing folder.
    • An in-project path keeps the adapter's answer. A missing outside path overrides only when a dbt_project.yml sits above it.
    • dbt-lineage-structured.test.ts:155-246 covers this with real files and symlinks.
  • MINOR 1 (whole-row reads): partly fixed, the rest reasonably deferred. NATURAL JOIN in every form is now uncertain and passes its doubt on; I checked this with the real engine. to_json(o) and md5(cast(o as varchar)) still give a confident non-reader, but the code and the PR body now name that gap.
  • MINOR 2 (snapshots in downstream/exposures): reasonably deferred. It is pre-existing and listed in the follow-ups.
  • Nits: the include_graph note, count_big/count_if, the backtick alias, unique_id and byPath are fixed. Cycles, the typo'd column, tests beyond the graph cap and synapse are reasonably deferred.

New minors (inline), neither blocks:

  • select * from identifier('<parent relation>') is a confident non-reader. Blocker: no. It is an edge input, since dbt models reach parents through ref(), and it belongs to the whole-row family the PR already documents.
  • Manifest placement re-implements Filesystem.realLocation. Blocker: no. Today's behaviour matches what the manifest reader opens, so this is maintainability and safe to do in a follow-up.

The 6 PR test files pass at a36f4be (120 tests). The stopped-early report is truthful: every count says "at least", the other-exposures header says some were not checked, and truncated is in the structured result. Nits are in a separate PR comment.

// Any mention of the column in the string counts (qualified, quoted, with dots in a quoted name);
// a doubled quote inside a quoted name (`"a""b"`) is one quote.
const text = literal.replace(/""/g, '"').toLowerCase()
if ([...wanted].some((column) => text.includes(column))) return true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor: select * from identifier('<parent relation>') gives a confident non-reader

namesThroughIdentifier clears any IDENTIFIER() whose literal is a plain name that doesn't contain the traced column. That also covers the relation form, where IDENTIFIER names the parent table.

  • The engine can't resolve that relation, so it reports no edge.
  • selectsStar is true, but the parent documents the column, so wildcard is false.
  • The SQL never names the column, and opaque is false.

So the model lands in none of models/uncertain/untraced, and neither do its children and dashboards.

Real-engine probe on the test manifest (snowflake): rpt_counts with either SQL below is reported nowhere.

  • select * from identifier('"shop"."main"."fct_orders"')
  • select o.* from identifier('shop.main.fct_orders') o

Fix: treat an IDENTIFIER() right after FROM/JOIN as opaque, or any IDENTIFIER() when selectsStar(sql) is true. Alternatively, clear only a literal that has no dot and matches none of the parents' relationKeys. Add a test for the relation form.

Not blocking: it's an edge input, since dbt models reach parents through ref(), which compiles to a plain relation. It's the same whole-row family the PR already lists as a known gap.

// followed to where it points. `..` is collapsed first, as the manifest reader (loadRawManifest's
// realpathSync) does, so this places the file that reader opens. Undefined past 64 links (a loop);
// climbing to the nearest existing folder is not counted.
const real = (p: string) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor: manifest placement re-implements Filesystem.realLocation

5b67069 switched this to Filesystem.realLocation. 6d71706 then replaced it with this local loop, which collapses .. lexically to match loadRawManifest's realpathSync. Today the placement does match the file the reader opens: I checked <proj>/link/../target/manifest.json under bun and node, and the tests pass.

Still, the repo now has two link resolvers that disagree:

  • Link budget: 64 here, 40 in realLocation.
  • .. handling: lexical here, the kernel's in realLocation.
  • Loop behaviour: this one returns undefined, realLocation throws.
  • Windows: this one skips the windowsPath normalisation that external-directory.ts applies before calling realLocation.

One edge follows from the reader itself. loadRawManifest's own existsSync is physical, so take <proj>/link/../target/manifest.json where only the link's physical target exists: placement says "inside", and the graph read fails with ENOENT. That surfaces as graph_error, not wrong data, and the helper predates this PR.

Fix (follow-up is fine): add a lexical-.. option to the shared helper, or resolve the path once and hand that same resolved path to both the placement check and loadRawManifest. Then a single resolver decides both. The 5b67069 commit message also no longer describes the code.

Not blocking: the behaviour is correct today; this is maintainability.

@sahrizvi

sahrizvi commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

Re-review nits for a36f4be. None of these block.

  • IDENTIFIER substring match. namesThroughIdentifier matches with includes, so tracking id matches identifier('customer_id'). It fails safe (uncertain).
  • NATURAL JOIN on raw SQL. This is by design and fails safe, but over-reports:
    • "natural join" inside a comment or string, or more than 100 "natural" words (a CASE over 'natural gas'), marks the model and every descendant uncertain.
    • More than 10,000 characters between NATURAL and JOIN hides the join. The code documents this.
    • Cost is bounded: crafted 1 MB inputs (100 unclosed /*, whitespace runs, -- runs, unclosed IDENTIFIER strings) take at most 0.3 s.
  • Link loop. A link loop at the manifest path inside the project ("cannot be placed") throws away the adapter's answer. This is pathological input.
  • "at least 0" wording. When column reach stops early having found no readers, the report says "read by at least 0 models and at least 0 exposures (stopped early)". That's true. "None found among the first 300 traced; the rest were not traced" would be harder to misread.
  • Fail-safe text heuristics. stripSql doesn't treat \' as an escape, and namesColumn doesn't skip an alias written without AS (count(x) revenue). Both only add uncertainty, never a false certain.
  • Tests.
    • Nothing drives the MAX_MODELS=300 stop through columnReach into formatImpactReport. The "at least" text is tested only with a hand-built truncated: true.
    • The impact_analysis tool tests mock dbt.manifest, so no test runs downstream and column_reach over one real manifest file.
    • The column-reach tests do run the real engine over real manifest files, and the placement tests use real symlinks.

@hkaltai
hkaltai merged commit e0dc3da into feat/dbt-lineage-structured-result Nov 4, 2026
20 checks passed
ralphstodomingo added a commit that referenced this pull request Nov 4, 2026
…ctured content (#1637)

`dbt_lineage` returns the dbt integration's model lineage as structured content, and a read-only call answers from the manifest without starting the dbt integration. Includes #1635: structured lineage and column reach for `dbt_lineage` and `impact_analysis`, with the review fixes for answering from the session's own dbt project and honouring a cancel while the project is read.

Closes #1636

Co-authored-by: Harish Kumar <harish@altimate.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNQjkcTQBy2pEa6qoBZQfb
sahrizvi added a commit that referenced this pull request Nov 4, 2026
main was merged into the release branch, bringing #1637 (with #1635): structured `dbt_lineage`, column reach in `impact_analysis`, and read-only `dbt_lineage` calls answering from the manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XM3J9mB75wZRnzTSGcW2kc
@sahrizvi sahrizvi mentioned this pull request Nov 4, 2026
7 of 8 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants