Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Kept `@pondpilot/flowscope-react` as a private monorepo workspace and removed it from the npm release pipeline

### Fixed

#### Core Engine (flowscope-core)

- **MSSQL `GO` batch separators** - treat standalone SQL Server batch separators as statement boundaries during analysis and statement splitting, including trailing separators and CRLF input.

## [0.9.0] - 2026-08-12

### Added
Expand Down
12 changes: 12 additions & 0 deletions crates/flowscope-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,18 @@ fn to_file_lint_result(
code: i.code.clone(),
message: i.message.clone(),
severity: i.severity,
metadata: {
let mut metadata = serde_json::to_value(i).unwrap_or(serde_json::Value::Null);
if let Some(object) = metadata.as_object_mut() {
object.insert(
"sourceName".to_string(),
serde_json::Value::String(
i.source_name.clone().unwrap_or_else(|| source.name.clone()),
),
);
}
metadata
},
}
})
.collect();
Expand Down
51 changes: 41 additions & 10 deletions crates/flowscope-cli/src/output/lint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ pub struct LintIssue {
pub code: String,
pub message: String,
pub severity: Severity,
/// Structured issue metadata preserved for JSON consumers.
pub metadata: serde_json::Value,
}

/// Convert a byte offset into a 1-based (line, col) pair.
Expand Down Expand Up @@ -204,17 +206,42 @@ pub fn format_lint_json(results: &[FileLintResult], compact: bool) -> String {
.issues
.iter()
.map(|issue| {
serde_json::json!({
"line": issue.line,
"column": issue.col,
"code": sqlfluff_display_code(&issue.code),
"message": issue.message,
"severity": match issue.severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
let mut violation = serde_json::Map::from_iter([
("line".to_string(), serde_json::json!(issue.line)),
("column".to_string(), serde_json::json!(issue.col)),
(
"code".to_string(),
serde_json::json!(sqlfluff_display_code(&issue.code)),
),
("message".to_string(), serde_json::json!(issue.message)),
(
"severity".to_string(),
serde_json::json!(match issue.severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
}),
),
]);

for key in [
"sourceName",
"statementIndex",
"span",
"sqlfluffName",
"lintEngine",
"lintConfidence",
"lintFallbackSource",
"autofix",
] {
if let Some(value) = issue.metadata.get(key) {
if !value.is_null() {
violation.insert(key.to_string(), value.clone());
}
}
})
}

serde_json::Value::Object(violation)
})
.collect();

Expand Down Expand Up @@ -302,13 +329,15 @@ mod tests {
code: "LINT_AM_007".to_string(),
message: "Use UNION DISTINCT or UNION ALL instead of bare UNION.".to_string(),
severity: Severity::Info,
metadata: serde_json::Value::Null,
},
LintIssue {
line: 7,
col: 1,
code: "LINT_ST_006".to_string(),
message: "CTE 'unused' is defined but never referenced.".to_string(),
severity: Severity::Info,
metadata: serde_json::Value::Null,
},
],
}];
Expand Down Expand Up @@ -348,6 +377,7 @@ mod tests {
code: "LINT_AM_007".to_string(),
message: "test".to_string(),
severity: Severity::Info,
metadata: serde_json::Value::Null,
}],
},
];
Expand All @@ -370,6 +400,7 @@ mod tests {
code: "LINT_AM_007".to_string(),
message: "Use UNION DISTINCT or UNION ALL.".to_string(),
severity: Severity::Info,
metadata: serde_json::Value::Null,
}],
}];

Expand Down
11 changes: 11 additions & 0 deletions crates/flowscope-cli/tests/lint_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,17 @@ fn test_lint_json_format() {
let arr = parsed.as_array().expect("Expected JSON array");
assert_eq!(arr.len(), 1);
assert!(!arr[0]["violations"].as_array().unwrap().is_empty());

let violation = &arr[0]["violations"][0];
assert_eq!(violation["sourceName"], sql_path.to_string_lossy().as_ref());
assert_eq!(violation["statementIndex"], 0);
assert!(violation["span"]["start"].is_number());
assert!(violation["span"]["end"].is_number());
assert_eq!(violation["sqlfluffName"], "ambiguous.union");
assert_eq!(violation["lintEngine"], "semantic");
assert_eq!(violation["lintConfidence"], "high");
assert_eq!(violation["autofix"]["applicability"], "safe");
assert!(violation["autofix"]["edits"].is_array());
}

#[test]
Expand Down
30 changes: 27 additions & 3 deletions crates/flowscope-core/src/analyzer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,17 @@ impl<'a> Analyzer<'a> {
parser_fallback_used,
Some(source_statement_ranges),
);
self.issues.extend(linter.check_document(&document));
let mut lint_issues = linter.check_document(&document);
for issue in &mut lint_issues {
if let Some(local_index) = issue.statement_index {
issue.statement_index =
(start + local_index < end).then_some(start + local_index);
}
if issue.source_name.is_none() {
issue.source_name = source_name_key.map(str::to_owned);
}
}
self.issues.extend(lint_issues);

start = end;
}
Expand All @@ -456,14 +466,28 @@ impl<'a> Analyzer<'a> {
}
for file in files {
let document = LintDocument::new(&file.content, self.request.dialect, Vec::new());
self.issues.extend(linter.check_document(&document));
let mut lint_issues = linter.check_document(&document);
for issue in &mut lint_issues {
issue.statement_index = None;
if issue.source_name.is_none() {
issue.source_name = Some(file.name.clone());
}
}
self.issues.extend(lint_issues);
}
return;
}

if !self.request.sql.is_empty() {
let document = LintDocument::new(&self.request.sql, self.request.dialect, Vec::new());
self.issues.extend(linter.check_document(&document));
let mut lint_issues = linter.check_document(&document);
for issue in &mut lint_issues {
issue.statement_index = None;
if issue.source_name.is_none() {
issue.source_name = self.request.source_name.clone();
}
}
self.issues.extend(lint_issues);
}
}

Expand Down
12 changes: 11 additions & 1 deletion crates/flowscope-core/src/analyzer/global.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,21 @@ impl<'a> Analyzer<'a> {
let summary = self.build_summary(&nodes);
let resolved_schema = self.build_resolved_schema();

let mut issues = self.issues.clone();
for issue in &mut issues {
if issue.source_name.is_none() {
issue.source_name = issue
.statement_index
.and_then(|index| statements.get(index))
.and_then(|statement| statement.source_name.clone());
}
}

crate::AnalyzeResult {
statements,
nodes,
edges,
issues: self.issues.clone(),
issues,
summary,
resolved_schema,
}
Expand Down
47 changes: 44 additions & 3 deletions crates/flowscope-core/src/analyzer/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -653,13 +653,14 @@ fn split_ranges_on_mssql_go_separators(sql: &str, ranges: Vec<Range<usize>>) ->
for range in ranges {
let mut cursor = range.start;
for go_range in &go_line_ranges {
if go_range.start < range.start || go_range.end > range.end || go_range.start < cursor {
if go_range.end <= cursor || go_range.start >= range.end {
continue;
}
if let Some(chunk) = trim_statement_range(sql, cursor, go_range.start) {
let separator_start = go_range.start.max(cursor);
if let Some(chunk) = trim_statement_range(sql, cursor, separator_start) {
out.push(chunk);
}
cursor = go_range.end;
cursor = go_range.end.min(range.end);
}

if let Some(chunk) = trim_statement_range(sql, cursor, range.end) {
Expand Down Expand Up @@ -1212,6 +1213,31 @@ mod tests {
assert_eq!(&sql[ranges[1].clone()], "CREATE TABLE test (id INT)");
}

#[test]
fn mssql_statement_ranges_split_trailing_go_batch_separators() {
for sql in [
"SELECT 1;\nGO\nSELECT 2;\nGO\n",
"SELECT 1;\r\n go \r\nSELECT 2;\r\nGO\r\n",
"SELECT 1\nGO\nGO\nSELECT 2\nGO\n",
] {
let ranges = compute_statement_ranges_for_dialect(sql, Dialect::Mssql);
assert_eq!(ranges.len(), 2, "unexpected ranges for {sql:?}");
assert_eq!(&sql[ranges[0].clone()], "SELECT 1");
assert_eq!(&sql[ranges[1].clone()], "SELECT 2");
}
}

#[test]
fn mssql_statement_ranges_ignore_go_inside_strings_comments_and_identifiers() {
let sql = "SELECT 'GO' AS literal;\nSELECT [GO] FROM [source];\n-- GO\n/* GO */\nGO\nSELECT 'inside\nGO\nstring' AS literal;";
let ranges = compute_statement_ranges_for_dialect(sql, Dialect::Mssql);

assert_eq!(ranges.len(), 3);
assert_eq!(&sql[ranges[0].clone()], "SELECT 'GO' AS literal");
assert_eq!(&sql[ranges[1].clone()], "SELECT [GO] FROM [source]");
assert!(sql[ranges[2].clone()].contains("inside\nGO\nstring"));
}

#[test]
fn collect_statements_mssql_go_batch_without_final_semicolon_parses_statements() {
let mut request = base_request();
Expand All @@ -1226,6 +1252,21 @@ mod tests {
assert_eq!(statements.len(), 2);
}

#[test]
fn collect_statements_mssql_go_batch_with_trailing_separator_has_no_parse_error() {
let mut request = base_request();
request.dialect = Dialect::Mssql;
request.sql = "SELECT 1;\nGO\nSELECT 2;\nGO\n".to_string();

let (statements, issues) = collect_statements(&request);

assert_eq!(statements.len(), 2);
assert!(
issues.is_empty(),
"MSSQL trailing GO should not produce parse errors: {issues:?}"
);
}

#[test]
fn parses_procedure_with_inner_semicolons() {
let mut request = base_request();
Expand Down
9 changes: 9 additions & 0 deletions crates/flowscope-core/src/analyzer/select_analyzer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ impl<'a, 'b> SelectAnalyzer<'a, 'b> {
self.analyze_projection(&select.projection);
self.analyze_selection(&select.selection);
self.analyze_having(&select.having);
self.analyze_qualify(&select.qualify);
}

/// Analyzes GROUP BY expressions to track grouping columns.
Expand Down Expand Up @@ -357,6 +358,14 @@ impl<'a, 'b> SelectAnalyzer<'a, 'b> {
}
}

fn analyze_qualify(&mut self, qualify: &Option<sqlparser::ast::Expr>) {
if let Some(qualify_expr) = qualify {
let mut ea = ExpressionAnalyzer::new(self.analyzer, self.ctx);
ea.analyze(qualify_expr);
ea.capture_filter_predicates(qualify_expr, FilterClauseType::Qualify);
}
}

/// Checks if an expression references any output column aliases and emits a warning.
///
/// Used by HAVING and can be extended to other clauses that need alias checking.
Expand Down
66 changes: 62 additions & 4 deletions crates/flowscope-core/src/analyzer/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ fn file_statements_produce_spans() {
.span
.expect("span should be present for file statement");
assert_eq!(&file_sql[span.start..span.end], "missing_table");
assert_eq!(issue.source_name.as_deref(), Some("file.sql"));
}

#[test]
Expand Down Expand Up @@ -360,14 +361,71 @@ fn lint_document_rules_apply_to_each_file_in_multi_file_request() {
.collect();

assert_eq!(st012_issues.len(), 2, "expected one ST_012 issue per file");
assert!(
st012_issues
assert_eq!(st012_issues[0].statement_index, Some(0));
assert_eq!(st012_issues[0].source_name.as_deref(), Some("first.sql"));
assert_eq!(st012_issues[1].statement_index, Some(1));
assert_eq!(st012_issues[1].source_name.as_deref(), Some("second.sql"));
}

#[test]
fn lint_issues_keep_global_statement_indices_within_each_file() {
let mut request = make_request("");
request.files = Some(vec![
FileSource {
name: "first.sql".to_string(),
content: "SELECT 1 UNION SELECT 2; SELECT 3 UNION SELECT 4;".to_string(),
},
FileSource {
name: "second.sql".to_string(),
content: "SELECT 5 UNION SELECT 6;".to_string(),
},
]);
request.options = Some(AnalysisOptions {
lint: Some(LintConfig::default()),
..Default::default()
});

let result = analyze(&request);
let union_issues: Vec<_> = result
.issues
.iter()
.filter(|issue| issue.code == issue_codes::LINT_AM_002)
.collect();

assert_eq!(union_issues.len(), 3);
assert_eq!(
union_issues
.iter()
.all(|issue| issue.statement_index == Some(0)),
"document-level lint rules should run with per-document statement indices"
.map(|issue| (issue.source_name.as_deref(), issue.statement_index))
.collect::<Vec<_>>(),
vec![
(Some("first.sql"), Some(0)),
(Some("first.sql"), Some(1)),
(Some("second.sql"), Some(2)),
]
);
}

#[test]
fn statementless_file_lint_issues_do_not_claim_another_statement() {
let mut request = make_request("");
request.files = Some(vec![FileSource {
name: "empty.sql".to_string(),
content: "-- noqa: disable=all\n".to_string(),
}]);
request.options = Some(AnalysisOptions {
lint: Some(LintConfig::default()),
..Default::default()
});

let result = analyze(&request);

for issue in result.issues {
assert_eq!(issue.source_name.as_deref(), Some("empty.sql"));
assert_eq!(issue.statement_index, None);
}
}

#[test]
fn parser_fallback_metadata_is_attached_to_lint_issues() {
let mut request =
Expand Down
Loading