Skip to main content

safe_migrate/engine/
engine.rs

1// FILE: src/engine/engine.rs
2use crate::analysis::mutations::Mutation;
3use crate::analysis::resolver::Resolver;
4use crate::analysis::state::AnalysisState;
5use crate::ast::visitor::AstVisitor;
6use crate::engine::config::Config;
7use crate::report::violations::{ReportFinding, SourceLocation, Violation};
8use crate::rules::Rule;
9use crate::rules::conflict::ConflictRule;
10use crate::rules::constraints::BlockingConstraintRule;
11use crate::rules::destructive::{
12    CascadingDropRule, CreateTableAsSelectRule, DropDatabaseRule, DropSchemaCascadeRule,
13    GeneralCascadeRule, ReversibilityRule, SizeAwareAddColumnRule, TypeChangeRewriteRule,
14};
15use crate::rules::drift::DriftDetectionRule;
16use crate::rules::expressions::VolatileDefaultRule;
17use crate::rules::functions::{BrokenComputeRule, FunctionVolatilityRule};
18use crate::rules::idempotency::IdempotencyRule;
19use crate::rules::indexes::ConcurrentIndexRule;
20use crate::rules::opaque::OpaqueDynamicSqlRule;
21use crate::rules::partitions::{PartitionLockRule, PartitionStrategyMismatchRule};
22use crate::rules::policies::RestrictivePolicyRule;
23use crate::rules::security::OverbroadGrantRule;
24use crate::rules::transactions::{
25    AlterTypeAddValueRule, ConcurrentInsideTransactionRule, VacuumFullRule,
26};
27use crate::rules::triggers::DisableTriggerRule;
28use crate::rules::views::MaterializedViewRefreshRule;
29use squawk_syntax::{
30    SyntaxKind,
31    ast::{AstNode, SourceFile},
32};
33use std::collections::HashSet;
34
35pub struct SafeMigrateEngine {
36    config: Config,
37    rules: Vec<Box<dyn Rule>>,
38}
39
40impl SafeMigrateEngine {
41    pub fn new(config: Config) -> Self {
42        Self {
43            config,
44            rules: vec![
45                Box::new(ReversibilityRule),
46                Box::new(DropDatabaseRule),
47                Box::new(DropSchemaCascadeRule),
48                Box::new(GeneralCascadeRule),
49                Box::new(CascadingDropRule),
50                Box::new(CreateTableAsSelectRule),
51                Box::new(SizeAwareAddColumnRule),
52                Box::new(TypeChangeRewriteRule),
53                Box::new(BlockingConstraintRule),
54                Box::new(ConcurrentIndexRule),
55                Box::new(MaterializedViewRefreshRule),
56                Box::new(PartitionLockRule),
57                Box::new(PartitionStrategyMismatchRule),
58                Box::new(RestrictivePolicyRule),
59                Box::new(DisableTriggerRule),
60                Box::new(BrokenComputeRule),
61                Box::new(FunctionVolatilityRule),
62                Box::new(IdempotencyRule),
63                Box::new(ConcurrentInsideTransactionRule),
64                Box::new(AlterTypeAddValueRule),
65                Box::new(VacuumFullRule),
66                Box::new(OpaqueDynamicSqlRule),
67                Box::new(VolatileDefaultRule),
68                Box::new(OverbroadGrantRule),
69                Box::new(DriftDetectionRule),
70                Box::new(ConflictRule),
71            ],
72        }
73    }
74
75    /// Returns the canonical primary rule IDs in evaluation order. This is the
76    /// source of truth for configuration and user-facing rule documentation.
77    pub fn primary_rule_ids(&self) -> Vec<&'static str> {
78        self.rules.iter().map(|rule| rule.id()).collect()
79    }
80
81    pub fn analyze_chain(
82        &self,
83        files: &[(String, String)],
84        state: &mut AnalysisState,
85    ) -> Result<Vec<Violation>, Vec<String>> {
86        let mut all_violations = Vec::new();
87        for (filename, sql) in files {
88            let violations = self.analyze_single_file(filename, sql, state)?;
89            all_violations.extend(violations);
90        }
91        // Phase 10.6: Deterministic violation ordering
92        all_violations.sort_by(|a, b| {
93            a.tier
94                .cmp(&b.tier)
95                .then_with(|| match (&a.source_range, &b.source_range) {
96                    (Some(ar), Some(br)) => ar
97                        .start()
98                        .cmp(&br.start())
99                        .then_with(|| ar.end().cmp(&br.end())),
100                    (Some(_), None) => std::cmp::Ordering::Less,
101                    (None, Some(_)) => std::cmp::Ordering::Greater,
102                    (None, None) => std::cmp::Ordering::Equal,
103                })
104                .then_with(|| a.object_name.cmp(&b.object_name))
105                .then_with(|| a.rule_id.cmp(b.rule_id))
106        });
107        Ok(all_violations)
108    }
109
110    pub fn analyze(
111        &self,
112        sql: &str,
113        state: &mut AnalysisState,
114    ) -> Result<Vec<Violation>, Vec<String>> {
115        self.analyze_chain(&[("<inline>".to_string(), sql.to_string())], state)
116    }
117
118    /// Analyze ordered files and retain reportable source locations for every
119    /// finding. The original `analyze_chain` API remains available to callers
120    /// that only need violations.
121    pub fn analyze_chain_with_locations(
122        &self,
123        files: &[(String, String)],
124        state: &mut AnalysisState,
125    ) -> Result<Vec<ReportFinding>, Vec<String>> {
126        let mut findings = Vec::new();
127
128        for (file_index, (filename, sql)) in files.iter().enumerate() {
129            let normalized_sql = Self::normalize_execute(sql);
130            let violations = self.analyze_normalized_file(filename, &normalized_sql, state)?;
131            findings.extend(
132                violations
133                    .into_iter()
134                    .map(|violation| ReportFinding {
135                        location: Self::source_location(
136                            filename,
137                            &normalized_sql,
138                            violation.source_range,
139                        ),
140                        violation,
141                    })
142                    .map(|finding| (file_index, finding)),
143            );
144        }
145
146        findings.sort_by(|(a_index, a), (b_index, b)| {
147            a.violation
148                .tier
149                .cmp(&b.violation.tier)
150                .then_with(|| a_index.cmp(b_index))
151                .then_with(|| match (&a.location, &b.location) {
152                    (Some(a_location), Some(b_location)) => a_location
153                        .line
154                        .cmp(&b_location.line)
155                        .then_with(|| a_location.column.cmp(&b_location.column)),
156                    (Some(_), None) => std::cmp::Ordering::Less,
157                    (None, Some(_)) => std::cmp::Ordering::Greater,
158                    (None, None) => std::cmp::Ordering::Equal,
159                })
160                .then_with(|| a.violation.object_name.cmp(&b.violation.object_name))
161                .then_with(|| a.violation.rule_id.cmp(b.violation.rule_id))
162        });
163
164        Ok(findings.into_iter().map(|(_, finding)| finding).collect())
165    }
166
167    pub fn analyze_with_locations(
168        &self,
169        filename: String,
170        sql: String,
171        state: &mut AnalysisState,
172    ) -> Result<Vec<ReportFinding>, Vec<String>> {
173        self.analyze_chain_with_locations(&[(filename, sql)], state)
174    }
175
176    fn analyze_single_file(
177        &self,
178        filename: &str,
179        sql: &str,
180        state: &mut AnalysisState,
181    ) -> Result<Vec<Violation>, Vec<String>> {
182        let sql = Self::normalize_execute(sql);
183        self.analyze_normalized_file(filename, &sql, state)
184    }
185
186    fn analyze_normalized_file(
187        &self,
188        _filename: &str,
189        sql: &str,
190        state: &mut AnalysisState,
191    ) -> Result<Vec<Violation>, Vec<String>> {
192        let parsed = SourceFile::parse(sql);
193        let errors: Vec<String> = parsed.errors().iter().map(|e| e.to_string()).collect();
194        if !errors.is_empty() {
195            return Err(errors);
196        }
197
198        let mut all_violations = Vec::new();
199        let mut warned_keys = HashSet::new();
200
201        let mut file_ignores = HashSet::new();
202        for token in parsed
203            .tree()
204            .syntax()
205            .descendants_with_tokens()
206            .filter_map(|it| it.into_token())
207            .filter(|token| token.kind() == SyntaxKind::COMMENT)
208        {
209            let mut dummy = HashSet::new();
210            Self::parse_directives(token.text(), &mut file_ignores, &mut dummy);
211        }
212
213        for stmt in parsed.tree().stmts() {
214            let mut stmt_ignores = HashSet::new();
215
216            let mut prev = stmt.syntax().prev_sibling_or_token();
217            while let Some(element) = prev {
218                if element.as_node().is_some() {
219                    break;
220                }
221                if let Some(token) = element.as_token()
222                    && token.kind() == SyntaxKind::COMMENT
223                {
224                    let mut dummy = HashSet::new();
225                    Self::parse_directives(token.text(), &mut dummy, &mut stmt_ignores);
226                }
227                prev = element.prev_sibling_or_token();
228            }
229
230            for token in stmt
231                .syntax()
232                .descendants_with_tokens()
233                .filter_map(|it| it.into_token())
234                .filter(|token| token.kind() == SyntaxKind::COMMENT)
235            {
236                let mut dummy = HashSet::new();
237                Self::parse_directives(token.text(), &mut dummy, &mut stmt_ignores);
238            }
239
240            // Capture raw statement text for sql field on violations (strip leading comments)
241            let stmt_text = Self::strip_sql_leading_comments(&stmt.syntax().text().to_string());
242
243            // PostgreSQL executes a statement atomically. Keep both state
244            // and diagnostics local until all resolved actions succeed so an
245            // earlier action in a failed compound statement cannot leak
246            // state, findings, or deduplication keys. A parsed statement
247            // without a typed extractor is explicitly opaque: silently
248            // ignoring it would claim exact confidence for later SQL.
249            let statement_checkpoint = state.clone();
250            let statement_confidence = state.local.confidence.clone();
251            let mut statement_violations = Vec::new();
252            let mut statement_warned_keys = HashSet::new();
253            let mutations = match AstVisitor::extract(&stmt) {
254                Some(fact) => Resolver::resolve(&fact, state),
255                None => vec![Mutation::Opaque(
256                    crate::analysis::mutations::OpaqueMutation::UnsupportedStatement,
257                )],
258            };
259
260            for mutation in mutations {
261                let pre_cascade = match &mutation {
262                    Mutation::DropTable(d) if d.cascade => Some(state.get_cascade_closure(&d.id)),
263                    _ => None,
264                };
265
266                let pre_state = state.capture_pre_state();
267                let result = state.apply(&mutation, pre_cascade.as_ref());
268
269                let statement_failed = matches!(
270                    result,
271                    crate::analysis::state::MutationResult::Conflict { .. }
272                );
273                if statement_failed {
274                    let transaction_aborted = state.local.transaction_aborted;
275                    *state = statement_checkpoint.clone();
276                    if transaction_aborted && !state.local.transactions.is_empty() {
277                        state.local.transaction_aborted = true;
278                    }
279                    statement_violations.clear();
280                    statement_warned_keys.clear();
281                }
282
283                if result == crate::analysis::state::MutationResult::NotExecuted {
284                    continue;
285                }
286
287                for rule in &self.rules {
288                    if file_ignores.contains(rule.id())
289                        || stmt_ignores.contains(rule.id())
290                        || self.config.is_rule_disabled(rule.id())
291                    {
292                        continue;
293                    }
294
295                    let violations = rule.evaluate(
296                        &mutation,
297                        &result,
298                        &pre_state,
299                        state,
300                        &self.config,
301                        pre_cascade.as_ref(),
302                    );
303
304                    for v in violations {
305                        if let Some(key) = &v.dedup_key
306                            && (warned_keys.contains(key)
307                                || !statement_warned_keys.insert(key.clone()))
308                        {
309                            continue;
310                        }
311                        let mut v = v;
312                        if v.source_range.is_none() {
313                            let start = stmt
314                                .syntax()
315                                .descendants_with_tokens()
316                                .filter_map(|element| element.into_token())
317                                .find(|token| {
318                                    let text = token.text().trim();
319                                    !text.is_empty()
320                                        && !text.starts_with("--")
321                                        && !text.starts_with("/*")
322                                })
323                                .map(|token| token.text_range().start())
324                                .unwrap_or_else(|| stmt.syntax().text_range().start());
325                            let end = stmt.syntax().text_range().end();
326                            v.source_range = Some(rowan::TextRange::new(start, end));
327                        }
328                        if v.sql.is_none() {
329                            if let Some(range) = v.source_range {
330                                let start = usize::from(range.start());
331                                let end = usize::from(range.end());
332                                if start < sql.len() && end <= sql.len() {
333                                    v.sql = Some(sql[start..end].trim().to_string());
334                                } else {
335                                    v.sql = Some(stmt_text.trim().to_string());
336                                }
337                            } else {
338                                v.sql = Some(stmt_text.trim().to_string());
339                            }
340                        }
341                        // A taint produced by this statement must not
342                        // downgrade that same statement's findings.
343                        if statement_confidence == crate::analysis::state::Confidence::Tainted
344                            && v.tier == crate::report::violations::ViolationTier::Tier1
345                        {
346                            v.tier = crate::report::violations::ViolationTier::Tier2;
347                        }
348                        statement_violations.push(v);
349                    }
350                }
351
352                if statement_failed {
353                    break;
354                }
355            }
356
357            warned_keys.extend(statement_warned_keys);
358            all_violations.extend(statement_violations);
359        }
360
361        Ok(all_violations)
362    }
363
364    fn source_location(
365        filename: &str,
366        sql: &str,
367        source_range: Option<rowan::TextRange>,
368    ) -> Option<SourceLocation> {
369        let start = usize::from(source_range?.start());
370        if start > sql.len() || !sql.is_char_boundary(start) {
371            return None;
372        }
373
374        let before = &sql[..start];
375        let line = before.bytes().filter(|byte| *byte == b'\n').count() + 1;
376        let column = before
377            .rsplit_once('\n')
378            .map_or(before, |(_, final_line)| final_line)
379            .chars()
380            .count()
381            + 1;
382        Some(SourceLocation {
383            file: filename.to_string(),
384            line,
385            column,
386        })
387    }
388
389    /// Pre-process SQL to handle EXECUTE '...' which Squawk's parser does not
390    /// recognize (top-level EXECUTE expects a prepared-statement name, not a
391    /// string literal). Rewriting to DO lets the parser produce a proper
392    /// DoBlock node. Keep the replacement byte-for-byte the same length so
393    /// source ranges still point into the original migration text.
394    fn normalize_execute(sql: &str) -> String {
395        let mut out = String::with_capacity(sql.len());
396        for line in sql.split_inclusive('\n') {
397            let trimmed = line.trim_start();
398            let bytes = trimmed.as_bytes();
399            if bytes.len() > 9 && bytes[..9].eq_ignore_ascii_case(b"EXECUTE '") {
400                let indent = &line[..line.len() - trimmed.len()];
401                out.push_str(indent);
402                out.push_str("DO      '");
403                out.push_str(&trimmed[9..]);
404            } else if bytes.len() > 10 && bytes[..10].eq_ignore_ascii_case(b"EXECUTE $$") {
405                let indent = &line[..line.len() - trimmed.len()];
406                out.push_str(indent);
407                out.push_str("DO      $$");
408                out.push_str(&trimmed[10..]);
409            } else {
410                out.push_str(line);
411            }
412        }
413        out
414    }
415
416    fn parse_directives(
417        text: &str,
418        file_ignores: &mut HashSet<String>,
419        stmt_ignores: &mut HashSet<String>,
420    ) {
421        let marker = "safe-migrate:";
422        let mut pos = 0;
423
424        while let Some(start) = text[pos..].find(marker) {
425            let after = text[pos + start + marker.len()..].trim_start();
426
427            if let Some(rest) = after.strip_prefix("ignore-file") {
428                let rest = rest.trim_start();
429                if let Some(inner) = rest
430                    .strip_prefix('(')
431                    .and_then(|s| s.find(')').map(|e| &s[..e]))
432                {
433                    file_ignores.insert(inner.trim().to_string());
434                }
435            } else if let Some(rest) = after.strip_prefix("ignore") {
436                let rest = rest.trim_start();
437                if let Some(inner) = rest
438                    .strip_prefix('(')
439                    .and_then(|s| s.find(')').map(|e| &s[..e]))
440                {
441                    stmt_ignores.insert(inner.trim().to_string());
442                }
443            }
444
445            pos = pos + start + marker.len();
446        }
447    }
448
449    fn strip_sql_leading_comments(s: &str) -> String {
450        let mut pos = 0;
451        let bytes = s.as_bytes();
452        while pos < bytes.len() {
453            while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
454                pos += 1;
455            }
456            if pos + 1 < bytes.len() && bytes[pos] == b'-' && bytes[pos + 1] == b'-' {
457                while pos < bytes.len() && bytes[pos] != b'\n' {
458                    pos += 1;
459                }
460                continue;
461            }
462            if pos + 1 < bytes.len() && bytes[pos] == b'/' && bytes[pos + 1] == b'*' {
463                pos += 2;
464                while pos + 1 < bytes.len() && !(bytes[pos] == b'*' && bytes[pos + 1] == b'/') {
465                    pos += 1;
466                }
467                if pos + 1 < bytes.len() {
468                    pos += 2;
469                }
470                continue;
471            }
472            break;
473        }
474        s[pos..].to_string()
475    }
476}