altimate-code 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -0
- package/README.md +1 -1
- package/dbt-tools/dist/index.js +210 -26
- package/package.json +15 -11
- package/skills/dbt-develop/SKILL.md +274 -1
- package/skills/dbt-pr-review/SKILL.md +74 -0
- package/skills/dbt-schema-verify/SKILL.md +146 -0
- package/skills/dbt-unit-tests/SKILL.md +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,86 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.8.0] - 2026-06-01
|
|
9
|
+
|
|
10
|
+
Headlined by **dbt PR Review** — a Cloudflare-style, dbt/SQL-specialized code reviewer that emits a single **signed** verdict (`APPROVE` / `COMMENT` / `REQUEST_CHANGES`) where every *blocking* finding is backed by a deterministic `altimate-core` engine call over parsed SQL ASTs, not a model's opinion. An optional LLM lane adds advisory context but can never block. This release also adds a **native Trino driver**, the opt-in **completion-gate validators**, and reliability/cost fixes. A five-persona pre-release review drove a security hardening pass on the new `reviewer` agent (see Security).
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **dbt PR Review — signed, deterministic verdicts on dbt pull requests.** New `altimate review` CLI command and a composite GitHub Action (`github/review`). The deterministic engine (column-lineage / DAG blast radius, query equivalence on before/after model SQL, PII classification, A–F grade + anti-pattern lint) is the **only** layer that can block; an optional LLM reviewer is clamped to ≤ warning and excluded from the gate, so a `REQUEST_CHANGES` is always provable and replayable. Runs in CI with **zero warehouse access** (consumes `dbt compile` artifacts), and the verdict is HMAC-signable and tamper-evident. `comment` mode never blocks; `gate` mode fails the check on `REQUEST_CHANGES`. The advisory model/credentials are configured on the Action (hosted `altimate_api_key`, or bring-your-own `model` + `model_api_key`); omit them to run deterministic-only. See [dbt PR Review docs](https://docs.altimate.sh/usage/dbt-pr-review/) and the copy-paste workflow in `github/review/examples/`. Depends on `@altimateai/altimate-core` ≥ 0.4.0. (#856)
|
|
15
|
+
- **Native Trino driver.** First-class Trino support over HTTP(S) with catalog/schema introspection and None / Basic / Bearer-token auth. **Migration note:** the dbt `trino` adapter previously mapped to the PostgreSQL driver; it now uses the native driver. Existing profiles are auto-aliased (`database` → `catalog`, `token` → `access_token`) and otherwise compatible. `trino-client` is an optional dependency — install it (`npm install trino-client`) to use Trino. (#795)
|
|
16
|
+
- **Completion-gate validator framework.** A new opt-in harness-side check
|
|
17
|
+
that runs after the LLM declares `finish === "stop"`. Two built-in
|
|
18
|
+
validators for dbt projects: `dbt-tests-pass` (runs `altimate-dbt test`
|
|
19
|
+
against modified models) and `dbt-schema-verify` (runs `altimate-dbt
|
|
20
|
+
schema-verify` against modified models). On failure, the framework
|
|
21
|
+
injects a synthetic user turn so the agent gets one more chance to fix
|
|
22
|
+
the issue, bounded by a per-session retry budget. Two opt-in modes:
|
|
23
|
+
`ALTIMATE_VALIDATORS_ENABLED=1` (enforcement + retries) and
|
|
24
|
+
`ALTIMATE_VALIDATORS_SHADOW=1` (telemetry-only — measure "would have
|
|
25
|
+
caught" rates without blocking). Default is **off** with zero overhead.
|
|
26
|
+
Two new telemetry events (`validator_check`, `validator_retries_exhausted`).
|
|
27
|
+
Configuration via `ALTIMATE_VALIDATORS_{MAX_RETRIES,TIMEOUT_MS,CONCURRENCY,DEBUG}`.
|
|
28
|
+
See [Validators docs](https://docs.altimate.sh/data-engineering/validators/)
|
|
29
|
+
for the full reference, performance characteristics, and the phased
|
|
30
|
+
rollout plan. (#849)
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **`cancel()` race / idle-on-clean-exit.** A cancel arriving during normal loop teardown could leave a session without a `session.status:idle` event, leaving callers waiting on idle stuck. Idle is now emitted correctly on both the abort and clean-exit paths. (#845)
|
|
35
|
+
- **Prompt caching on the `altimate-backend` provider.** Enabled the `cache_control` trigger for the hosted provider so the litellm Anthropic fallback caches repeated prompt prefixes — cheaper and faster repeat turns (no-op on providers that ignore the marker). (#850)
|
|
36
|
+
|
|
37
|
+
### Security
|
|
38
|
+
|
|
39
|
+
- **`reviewer` agent hardened to deny bash.** The v0.8.0 pre-release review found that the new `reviewer` agent — advertised as "read-only" — had a bash allowlist (`git log *`, `cat *`, `ls *`) that was bypassable: shell redirects rode *inside* a matched command (`git log -p > ~/.ssh/authorized_keys` was allowed) and `cat *` could read arbitrary files (e.g. `~/.altimate/altimate.json`) and exfiltrate them through the PR comment the agent posts. Bash is now **denied** for the reviewer (it uses the structured `read`/`grep`/`glob` tools + the verdict engine, which does its own diffing); the agent description was corrected; and the reviewer prompt now treats PR content as untrusted input. The CI Action path was never affected — its LLM lane runs with no tools. (#856)
|
|
40
|
+
|
|
41
|
+
## [0.7.3] - 2026-05-24
|
|
42
|
+
|
|
43
|
+
A telemetry-driven hardening release. Five P0 fixes merged from a `telemetry-analysis-2026-05-21` pass — every one tied to a measured failure number from the App Insights pipeline. The headline wins are user-visible: `finops_*` tools now work without an explicit `warehouse=` parameter (auto-pick the first compatible connection); `project_scan` no longer crashes on hosts where `git` isn't in PATH (the silent 437-user regression was masked by the PII filter collapsing the binary name to `?` in error messages); and `webfetch` caches 404/410/451 responses for up to 30 minutes so the agent stops re-asking dead URLs. Two telemetry-only fixes (build-agent name normalization, Anthropic token-count semantics) clean up dashboard mis-bucketing without changing user-visible behavior.
|
|
44
|
+
|
|
45
|
+
A five-persona pre-release review drove a second hardening pass: HTTPS basic-auth credentials are now stripped from `git remote get-url origin` output before it reaches LLM-visible metadata or session transcripts; raw `git` stderr is routed through `Telemetry.maskString` to redact embedded emails and bearer tokens; `normalizeAgentName` gained C0 control-char stripping + NFKC normalization + a 64-char cap to neutralize log-injection and homoglyph-bucket attacks via custom agent configs; the webfetch failure cache now also drops auth-bearing query params (`token`, `api_key`, `signature`, `x-amz-signature`, etc.) from the cache key so presigned-URL secrets can't sit in an in-memory `Map` for 30 minutes; cache-hit error messages are prefixed with `(cached failure, Nm ago)` so an agent debugging a fast failure can tell cache vs. live network apart; and 34 adversarial tests pin every one of these regression classes plus the `warehouse_filter` / `warehouse` parameter disambiguation that caused LLM confusion.
|
|
46
|
+
|
|
47
|
+
### Dashboard query change
|
|
48
|
+
|
|
49
|
+
`tokens_input_total` is now **always** emitted on `generation` telemetry events (previously omitted when zero/null). Dashboards reading `tokens_input` to compute volume will under-report on Anthropic-cache-hit sessions — switch any "total input tokens consumed" queries to `tokens_input_total`. The old `tokens_input` field is preserved and reports uncached input tokens (clamped at zero so inconsistent provider counts can't produce negative cost). The two fields satisfy the invariant `tokens_input + tokens_cache_read + tokens_cache_write === tokens_input_total`.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- **`finops_*` tools auto-pick a warehouse when omitted.** All six finops tools (`finops_analyze_credits`, `finops_expensive_queries`, `finops_query_history`, `finops_role_*`, `finops_unused_resources`, `finops_warehouse_advice`) previously required an explicit `warehouse=<connection-name>` argument. Telemetry showed them at a 100% error rate because the LLM consistently guessed unconfigured warehouse names and got dead-end "Credit analysis is not available for unknown warehouses" errors with no enumeration of what *was* configured. The new `resolveFinopsWarehouse` helper centralizes resolution: if no warehouse is requested, it auto-picks the first configured connection whose driver type supports the operation (`snowflake` / `bigquery` / `databricks` for credit/sizing/usage tools; broader for query history; Snowflake-only for role-hierarchy). Errors now enumerate configured warehouse names and point at `warehouse_add` when nothing is configured. Trim is applied on the requested name (LLM whitespace edge cases) and type matching is case-insensitive (`"Snowflake"` / `"snowflake"` / `"SNOWFLAKE"` resolve identically). (#828, closes #827)
|
|
54
|
+
- **`project_scan` no longer crashes when `git` is missing from PATH.** Telemetry showed a 32% failure rate across 437 users with the masked error `"Executable not found in $PATH: ?"` — the binary name was being collapsed to `?` by the PII filter, hiding the real cause for two months. `Bun.spawnSync` *throws* when a binary isn't found (not just non-zero exit), and `project_scan` was running the throw unguarded. The new `safeSpawnSync` wrapper catches it cleanly and returns `null`, and `detectGit` distinguishes three states via `GitInfo.gitAvailable` + `gitError`: `git missing` (binary not in PATH — surface install hint), `git present but degraded` (non-128 exit code — corrupted `.git`, permission denied; surfaces exit code + masked stderr), and `git present, not a repo` (clean exit 128 — render "Not a git repository"). The `metadata.degraded` array always emits even when empty, sorted and deduplicated, so dashboard queries never have to null-coalesce. (#831, closes #830)
|
|
55
|
+
- **Build agent telemetry no longer reports 0% completion.** A rename from `"build"` → `"builder"` in the agent registry left three writer sites still emitting the legacy name: the TUI sent `agent: "build"` on session start, the plan-exit synthetic message wrote `lastUser.agent = "build"`, and the `agent_outcome` event read from that. The agent execution alias at `Agent.get()` masked the issue at runtime, but every dashboard saw a phantom `"build"` bucket with 0% completion alongside the real `"builder"` bucket. The new `normalizeAgentName` helper is the single source of truth — used by both `session_start` and `agent_outcome` emits so they can never drift, case-insensitive against the legacy name, plus C0-control-char strip + NFKC + 64-char cap so a malicious or hand-edited config can't inject newlines into App Insights or create a cardinality bomb. The TUI and plan tool now write `"builder"` directly. (#833, closes #832)
|
|
56
|
+
- **Anthropic `tokens_input` is no longer always 0 in telemetry.** `Session.getUsage` previously assumed OpenAI-style accounting (where `inputTokens` is the inclusive total including cached) — but Anthropic returns `inputTokens` exclusive of cached, so the subtraction `inputTokens - cacheRead - cacheWrite` produced 0 (or negative) on every cache-hit session. The new branch tests `metadata.anthropic || metadata.bedrock` and skips the subtraction for those providers. `tokens_input_total` is now always emitted as the inclusive total (so dashboards have a clean denominator), and `tokens.input` is clamped at zero — inconsistent provider counts (e.g. `inputTokens=1000, cachedInputTokens=2000`) can no longer leak negative cost into App Insights or session totals. Venice metadata path (`metadata.venice.usage.cacheCreationInputTokens`) is now covered with the same treatment as Anthropic/Bedrock. (#837, closes #836)
|
|
57
|
+
- **`webfetch` failure cache now persists known-bad URLs for 30 minutes (5 min for 451) and collapses LLM-generated URL variations.** Telemetry showed 486 residual webfetch 404 retries across the 14-day window (down from March's 2,222 but still meaningful) — the LLM kept fetching the same dead URL with different tracking parameters, fragments, and case variations and getting fresh 404s each time. The cache key is now the *normalized* URL: tracking params (`utm_*`, `fbclid`, `gclid`, `mc_*`, `_ga`, `_gl`, `igshid`, `mibextid`, HubSpot `_hsenc`/`_hsmi`, Marketo `mkt_tok`, Adobe `s_cid`/`s_kwcid`, Piwik/Matomo `pk_*`/`piwik_*`, `__cf_chl_*`) are stripped, query params are sorted by (key, value), the host is lowercased, the fragment is dropped, and HTTPS basic-auth userinfo (`user:pass@`) is removed. The functional `ref` param is deliberately *preserved* (GitHub raw URLs and git APIs use it to select branches/tags). Auth-bearing query params (`token`, `access_token`, `api_key`, `apikey`, `signature`, `sig`, `x-amz-signature`, `x-amz-credential`, `x-amz-security-token`, `x-goog-signature`, `x-goog-credential`) are dropped from the **cache key only** — the actual `fetch()` still uses the raw URL — so an S3 presigned URL that 404s doesn't leak its signature into the in-memory `Map` for 30 minutes. 451 uses a shorter 5-min TTL because it's observer-conditional (VPN swap unblocks). Cache-hit errors now prefix with `(cached failure, Nm ago)` so an agent debugging a fast failure can tell cache vs. live network apart. LRU re-touch on repeated failures via `delete-then-set` so a frequently-failing URL doesn't sit at the head of the FIFO and get evicted first under pressure (cache size cap is 500). (#839, closes #838)
|
|
58
|
+
|
|
59
|
+
### Privacy & Security (pre-release hardening pass)
|
|
60
|
+
|
|
61
|
+
- **`git remote get-url origin` output is now credential-stripped before it reaches LLM-visible metadata or session transcripts.** Pre-fix, `metadata.git.remoteUrl` shipped the URL verbatim — including embedded HTTPS basic-auth like `https://x-access-token:ghp_xxx@github.com/...` or `https://user:pass@gitlab.internal/...` — which then flowed to the LLM provider on every `project_scan` call and was preserved in the session transcript. The new `stripGitRemoteCredentials` helper sets `username` and `password` to empty on parseable URLs, preserves SSH-form remotes (`git@github.com:owner/repo.git` has no userinfo concept), and drops unparseable URLs entirely rather than risk leaking embedded creds. Flagged by the v0.7.3 compliance/chaos review.
|
|
62
|
+
- **`gitError.stderr` is masked via `Telemetry.maskString` before persisting.** Git stderr on auth failures and bad-object errors contains embedded HTTPS remote URLs (with userinfo), commit emails, and `/Users/<realname>/` paths. The masker (already redacting `sk-`, `Bearer`, and `<email>` in error strings elsewhere) now runs before the stderr is sliced to 240 chars and assigned to `metadata.git.gitError.stderr`. Mask-before-slice order is pinned by adversarial test so a refactor can't reorder them and surface a half-redacted email.
|
|
63
|
+
- **`normalizeAgentName` hardens against adversarial input.** Strips C0 control characters (`\x00-\x1f` + DEL) — a custom-agent config with `name: "x\nfake_event=hello"` would have split App Insights into two events; NFKC-normalizes so fullwidth/homoglyph agent names (`"builder"`) don't create separate telemetry buckets; caps length at 64 chars because agent names are slugs and anything larger is a cardinality bomb. Three new pinning tests guarantee each invariant.
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
|
|
67
|
+
- **`warehouse_filter` parameter description disambiguated from `warehouse`.** With `warehouse` now optional, the previous description ("Filter to a specific Snowflake warehouse") was ambiguous next to the connection-name `warehouse` parameter. New description: "Filter result rows by an in-warehouse Snowflake compute name (NOT the connection name). Distinct from `warehouse`: `warehouse` picks which connection to query; `warehouse_filter` narrows which Snowflake virtual warehouse's queries are returned in the result set. Snowflake only." Applied to both `finops_analyze_credits` and `finops_query_history`. End-user persona flagged the LLM-confusion risk during pre-release review.
|
|
68
|
+
- **`DEFAULT_FINOPS_TYPES` is the canonical "supported by any cloud warehouse" type list.** Pre-fix, three handlers (`credit-analyzer`, `unused-resources`, `warehouse-advisor`) each declared the same `["snowflake", "bigquery", "databricks"]` literal as a local `const`. Now exported from `warehouse-resolver.ts`; adding a fourth driver (e.g. Redshift `account_usage`) is one edit, not three.
|
|
69
|
+
- **`docs/docs/data-engineering/tools/finops-tools.md` updated for the auto-pick behavior.** Pre-fix, the doc said `warehouse (required): Connection name` — directly contradicting the v0.7.3 code. Now lists `warehouse` as optional with the auto-pick semantics, includes a top-of-file `v0.7.3+` note about the change, and shows the bare (no-warehouse) invocation form in at least one example. PM persona flagged the doc/code drift as P0 pre-release.
|
|
70
|
+
|
|
71
|
+
### Testing
|
|
72
|
+
|
|
73
|
+
- **34 adversarial tests** in `release-v0.7.3-adversarial.test.ts` pin the regression classes:
|
|
74
|
+
- **finops auto-pick** — code/doc/tool-description alignment (no `warehouse (required)` left in docs; "Optional" appears in tool descriptions; `warehouse_filter` description disambiguates from `warehouse`; `DEFAULT_FINOPS_TYPES` is single source of truth; resolver trims whitespace; resolver auto-picks on empty/undefined/whitespace).
|
|
75
|
+
- **project_scan privacy** — `stripGitRemoteCredentials` present + sets `username`/`password` empty; `gitError.stderr` runs through `Telemetry.maskString`; mask-before-slice order; `safeSpawnSync` returns null on missing binary; `GitInfo.gitAvailable` + `gitError` distinguish three states; output line distinguishes "binary missing" from "not a repo".
|
|
76
|
+
- **normalizeAgentName** — single declaration site (single source of truth); case-folds before "build" compare; strips C0 control chars; NFKC-normalizes; length-caps at 64; both `session_start` and `agent_outcome` route through the helper.
|
|
77
|
+
- **webfetch cache key** — `AUTH_PARAMS_FOR_CACHE_KEY` enumerates `token`/`access_token`/`api_key`/`apikey`/`signature`/`sig`/`x-amz-signature`/`x-amz-credential`/`x-goog-signature`; presigned-S3 URLs with different signatures collapse; api_key/token variants all collapse; functional params (`page`, `q`) are NOT stripped; auth-param strip is case-insensitive; cache-hit error prefix wording is distinguishable; `isUrlCachedFailure` exposes `ageMs`; 451 has shorter TTL than 404/410.
|
|
78
|
+
- **URL normalization privacy invariants** — userinfo stripped from cache key; utm/fbclid/mc_ tracking collapses; normalization is idempotent; invalid URL passes through verbatim.
|
|
79
|
+
- **CHANGELOG presence** — release-skill backstop that catches a release commit without a 0.7.3 entry + all 5 merged PR numbers referenced.
|
|
80
|
+
|
|
81
|
+
### Deferred to follow-up issues
|
|
82
|
+
|
|
83
|
+
- [#840](https://github.com/AltimateAI/altimate-code/issues/840) Surface auto-picked warehouse name in `finops_*` tool output (currently `autoPicked: true` is computed but not rendered).
|
|
84
|
+
- [#841](https://github.com/AltimateAI/altimate-code/issues/841) Audit: webfetch failure cache crosses logical sessions in `altimate serve` daemon mode.
|
|
85
|
+
- [#842](https://github.com/AltimateAI/altimate-code/issues/842) Route the three compaction-summary `lastUser.agent` writes through `normalizeAgentName` (currently only the emit boundary is normalized; persisted state can still record legacy `"build"`).
|
|
86
|
+
- [#843](https://github.com/AltimateAI/altimate-code/issues/843) Audit: cost telemetry retention + re-identification policy (`tokens_input_total` + `cost` are now always emitted per generation event; document TTL and TOS alignment).
|
|
87
|
+
|
|
8
88
|
## [0.7.2] - 2026-05-21
|
|
9
89
|
|
|
10
90
|
A focused hotfix for v0.7.1's broken install endpoint plus a defensive pass on the upgrade fetch surface. v0.7.1 documented and embedded `https://altimate.ai/install` in the curl install path and in `altimate upgrade`'s in-place upgrader — that host is the marketing-site SPA and returns an HTML 404 for `/install`, so every curl install and every curl-installed user's `altimate upgrade` silently failed end-to-end. v0.7.2 swaps the host to `https://www.altimate.sh/install` (apex `altimate.sh` is still not routed to the Amplify Next.js app — tracked separately, drop the `www.` once apex DNS is fixed), wraps the upgrader fetch with a 15s bounded timeout, replaces the raw `AbortError: The operation was aborted` with an actionable error that names the URL, prints the manual re-install one-liner, and points at the GitHub releases fallback. Realigns the published GitHub Action (`github/action.yml`) with the v0.7.1 binary rename (`altimate-code` → `altimate`) and new install directory (`~/.altimate/bin`) — pre-fix, every Action consumer hit the broken URL on cache miss and then a missing binary even if the URL had worked. 30 adversarial tests pin the regression classes (URL eradication, cross-file host consistency, named-constant invariants, error-surface invariants, action.yml alignment, marker integrity, migration recovery surface, CHANGELOG presence).
|
package/README.md
CHANGED
|
@@ -175,7 +175,7 @@ Each mode has scoped permissions, tool access, and SQL write-access control.
|
|
|
175
175
|
|
|
176
176
|
## Supported Warehouses
|
|
177
177
|
|
|
178
|
-
Snowflake · BigQuery · Databricks · PostgreSQL · Redshift · ClickHouse · DuckDB · MySQL · SQL Server (incl. Microsoft Fabric) · Oracle · SQLite · MongoDB
|
|
178
|
+
Snowflake · BigQuery · Databricks · PostgreSQL · Redshift · Trino · ClickHouse · DuckDB · MySQL · SQL Server (incl. Microsoft Fabric) · Oracle · SQLite · MongoDB
|
|
179
179
|
|
|
180
180
|
First-class support with schema indexing, query execution, and metadata introspection. SSH tunneling available for secure connections.
|
|
181
181
|
|
package/dbt-tools/dist/index.js
CHANGED
|
@@ -23515,6 +23515,117 @@ var init_compile = __esm(() => {
|
|
|
23515
23515
|
init_dbt_cli();
|
|
23516
23516
|
});
|
|
23517
23517
|
|
|
23518
|
+
// src/commands/schema-verify.ts
|
|
23519
|
+
var exports_schema_verify = {};
|
|
23520
|
+
__export(exports_schema_verify, {
|
|
23521
|
+
schemaVerify: () => schemaVerify
|
|
23522
|
+
});
|
|
23523
|
+
async function schemaVerify(adapter, args) {
|
|
23524
|
+
const model = flag2(args, "model");
|
|
23525
|
+
if (!model)
|
|
23526
|
+
return { error: "Missing --model" };
|
|
23527
|
+
const parsed = await adapter.parseManifest();
|
|
23528
|
+
const node = parsed?.nodeMetaMap.lookupByBaseName(model);
|
|
23529
|
+
if (!node) {
|
|
23530
|
+
return {
|
|
23531
|
+
error: `Model '${model}' not found in manifest. Did you run \`altimate-dbt compile\` or \`altimate-dbt build\` first?`
|
|
23532
|
+
};
|
|
23533
|
+
}
|
|
23534
|
+
const expectedEntries = Object.values(node.columns ?? {});
|
|
23535
|
+
let actual;
|
|
23536
|
+
try {
|
|
23537
|
+
actual = await adapter.getColumnsOfModel(model);
|
|
23538
|
+
} catch (e2) {
|
|
23539
|
+
return {
|
|
23540
|
+
error: `Failed to read actual columns for '${model}': ${e2 instanceof Error ? e2.message : String(e2)}. Build the model first: altimate-dbt build --model ${model}`
|
|
23541
|
+
};
|
|
23542
|
+
}
|
|
23543
|
+
if (!actual) {
|
|
23544
|
+
return {
|
|
23545
|
+
error: `Model '${model}' is in the manifest but has no warehouse table. Build it first: altimate-dbt build --model ${model}`
|
|
23546
|
+
};
|
|
23547
|
+
}
|
|
23548
|
+
if (expectedEntries.length === 0) {
|
|
23549
|
+
return {
|
|
23550
|
+
model,
|
|
23551
|
+
verdict: "no-spec",
|
|
23552
|
+
message: `Model '${model}' has no columns declared in schema.yml. There is no spec to verify against; the agent's column choices are unconstrained.`,
|
|
23553
|
+
actual_columns: actual.map((c) => c.column)
|
|
23554
|
+
};
|
|
23555
|
+
}
|
|
23556
|
+
const actualNames = actual.map((c) => c.column ?? "");
|
|
23557
|
+
const actualLower = actualNames.map((n) => n.toLowerCase());
|
|
23558
|
+
const expectedNames = expectedEntries.map((c) => c.name ?? "");
|
|
23559
|
+
const expectedLower = expectedNames.map((n) => n.toLowerCase());
|
|
23560
|
+
const actualSet = new Set(actualLower);
|
|
23561
|
+
const expectedSet = new Set(expectedLower);
|
|
23562
|
+
const columns_extra = [];
|
|
23563
|
+
for (let i2 = 0;i2 < actualNames.length; i2++) {
|
|
23564
|
+
const low = actualLower[i2] ?? "";
|
|
23565
|
+
const orig = actualNames[i2] ?? "";
|
|
23566
|
+
if (!expectedSet.has(low))
|
|
23567
|
+
columns_extra.push(orig);
|
|
23568
|
+
}
|
|
23569
|
+
const columns_missing = [];
|
|
23570
|
+
for (let i2 = 0;i2 < expectedNames.length; i2++) {
|
|
23571
|
+
const low = expectedLower[i2] ?? "";
|
|
23572
|
+
const orig = expectedNames[i2] ?? "";
|
|
23573
|
+
if (!actualSet.has(low))
|
|
23574
|
+
columns_missing.push(orig);
|
|
23575
|
+
}
|
|
23576
|
+
const intersection = expectedLower.filter((n) => actualSet.has(n));
|
|
23577
|
+
const actualIntersection = actualLower.filter((n) => expectedSet.has(n));
|
|
23578
|
+
const columns_reordered = [];
|
|
23579
|
+
for (let i2 = 0;i2 < intersection.length; i2++) {
|
|
23580
|
+
const expectedAtI = intersection[i2] ?? "";
|
|
23581
|
+
const actualAtI = actualIntersection[i2] ?? "";
|
|
23582
|
+
if (expectedAtI !== actualAtI) {
|
|
23583
|
+
const colLower = expectedAtI;
|
|
23584
|
+
const actualIdx = actualLower.indexOf(colLower);
|
|
23585
|
+
const expectedPos = expectedLower.indexOf(colLower);
|
|
23586
|
+
const original = expectedNames[expectedPos] ?? colLower;
|
|
23587
|
+
columns_reordered.push({
|
|
23588
|
+
column: original,
|
|
23589
|
+
actual_position: actualIdx,
|
|
23590
|
+
expected_position: expectedPos
|
|
23591
|
+
});
|
|
23592
|
+
}
|
|
23593
|
+
}
|
|
23594
|
+
const actualTypeByName = {};
|
|
23595
|
+
for (const c of actual)
|
|
23596
|
+
actualTypeByName[c.column.toLowerCase()] = c.dtype || "";
|
|
23597
|
+
const type_mismatches = [];
|
|
23598
|
+
for (const ec of expectedEntries) {
|
|
23599
|
+
const key = ec.name.toLowerCase();
|
|
23600
|
+
if (!actualTypeByName[key])
|
|
23601
|
+
continue;
|
|
23602
|
+
if (!ec.data_type)
|
|
23603
|
+
continue;
|
|
23604
|
+
if (actualTypeByName[key].toLowerCase() !== ec.data_type.toLowerCase()) {
|
|
23605
|
+
type_mismatches.push({
|
|
23606
|
+
column: ec.name,
|
|
23607
|
+
actual_type: actualTypeByName[key],
|
|
23608
|
+
expected_type: ec.data_type
|
|
23609
|
+
});
|
|
23610
|
+
}
|
|
23611
|
+
}
|
|
23612
|
+
const verdict = columns_extra.length === 0 && columns_missing.length === 0 && columns_reordered.length === 0 && type_mismatches.length === 0 ? "match" : "mismatch";
|
|
23613
|
+
return {
|
|
23614
|
+
model,
|
|
23615
|
+
verdict,
|
|
23616
|
+
expected_columns: expectedNames,
|
|
23617
|
+
actual_columns: actualNames,
|
|
23618
|
+
columns_extra,
|
|
23619
|
+
columns_missing,
|
|
23620
|
+
columns_reordered,
|
|
23621
|
+
type_mismatches
|
|
23622
|
+
};
|
|
23623
|
+
}
|
|
23624
|
+
function flag2(args, name) {
|
|
23625
|
+
const i2 = args.indexOf(`--${name}`);
|
|
23626
|
+
return i2 >= 0 ? args[i2 + 1] : undefined;
|
|
23627
|
+
}
|
|
23628
|
+
|
|
23518
23629
|
// src/commands/build.ts
|
|
23519
23630
|
var exports_build = {};
|
|
23520
23631
|
__export(exports_build, {
|
|
@@ -23524,7 +23635,7 @@ __export(exports_build, {
|
|
|
23524
23635
|
build: () => build
|
|
23525
23636
|
});
|
|
23526
23637
|
async function build(adapter, args) {
|
|
23527
|
-
const model =
|
|
23638
|
+
const model = flag3(args, "model");
|
|
23528
23639
|
const downstream = args.includes("--downstream");
|
|
23529
23640
|
if (!model) {
|
|
23530
23641
|
if (downstream)
|
|
@@ -23536,10 +23647,14 @@ async function build(adapter, args) {
|
|
|
23536
23647
|
modelName: model,
|
|
23537
23648
|
plusOperatorRight: downstream ? "+" : ""
|
|
23538
23649
|
});
|
|
23539
|
-
|
|
23650
|
+
const formatted = format(result);
|
|
23651
|
+
if (!("error" in formatted)) {
|
|
23652
|
+
return { ...formatted, schema_verify: await safeVerify(adapter, model) };
|
|
23653
|
+
}
|
|
23654
|
+
return formatted;
|
|
23540
23655
|
}
|
|
23541
23656
|
async function run3(adapter, args) {
|
|
23542
|
-
const model =
|
|
23657
|
+
const model = flag3(args, "model");
|
|
23543
23658
|
if (!model)
|
|
23544
23659
|
return { error: "Missing --model" };
|
|
23545
23660
|
const downstream = args.includes("--downstream");
|
|
@@ -23551,7 +23666,7 @@ async function run3(adapter, args) {
|
|
|
23551
23666
|
return format(result);
|
|
23552
23667
|
}
|
|
23553
23668
|
async function test(adapter, args) {
|
|
23554
|
-
const model =
|
|
23669
|
+
const model = flag3(args, "model");
|
|
23555
23670
|
if (!model)
|
|
23556
23671
|
return { error: "Missing --model" };
|
|
23557
23672
|
const result = await adapter.unsafeRunModelTestImmediately(model);
|
|
@@ -23559,17 +23674,82 @@ async function test(adapter, args) {
|
|
|
23559
23674
|
}
|
|
23560
23675
|
async function project2(adapter) {
|
|
23561
23676
|
const result = await adapter.unsafeBuildProjectImmediately();
|
|
23562
|
-
|
|
23677
|
+
const formatted = format(result);
|
|
23678
|
+
if ("error" in formatted)
|
|
23679
|
+
return formatted;
|
|
23680
|
+
try {
|
|
23681
|
+
const parsed = await adapter.parseManifest();
|
|
23682
|
+
const nodes = parsed?.nodeMetaMap?.nodes ? Array.from(parsed.nodeMetaMap.nodes()) : [];
|
|
23683
|
+
const verified = [];
|
|
23684
|
+
const errored = [];
|
|
23685
|
+
let nospec_count = 0;
|
|
23686
|
+
for (const node of nodes) {
|
|
23687
|
+
const resType = node.resource_type;
|
|
23688
|
+
if (resType !== "model")
|
|
23689
|
+
continue;
|
|
23690
|
+
const name = node.name;
|
|
23691
|
+
if (!name)
|
|
23692
|
+
continue;
|
|
23693
|
+
const cols = node.columns ?? {};
|
|
23694
|
+
if (Object.keys(cols).length === 0) {
|
|
23695
|
+
nospec_count++;
|
|
23696
|
+
continue;
|
|
23697
|
+
}
|
|
23698
|
+
try {
|
|
23699
|
+
const v2 = await schemaVerify(adapter, ["--model", name]);
|
|
23700
|
+
if ("error" in v2) {
|
|
23701
|
+
errored.push({ model: name, error: String(v2.error) });
|
|
23702
|
+
} else if (v2.verdict === "no-spec") {
|
|
23703
|
+
nospec_count++;
|
|
23704
|
+
} else {
|
|
23705
|
+
verified.push(v2);
|
|
23706
|
+
}
|
|
23707
|
+
} catch (e2) {
|
|
23708
|
+
errored.push({ model: name, error: e2 instanceof Error ? e2.message : String(e2) });
|
|
23709
|
+
}
|
|
23710
|
+
}
|
|
23711
|
+
const mismatches = verified.filter((r2) => r2.verdict === "mismatch");
|
|
23712
|
+
const matches = verified.filter((r2) => r2.verdict === "match");
|
|
23713
|
+
return {
|
|
23714
|
+
...formatted,
|
|
23715
|
+
schema_verify_summary: {
|
|
23716
|
+
models_checked: verified.length + errored.length,
|
|
23717
|
+
match: matches.length,
|
|
23718
|
+
mismatch: mismatches.length,
|
|
23719
|
+
no_spec: nospec_count,
|
|
23720
|
+
errored: errored.length,
|
|
23721
|
+
mismatches,
|
|
23722
|
+
...errored.length > 0 && { errors: errored }
|
|
23723
|
+
}
|
|
23724
|
+
};
|
|
23725
|
+
} catch (e2) {
|
|
23726
|
+
return {
|
|
23727
|
+
...formatted,
|
|
23728
|
+
schema_verify_summary: {
|
|
23729
|
+
error: `Bulk schema-verify failed: ${e2 instanceof Error ? e2.message : String(e2)}. Run \`altimate-dbt schema-verify --model <name>\` per model to inspect.`
|
|
23730
|
+
}
|
|
23731
|
+
};
|
|
23732
|
+
}
|
|
23733
|
+
}
|
|
23734
|
+
async function safeVerify(adapter, model) {
|
|
23735
|
+
try {
|
|
23736
|
+
return await schemaVerify(adapter, ["--model", model]);
|
|
23737
|
+
} catch (e2) {
|
|
23738
|
+
return {
|
|
23739
|
+
error: `schema-verify failed: ${e2 instanceof Error ? e2.message : String(e2)}. Run \`altimate-dbt schema-verify --model ${model}\` manually to inspect.`
|
|
23740
|
+
};
|
|
23741
|
+
}
|
|
23563
23742
|
}
|
|
23564
23743
|
function format(result) {
|
|
23565
23744
|
if (result?.stderr)
|
|
23566
23745
|
return { error: result.stderr, stdout: result.stdout };
|
|
23567
23746
|
return { stdout: result?.stdout ?? "" };
|
|
23568
23747
|
}
|
|
23569
|
-
function
|
|
23748
|
+
function flag3(args, name) {
|
|
23570
23749
|
const i2 = args.indexOf(`--${name}`);
|
|
23571
23750
|
return i2 >= 0 ? args[i2 + 1] : undefined;
|
|
23572
23751
|
}
|
|
23752
|
+
var init_build = () => {};
|
|
23573
23753
|
|
|
23574
23754
|
// src/commands/execute.ts
|
|
23575
23755
|
var exports_execute = {};
|
|
@@ -23577,11 +23757,11 @@ __export(exports_execute, {
|
|
|
23577
23757
|
execute: () => execute
|
|
23578
23758
|
});
|
|
23579
23759
|
async function execute(adapter, args) {
|
|
23580
|
-
const sql =
|
|
23760
|
+
const sql = flag4(args, "query");
|
|
23581
23761
|
if (!sql)
|
|
23582
23762
|
return { error: "Missing --query" };
|
|
23583
|
-
const model =
|
|
23584
|
-
const raw =
|
|
23763
|
+
const model = flag4(args, "model") ?? "";
|
|
23764
|
+
const raw = flag4(args, "limit");
|
|
23585
23765
|
const limit = raw !== undefined ? parseInt(raw, 10) : undefined;
|
|
23586
23766
|
try {
|
|
23587
23767
|
if (limit !== undefined && !Number.isNaN(limit))
|
|
@@ -23594,7 +23774,7 @@ async function execute(adapter, args) {
|
|
|
23594
23774
|
throw e2;
|
|
23595
23775
|
}
|
|
23596
23776
|
}
|
|
23597
|
-
function
|
|
23777
|
+
function flag4(args, name) {
|
|
23598
23778
|
const i2 = args.indexOf(`--${name}`);
|
|
23599
23779
|
return i2 >= 0 ? args[i2 + 1] : undefined;
|
|
23600
23780
|
}
|
|
@@ -23610,7 +23790,7 @@ __export(exports_columns, {
|
|
|
23610
23790
|
columns: () => columns
|
|
23611
23791
|
});
|
|
23612
23792
|
async function columns(adapter, args) {
|
|
23613
|
-
const model =
|
|
23793
|
+
const model = flag5(args, "model");
|
|
23614
23794
|
if (!model)
|
|
23615
23795
|
return { error: "Missing --model" };
|
|
23616
23796
|
try {
|
|
@@ -23624,8 +23804,8 @@ async function columns(adapter, args) {
|
|
|
23624
23804
|
}
|
|
23625
23805
|
}
|
|
23626
23806
|
async function source(adapter, args) {
|
|
23627
|
-
const name =
|
|
23628
|
-
const table =
|
|
23807
|
+
const name = flag5(args, "source");
|
|
23808
|
+
const table = flag5(args, "table");
|
|
23629
23809
|
if (!name)
|
|
23630
23810
|
return { error: "Missing --source" };
|
|
23631
23811
|
if (!table)
|
|
@@ -23641,15 +23821,15 @@ async function source(adapter, args) {
|
|
|
23641
23821
|
}
|
|
23642
23822
|
}
|
|
23643
23823
|
async function values(adapter, args) {
|
|
23644
|
-
const model =
|
|
23645
|
-
const col =
|
|
23824
|
+
const model = flag5(args, "model");
|
|
23825
|
+
const col = flag5(args, "column");
|
|
23646
23826
|
if (!model)
|
|
23647
23827
|
return { error: "Missing --model" };
|
|
23648
23828
|
if (!col)
|
|
23649
23829
|
return { error: "Missing --column" };
|
|
23650
23830
|
return adapter.getColumnValues(model, col);
|
|
23651
23831
|
}
|
|
23652
|
-
function
|
|
23832
|
+
function flag5(args, name) {
|
|
23653
23833
|
const i2 = args.indexOf(`--${name}`);
|
|
23654
23834
|
return i2 >= 0 ? args[i2 + 1] : undefined;
|
|
23655
23835
|
}
|
|
@@ -23661,7 +23841,7 @@ __export(exports_graph, {
|
|
|
23661
23841
|
children: () => children
|
|
23662
23842
|
});
|
|
23663
23843
|
async function children(adapter, args) {
|
|
23664
|
-
const model =
|
|
23844
|
+
const model = flag6(args, "model");
|
|
23665
23845
|
if (!model)
|
|
23666
23846
|
return { error: "Missing --model" };
|
|
23667
23847
|
try {
|
|
@@ -23674,7 +23854,7 @@ async function children(adapter, args) {
|
|
|
23674
23854
|
}
|
|
23675
23855
|
}
|
|
23676
23856
|
async function parents(adapter, args) {
|
|
23677
|
-
const model =
|
|
23857
|
+
const model = flag6(args, "model");
|
|
23678
23858
|
if (!model)
|
|
23679
23859
|
return { error: "Missing --model" };
|
|
23680
23860
|
try {
|
|
@@ -23686,7 +23866,7 @@ async function parents(adapter, args) {
|
|
|
23686
23866
|
throw e2;
|
|
23687
23867
|
}
|
|
23688
23868
|
}
|
|
23689
|
-
function
|
|
23869
|
+
function flag6(args, name) {
|
|
23690
23870
|
const i2 = args.indexOf(`--${name}`);
|
|
23691
23871
|
return i2 >= 0 ? args[i2 + 1] : undefined;
|
|
23692
23872
|
}
|
|
@@ -23705,7 +23885,7 @@ async function deps(adapter) {
|
|
|
23705
23885
|
return format2(result);
|
|
23706
23886
|
}
|
|
23707
23887
|
async function add(adapter, args) {
|
|
23708
|
-
const raw =
|
|
23888
|
+
const raw = flag7(args, "packages");
|
|
23709
23889
|
if (!raw)
|
|
23710
23890
|
return { error: "Missing --packages" };
|
|
23711
23891
|
const result = await adapter.installDbtPackages(raw.split(","));
|
|
@@ -23716,7 +23896,7 @@ function format2(result) {
|
|
|
23716
23896
|
return { error: result.stderr, stdout: result.stdout };
|
|
23717
23897
|
return { stdout: result?.stdout ?? "" };
|
|
23718
23898
|
}
|
|
23719
|
-
function
|
|
23899
|
+
function flag7(args, name) {
|
|
23720
23900
|
const i2 = args.indexOf(`--${name}`);
|
|
23721
23901
|
return i2 >= 0 ? args[i2 + 1] : undefined;
|
|
23722
23902
|
}
|
|
@@ -23854,6 +24034,7 @@ var USAGE = {
|
|
|
23854
24034
|
execute: "Execute SQL --query <sql> [--model <name>] [--limit <n>]",
|
|
23855
24035
|
columns: "Get columns of model --model <name>",
|
|
23856
24036
|
"columns-source": "Get columns of source --source <name> --table <name>",
|
|
24037
|
+
"schema-verify": "Diff a model's actual columns against the schema.yml spec --model <name>. Returns columns_extra / columns_missing / columns_reordered / type_mismatches. verdict: match | mismatch | no-spec",
|
|
23857
24038
|
"column-values": "Get column values --model <name> --column <col>",
|
|
23858
24039
|
children: "Get downstream models --model <name>",
|
|
23859
24040
|
parents: "Get upstream models --model <name>",
|
|
@@ -23863,7 +24044,7 @@ var USAGE = {
|
|
|
23863
24044
|
};
|
|
23864
24045
|
var cmd = process.argv[2];
|
|
23865
24046
|
var rest = process.argv.slice(3);
|
|
23866
|
-
function
|
|
24047
|
+
function flag8(args, name) {
|
|
23867
24048
|
const i2 = args.indexOf(`--${name}`);
|
|
23868
24049
|
return i2 >= 0 ? args[i2 + 1] : undefined;
|
|
23869
24050
|
}
|
|
@@ -23946,7 +24127,7 @@ async function main() {
|
|
|
23946
24127
|
const cfg = await read();
|
|
23947
24128
|
if (!cfg)
|
|
23948
24129
|
return { error: "No config found. Run: altimate-dbt init" };
|
|
23949
|
-
const dirFlag =
|
|
24130
|
+
const dirFlag = flag8(rest, "project-dir");
|
|
23950
24131
|
if (dirFlag) {
|
|
23951
24132
|
cfg.projectRoot = resolve4(dirFlag);
|
|
23952
24133
|
} else {
|
|
@@ -23982,13 +24163,13 @@ async function main() {
|
|
|
23982
24163
|
result = await (await Promise.resolve().then(() => (init_compile(), exports_compile))).query(adapter, rest);
|
|
23983
24164
|
break;
|
|
23984
24165
|
case "build":
|
|
23985
|
-
result = await (await Promise.resolve().then(() => exports_build)).build(adapter, rest);
|
|
24166
|
+
result = await (await Promise.resolve().then(() => (init_build(), exports_build))).build(adapter, rest);
|
|
23986
24167
|
break;
|
|
23987
24168
|
case "run":
|
|
23988
|
-
result = await (await Promise.resolve().then(() => exports_build)).run(adapter, rest);
|
|
24169
|
+
result = await (await Promise.resolve().then(() => (init_build(), exports_build))).run(adapter, rest);
|
|
23989
24170
|
break;
|
|
23990
24171
|
case "test":
|
|
23991
|
-
result = await (await Promise.resolve().then(() => exports_build)).test(adapter, rest);
|
|
24172
|
+
result = await (await Promise.resolve().then(() => (init_build(), exports_build))).test(adapter, rest);
|
|
23992
24173
|
break;
|
|
23993
24174
|
case "execute":
|
|
23994
24175
|
result = await (await Promise.resolve().then(() => (init_execute(), exports_execute))).execute(adapter, rest);
|
|
@@ -24002,6 +24183,9 @@ async function main() {
|
|
|
24002
24183
|
case "column-values":
|
|
24003
24184
|
result = await (await Promise.resolve().then(() => exports_columns)).values(adapter, rest);
|
|
24004
24185
|
break;
|
|
24186
|
+
case "schema-verify":
|
|
24187
|
+
result = await (await Promise.resolve().then(() => exports_schema_verify)).schemaVerify(adapter, rest);
|
|
24188
|
+
break;
|
|
24005
24189
|
case "children":
|
|
24006
24190
|
result = await (await Promise.resolve().then(() => (init_graph(), exports_graph))).children(adapter, rest);
|
|
24007
24191
|
break;
|
package/package.json
CHANGED
|
@@ -14,20 +14,20 @@
|
|
|
14
14
|
"scripts": {
|
|
15
15
|
"postinstall": "bun ./postinstall.mjs || node ./postinstall.mjs"
|
|
16
16
|
},
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.8.0",
|
|
18
18
|
"license": "MIT",
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@altimateai/altimate-core": "0.
|
|
20
|
+
"@altimateai/altimate-core": "0.4.0"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@altimateai/altimate-code-
|
|
24
|
-
"@altimateai/altimate-code-
|
|
25
|
-
"@altimateai/altimate-code-linux-
|
|
26
|
-
"@altimateai/altimate-code-
|
|
27
|
-
"@altimateai/altimate-code-
|
|
28
|
-
"@altimateai/altimate-code-
|
|
29
|
-
"@altimateai/altimate-code-
|
|
30
|
-
"@altimateai/altimate-code-
|
|
23
|
+
"@altimateai/altimate-code-linux-arm64": "0.8.0",
|
|
24
|
+
"@altimateai/altimate-code-darwin-arm64": "0.8.0",
|
|
25
|
+
"@altimateai/altimate-code-linux-x64-baseline": "0.8.0",
|
|
26
|
+
"@altimateai/altimate-code-darwin-x64-baseline": "0.8.0",
|
|
27
|
+
"@altimateai/altimate-code-windows-x64-baseline": "0.8.0",
|
|
28
|
+
"@altimateai/altimate-code-darwin-x64": "0.8.0",
|
|
29
|
+
"@altimateai/altimate-code-windows-x64": "0.8.0",
|
|
30
|
+
"@altimateai/altimate-code-linux-x64": "0.8.0"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"pg": ">=8",
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"mssql": ">=11",
|
|
39
39
|
"oracledb": ">=6",
|
|
40
40
|
"duckdb": ">=1",
|
|
41
|
-
"@clickhouse/client": ">=1"
|
|
41
|
+
"@clickhouse/client": ">=1",
|
|
42
|
+
"trino-client": ">=0.2"
|
|
42
43
|
},
|
|
43
44
|
"peerDependenciesMeta": {
|
|
44
45
|
"pg": {
|
|
@@ -67,6 +68,9 @@
|
|
|
67
68
|
},
|
|
68
69
|
"@clickhouse/client": {
|
|
69
70
|
"optional": true
|
|
71
|
+
},
|
|
72
|
+
"trino-client": {
|
|
73
|
+
"optional": true
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
76
|
}
|
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: dbt-develop
|
|
3
|
-
|
|
3
|
+
applyPaths:
|
|
4
|
+
- "dbt_project.yml"
|
|
5
|
+
- "**/dbt_project.yml"
|
|
6
|
+
description: |
|
|
7
|
+
REQUIRED before writing or modifying ANY dbt model. Invoke this skill FIRST
|
|
8
|
+
whenever a task says "create", "build", "add", "modify", "update", "fix", or
|
|
9
|
+
"refactor" a dbt model, staging file, mart, incremental, or snapshot.
|
|
10
|
+
|
|
11
|
+
Skipping this skill is the leading cause of silent-correctness bugs —
|
|
12
|
+
models that compile and `dbt build` cleanly but produce wrong values. It
|
|
13
|
+
contains the patterns that prevent the most common such bugs encountered
|
|
14
|
+
in real dbt projects:
|
|
15
|
+
|
|
16
|
+
• Incremental high-water marks (`>=` vs `>` ties → silent row dropout)
|
|
17
|
+
• Snapshot strategy selection (timestamp vs check, `unique_key` choice)
|
|
18
|
+
• `LEFT JOIN + COUNT(*)` phantom rows from unmatched parents
|
|
19
|
+
• Type harmonization in `COALESCE` / `CASE` / `UNION` legs
|
|
20
|
+
• Date-spine completeness (every period present, even empty ones)
|
|
21
|
+
• Off-by-one window boundaries (`BETWEEN d - (N-1) AND d` for N-wide)
|
|
22
|
+
• Uniqueness enforcement when schema implies a key
|
|
23
|
+
• Window-function `LIMIT` with deterministic tiebreaker
|
|
24
|
+
• Verifying transformation correctness with dbt unit tests, not just `dbt build`
|
|
25
|
+
• Enumerating every requested deliverable and checking each exists on disk
|
|
26
|
+
|
|
27
|
+
Do not start writing SQL until this skill is loaded. Powered by altimate-dbt.
|
|
4
28
|
---
|
|
5
29
|
|
|
6
30
|
# dbt Model Development
|
|
@@ -31,6 +55,12 @@ description: Create and modify dbt models — staging, intermediate, marts, incr
|
|
|
31
55
|
|
|
32
56
|
Before writing any SQL:
|
|
33
57
|
- Read the task requirements carefully
|
|
58
|
+
- **Enumerate every concrete deliverable the task asks for** — write down each
|
|
59
|
+
model name, every column/test/config change mentioned, and any "create N
|
|
60
|
+
models" count. This list becomes the checklist you verify against in
|
|
61
|
+
step 4. A task asking for four models is not done if only three exist on
|
|
62
|
+
disk. If the task references a `schema.yml`, `_models.yml`, or similar
|
|
63
|
+
spec file, every entry there is a deliverable.
|
|
34
64
|
- Identify which layer this model belongs to (staging, intermediate, mart)
|
|
35
65
|
- Check existing models for naming conventions and patterns
|
|
36
66
|
- **Check dependencies:** If `packages.yml` exists, check for `dbt_packages/` or `package-lock.yml`. Only run `dbt deps` if packages are declared but not yet installed.
|
|
@@ -98,6 +128,51 @@ altimate-dbt compile --model <name> # catch Jinja errors
|
|
|
98
128
|
altimate-dbt build --model <name> # materialize + run tests
|
|
99
129
|
```
|
|
100
130
|
|
|
131
|
+
**Verify transformation correctness with unit tests:**
|
|
132
|
+
|
|
133
|
+
For models with non-trivial transformation logic — aggregations, JOINs, CASE/WHEN,
|
|
134
|
+
window functions, ratio / rate / NPS calculations, COALESCE / NULL coalescing, date
|
|
135
|
+
spines, incremental merge keys — generate and run dbt unit tests before declaring
|
|
136
|
+
the model done. Schema checks ("table exists with the right columns") only verify
|
|
137
|
+
mechanics; value-level correctness needs unit tests.
|
|
138
|
+
|
|
139
|
+
Invoke the **dbt-unit-tests** skill, which will:
|
|
140
|
+
- Analyze your SQL for the constructs above
|
|
141
|
+
- Build typed mock input rows from the manifest
|
|
142
|
+
- Compute expected outputs by running the SQL against the mocks
|
|
143
|
+
- Write a `unit_tests:` block in the model's `_models.yml`
|
|
144
|
+
|
|
145
|
+
Then run them:
|
|
146
|
+
```bash
|
|
147
|
+
altimate-dbt test --model <name> # runs unit tests + schema tests
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
If a unit test fails, the transformation logic is wrong — **fix the SQL, do not
|
|
151
|
+
weaken the test**. Skip unit tests only for genuinely trivial models: pure renames,
|
|
152
|
+
simple `SELECT *` passthrough, materialization / config-only changes, format-only
|
|
153
|
+
edits.
|
|
154
|
+
|
|
155
|
+
**Verify every requested deliverable exists:**
|
|
156
|
+
|
|
157
|
+
Walk the checklist you wrote in the Plan step. For each model the task asked
|
|
158
|
+
for, confirm: (1) the `.sql` file exists in the project, (2) it appears in
|
|
159
|
+
`altimate-dbt info` / the manifest, (3) `altimate-dbt columns --model <name>`
|
|
160
|
+
returns the expected columns, (4) the materialization config matches the
|
|
161
|
+
spec. A task that asked for N models is not complete with N-1 files on disk,
|
|
162
|
+
even if those N-1 build cleanly. Use:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
ls models/ # confirm every requested file exists
|
|
166
|
+
altimate-dbt info # confirm every requested model is in the project
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**Diff column shape against the spec — use the `dbt-schema-verify` skill.**
|
|
170
|
+
For each model the task touched, run `altimate-dbt schema-verify --model
|
|
171
|
+
<name>` and treat any `mismatch` verdict as "not done." Full procedure,
|
|
172
|
+
output interpretation, and fallback (when `altimate-dbt` is missing) live
|
|
173
|
+
in the dedicated **dbt-schema-verify** skill, which auto-loads alongside
|
|
174
|
+
this one.
|
|
175
|
+
|
|
101
176
|
**Verify the output:**
|
|
102
177
|
```bash
|
|
103
178
|
altimate-dbt columns --model <name> # confirm expected columns exist
|
|
@@ -127,6 +202,203 @@ Use `altimate-dbt children` and `altimate-dbt parents` to verify the DAG is inta
|
|
|
127
202
|
3. **Match existing patterns.** Read 2-3 existing models in the same directory before writing.
|
|
128
203
|
4. **One model, one purpose.** A staging model should not contain business logic. An intermediate model should not be materialized as a table unless it has consumers.
|
|
129
204
|
5. **Fix ALL errors, not just yours.** After creating/modifying models, run a full `dbt build`. If ANY model fails — even pre-existing ones you didn't touch — fix them. Your job is to leave the project in a fully working state.
|
|
205
|
+
6. **Verify transformation correctness, not just mechanics.** For non-trivial models, generate and run dbt unit tests as part of the validate step (use the `dbt-unit-tests` skill). Passing `dbt build` only proves the SQL is syntactically valid — it doesn't prove the *values* are right.
|
|
206
|
+
7. **Enumerate deliverables, then check them off.** The task is not done until every model, column, test, and config change explicitly requested exists on disk and in the manifest. Re-read the prompt at the end and verify each requested item — don't trust your own intermediate "done" feeling.
|
|
207
|
+
8. **Match the column spec exactly — and verify it mechanically, not by inspection.** Use the dedicated **dbt-schema-verify** skill. Before declaring any model task done, run `altimate-dbt schema-verify --model <name>` and treat any `mismatch` verdict as "not done." Adding "helpful" extras (rank breakdowns, name-resolved fields, lineage metadata), reordering columns "more logically", or substituting synonyms (`supplier_id` for `supplier_company`, `transaction_type_name` for `transaction_type`) all break equality tests. The contract is what the spec says, not what you think would be useful.
|
|
208
|
+
|
|
209
|
+
## Common Pitfalls in Transformation Logic
|
|
210
|
+
|
|
211
|
+
When the model involves any of the following SQL constructs, watch for these
|
|
212
|
+
generic bugs that mostly compile cleanly but produce wrong values:
|
|
213
|
+
|
|
214
|
+
### Incremental models and snapshots
|
|
215
|
+
|
|
216
|
+
- **High-water mark boundary**: in the `{% if is_incremental() %}` filter, use
|
|
217
|
+
`>=` (not `>`) when the upstream timestamp can repeat or land exactly on the
|
|
218
|
+
prior max — a strict `>` silently drops every event that ties with the most
|
|
219
|
+
recent prior load.
|
|
220
|
+
- **`unique_key` choice**: must be the *natural* unique key of the row. Picking
|
|
221
|
+
a column that is not actually unique (e.g. a foreign-key like `customer_id`
|
|
222
|
+
instead of `order_id`) causes silent merges and lost rows.
|
|
223
|
+
- **`on_schema_change`**: set `append_new_columns` (or `sync_all_columns` if
|
|
224
|
+
upstream evolves) so a new source column doesn't NULL-out existing data.
|
|
225
|
+
- **Snapshots — strategy selection**: use `strategy='timestamp'` only when the
|
|
226
|
+
source has a reliable `updated_at` that monotonically increases on every
|
|
227
|
+
change. If `updated_at` can be NULL, be reset, or move backwards, switch to
|
|
228
|
+
`strategy='check'` with an explicit `check_cols` list. Verify by querying
|
|
229
|
+
the source for `MAX(updated_at)` and looking for repeats or NULLs.
|
|
230
|
+
- **Backfilling**: `--full-refresh` rebuilds incremental tables from scratch.
|
|
231
|
+
Use it whenever you change the incremental SQL, the merge key, or
|
|
232
|
+
`on_schema_change`.
|
|
233
|
+
|
|
234
|
+
### Date and time arithmetic
|
|
235
|
+
|
|
236
|
+
- **"current age", "days since", "elapsed", "tenure"** — if the column is not
|
|
237
|
+
pre-computed in the source, compute it. For year-based age, account for
|
|
238
|
+
month/day so the change happens on the birthday, not on Jan 1:
|
|
239
|
+
```sql
|
|
240
|
+
date_part('year', age(birth_date)) -- in postgres-family
|
|
241
|
+
EXTRACT(YEAR FROM CURRENT_DATE) - EXTRACT(YEAR FROM birth_date)
|
|
242
|
+
- CASE WHEN (EXTRACT(MONTH FROM CURRENT_DATE), EXTRACT(DAY FROM CURRENT_DATE))
|
|
243
|
+
< (EXTRACT(MONTH FROM birth_date), EXTRACT(DAY FROM birth_date))
|
|
244
|
+
THEN 1 ELSE 0 END -- portable form
|
|
245
|
+
```
|
|
246
|
+
- **Date spines**: when a daily/weekly/monthly model must have a row for
|
|
247
|
+
every period (even periods with zero events), build a spine first with
|
|
248
|
+
`dbt_utils.date_spine` or a recursive CTE, then LEFT JOIN the events onto
|
|
249
|
+
it. Never compute date series by `DISTINCT date_col FROM events` — that
|
|
250
|
+
silently drops empty periods.
|
|
251
|
+
- **Date boundaries for windowed sums**: rolling-N-day windows expressed as
|
|
252
|
+
`BETWEEN d - (N-1) AND d` (inclusive both ends) give a width of exactly N.
|
|
253
|
+
`BETWEEN d - N AND d` gives N+1 — a classic off-by-one.
|
|
254
|
+
|
|
255
|
+
### Type harmonization in `COALESCE` / `CASE` / `UNION`
|
|
256
|
+
|
|
257
|
+
`COALESCE(timestamp_col, integer_col)` and `CASE WHEN ... THEN '0' ELSE 0 END`
|
|
258
|
+
fail at compile or coerce silently to whatever type the engine guesses.
|
|
259
|
+
Cast every branch / argument to the same explicit type:
|
|
260
|
+
```sql
|
|
261
|
+
COALESCE(CAST(timestamp_col AS TIMESTAMP), CAST(integer_col AS TIMESTAMP))
|
|
262
|
+
CASE WHEN cond THEN CAST('0' AS NUMERIC) ELSE CAST(0 AS NUMERIC) END
|
|
263
|
+
```
|
|
264
|
+
Same applies to `UNION` / `UNION ALL` — column types must match across legs.
|
|
265
|
+
|
|
266
|
+
### String concatenation with `NULL` operands
|
|
267
|
+
|
|
268
|
+
`||` and `CONCAT()` propagate `NULL` in most engines — a single `NULL` operand
|
|
269
|
+
makes the whole expression `NULL`. When the result feeds an equality join or
|
|
270
|
+
surrogate-key generation, that's an invisible row-dropper:
|
|
271
|
+
```sql
|
|
272
|
+
-- Wrong: NULL region OR NULL segment produces NULL geo_segment
|
|
273
|
+
region || '-' || segment AS geo_segment
|
|
274
|
+
|
|
275
|
+
-- Right: explicit placeholder
|
|
276
|
+
COALESCE(region, 'UNKNOWN') || '-' || COALESCE(segment, 'UNKNOWN') AS geo_segment
|
|
277
|
+
```
|
|
278
|
+
Use `CONCAT_WS()` if your dialect supports it (Snowflake, BigQuery) — it
|
|
279
|
+
skips `NULL` operands instead of propagating them, which is usually safer
|
|
280
|
+
than a static placeholder.
|
|
281
|
+
|
|
282
|
+
### dbt model versioning (dbt 1.8+)
|
|
283
|
+
|
|
284
|
+
When the task asks for a v2 of an existing model (and v1 must keep
|
|
285
|
+
working — common during a rolling schema change), use dbt's **versioned
|
|
286
|
+
models** feature, not a sibling `.sql` file with a `_v2` suffix:
|
|
287
|
+
|
|
288
|
+
1. Create the new SQL file (e.g. `dim_accounts_v2.sql`).
|
|
289
|
+
2. Add a `versions:` block to the model's entry in `_models.yml`:
|
|
290
|
+
```yaml
|
|
291
|
+
models:
|
|
292
|
+
- name: dim_accounts
|
|
293
|
+
latest_version: 1
|
|
294
|
+
versions:
|
|
295
|
+
- v: 1
|
|
296
|
+
- v: 2
|
|
297
|
+
defined_in: dim_accounts_v2 # filename without .sql
|
|
298
|
+
```
|
|
299
|
+
3. Downstream callers reference the version with
|
|
300
|
+
`{{ ref('dim_accounts', v=2) }}`. Without the `versions:` block, dbt
|
|
301
|
+
treats `dim_accounts_v2` as an unrelated sibling model — versioning
|
|
302
|
+
tests will fail and v1↔v2 lineage won't appear in the DAG.
|
|
303
|
+
|
|
304
|
+
### Refactoring a CTE into its own model — preserve row-count semantics
|
|
305
|
+
|
|
306
|
+
When a task asks to extract a CTE from a larger model into its own
|
|
307
|
+
intermediate model, the new model's row count must match what the CTE
|
|
308
|
+
produced inside the original. Common bug: the CTE was on the parent side of
|
|
309
|
+
a `LEFT JOIN` that preserved parent rows with no children; the agent's
|
|
310
|
+
extracted model starts `FROM child_table` and joins back to the parent,
|
|
311
|
+
silently dropping parents that have no children.
|
|
312
|
+
|
|
313
|
+
**Rule of thumb:** the extracted model should start `FROM` the same table
|
|
314
|
+
the CTE started from. Build the extracted model inside-out from the
|
|
315
|
+
parent's perspective, not the child's.
|
|
316
|
+
|
|
317
|
+
```sql
|
|
318
|
+
-- Original CTE (inside the larger model):
|
|
319
|
+
-- WITH agg_users AS (
|
|
320
|
+
-- SELECT p.project_id, listagg(u.user_id) AS users
|
|
321
|
+
-- FROM projects p
|
|
322
|
+
-- LEFT JOIN project_users u ON u.project_id = p.project_id
|
|
323
|
+
-- GROUP BY p.project_id
|
|
324
|
+
-- )
|
|
325
|
+
--
|
|
326
|
+
-- Right refactor — preserves projects with no users:
|
|
327
|
+
SELECT p.project_id, listagg(u.user_id) AS users
|
|
328
|
+
FROM {{ ref('projects') }} p
|
|
329
|
+
LEFT JOIN {{ ref('project_users') }} u ON u.project_id = p.project_id
|
|
330
|
+
GROUP BY p.project_id
|
|
331
|
+
|
|
332
|
+
-- Wrong refactor — drops projects with no users:
|
|
333
|
+
SELECT u.project_id, listagg(u.user_id) AS users
|
|
334
|
+
FROM {{ ref('project_users') }} u
|
|
335
|
+
GROUP BY u.project_id -- projects with zero users vanish
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
**Verification** (in order of preference):
|
|
339
|
+
|
|
340
|
+
```sql
|
|
341
|
+
-- If dbt_utils is installed, add to schema.yml on the extracted model:
|
|
342
|
+
tests:
|
|
343
|
+
- dbt_utils.equal_rowcount:
|
|
344
|
+
compare_model: ref('<parent_table>')
|
|
345
|
+
|
|
346
|
+
-- If dbt-audit-helper is installed:
|
|
347
|
+
{{ audit_helper.compare_relations(
|
|
348
|
+
a_relation=ref('<original_or_parent>'),
|
|
349
|
+
b_relation=ref('<extracted>'),
|
|
350
|
+
primary_key='<key>'
|
|
351
|
+
) }}
|
|
352
|
+
|
|
353
|
+
-- Manual fallback — always available:
|
|
354
|
+
SELECT (SELECT COUNT(*) FROM {{ ref('<parent>') }}) AS parent_rows,
|
|
355
|
+
(SELECT COUNT(*) FROM {{ ref('<extracted>') }}) AS extracted_rows
|
|
356
|
+
-- These must match if the original CTE was LEFT-joined to its parent.
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
If `extracted_rows < parent_rows`, the refactor is wrong — you've turned a
|
|
360
|
+
LEFT JOIN into an INNER JOIN somewhere. Same trap shows up when filtering a
|
|
361
|
+
right-side column in `WHERE` (silently converts the LEFT JOIN to an INNER
|
|
362
|
+
JOIN); move that filter into the `ON` clause.
|
|
363
|
+
|
|
364
|
+
### Uniqueness when the schema implies it
|
|
365
|
+
|
|
366
|
+
If the model is named `dim_*`, has a `unique` test in `schema.yml`, or the
|
|
367
|
+
task says "one row per X", the model must enforce that grain. Source data
|
|
368
|
+
often has duplicates. Use one of:
|
|
369
|
+
- `SELECT DISTINCT ...`
|
|
370
|
+
- `QUALIFY ROW_NUMBER() OVER (PARTITION BY <key> ORDER BY <tiebreaker>) = 1`
|
|
371
|
+
- `GROUP BY <key>` with explicit aggregation of all other columns
|
|
372
|
+
|
|
373
|
+
### Window functions / ranking with `LIMIT` and ties
|
|
374
|
+
|
|
375
|
+
`ORDER BY metric DESC LIMIT N` (and equivalently `ROW_NUMBER() / RANK() OVER
|
|
376
|
+
(PARTITION BY ... ORDER BY metric)` filtered to `<= N`) over a column with
|
|
377
|
+
ties returns a **non-deterministic** set — the engine can pick any N of the
|
|
378
|
+
tied rows, and the choice often differs across runs, engines, or warehouse
|
|
379
|
+
versions. The rest of the pipeline then sees row-count drift or different
|
|
380
|
+
keys appearing in downstream joins.
|
|
381
|
+
|
|
382
|
+
Always add a deterministic tiebreaker to the `ORDER BY` (a primary key, a
|
|
383
|
+
surrogate id, or any column guaranteed unique within the partition):
|
|
384
|
+
```sql
|
|
385
|
+
-- Wrong: ties produce different "top 20" every run
|
|
386
|
+
SELECT * FROM standings
|
|
387
|
+
ORDER BY points DESC
|
|
388
|
+
LIMIT 20
|
|
389
|
+
|
|
390
|
+
-- Right: tie on points falls back to driver_id
|
|
391
|
+
SELECT * FROM standings
|
|
392
|
+
ORDER BY points DESC, driver_id ASC
|
|
393
|
+
LIMIT 20
|
|
394
|
+
|
|
395
|
+
-- Same fix inside QUALIFY / window-row-number patterns:
|
|
396
|
+
QUALIFY ROW_NUMBER() OVER (
|
|
397
|
+
PARTITION BY season ORDER BY points DESC, driver_id ASC
|
|
398
|
+
) <= 20
|
|
399
|
+
```
|
|
400
|
+
If you can't think of a tiebreaker column, the model probably doesn't yet
|
|
401
|
+
have a unique key — fix that first.
|
|
130
402
|
|
|
131
403
|
## Common Mistakes
|
|
132
404
|
|
|
@@ -138,6 +410,7 @@ Use `altimate-dbt children` and `altimate-dbt parents` to verify the DAG is inta
|
|
|
138
410
|
| Creating a staging model with JOINs | Staging = 1:1 with source. JOINs belong in intermediate or mart |
|
|
139
411
|
| Not checking existing naming conventions | Read existing models in the same directory first |
|
|
140
412
|
| Using `SELECT *` in final models | Explicitly list columns for clarity and contract stability |
|
|
413
|
+
| `COUNT(*)` over a `LEFT JOIN` — counts unmatched parent rows as if they had one child (e.g. a `dim_parent LEFT JOIN fct_child` with no matching children still yields one row, so `COUNT(*) = 1` instead of `0`) | Use `COUNT(<child_key>)` or `COUNT(CASE WHEN <child_key> IS NOT NULL THEN 1 END)`. If you intended to exclude unmatched parents, switch to `INNER JOIN`. Same trap applies to `SUM`, `AVG`, etc. when the unmatched side contributes a "ghost" `NULL` row |
|
|
141
414
|
|
|
142
415
|
## Reference Guides
|
|
143
416
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dbt-pr-review
|
|
3
|
+
description: Cloudflare-style AI code review for dbt/SQL pull requests. Produces a signed APPROVE/COMMENT/REQUEST_CHANGES verdict where every blocking finding is backed by a deterministic engine call — column-lineage blast radius, query equivalence, PII classification, and A–F grade. Use to review a dbt PR or the working-tree changes before merge.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# dbt PR Review
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
**Agent:** `reviewer` (read-only) — also works from `analyst`/`builder`.
|
|
10
|
+
**Tools used:** `dbt_pr_review` (primary), `impact_analysis`, `altimate_core_equivalence`, `altimate_core_check`, `lineage_check`, read-only `git`.
|
|
11
|
+
|
|
12
|
+
## When to Use This Skill
|
|
13
|
+
|
|
14
|
+
Use when the user wants to:
|
|
15
|
+
- Review a dbt pull request (changed models) before merge
|
|
16
|
+
- Get a single verdict (APPROVE / COMMENT / REQUEST_CHANGES) with evidence
|
|
17
|
+
- Understand the downstream blast radius of a model/column change
|
|
18
|
+
- Check whether a "refactor" actually preserves results (query equivalence)
|
|
19
|
+
- Catch PII exposure, contract breaks, or warehouse-cost anti-patterns pre-merge
|
|
20
|
+
|
|
21
|
+
## What makes this different from a generic AI reviewer
|
|
22
|
+
|
|
23
|
+
Generic reviewers read the diff as text and guess. This review is backed by the
|
|
24
|
+
Rust core: every blocking finding carries a deterministic proof (an equivalence
|
|
25
|
+
counterexample, a downstream-model list, a PII classification). The verdict is
|
|
26
|
+
**signed** into a replayable envelope keyed to the dbt manifest.
|
|
27
|
+
|
|
28
|
+
## Workflow
|
|
29
|
+
|
|
30
|
+
1. **Run the verdict engine.** Call `dbt_pr_review` once:
|
|
31
|
+
- `dbt_pr_review({})` reviews the working tree against `origin/main`.
|
|
32
|
+
- `dbt_pr_review({ base: "origin/main", head: "HEAD", manifest_path: "target/manifest.json" })`
|
|
33
|
+
for an explicit PR range.
|
|
34
|
+
- The tool reads `.altimate/review.yml` for the per-repo rubric and `mode`.
|
|
35
|
+
|
|
36
|
+
2. **Read the signed envelope.** It contains the verdict, a risk tier
|
|
37
|
+
(trivial / lite / full), and findings grouped by severity
|
|
38
|
+
(critical / warning / suggestion), each with engine evidence.
|
|
39
|
+
|
|
40
|
+
3. **Present the verdict** exactly as returned. Group findings by severity.
|
|
41
|
+
If the run is **degraded** (no manifest/warehouse), state that lineage,
|
|
42
|
+
equivalence, and data-impact were NOT verified — it is a lint-only review.
|
|
43
|
+
|
|
44
|
+
4. **Respect the safety invariant.** An UNDECIDABLE equivalence result is a
|
|
45
|
+
WARNING, never a block. Never claim a refactor is unsafe when equivalence
|
|
46
|
+
could not be decided — recommend a data-diff instead.
|
|
47
|
+
|
|
48
|
+
## Configuration (`.altimate/review.yml`)
|
|
49
|
+
|
|
50
|
+
```yaml
|
|
51
|
+
mode: comment # comment (never blocks) | gate (blocks on REQUEST_CHANGES)
|
|
52
|
+
severityThreshold: suggestion
|
|
53
|
+
manifestPath: target/manifest.json
|
|
54
|
+
dialect: snowflake
|
|
55
|
+
reviewers: [] # empty = tier defaults; or pin lanes e.g. [lineage_breakage, semantic_change]
|
|
56
|
+
exclude:
|
|
57
|
+
- models/legacy/**
|
|
58
|
+
rubric:
|
|
59
|
+
blockOn: [lineage_breakage, contract_violation, pii_exposure, semantic_change]
|
|
60
|
+
warningPatternThreshold: 3
|
|
61
|
+
thresholds:
|
|
62
|
+
warehouseCostMinRows: 1000000
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Verdict rubric (defaults)
|
|
66
|
+
|
|
67
|
+
- **REQUEST_CHANGES** — any blocking-category `critical` (broken lineage with
|
|
68
|
+
downstream consumers, contract violation, PII exposure, proven non-equivalent
|
|
69
|
+
rewrite), or ≥3 warnings (risk pattern).
|
|
70
|
+
- **COMMENT** — only suggestions, or a single non-blocking warning.
|
|
71
|
+
- **APPROVE** — no findings.
|
|
72
|
+
|
|
73
|
+
In `comment` mode (default), REQUEST_CHANGES is posted as comments rather than
|
|
74
|
+
blocking the merge. Switch to `gate` per-repo once you trust the false-positive rate.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dbt-schema-verify
|
|
3
|
+
applyPaths:
|
|
4
|
+
- "dbt_project.yml"
|
|
5
|
+
- "**/dbt_project.yml"
|
|
6
|
+
description: |
|
|
7
|
+
REQUIRED after building or modifying ANY dbt model that has columns declared
|
|
8
|
+
in `schema.yml` / `_models.yml`. Run `altimate-dbt schema-verify --model
|
|
9
|
+
<name>` to diff actual columns against the spec, and treat any `mismatch`
|
|
10
|
+
verdict as "not done."
|
|
11
|
+
|
|
12
|
+
The most common reason "the build is green but the tests still fail" is
|
|
13
|
+
that the model produces the right *data values* in the wrong *column
|
|
14
|
+
shape* — extra columns, missing columns, wrong order, wrong types. Many
|
|
15
|
+
dbt equality tests grade the column tuple `(name, type, position)`
|
|
16
|
+
exactly, and the agent's prior bias is to add "helpful" extras
|
|
17
|
+
(`p1`/`p2`/`p3` rank breakdowns, name-resolved variants, lineage
|
|
18
|
+
metadata) or reorder columns "more logically." Both break the contract.
|
|
19
|
+
|
|
20
|
+
This skill enforces the mechanical check that catches those bugs before
|
|
21
|
+
declaring done. Use it before declaring any model task complete.
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
# dbt schema-verify
|
|
25
|
+
|
|
26
|
+
## When to invoke this skill — every time
|
|
27
|
+
|
|
28
|
+
Run `altimate-dbt schema-verify --model <name>` before declaring any of the
|
|
29
|
+
following tasks complete:
|
|
30
|
+
|
|
31
|
+
- Creating a new dbt model that has (or will have) a `schema.yml` entry
|
|
32
|
+
- Modifying an existing model whose `schema.yml` declares columns
|
|
33
|
+
- Refactoring a CTE into its own intermediate model
|
|
34
|
+
- Renaming columns or changing their order
|
|
35
|
+
- Changing materialization config in a way that re-creates the table
|
|
36
|
+
- Any task that says "match the schema", "produce these columns", "the
|
|
37
|
+
output should have columns X, Y, Z", or references a `_models.yml`
|
|
38
|
+
- Any task with `AUTO_*_equality` or `AUTO_*_existence` tests on a model
|
|
39
|
+
|
|
40
|
+
If the task touched N models, run schema-verify on **all N of them**, not
|
|
41
|
+
just the last one. A `build` is not a verify.
|
|
42
|
+
|
|
43
|
+
## How to run it
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
altimate-dbt schema-verify --model <name>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Note**: `altimate-dbt build --model <name>` already runs schema-verify
|
|
50
|
+
automatically after a successful build and includes the verdict in its
|
|
51
|
+
response under a `schema_verify` field. You will see the diff in the same
|
|
52
|
+
result that reported the build outcome — read it there before deciding
|
|
53
|
+
the task is done. If you need to re-check after editing, call
|
|
54
|
+
`schema-verify` directly.
|
|
55
|
+
|
|
56
|
+
Returns a structured JSON result:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"model": "int_asana__project_user_agg",
|
|
61
|
+
"verdict": "mismatch",
|
|
62
|
+
"expected_columns": ["project_id", "users", "number_of_users_involved"],
|
|
63
|
+
"actual_columns": ["project_id", "users"],
|
|
64
|
+
"columns_extra": [],
|
|
65
|
+
"columns_missing": ["number_of_users_involved"],
|
|
66
|
+
"columns_reordered": [],
|
|
67
|
+
"type_mismatches": []
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## How to read the verdict
|
|
72
|
+
|
|
73
|
+
| verdict | meaning | what to do |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `match` | actual columns match the spec exactly (case-insensitive on names) | DONE — proceed |
|
|
76
|
+
| `mismatch` | one or more of `columns_extra`, `columns_missing`, `columns_reordered`, `type_mismatches` is non-empty | NOT DONE — read the diff, fix the model SQL, rebuild, re-run schema-verify |
|
|
77
|
+
| `no-spec` | the model has no columns declared in `schema.yml` | DONE for shape-fidelity purposes — no contract to verify against |
|
|
78
|
+
|
|
79
|
+
## How to act on a `mismatch`
|
|
80
|
+
|
|
81
|
+
For each non-empty list, the fix is mechanical:
|
|
82
|
+
|
|
83
|
+
| Field | What it means | What to change in the model SQL |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `columns_extra` | columns in your model NOT in the spec | REMOVE them from the `SELECT` |
|
|
86
|
+
| `columns_missing` | columns in the spec NOT in your model | ADD them to the `SELECT` (compute them, or rename an existing column if you used a synonym) |
|
|
87
|
+
| `columns_reordered` | columns present in both but at different positions | REORDER the columns in your `SELECT` to match the spec's order |
|
|
88
|
+
| `type_mismatches` | declared `data_type` in spec disagrees with the warehouse's reported type | CAST in the `SELECT` or change the upstream source |
|
|
89
|
+
|
|
90
|
+
Then run `altimate-dbt build --model <name>` again, then re-run
|
|
91
|
+
`altimate-dbt schema-verify --model <name>` until verdict is `match`.
|
|
92
|
+
|
|
93
|
+
## Iron Rules
|
|
94
|
+
|
|
95
|
+
1. **The verdict is the source of truth, not your inspection.** Reading the
|
|
96
|
+
columns yourself and concluding "looks right to me" does not count.
|
|
97
|
+
Run the command and read its output.
|
|
98
|
+
2. **A `mismatch` is "not done", even if the build is green.** dbt build
|
|
99
|
+
only proves the SQL compiled and ran without errors. It does not prove
|
|
100
|
+
the column shape is correct. Equality tests grade shape AND values.
|
|
101
|
+
3. **Do not reinterpret the spec to make the model right.** The spec is
|
|
102
|
+
the contract. If the spec lists `supplier_company` and your model has
|
|
103
|
+
`supplier_id`, the answer is to fix your model, not to argue that
|
|
104
|
+
`supplier_id` is more useful.
|
|
105
|
+
4. **Run schema-verify on every model touched, not just the last one.**
|
|
106
|
+
The most common "almost-pass" is N-1 models passing and the Nth one
|
|
107
|
+
silently failing on column shape. Walk the list.
|
|
108
|
+
5. **Skip only on `no-spec`.** Do not skip on the grounds that the model
|
|
109
|
+
is small, or trivial, or "obvious." The spec is small only because
|
|
110
|
+
the dbt project author already curated it.
|
|
111
|
+
|
|
112
|
+
## Fallback when altimate-dbt is unavailable
|
|
113
|
+
|
|
114
|
+
If `which altimate-dbt` returns nothing, do the same diff by hand:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# 1. Read expected columns from any YAML spec under models/
|
|
118
|
+
# dbt allows any .yml filename; common patterns include schema.yml,
|
|
119
|
+
# _models.yml, models.yml, sources.yml, etc.
|
|
120
|
+
cat models/**/*.yml | grep -A 50 "name: <name>" # or: yq eval '...' models/**/*.yml
|
|
121
|
+
|
|
122
|
+
# 2. Read actual columns from the materialized table
|
|
123
|
+
dbt show --select <name> --limit 0
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Compare the two ordered lists. Produce the same four-bucket diff
|
|
127
|
+
(`columns_extra`, `columns_missing`, `columns_reordered`,
|
|
128
|
+
`type_mismatches`) in your head, and apply the same fix logic. The
|
|
129
|
+
mechanics don't change; only the tool name does.
|
|
130
|
+
|
|
131
|
+
## What this skill does NOT cover
|
|
132
|
+
|
|
133
|
+
- **Value-level correctness** — passing schema-verify only proves shape;
|
|
134
|
+
whether the *values* in each column are right is a separate check
|
|
135
|
+
(`altimate-dbt test` + dbt unit tests). Generate unit tests with the
|
|
136
|
+
`dbt-unit-tests` skill when the model has non-trivial transformation
|
|
137
|
+
logic.
|
|
138
|
+
- **Row count** — schema-verify compares columns, not rows. If a refactor
|
|
139
|
+
drops rows that should be preserved (common when extracting a CTE into
|
|
140
|
+
its own model — see `dbt-develop`'s "Refactoring a CTE into its own
|
|
141
|
+
model" section), schema-verify will pass while equality tests fail.
|
|
142
|
+
Check row counts separately.
|
|
143
|
+
- **Custom tests** — `check_*` and other non-AUTO tests check
|
|
144
|
+
task-specific business rules, not column shape. schema-verify can pass
|
|
145
|
+
while a custom test fails. Read the custom test SQL to understand
|
|
146
|
+
what's being asserted.
|
|
@@ -32,6 +32,19 @@ description: Generate dbt unit tests automatically for any model. Analyzes SQL l
|
|
|
32
32
|
3. **Use sql format for ephemeral models.** Dict format fails silently for ephemeral upstreams.
|
|
33
33
|
4. **Never weaken a test to make it pass.** If the test fails, the model logic may be wrong. Investigate before changing expected values.
|
|
34
34
|
5. **Compile before committing.** Always run `altimate-dbt test --model <name>` to verify tests compile and execute.
|
|
35
|
+
6. **Mock data MUST exercise the failure modes of every SQL construct in the model.** A unit test that only covers the happy path validates that the model handles easy inputs — it does not validate correctness. Before writing `given:` rows, list every SQL construct in the model and the boundary case it can mishandle, then ensure at least one mock row triggers each. Universal cases to always cover when the construct appears:
|
|
36
|
+
- **`LEFT JOIN` / `LEFT OUTER JOIN`** → at least one parent row with **no matching child** (catches `COUNT(*)` phantom rows, `SUM` over `NULL`, fan-out / dropout)
|
|
37
|
+
- **`INNER JOIN`** → at least one parent row whose child is filtered out by the JOIN condition (catches missing rows)
|
|
38
|
+
- **`COUNT(*)` / `COUNT(<col>)`** → row where the counted column is `NULL` (catches `COUNT(*)` vs `COUNT(col)` divergence)
|
|
39
|
+
- **`NULLIF(x, y)`** → row where `x = y` (so the result is `NULL`, exercising downstream `NULL`-handling)
|
|
40
|
+
- **`/` division** → row where the denominator is `0` or `NULL`
|
|
41
|
+
- **`CASE WHEN`** → at least one row matching each branch, including the implicit `ELSE NULL` if no explicit `ELSE` is set
|
|
42
|
+
- **`COALESCE` / `IFNULL`** → row where every argument is `NULL`
|
|
43
|
+
- **Window functions (`OVER`)** → a partition of size 1 (single-row group exercises rank/first/last edge cases), a row at the partition boundary, and a tie-break row (two rows with the same ORDER BY key)
|
|
44
|
+
- **Date arithmetic / date spines** → a row at the start of range, end of range, and a gap day with no events
|
|
45
|
+
- **Aggregations with `GROUP BY`** → at least one group of size 1 (often masks fan-out bugs) and one group whose key is `NULL`
|
|
46
|
+
- **Incremental merge keys** → both an "insert" row and an "update" row matching an existing key
|
|
47
|
+
If you can't think of a failure mode for a construct, you don't yet understand it well enough to test it — read the SQL again before guessing inputs.
|
|
35
48
|
|
|
36
49
|
## Core Workflow: Analyze -> Generate -> Refine -> Validate -> Write
|
|
37
50
|
|