Visitar URL original
feat(review): consumers outside dbt, engine-aware integer division, opt-in contract and column-list checks by RaghwendraSingh · Pull Request #1724 · AltimateAI/altimate-code · GitHub
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/docs/usage/dbt-pr-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,8 @@ riskTierPathTokens: # OFF by default — see "Risk-tier path tokens" b
finops: [preset:finops] # promote any FinOps-named path to `full` tier
pii: [ssn, email, phone] # or supply your own case-insensitive tokens
allowForceTier: true # set false in gate CI: --force-tier is then rejected
checkContractColumns: false # opt-in: new columns missing from an enforced contract (CI that doesn't run `dbt build`)
consumerColumnLists: [] # OFF by default — see "Consumers outside dbt" below
rubric:
blockOn: [lineage_breakage, contract_violation, pii_exposure, semantic_change]
warningPatternThreshold: 3
Expand Down Expand Up @@ -303,6 +305,33 @@ rubric:
review is dismissed only by a later run that completed: when an engine call
or a file failed, the earlier block stays.

### Consumers outside dbt

A column removed or renamed can break readers the dbt DAG doesn't show. With a
manifest, the review reports:

- **Exposures and contracts.** Removing a column from a model that has
`exposures` (dashboards, apps, ML) or an enforced contract names those
consumers and suggests a deprecation period or a new model version.
Downstream dbt models that select the column are reported separately, with
the models named.
- **Contract vs `SELECT` (opt-in).** A new column missing from an enforced
contract fails `dbt build`, so this is off by default. Turn it on with
`checkContractColumns: true` when CI runs only `dbt parse` or `dbt compile`.
A column the PR declares in a changed yml counts as declared even when the
manifest predates the PR. It comments; it never blocks.
- **Explicit column lists (opt-in).** Some projects copy a model's columns
into code outside dbt: an export or reverse-ETL script, a feature list, an
API schema. Name those files and the list's pattern, and when a PR adds or
removes a model's columns, each matching list must follow.

```yaml
consumerColumnLists:
- files: "exports/**/*.py" # repo-relative glob
pattern: "COLUMNS\\s*=\\s*\\[([^\\]]*)\\]" # first capture group = the list
model: fct_orders # omit: files that mention the model
```

### What the PR comment shows

- **One summary comment**, updated in place on every push, capped at about
Expand Down Expand Up @@ -448,6 +477,15 @@ The deterministic rules group into four families:
cartesian (`JOIN` without a condition), `NOT IN (… NULL)`, sum-of-a-ratio,
non-portable dialect functions, integer-multiplication overflow, PK-aware
join fan-out, timezone-in-hash-key.
- **Engine-dependent rules read an adapter table.** Where behavior differs by
engine, a rule checks the project's adapter (from the manifest or `dialect`)
instead of assuming one. Example: `int / int` (and `count(…) / count(…)`)
truncates on Postgres, Redshift, Trino/Athena and SQL Server, so it is a
correctness finding there. On Snowflake, BigQuery, Databricks and DuckDB it is
only portability advice, shown for packages (`portability: true`).
- **dbt unit tests.** A unit test whose `given` inputs are all `rows: []` is
reported: it either never tests the model's logic, or expects rows the model has nothing to
build from.
- **dbt config / Jinja (`DBT0xx`).** A core **minijinja** parser reads
`{{ config(...) }}` and the model body: incremental model without an
`is_incremental()` guard, `merge`/`delete+insert` without a `unique_key`,
Expand Down
79 changes: 79 additions & 0 deletions packages/opencode/src/altimate/review/adapter-capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Per-adapter behavior that review rules depend on. A rule whose correctness
* differs by engine reads this table instead of hard-coding one engine, so it
* fires where the behavior is real and stays silent elsewhere. Every entry
* cites the engine's documentation. An adapter missing from the table gets the
* generic (engine-agnostic) form of each rule only.
*
* Keys are the review's dialect names (see run.ts `ADAPTER_DIALECT`).
*/

export interface AdapterCapability {
/** `int / int` returns an integer, dropping the fraction (7 / 2 = 3). */
integerDivisionTruncates: boolean
/** Documentation backing the entries above. */
sources: string[]
}

export const ADAPTER_CAPABILITIES: Record<string, AdapterCapability> = {
postgres: {
integerDivisionTruncates: true,
sources: ["https://www.postgresql.org/docs/current/functions-math.html"],
},
redshift: {
integerDivisionTruncates: true,
sources: ["https://docs.aws.amazon.com/redshift/latest/dg/r_numeric_computations201.html"],
},
trino: {
integerDivisionTruncates: true,
sources: ["https://trino.io/docs/current/functions/math.html"],
},
athena: {
// Athena SQL runs on Trino.
integerDivisionTruncates: true,
sources: ["https://trino.io/docs/current/functions/math.html"],
},
tsql: {
integerDivisionTruncates: true,
sources: ["https://learn.microsoft.com/en-us/sql/t-sql/language-elements/divide-transact-sql"],
},
fabric: {
integerDivisionTruncates: true,
sources: ["https://learn.microsoft.com/en-us/sql/t-sql/language-elements/divide-transact-sql"],
},
snowflake: {
integerDivisionTruncates: false,
sources: ["https://docs.snowflake.com/en/sql-reference/operators-arithmetic"],
},
bigquery: {
integerDivisionTruncates: false,
sources: ["https://cloud.google.com/bigquery/docs/reference/standard-sql/operators#arithmetic_operators"],
},
databricks: {
integerDivisionTruncates: false,
sources: ["https://docs.databricks.com/en/sql/language-manual/functions/slashsign.html"],
},
duckdb: {
integerDivisionTruncates: false,
sources: ["https://duckdb.org/docs/sql/functions/numeric"],
},
mysql: {
integerDivisionTruncates: false,
sources: ["https://dev.mysql.com/doc/refman/8.0/en/arithmetic-functions.html"],
},
oracle: {
integerDivisionTruncates: false,
sources: ["https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/Arithmetic-Operators.html"],
},
clickhouse: {
integerDivisionTruncates: false,
sources: ["https://clickhouse.com/docs/en/sql-reference/functions/arithmetic-functions"],
},
}

export function capabilityFor(dialect: string | undefined): AdapterCapability | undefined {
return dialect ? ADAPTER_CAPABILITIES[dialect.toLowerCase()] : undefined
}

/** Display names of the engines where `int / int` truncates. */
export const TRUNCATING_DIVISION_ENGINES = "Postgres, Redshift, Trino/Athena and SQL Server"
15 changes: 15 additions & 0 deletions packages/opencode/src/altimate/review/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ export const ReviewConfig = z.object({
* Functions missing on the project's own engine are always reported.
*/
portability: z.boolean().optional(),
/**
* Opt-in: report a new column missing from an enforced contract. `dbt build`
* already fails on it, so this only helps CI that runs `dbt parse`/`compile`
* without building.
*/
checkContractColumns: z.boolean().default(false),
/**
* Opt-in: explicit column lists outside dbt (loaders, exports) that must
* track a model's columns. `files` is a repo-relative glob, `pattern` a regex
* whose first capture group is the list, `model` the model it mirrors
* (omitted = files that mention the model by name).
*/
consumerColumnLists: z
.array(z.object({ files: z.string(), pattern: z.string(), model: z.string().optional() }))
.default([]),
/** Allow `--force-tier` (a debug bypass of the risk classifier). Set false
* in gate CI so a run cannot skip checks by forcing a lower tier. */
allowForceTier: z.boolean().default(true),
Expand Down
88 changes: 88 additions & 0 deletions packages/opencode/src/altimate/review/consumer-lists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { execFile } from "node:child_process"
import { promisify } from "node:util"

/**
* Opt-in: explicit column lists outside dbt (loaders, exports, reverse-ETL
* configs) that must stay in step with a model's columns. A project names the
* files and a regex whose first capture group holds the list in
* `.altimate/review.yml`; the review then reports model columns a list is
* missing, and list entries the model no longer selects. Off unless configured,
* since no such convention is universal.
*/

export interface ConsumerColumnListConfig {
/** Repository-relative glob of the files holding the lists. */
files: string
/** Regex whose first capture group is the column list. */
pattern: string
/** The model the lists mirror; omitted = files that mention the model by name. */
model?: string
}

export interface ConsumerColumnList {
path: string
model?: string
columns: string[]
/** File text, to match lists to models by name when `model` is omitted. */
content: string
}

const exec = promisify(execFile)
const IDENT = /^[A-Za-z_][A-Za-z0-9_$]*$/

/** Column names from every match of `pattern` in `content` (lowercased, de-duplicated). */
export function extractColumnList(content: string, pattern: RegExp): string[] {
const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g"
const re = new RegExp(pattern.source, flags)
const out = new Set<string>()
for (const m of content.matchAll(re)) {
for (const token of (m[1] ?? "").split(/[,\s]+/)) {
const name = token.replace(/^["'`]+|["'`]+$/g, "")
if (IDENT.test(name)) out.add(name.toLowerCase())
}
}
return [...out]
}

/** True when a list belongs to `model`: named explicitly, or the file mentions the model. */
export function listMirrorsModel(list: ConsumerColumnList, model: string): boolean {
if (list.model) return list.model.toLowerCase() === model.toLowerCase()
return new RegExp(`\\b${model.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(list.content)
}

/**
* Read the configured lists at the head (`head` ref, or the working tree).
* An invalid pattern is reported on stderr and skipped, never fatal.
*/
export async function loadConsumerLists(opts: {
configs: ConsumerColumnListConfig[]
gitRoot: string
head?: string
read: (repoRelPath: string) => Promise<string | undefined>
}): Promise<ConsumerColumnList[]> {
const out: ConsumerColumnList[] = []
for (const cfg of opts.configs) {
let re: RegExp
try {
re = new RegExp(cfg.pattern, "g")
} catch (e) {
process.stderr.write(`⚠️ consumerColumnLists: invalid pattern ${JSON.stringify(cfg.pattern)} (${String(e)}); skipped.\n`)
continue
}
const pathspec = `:(glob)${cfg.files}`
const args = opts.head ? ["ls-tree", "-r", "--name-only", opts.head, "--", pathspec] : ["ls-files", "--", pathspec]
let files: string[] = []
try {
files = (await exec("git", args, { cwd: opts.gitRoot, maxBuffer: 16 * 1024 * 1024 })).stdout.split("\n").filter(Boolean)
} catch {
files = []
}
for (const file of files) {
const content = await opts.read(file)
if (!content) continue
const columns = extractColumnList(content, re)
if (columns.length) out.push({ path: file, model: cfg.model, columns, content })
}
}
return out
}
11 changes: 8 additions & 3 deletions packages/opencode/src/altimate/review/dbt-patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Log } from "@/altimate/util/log"
import { type Finding, type Severity, type ReviewCategory, makeFinding } from "./finding"
import { type ChangedFile, fileKind } from "./diff-filter"
import { type Rubric, clampSeverity, exclusionReason } from "./rubric"
import { evaluateCatalog } from "./rule-catalog"
import { evaluateCatalog, type CatalogContext } from "./rule-catalog"

/** Local copy to avoid a cycle with orchestrate.ts. */
function modelNameFromPath(p: string): string {
Expand Down Expand Up @@ -765,7 +765,12 @@ const MODEL_DETECTORS: Detector[] = [
]

/** Run the dbt anti-pattern detectors over a changed MODEL file. */
export function detectModelPatterns(file: ChangedFile, newSql: string | undefined, rubric: Rubric): Finding[] {
export function detectModelPatterns(
file: ChangedFile,
newSql: string | undefined,
rubric: Rubric,
catalogCtx?: CatalogContext,
): Finding[] {
const kind = fileKind(file)
if (!isModelSql(kind) || file.status === "deleted" || !newSql) return []
const { added, removed } = splitDiff(file.diff)
Expand All @@ -784,7 +789,7 @@ export function detectModelPatterns(file: ChangedFile, newSql: string | undefine
if (f) out.push({ ...f, severity: clampSeverity(f.category, f.severity, f.confidence) })
}
// Declarative rule catalog (data-driven checks) complements the programmatic detectors.
out.push(...evaluateCatalog(file, newSql, added, removed, rubric))
out.push(...evaluateCatalog(file, newSql, added, removed, rubric, catalogCtx))
return out.filter((f) => !exclusionReason(f, rubric))
}

Expand Down
Loading
Loading