refactor(dynamic): enhance project file indexing for Go and Rust with recursive directory inclusion, refine migration adapters for Go and Rust, and expand test coverage

This commit is contained in:
elipeter 2026-05-25 08:28:59 -05:00
parent 680fc6bd28
commit 9c323d0ed5
7 changed files with 395 additions and 51 deletions

View file

@ -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(<n>)` is called. Scan for the
/// argument to a `Migrate(` call as a best-effort version hint.
fn extract_version(file_bytes: &[u8]) -> Option<String> {
/// / `000001_init.down.sql`). When the runner calls `Migrate(<n>)`,
/// 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<FrameworkDetectionContext<'_>>,
) -> Option<String> {
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<String> {
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<String> {
Some(raw.to_owned())
}
fn migration_filename_from_project_files(
context: FrameworkDetectionContext<'_>,
target_version: Option<&str>,
) -> Option<String> {
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<FrameworkBinding> {
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<FrameworkBinding> {
detect_go_migrate(summary, file_bytes, Some(context))
}
}
fn detect_go_migrate(
summary: &FuncSummary,
file_bytes: &[u8],
context: Option<FrameworkDetectionContext<'_>>,
) -> Option<FrameworkBinding> {
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");
}
}
}

View file

@ -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";

View file

@ -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<FrameworkBinding> {
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",
);
}
}

View file

@ -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;

View file

@ -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<String>, bytes: impl Into<Vec<u8>>) {
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<Item = (&str, &[u8])> {
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>) -> 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);

View file

@ -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,

View file

@ -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<PathBuf> {