diff --git a/packages/opencode/src/altimate/review/compiled.ts b/packages/opencode/src/altimate/review/compiled.ts index 05fe1a7ce..d6ce7ca6a 100644 --- a/packages/opencode/src/altimate/review/compiled.ts +++ b/packages/opencode/src/altimate/review/compiled.ts @@ -36,12 +36,20 @@ export async function dbtProjectName(cwd: string): Promise { } export interface CompiledResolverOptions { + /** dbt project root (the dir containing `dbt_project.yml`). */ cwd: string projectName?: string /** Directory holding HEAD-side compiled SQL (relative to cwd). */ headDir?: string /** Directory holding BASE-side compiled SQL (relative to cwd). */ baseDir?: string + /** Prefix within the repo-relative file path that maps to `cwd`. + * For a monorepo where the dbt project lives at `packages/dbt/`, callers + * pass `pathPrefix: "packages/dbt"` so a repo-relative path like + * `packages/dbt/models/foo.sql` resolves inside the dbt project root as + * `models/foo.sql` before joining with `target/compiled//`. + * Omit when `cwd` IS the repo root. */ + pathPrefix?: string } /** @@ -52,13 +60,35 @@ export function makeCompiledResolver(opts: CompiledResolverOptions) { const project = opts.projectName const headDir = opts.headDir ?? "target/compiled" const baseDir = opts.baseDir ?? "target-base/compiled" + // Normalise the prefix so both "" and "." mean "no prefix". Match against + // git-style forward slashes because `git diff --name-status` always emits + // POSIX separators regardless of platform. `path.relative()` on Windows + // returns backslashes, so callers passing that verbatim would produce a + // prefix that never matches — normalise here (codex R20 review HIGH). + const prefix = + opts.pathPrefix && opts.pathPrefix !== "." + ? opts.pathPrefix.replace(/\\/g, "/").replace(/\/+$/, "") + : "" return async (file: string, side: "old" | "new"): Promise => { if (!project) return undefined + // When the dbt project sits inside a subdir of the repo, `file` (from + // `git diff --name-status`) is repo-root relative and always uses + // POSIX separators. Strip the mapped prefix so it becomes dbt-root + // relative before joining with `cwd` (which IS the dbt root). Without + // this the compiled resolver silently misses in monorepo layouts. + const rel = prefix && (file === prefix || file.startsWith(prefix + "/")) ? file.slice(prefix.length + 1) : file const root = side === "new" ? headDir : baseDir - const full = path.join(opts.cwd, root, project, file) + const compiledRoot = path.join(opts.cwd, root, project) + // Containment check via realpath — matches makeContentResolver's + // symlink-safe read (coderabbit + cubic security review). A symlinked + // compiled artifact pointing outside `compiledRoot` is not read. try { - return await fs.readFile(full, "utf8") + const rootReal = await fs.realpath(compiledRoot) + const targetReal = await fs.realpath(path.join(compiledRoot, rel)) + const sep = path.sep + if (targetReal !== rootReal && !targetReal.startsWith(rootReal + sep)) return undefined + return await fs.readFile(targetReal, "utf8") } catch { return undefined } diff --git a/packages/opencode/src/altimate/review/dbt-patterns.ts b/packages/opencode/src/altimate/review/dbt-patterns.ts index 85b475aba..1af4fd588 100644 --- a/packages/opencode/src/altimate/review/dbt-patterns.ts +++ b/packages/opencode/src/altimate/review/dbt-patterns.ts @@ -1,4 +1,6 @@ import path from "node:path" +import YAML from "yaml" +import { Log } from "@/altimate/util/log" import { type Finding, type Severity, type ReviewCategory, makeFinding } from "./finding" import { type ChangedFile, classifyDbtFile } from "./diff-filter" import { type Rubric, clampSeverity, exclusionReason } from "./rubric" @@ -790,33 +792,433 @@ export function detectModelPatterns(file: ChangedFile, newSql: string | undefine * Detect removal of `unique` / `not_null` / `relationships` tests in a schema.yml * diff — the guardrail that catches fan-out/dupes is the thing being deleted. */ -export function detectSchemaYmlPatterns(file: ChangedFile, rubric: Rubric): Finding[] { +/** + * Structural extraction of `(model, column?, test)` tuples from a parsed + * schema.yml document. Handles: + * - Column-level tests under `models[*].columns[*].tests` / `.data_tests` + * - Model-level tests directly under `models[*].tests` / `.data_tests` + * (surrogate-key `unique`, etc. — no column) + * - Sources (`sources[*].tables[*].(columns[*].)tests`) since dbt supports + * the same test grammar there + * - Snapshots (`snapshots[*].(columns[*].)tests`) + * - Both bare (`- unique`) and block-form (`- relationships: {...}`) + * - Both `tests` and `data_tests` (dbt 1.8+ alias) + * + * Returns a Set of canonical `${modelOrSource}\x00${column}\x00${test}` keys + * (column is empty string for model-level tests). Yields nothing when the + * document isn't shaped like a schema.yml. + */ +function extractTestOccurrences(doc: unknown): Set { + const out = new Set() + if (!doc || typeof doc !== "object") return out + const d = doc as Record + + const emitTests = (entity: string, column: string, tests: unknown): void => { + if (!Array.isArray(tests)) return + for (const t of tests) { + const name = + typeof t === "string" + ? t + : t && typeof t === "object" + ? Object.keys(t as Record)[0] + : undefined + if (!name) continue + const n = name.toLowerCase() + // Restrict to the guardrail tests the detector cares about; a bespoke + // custom test being removed is not a signal at this level. + if (n === "unique" || n === "not_null" || n === "relationships") { + // PR #1027 consensus MINOR #4 — a column can carry MULTIPLE + // `relationships` tests pointing at different parents (e.g. one to + // dim_customers, one to legacy.dim_customers during a migration). + // Keying on `(entity, column, test)` alone collapses them into a + // single set entry, so dropping one silently doesn't surface. + // `unique` / `not_null` are single-instance semantically (a repeat is + // a no-op), so this discriminator only affects `relationships`. + // Block-form: `- relationships: {to: ..., field: ...}` → append the + // (to, field) tuple to the key. Bare string form: no discriminator + // (the block-form is the shape dbt requires for `relationships` + // anyway; a bare `- relationships` isn't a legal declaration). + let discriminator = "" + if (n === "relationships" && t && typeof t === "object") { + const args = (t as Record)[name] + // dbt 1.10.5+ nests test args under `arguments:`; earlier versions + // put them at the top level of the test's value map (cubic-review P3). + const argsObj = + args && typeof args === "object" ? (args as Record) : undefined + const nested = + argsObj?.arguments && typeof argsObj.arguments === "object" + ? (argsObj.arguments as Record) + : undefined + const to = String((nested ?? argsObj)?.to ?? "") + const field = String((nested ?? argsObj)?.field ?? "") + if (to || field) discriminator = `\x01${to}\x02${field}` + } + out.add(`${entity}\x00${column}\x00${n}${discriminator}`) + } + } + } + + const walkEntityWithColumns = (entityName: string | undefined, node: unknown): void => { + if (!entityName || !node || typeof node !== "object") return + const n = node as Record + // Model/source/snapshot-level tests: `tests:` or `data_tests:` right under the entity. + emitTests(entityName, "", n.tests) + emitTests(entityName, "", n.data_tests) + if (Array.isArray(n.columns)) { + for (const col of n.columns) { + if (!col || typeof col !== "object") continue + const c = col as Record + const cname = typeof c.name === "string" ? c.name : undefined + if (!cname) continue + emitTests(entityName, cname, c.tests) + emitTests(entityName, cname, c.data_tests) + } + } + } + + // `models:` — array of `{ name, columns?, tests? }` + if (Array.isArray(d.models)) { + for (const m of d.models) { + if (!m || typeof m !== "object") continue + const mm = m as Record + const mname = typeof mm.name === "string" ? mm.name : undefined + walkEntityWithColumns(mname, mm) + } + } + // `snapshots:` — same shape as models + if (Array.isArray(d.snapshots)) { + for (const s of d.snapshots) { + if (!s || typeof s !== "object") continue + const ss = s as Record + const sname = typeof ss.name === "string" ? ss.name : undefined + walkEntityWithColumns(sname, ss) + } + } + // `sources:` — `[{ name, tables: [{ name, columns?, tests? }] }]` + if (Array.isArray(d.sources)) { + for (const src of d.sources) { + if (!src || typeof src !== "object") continue + const srcObj = src as Record + const srcName = typeof srcObj.name === "string" ? srcObj.name : undefined + if (!Array.isArray(srcObj.tables)) continue + for (const t of srcObj.tables) { + if (!t || typeof t !== "object") continue + const tt = t as Record + const tname = typeof tt.name === "string" ? tt.name : undefined + const qualified = srcName && tname ? `${srcName}.${tname}` : tname || undefined + walkEntityWithColumns(qualified, tt) + } + } + } + // `seeds:` — leaf entity, same test grammar + if (Array.isArray(d.seeds)) { + for (const s of d.seeds) { + if (!s || typeof s !== "object") continue + const ss = s as Record + const sname = typeof ss.name === "string" ? ss.name : undefined + walkEntityWithColumns(sname, ss) + } + } + return out +} + +/** + * Fallback removed-test detector for callers that supply only the diff + * (no old/new content). Kept intentionally conservative: matches removed + * lines that look like `- unique | not_null | relationships`, subtracts any + * added line with the same trimmed text. Cannot distinguish "moved to + * another column" from "removed from this column" — that requires content. + * The old-string dedup false-negative on sibling columns is inherent to + * diff-only input; when structural content is available we use the + * structural path (`extractTestOccurrences` diff of old vs new). + */ +function fallbackRemovedTestLines(diff: string | undefined): string[] { + if (!diff) return [] + const added: string[] = [] + const removed: string[] = [] + for (const raw of diff.split("\n")) { + if (raw.startsWith("+") && !raw.startsWith("+++")) added.push(raw.slice(1)) + else if (raw.startsWith("-") && !raw.startsWith("---")) removed.push(raw.slice(1)) + } + const testLine = /^\s*-\s*(unique|not_null|relationships)\b/i + const removedTests = removed.filter((l) => testLine.test(l)) + const addedTrim = new Set(added.map((l) => l.trim())) + return removedTests.filter((l) => !addedTrim.has(l.trim())) +} + +export interface SchemaYmlDetectContent { + oldContent?: string + newContent?: string +} + +/** + * Detect removed data tests in a schema.yml diff. + * + * PREFERRED path (production, via orchestrator): pass full old/new content in + * `opts` — the detector parses both YAML documents, diffs by + * `(entity, column, test)` tuples, and emits one finding per genuine removal. + * This captures model-level tests (no column), sibling-column edge cases + * (unique removed from column X while column Y still declares it), quoted / + * commented YAML names, and any layout / indent style the parser accepts. + * + * FALLBACK path (callers that only have the raw diff, e.g. unit tests or + * pre-collected CI diffs without a content resolver): use the string-based + * removed-line detection preserved from the pre-R18 detector. It cannot + * tell "removed" from "moved to another column" for the same test type on + * a sibling column — that limitation is inherent to diff-only input. + */ +export function detectSchemaYmlPatterns( + file: ChangedFile, + rubric: Rubric, + opts: SchemaYmlDetectContent = {}, +): Finding[] { const kind = classifyDbtFile(file.path) - if (kind !== "schema_yml" || file.status === "deleted") return [] - const { added, removed } = splitDiff(file.diff) - const removedTests = removed.filter((l) => /^\s*-\s*(unique|not_null|relationships)\b/i.test(l)) - // A test still present (just moved) shouldn't fire. - const addedTests = new Set(added.map((l) => l.trim())) - const genuinelyRemoved = removedTests.filter((l) => !addedTests.has(l.trim())) - if (!genuinelyRemoved.length) return [] - const removedUnique = genuinelyRemoved.some((l) => /\bunique\b/i.test(l)) - const f = makeFinding({ - severity: removedUnique ? "warning" : "suggestion", - category: "test_coverage", - title: `${file.path.split("/").pop()}: removed ${genuinelyRemoved.length} data test(s)`, - body: - "This change deletes `unique`/`not_null`/`relationships` test(s) — the guardrails that catch fan-out, duplicate, " + - "and broken-FK regressions. Removing a `unique` test on a grain key is how silent duplicate bugs ship. " + - "Confirm the test is genuinely obsolete, not removed to make CI green.", - file: file.path, - confidence: "high", - evidence: { - tool: "dbt-patterns", - result: { rule: "removed_tests", removed: genuinelyRemoved.map((l) => l.trim()) }, - }, - ruleKey: "test_coverage:removed-tests", - }) - return [{ ...f, severity: clampSeverity(f.category, f.severity, f.confidence) }].filter( - (x) => !exclusionReason(x, rubric), - ) + if (kind !== "schema_yml") return [] + + const removals: Array<{ + model: string + column: string + test: string + /** Extra discriminator (e.g. `to:field` for a `relationships` test) so + * multiple same-named tests on one column don't collapse via ruleKey. + * Empty for `unique` / `not_null` and for fallback (diff-only) findings. */ + testTag?: string + }> = [] + let usedStructural = false + + // Deleting a whole schema.yml removes every test declared in it — arguably + // a bigger removal than dropping a single test. Treat the new side as empty + // and diff the old document against `{}` so every prior test surfaces as a + // removal (cubic-review P2). Requires `oldContent` from the caller. + const isDeletedFile = file.status === "deleted" + + // PREFERRED: structural YAML diff when we have both sides' content. + // + // "Added" file case (no `oldContent` supplied AND status marks the file as + // newly-added upstream) — the old side is empty, so there's nothing to + // remove; using the structural path with an empty old set is correct. + // + // "Deleted" file case — the new side is empty; use the structural path + // with an empty new set so every old test surfaces as a removal. + // + // "Modified but resolver returned undefined" case (`oldContent === undefined` + // for a file whose status is `modified` or `renamed`) — the resolver couldn't + // read the old side. Do NOT treat this as an added file (would silently drop + // real removals). Fall through to the diff-only fallback below. + const isAddedFile = file.status === "added" + const canUseStructural = + (isDeletedFile + ? opts.oldContent !== undefined + : opts.newContent !== undefined && + // For a real modification/rename we require both sides; without oldContent + // fall back to diff parsing so removals in the diff still surface. + (isAddedFile || opts.oldContent !== undefined)) + + if (canUseStructural) { + let oldDoc: unknown = undefined + let newDoc: unknown = undefined + if (opts.newContent !== undefined) { + try { + newDoc = YAML.parse(opts.newContent) + } catch (err) { + // Debug telemetry: YAML parsers can trip on Jinja injected into a + // schema.yml or on non-ASCII quirks; log so failures are diagnosable + // rather than silently demoting to the fallback path. + Log.create({ service: "review", tag: "detectSchemaYmlPatterns" }).warn( + `YAML.parse failed on new content of ${file.path}; falling back to diff-only detection`, + err instanceof Error ? { error: err.message } : undefined, + ) + newDoc = undefined + } + } + if (opts.oldContent !== undefined) { + try { + oldDoc = YAML.parse(opts.oldContent) + } catch (err) { + Log.create({ service: "review", tag: "detectSchemaYmlPatterns" }).warn( + `YAML.parse failed on old content of ${file.path}; falling back to diff-only detection`, + err instanceof Error ? { error: err.message } : undefined, + ) + oldDoc = undefined + } + } + // Only commit to the structural path when the appropriate sides parsed: + // - deleted → old must parse; new is treated as `{}` + // - added → new must parse; old is treated as `{}` + // - modified/renamed → both must parse + // This preserves "unparseable YAML" → fallback rather than + // "structural with empty side = every test looks removed/added". + const canCommit = isDeletedFile + ? oldDoc !== undefined + : newDoc !== undefined && (isAddedFile || oldDoc !== undefined) + if (canCommit) { + const oldSet = extractTestOccurrences(oldDoc ?? {}) + const newSet = extractTestOccurrences(isDeletedFile ? {} : newDoc) + for (const key of oldSet) { + if (newSet.has(key)) continue + const [model, column, testField] = key.split("\x00") + // `testField` may carry a `\x01\x02` discriminator for + // `relationships` tests so multiple relationships on the same column + // don't collapse to one removal (MINOR #4). Strip it for display; + // retain the internal `\x02` separator in `testTag` for the ruleKey + // — a colon-joined form would collide when either arg contains `:` + // (e.g. `to='a:b'` vs `field='b:c'`, codex R20 review minor). + // ruleKey is hashed for fingerprinting; control chars survive. + const [test, tagPayload] = testField.split("\x01") + const testTag = tagPayload ?? "" + removals.push({ model, column, test, testTag }) + } + usedStructural = true + } + } + + // FALLBACK: line-based detection for diff-only callers (unit tests, offline + // CI diffs) OR when the structural path couldn't parse both sides. + // + // No local deduplication: each entry in `removedLines` is already one match + // per raw removed line, and model/column are always empty on this path — a + // (model, column, test) dedup key would collapse to just `test` and silently + // merge distinct removals of the same test type on different columns. + // + // Downstream, `runReview` runs a global `dedupe` step that fingerprints + // findings by (category, file, model, column, ruleKey). Two fallback + // findings that share `file`, empty `model`, empty `column`, and a + // test-name-only `ruleKey` would still collapse there. We tag each fallback + // finding with a stable occurrence-index discriminator (`#0`, `#1`, …) so + // distinct removals in the same diff survive the global dedupe. The index + // is per-diff, not per-file-history — sufficient for one review pass and + // stable across dry-repeats of the same diff. + const fallbackDiscriminators: string[] = [] + if (!usedStructural) { + const removedLines = fallbackRemovedTestLines(file.diff) + let idx = 0 + for (const l of removedLines) { + const m = /^\s*-\s*(unique|not_null|relationships)\b/i.exec(l) + if (!m) continue + removals.push({ model: "", column: "", test: m[1].toLowerCase() }) + fallbackDiscriminators.push(`#${idx++}`) + } + } + + if (!removals.length) return [] + + const findings: Finding[] = [] + const isMartLayer = /(^|\/)(marts?|reporting)\//.test(file.path) + const layerLabel = isMartLayer ? "mart-layer" : "declared" + // Emit the "N removals across models A/B/C" summary line ONCE per file + // (on the first finding), not once per distinct model — otherwise a diff + // that removes tests from three models produces three copies of the same + // global summary. A single boolean guard is enough. + let summaryEmitted = false + + // Consumed in lock-step with the removals loop below (fallback path only). + // Using an index avoids `shift()`, which would be O(N) per call on the + // discriminator array — negligible on 2-3 removals but O(N²) on a large + // schema.yml PR that drops many tests. + let fallbackIdx = 0 + for (const r of removals) { + const isUniquenessSignal = r.test === "unique" + const notNullOnLikelyPK = + r.test === "not_null" && !!r.column && /(_id$|^id$|_key$)/i.test(r.column) + // Uniqueness OR not_null on an id/key column = warning (silent-dup / null-PK + // risk); other not_null / relationships removals = suggestion. + const sev: Severity = isUniquenessSignal || notNullOnLikelyPK ? "warning" : "suggestion" + + // Title / body vary based on whether structural attribution is available. + const attributed = !!r.model && !!r.column + const modelLevel = !!r.model && !r.column + const filename = file.path.split("/").pop() + const locKey = attributed ? `${r.model}.${r.column}` : modelLevel ? `${r.model} (model-level)` : "(unknown location)" + const title = attributed + ? `${filename}: ${r.test} test removed from ${locKey}` + : modelLevel + ? `${filename}: model-level ${r.test} test removed from \`${r.model}\`` + : `${filename}: ${r.test} data test removed` + + let bodyLead: string + if (attributed) { + bodyLead = `The \`${r.test}\` data test was removed from column \`${r.column}\` on model \`${r.model}\`. ` + } else if (modelLevel) { + bodyLead = `A model-level \`${r.test}\` test was removed from \`${r.model}\`. ` + } else { + bodyLead = `A \`${r.test}\` data test was removed from this schema file. ` + } + let bodyRationale: string + if (isUniquenessSignal) { + bodyRationale = + `Removing a \`unique\` test on a ${layerLabel} key is how silent duplicate rows ship — ` + + `downstream joins fan out, aggregates double-count, and no test catches it. Restore ` + + `the test, or explicitly document why the grain no longer needs to be unique on this column.` + } else if (notNullOnLikelyPK) { + bodyRationale = + `Removing \`not_null\` on what looks like an identifier column (\`${r.column}\`) means nulls ` + + `will slip through into downstream joins and aggregates. Restore the test unless the column ` + + `is genuinely nullable now.` + } else { + bodyRationale = + `Removing an existing data test is a silent-regression risk. Confirm the test is genuinely ` + + `obsolete, not dropped to make CI green.` + } + // Emit the aggregate summary once per file, on the first finding, and + // only when there are multiple removals to summarise. Uses the file-scoped + // `summaryEmitted` flag rather than a per-model guard so a diff touching + // three models emits ONE summary, not three. Distinct-models computation + // is gated inside the branch so we don't pay for the Set/spread/map on + // every loop iteration when the summary won't be attached. + const shouldEmitSummary = !summaryEmitted && removals.length > 1 + let bodyTail = "" + if (shouldEmitSummary) { + summaryEmitted = true + const distinctModels = [...new Set(removals.map((x) => x.model).filter(Boolean))] + const modelClause = distinctModels.length + ? ` on model(s) ${distinctModels.map((m) => `\`${m}\``).join(", ")}` + : "" + // Scoped to THIS schema file — the removals loop counts the current + // file's removals only, not the PR's aggregate. Wording was previously + // "This PR removes N data tests in total" which misled reviewers on + // multi-file diffs (per PR #1027 consensus MINOR #3). A global-PR + // summary would need to move to orchestrate.ts. + bodyTail = `\n\n_This schema file drops ${removals.length} data tests${modelClause}._` + } + + // ruleKey feeds the finding fingerprint (finding.ts:107). For attributed + // (structural) findings, `(model.column.test)` is unique per removal in + // the file. For unattributed fallback findings, `(?.?.test)` collapses + // distinct removals of the same test type — we append the fallback + // discriminator so each removed line becomes a distinct finding + // downstream of the global dedupe. + const discriminator = attributed || modelLevel ? "" : `.${fallbackDiscriminators[fallbackIdx++] ?? "#?"}` + findings.push( + makeFinding({ + severity: clampSeverity("test_coverage", sev, "high"), + category: "test_coverage", + title, + body: bodyLead + bodyRationale + bodyTail, + file: file.path, + // Surface the extracted attribution on the top-level Finding so + // downstream consumers (dedupe, formatting, telemetry) see it — + // not just inside `evidence.result` (cubic-review P3). + model: r.model || undefined, + column: r.column || undefined, + confidence: "high", + evidence: { + tool: "dbt-patterns", + result: { + rule: "removed_tests", + model: r.model || undefined, + column: r.column || undefined, + test: r.test, + attribution: attributed ? "column" : modelLevel ? "model-level" : "diff-only", + }, + }, + // testTag suffix (`:to:field` for `relationships`) survives the global + // finding fingerprint dedupe so multiple relationships on the same + // column emit distinct findings (MINOR #4). + ruleKey: `test_coverage:removed-tests:${r.model || "?"}.${r.column || "?"}.${r.test}${ + r.testTag ? `.${r.testTag}` : "" + }${discriminator}`, + }), + ) + } + return findings.filter((x) => !exclusionReason(x, rubric)) } diff --git a/packages/opencode/src/altimate/review/diff-filter.ts b/packages/opencode/src/altimate/review/diff-filter.ts index 234c04c29..4079fb640 100644 --- a/packages/opencode/src/altimate/review/diff-filter.ts +++ b/packages/opencode/src/altimate/review/diff-filter.ts @@ -61,7 +61,11 @@ export function classifyDbtFile(path: string): DbtFileKind { const isYaml = p.endsWith(".yml") || p.endsWith(".yaml") if (/(^|\/)(dbt_project|profiles|packages|dependencies)\.ya?ml$/.test(p)) return "project_config" if (/(^|\/)macros\//.test(p)) return "macro" - if (/(^|\/)snapshots\//.test(p)) return "snapshot" + // Snapshots split by extension: `.sql` files are snapshot definitions + // (tier-forcing, catalog-scoped), YAML property files under `snapshots/` + // are schema files (declared tests + column metadata) and should route to + // `schema_yml` so the test-removal detector runs on them. + if (/(^|\/)snapshots\//.test(p) && p.endsWith(".sql")) return "snapshot" if (/(^|\/)seeds\//.test(p) && p.endsWith(".csv")) return "seed" if (/(^|\/)tests\//.test(p) && p.endsWith(".sql")) return "test" if (/(^|\/)analyses\//.test(p)) return "analysis" diff --git a/packages/opencode/src/altimate/review/format.ts b/packages/opencode/src/altimate/review/format.ts index c6bebbc4e..0befe5883 100644 --- a/packages/opencode/src/altimate/review/format.ts +++ b/packages/opencode/src/altimate/review/format.ts @@ -28,7 +28,12 @@ export function verdictHeadline(env: VerdictEnvelope): string { [critical && `${critical} critical`, warning && `${warning} warning`, suggestion && `${suggestion} suggestion`] .filter(Boolean) .join(", ") || "no findings" - return `${VERDICT_LABEL[env.verdict]} — ${counts} (${env.tier} tier)` + // tierClassified is optional in the schema — guard against externally-built + // envelopes that mark tierForced without threading the original classification. + const tierLabel = env.tierForced + ? `${env.tier} tier — forced (was ${env.tierClassified ?? "unknown"})` + : `${env.tier} tier` + return `${VERDICT_LABEL[env.verdict]} — ${counts} (${tierLabel})` } /** Full PR/MR summary comment body (markdown), prefixed with the dedup marker. */ @@ -43,6 +48,37 @@ export function renderSummary(env: VerdictEnvelope): string { ) } + // G1 (Round 18) — surface classifier reasons when --explain-tier populated + // them, so a customer can see why the review ran at this tier. Also surfaces + // when --force-tier bypassed the classifier (tierReasons is auto-populated). + // Truncate the rendered summary on very large diffs: classifyPR appends one + // reason per file forcing FULL tier (e.g. every touched schema.yml or every + // PII column), which bloats the comment on wide PRs. The full list stays in + // the signed envelope's tierReasons[]; the summary shows the first 8. + if (env.tierReasons && env.tierReasons.length) { + const RENDER_CAP = 8 + // Pick an inline-code-span fence longer than any backtick run inside `r` + // so a path like `packages/…/foo`bar`.sql` cannot terminate the span + // (cubic-review P3). + const shown = env.tierReasons + .slice(0, RENDER_CAP) + .map((r) => { + const runs = r.match(/`+/g) + const maxRun = runs ? Math.max(...runs.map((run) => run.length)) : 0 + const fence = "`".repeat(maxRun + 1) + // If the reason itself starts/ends with a backtick, pad with a space so + // the leading/trailing backtick isn't glued to the fence. + const pad = /^`|`$/.test(r) ? " " : "" + return `${fence}${pad}${r}${pad}${fence}` + }) + .join(", ") + const overflow = + env.tierReasons.length > RENDER_CAP + ? ` (+${env.tierReasons.length - RENDER_CAP} more in verdict envelope)` + : "" + lines.push(`> 🧭 **Tier: ${env.tier}** — ${shown}${overflow}`, "") + } + if (!env.findings.length) { lines.push("No issues found in the changed dbt models. 🎉", "") } else { diff --git a/packages/opencode/src/altimate/review/git.ts b/packages/opencode/src/altimate/review/git.ts index 595c61f43..447822a31 100644 --- a/packages/opencode/src/altimate/review/git.ts +++ b/packages/opencode/src/altimate/review/git.ts @@ -62,10 +62,36 @@ export async function collectChangedFiles(opts: CollectOptions): Promise { + try { + // Realpath both sides so a symlink IN the repo, or a repo checked out + // under a symlinked path (`/var` → `/private/var` on macOS), still + // compares apples to apples. Missing realpath calls on the target fail + // fast into the catch (no read attempted). + const rootReal = await fs.realpath(root) + const targetReal = await fs.realpath(path.join(root, rel)) + // Ensure `targetReal` is inside `rootReal` using a separator-aware + // startsWith check so `/repo-backup` doesn't count as inside `/repo`. + const sep = path.sep + if (targetReal !== rootReal && !targetReal.startsWith(rootReal + sep)) return undefined + return await fs.readFile(targetReal, "utf8") + } catch { + return undefined + } +} + /** Build a getContent(path, side) resolver over git refs / the working tree. * `renames` maps a new path → its old path so the "old" side of a renamed file - * resolves from where it actually lived at `base` (not the post-rename path). */ -export function makeContentResolver(opts: CollectOptions & { renames?: Map }) { + * resolves from where it actually lived at `base` (not the post-rename path). + * `gitRoot` is the repository top-level (from `git rev-parse --show-toplevel`). + * When omitted, working-tree reads fall back to `opts.cwd`, which is only + * correct when the CLI is invoked from the repo root. */ +export function makeContentResolver(opts: CollectOptions & { renames?: Map; gitRoot?: string }) { return async (file: string, side: "old" | "new"): Promise => { try { if (side === "old") { @@ -75,13 +101,38 @@ export function makeContentResolver(opts: CollectOptions & { renames?: Map { + try { + const out = await git(["rev-parse", "--show-toplevel"], cwd) + const root = out.replace(/[\r\n]+$/, "") + return root || undefined + } catch { + return undefined + } +} + /** Resolve a sensible default base ref (merge-base with origin/main/master). */ export async function defaultBaseRef(cwd: string): Promise { for (const candidate of ["origin/main", "origin/master", "main", "master"]) { diff --git a/packages/opencode/src/altimate/review/orchestrate.ts b/packages/opencode/src/altimate/review/orchestrate.ts index 68cefafde..07c60565b 100644 --- a/packages/opencode/src/altimate/review/orchestrate.ts +++ b/packages/opencode/src/altimate/review/orchestrate.ts @@ -190,6 +190,10 @@ export interface OrchestrateInput { /** PR metadata passed to the AI reviewer for intent checking. */ prTitle?: string prBody?: string + /** G1 — attach the classifier's reason list to the envelope. */ + explainTier?: boolean + /** G2 — override the classifier's tier decision. Envelope carries tierForced:true. */ + forceTier?: "trivial" | "lite" | "full" } /** Derive the dbt model name from a model file path. */ @@ -1088,14 +1092,31 @@ export async function runReview(input: OrchestrateInput): Promise 0 ? !anyManifest : reviewable.length === 0 - const tier = classifyPR(reviewable, { + const tierResult = classifyPR(reviewable, { blastRadiusOf: (p) => { const c = ctxByPath.get(p) return c ? c.impact.directCount + c.impact.transitiveCount : 0 }, touchesPiiOf: (f) => (ctxByPath.get(f.path)?.pii.length ?? 0) > 0, isComplexOf: (f) => ctxByPath.get(f.path)?.complex ?? false, - }).tier + }) + const classifiedTier = tierResult.tier + // G2 — --force-tier overrides the classifier. Envelope records both the + // forced tier and the original classification whenever the flag is passed + // (regardless of whether the forced value happens to match the classifier), + // so audits can see the bypass every time the flag was used. The reasons + // list gets a leading "forced" marker so downstream doesn't confuse the + // forced tier for a natural one. Codex-R18-review fix — the earlier + // `!== classifiedTier` gate silently hid the bypass when the forced tier + // matched the classifier's result. + const tier = input.forceTier ?? classifiedTier + const tierForced = input.forceTier !== undefined + const tierReasons = tierForced + ? [ + `forced via --force-tier=${input.forceTier} (classifier said ${classifiedTier})`, + ...tierResult.reasons, + ] + : tierResult.reasons const lanes = new Set(input.config.reviewers.length ? input.config.reviewers : TIER_LANES[tier]) @@ -1196,8 +1217,38 @@ export async function runReview(input: OrchestrateInput): Promise f.kind === "schema_yml") + if (schemaFiles.length) { + const schemaFindingSets = await Promise.all( + schemaFiles.map(async (file) => { + const oldRef = file.oldPath ?? file.path + const [oldContent, newContent] = await Promise.all([ + file.status !== "added" ? getContent?.(oldRef, "old") : Promise.resolve(undefined), + file.status !== "deleted" ? getContent?.(file.path, "new") : Promise.resolve(undefined), + ]) + return detectSchemaYmlPatterns(file, input.rubric, { oldContent, newContent }) + }), + ) + for (const findings of schemaFindingSets) all.push(findings) } // Architectural dedup: the regex `dbt-patterns`/`rule-catalog` layer is a @@ -1349,6 +1400,9 @@ export async function runReview(input: OrchestrateInput): Promise { } } +/** + * G3 (Round 18) — walk upward from `cwd` looking for the nearest + * `dbt_project.yml`; if found, return the adjacent `target/manifest.json` + * (when it exists). Anchored on `dbt_project.yml` so we never pick up a + * `target/` from an unrelated tool (e.g. an Airflow project) that happens + * to live above us on the filesystem. + * + * We DO NOT re-run `dbt compile` here — that could touch a real warehouse or + * consume credentials. The customer is still responsible for compiling; we + * just find the existing artifact when they didn't pass `--manifest`. + * + * Returns undefined when no dbt project is found upward, or when the found + * project has no compiled manifest. + */ +async function autoDiscoverManifest(cwd: string): Promise<{ path: string; projectRoot: string } | undefined> { + // Walk up looking for dbt_project.yml so the manifest we auto-discover is + // demonstrably tied to a dbt project (never `target/manifest.json` from an + // unrelated cwd, e.g. Airflow's `target/`). `path.dirname(root) === root` + // on every platform, so once we reach the filesystem root the next step + // is a fixed point — exit at that point (NIT #6 tidy from consensus review). + for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) { + for (const fn of ["dbt_project.yml", "dbt_project.yaml"]) { + try { + await access(path.join(dir, fn)) + const candidate = path.join(dir, "target", "manifest.json") + try { + await access(candidate) + return { path: candidate, projectRoot: dir } + } catch { + return undefined // dbt project found but not compiled — respect that + } + } catch { + /* keep walking */ + } + } + if (path.dirname(dir) === dir) return undefined + } +} + +/** Warn to stderr when the manifest looks stale relative to changed files. + * Only considers dbt-relevant files (SQL, YAML, Python models, seed CSV, + * docs markdown) — changes to `README.md` at repo root, `.github/`, + * `package.json`, etc. don't affect whether the compiled manifest is still + * valid, so their mtimes shouldn't trigger a stale warning. */ +/** Exported for tests — see review-run-stale.test.ts. */ +export function isManifestAffecting(rel: string): boolean { + // dbt source directories — code (sql/py), schema (yml), seeds (csv), and + // docs blocks (md files under models/ / snapshots/ / analyses/ / etc.). + if (/(^|\/)(models|seeds|snapshots|macros|tests|analyses)\/.*\.(sql|py|yml|yaml|csv|md)$/i.test(rel)) return true + // Top-level dbt project config files. + if (/(^|\/)(dbt_project|packages|profiles|dependencies)\.ya?ml$/i.test(rel)) return true + return false +} + +async function warnIfStale(manifestAbs: string, changedPaths: string[], fsRoot: string): Promise { + try { + const manifestMtime = (await stat(manifestAbs)).mtimeMs + for (const rel of changedPaths) { + if (!isManifestAffecting(rel)) continue + // `changedPaths` are repo-root relative (from `git diff --name-status`), + // so root at the git top-level rather than the caller's cwd. When the + // CLI is invoked from a subdir the naive path.join(cwd, rel) points at + // a non-existent path and the stale check silently no-ops. + const abs = path.isAbsolute(rel) ? rel : path.join(fsRoot, rel) + try { + const changedMtime = (await stat(abs)).mtimeMs + if (changedMtime > manifestMtime) { + process.stderr.write( + `⚠️ manifest ${manifestAbs} appears stale — ${rel} was modified after the manifest was written. ` + + `Re-run \`dbt compile\` (or \`dbt build\`) to refresh before reviewing.\n`, + ) + return + } + } catch { + /* file not on disk (e.g. removed by the change) — skip */ + } + } + } catch { + /* manifest unreadable → detectDialect will have already returned undefined */ + } +} + export async function reviewPullRequest(opts: ReviewPullRequestOptions): Promise { const config = await loadReviewConfig(opts.cwd) if (opts.manifestPath) config.manifestPath = opts.manifestPath @@ -87,19 +174,61 @@ export async function reviewPullRequest(opts: ReviewPullRequestOptions): Promise const needGit = !opts.changedFiles || !opts.getContent const base = opts.base ?? (needGit ? await defaultBaseRef(opts.cwd) : "") const changedFiles = opts.changedFiles ?? (await collectChangedFiles({ base, head: opts.head, cwd: opts.cwd })) + // Resolve the repo top-level once; used to root working-tree FS reads, the + // stale-manifest existence check, and the compiled-SQL resolver's path + // mapping. Falls back to opts.cwd when we couldn't resolve it (non-git or + // bare-repo contexts) — safe for the repo-root-invoked case but not helpful + // in the subdir-invocation case that this addresses. + const gitRoot = (await gitRepoRoot(opts.cwd)) ?? opts.cwd // Map renamed files → their old path so getContent resolves the "old" side // from where the file lived at `base`. const renames = new Map( changedFiles.filter((f) => f.status === "renamed" && f.oldPath).map((f) => [f.path, f.oldPath as string]), ) - const getContent = opts.getContent ?? makeContentResolver({ base, head: opts.head, cwd: opts.cwd, renames }) + const getContent = + opts.getContent ?? makeContentResolver({ base, head: opts.head, cwd: opts.cwd, renames, gitRoot }) // Resolve the manifest against the PROJECT being reviewed (cwd), not the // binary's process.cwd() — otherwise a relative path silently misses when the // CLI is invoked from elsewhere, degrading every review to lint-only. - const manifestAbs = path.isAbsolute(config.manifestPath) + let manifestAbs = path.isAbsolute(config.manifestPath) ? config.manifestPath : path.join(opts.cwd, config.manifestPath) + // The "effective dbt project root" for downstream lookups (dbtProjectName, + // compiled-SQL resolver). Starts as opts.cwd — G3 auto-discovery updates it + // when it finds a dbt_project.yml in an ancestor directory, so the compiled + // resolver and project-name lookups target the discovered project rather + // than the CLI's working directory (which may be a subdir). + let dbtRoot = opts.cwd + // G3 (Round 18) — auto-discovery when the caller didn't pass `--manifest` and + // the config-relative resolve above doesn't point at an existing file. Walk + // upward for the dbt project root and use its `target/manifest.json`. Explicit + // `--manifest` always wins (see run.ts opts.manifestPath override at the top). + if (!opts.manifestPath) { + let manifestExists = false + try { + await access(manifestAbs) + manifestExists = true + } catch { + /* fall through to auto-discovery */ + } + if (!manifestExists) { + const discovered = await autoDiscoverManifest(opts.cwd) + if (discovered) { + manifestAbs = discovered.path + dbtRoot = discovered.projectRoot + process.stderr.write( + `ℹ️ auto-discovered dbt manifest at ${discovered.path} ` + + `(dbt project root: ${discovered.projectRoot}). Pass --manifest to override.\n`, + ) + } + } + } + // Freshness check: warn (don't fail) when the manifest predates changed files. + // Skip when we're diffing against the working tree (mtime signal is noisy + // during active edits) — only warn when the caller explicitly pinned a head + // ref, which is the CI / bench shape where a stale manifest is a real risk. + if (opts.head) await warnIfStale(manifestAbs, changedFiles.map((f) => f.path), gitRoot) // Resolve the SQL dialect: explicit config wins; otherwise auto-detect from // the dbt manifest's `adapter_type` (so a BigQuery/Redshift project isn't @@ -117,8 +246,36 @@ export async function reviewPullRequest(opts: ReviewPullRequestOptions): Promise // Prefer dbt's COMPILED SQL (target/compiled) for the engine lanes — the clean // approach (Datafold/Recce/Fusion all render-then-analyze rather than parse Jinja). - const projectName = await dbtProjectName(opts.cwd) - const getCompiled = opts.getContent ? undefined : makeCompiledResolver({ cwd: opts.cwd, projectName }) + // Use `dbtRoot` (the discovered project root when G3 auto-discovery fired, + // else opts.cwd) so a subdir invocation still finds `target/compiled/…` + // next to the discovered manifest instead of falling back to raw Jinja. + const projectName = await dbtProjectName(dbtRoot) + // Repo-relative file paths need the git-root → dbt-root prefix stripped + // so `packages/dbt/models/foo.sql` resolves to `models/foo.sql` inside the + // dbt project. `path.relative` returns "" when dbtRoot === gitRoot (the + // repo-root-invoked case) — makeCompiledResolver's `pathPrefix` treats + // that as a no-op. + // + // Canonicalise both roots via `fs.realpath` before computing the relative + // path. `gitRoot` from `git rev-parse --show-toplevel` is already + // symlink-resolved; `dbtRoot` is not, so on macOS (`/var` → `/private/var`) + // or any other symlinked-parent layout the `path.relative` result was a + // meaningless climb path (`../../var/...`) that never matched incoming + // repo-relative paths, and compiled SQL was silently missed + // (cubic-review P2 + kilo suggestion). Falls back gracefully when the + // realpath call fails (e.g. path deleted mid-run). + let gitRootReal = gitRoot + let dbtRootReal = dbtRoot + try { + gitRootReal = await realpath(gitRoot) + dbtRootReal = await realpath(dbtRoot) + } catch { + /* keep original values on realpath failure */ + } + const pathPrefix = path.relative(gitRootReal, dbtRootReal) + const getCompiled = opts.getContent + ? undefined + : makeCompiledResolver({ cwd: dbtRootReal, projectName, pathPrefix }) return runReview({ changedFiles, @@ -135,5 +292,7 @@ export async function reviewPullRequest(opts: ReviewPullRequestOptions): Promise aiReview: opts.noAi || config.ai === false ? undefined : runAiReview, prTitle: opts.prTitle, prBody: opts.prBody, + explainTier: opts.explainTier, + forceTier: opts.forceTier, }) } diff --git a/packages/opencode/src/altimate/review/verdict.ts b/packages/opencode/src/altimate/review/verdict.ts index d499695d9..ef876688f 100644 --- a/packages/opencode/src/altimate/review/verdict.ts +++ b/packages/opencode/src/altimate/review/verdict.ts @@ -92,6 +92,13 @@ export const VerdictEnvelope = z.object({ idealVerdict: Verdict, mode: ReviewMode, tier: RiskTier, + /** G1 — reasons the classifier assigned this tier (only when --explain-tier). */ + tierReasons: z.array(z.string()).optional(), + /** G2 — true when --force-tier bypassed the classifier. Included in signature so + * a tampered envelope claiming natural tier can't fake a forced run. */ + tierForced: z.boolean().optional(), + /** The classifier's original tier before --force-tier overrode it (G2). */ + tierClassified: RiskTier.optional(), findings: z.array(Finding), summary: z.object({ critical: z.number().int().nonnegative(), @@ -115,6 +122,32 @@ export const VerdictEnvelope = z.object({ .optional(), /** HMAC-SHA256 over the canonical body (added by signEnvelope). */ signature: z.string().optional(), +}).superRefine((env, ctx) => { + // G2 audit invariant — enforced at the envelope boundary so a hand-built + // envelope (bypassing runReview) can't sign inconsistent forced-tier + // metadata. When --force-tier is used, BOTH tierForced=true AND + // tierClassified must be present (per PR #1027 consensus MINOR #2). + const hasForced = env.tierForced === true + const hasClassified = env.tierClassified !== undefined + if (hasForced !== hasClassified) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "tierForced and tierClassified must both be present or both absent: " + + `tierForced=${env.tierForced}, tierClassified=${env.tierClassified ?? "undefined"}`, + path: ["tierForced"], + }) + } + // tierForced: false is not a legitimate state — the flag records + // "was --force-tier used?" as a positive marker; false is indistinguishable + // from a natural (unforced) run and only adds noise to the canonical body. + if (env.tierForced === false) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "tierForced must be true or undefined, not false", + path: ["tierForced"], + }) + } }) export type VerdictEnvelope = z.infer @@ -127,6 +160,12 @@ export interface BuildEnvelopeInput { manifestHash?: string generatedAt?: string degraded?: boolean + /** G1 — classifier reasons for the tier (only surfaced when explainTier=true). */ + tierReasons?: string[] + /** G2 — set when --force-tier was applied. */ + tierForced?: boolean + /** G2 — classifier's original tier before the force override. */ + tierClassified?: RiskTier } function summarize(findings: Finding[], degraded: boolean): VerdictEnvelope["summary"] { @@ -147,6 +186,9 @@ export function buildEnvelope(input: BuildEnvelopeInput): VerdictEnvelope { idealVerdict: ideal, mode: input.mode, tier: input.tier, + tierReasons: input.tierReasons, + tierForced: input.tierForced, + tierClassified: input.tierClassified, findings: input.findings, summary: summarize(input.findings, degraded), engine: EngineVersions.parse(input.engine ?? {}), diff --git a/packages/opencode/src/cli/cmd/review.ts b/packages/opencode/src/cli/cmd/review.ts index c271dbe5f..2a2f9c07f 100644 --- a/packages/opencode/src/cli/cmd/review.ts +++ b/packages/opencode/src/cli/cmd/review.ts @@ -22,9 +22,15 @@ export const ReviewCommand = cmd({ describe: "review dbt/SQL changes and emit a signed verdict (APPROVE/COMMENT/REQUEST_CHANGES)", builder: (yargs) => yargs + // Disable yargs' `--no-