Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bc86f06
feat(lint): warn when SQL lint is unavailable for a dialect
kvlonge Nov 7, 2026
7edf70d
feat(lint): keep L007 out of every exit decision and show it in compi…
kvlonge Nov 7, 2026
2fb4547
fix(lint): keep L007 out of rule exceptions and the plan rules gate
kvlonge Nov 7, 2026
3572408
docs(rules): name plan and build, not format, in the L007 exit note
kvlonge Nov 7, 2026
bc89e18
Merge branch 'main' into feat/lint-unavailable-warning
kvlonge Nov 7, 2026
5aea666
Merge branch 'main' into feat/lint-unavailable-warning
kvlonge Nov 7, 2026
630b2d8
wip: read rule facts with SQLQuack
kvlonge Nov 8, 2026
d9e86d0
wip: locate cast types and Snowflake date parts without a generator
kvlonge Nov 8, 2026
27e4cb4
Merge remote-tracking branch 'origin/feat/lint-unavailable-warning' i…
kvlonge Nov 8, 2026
a2909a3
wip: report built-in rules unavailable for unparsed dialects
kvlonge Nov 8, 2026
7a85582
wip: e2e for built-in rules on unparsed dialects
kvlonge Nov 8, 2026
adc7984
wip: docs and tests for built-in rules on unparsed dialects
kvlonge Nov 8, 2026
088bd97
wip: format tests
kvlonge Nov 8, 2026
8b2a9d3
fix(rules): refuse rule facts for statements deeper than 512 levels
kvlonge Nov 8, 2026
d9fa004
fix(rules): anchor nested comparisons and Snowflake LAST_DAY date parts
kvlonge Nov 8, 2026
0352b44
refactor(rules): name the date-part and unavailable-rule loops
kvlonge Nov 8, 2026
e9221bc
Merge remote-tracking branch 'origin/main' into feat/rule-facts-sqlquack
kvlonge Nov 8, 2026
e0638cd
refactor(rules): read the shared-parser dialect from one lookup
kvlonge Nov 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,23 +1,13 @@
//! `rules_tree_parser`: the parser that reads a dialect's SQL for rules.

use sqlquack_syntax::Dialect;

use crate::sql_tree::constants::{
DUCKDB_DIALECTS, POSTGRES_DIALECTS, SNOWFLAKE_DIALECT, SQLITE_DIALECT,
};
use crate::sql_tree::constants::POSTGRES_DIALECTS;
use crate::sql_tree::main::shared_dialect::shared_dialect;
use crate::sql_tree::models::TreeParser;

/// The parser for `dialect`, or `None`; add a dialect (one line) only once its parser passes the depth probe with an empty allowlist.
pub(crate) fn rules_tree_parser(dialect: &str) -> Option<TreeParser> {
if POSTGRES_DIALECTS.contains(&dialect) {
return Some(TreeParser::Postgres);
}
if DUCKDB_DIALECTS.contains(&dialect) {
return Some(TreeParser::Shared(Dialect::DuckDB));
}
match dialect {
SNOWFLAKE_DIALECT => Some(TreeParser::Shared(Dialect::Snowflake)),
SQLITE_DIALECT => Some(TreeParser::Shared(Dialect::SQLite)),
_ => None,
}
shared_dialect(dialect).map(TreeParser::Shared)
}
1 change: 1 addition & 0 deletions crates/sqlbuild-analysis/src/sql_tree/main/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
//! Entry points.

pub mod shared_dialect;
pub mod sql_tree_nodes;
22 changes: 22 additions & 0 deletions crates/sqlbuild-analysis/src/sql_tree/main/shared_dialect.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//! `shared_dialect`: the grammar of SQLQuack's shared parser that reads a dialect's SQL.

use sqlquack_syntax::Dialect;

use crate::sql_tree::constants::{
DUCKDB_DIALECTS, POSTGRES_DIALECTS, SNOWFLAKE_DIALECT, SQLITE_DIALECT,
};

/// The shared-parser grammar for `dialect`, or `None` when the shared parser reads none of it.
pub fn shared_dialect(dialect: &str) -> Option<Dialect> {
if DUCKDB_DIALECTS.contains(&dialect) {
return Some(Dialect::DuckDB);
}
if POSTGRES_DIALECTS.contains(&dialect) {
return Some(Dialect::PostgreSQL);
}
match dialect {
SNOWFLAKE_DIALECT => Some(Dialect::Snowflake),
SQLITE_DIALECT => Some(Dialect::SQLite),
_ => None,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyModule, PyModuleMethods, PyRes
use pyo3::types::{PyBool, PyDict, PyDictMethods, PyString, PyTuple, PyTupleMethods};
use pyo3::{IntoPyObject, pyfunction, wrap_pyfunction};
use sqlbuild_host::entries::models::ExecutorHost;
use sqlbuild_rules::lint_text::main::counts_toward_exit::counts_toward_exit;

use crate::bindings::_helpers::boundary::python_calls::{
Status, call_with, imported, keywords, within,
Expand Down Expand Up @@ -546,14 +547,15 @@ impl<'py> Driver<'_, 'py> {
}
}

/// The core diagnostics followed by one rule-phase error per rule finding.
/// The core diagnostics followed by one rule-phase error per rule finding; lint-unavailable ones only warn.
fn with_rule_diagnostics<'py>(
py: Python<'py>,
core: &Bound<'py, PyTuple>,
rules_result: &Bound<'py, PyAny>,
) -> PyResult<Bound<'py, PyTuple>> {
let phase: Bound<'py, PyAny> = enum_member(py, RecordId::DiagnosticPhase, "RULE")?;
let severity: Bound<'py, PyAny> = enum_member(py, RecordId::DiagnosticSeverity, "ERROR")?;
let unavailable: Bound<'py, PyAny> = enum_member(py, RecordId::DiagnosticSeverity, "WARNING")?;
let fix_note: Bound<'py, PyAny> = PyString::new(py, RULE_FIX_AVAILABLE_NOTE).into_any();
let mut diagnostics: Vec<Bound<'py, PyAny>> = core.iter().collect();
for fault in native_field(rules_result, "findings")?.try_iter()? {
Expand All @@ -568,7 +570,13 @@ fn with_rule_diagnostics<'py>(
RecordId::CompilerDiagnostic,
&[
("phase", phase.clone()),
("severity", severity.clone()),
(
"severity",
match counts_toward_exit(&native_field(&fault, "code")?.extract::<String>()?) {
true => severity.clone(),
false => unavailable.clone(),
},
),
("code", native_field(&fault, "code")?),
("message", native_field(&fault, "message")?),
("path", native_field(&fault, "path")?),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
//! The compile-and-plan pipeline of execution commands: compile, rules gate, then planning.

use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyModule, PyModuleMethods, PyResult, Python};
use pyo3::types::PyDict;
use pyo3::types::{PyDict, PyTuple};
use pyo3::{IntoPyObject, PyErr, pyfunction, wrap_pyfunction};
use sqlbuild_host::entries::models::ExecutorHost;
use sqlbuild_rules::lint_text::main::counts_toward_exit::counts_toward_exit;
use sqlbuild_rules::rule_results::main::default_rules_notice::default_rules_notice;

use crate::bindings::_helpers::boundary::python_calls::{KeywordArguments, keywords};
Expand Down Expand Up @@ -135,10 +136,17 @@ fn run_rules_gate<'py>(
let message: String = rules_progress(elapsed, &result, true)?;
on_progress.call1((message,))?;
}
let findings: Bound<'py, PyAny> = native_field(&result, "findings")?;
if !findings.is_truthy()? {
let mut failing: Vec<Bound<'py, PyAny>> = Vec::new();
for finding in native_field(&result, "findings")?.try_iter()? {
let finding: Bound<'py, PyAny> = finding?;
if counts_toward_exit(&native_field(&finding, "code")?.extract::<String>()?) {
failing.push(finding);
}
}
if failing.is_empty() {
return Ok(());
}
let findings: Bound<'py, PyAny> = PyTuple::new(py, failing)?.into_any();
let summary: Bound<'py, PyAny> = new_record_with(
py,
RecordId::RulesResult,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use pyo3::prelude::{
};
use pyo3::types::{PyDict, PyDictMethods, PyTuple};
use pyo3::wrap_pyfunction;
use sqlbuild_rules::lint_text::main::counts_toward_exit::counts_toward_exit;
use sqlbuild_rules::rule_catalogue::main::render_rule_list::render_rule_list;
use sqlbuild_rules::rule_catalogue::models::RuleListing;

Expand Down Expand Up @@ -246,7 +247,12 @@ fn run_rule_selection(
native_field(request, "json_output")?.is_truthy()?,
)?;
print_line(py, &text, false)?;
let findings: usize = native_field(&result, "findings")?.len()?;
let mut findings: usize = 0;
for finding in native_field(&result, "findings")?.try_iter()? {
if counts_toward_exit(&native_field(&finding?, "code")?.extract::<String>()?) {
findings += 1;
}
}
if findings > 0 {
print_line(
py,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use pyo3::prelude::{
use pyo3::types::{PyBytes, PyDict, PyDictMethods, PyString, PyTuple};
use pyo3::{IntoPyObjectExt, pyfunction, wrap_pyfunction};
use sqlbuild_core::text::main::python_splitlines::python_splitlines;
use sqlbuild_rules::lint_text::main::counts_toward_exit::counts_toward_exit;
use sqlbuild_rules::rule_cache::main::file_rule_key::file_rule_key;
use sqlbuild_rules::rule_results::main::duplicate_literal_hints::duplicate_literal_hints;
use sqlbuild_rules::rule_results::models::RuleFinding;
Expand Down Expand Up @@ -670,7 +671,7 @@ fn lint_expansion_context(
})
}

/// The findings of the selected SQL rules' violations; an engine failure leaves each unevaluated.
/// The selected SQL rules' findings and lint-unavailable warnings; an engine failure leaves each rule unevaluated.
fn violation_findings(
run: &SqlRun<'_, '_>,
violations: &[(PathBuf, LintViolationRow)],
Expand All @@ -686,6 +687,8 @@ fn violation_findings(
let code: &str = &violation.code;
if code == SQL_RULE_SUPPRESSION_CODE || run.codes.iter().any(|known| known == code) {
evaluated.push(lint_finding((path, violation), &resolved_dir, model_paths));
} else if !counts_toward_exit(code) {
evaluated.push(lint_finding((path, violation), &resolved_dir, &no_paths));
} else if code == engine_error {
let finding: RuleFinding = lint_finding((path, violation), &resolved_dir, &no_paths);
for rule_code in run.codes {
Expand Down
2 changes: 1 addition & 1 deletion crates/sqlbuild-rules/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ rust-version.workspace = true
blake3.workspace = true
fensu-policy.workspace = true
globset.workspace = true
polyglot-sql.workspace = true
sqlquack-syntax.workspace = true
rayon.workspace = true
regex.workspace = true
rustc-hash.workspace = true
Expand Down
3 changes: 3 additions & 0 deletions crates/sqlbuild-rules/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,6 @@ pub(crate) const ALLOWED_QUALIFIERS_OPTION: &str = "allowed_qualifiers";
pub(crate) const ALLOWED_PATHS_KEY: &str = "paths";
/// The published layers when a project does not configure them.
pub(crate) const DEFAULT_PUBLISHED_LAYERS: [&str; 2] = ["mart", "mart_v"];
/// What to do when SQLBuild does not parse a project's dialect for built-in rules.
pub(crate) const RULES_UNAVAILABLE_REMEDIATION: &str =
"These rules did not run. Review this file by hand until built-in rules support this dialect.";
6 changes: 6 additions & 0 deletions crates/sqlbuild-rules/src/engine/_helpers/evaluation.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::configuration::main::validate as config;
use crate::constants::{API_VERSION, NATIVE_BUILD_IDENTITY, RELATION_EXCEPTION_RULES};
use crate::engine::_helpers::cache::{Cache, PendingBucketWrite, RuleCacheEntry};
use crate::lint_text::constants::LINT_UNAVAILABLE_CODE;
use crate::models::{
EvaluateRequest, EvaluateResponse, Fault, Model, RuleException, RuleMetadata, RuleScope,
RulesCodeGrammar, RulesConfig,
Expand Down Expand Up @@ -580,11 +581,15 @@ pub(crate) fn finalize_findings(
apply_fault_policy(&request, evaluated_codes, findings)
}

/// Apply rule exceptions to `faults`; lint-unavailable warnings report our gap, not a rule, so they pass through unsuppressed.
fn apply_fault_policy(
request: &EvaluateRequest,
evaluated_codes: &[String],
faults: Vec<Fault>,
) -> Result<Vec<Fault>, String> {
let (unavailable, faults): (Vec<Fault>, Vec<Fault>) = faults
.into_iter()
.partition(|fault| fault.code == LINT_UNAVAILABLE_CODE);
let unevaluated: BTreeSet<_> = faults
.iter()
.filter(|fault| fault.unevaluated)
Expand Down Expand Up @@ -641,6 +646,7 @@ fn apply_fault_policy(
));
fault
})
.chain(unavailable)
.collect())
}

Expand Down
19 changes: 10 additions & 9 deletions crates/sqlbuild-rules/src/engine/tests/test_empty_input_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const NO_ROWS_ASSERTION: (&str, &str) = (
"__assert__empty_inputs_produce_no_rows",
"SELECT 1 AS unexpected_row FROM __ref(\"orders\")",
);
const UNAVAILABLE_MESSAGE: &str = "L007 tests/unit/test_orders__empty_inputs_produce_no_rows.sql: SQLBuild cannot parse mssql SQL for built-in rules yet, so these rules were not evaluated: SQBRTEST203";
const FLAGGED_MESSAGE: &str = "SQBRTEST203 tests/unit/test_orders__empty_inputs_produce_no_rows.sql: unit test block 1 (\"orders__empty_inputs_produce_no_rows\") mocks only empty inputs and asserts only that no rows are produced";

#[test]
Expand Down Expand Up @@ -419,7 +420,7 @@ fn given_cached_minimum_tests_when_filler_becomes_real_test_then_model_cache_is_
}

#[test]
fn given_sql_server_conversions_when_evaluating_empty_input_rule_then_type_arguments_are_not_calls()
fn given_sql_server_conversions_when_evaluating_empty_input_rule_then_reports_the_dialect_unavailable()
-> Result<(), String> {
let project_dir = TempDir::new().map_err(|error| error.to_string())?;
let empty_orders = (
Expand All @@ -428,7 +429,7 @@ fn given_sql_server_conversions_when_evaluating_empty_input_rule_then_type_argum
);
let test_cases = [
test_types::EmptyInputTestRuleTestCase {
description: "a CONVERT to a parameterized type under a false predicate is an empty mock",
description: "a CONVERT to a parameterized type under a false predicate is an empty mock, reported unavailable until SQLBuild parses the dialect",
test: helpers::empty_input_test_fact(
"orders__empty_inputs_produce_no_rows",
&[(
Expand All @@ -439,10 +440,10 @@ fn given_sql_server_conversions_when_evaluating_empty_input_rule_then_type_argum
&[NO_ROWS_ASSERTION],
),
allowed_tests: json!([]),
expected_messages: &[FLAGGED_MESSAGE],
expected_messages: &[UNAVAILABLE_MESSAGE],
},
test_types::EmptyInputTestRuleTestCase {
description: "a CONVERT with a style argument is an empty mock",
description: "a CONVERT with a style argument is an empty mock, reported unavailable until SQLBuild parses the dialect",
test: helpers::empty_input_test_fact(
"orders__empty_inputs_produce_no_rows",
&[(
Expand All @@ -453,10 +454,10 @@ fn given_sql_server_conversions_when_evaluating_empty_input_rule_then_type_argum
&[NO_ROWS_ASSERTION],
),
allowed_tests: json!([]),
expected_messages: &[FLAGGED_MESSAGE],
expected_messages: &[UNAVAILABLE_MESSAGE],
},
test_types::EmptyInputTestRuleTestCase {
description: "a TRY_CONVERT to a parameterized type keeps a bare existence check",
description: "a TRY_CONVERT to a parameterized type keeps a bare existence check, reported unavailable until SQLBuild parses the dialect",
test: helpers::empty_input_test_fact(
"orders__empty_inputs_produce_no_rows",
&[empty_orders],
Expand All @@ -467,10 +468,10 @@ fn given_sql_server_conversions_when_evaluating_empty_input_rule_then_type_argum
)],
),
allowed_tests: json!([]),
expected_messages: &[FLAGGED_MESSAGE],
expected_messages: &[UNAVAILABLE_MESSAGE],
},
test_types::EmptyInputTestRuleTestCase {
description: "a function call in a CONVERT value argument still counts",
description: "a function call in a CONVERT value argument still counts, reported unavailable until SQLBuild parses the dialect",
test: helpers::empty_input_test_fact(
"orders__empty_inputs_produce_no_rows",
&[(
Expand All @@ -481,7 +482,7 @@ fn given_sql_server_conversions_when_evaluating_empty_input_rule_then_type_argum
&[NO_ROWS_ASSERTION],
),
allowed_tests: json!([]),
expected_messages: &[],
expected_messages: &[UNAVAILABLE_MESSAGE],
},
];

Expand Down
Loading
Loading