diff --git a/src/dynamic/framework/adapters/migration_go_migrate.rs b/src/dynamic/framework/adapters/migration_go_migrate.rs index efa08df5..0d5bfaec 100644 --- a/src/dynamic/framework/adapters/migration_go_migrate.rs +++ b/src/dynamic/framework/adapters/migration_go_migrate.rs @@ -13,7 +13,7 @@ //! mirror the Phase 21 binding-stealing audit applied to //! `migration_rails` and `migration_flyway`. -use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, FrameworkDetectionContext}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; use crate::symbol::Lang; @@ -44,10 +44,25 @@ fn name_is_migration_entry(name: &str) -> bool { } /// golang-migrate uses filename-encoded versions (`000001_init.up.sql` -/// / `000001_init.down.sql`); the Go-side runner only carries the -/// numeric version when `m.Migrate()` is called. Scan for the -/// argument to a `Migrate(` call as a best-effort version hint. -fn extract_version(file_bytes: &[u8]) -> Option { +/// / `000001_init.down.sql`). When the runner calls `Migrate()`, +/// prefer the matching migration filename from the project index; +/// otherwise fall back to a single discovered SQL migration or the +/// numeric version itself. +fn extract_version( + file_bytes: &[u8], + context: Option>, +) -> Option { + let migrate_target = extract_migrate_target(file_bytes); + if let Some(context) = context + && let Some(filename) = + migration_filename_from_project_files(context, migrate_target.as_deref()) + { + return Some(filename); + } + migrate_target +} + +fn extract_migrate_target(file_bytes: &[u8]) -> Option { let text = std::str::from_utf8(file_bytes).unwrap_or(""); let needle = ".Migrate("; let idx = text.find(needle)?; @@ -60,6 +75,66 @@ fn extract_version(file_bytes: &[u8]) -> Option { Some(raw.to_owned()) } +fn migration_filename_from_project_files( + context: FrameworkDetectionContext<'_>, + target_version: Option<&str>, +) -> Option { + let mut candidates: Vec<&str> = context + .project_files + .iter() + .map(|(path, _)| path) + .filter(|path| is_go_migrate_sql_file(path)) + .collect(); + candidates.sort_unstable(); + + if let Some(target_version) = target_version + && let Some(path) = candidates + .iter() + .find(|path| { + path.ends_with(".up.sql") + && migration_file_version(path) + .map(|version| migration_versions_equal(version, target_version)) + .unwrap_or(false) + }) + .or_else(|| { + candidates.iter().find(|path| { + migration_file_version(path) + .map(|version| migration_versions_equal(version, target_version)) + .unwrap_or(false) + }) + }) + { + return Some((*path).to_owned()); + } + + if candidates.len() == 1 { + return candidates.first().map(|path| (*path).to_owned()); + } + + None +} + +fn is_go_migrate_sql_file(path: &str) -> bool { + path.ends_with(".up.sql") || path.ends_with(".down.sql") +} + +fn migration_file_version(path: &str) -> Option<&str> { + let filename = path.rsplit('/').next().unwrap_or(path); + let (version, _) = filename.split_once('_')?; + if version.is_empty() || !version.chars().all(|c| c.is_ascii_digit()) { + return None; + } + Some(version) +} + +fn migration_versions_equal(left: &str, right: &str) -> bool { + let left = left.trim_start_matches('0'); + let right = right.trim_start_matches('0'); + let left = if left.is_empty() { "0" } else { left }; + let right = if right.is_empty() { "0" } else { right }; + left == right +} + impl FrameworkAdapter for MigrationGoMigrateAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -75,29 +150,48 @@ impl FrameworkAdapter for MigrationGoMigrateAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let has_shape = source_imports_go_migrate(file_bytes); - let name_matches = name_is_migration_entry(&summary.name); - let body_runs_driver = super::any_callee_matches(summary, callee_is_go_migrate); - let binds = has_shape && (name_matches || body_runs_driver); - if !binds { - return None; - } - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::Migration { - version: extract_version(file_bytes), - }, - route: None, - request_params: Vec::new(), - response_writer: None, - middleware: Vec::new(), - }) + detect_go_migrate(summary, file_bytes, None) } + + fn detect_with_project_context( + &self, + summary: &FuncSummary, + context: FrameworkDetectionContext<'_>, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_go_migrate(summary, file_bytes, Some(context)) + } +} + +fn detect_go_migrate( + summary: &FuncSummary, + file_bytes: &[u8], + context: Option>, +) -> Option { + let has_shape = source_imports_go_migrate(file_bytes); + let name_matches = name_is_migration_entry(&summary.name); + let body_runs_driver = super::any_callee_matches(summary, callee_is_go_migrate); + let binds = has_shape && (name_matches || body_runs_driver); + if !binds { + return None; + } + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::Migration { + version: extract_version(file_bytes, context), + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) } #[cfg(test)] mod tests { use super::*; + use crate::dynamic::framework::ProjectFileIndex; use crate::summary::CalleeSite; fn parse_go(src: &[u8]) -> tree_sitter::Tree { @@ -203,4 +297,78 @@ mod tests { panic!("expected Migration entry kind"); } } + + #[test] + fn stamps_matching_sql_migration_filename_from_project_files() { + let src: &[u8] = b"package entry\n\ + import \"github.com/golang-migrate/migrate/v4\"\n\ + func RunTo() {\n\ + m, _ := migrate.New(\"file://./migrations\", \"postgres://x\")\n\ + m.Migrate(42)\n\ + }\n"; + let tree = parse_go(src); + let summary = FuncSummary { + name: "RunTo".into(), + callees: vec![CalleeSite::bare("m.Migrate")], + ..Default::default() + }; + let mut project_files = ProjectFileIndex::new(); + project_files.insert( + "migrations/000041_old.up.sql", + b"CREATE TABLE old_users(id int);", + ); + project_files.insert( + "migrations/000042_init.up.sql", + b"CREATE TABLE users(id int);", + ); + project_files.insert("migrations/000042_init.down.sql", b"DROP TABLE users;"); + let context = FrameworkDetectionContext { + ssa_summary: None, + project_files: &project_files, + }; + let binding = MigrationGoMigrateAdapter + .detect_with_project_context(&summary, context, tree.root_node(), src) + .expect("binds"); + if let EntryKind::Migration { version } = binding.kind { + assert_eq!(version.as_deref(), Some("migrations/000042_init.up.sql")); + } else { + panic!("expected Migration entry kind"); + } + } + + #[test] + fn stamps_single_sql_migration_filename_for_steps_runner() { + let src: &[u8] = b"package entry\n\ + import \"github.com/golang-migrate/migrate/v4\"\n\ + func RunOne() {\n\ + m, _ := migrate.New(\"file://./migrations\", \"postgres://x\")\n\ + m.Steps(1)\n\ + }\n"; + let tree = parse_go(src); + let summary = FuncSummary { + name: "RunOne".into(), + callees: vec![CalleeSite::bare("m.Steps")], + ..Default::default() + }; + let mut project_files = ProjectFileIndex::new(); + project_files.insert( + "db/migrations/000001_create_users.up.sql", + b"CREATE TABLE users(id int);", + ); + let context = FrameworkDetectionContext { + ssa_summary: None, + project_files: &project_files, + }; + let binding = MigrationGoMigrateAdapter + .detect_with_project_context(&summary, context, tree.root_node(), src) + .expect("binds"); + if let EntryKind::Migration { version } = binding.kind { + assert_eq!( + version.as_deref(), + Some("db/migrations/000001_create_users.up.sql") + ); + } else { + panic!("expected Migration entry kind"); + } + } } diff --git a/src/dynamic/framework/adapters/migration_refinery.rs b/src/dynamic/framework/adapters/migration_refinery.rs index ad5e994c..313e7669 100644 --- a/src/dynamic/framework/adapters/migration_refinery.rs +++ b/src/dynamic/framework/adapters/migration_refinery.rs @@ -7,12 +7,6 @@ //! against the macro-generated module) or itself names one of those //! entry verbs. //! -//! Also recognises the parallel `sqlx::migrate!()` runner so a single -//! adapter covers both the `refinery` + `sqlx-cli` shapes mentioned in -//! the Phase 21 deferred audit — both projects ship `.sql` migration -//! files driven by a Rust-side runner, and the source-import discriminator -//! cleanly distinguishes them from arbitrary code. - use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; @@ -38,8 +32,6 @@ fn source_imports_refinery(file_bytes: &[u8]) -> bool { b"embed_migrations!", b"refinery::Runner", b"refinery::Migration", - b"sqlx::migrate!", - b"use sqlx_cli", ]; NEEDLES .iter() @@ -115,24 +107,6 @@ mod tests { assert!(matches!(binding.kind, EntryKind::Migration { .. })); } - #[test] - fn fires_on_sqlx_migrate_macro() { - let src: &[u8] = b"async fn migrate(pool: &PgPool) -> sqlx::Result<()> {\n\ - sqlx::migrate!(\"./migrations\").run(pool).await\n\ - }\n"; - let tree = parse_rust(src); - let summary = FuncSummary { - name: "migrate".into(), - ..Default::default() - }; - assert!( - MigrationRefineryAdapter - .detect(&summary, tree.root_node(), src) - .is_some(), - "sqlx::migrate! macro must bind", - ); - } - #[test] fn skips_helper_named_run_without_refinery_import() { let src: &[u8] = b"pub fn run() {}\n"; diff --git a/src/dynamic/framework/adapters/migration_sqlx.rs b/src/dynamic/framework/adapters/migration_sqlx.rs new file mode 100644 index 00000000..8dc0abad --- /dev/null +++ b/src/dynamic/framework/adapters/migration_sqlx.rs @@ -0,0 +1,134 @@ +//! sqlx migration adapter (Rust). +//! +//! Fires when the surrounding source invokes `sqlx::migrate!()` or +//! imports the `sqlx-cli` migration runner and the function under +//! analysis is the canonical migration runner. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct MigrationSqlxAdapter; + +const ADAPTER_NAME: &str = "migration-sqlx"; + +fn callee_is_sqlx_migration(name: &str) -> bool { + let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name); + let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last); + matches!(last, "migrate" | "run" | "run_direct" | "run_migration") +} + +fn source_imports_sqlx_migration(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"sqlx::migrate!", + b"use sqlx::migrate", + b"use sqlx_cli", + b"sqlx_cli::migrate", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +fn name_is_migration_entry(name: &str) -> bool { + matches!(name, "migrate" | "run" | "run_migration") +} + +impl FrameworkAdapter for MigrationSqlxAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Rust + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let has_shape = source_imports_sqlx_migration(file_bytes); + let name_matches = name_is_migration_entry(&summary.name); + let body_runs_runner = super::any_callee_matches(summary, callee_is_sqlx_migration); + if !(has_shape && (name_matches || body_runs_runner)) { + return None; + } + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::Migration { version: None }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::summary::CalleeSite; + + fn parse_rust(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_rust::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_sqlx_migrate_macro() { + let src: &[u8] = b"async fn migrate(pool: &PgPool) -> sqlx::Result<()> {\n\ + sqlx::migrate!(\"./migrations\").run(pool).await\n\ + }\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "migrate".into(), + callees: vec![CalleeSite::bare("run")], + ..Default::default() + }; + let binding = MigrationSqlxAdapter + .detect(&summary, tree.root_node(), src) + .expect("sqlx migration binds"); + assert_eq!(binding.adapter, "migration-sqlx"); + assert!(matches!(binding.kind, EntryKind::Migration { .. })); + } + + #[test] + fn skips_helper_named_migrate_without_sqlx_marker() { + let src: &[u8] = b"pub fn migrate() {}\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "migrate".into(), + ..Default::default() + }; + assert!( + MigrationSqlxAdapter + .detect(&summary, tree.root_node(), src) + .is_none(), + "helper named migrate without sqlx marker must not bind", + ); + } + + #[test] + fn skips_unrelated_helper_in_sqlx_file() { + let src: &[u8] = b"async fn migrate(pool: &PgPool) -> sqlx::Result<()> {\n\ + sqlx::migrate!(\"./migrations\").run(pool).await\n\ + }\n\ + pub fn helper() {}\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "helper".into(), + ..Default::default() + }; + assert!( + MigrationSqlxAdapter + .detect(&summary, tree.root_node(), src) + .is_none(), + "unrelated helper in sqlx migration file must not bind", + ); + } +} diff --git a/src/dynamic/framework/adapters/mod.rs b/src/dynamic/framework/adapters/mod.rs index 7e033c9c..6629208c 100644 --- a/src/dynamic/framework/adapters/mod.rs +++ b/src/dynamic/framework/adapters/mod.rs @@ -76,6 +76,7 @@ pub mod migration_prisma; pub mod migration_rails; pub mod migration_refinery; pub mod migration_sequelize; +pub mod migration_sqlx; pub mod nats_go; pub mod php_codeigniter; pub mod php_laravel; @@ -198,6 +199,7 @@ pub use migration_prisma::MigrationPrismaAdapter; pub use migration_rails::MigrationRailsAdapter; pub use migration_refinery::MigrationRefineryAdapter; pub use migration_sequelize::MigrationSequelizeAdapter; +pub use migration_sqlx::MigrationSqlxAdapter; pub use nats_go::NatsGoAdapter; pub use php_codeigniter::PhpCodeIgniterAdapter; pub use php_laravel::PhpLaravelAdapter; diff --git a/src/dynamic/framework/mod.rs b/src/dynamic/framework/mod.rs index 7ea888c0..ea8a1bb3 100644 --- a/src/dynamic/framework/mod.rs +++ b/src/dynamic/framework/mod.rs @@ -57,6 +57,16 @@ impl ProjectFileIndex { index } + /// Add files under each project-relative directory when their + /// extension matches `extensions`. Missing directories are skipped. + pub fn include_dirs(mut self, root: &Path, rel_dirs: &[&str], extensions: &[&str]) -> Self { + for rel_dir in rel_dirs { + let dir = root.join(rel_dir); + self.insert_matching_files(root, &dir, extensions, 0); + } + self + } + /// Insert or replace a project-relative file. pub fn insert(&mut self, rel_path: impl Into, bytes: impl Into>) { self.files @@ -70,10 +80,61 @@ impl ProjectFileIndex { .map(Vec::as_slice) } + /// Iterate project-relative file paths and raw bytes. + pub fn iter(&self) -> impl Iterator { + self.files + .iter() + .map(|(path, bytes)| (path.as_str(), bytes.as_slice())) + } + /// True when the index has no files. pub fn is_empty(&self) -> bool { self.files.is_empty() } + + fn insert_matching_files( + &mut self, + root: &Path, + dir: &Path, + extensions: &[&str], + depth: usize, + ) { + const MAX_DEPTH: usize = 4; + if depth > MAX_DEPTH { + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + self.insert_matching_files(root, &path, extensions, depth + 1); + continue; + } + if !file_type.is_file() { + continue; + } + let Some(ext) = path.extension().and_then(|e| e.to_str()) else { + continue; + }; + if !extensions.iter().any(|want| ext.eq_ignore_ascii_case(want)) { + continue; + } + let Ok(rel) = path.strip_prefix(root) else { + continue; + }; + let Some(rel) = rel.to_str() else { + continue; + }; + if let Ok(bytes) = std::fs::read(&path) { + self.insert(rel, bytes); + } + } + } } fn normalize_project_rel(rel_path: impl Into) -> String { @@ -509,8 +570,8 @@ mod tests { let rust_registered = registry::adapters_for(Lang::Rust); assert_eq!( rust_registered.len(), - 10, - "Rust must have Phase 20 baseline (6) + M.3 juniper (1) + refinery (1) + Track L.9 (CryptoRust, DataExfilRust)", + 11, + "Rust must have Phase 20 baseline (6) + M.3 juniper/refinery/sqlx (3) + Track L.9 (CryptoRust, DataExfilRust)", ); for adapter in rust_registered { assert_eq!(adapter.lang(), Lang::Rust); diff --git a/src/dynamic/framework/registry.rs b/src/dynamic/framework/registry.rs index eba006a4..b4f9bb72 100644 --- a/src/dynamic/framework/registry.rs +++ b/src/dynamic/framework/registry.rs @@ -50,6 +50,7 @@ static RUST: &[&dyn FrameworkAdapter] = &[ &super::adapters::GraphqlJuniperAdapter, &super::adapters::HeaderRustAdapter, &super::adapters::MigrationRefineryAdapter, + &super::adapters::MigrationSqlxAdapter, &super::adapters::RedirectRustAdapter, &super::adapters::RustActixAdapter, &super::adapters::RustAxumAdapter, diff --git a/src/dynamic/spec.rs b/src/dynamic/spec.rs index adb77e65..2fd8e6f8 100644 --- a/src/dynamic/spec.rs +++ b/src/dynamic/spec.rs @@ -1286,7 +1286,11 @@ fn framework_project_files_for_entry(entry_file: &str, lang: Lang) -> ProjectFil ], _ => &[], }; - ProjectFileIndex::from_root(&root, rel_paths) + let index = ProjectFileIndex::from_root(&root, rel_paths); + match lang { + Lang::Go => index.include_dirs(&root, &["migrations", "db/migrations"], &["sql"]), + _ => index, + } } fn infer_framework_project_root(entry_path: &Path, lang: Lang) -> Option {