From fd39304eed8a1deca329114d293ac72873689cfb Mon Sep 17 00:00:00 2001 From: elipeter Date: Wed, 27 May 2026 15:06:51 -0500 Subject: [PATCH] refactor(dynamic): enhance migration harnesses with Prisma, Sequelize-CLI, Laravel, Rails, Flask support; implement fallback logic and extend SQL framework integration --- src/dynamic/framework/runtime_deps.rs | 18 +++- src/dynamic/lang/js_shared.rs | 138 +++++++++++++++++++++++++- src/dynamic/lang/php.rs | 112 ++++++++++++++++++++- src/dynamic/lang/python.rs | 10 +- src/dynamic/lang/ruby.rs | 58 ++++++++++- src/dynamic/stubs/broker.rs | 12 +-- tests/phase21_corpus.rs | 97 ++++++++++++++++++ 7 files changed, 431 insertions(+), 14 deletions(-) diff --git a/src/dynamic/framework/runtime_deps.rs b/src/dynamic/framework/runtime_deps.rs index 532c323d..d1d60cd1 100644 --- a/src/dynamic/framework/runtime_deps.rs +++ b/src/dynamic/framework/runtime_deps.rs @@ -133,15 +133,25 @@ const NODE_KNEX: &[VersionedPackage] = &[VersionedPackage { name: "knex", version: "^3.1.0", }]; -const NODE_PRISMA: &[VersionedPackage] = &[VersionedPackage { - name: "@prisma/client", - version: "^5.14.0", -}]; +const NODE_PRISMA: &[VersionedPackage] = &[ + VersionedPackage { + name: "@prisma/client", + version: "^5.14.0", + }, + VersionedPackage { + name: "prisma", + version: "^5.14.0", + }, +]; const NODE_SEQUELIZE: &[VersionedPackage] = &[ VersionedPackage { name: "sequelize", version: "^6.37.3", }, + VersionedPackage { + name: "sequelize-cli", + version: "^6.6.2", + }, VersionedPackage { name: "sqlite3", version: "^5.1.7", diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index e12a070b..013a5187 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -1582,6 +1582,11 @@ fn emit_migration(spec: &HarnessSpec, version: Option<&str>, is_typescript: bool let (preamble, entry_subpath) = nyx_js_preamble(spec, is_typescript); let handler = &spec.entry_name; let version_repr = version.unwrap_or(""); + let framework = spec + .framework + .as_ref() + .map(|binding| binding.adapter.as_str()) + .unwrap_or(""); let body = format!( r#"{preamble} // Phase 21 (Track M.3) — migration. @@ -1591,6 +1596,7 @@ if (_h == null) {{ process.stderr.write('NYX_HANDLER_NOT_FOUND: ' + {handler:?} + '\n'); process.exit(78); }} +const _nyxFramework = {framework:?}; function _nyxLooksLikeSql(sql) {{ const upper = String(sql).toUpperCase(); return ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP'].some((k) => upper.includes(k)); @@ -1618,6 +1624,124 @@ function _nyxMigrationSqlRecord(sql, driver) {{ const sqliteDriver = _nyxTryExecuteSqlite(sql); __nyx_stub_sql_record(String(sql), {{ driver: driver, source: 'migration', sqlite_driver: sqliteDriver }}); }} +function _nyxCliBin(names) {{ + const fs = require('fs'); + const path = require('path'); + const suffix = process.platform === 'win32' ? '.cmd' : ''; + for (const name of names) {{ + const candidate = path.join(__dirname, 'node_modules', '.bin', name + suffix); + if (fs.existsSync(candidate)) return candidate; + }} + return null; +}} +function _nyxWriteSequelizeCliConfig(configPath) {{ + const open = String.fromCharCode(123); + const close = String.fromCharCode(125); + const lines = [ + "const fs = require('fs');", + "function record(sql) " + open, + " const p = process.env.NYX_SQL_LOG;", + " if (!p || !sql) return;", + " let out = '# driver: sequelize-cli\\n# source: migration-cli\\n' + String(sql);", + " if (!out.endsWith('\\n')) out += '\\n';", + " try " + open + " fs.appendFileSync(p, out); " + close + " catch (_) " + open + close, + close, + "module.exports = " + open + " nyx: " + open + " dialect: 'sqlite', storage: process.env.NYX_SQL_ENDPOINT || 'nyx.sqlite', logging: record " + close + " " + close + ";", + ]; + require('fs').writeFileSync(configPath, lines.join('\n')); +}} +function _nyxTrySequelizeCli() {{ + if (_nyxFramework !== 'migration-sequelize') return false; + try {{ + const fs = require('fs'); + const path = require('path'); + const cp = require('child_process'); + const bin = _nyxCliBin(['sequelize', 'sequelize-cli']); + if (!bin) return false; + const migrationsDir = path.join(__dirname, 'migrations'); + fs.mkdirSync(migrationsDir, {{ recursive: true }}); + fs.writeFileSync( + path.join(migrationsDir, '00000000000000-nyx-migration.js'), + "module.exports = require('../" + {entry_subpath:?} + "');\n" + ); + const configPath = path.join(__dirname, 'nyx-sequelize-config.cjs'); + _nyxWriteSequelizeCliConfig(configPath); + const result = cp.spawnSync(bin, [ + 'db:migrate', + '--migrations-path', migrationsDir, + '--config', configPath, + '--env', 'nyx', + ], {{ + cwd: __dirname, + env: process.env, + encoding: 'utf8', + }}); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + return result.status === 0; + }} catch (e) {{ + process.stderr.write('NYX_SEQUELIZE_CLI_FALLBACK: ' + (e && e.message ? e.message : String(e)) + '\n'); + return false; + }} +}} +function _nyxRecordPrismaMigrationFiles(schemaPath) {{ + try {{ + const fs = require('fs'); + const path = require('path'); + const root = path.dirname(schemaPath); + const migrations = path.join(root, 'migrations'); + if (!fs.existsSync(migrations)) return; + for (const dirent of fs.readdirSync(migrations, {{ withFileTypes: true }})) {{ + if (!dirent.isDirectory()) continue; + const sqlPath = path.join(migrations, dirent.name, 'migration.sql'); + if (!fs.existsSync(sqlPath)) continue; + const sql = fs.readFileSync(sqlPath, 'utf8'); + _nyxMigrationSqlRecord(sql, 'prisma-cli'); + }} + }} catch (e) {{ + process.stderr.write('NYX_PRISMA_CLI_SQLLOG_FALLBACK: ' + (e && e.message ? e.message : String(e)) + '\n'); + }} +}} +function _nyxTryPrismaCli() {{ + if (_nyxFramework !== 'migration-prisma') return false; + try {{ + const fs = require('fs'); + const path = require('path'); + const cp = require('child_process'); + const bin = _nyxCliBin(['prisma']); + if (!bin) return false; + const candidates = [ + path.join(__dirname, 'prisma', 'schema.prisma'), + path.join(__dirname, 'schema.prisma'), + ]; + const schemaPath = candidates.find((p) => fs.existsSync(p)); + if (!schemaPath) return false; + if (process.env.NYX_SQL_ENDPOINT && !process.env.DATABASE_URL) {{ + process.env.DATABASE_URL = 'file:' + process.env.NYX_SQL_ENDPOINT; + }} + let result = cp.spawnSync(bin, ['migrate', 'deploy', '--schema', schemaPath], {{ + cwd: __dirname, + env: process.env, + encoding: 'utf8', + }}); + if (result.status !== 0) {{ + process.stderr.write('NYX_PRISMA_CLI_DEPLOY_FALLBACK: ' + (result.stderr || result.stdout || '') + '\n'); + result = cp.spawnSync(bin, ['db', 'push', '--skip-generate', '--schema', schemaPath], {{ + cwd: __dirname, + env: process.env, + encoding: 'utf8', + }}); + }} + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + if (result.status !== 0) return false; + _nyxRecordPrismaMigrationFiles(schemaPath); + return true; + }} catch (e) {{ + process.stderr.write('NYX_PRISMA_CLI_FALLBACK: ' + (e && e.message ? e.message : String(e)) + '\n'); + return false; + }} +}} function _nyxTryRealSequelize() {{ try {{ const SequelizeLib = require('sequelize'); @@ -1732,6 +1856,8 @@ async function _nyxTryRealPrismaClient() {{ }} (async () => {{ try {{ + if (_nyxTryPrismaCli()) return; + if (_nyxTrySequelizeCli()) return; _realPrisma = await _nyxTryRealPrismaClient(); if (_realPrisma && _realPrisma.prisma) {{ _prisma = _realPrisma.prisma; @@ -1769,6 +1895,8 @@ async function _nyxTryRealPrismaClient() {{ preamble = preamble, handler = handler, version = version_repr, + framework = framework, + entry_subpath = entry_subpath, ); HarnessSource { source: body, @@ -3105,7 +3233,7 @@ fn message_handler_dependency_files(spec: &HarnessSpec) -> Vec<(String, String)> } fn framework_dependency_files(spec: &HarnessSpec) -> Vec<(String, String)> { - if spec.expected_cap != crate::labels::Cap::CODE_EXEC { + if !should_stage_framework_dependency_files(spec) { return Vec::new(); } let Some(adapter) = spec.framework.as_ref().map(|b| b.adapter.as_str()) else { @@ -3134,6 +3262,14 @@ fn framework_dependency_files(spec: &HarnessSpec) -> Vec<(String, String)> { ] } +fn should_stage_framework_dependency_files(spec: &HarnessSpec) -> bool { + spec.expected_cap == crate::labels::Cap::CODE_EXEC + || matches!( + &spec.entry_kind, + crate::evidence::EntryKind::Migration { .. } + ) +} + fn js_message_handler_deps(source: &str) -> Vec<(&'static str, &'static str)> { let mut deps = Vec::new(); for raw_line in source.lines() { diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index ecaa8ca3..865a2b44 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -3135,7 +3135,28 @@ function __nyx_make_middleware_request(string $payload) {{ $req = __nyx_make_middleware_request($payload); $next = function ($r) {{ return $r; }}; +function __nyx_try_laravel_pipeline(string $handler, $req, callable $next): bool {{ + if (!class_exists('Illuminate\\Pipeline\\Pipeline')) return false; + try {{ + $container = class_exists('Illuminate\\Container\\Container') + ? new Illuminate\Container\Container() + : null; + $pipeline = $container === null + ? new Illuminate\Pipeline\Pipeline() + : new Illuminate\Pipeline\Pipeline($container); + $result = $pipeline->send($req)->through([$handler])->then($next); + if ($result !== null) echo (string)$result . "\n"; + return true; + }} catch (Throwable $e) {{ + fwrite(STDERR, 'NYX_LARAVEL_PIPELINE_FALLBACK: ' . get_class($e) . ': ' . $e->getMessage() . "\n"); + return false; + }} +}} + if (class_exists({handler:?})) {{ + if (__nyx_try_laravel_pipeline({handler:?}, $req, $next)) {{ + exit(0); + }} $inst = new {handler}(); if (method_exists($inst, 'handle')) {{ try {{ @@ -3217,7 +3238,88 @@ function __nyx_try_execute_migration_sqlite($value): string {{ }} }} +function __nyx_snake_migration_name(string $class): string {{ + $base = preg_replace('/[^A-Za-z0-9]+/', '_', $class); + $snake = strtolower(preg_replace('/(?addConnection([ + 'driver' => 'sqlite', + 'database' => $endpoint, + 'prefix' => '', + ]); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $db = $capsule->getDatabaseManager(); + $GLOBALS['__nyx_laravel_db'] = $db; + if (class_exists('Illuminate\\Container\\Container')) {{ + $container = new Illuminate\Container\Container(); + $container->instance('db', $db); + $container->instance('db.connection', $db->connection()); + if (class_exists('Illuminate\\Support\\Facades\\Facade')) {{ + Illuminate\Support\Facades\Facade::setFacadeApplication($container); + }} + }} + try {{ + $db->connection()->listen(function ($query) {{ + $sql = is_object($query) && isset($query->sql) ? $query->sql : (string)$query; + __nyx_record_migration_result($sql, 'laravel-listener'); + }}); + }} catch (Throwable $e) {{}} + return true; + }} catch (Throwable $e) {{ + fwrite(STDERR, 'NYX_LARAVEL_DB_BOOTSTRAP_FALLBACK: ' . get_class($e) . ': ' . $e->getMessage() . "\n"); + return false; + }} +}} + +function __nyx_try_laravel_migrator(string $class): bool {{ + if (!class_exists($class)) return false; + if (!class_exists('Illuminate\\Database\\Migrations\\Migrator')) return false; + if (!class_exists('Illuminate\\Database\\Migrations\\DatabaseMigrationRepository')) return false; + if (!class_exists('Illuminate\\Filesystem\\Filesystem')) return false; + if (!__nyx_boot_laravel_database()) return false; + try {{ + $db = $GLOBALS['__nyx_laravel_db'] ?? null; + if (!$db) return false; + }} catch (Throwable $e) {{ + try {{ + $db = Illuminate\Support\Facades\DB::getFacadeRoot(); + }} catch (Throwable $inner) {{ + fwrite(STDERR, 'NYX_LARAVEL_MIGRATOR_DB_FALLBACK: ' . get_class($inner) . ': ' . $inner->getMessage() . "\n"); + return false; + }} + }} + try {{ + $repo = new Illuminate\Database\Migrations\DatabaseMigrationRepository($db, 'migrations'); + if (!$repo->repositoryExists()) {{ + $repo->createRepository(); + }} + $files = new Illuminate\Filesystem\Filesystem(); + $migrator = new Illuminate\Database\Migrations\Migrator($repo, $db, $files); + $dir = __DIR__ . '/nyx_laravel_migrations'; + if (!is_dir($dir)) mkdir($dir, 0777, true); + $file = $dir . '/2026_01_01_000000_' . __nyx_snake_migration_name($class) . '.php'; + file_put_contents($file, "run([$dir], ['pretend' => false]); + return true; + }} catch (Throwable $e) {{ + fwrite(STDERR, 'NYX_LARAVEL_MIGRATOR_FALLBACK: ' . get_class($e) . ': ' . $e->getMessage() . "\n"); + return false; + }} +}} + if (class_exists({handler:?})) {{ + if (__nyx_try_laravel_migrator({handler:?})) {{ + exit(0); + }} $inst = new {handler}(); if (method_exists($inst, 'up')) {{ try {{ @@ -3258,7 +3360,7 @@ if (class_exists({handler:?})) {{ } fn framework_dependency_files(spec: &HarnessSpec) -> Vec<(String, String)> { - if spec.expected_cap != crate::labels::Cap::CODE_EXEC { + if !should_stage_framework_dependency_files(spec) { return Vec::new(); } let Some(adapter) = spec.framework.as_ref().map(|b| b.adapter.as_str()) else { @@ -3281,6 +3383,14 @@ fn framework_dependency_files(spec: &HarnessSpec) -> Vec<(String, String)> { vec![("composer.json".to_owned(), body)] } +fn should_stage_framework_dependency_files(spec: &HarnessSpec) -> bool { + spec.expected_cap == crate::labels::Cap::CODE_EXEC + || matches!( + &spec.entry_kind, + crate::evidence::EntryKind::Migration { .. } + ) +} + fn build_call_expr(spec: &HarnessSpec, shape: PhpShape, func: &str) -> String { match shape { PhpShape::TopLevelScript => "null".to_owned(), diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index fd7582cc..ddbf1385 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -4026,7 +4026,7 @@ fn message_handler_dependency_files(spec: &HarnessSpec) -> Vec<(String, String)> } fn framework_dependency_files(spec: &HarnessSpec) -> Vec<(String, String)> { - if spec.expected_cap != crate::labels::Cap::CODE_EXEC { + if !should_stage_framework_dependency_files(spec) { return Vec::new(); } let Some(adapter) = spec.framework.as_ref().map(|b| b.adapter.as_str()) else { @@ -4049,6 +4049,14 @@ fn framework_dependency_files(spec: &HarnessSpec) -> Vec<(String, String)> { vec![("requirements.txt".to_owned(), body)] } +fn should_stage_framework_dependency_files(spec: &HarnessSpec) -> bool { + spec.expected_cap == crate::labels::Cap::CODE_EXEC + || matches!( + &spec.entry_kind, + crate::evidence::EntryKind::Migration { .. } + ) +} + fn python_message_handler_deps(source: &str) -> Vec<&'static str> { let mut deps = Vec::new(); for raw_line in source.lines() { diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index efca6472..b1ac939c 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -1054,6 +1054,51 @@ def __nyx_patch_active_record_sql_recording end end +def __nyx_try_rails_migration_context(cls, version) + return false unless defined?(Rails) + return false unless defined?(ActiveRecord::MigrationContext) + return false unless Rails.respond_to?(:application) && Rails.application + begin + paths = [] + begin + app_paths = Rails.application.paths if Rails.application.respond_to?(:paths) + if app_paths && app_paths['db/migrate'] + paths = Array(app_paths['db/migrate'].respond_to?(:existent) ? app_paths['db/migrate'].existent : app_paths['db/migrate']) + end + rescue StandardError + paths = [] + end + paths = [File.dirname(File.expand_path(__FILE__))] if paths.empty? + __nyx_patch_active_record_sql_recording + context = begin + if defined?(ActiveRecord::SchemaMigration) + ActiveRecord::MigrationContext.new(paths, ActiveRecord::SchemaMigration) + else + ActiveRecord::MigrationContext.new(paths) + end + end + target = nil + if version && version != '' + begin + target = Integer(version) + rescue ArgumentError + target = nil + end + end + if target && target > 0 && context.respond_to?(:run) + context.run(:up, target) + elsif context.respond_to?(:up) + context.up + else + return false + end + true + rescue StandardError => e + STDERR.puts("NYX_RAILS_MIGRATION_CONTEXT_FALLBACK: #{{e.class.name}}: #{{e.message}}") + false + end +end + # ActiveRecord migrations expose `up` / `down` / `change` on a subclass. if Object.const_defined?({handler:?}) cls = Object.const_get({handler:?}) @@ -1061,6 +1106,9 @@ if Object.const_defined?({handler:?}) if defined?(ActiveRecord::Migration) && cls.is_a?(Class) && cls < ActiveRecord::Migration begin __nyx_patch_active_record_sql_recording + if __nyx_try_rails_migration_context(cls, {ver:?}) + exit 0 + end cls.migrate(:up) exit 0 rescue StandardError => e @@ -1125,7 +1173,7 @@ end } fn framework_dependency_files(spec: &HarnessSpec) -> Vec<(String, String)> { - if spec.expected_cap != crate::labels::Cap::CODE_EXEC { + if !should_stage_framework_dependency_files(spec) { return Vec::new(); } let Some(adapter) = spec.framework.as_ref().map(|b| b.adapter.as_str()) else { @@ -1147,6 +1195,14 @@ fn framework_dependency_files(spec: &HarnessSpec) -> Vec<(String, String)> { vec![("Gemfile".to_owned(), body)] } +fn should_stage_framework_dependency_files(spec: &HarnessSpec) -> bool { + spec.expected_cap == crate::labels::Cap::CODE_EXEC + || matches!( + &spec.entry_kind, + crate::evidence::EntryKind::Migration { .. } + ) +} + /// Phase 03 — Track J.1 deserialize harness for Ruby. /// /// Wraps a call to `Marshal.load(input)` with a const-lookup diff --git a/src/dynamic/stubs/broker.rs b/src/dynamic/stubs/broker.rs index d6ef6dd2..d8f25b5f 100644 --- a/src/dynamic/stubs/broker.rs +++ b/src/dynamic/stubs/broker.rs @@ -698,14 +698,14 @@ fn kafka_parse_produce_request(version: i16, body: &[u8]) -> Vec<(String, i32, S return Vec::new(); }; let mut out = Vec::new(); - for _ in 0..topic_len.max(0).min(256) { + for _ in 0..topic_len.clamp(0, 256) { let Some(topic) = reader.string() else { break; }; let Some(partition_len) = reader.array_len() else { break; }; - for _ in 0..partition_len.max(0).min(256) { + for _ in 0..partition_len.clamp(0, 256) { let Some(partition) = reader.i32() else { break; }; @@ -783,7 +783,7 @@ fn kafka_parse_fetch_request(version: i16, body: &[u8]) -> BTreeMap BTreeMap BTreeMap BTreeMap HarnessSpec { + let mut spec = framework_bound_spec(lang, kind, entry_name, entry_file, adapter); + spec.expected_cap = Cap::SQL_QUERY; + spec.stubs_required = StubKind::for_cap(Cap::SQL_QUERY); + spec +} + fn extra_file_content<'a>(files: &'a [(String, String)], rel: &str) -> &'a str { files .iter() @@ -1001,6 +1014,7 @@ fn middleware_php_harness_carries_sentinel_and_handler() { assert!(h.source.contains("__NYX_MIDDLEWARE__")); assert!(h.source.contains("Audit")); assert!(h.source.contains("Illuminate\\Http\\Request")); + assert!(h.source.contains("Illuminate\\Pipeline\\Pipeline")); assert!(h.source.contains("__nyx_make_middleware_request")); } @@ -1044,6 +1058,10 @@ fn migration_js_harness_carries_sentinel_and_handler() { assert!(h.source.contains("global.__nyx_prisma")); assert!(h.source.contains("require('@prisma/client')")); assert!(h.source.contains("_nyxTryRealPrismaClient")); + assert!(h.source.contains("_nyxTrySequelizeCli")); + assert!(h.source.contains("_nyxTryPrismaCli")); + assert!(h.source.contains("sequelize-cli")); + assert!(h.source.contains("'migrate', 'deploy'")); assert!(h.source.contains("NYX_PRISMA_CLIENT_SQL")); assert!(h.source.contains("$disconnect")); assert!(h.source.contains("node:sqlite")); @@ -1063,6 +1081,8 @@ fn migration_ruby_harness_carries_sentinel_and_handler() { assert!(h.source.contains("AddIndex")); assert!(h.source.contains("__nyx_stub_sql_record")); assert!(h.source.contains("ActiveRecord::Base.establish_connection")); + assert!(h.source.contains("ActiveRecord::MigrationContext")); + assert!(h.source.contains("__nyx_try_rails_migration_context")); assert!(h.source.contains("cls.migrate(:up)")); assert!(h.source.contains("SQLite3::Database")); assert!(h.source.contains("NYX_SQL_ENDPOINT")); @@ -1081,10 +1101,87 @@ fn migration_php_harness_carries_sentinel_and_handler() { assert!(h.source.contains("AddUsers")); assert!(h.source.contains("__nyx_stub_sql_record")); assert!(h.source.contains("vendor/autoload.php")); + assert!( + h.source + .contains("Illuminate\\Database\\Migrations\\Migrator") + ); + assert!(h.source.contains("Illuminate\\Database\\Capsule\\Manager")); assert!(h.source.contains("new SQLite3")); assert!(h.source.contains("NYX_SQL_ENDPOINT")); } +#[test] +fn migration_harnesses_stage_framework_deps_for_sql_specs() { + let cases = [ + ( + framework_bound_sql_spec( + Lang::Python, + EvEntryKind::Migration { version: None }, + "upgrade", + "tests/dynamic_fixtures/migration/flask/vuln.py", + "migration-flask", + ), + "requirements.txt", + vec!["alembic", "Flask-Migrate"], + ), + ( + framework_bound_sql_spec( + Lang::JavaScript, + EvEntryKind::Migration { version: None }, + "up", + "tests/dynamic_fixtures/migration/sequelize/vuln.js", + "migration-sequelize", + ), + "package.json", + vec!["sequelize", "sequelize-cli", "sqlite3"], + ), + ( + framework_bound_sql_spec( + Lang::JavaScript, + EvEntryKind::Migration { version: None }, + "up", + "tests/dynamic_fixtures/migration/prisma/vuln.js", + "migration-prisma", + ), + "package.json", + vec!["@prisma/client", "\"prisma\""], + ), + ( + framework_bound_sql_spec( + Lang::Ruby, + EvEntryKind::Migration { version: None }, + "AddIndex", + "tests/dynamic_fixtures/migration/rails/vuln.rb", + "migration-rails", + ), + "Gemfile", + vec!["rails"], + ), + ( + framework_bound_sql_spec( + Lang::Php, + EvEntryKind::Migration { version: None }, + "AddUsers", + "tests/dynamic_fixtures/migration/laravel/vuln.php", + "migration-laravel", + ), + "composer.json", + vec!["laravel/framework"], + ), + ]; + + for (spec, manifest, needles) in cases { + let harness = lang::emit(&spec).expect("emit ok"); + let manifest_content = extra_file_content(&harness.extra_files, manifest); + for needle in needles { + assert!( + manifest_content.contains(needle), + "{manifest} missing {needle}: {manifest_content}", + ); + } + } +} + #[test] fn phase21_harness_emitters_stage_framework_dependency_manifests() { let cases = [