diff --git a/.opencode/command/dbt-health-config.md b/.opencode/command/dbt-health-config.md new file mode 100644 index 000000000..bccb45eb9 --- /dev/null +++ b/.opencode/command/dbt-health-config.md @@ -0,0 +1,25 @@ +--- +description: infer a dbt health-check config from the project's own conventions and write .altimate/dbt-health.yml +agent: build +subtask: true +--- + +Generate a dbt health-check configuration by inferring it from a dbt project's **existing conventions** (deterministic — no guessing). + +## Target project + +Use `$ARGUMENTS` as the dbt project directory if provided. Otherwise, locate the dbt project in the current worktree by finding the directory that contains a `dbt_project.yml` (run `project_scan` if you need to discover it). If there are **multiple** dbt projects, do this for **each** one — every project gets its own `.altimate/dbt-health.yml`. + +## Steps + +1. For each target project directory, call the **`dbt_health_config`** tool: + `dbt_health_config({ project_dir: "" })` + This deterministically infers the config from the project (modal tags / meta keys / tests, configured schemas & databases, naming contracts, distribution-ratcheted thresholds) and writes it to `/.altimate/dbt-health.yml`. + +2. Then run **`dbt_project_health`** on the same directory: + `dbt_project_health({ project_dir: "" })` + It auto-discovers the freshly written `.altimate/dbt-health.yml`, so the report now reflects the inferred rules. + +3. Summarize for the user: the path(s) written, which config-driven checks were activated (tags, meta keys, tests, naming contracts, parent schema/database whitelists, thresholds), and the resulting finding counts by severity. Tell them the file is committed with the project and can be hand-edited — re-running this command regenerates it deterministically. + +Do not fabricate values; everything comes from the two tools above. diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 173bd0e1b..d45dc1261 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -78,7 +78,7 @@ "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", "@ai-sdk/xai": "3.0.82", - "@altimateai/altimate-core": "0.5.1", + "@altimateai/altimate-core": "0.8.0", "@altimateai/drivers": "workspace:*", "@aws-sdk/credential-providers": "3.1057.0", "@clack/prompts": "1.0.0-alpha.1", diff --git a/packages/opencode/src/altimate/index.ts b/packages/opencode/src/altimate/index.ts index f6dae26fc..d6894bdfe 100644 --- a/packages/opencode/src/altimate/index.ts +++ b/packages/opencode/src/altimate/index.ts @@ -40,6 +40,8 @@ export * from "./tools/altimate-core-validate" export * from "./tools/dbt-lineage" export * from "./tools/dbt-manifest" export * from "./tools/dbt-profiles" +export * from "./tools/dbt-project-health" +export * from "./tools/dbt-health-config" export * from "./tools/finops-analyze-credits" export * from "./tools/finops-expensive-queries" diff --git a/packages/opencode/src/altimate/native/altimate-core.ts b/packages/opencode/src/altimate/native/altimate-core.ts index 137e28d84..65e073cfc 100644 --- a/packages/opencode/src/altimate/native/altimate-core.ts +++ b/packages/opencode/src/altimate/native/altimate-core.ts @@ -267,6 +267,23 @@ export function registerAll(): void { return fail(e) } }) + // dbt project health checks — reads the dbt project files directly (no manifest required). + register("altimate_core.dbt_project_health", async (params) => { + try { + const json = core.dbtProjectHealth(params.project_dir, params.config_json, params.catalog_path) + return ok(true, { findings: JSON.parse(json) }) + } catch (e) { + return fail(e) + } + }) + // Deterministically infer a dbt health-check config (YAML) from a project's conventions. + register("altimate_core.dbt_health_infer_config", async (params) => { + try { + return ok(true, { config: core.dbtHealthInferConfig(params.project_dir) }) + } catch (e) { + return fail(e) + } + }) // altimate_change end // 7. altimate_core.fix diff --git a/packages/opencode/src/altimate/native/types.ts b/packages/opencode/src/altimate/native/types.ts index ad90c7631..1176bafe5 100644 --- a/packages/opencode/src/altimate/native/types.ts +++ b/packages/opencode/src/altimate/native/types.ts @@ -1244,6 +1244,14 @@ export const BridgeMethods = { params: { base_sql: string; head_sql: string } result: AltimateCoreResult }, + "altimate_core.dbt_project_health": {} as { + params: { project_dir: string; config_json?: string; catalog_path?: string } + result: AltimateCoreResult + }, + "altimate_core.dbt_health_infer_config": {} as { + params: { project_dir: string } + result: AltimateCoreResult + }, // altimate_change end // --- altimate-core Phase 1 (P0) --- "altimate_core.fix": {} as { params: AltimateCoreFixParams; result: AltimateCoreResult }, diff --git a/packages/opencode/src/altimate/tools/dbt-health-config.ts b/packages/opencode/src/altimate/tools/dbt-health-config.ts new file mode 100644 index 000000000..139fed334 --- /dev/null +++ b/packages/opencode/src/altimate/tools/dbt-health-config.ts @@ -0,0 +1,55 @@ +import { mkdir } from "node:fs/promises" +import { dirname } from "node:path" +import z from "zod" +import { Tool } from "../../tool/tool" +import { Dispatcher } from "../native" + +/** Relative path (from the dbt project root) where the inferred config is written. */ +export const DBT_HEALTH_CONFIG_RELPATH = ".altimate/dbt-health.yml" + +export const DbtHealthConfigTool = Tool.define("dbt_health_config", { + description: + "Deterministically infer a dbt health-check config from a dbt project's EXISTING conventions (modal tags/meta keys/tests, configured schemas & databases, naming contracts, distribution-ratcheted thresholds) and write it to /.altimate/dbt-health.yml. This activates the config-driven governance checks (which are no-ops until configured). Per-project: each dbt project gets its own config file. Reproducible — no LLM, same project always yields the same file.", + parameters: z.object({ + project_dir: z.string().describe("Path to the dbt project root (the directory containing dbt_project.yml)"), + write: z + .boolean() + .optional() + .describe("Write the config to /.altimate/dbt-health.yml (default true). If false, only return the YAML."), + }), + async execute(args, _ctx) { + const result = await Dispatcher.call("altimate_core.dbt_health_infer_config", { + project_dir: args.project_dir, + }) + + if (!result.success) { + const error = result.error ?? "unknown error" + return { + title: "dbt health config: ERROR", + metadata: { error }, + output: `Failed to infer dbt health config: ${error}`, + } + } + + const yaml = String((result.data as { config?: unknown }).config ?? "") + const outPath = `${args.project_dir.replace(/\/+$/, "")}/${DBT_HEALTH_CONFIG_RELPATH}` + + let written = false + if (args.write !== false) { + await mkdir(dirname(outPath), { recursive: true }) + await Bun.write(outPath, yaml) + written = true + } + + const checkCount = (yaml.match(/^ {2}\S/gm) ?? []).length + return { + title: written + ? `dbt health config written (${checkCount} checks tuned) → ${outPath}` + : `dbt health config inferred (${checkCount} checks tuned)`, + metadata: { path: outPath, written, project_dir: args.project_dir }, + output: written + ? `Wrote inferred config to ${outPath}:\n\n${yaml}` + : `Inferred config (not written):\n\n${yaml}`, + } + }, +}) diff --git a/packages/opencode/src/altimate/tools/dbt-project-health.ts b/packages/opencode/src/altimate/tools/dbt-project-health.ts new file mode 100644 index 000000000..dab9e6e49 --- /dev/null +++ b/packages/opencode/src/altimate/tools/dbt-project-health.ts @@ -0,0 +1,100 @@ +import z from "zod" +import { Tool } from "../../tool/tool" +import { Dispatcher } from "../native" + +/** A single finding returned by `altimate_core.dbt_project_health` (Rust `HealthFinding`). */ +interface HealthFinding { + code: string + alias: string + resource_type: "model" | "source" | "exposure" | "macro" | "project" + unique_id?: string + file?: string + severity: "error" | "warning" | "info" + message: string + recommendation?: string + reason_to_flag?: string + metadata?: unknown +} + +const SEVERITY_ORDER: Record = { error: 0, warning: 1, info: 2 } + +export const DbtProjectHealthTool = Tool.define("dbt_project_health", { + description: + "Run dbt project health checks (governance, modelling, documentation, tests, sources) directly against a dbt project's files — no compiled manifest.json required. Reads .sql models (via the dbt Jinja AST parser), schema.yml properties and dbt_project.yml. Optionally uses a catalog.json for column-level checks; otherwise columns are inferred from the SQL parser.", + parameters: z.object({ + project_dir: z.string().describe("Path to the dbt project root (the directory containing dbt_project.yml)"), + config_path: z + .string() + .optional() + .describe("Optional path to a JSON health-check config (disabled checks, severity overrides, options)"), + catalog_path: z + .string() + .optional() + .describe("Optional path to a dbt catalog.json to power column-aware checks"), + }), + async execute(args, _ctx) { + // Explicit config_path wins; otherwise auto-discover a per-project inferred config + // at /.altimate/dbt-health.{yml,yaml,json} (YAML or JSON both parse). + let config_json: string | undefined + const candidates = args.config_path + ? [args.config_path] + : [ + `${args.project_dir.replace(/\/+$/, "")}/.altimate/dbt-health.yml`, + `${args.project_dir.replace(/\/+$/, "")}/.altimate/dbt-health.yaml`, + `${args.project_dir.replace(/\/+$/, "")}/.altimate/dbt-health.json`, + ] + for (const candidate of candidates) { + const file = Bun.file(candidate) + if (await file.exists()) { + config_json = await file.text() + break + } + } + + const result = await Dispatcher.call("altimate_core.dbt_project_health", { + project_dir: args.project_dir, + config_json, + catalog_path: args.catalog_path, + }) + + if (!result.success) { + const error = result.error ?? "unknown error" + return { + title: "dbt health: ERROR", + metadata: { error }, + output: `Failed to run dbt health checks: ${error}`, + } + } + + const findings = ((result.data.findings as HealthFinding[] | undefined) ?? []).slice() + findings.sort( + (a, b) => (SEVERITY_ORDER[a.severity] ?? 3) - (SEVERITY_ORDER[b.severity] ?? 3) || a.code.localeCompare(b.code), + ) + + const counts = { error: 0, warning: 0, info: 0 } as Record + for (const f of findings) counts[f.severity] = (counts[f.severity] ?? 0) + 1 + + return { + title: `dbt health: ${findings.length} finding(s) — ${counts.error} error, ${counts.warning} warning, ${counts.info} info`, + metadata: { + finding_count: findings.length, + error_count: counts.error, + warning_count: counts.warning, + info_count: counts.info, + }, + output: formatFindings(findings), + } + }, +}) + +function formatFindings(findings: HealthFinding[]): string { + if (findings.length === 0) return "✓ No dbt health issues found." + const lines: string[] = [] + for (const f of findings) { + const where = f.file ? ` (${f.file})` : f.unique_id ? ` (${f.unique_id})` : "" + lines.push(`[${f.severity.toUpperCase()}] ${f.code} ${f.alias}${where}`) + lines.push(` ${f.message}`) + if (f.recommendation) lines.push(` → ${f.recommendation}`) + } + return lines.join("\n") +} diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 570bbd03c..349246582 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -69,6 +69,8 @@ import { WarehouseDiscoverTool } from "../altimate/tools/warehouse-discover" import { McpDiscoverTool } from "../altimate/tools/mcp-discover" import { DbtManifestTool } from "../altimate/tools/dbt-manifest" +import { DbtProjectHealthTool } from "../altimate/tools/dbt-project-health" +import { DbtHealthConfigTool } from "../altimate/tools/dbt-health-config" // altimate_change start - import dbt unit test generation tool import { DbtUnitTestGenTool } from "../altimate/tools/dbt-unit-test-gen" // altimate_change end @@ -405,6 +407,8 @@ export namespace ToolRegistry { DbtUnitTestGenTool, // altimate_change end DbtProfilesTool, + DbtProjectHealthTool, + DbtHealthConfigTool, DbtLineageTool, SchemaIndexTool, SchemaSearchTool,