Skip to content

Commit 04bdbc4

Browse files
anandgupta42claude
andcommitted
fix: [AI-673] address PR #674 review comments (CodeRabbit + cubic)
Address review comments from CodeRabbit and cubic on PR #674. Bug fixes: - `resolveUpstream` now resolves seed.* and snapshot.* deps in addition to models and sources. Previously silently dropped, causing `dbt test` to fail for any model ref()ing a seed or snapshot. - Test name truncation no longer cuts off scenario suffix. Budget suffix length first, then truncate the model-name portion, so scenario names like `_null_handling_2` are always preserved even for long model names. - `findModel`/`getUniqueId` in helpers.ts now validate `resource_type === "model"` on the key lookup path, not just the name fallback. Prevents returning non-model nodes by unique_id. - Division detection regex now strips string literals AND comments before matching, so `'2024/01/15'` no longer triggers a false-positive boundary scenario. Documentation fixes: - `incremental-testing.md`: fix Jinja syntax — `{{ if is_incremental() }}` is invalid; use `{% if is_incremental() %}` for control flow. - `SKILL.md`: workflow header now shows all 5 phases (Analyze -> Generate -> Refine -> Validate -> Write). - `SKILL.md`: add language label to fenced code block for markdownlint. - `unit-test-yaml-spec.md`: show both top-level `tags` and nested `config.tags` forms explicitly. Infrastructure: - Add `seeds` and `snapshots` arrays to `DbtManifestResult` (previously only counts were returned). `parseManifest` now extracts full seed and snapshot info using the same shape as `DbtModelInfo`. - Tests migrate to shared `tmpdir()` fixture from `test/fixture/fixture.ts` for automatic cleanup (per project coding guidelines). - Wrap `DbtUnitTestGenTool` import/registration in `registry.ts` with `altimate_change` markers (upstream-shared file). New tests (4): - seed deps resolve via ref() not source() - snapshot deps resolve via ref() not source() - long model names preserve scenario suffix (no truncation collision) - division in string literals does not trigger boundary scenario Full suite: 2676 pass, 0 fail. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c4f075f commit 04bdbc4

9 files changed

Lines changed: 179 additions & 55 deletions

File tree

.opencode/skills/dbt-unit-tests/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ description: Generate dbt unit tests automatically for any model. Analyzes SQL l
3333
4. **Never weaken a test to make it pass.** If the test fails, the model logic may be wrong. Investigate before changing expected values.
3434
5. **Compile before committing.** Always run `altimate-dbt test --model <name>` to verify tests compile and execute.
3535

36-
## Core Workflow: Analyze -> Generate -> Refine -> Validate
36+
## Core Workflow: Analyze -> Generate -> Refine -> Validate -> Write
3737

3838
### Phase 1: Analyze the Model
3939

@@ -61,7 +61,7 @@ dbt_unit_test_gen(manifest_path: "target/manifest.json", model: "<name>")
6161

6262
The `dbt_unit_test_gen` tool does the heavy lifting:
6363

64-
```
64+
```text
6565
dbt_unit_test_gen(
6666
manifest_path: "target/manifest.json",
6767
model: "fct_orders",

.opencode/skills/dbt-unit-tests/references/incremental-testing.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
# Testing Incremental dbt Models
22

3-
Incremental models have two code paths controlled by `{{ if is_incremental() }}`. Both paths must be tested.
3+
Incremental models have two code paths controlled by `{% if is_incremental() %}`. Both paths must be tested.
44

55
## The Two Paths
66

77
```sql
8-
-- Full refresh path (is_incremental = false)
98
SELECT * FROM {{ ref('stg_orders') }}
109

11-
-- Incremental path (is_incremental = true)
12-
SELECT * FROM {{ ref('stg_orders') }}
13-
WHERE updated_at > (SELECT MAX(updated_at) FROM {{ this }})
10+
{% if is_incremental() %}
11+
-- Incremental path: only process new rows
12+
WHERE updated_at > (SELECT MAX(updated_at) FROM {{ this }})
13+
{% endif %}
1414
```
1515

1616
## Test 1: Full Refresh

.opencode/skills/dbt-unit-tests/references/unit-test-yaml-spec.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,24 @@ Used with `overrides.macros.is_incremental: true` to mock the existing table sta
131131

132132
## Configuration
133133

134+
Tags can be set at the top level (sibling of `config`) or nested under `config`:
135+
134136
```yaml
135-
config:
136-
tags: ["unit-test", "revenue"]
137+
unit_tests:
138+
- name: test_example
139+
model: fct_orders
140+
tags: ["unit-test", "revenue"]
141+
# ... rest of test
142+
```
143+
144+
Or via config:
145+
146+
```yaml
147+
unit_tests:
148+
- name: test_example
149+
model: fct_orders
150+
config:
151+
tags: ["unit-test", "revenue"]
137152
```
138153

139154
## Naming Conventions

packages/opencode/src/altimate/native/dbt/helpers.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,10 @@ export function loadRawManifest(manifestPath: string): any | null {
4141

4242
/**
4343
* Find a model node in the manifest by name or unique_id.
44+
* Only returns nodes where resource_type === "model".
4445
*/
4546
export function findModel(nodes: Record<string, any>, model: string): any | null {
46-
if (model in nodes) return nodes[model]
47+
if (model in nodes && nodes[model]?.resource_type === "model") return nodes[model]
4748
for (const [, node] of Object.entries(nodes)) {
4849
if (node.resource_type !== "model") continue
4950
if (node.name === model) return node
@@ -53,9 +54,10 @@ export function findModel(nodes: Record<string, any>, model: string): any | null
5354

5455
/**
5556
* Get the unique_id for a model (by name or unique_id lookup).
57+
* Only matches nodes where resource_type === "model".
5658
*/
5759
export function getUniqueId(nodes: Record<string, any>, model: string): string | undefined {
58-
if (model in nodes) return model
60+
if (model in nodes && nodes[model]?.resource_type === "model") return model
5961
for (const [nodeId, node] of Object.entries(nodes)) {
6062
if (node.resource_type === "model" && node.name === model) return nodeId
6163
}

packages/opencode/src/altimate/native/dbt/manifest.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export async function parseManifest(params: DbtManifestParams): Promise<DbtManif
3232
models: [],
3333
sources: [],
3434
tests: [],
35+
seeds: [],
36+
snapshots: [],
3537
source_count: 0,
3638
model_count: 0,
3739
test_count: 0,
@@ -70,37 +72,34 @@ export async function parseManifest(params: DbtManifestParams): Promise<DbtManif
7072

7173
const models: DbtModelInfo[] = []
7274
const tests: DbtTestInfo[] = []
75+
const seeds: DbtModelInfo[] = []
76+
const snapshots: DbtModelInfo[] = []
7377
let testCount = 0
74-
let snapshotCount = 0
75-
let seedCount = 0
7678

7779
for (const [nodeId, node] of Object.entries<any>(nodes)) {
7880
const resourceType = node.resource_type
7981

80-
if (resourceType === "model") {
81-
const dependsOnNodes = node.depends_on?.nodes || []
82-
const columns = extractColumns(node.columns || {})
83-
models.push({
82+
if (resourceType === "model" || resourceType === "seed" || resourceType === "snapshot") {
83+
const info: DbtModelInfo = {
8484
unique_id: nodeId,
8585
name: node.name || "",
8686
description: node.description || undefined,
8787
schema_name: node.schema || undefined,
8888
database: node.database || undefined,
8989
materialized: node.config?.materialized || undefined,
90-
depends_on: dependsOnNodes,
91-
columns,
92-
})
90+
depends_on: node.depends_on?.nodes || [],
91+
columns: extractColumns(node.columns || {}),
92+
}
93+
if (resourceType === "model") models.push(info)
94+
else if (resourceType === "seed") seeds.push(info)
95+
else snapshots.push(info)
9396
} else if (resourceType === "test") {
9497
testCount++
9598
tests.push({
9699
unique_id: nodeId,
97100
name: node.name || "",
98101
depends_on: node.depends_on?.nodes || [],
99102
})
100-
} else if (resourceType === "snapshot") {
101-
snapshotCount++
102-
} else if (resourceType === "seed") {
103-
seedCount++
104103
}
105104
}
106105

@@ -122,11 +121,13 @@ export async function parseManifest(params: DbtManifestParams): Promise<DbtManif
122121
models,
123122
sources,
124123
tests,
124+
seeds,
125+
snapshots,
125126
source_count: sources.length,
126127
model_count: models.length,
127128
test_count: testCount,
128-
snapshot_count: snapshotCount,
129-
seed_count: seedCount,
129+
snapshot_count: snapshots.length,
130+
seed_count: seeds.length,
130131
adapter_type: manifest.metadata?.adapter_type || undefined,
131132
}
132133
}

packages/opencode/src/altimate/native/dbt/unit-tests.ts

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ interface UpstreamDep {
7373
schema_name?: string
7474
database?: string
7575
description?: string
76-
resource_type: "model" | "source"
76+
resource_type: "model" | "source" | "seed" | "snapshot"
7777
materialized?: string
7878
columns: ModelColumn[]
7979
}
@@ -82,23 +82,30 @@ function resolveUpstream(
8282
upstreamIds: string[],
8383
models: DbtModelInfo[],
8484
sources: DbtSourceInfo[],
85+
seeds: DbtModelInfo[],
86+
snapshots: DbtModelInfo[],
8587
): UpstreamDep[] {
86-
const modelMap = new Map(models.map((m) => [m.unique_id, m]))
88+
// Map each unique_id to its info + resource_type.
89+
// Seeds, snapshots, and models all use ref() so they share handling.
90+
const typedMap = new Map<string, { info: DbtModelInfo; kind: "model" | "seed" | "snapshot" }>()
91+
for (const m of models) typedMap.set(m.unique_id, { info: m, kind: "model" })
92+
for (const s of seeds) typedMap.set(s.unique_id, { info: s, kind: "seed" })
93+
for (const s of snapshots) typedMap.set(s.unique_id, { info: s, kind: "snapshot" })
8794
const sourceMap = new Map(sources.map((s) => [s.unique_id, s]))
8895

8996
const result: UpstreamDep[] = []
9097
for (const uid of upstreamIds) {
91-
const model = modelMap.get(uid)
92-
if (model) {
98+
const entry = typedMap.get(uid)
99+
if (entry) {
93100
result.push({
94101
unique_id: uid,
95-
name: model.name,
96-
schema_name: model.schema_name,
97-
database: model.database,
98-
description: model.description,
99-
resource_type: "model",
100-
materialized: model.materialized,
101-
columns: model.columns,
102+
name: entry.info.name,
103+
schema_name: entry.info.schema_name,
104+
database: entry.info.database,
105+
description: entry.info.description,
106+
resource_type: entry.kind,
107+
materialized: entry.info.materialized,
108+
columns: entry.info.columns,
102109
})
103110
continue
104111
}
@@ -120,6 +127,7 @@ function resolveUpstream(
120127
}
121128

122129
function depRef(dep: UpstreamDep): string {
130+
// Models, seeds, and snapshots all use ref(); only sources use source()
123131
return dep.resource_type === "source"
124132
? `source('${dep.source_name}', '${dep.name}')`
125133
: `ref('${dep.name}')`
@@ -204,8 +212,10 @@ export async function generateDbtUnitTests(
204212
warnings.push("Column lineage analysis failed — generating tests without lineage context")
205213
}
206214

207-
// 5. Resolve upstream deps from parsed manifest data (no raw nodes access)
208-
const upstreamDeps = resolveUpstream(model.depends_on, manifest.models, manifest.sources)
215+
// 5. Resolve upstream deps (models, sources, seeds, snapshots)
216+
const upstreamDeps = resolveUpstream(
217+
model.depends_on, manifest.models, manifest.sources, manifest.seeds, manifest.snapshots,
218+
)
209219
const materialized = model.materialized || "view"
210220

211221
// 6. Enrich columns from warehouse (parallel, best-effort)
@@ -320,8 +330,13 @@ interface Scenario {
320330
* directly for nuanced logic analysis. This just determines the scaffold.
321331
*/
322332
function detectScenarios(sql: string, materialized: string): Scenario[] {
323-
// Strip SQL comments to avoid false positives (e.g., "-- old/code" matching division)
324-
const cleaned = sql.replace(/--.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "")
333+
// Strip SQL comments AND string literals to avoid false positives
334+
// (e.g., "-- old/code", "'2024/01/15'", "'a/b'" matching division).
335+
const cleaned = sql
336+
.replace(/--.*$/gm, "") // line comments
337+
.replace(/\/\*[\s\S]*?\*\//g, "") // block comments
338+
.replace(/'(?:[^'\\]|\\.|'')*'/g, "''") // single-quoted strings
339+
.replace(/"(?:[^"\\]|\\.|"")*"/g, '""') // double-quoted identifiers/strings
325340
const upper = cleaned.toUpperCase()
326341
const scenarios: Scenario[] = [
327342
{ category: "happy_path", description: "Verify correct output for standard input data", mockStyle: "happy_path", rowCount: 2 },
@@ -406,9 +421,14 @@ function buildTests(
406421
maxScenarios: number,
407422
): UnitTestCase[] {
408423
return scenarios.slice(0, maxScenarios).map((scenario, idx) => {
409-
const testName = sanitizeName(
410-
`test_${modelName}_${scenario.category}${idx > 0 ? `_${idx}` : ""}`,
411-
)
424+
// Build the scenario suffix first, then truncate the model-name portion
425+
// so the suffix is always preserved (prevents collisions for long names).
426+
const suffix = `_${scenario.category}${idx > 0 ? `_${idx}` : ""}`
427+
const prefix = "test_"
428+
const maxLen = 64
429+
const modelBudget = maxLen - prefix.length - suffix.length
430+
const truncatedModel = modelName.length > modelBudget ? modelName.slice(0, Math.max(1, modelBudget)) : modelName
431+
const testName = sanitizeName(`${prefix}${truncatedModel}${suffix}`)
412432

413433
const given: UnitTestMockInput[] = deps.map((dep) => {
414434
const input = depRef(dep)

packages/opencode/src/altimate/native/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,10 @@ export interface DbtManifestResult {
200200
models: DbtModelInfo[]
201201
sources: DbtSourceInfo[]
202202
tests: DbtTestInfo[]
203+
/** Seeds parsed from the manifest (extracted like models for ref() resolution) */
204+
seeds: DbtModelInfo[]
205+
/** Snapshots parsed from the manifest (extracted like models for ref() resolution) */
206+
snapshots: DbtModelInfo[]
203207
source_count: number
204208
model_count: number
205209
test_count: number

packages/opencode/src/tool/registry.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ import { WarehouseDiscoverTool } from "../altimate/tools/warehouse-discover"
4747
import { McpDiscoverTool } from "../altimate/tools/mcp-discover"
4848

4949
import { DbtManifestTool } from "../altimate/tools/dbt-manifest"
50+
// altimate_change start - import dbt unit test generation tool
5051
import { DbtUnitTestGenTool } from "../altimate/tools/dbt-unit-test-gen"
52+
// altimate_change end
5153
import { DbtProfilesTool } from "../altimate/tools/dbt-profiles"
5254
import { DbtLineageTool } from "../altimate/tools/dbt-lineage"
5355
import { SchemaIndexTool } from "../altimate/tools/schema-index"
@@ -224,7 +226,9 @@ export namespace ToolRegistry {
224226
// altimate_change end
225227

226228
DbtManifestTool,
229+
// altimate_change start - register dbt unit test generation tool
227230
DbtUnitTestGenTool,
231+
// altimate_change end
228232
DbtProfilesTool,
229233
DbtLineageTool,
230234
SchemaIndexTool,

0 commit comments

Comments
 (0)