Skip to content

Commit 34f91df

Browse files
anandgupta42claude
andcommitted
fix: address 4 major review findings in check command
1. `--fail-on` now evaluates UNFILTERED findings before `--severity` filtering. Previously, `--severity error --fail-on warning` would false-pass because warnings were filtered out before exit logic. 2. Removed unused CLI options (`--dialect`, `--dbt-project`, `--manifest`) that were documented but had no effect — misleading for CI users. 3. `runPii` now checks `result.success` before processing data, consistent with all other check runners. 4. Dispatcher failures now emit error-severity findings instead of silently returning `[]`. A broken native bridge no longer reports PASS with zero findings. Also: - Use `buildCheckOutput()` helper instead of duplicated inline logic - Remove dead `|| ("error" as const)` fallbacks after `normalizeSeverity` - Add 4 new tests: severity/fail-on interaction, runPii failure, Dispatcher failure exit code Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5c10b37 commit 34f91df

2 files changed

Lines changed: 134 additions & 62 deletions

File tree

packages/opencode/src/cli/cmd/check.ts

Lines changed: 51 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,39 @@ import path from "path"
88
import {
99
type Finding,
1010
type CheckCategoryResult,
11-
type CheckOutput,
1211
type Severity,
13-
SEVERITY_RANK,
1412
VALID_CHECKS,
1513
normalizeSeverity,
1614
filterBySeverity,
1715
toCategoryResult,
1816
formatText,
17+
buildCheckOutput,
1918
} from "./check-helpers"
2019

2120
// ---------------------------------------------------------------------------
2221
// Check runners — each calls Dispatcher.call() and normalizes to Finding[]
22+
// On Dispatcher failure, emit an error-severity finding so CI doesn't false-pass.
2323
// ---------------------------------------------------------------------------
2424

25+
function dispatcherErrorFinding(check: string, file: string, e: unknown): Finding {
26+
return {
27+
file,
28+
rule: `${check}-error`,
29+
severity: "error",
30+
message: `[${check}] check failed: ${e instanceof Error ? e.message : String(e)}`,
31+
}
32+
}
33+
2534
async function runLint(sql: string, file: string, schemaPath?: string): Promise<Finding[]> {
2635
try {
2736
const result = await Dispatcher.call("altimate_core.lint", {
2837
sql,
2938
schema_path: schemaPath ?? "",
3039
schema_context: undefined as any,
3140
})
32-
if (!result.success) return []
41+
if (!result.success) {
42+
return [dispatcherErrorFinding("lint", file, result.error ?? "altimate_core.lint failed")]
43+
}
3344
const violations = (result.data.violations ?? result.data.findings ?? []) as Array<Record<string, unknown>>
3445
return violations.map((f) => ({
3546
file,
@@ -43,7 +54,7 @@ async function runLint(sql: string, file: string, schemaPath?: string): Promise<
4354
}))
4455
} catch (e) {
4556
console.error(`[lint] error processing ${file}: ${e instanceof Error ? e.message : String(e)}`)
46-
return []
57+
return [dispatcherErrorFinding("lint", file, e)]
4758
}
4859
}
4960

@@ -63,7 +74,7 @@ async function runValidate(sql: string, file: string, schemaPath?: string): Prom
6374
column: f.column as number | undefined,
6475
code: f.code as string | undefined,
6576
rule: "validate",
66-
severity: normalizeSeverity(f.severity as string) || ("error" as const),
77+
severity: normalizeSeverity(f.severity as string),
6778
message: (f.message ?? f.description ?? "") as string,
6879
suggestion: f.suggestion as string | undefined,
6980
}))
@@ -80,7 +91,7 @@ async function runValidate(sql: string, file: string, schemaPath?: string): Prom
8091
]
8192
} catch (e) {
8293
console.error(`[validate] error processing ${file}: ${e instanceof Error ? e.message : String(e)}`)
83-
return []
94+
return [dispatcherErrorFinding("validate", file, e)]
8495
}
8596
}
8697

@@ -96,7 +107,7 @@ async function runSafety(sql: string, file: string): Promise<Finding[]> {
96107
column: f.column as number | undefined,
97108
code: f.code as string | undefined,
98109
rule: (f.rule ?? f.category ?? "safety") as string,
99-
severity: normalizeSeverity(f.severity as string) || ("warning" as const),
110+
severity: normalizeSeverity(f.severity as string),
100111
message: (f.message ?? f.description ?? "") as string,
101112
suggestion: f.suggestion as string | undefined,
102113
}))
@@ -114,7 +125,7 @@ async function runSafety(sql: string, file: string): Promise<Finding[]> {
114125
return []
115126
} catch (e) {
116127
console.error(`[safety] error processing ${file}: ${e instanceof Error ? e.message : String(e)}`)
117-
return []
128+
return [dispatcherErrorFinding("safety", file, e)]
118129
}
119130
}
120131

@@ -135,7 +146,7 @@ async function runPolicy(sql: string, file: string, policyJson: string, schemaPa
135146
column: f.column as number | undefined,
136147
code: f.code as string | undefined,
137148
rule: (f.rule ?? f.policy ?? "policy") as string,
138-
severity: normalizeSeverity(f.severity as string) || ("error" as const),
149+
severity: normalizeSeverity(f.severity as string),
139150
message: (f.message ?? f.description ?? "") as string,
140151
suggestion: f.suggestion as string | undefined,
141152
}))
@@ -153,7 +164,7 @@ async function runPolicy(sql: string, file: string, policyJson: string, schemaPa
153164
return []
154165
} catch (e) {
155166
console.error(`[policy] error processing ${file}: ${e instanceof Error ? e.message : String(e)}`)
156-
return []
167+
return [dispatcherErrorFinding("policy", file, e)]
157168
}
158169
}
159170

@@ -164,6 +175,9 @@ async function runPii(sql: string, file: string, schemaPath?: string): Promise<F
164175
schema_path: schemaPath ?? "",
165176
schema_context: undefined as any,
166177
})
178+
if (!result.success) {
179+
return [dispatcherErrorFinding("pii", file, result.error ?? "altimate_core.query_pii failed")]
180+
}
167181
const piiFindings = (result.data.pii_columns ?? result.data.findings ?? []) as Array<Record<string, unknown>>
168182
return piiFindings.map((f) => ({
169183
file,
@@ -177,7 +191,7 @@ async function runPii(sql: string, file: string, schemaPath?: string): Promise<F
177191
}))
178192
} catch (e) {
179193
console.error(`[pii] error processing ${file}: ${e instanceof Error ? e.message : String(e)}`)
180-
return []
194+
return [dispatcherErrorFinding("pii", file, e)]
181195
}
182196
}
183197

@@ -197,7 +211,7 @@ async function runSemantic(sql: string, file: string, schemaPath?: string): Prom
197211
column: f.column as number | undefined,
198212
code: f.code as string | undefined,
199213
rule: (f.rule ?? "semantic") as string,
200-
severity: normalizeSeverity(f.severity as string) || ("warning" as const),
214+
severity: normalizeSeverity(f.severity as string),
201215
message: (f.message ?? f.description ?? "") as string,
202216
suggestion: f.suggestion as string | undefined,
203217
}))
@@ -215,7 +229,7 @@ async function runSemantic(sql: string, file: string, schemaPath?: string): Prom
215229
return []
216230
} catch (e) {
217231
console.error(`[semantic] error processing ${file}: ${e instanceof Error ? e.message : String(e)}`)
218-
return []
232+
return [dispatcherErrorFinding("semantic", file, e)]
219233
}
220234
}
221235

@@ -235,13 +249,13 @@ async function runGrade(sql: string, file: string, schemaPath?: string): Promise
235249
column: f.column as number | undefined,
236250
code: f.code as string | undefined,
237251
rule: (f.rule ?? f.category ?? "grade") as string,
238-
severity: normalizeSeverity(f.severity as string) || ("info" as const),
252+
severity: normalizeSeverity(f.severity as string),
239253
message: (f.message ?? f.description ?? "") as string,
240254
suggestion: f.suggestion as string | undefined,
241255
}))
242256
} catch (e) {
243257
console.error(`[grade] error processing ${file}: ${e instanceof Error ? e.message : String(e)}`)
244-
return []
258+
return [dispatcherErrorFinding("grade", file, e)]
245259
}
246260
}
247261

@@ -273,10 +287,6 @@ export const CheckCommand = cmd({
273287
describe: "path to policy JSON file for policy checks",
274288
type: "string",
275289
})
276-
.option("dialect", {
277-
describe: "SQL dialect (snowflake, bigquery, postgres, etc.)",
278-
type: "string",
279-
})
280290
.option("severity", {
281291
describe: "minimum severity level to report",
282292
choices: ["info", "warning", "error"] as const,
@@ -286,14 +296,6 @@ export const CheckCommand = cmd({
286296
describe: "exit 1 if findings at this level or above are found",
287297
choices: ["none", "warning", "error"] as const,
288298
default: "none" as const,
289-
})
290-
.option("dbt-project", {
291-
describe: "path to dbt project directory",
292-
type: "string",
293-
})
294-
.option("manifest", {
295-
describe: "path to dbt manifest.json",
296-
type: "string",
297299
}),
298300

299301
handler: async (args: {
@@ -302,12 +304,9 @@ export const CheckCommand = cmd({
302304
checks?: string
303305
schema?: string
304306
policy?: string
305-
dialect?: string
306307
severity?: "info" | "warning" | "error"
307308
"fail-on"?: "none" | "warning" | "error"
308309
failOn?: "none" | "warning" | "error"
309-
"dbt-project"?: string
310-
manifest?: string
311310
}) => {
312311
const startTime = Date.now()
313312

@@ -436,40 +435,35 @@ export const CheckCommand = cmd({
436435
await Promise.all(batchPromises)
437436
}
438437

439-
// 6. Filter by severity
438+
// 6. Compute pass/fail on UNFILTERED findings (before severity filtering)
439+
// This ensures --severity only controls output display, not exit code logic.
440+
const failOn = args["fail-on"] ?? args.failOn ?? "none"
441+
const allUnfiltered = Object.values(allResults).flat()
442+
const unfilteredErrors = allUnfiltered.filter((f) => f.severity === "error").length
443+
const unfilteredWarnings = allUnfiltered.filter((f) => f.severity === "warning").length
444+
let pass = true
445+
if (failOn === "error" && unfilteredErrors > 0) pass = false
446+
if (failOn === "warning" && (unfilteredErrors > 0 || unfilteredWarnings > 0)) pass = false
447+
448+
// 7. Filter by severity for display
440449
const minSeverity = args.severity as Severity
441450
const results: Record<string, CheckCategoryResult> = {}
442451
for (const [check, findings] of Object.entries(allResults)) {
443452
results[check] = toCategoryResult(filterBySeverity(findings, minSeverity))
444453
}
445454

446-
// 7. Build output
447-
const allFindings = Object.values(results).flatMap((r) => r.findings)
448-
const errors = allFindings.filter((f) => f.severity === "error").length
449-
const warnings = allFindings.filter((f) => f.severity === "warning").length
450-
const info = allFindings.filter((f) => f.severity === "info").length
451-
452-
const failOn = args["fail-on"] ?? args.failOn ?? "none"
453-
let pass = true
454-
if (failOn === "error" && errors > 0) pass = false
455-
if (failOn === "warning" && (errors > 0 || warnings > 0)) pass = false
456-
457-
const output: CheckOutput = {
458-
version: 1,
459-
files_checked: files.length,
460-
checks_run: checks,
461-
schema_resolved: schemaPath !== undefined,
455+
// 8. Build output using the helper
456+
const output = buildCheckOutput({
457+
filesChecked: files.length,
458+
checksRun: checks,
459+
schemaResolved: schemaPath !== undefined,
462460
results,
463-
summary: {
464-
total_findings: allFindings.length,
465-
errors,
466-
warnings,
467-
info,
468-
pass,
469-
},
470-
}
461+
failOn: "none", // pass is already computed above from unfiltered findings
462+
})
463+
// Override pass with our pre-computed value from unfiltered findings
464+
output.summary.pass = pass
471465

472-
// 8. Output
466+
// 9. Output
473467
const duration = Date.now() - startTime
474468
if (args.format === "json") {
475469
process.stdout.write(JSON.stringify(output, null, 2) + "\n")
@@ -478,7 +472,7 @@ export const CheckCommand = cmd({
478472
}
479473
console.error(`Completed in ${duration}ms`)
480474

481-
// 9. Exit code
475+
// 10. Exit code
482476
if (!pass) {
483477
process.exit(1)
484478
}

packages/opencode/test/cli/check-e2e.test.ts

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,78 @@ describe("check command E2E", () => {
342342
expect(j.results.lint.findings[0].rule).toBe("L003")
343343
})
344344

345+
// --- --severity + --fail-on interaction ---
346+
347+
test("--severity=error --fail-on=warning still fails when warnings exist (unfiltered)", async () => {
348+
const file = await writeSql(tmpDir.dir, "sev-fail.sql", "SELECT 1;")
349+
setDispatcherResponse("altimate_core.lint", () => ({
350+
success: true,
351+
data: {
352+
violations: [
353+
{ rule: "L001", severity: "warning", message: "A warning" },
354+
{ rule: "L002", severity: "error", message: "An error" },
355+
],
356+
},
357+
}))
358+
359+
// severity=error filters warnings from output, but fail-on=warning
360+
// should still detect warnings in unfiltered findings
361+
const r = await runHandler(
362+
baseArgs({ files: [file], checks: "lint", severity: "error", "fail-on": "warning", failOn: "warning" }),
363+
)
364+
// Output only shows 1 error (severity filter hides warning)
365+
const j = parseJson(r.stdout)
366+
expect(j.summary.total_findings).toBe(1)
367+
// But exit code is 1 because warnings exist in unfiltered findings
368+
expect(r.exitCode).toBe(1)
369+
expect(j.summary.pass).toBe(false)
370+
})
371+
372+
test("--severity=error --fail-on=error passes when only warnings exist", async () => {
373+
const file = await writeSql(tmpDir.dir, "sev-pass.sql", "SELECT 1;")
374+
setDispatcherResponse("altimate_core.lint", () => ({
375+
success: true,
376+
data: {
377+
violations: [{ rule: "L001", severity: "warning", message: "A warning" }],
378+
},
379+
}))
380+
381+
const r = await runHandler(
382+
baseArgs({ files: [file], checks: "lint", severity: "error", "fail-on": "error", failOn: "error" }),
383+
)
384+
// No errors, only warnings — should pass even with fail-on=error
385+
expect(r.exitCode).toBeUndefined()
386+
})
387+
388+
// --- runPii with success=false ---
389+
390+
test("pii check with dispatcher failure emits error finding", async () => {
391+
const file = await writeSql(tmpDir.dir, "pii-fail.sql", "SELECT email FROM users;")
392+
setDispatcherResponse("altimate_core.query_pii", () => ({
393+
success: false,
394+
error: "PII engine unavailable",
395+
data: {},
396+
}))
397+
398+
const r = await runHandler(baseArgs({ files: [file], checks: "pii" }))
399+
const j = parseJson(r.stdout)
400+
expect(j.results.pii.findings).toHaveLength(1)
401+
expect(j.results.pii.findings[0].severity).toBe("error")
402+
expect(j.results.pii.findings[0].message).toContain("PII engine unavailable")
403+
})
404+
405+
// --- Dispatcher failure triggers --fail-on exit code ---
406+
407+
test("Dispatcher failure exits 1 with --fail-on=error", async () => {
408+
const file = await writeSql(tmpDir.dir, "fail-exit.sql", "SELECT 1;")
409+
setDispatcherResponse("altimate_core.lint", () => {
410+
throw new Error("native binding missing")
411+
})
412+
413+
const r = await runHandler(baseArgs({ files: [file], checks: "lint", "fail-on": "error", failOn: "error" }))
414+
expect(r.exitCode).toBe(1)
415+
})
416+
345417
// --- Policy check ---
346418

347419
test("policy check requires --policy flag", async () => {
@@ -432,7 +504,7 @@ describe("check command E2E", () => {
432504

433505
// --- Dispatcher error handling ---
434506

435-
test("gracefully handles Dispatcher.call() throwing", async () => {
507+
test("Dispatcher.call() throwing emits error finding (no false pass)", async () => {
436508
const file = await writeSql(tmpDir.dir, "crash.sql", "SELECT 1;")
437509
setDispatcherResponse("altimate_core.lint", () => {
438510
throw new Error("napi-rs binding crashed")
@@ -441,8 +513,11 @@ describe("check command E2E", () => {
441513
const r = await runHandler(baseArgs({ files: [file], checks: "lint" }))
442514
expect(r.stderr).toContain("napi-rs binding crashed")
443515
const j = parseJson(r.stdout)
444-
expect(j.results.lint.findings).toHaveLength(0)
445-
expect(j.summary.pass).toBe(true)
516+
// Dispatcher failure now emits an error-severity finding instead of returning []
517+
expect(j.results.lint.findings).toHaveLength(1)
518+
expect(j.results.lint.findings[0].severity).toBe("error")
519+
expect(j.results.lint.findings[0].message).toContain("napi-rs binding crashed")
520+
expect(j.summary.errors).toBe(1)
446521
})
447522

448523
test("validate: success=false with error message emits finding", async () => {
@@ -664,7 +739,8 @@ describe("check command E2E", () => {
664739
expect(r.stderr).toContain("Safety engine unavailable")
665740
const j = parseJson(r.stdout)
666741
expect(j.results.lint.findings).toHaveLength(1)
667-
expect(j.results.safety.findings).toHaveLength(0) // error caught
742+
expect(j.results.safety.findings).toHaveLength(1) // error finding emitted
743+
expect(j.results.safety.findings[0].severity).toBe("error")
668744
expect(j.results.validate.findings).toHaveLength(0)
669745
})
670746
})
@@ -748,7 +824,9 @@ describe("check command adversarial", () => {
748824
const r = await runHandler(baseArgs({ files: [file], checks: "policy", policy: policyFile }))
749825
expect(r.stderr).toContain("Invalid policy JSON")
750826
const j = parseJson(r.stdout)
751-
expect(j.results.policy.findings).toHaveLength(0)
827+
// Dispatcher failure now emits an error finding
828+
expect(j.results.policy.findings).toHaveLength(1)
829+
expect(j.results.policy.findings[0].severity).toBe("error")
752830
})
753831

754832
test("handles very large policy file (1MB)", async () => {

0 commit comments

Comments
 (0)