Skip to content

Commit d1e8419

Browse files
authored
test: altimate tools — impact analysis DAG traversal and training import parsing (#384)
* test: altimate tools — impact analysis DAG traversal and training import markdown parsing New tests for two recently added tools (impact_analysis, training_import) that had zero test coverage. These tools are user-facing and incorrect behavior leads to wrong blast radius assessments or silent data loss when importing team standards. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> https://claude.ai/code/session_01H7d93hQP5qAwYhLgRdkqhV * test: address critic review — boundary tests, tighter assertions, constant import - Add severity boundary tests at exact thresholds (3→LOW, 4→MEDIUM, 10→MEDIUM, 11→HIGH) - Fix blast radius test to verify model_count drives denominator, not models.length - Import TRAINING_MAX_PATTERNS_PER_KIND instead of hardcoding 48 - Document that parseMarkdownSections includes empty sections (potential future fix) - Tighten empty-section assertion from toBeGreaterThanOrEqual to exact toBe https://claude.ai/code/session_01H7d93hQP5qAwYhLgRdkqhV
1 parent 11a27fe commit d1e8419

File tree

2 files changed

+555
-0
lines changed

2 files changed

+555
-0
lines changed
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
/**
2+
* Tests for the impact_analysis tool — DAG traversal, severity classification,
3+
* and report formatting.
4+
*
5+
* Mocks Dispatcher.call to supply known dbt manifests so we can verify
6+
* findDownstream logic without a real napi binary or dbt project.
7+
*/
8+
import { describe, test, expect, spyOn, afterAll, beforeEach } from "bun:test"
9+
import * as Dispatcher from "../../src/altimate/native/dispatcher"
10+
import { ImpactAnalysisTool } from "../../src/altimate/tools/impact-analysis"
11+
import { SessionID, MessageID } from "../../src/session/schema"
12+
13+
// Disable telemetry
14+
beforeEach(() => {
15+
process.env.ALTIMATE_TELEMETRY_DISABLED = "true"
16+
})
17+
afterAll(() => {
18+
delete process.env.ALTIMATE_TELEMETRY_DISABLED
19+
})
20+
21+
const ctx = {
22+
sessionID: SessionID.make("ses_test"),
23+
messageID: MessageID.make("msg_test"),
24+
callID: "call_test",
25+
agent: "build",
26+
abort: AbortSignal.any([]),
27+
messages: [],
28+
metadata: () => {},
29+
ask: async () => {},
30+
}
31+
32+
// Spy on Dispatcher.call so we control what "dbt.manifest" and "lineage.check" return
33+
let dispatcherSpy: ReturnType<typeof spyOn>
34+
35+
function mockDispatcher(responses: Record<string, any>) {
36+
dispatcherSpy?.mockRestore()
37+
dispatcherSpy = spyOn(Dispatcher, "call").mockImplementation(async (method: string, params: any) => {
38+
if (responses[method]) return responses[method]
39+
throw new Error(`No mock for ${method}`)
40+
})
41+
}
42+
43+
afterAll(() => {
44+
dispatcherSpy?.mockRestore()
45+
})
46+
47+
describe("impact_analysis: empty / missing manifest", () => {
48+
test("reports NO MANIFEST when manifest has no models", async () => {
49+
mockDispatcher({
50+
"dbt.manifest": { models: [], model_count: 0, test_count: 0 },
51+
})
52+
const tool = await ImpactAnalysisTool.init()
53+
const result = await tool.execute(
54+
{ model: "stg_orders", change_type: "remove", manifest_path: "target/manifest.json", dialect: "snowflake" },
55+
ctx,
56+
)
57+
expect(result.title).toContain("NO MANIFEST")
58+
expect(result.metadata.success).toBe(false)
59+
expect(result.output).toContain("dbt compile")
60+
})
61+
62+
test("reports MODEL NOT FOUND when model is absent", async () => {
63+
mockDispatcher({
64+
"dbt.manifest": {
65+
models: [{ name: "dim_customers", depends_on: [], materialized: "table" }],
66+
model_count: 1,
67+
test_count: 0,
68+
},
69+
})
70+
const tool = await ImpactAnalysisTool.init()
71+
const result = await tool.execute(
72+
{ model: "stg_orders", change_type: "remove", manifest_path: "target/manifest.json", dialect: "snowflake" },
73+
ctx,
74+
)
75+
expect(result.title).toContain("MODEL NOT FOUND")
76+
expect(result.metadata.success).toBe(false)
77+
expect(result.output).toContain("dim_customers")
78+
})
79+
})
80+
81+
describe("impact_analysis: DAG traversal", () => {
82+
const linearDAG = {
83+
models: [
84+
{ name: "stg_orders", depends_on: [], materialized: "view" },
85+
{ name: "int_orders", depends_on: ["stg_orders"], materialized: "ephemeral" },
86+
{ name: "fct_orders", depends_on: ["int_orders"], materialized: "table" },
87+
{ name: "rpt_daily", depends_on: ["fct_orders"], materialized: "table" },
88+
],
89+
model_count: 4,
90+
test_count: 5,
91+
}
92+
93+
test("finds direct and transitive dependents in a linear chain", async () => {
94+
mockDispatcher({ "dbt.manifest": linearDAG })
95+
const tool = await ImpactAnalysisTool.init()
96+
const result = await tool.execute(
97+
{ model: "stg_orders", change_type: "remove", manifest_path: "target/manifest.json", dialect: "snowflake" },
98+
ctx,
99+
)
100+
expect(result.metadata.success).toBe(true)
101+
expect(result.metadata.direct_count).toBe(1) // int_orders
102+
expect(result.metadata.transitive_count).toBe(2) // fct_orders, rpt_daily
103+
expect(result.output).toContain("int_orders")
104+
expect(result.output).toContain("fct_orders")
105+
expect(result.output).toContain("rpt_daily")
106+
expect(result.output).toContain("BREAKING")
107+
})
108+
109+
test("SAFE severity when no downstream models exist", async () => {
110+
mockDispatcher({ "dbt.manifest": linearDAG })
111+
const tool = await ImpactAnalysisTool.init()
112+
// rpt_daily is a leaf — nothing depends on it
113+
const result = await tool.execute(
114+
{ model: "rpt_daily", change_type: "modify", manifest_path: "target/manifest.json", dialect: "snowflake" },
115+
ctx,
116+
)
117+
expect(result.metadata.success).toBe(true)
118+
expect(result.metadata.severity).toBe("SAFE")
119+
expect(result.metadata.direct_count).toBe(0)
120+
expect(result.output).toContain("safe to make")
121+
})
122+
123+
test("diamond dependency counts each model only once", async () => {
124+
mockDispatcher({
125+
"dbt.manifest": {
126+
models: [
127+
{ name: "src", depends_on: [], materialized: "view" },
128+
{ name: "left", depends_on: ["src"], materialized: "table" },
129+
{ name: "right", depends_on: ["src"], materialized: "table" },
130+
{ name: "merge", depends_on: ["left", "right"], materialized: "table" },
131+
],
132+
model_count: 4,
133+
test_count: 0,
134+
},
135+
})
136+
const tool = await ImpactAnalysisTool.init()
137+
const result = await tool.execute(
138+
{ model: "src", change_type: "rename", manifest_path: "target/manifest.json", dialect: "snowflake" },
139+
ctx,
140+
)
141+
expect(result.metadata.success).toBe(true)
142+
// left, right are direct; merge is transitive. merge must appear only once.
143+
expect(result.metadata.direct_count).toBe(2)
144+
expect(result.metadata.transitive_count).toBe(1)
145+
// Total = 3, which is LOW severity
146+
expect(result.metadata.severity).toBe("LOW")
147+
})
148+
149+
test("handles dotted depends_on references (e.g. project.model)", async () => {
150+
mockDispatcher({
151+
"dbt.manifest": {
152+
models: [
153+
{ name: "stg_users", depends_on: [], materialized: "view" },
154+
{ name: "dim_users", depends_on: ["myproject.stg_users"], materialized: "table" },
155+
],
156+
model_count: 2,
157+
test_count: 0,
158+
},
159+
})
160+
const tool = await ImpactAnalysisTool.init()
161+
const result = await tool.execute(
162+
{ model: "stg_users", change_type: "retype", manifest_path: "target/manifest.json", dialect: "snowflake" },
163+
ctx,
164+
)
165+
expect(result.metadata.success).toBe(true)
166+
expect(result.metadata.direct_count).toBe(1)
167+
expect(result.output).toContain("dim_users")
168+
expect(result.output).toContain("CAUTION") // retype warning
169+
})
170+
})
171+
172+
describe("impact_analysis: severity classification", () => {
173+
function makeManifest(downstreamCount: number) {
174+
const models = [{ name: "root", depends_on: [] as string[], materialized: "view" }]
175+
for (let i = 0; i < downstreamCount; i++) {
176+
models.push({ name: `model_${i}`, depends_on: ["root"], materialized: "table" })
177+
}
178+
return { models, model_count: models.length, test_count: 0 }
179+
}
180+
181+
test("LOW severity boundary: exactly 3 downstream models", async () => {
182+
mockDispatcher({ "dbt.manifest": makeManifest(3) })
183+
const tool = await ImpactAnalysisTool.init()
184+
const result = await tool.execute(
185+
{ model: "root", change_type: "modify", manifest_path: "target/manifest.json", dialect: "snowflake" },
186+
ctx,
187+
)
188+
expect(result.metadata.severity).toBe("LOW")
189+
})
190+
191+
test("MEDIUM severity boundary: exactly 4 downstream models", async () => {
192+
mockDispatcher({ "dbt.manifest": makeManifest(4) })
193+
const tool = await ImpactAnalysisTool.init()
194+
const result = await tool.execute(
195+
{ model: "root", change_type: "modify", manifest_path: "target/manifest.json", dialect: "snowflake" },
196+
ctx,
197+
)
198+
expect(result.metadata.severity).toBe("MEDIUM")
199+
})
200+
201+
test("MEDIUM severity boundary: exactly 10 downstream models", async () => {
202+
mockDispatcher({ "dbt.manifest": makeManifest(10) })
203+
const tool = await ImpactAnalysisTool.init()
204+
const result = await tool.execute(
205+
{ model: "root", change_type: "modify", manifest_path: "target/manifest.json", dialect: "snowflake" },
206+
ctx,
207+
)
208+
expect(result.metadata.severity).toBe("MEDIUM")
209+
})
210+
211+
test("HIGH severity boundary: exactly 11 downstream models", async () => {
212+
mockDispatcher({ "dbt.manifest": makeManifest(11) })
213+
const tool = await ImpactAnalysisTool.init()
214+
const result = await tool.execute(
215+
{ model: "root", change_type: "modify", manifest_path: "target/manifest.json", dialect: "snowflake" },
216+
ctx,
217+
)
218+
expect(result.metadata.severity).toBe("HIGH")
219+
})
220+
})
221+
222+
describe("impact_analysis: error handling", () => {
223+
test("returns ERROR when Dispatcher throws", async () => {
224+
mockDispatcher({}) // no mock for dbt.manifest — will throw
225+
const tool = await ImpactAnalysisTool.init()
226+
const result = await tool.execute(
227+
{ model: "x", change_type: "remove", manifest_path: "target/manifest.json", dialect: "snowflake" },
228+
ctx,
229+
)
230+
expect(result.title).toContain("ERROR")
231+
expect(result.metadata.success).toBe(false)
232+
expect(result.output).toContain("dbt compile")
233+
})
234+
})
235+
236+
describe("impact_analysis: blast radius percentage", () => {
237+
test("percentage uses model_count, not models array length", async () => {
238+
// model_count (20) intentionally differs from models.length (4)
239+
// to verify the denominator comes from the declared count
240+
mockDispatcher({
241+
"dbt.manifest": {
242+
models: [
243+
{ name: "root", depends_on: [], materialized: "view" },
244+
{ name: "child1", depends_on: ["root"], materialized: "table" },
245+
{ name: "child2", depends_on: ["root"], materialized: "table" },
246+
{ name: "unrelated", depends_on: [], materialized: "view" },
247+
],
248+
model_count: 20,
249+
test_count: 3,
250+
},
251+
})
252+
const tool = await ImpactAnalysisTool.init()
253+
const result = await tool.execute(
254+
{ model: "root", change_type: "remove", manifest_path: "target/manifest.json", dialect: "snowflake" },
255+
ctx,
256+
)
257+
// 2 downstream out of 20 declared = 10.0%
258+
expect(result.output).toContain("10.0%")
259+
expect(result.output).toContain("2/20")
260+
})
261+
})

0 commit comments

Comments
 (0)