From ed96f94bb55caacc7322f7cf86d4c2faa863ec95 Mon Sep 17 00:00:00 2001 From: elipeter Date: Tue, 26 May 2026 15:07:51 -0500 Subject: [PATCH] refactor(dynamic): centralize runtime dependency handling across frameworks, enhance manifest generation for Rust, Java, Python, Go, and PHP, and improve framework adapter integration --- src/dynamic/environment.rs | 12 + src/dynamic/framework/mod.rs | 13 + src/dynamic/framework/runtime_deps.rs | 537 ++++++++++++++++++++++++++ src/dynamic/lang/go.rs | 68 +++- src/dynamic/lang/java.rs | 79 +++- src/dynamic/lang/js_shared.rs | 59 ++- src/dynamic/lang/php.rs | 60 ++- src/dynamic/lang/python.rs | 57 ++- src/dynamic/lang/ruby.rs | 61 ++- src/dynamic/lang/rust.rs | 48 ++- tests/env_capture_flask.rs | 139 +++++++ tests/phase21_corpus.rs | 119 ++++++ 12 files changed, 1202 insertions(+), 50 deletions(-) create mode 100644 src/dynamic/framework/runtime_deps.rs diff --git a/src/dynamic/environment.rs b/src/dynamic/environment.rs index 98c42903..a0e1df9c 100644 --- a/src/dynamic/environment.rs +++ b/src/dynamic/environment.rs @@ -359,6 +359,13 @@ pub struct CapturedDeps { /// version even when the entry file imports the framework /// transitively. pub frameworks: Vec, + /// Adapter id attached to the spec by framework binding detection. + /// + /// This is distinct from manifest-detected web frameworks: Phase 20/21 + /// adapters can bind from route/config metadata or marker comments while + /// the entry source avoids a hard import. The id lets manifest synthesis + /// add the package-manager deps required when the real import is present. + pub framework_adapter: Option, /// Three-valued lang-has-framework signal (see /// [`FrameworkContext::lang_has_web_framework`]). pub framework_signal: Option, @@ -425,6 +432,8 @@ pub struct Environment { pub direct_deps: Vec, /// Frameworks detected in the project root. pub frameworks: Vec, + /// Adapter id attached to the originating spec, when any. + pub framework_adapter: Option, /// Language pinned via the originating spec. Cached here so the /// emitter does not have to re-thread the spec. pub lang: Lang, @@ -493,6 +502,7 @@ pub fn capture_project_dependencies_with_context( let framework_ctx = detect_frameworks(project_root); let frameworks = framework_ctx.frameworks.clone(); + let framework_adapter = spec.framework.as_ref().map(|b| b.adapter.clone()); let framework_signal = framework_ctx.lang_has_web_framework(framework_slug_for_lang(spec.lang)); let config_files = collect_config_files(&entry_file, project_root); @@ -509,6 +519,7 @@ pub fn capture_project_dependencies_with_context( toolchain, direct_deps, frameworks, + framework_adapter, framework_signal, config_files, source_closure, @@ -644,6 +655,7 @@ pub fn stage_workdir_full( toolchain: captured.toolchain.clone(), direct_deps: captured.direct_deps.clone(), frameworks: captured.frameworks.clone(), + framework_adapter: captured.framework_adapter.clone(), lang, }) } diff --git a/src/dynamic/framework/mod.rs b/src/dynamic/framework/mod.rs index ea8a1bb3..a8c8792f 100644 --- a/src/dynamic/framework/mod.rs +++ b/src/dynamic/framework/mod.rs @@ -17,6 +17,7 @@ pub mod adapters; pub mod auth_markers; pub mod registry; +pub mod runtime_deps; use crate::evidence::EntryKind; use crate::summary::FuncSummary; @@ -323,6 +324,18 @@ pub trait FrameworkAdapter: Sync { /// the trace-event detail string emitted by the verifier. fn name(&self) -> &'static str; + /// Runtime package-manager dependencies needed when a real harness + /// loads code matched by this adapter. + /// + /// Most adapters need no extra metadata because the entry source's + /// imports are enough for dependency capture. Adapters that can bind + /// from route files, annotations, or marker comments use the central + /// adapter-id registry so manifest synthesis can still install the + /// actual framework library before execution. + fn runtime_dependencies(&self) -> runtime_deps::FrameworkRuntimeDeps { + runtime_deps::deps_for_adapter(self.name()) + } + /// Language this adapter targets. fn lang(&self) -> Lang; diff --git a/src/dynamic/framework/runtime_deps.rs b/src/dynamic/framework/runtime_deps.rs new file mode 100644 index 00000000..3bd6c179 --- /dev/null +++ b/src/dynamic/framework/runtime_deps.rs @@ -0,0 +1,537 @@ +//! Runtime dependency hints for framework-bound dynamic harnesses. +//! +//! Framework adapters sometimes bind from marker text or framework +//! configuration while the entry source itself keeps the real import +//! commented out for host-portable corpus tests. When such a binding is +//! used to drive a real harness, the build step still needs the matching +//! package manager manifest so top-level imports resolve under the verifier. + +/// Package with a package-manager specific version requirement. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VersionedPackage { + pub name: &'static str, + pub version: &'static str, +} + +/// Maven dependency coordinates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MavenPackage { + pub group_id: &'static str, + pub artifact_id: &'static str, + pub version: &'static str, +} + +/// Adapter runtime dependencies grouped by package manager. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FrameworkRuntimeDeps { + pub python_packages: &'static [&'static str], + pub node_packages: &'static [VersionedPackage], + pub ruby_gems: &'static [&'static str], + pub composer_packages: &'static [VersionedPackage], + pub maven_packages: &'static [MavenPackage], + pub go_modules: &'static [VersionedPackage], + pub rust_crates: &'static [VersionedPackage], +} + +impl FrameworkRuntimeDeps { + pub const EMPTY: Self = Self { + python_packages: &[], + node_packages: &[], + ruby_gems: &[], + composer_packages: &[], + maven_packages: &[], + go_modules: &[], + rust_crates: &[], + }; + + pub fn is_empty(&self) -> bool { + self.python_packages.is_empty() + && self.node_packages.is_empty() + && self.ruby_gems.is_empty() + && self.composer_packages.is_empty() + && self.maven_packages.is_empty() + && self.go_modules.is_empty() + && self.rust_crates.is_empty() + } +} + +const PY_FLASK: &[&str] = &["Flask"]; +const PY_FASTAPI: &[&str] = &["fastapi", "httpx"]; +const PY_STARLETTE: &[&str] = &["starlette", "httpx"]; +const PY_DJANGO: &[&str] = &["Django"]; +const PY_CELERY: &[&str] = &["celery"]; +const PY_GRAPHENE: &[&str] = &["graphene"]; +const PY_CHANNELS: &[&str] = &["channels"]; +const PY_SOCKETIO: &[&str] = &["python-socketio"]; +const PY_ALEMBIC: &[&str] = &["alembic", "Flask-Migrate"]; +const PY_KAFKA: &[&str] = &["kafka-python"]; +const PY_SQS: &[&str] = &["boto3"]; +const PY_PUBSUB: &[&str] = &["google-cloud-pubsub"]; +const PY_RABBIT: &[&str] = &["pika"]; + +const NODE_EXPRESS: &[VersionedPackage] = &[VersionedPackage { + name: "express", + version: "^4.19.2", +}]; +const NODE_KOA: &[VersionedPackage] = &[ + VersionedPackage { + name: "koa", + version: "^2.15.3", + }, + VersionedPackage { + name: "@koa/router", + version: "^12.0.1", + }, +]; +const NODE_FASTIFY: &[VersionedPackage] = &[VersionedPackage { + name: "fastify", + version: "^4.28.1", +}]; +const NODE_CRON: &[VersionedPackage] = &[VersionedPackage { + name: "node-cron", + version: "^3.0.3", +}]; +const NODE_APOLLO: &[VersionedPackage] = &[ + VersionedPackage { + name: "@apollo/server", + version: "^4.10.4", + }, + VersionedPackage { + name: "apollo-server", + version: "^3.13.0", + }, + VersionedPackage { + name: "graphql", + version: "^16.8.1", + }, +]; +const NODE_RELAY: &[VersionedPackage] = &[ + VersionedPackage { + name: "graphql-relay", + version: "^0.10.0", + }, + VersionedPackage { + name: "graphql", + version: "^16.8.1", + }, +]; +const NODE_WS: &[VersionedPackage] = &[VersionedPackage { + name: "ws", + version: "^8.17.0", +}]; +const NODE_SQS: &[VersionedPackage] = &[ + VersionedPackage { + name: "@aws-sdk/client-sqs", + version: "^3.583.0", + }, + VersionedPackage { + name: "sqs-consumer", + version: "^11.5.0", + }, +]; +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_SEQUELIZE: &[VersionedPackage] = &[VersionedPackage { + name: "sequelize", + version: "^6.37.3", +}]; + +const RUBY_RACK: &[&str] = &["rack"]; +const RUBY_SINATRA: &[&str] = &["rack", "sinatra"]; +const RUBY_HANAMI: &[&str] = &["rack", "hanami-controller"]; +const RUBY_RAILS: &[&str] = &["rails"]; +const RUBY_SIDEKIQ: &[&str] = &["sidekiq"]; + +const PHP_LARAVEL: &[VersionedPackage] = &[VersionedPackage { + name: "laravel/framework", + version: "^10.0", +}]; +const PHP_SYMFONY: &[VersionedPackage] = &[ + VersionedPackage { + name: "symfony/http-foundation", + version: "^6.4", + }, + VersionedPackage { + name: "symfony/http-kernel", + version: "^6.4", + }, +]; +const PHP_CODEIGNITER: &[VersionedPackage] = &[VersionedPackage { + name: "codeigniter4/framework", + version: "^4.4", +}]; + +const JAVA_SPRING: &[MavenPackage] = &[MavenPackage { + group_id: "org.springframework", + artifact_id: "spring-webmvc", + version: "6.1.8", +}]; +const JAVA_SERVLET: &[MavenPackage] = &[ + MavenPackage { + group_id: "jakarta.servlet", + artifact_id: "jakarta.servlet-api", + version: "6.0.0", + }, + MavenPackage { + group_id: "javax.servlet", + artifact_id: "javax.servlet-api", + version: "4.0.1", + }, +]; +const JAVA_QUARTZ: &[MavenPackage] = &[MavenPackage { + group_id: "org.quartz-scheduler", + artifact_id: "quartz", + version: "2.3.2", +}]; +const JAVA_FLYWAY: &[MavenPackage] = &[MavenPackage { + group_id: "org.flywaydb", + artifact_id: "flyway-core", + version: "10.13.0", +}]; +const JAVA_LIQUIBASE: &[MavenPackage] = &[MavenPackage { + group_id: "org.liquibase", + artifact_id: "liquibase-core", + version: "4.28.0", +}]; +const JAVA_KAFKA: &[MavenPackage] = &[MavenPackage { + group_id: "org.apache.kafka", + artifact_id: "kafka-clients", + version: "3.7.0", +}]; +const JAVA_SQS: &[MavenPackage] = &[MavenPackage { + group_id: "software.amazon.awssdk", + artifact_id: "sqs", + version: "2.25.60", +}]; +const JAVA_RABBIT: &[MavenPackage] = &[MavenPackage { + group_id: "com.rabbitmq", + artifact_id: "amqp-client", + version: "5.21.0", +}]; +const JAVA_QUARKUS: &[MavenPackage] = &[MavenPackage { + group_id: "io.quarkus", + artifact_id: "quarkus-resteasy-reactive", + version: "3.10.2", +}]; +const JAVA_MICRONAUT: &[MavenPackage] = &[MavenPackage { + group_id: "io.micronaut", + artifact_id: "micronaut-http-server-netty", + version: "4.4.4", +}]; + +const GO_GIN: &[VersionedPackage] = &[VersionedPackage { + name: "github.com/gin-gonic/gin", + version: "v1.10.0", +}]; +const GO_ECHO: &[VersionedPackage] = &[VersionedPackage { + name: "github.com/labstack/echo/v4", + version: "v4.12.0", +}]; +const GO_FIBER: &[VersionedPackage] = &[VersionedPackage { + name: "github.com/gofiber/fiber/v2", + version: "v2.52.5", +}]; +const GO_CHI: &[VersionedPackage] = &[VersionedPackage { + name: "github.com/go-chi/chi/v5", + version: "v5.0.12", +}]; +const GO_GQLGEN: &[VersionedPackage] = &[VersionedPackage { + name: "github.com/99designs/gqlgen", + version: "v0.17.49", +}]; +const GO_MIGRATE: &[VersionedPackage] = &[VersionedPackage { + name: "github.com/golang-migrate/migrate/v4", + version: "v4.17.1", +}]; +const GO_PUBSUB: &[VersionedPackage] = &[VersionedPackage { + name: "cloud.google.com/go/pubsub", + version: "v1.39.0", +}]; +const GO_NATS: &[VersionedPackage] = &[VersionedPackage { + name: "github.com/nats-io/nats.go", + version: "v1.34.1", +}]; + +const RUST_AXUM: &[VersionedPackage] = &[ + VersionedPackage { + name: "axum", + version: "0.7", + }, + VersionedPackage { + name: "tokio", + version: "1", + }, +]; +const RUST_ACTIX: &[VersionedPackage] = &[VersionedPackage { + name: "actix-web", + version: "4", +}]; +const RUST_ROCKET: &[VersionedPackage] = &[VersionedPackage { + name: "rocket", + version: "0.5", +}]; +const RUST_WARP: &[VersionedPackage] = &[ + VersionedPackage { + name: "warp", + version: "0.3", + }, + VersionedPackage { + name: "tokio", + version: "1", + }, +]; +const RUST_JUNIPER: &[VersionedPackage] = &[VersionedPackage { + name: "juniper", + version: "0.16", +}]; +const RUST_REFINERY: &[VersionedPackage] = &[VersionedPackage { + name: "refinery", + version: "0.8", +}]; +const RUST_SQLX: &[VersionedPackage] = &[VersionedPackage { + name: "sqlx", + version: "0.7", +}]; + +/// Dependencies known for a framework adapter id. +pub fn deps_for_adapter(adapter: &str) -> FrameworkRuntimeDeps { + match adapter { + "python-flask" => FrameworkRuntimeDeps { + python_packages: PY_FLASK, + ..FrameworkRuntimeDeps::EMPTY + }, + "python-fastapi" => FrameworkRuntimeDeps { + python_packages: PY_FASTAPI, + ..FrameworkRuntimeDeps::EMPTY + }, + "python-starlette" => FrameworkRuntimeDeps { + python_packages: PY_STARLETTE, + ..FrameworkRuntimeDeps::EMPTY + }, + "python-django" | "middleware-django" | "migration-django" => FrameworkRuntimeDeps { + python_packages: PY_DJANGO, + ..FrameworkRuntimeDeps::EMPTY + }, + "scheduled-celery" => FrameworkRuntimeDeps { + python_packages: PY_CELERY, + ..FrameworkRuntimeDeps::EMPTY + }, + "graphql-graphene" => FrameworkRuntimeDeps { + python_packages: PY_GRAPHENE, + ..FrameworkRuntimeDeps::EMPTY + }, + "websocket-channels" => FrameworkRuntimeDeps { + python_packages: PY_CHANNELS, + ..FrameworkRuntimeDeps::EMPTY + }, + "websocket-socketio" => FrameworkRuntimeDeps { + python_packages: PY_SOCKETIO, + ..FrameworkRuntimeDeps::EMPTY + }, + "migration-flask" => FrameworkRuntimeDeps { + python_packages: PY_ALEMBIC, + ..FrameworkRuntimeDeps::EMPTY + }, + "kafka-python" => FrameworkRuntimeDeps { + python_packages: PY_KAFKA, + ..FrameworkRuntimeDeps::EMPTY + }, + "sqs-python" => FrameworkRuntimeDeps { + python_packages: PY_SQS, + ..FrameworkRuntimeDeps::EMPTY + }, + "pubsub-python" => FrameworkRuntimeDeps { + python_packages: PY_PUBSUB, + ..FrameworkRuntimeDeps::EMPTY + }, + "rabbit-python" => FrameworkRuntimeDeps { + python_packages: PY_RABBIT, + ..FrameworkRuntimeDeps::EMPTY + }, + "js-express" | "middleware-express" => FrameworkRuntimeDeps { + node_packages: NODE_EXPRESS, + ..FrameworkRuntimeDeps::EMPTY + }, + "js-koa" => FrameworkRuntimeDeps { + node_packages: NODE_KOA, + ..FrameworkRuntimeDeps::EMPTY + }, + "js-fastify" => FrameworkRuntimeDeps { + node_packages: NODE_FASTIFY, + ..FrameworkRuntimeDeps::EMPTY + }, + "scheduled-cron" => FrameworkRuntimeDeps { + node_packages: NODE_CRON, + ..FrameworkRuntimeDeps::EMPTY + }, + "graphql-apollo" => FrameworkRuntimeDeps { + node_packages: NODE_APOLLO, + ..FrameworkRuntimeDeps::EMPTY + }, + "graphql-relay" => FrameworkRuntimeDeps { + node_packages: NODE_RELAY, + ..FrameworkRuntimeDeps::EMPTY + }, + "websocket-ws" => FrameworkRuntimeDeps { + node_packages: NODE_WS, + ..FrameworkRuntimeDeps::EMPTY + }, + "sqs-node" => FrameworkRuntimeDeps { + node_packages: NODE_SQS, + ..FrameworkRuntimeDeps::EMPTY + }, + "migration-knex" => FrameworkRuntimeDeps { + node_packages: NODE_KNEX, + ..FrameworkRuntimeDeps::EMPTY + }, + "migration-prisma" => FrameworkRuntimeDeps { + node_packages: NODE_PRISMA, + ..FrameworkRuntimeDeps::EMPTY + }, + "migration-sequelize" => FrameworkRuntimeDeps { + node_packages: NODE_SEQUELIZE, + ..FrameworkRuntimeDeps::EMPTY + }, + "ruby-sinatra" => FrameworkRuntimeDeps { + ruby_gems: RUBY_SINATRA, + ..FrameworkRuntimeDeps::EMPTY + }, + "ruby-hanami" => FrameworkRuntimeDeps { + ruby_gems: RUBY_HANAMI, + ..FrameworkRuntimeDeps::EMPTY + }, + "ruby-rails" | "middleware-rails" | "migration-rails" | "websocket-actioncable" => { + FrameworkRuntimeDeps { + ruby_gems: RUBY_RAILS, + ..FrameworkRuntimeDeps::EMPTY + } + } + "scheduled-sidekiq" => FrameworkRuntimeDeps { + ruby_gems: RUBY_SIDEKIQ, + ..FrameworkRuntimeDeps::EMPTY + }, + "middleware-rack" => FrameworkRuntimeDeps { + ruby_gems: RUBY_RACK, + ..FrameworkRuntimeDeps::EMPTY + }, + "php-laravel" | "middleware-laravel" | "migration-laravel" => FrameworkRuntimeDeps { + composer_packages: PHP_LARAVEL, + ..FrameworkRuntimeDeps::EMPTY + }, + "php-symfony" => FrameworkRuntimeDeps { + composer_packages: PHP_SYMFONY, + ..FrameworkRuntimeDeps::EMPTY + }, + "php-codeigniter" => FrameworkRuntimeDeps { + composer_packages: PHP_CODEIGNITER, + ..FrameworkRuntimeDeps::EMPTY + }, + "java-spring" | "middleware-spring" => FrameworkRuntimeDeps { + maven_packages: JAVA_SPRING, + ..FrameworkRuntimeDeps::EMPTY + }, + "java-servlet" => FrameworkRuntimeDeps { + maven_packages: JAVA_SERVLET, + ..FrameworkRuntimeDeps::EMPTY + }, + "java-quarkus" => FrameworkRuntimeDeps { + maven_packages: JAVA_QUARKUS, + ..FrameworkRuntimeDeps::EMPTY + }, + "java-micronaut" => FrameworkRuntimeDeps { + maven_packages: JAVA_MICRONAUT, + ..FrameworkRuntimeDeps::EMPTY + }, + "scheduled-quartz" => FrameworkRuntimeDeps { + maven_packages: JAVA_QUARTZ, + ..FrameworkRuntimeDeps::EMPTY + }, + "migration-flyway" => FrameworkRuntimeDeps { + maven_packages: JAVA_FLYWAY, + ..FrameworkRuntimeDeps::EMPTY + }, + "migration-liquibase" => FrameworkRuntimeDeps { + maven_packages: JAVA_LIQUIBASE, + ..FrameworkRuntimeDeps::EMPTY + }, + "kafka-java" => FrameworkRuntimeDeps { + maven_packages: JAVA_KAFKA, + ..FrameworkRuntimeDeps::EMPTY + }, + "sqs-java" => FrameworkRuntimeDeps { + maven_packages: JAVA_SQS, + ..FrameworkRuntimeDeps::EMPTY + }, + "rabbit-java" => FrameworkRuntimeDeps { + maven_packages: JAVA_RABBIT, + ..FrameworkRuntimeDeps::EMPTY + }, + "go-gin" => FrameworkRuntimeDeps { + go_modules: GO_GIN, + ..FrameworkRuntimeDeps::EMPTY + }, + "go-echo" => FrameworkRuntimeDeps { + go_modules: GO_ECHO, + ..FrameworkRuntimeDeps::EMPTY + }, + "go-fiber" => FrameworkRuntimeDeps { + go_modules: GO_FIBER, + ..FrameworkRuntimeDeps::EMPTY + }, + "go-chi" => FrameworkRuntimeDeps { + go_modules: GO_CHI, + ..FrameworkRuntimeDeps::EMPTY + }, + "graphql-gqlgen" => FrameworkRuntimeDeps { + go_modules: GO_GQLGEN, + ..FrameworkRuntimeDeps::EMPTY + }, + "migration-go-migrate" => FrameworkRuntimeDeps { + go_modules: GO_MIGRATE, + ..FrameworkRuntimeDeps::EMPTY + }, + "pubsub-go" => FrameworkRuntimeDeps { + go_modules: GO_PUBSUB, + ..FrameworkRuntimeDeps::EMPTY + }, + "nats-go" => FrameworkRuntimeDeps { + go_modules: GO_NATS, + ..FrameworkRuntimeDeps::EMPTY + }, + "rust-axum" => FrameworkRuntimeDeps { + rust_crates: RUST_AXUM, + ..FrameworkRuntimeDeps::EMPTY + }, + "rust-actix" => FrameworkRuntimeDeps { + rust_crates: RUST_ACTIX, + ..FrameworkRuntimeDeps::EMPTY + }, + "rust-rocket" => FrameworkRuntimeDeps { + rust_crates: RUST_ROCKET, + ..FrameworkRuntimeDeps::EMPTY + }, + "rust-warp" => FrameworkRuntimeDeps { + rust_crates: RUST_WARP, + ..FrameworkRuntimeDeps::EMPTY + }, + "graphql-juniper" => FrameworkRuntimeDeps { + rust_crates: RUST_JUNIPER, + ..FrameworkRuntimeDeps::EMPTY + }, + "migration-refinery" => FrameworkRuntimeDeps { + rust_crates: RUST_REFINERY, + ..FrameworkRuntimeDeps::EMPTY + }, + "migration-sqlx" => FrameworkRuntimeDeps { + rust_crates: RUST_SQLX, + ..FrameworkRuntimeDeps::EMPTY + }, + _ => FrameworkRuntimeDeps::EMPTY, + } +} diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index 720b5b23..6575b984 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -317,6 +317,14 @@ pub fn materialize_go(env: &Environment) -> RuntimeArtifacts { }; let mut deps: Vec = Vec::new(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut versioned: Vec = Vec::new(); + if let Some(adapter) = env.framework_adapter.as_deref() { + for dep in crate::dynamic::framework::runtime_deps::deps_for_adapter(adapter).go_modules { + if seen.insert(dep.name.to_owned()) { + versioned.push(*dep); + } + } + } for d in &env.direct_deps { if is_go_stdlib(d) { continue; @@ -330,8 +338,11 @@ pub fn materialize_go(env: &Environment) -> RuntimeArtifacts { let mut body = String::with_capacity(128); body.push_str("module nyx_harness\n\n"); body.push_str(&format!("go {go_version}\n")); - if !deps.is_empty() { + if !deps.is_empty() || !versioned.is_empty() { body.push_str("\nrequire (\n"); + for dep in &versioned { + body.push_str(&format!("\t{} {}\n", dep.name, dep.version)); + } for d in &deps { body.push_str(&format!("\t{d} latest\n")); } @@ -651,6 +662,7 @@ pub fn emit(spec: &HarnessSpec) -> Result { // GraphQLResolver short-circuit (gqlgen). if let crate::evidence::EntryKind::GraphQLResolver { type_name, field } = &spec.entry_kind { return Ok(emit_graphql_resolver_harness( + spec, &spec.entry_name, type_name, field, @@ -660,7 +672,7 @@ pub fn emit(spec: &HarnessSpec) -> Result { let entry_source = read_entry_source(&spec.entry_file); let shape = GoShape::detect(spec, &entry_source); let main_go = generate_main_go(spec, shape); - let go_mod = generate_go_mod(shape); + let go_mod = generate_go_mod_for_spec(shape, spec); let mut extra_files = vec![("go.mod".to_owned(), go_mod)]; // Phase 15: GinHandler shape stages a minimal gin stub package so @@ -1356,23 +1368,56 @@ fn framework_route_invocation( } fn generate_go_mod(shape: GoShape) -> String { - let deps: &[(&str, &str)] = match shape { + render_go_mod(shape_go_deps(shape), &[]) +} + +fn generate_go_mod_for_spec(shape: GoShape, spec: &HarnessSpec) -> String { + let adapter_deps = spec + .framework + .as_ref() + .map(|binding| { + crate::dynamic::framework::runtime_deps::deps_for_adapter(&binding.adapter).go_modules + }) + .unwrap_or(&[]); + render_go_mod(shape_go_deps(shape), adapter_deps) +} + +fn shape_go_deps(shape: GoShape) -> &'static [(&'static str, &'static str)] { + match shape { GoShape::GinRoute => &[("github.com/gin-gonic/gin", "v1.10.0")], GoShape::EchoRoute => &[("github.com/labstack/echo/v4", "v4.12.0")], GoShape::FiberRoute => &[("github.com/gofiber/fiber/v2", "v2.52.5")], GoShape::ChiRoute => &[("github.com/go-chi/chi/v5", "v5.0.12")], _ => &[], - }; + } +} + +fn render_go_mod( + shape_deps: &[(&str, &str)], + adapter_deps: &[crate::dynamic::framework::runtime_deps::VersionedPackage], +) -> String { let mut out = "module nyx-harness\n\ngo 1.21\n".to_owned(); - if !deps.is_empty() { + if !shape_deps.is_empty() || !adapter_deps.is_empty() { out.push_str("\nrequire (\n"); - for (module, version) in deps { + let mut seen = std::collections::HashSet::new(); + for (module, version) in shape_deps { + seen.insert(*module); out.push('\t'); out.push_str(module); out.push(' '); out.push_str(version); out.push('\n'); } + for dep in adapter_deps { + if !seen.insert(dep.name) { + continue; + } + out.push('\t'); + out.push_str(dep.name); + out.push(' '); + out.push_str(dep.version); + out.push('\n'); + } out.push_str(")\n"); } out @@ -2106,7 +2151,7 @@ var NyxAutoReceivers = map[string]interface{{}}{{ /// default → Pub/Sub. fn emit_message_handler_harness(spec: &HarnessSpec, queue: &str) -> HarnessSource { let shim = probe_shim(); - let go_mod = generate_go_mod(GoShape::Generic); + let go_mod = generate_go_mod_for_spec(GoShape::Generic, spec); let handler = &spec.entry_name; let broker = go_broker_for_adapter(spec); @@ -2259,9 +2304,14 @@ func main() {{ /// map (mirrors the `NyxReceivers` / `NyxHandlers` contracts from /// Phase 19 / 20), constructs a synthetic `context.Background()`, and /// invokes the resolver with the payload positionally. -fn emit_graphql_resolver_harness(handler: &str, type_name: &str, field: &str) -> HarnessSource { +fn emit_graphql_resolver_harness( + spec: &HarnessSpec, + handler: &str, + type_name: &str, + field: &str, +) -> HarnessSource { let shim = probe_shim(); - let go_mod = generate_go_mod(GoShape::Generic); + let go_mod = generate_go_mod_for_spec(GoShape::Generic, spec); let source = format!( r##"// Nyx dynamic harness — GraphQL resolver (Phase 21 / Track M.3). package main diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 00f5ad9a..2bc0474b 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -498,6 +498,17 @@ pub fn materialize_java(env: &Environment) -> RuntimeArtifacts { .to_owned(); let mut deps: Vec = Vec::new(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut maven_deps: Vec = Vec::new(); + let mut seen_maven: std::collections::HashSet<(&'static str, &'static str)> = + std::collections::HashSet::new(); + if let Some(adapter) = env.framework_adapter.as_deref() { + for dep in crate::dynamic::framework::runtime_deps::deps_for_adapter(adapter).maven_packages + { + if seen_maven.insert((dep.group_id, dep.artifact_id)) { + maven_deps.push(*dep); + } + } + } for d in &env.direct_deps { if is_java_stdlib(d) { continue; @@ -523,8 +534,18 @@ pub fn materialize_java(env: &Environment) -> RuntimeArtifacts { " {java_version}\n" )); body.push_str(" \n"); - if !deps.is_empty() { + if !deps.is_empty() || !maven_deps.is_empty() { body.push_str(" \n"); + for dep in &maven_deps { + body.push_str(" \n"); + body.push_str(&format!(" {}\n", dep.group_id)); + body.push_str(&format!( + " {}\n", + dep.artifact_id + )); + body.push_str(&format!(" {}\n", dep.version)); + body.push_str(" \n"); + } for d in &deps { body.push_str(" \n"); body.push_str(&format!(" {d}\n")); @@ -3924,7 +3945,11 @@ public class NyxHarness {{ ".".to_owned(), "NyxHarness".to_owned(), ], - extra_files: message_handler_annotation_stubs(), + extra_files: { + let mut files = message_handler_annotation_stubs(); + files.extend(framework_dependency_files(spec)); + files + }, entry_subpath: Some(format!("{entry_class}.java")), } } @@ -3969,6 +3994,52 @@ public @interface RabbitListener { ] } +fn framework_dependency_files(spec: &HarnessSpec) -> Vec<(String, String)> { + if spec.expected_cap != crate::labels::Cap::CODE_EXEC { + return Vec::new(); + } + let Some(adapter) = spec.framework.as_ref().map(|b| b.adapter.as_str()) else { + return Vec::new(); + }; + let deps = crate::dynamic::framework::runtime_deps::deps_for_adapter(adapter); + if deps.maven_packages.is_empty() { + return Vec::new(); + } + let java_version = spec + .toolchain_id + .strip_prefix("java-") + .and_then(|v| v.parse::().ok()) + .unwrap_or(21); + let mut body = String::from("\n"); + body.push_str("\n"); + body.push_str(" 4.0.0\n"); + body.push_str(" nyx\n"); + body.push_str(" harness-framework\n"); + body.push_str(" 0.0.1\n"); + body.push_str(" \n"); + body.push_str(&format!( + " {java_version}\n" + )); + body.push_str(&format!( + " {java_version}\n" + )); + body.push_str(" \n"); + body.push_str(" \n"); + for dep in deps.maven_packages { + body.push_str(" \n"); + body.push_str(&format!(" {}\n", dep.group_id)); + body.push_str(&format!( + " {}\n", + dep.artifact_id + )); + body.push_str(&format!(" {}\n", dep.version)); + body.push_str(" \n"); + } + body.push_str(" \n"); + body.push_str("\n"); + vec![("pom.xml".to_owned(), body)] +} + // ── Phase 21 (Track M.3) — synthetic entry-kind harnesses ───────────────────── fn emit_scheduled_job_harness( @@ -4047,7 +4118,7 @@ public class NyxHarness {{ ".".to_owned(), "NyxHarness".to_owned(), ], - extra_files: vec![], + extra_files: framework_dependency_files(spec), entry_subpath: Some(format!("{entry_class}.java")), } } @@ -4123,7 +4194,7 @@ public class NyxHarness {{ ".".to_owned(), "NyxHarness".to_owned(), ], - extra_files: vec![], + extra_files: framework_dependency_files(spec), entry_subpath: Some(format!("{entry_class}.java")), } } diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index 70de48ca..22242706 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -380,6 +380,14 @@ pub fn materialize_node(env: &Environment) -> RuntimeArtifacts { let mut deps: Vec<(String, &'static str)> = Vec::new(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + if let Some(adapter) = env.framework_adapter.as_deref() { + for dep in crate::dynamic::framework::runtime_deps::deps_for_adapter(adapter).node_packages + { + if seen.insert(dep.name.to_owned()) { + deps.push((dep.name.to_owned(), dep.version)); + } + } + } for d in &env.direct_deps { if is_node_builtin(d) { continue; @@ -1039,7 +1047,7 @@ if (_h == null) {{ source: body, filename: "harness.js".to_owned(), command: vec!["node".to_owned(), "harness.js".to_owned()], - extra_files: Vec::new(), + extra_files: framework_dependency_files(spec), entry_subpath: Some(entry_subpath), } } @@ -1081,7 +1089,7 @@ if (_h == null) {{ source: body, filename: "harness.js".to_owned(), command: vec!["node".to_owned(), "harness.js".to_owned()], - extra_files: Vec::new(), + extra_files: framework_dependency_files(spec), entry_subpath: Some(entry_subpath), } } @@ -1125,7 +1133,7 @@ if (_h == null) {{ source: body, filename: "harness.js".to_owned(), command: vec!["node".to_owned(), "harness.js".to_owned()], - extra_files: Vec::new(), + extra_files: framework_dependency_files(spec), entry_subpath: Some(entry_subpath), } } @@ -1161,7 +1169,7 @@ const _res = {{ statusCode: 200, headers: {{}}, end: function(d){{ if (d != null source: body, filename: "harness.js".to_owned(), command: vec!["node".to_owned(), "harness.js".to_owned()], - extra_files: Vec::new(), + extra_files: framework_dependency_files(spec), entry_subpath: Some(entry_subpath), } } @@ -1222,7 +1230,7 @@ const _prisma = {{ source: body, filename: "harness.js".to_owned(), command: vec!["node".to_owned(), "harness.js".to_owned()], - extra_files: Vec::new(), + extra_files: framework_dependency_files(spec), entry_subpath: Some(entry_subpath), } } @@ -2527,10 +2535,19 @@ fn message_handler_dependency_files(spec: &HarnessSpec) -> Vec<(String, String)> return Vec::new(); } let source = read_entry_source(&spec.entry_file); - let deps = js_message_handler_deps(&source); + let mut deps = js_message_handler_deps(&source); + if let Some(adapter) = spec.framework.as_ref().map(|b| b.adapter.as_str()) { + for dep in crate::dynamic::framework::runtime_deps::deps_for_adapter(adapter).node_packages + { + if !deps.iter().any(|(name, _)| *name == dep.name) { + deps.push((dep.name, dep.version)); + } + } + } if deps.is_empty() { return Vec::new(); } + deps.sort_by(|a, b| a.0.cmp(b.0)); vec![ ( "package.json".to_owned(), @@ -2543,6 +2560,36 @@ 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 { + return Vec::new(); + } + let Some(adapter) = spec.framework.as_ref().map(|b| b.adapter.as_str()) else { + return Vec::new(); + }; + let mut deps: Vec<(&'static str, &'static str)> = + crate::dynamic::framework::runtime_deps::deps_for_adapter(adapter) + .node_packages + .iter() + .map(|dep| (dep.name, dep.version)) + .collect(); + if deps.is_empty() { + return Vec::new(); + } + deps.sort_by(|a, b| a.0.cmp(b.0)); + deps.dedup_by(|a, b| a.0 == b.0); + vec![ + ( + "package.json".to_owned(), + package_json_multi("nyx-harness-framework", &deps), + ), + ( + "package-lock.json".to_owned(), + package_lock_skeleton("nyx-harness-framework"), + ), + ] +} + 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 48af062b..d9758125 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -301,11 +301,31 @@ pub fn materialize_php(env: &Environment) -> RuntimeArtifacts { } else { php_ver }; + let adapter_deps = env + .framework_adapter + .as_deref() + .map(crate::dynamic::framework::runtime_deps::deps_for_adapter); + let composer_deps = adapter_deps + .as_ref() + .map(|deps| deps.composer_packages) + .unwrap_or(&[]); let mut body = String::with_capacity(128); body.push_str("{\n"); body.push_str(" \"name\": \"nyx/harness\",\n"); body.push_str(" \"require\": {\n"); - body.push_str(&format!(" \"php\": \">={php_ver}\"\n")); + body.push_str(&format!(" \"php\": \">={php_ver}\"")); + if !composer_deps.is_empty() { + body.push_str(",\n"); + for (i, dep) in composer_deps.iter().enumerate() { + body.push_str(&format!(" \"{}\": \"{}\"", dep.name, dep.version)); + if i + 1 != composer_deps.len() { + body.push(','); + } + body.push('\n'); + } + } else { + body.push('\n'); + } body.push_str(" }\n"); body.push_str("}\n"); artifacts.push("composer.json", body); @@ -567,12 +587,12 @@ pub fn emit(spec: &HarnessSpec) -> Result { // Phase 21 (Track M.3): Middleware short-circuit (Laravel handle()). if let crate::evidence::EntryKind::Middleware { name } = &spec.entry_kind { - return Ok(emit_middleware_harness(&spec.entry_name, name)); + return Ok(emit_middleware_harness(spec, name)); } // Phase 21 (Track M.3): Migration short-circuit (Laravel up()). if let crate::evidence::EntryKind::Migration { version } = &spec.entry_kind { - return Ok(emit_migration_harness(&spec.entry_name, version.as_deref())); + return Ok(emit_migration_harness(spec, version.as_deref())); } let entry_source = read_entry_source(&spec.entry_file); @@ -3084,8 +3104,9 @@ echo "__NYX_SINK_HIT__\n"; ) } -fn emit_middleware_harness(handler: &str, name: &str) -> HarnessSource { +fn emit_middleware_harness(spec: &HarnessSpec, name: &str) -> HarnessSource { let preamble = nyx_php_preamble(); + let handler = &spec.entry_name; let body = format!( r#"{preamble} echo "__NYX_MIDDLEWARE__: " . {name:?} . "\n"; @@ -3130,13 +3151,14 @@ if (class_exists({handler:?})) {{ source: body, filename: "harness.php".to_owned(), command: vec!["php".to_owned(), "harness.php".to_owned()], - extra_files: vec![], + extra_files: framework_dependency_files(spec), entry_subpath: Some("entry.php".to_owned()), } } -fn emit_migration_harness(handler: &str, version: Option<&str>) -> HarnessSource { +fn emit_migration_harness(spec: &HarnessSpec, version: Option<&str>) -> HarnessSource { let preamble = nyx_php_preamble(); + let handler = &spec.entry_name; let version_repr = version.unwrap_or(""); let body = format!( r#"{preamble} @@ -3175,11 +3197,35 @@ if (class_exists({handler:?})) {{ source: body, filename: "harness.php".to_owned(), command: vec!["php".to_owned(), "harness.php".to_owned()], - extra_files: vec![], + extra_files: framework_dependency_files(spec), entry_subpath: Some("entry.php".to_owned()), } } +fn framework_dependency_files(spec: &HarnessSpec) -> Vec<(String, String)> { + if spec.expected_cap != crate::labels::Cap::CODE_EXEC { + return Vec::new(); + } + let Some(adapter) = spec.framework.as_ref().map(|b| b.adapter.as_str()) else { + return Vec::new(); + }; + let deps = crate::dynamic::framework::runtime_deps::deps_for_adapter(adapter); + if deps.composer_packages.is_empty() { + return Vec::new(); + } + let mut body = String::from("{\n \"name\": \"nyx/harness-framework\",\n \"require\": {\n"); + body.push_str(" \"php\": \">=8.1\",\n"); + for (i, dep) in deps.composer_packages.iter().enumerate() { + body.push_str(&format!(" \"{}\": \"{}\"", dep.name, dep.version)); + if i + 1 != deps.composer_packages.len() { + body.push(','); + } + body.push('\n'); + } + body.push_str(" }\n}\n"); + vec![("composer.json".to_owned(), body)] +} + 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 36013c1d..0780eb46 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -468,6 +468,15 @@ pub fn materialize_python(env: &Environment) -> RuntimeArtifacts { let mut deps: Vec = Vec::new(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + if let Some(adapter) = env.framework_adapter.as_deref() { + for d in crate::dynamic::framework::runtime_deps::deps_for_adapter(adapter).python_packages + { + let canonical = canonical_python_pkg_name(d); + if seen.insert(canonical.clone()) { + deps.push(canonical); + } + } + } for d in &env.direct_deps { if is_python_stdlib(d) { continue; @@ -918,7 +927,7 @@ except Exception as _e: source: format!("{preamble}\n{body}\n{postamble}"), filename: "harness.py".to_owned(), command: vec!["python3".to_owned(), "harness.py".to_owned()], - extra_files: vec![], + extra_files: framework_dependency_files(spec), entry_subpath: None, } } @@ -1089,7 +1098,7 @@ except Exception as _e: source: format!("{preamble}\n{body}\n{postamble}"), filename: "harness.py".to_owned(), command: vec!["python3".to_owned(), "harness.py".to_owned()], - extra_files: vec![], + extra_files: framework_dependency_files(spec), entry_subpath: None, } } @@ -1147,7 +1156,7 @@ except Exception as _e: source: format!("{preamble}\n{body}\n{postamble}"), filename: "harness.py".to_owned(), command: vec!["python3".to_owned(), "harness.py".to_owned()], - extra_files: vec![], + extra_files: framework_dependency_files(spec), entry_subpath: None, } } @@ -1194,7 +1203,7 @@ except Exception as _e: source: format!("{preamble}\n{body}\n{postamble}"), filename: "harness.py".to_owned(), command: vec!["python3".to_owned(), "harness.py".to_owned()], - extra_files: vec![], + extra_files: framework_dependency_files(spec), entry_subpath: None, } } @@ -1250,7 +1259,7 @@ except Exception as _e: source: format!("{preamble}\n{body}\n{postamble}"), filename: "harness.py".to_owned(), command: vec!["python3".to_owned(), "harness.py".to_owned()], - extra_files: vec![], + extra_files: framework_dependency_files(spec), entry_subpath: None, } } @@ -1299,7 +1308,7 @@ except Exception as _e: source: format!("{preamble}\n{body}\n{postamble}"), filename: "harness.py".to_owned(), command: vec!["python3".to_owned(), "harness.py".to_owned()], - extra_files: vec![], + extra_files: framework_dependency_files(spec), entry_subpath: None, } } @@ -3129,10 +3138,44 @@ fn message_handler_dependency_files(spec: &HarnessSpec) -> Vec<(String, String)> return Vec::new(); } let source = read_entry_source(&spec.entry_file); - let deps = python_message_handler_deps(&source); + let mut deps = python_message_handler_deps(&source); + if let Some(adapter) = spec.framework.as_ref().map(|b| b.adapter.as_str()) { + for &dep in + crate::dynamic::framework::runtime_deps::deps_for_adapter(adapter).python_packages + { + if !deps.contains(&dep) { + deps.push(dep); + } + } + } if deps.is_empty() { return Vec::new(); } + deps.sort_unstable(); + let mut body = String::new(); + for dep in deps { + body.push_str(dep); + body.push('\n'); + } + vec![("requirements.txt".to_owned(), body)] +} + +fn framework_dependency_files(spec: &HarnessSpec) -> Vec<(String, String)> { + if spec.expected_cap != crate::labels::Cap::CODE_EXEC { + return Vec::new(); + } + let Some(adapter) = spec.framework.as_ref().map(|b| b.adapter.as_str()) else { + return Vec::new(); + }; + let mut deps: Vec<&'static str> = + crate::dynamic::framework::runtime_deps::deps_for_adapter(adapter) + .python_packages + .to_vec(); + if deps.is_empty() { + return Vec::new(); + } + deps.sort_unstable(); + deps.dedup(); let mut body = String::new(); for dep in deps { body.push_str(dep); diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index ebc30dac..696c2206 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -385,6 +385,13 @@ pub fn materialize_ruby(env: &Environment) -> RuntimeArtifacts { let mut artifacts = RuntimeArtifacts::new(); let mut deps: Vec = Vec::new(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + if let Some(adapter) = env.framework_adapter.as_deref() { + for d in crate::dynamic::framework::runtime_deps::deps_for_adapter(adapter).ruby_gems { + if seen.insert((*d).to_owned()) { + deps.push((*d).to_owned()); + } + } + } for d in &env.direct_deps { if is_ruby_stdlib(d) { continue; @@ -495,25 +502,22 @@ pub fn emit(spec: &HarnessSpec) -> Result { // Phase 21 (Track M.3): ScheduledJob short-circuit (Sidekiq workers). if let crate::evidence::EntryKind::ScheduledJob { schedule } = &spec.entry_kind { - return Ok(emit_scheduled_job_harness( - &spec.entry_name, - schedule.as_deref(), - )); + return Ok(emit_scheduled_job_harness(spec, schedule.as_deref())); } // Phase 21 (Track M.3): WebSocket short-circuit (ActionCable channels). if let crate::evidence::EntryKind::WebSocket { path } = &spec.entry_kind { - return Ok(emit_websocket_handler_harness(&spec.entry_name, path)); + return Ok(emit_websocket_handler_harness(spec, path)); } // Phase 21 (Track M.3): Middleware short-circuit (Rack-shape). if let crate::evidence::EntryKind::Middleware { name } = &spec.entry_kind { - return Ok(emit_middleware_harness(&spec.entry_name, name)); + return Ok(emit_middleware_harness(spec, name)); } // Phase 21 (Track M.3): Migration short-circuit (ActiveRecord up/down). if let crate::evidence::EntryKind::Migration { version } = &spec.entry_kind { - return Ok(emit_migration_harness(&spec.entry_name, version.as_deref())); + return Ok(emit_migration_harness(spec, version.as_deref())); } let entry_source = read_entry_source(&spec.entry_file); @@ -727,8 +731,9 @@ puts "__NYX_SINK_HIT__" ) } -fn emit_scheduled_job_harness(handler: &str, schedule: Option<&str>) -> HarnessSource { +fn emit_scheduled_job_harness(spec: &HarnessSpec, schedule: Option<&str>) -> HarnessSource { let preamble = nyx_ruby_preamble(); + let handler = &spec.entry_name; let sched = schedule.unwrap_or(""); let body = format!( r#"{preamble} @@ -773,13 +778,14 @@ end source: body, filename: "harness.rb".to_owned(), command: vec!["ruby".to_owned(), "harness.rb".to_owned()], - extra_files: vec![], + extra_files: framework_dependency_files(spec), entry_subpath: Some("entry.rb".to_owned()), } } -fn emit_websocket_handler_harness(handler: &str, path: &str) -> HarnessSource { +fn emit_websocket_handler_harness(spec: &HarnessSpec, path: &str) -> HarnessSource { let preamble = nyx_ruby_preamble(); + let handler = &spec.entry_name; let body = format!( r#"{preamble} puts "__NYX_WEBSOCKET__: " + {path:?} @@ -823,13 +829,14 @@ end source: body, filename: "harness.rb".to_owned(), command: vec!["ruby".to_owned(), "harness.rb".to_owned()], - extra_files: vec![], + extra_files: framework_dependency_files(spec), entry_subpath: Some("entry.rb".to_owned()), } } -fn emit_middleware_harness(handler: &str, name: &str) -> HarnessSource { +fn emit_middleware_harness(spec: &HarnessSpec, name: &str) -> HarnessSource { let preamble = nyx_ruby_preamble(); + let handler = &spec.entry_name; let body = format!( r#"{preamble} puts "__NYX_MIDDLEWARE__: " + {name:?} @@ -879,13 +886,14 @@ end source: body, filename: "harness.rb".to_owned(), command: vec!["ruby".to_owned(), "harness.rb".to_owned()], - extra_files: vec![], + extra_files: framework_dependency_files(spec), entry_subpath: Some("entry.rb".to_owned()), } } -fn emit_migration_harness(handler: &str, version: Option<&str>) -> HarnessSource { +fn emit_migration_harness(spec: &HarnessSpec, version: Option<&str>) -> HarnessSource { let preamble = nyx_ruby_preamble(); + let handler = &spec.entry_name; let ver = version.unwrap_or(""); let body = format!( r#"{preamble} @@ -932,11 +940,34 @@ end source: body, filename: "harness.rb".to_owned(), command: vec!["ruby".to_owned(), "harness.rb".to_owned()], - extra_files: vec![], + extra_files: framework_dependency_files(spec), entry_subpath: Some("entry.rb".to_owned()), } } +fn framework_dependency_files(spec: &HarnessSpec) -> Vec<(String, String)> { + if spec.expected_cap != crate::labels::Cap::CODE_EXEC { + return Vec::new(); + } + let Some(adapter) = spec.framework.as_ref().map(|b| b.adapter.as_str()) else { + return Vec::new(); + }; + let mut deps: Vec<&'static str> = + crate::dynamic::framework::runtime_deps::deps_for_adapter(adapter) + .ruby_gems + .to_vec(); + if deps.is_empty() { + return Vec::new(); + } + deps.sort_unstable(); + deps.dedup(); + let mut body = String::from("source 'https://rubygems.org'\n"); + for dep in deps { + body.push_str(&format!("gem '{dep}'\n")); + } + vec![("Gemfile".to_owned(), body)] +} + /// 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/lang/rust.rs b/src/dynamic/lang/rust.rs index 2dda8f0e..68d55f62 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -147,6 +147,14 @@ pub fn materialize_rust(env: &Environment) -> RuntimeArtifacts { let mut artifacts = RuntimeArtifacts::new(); let mut deps: Vec = Vec::new(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut versioned: Vec = Vec::new(); + if let Some(adapter) = env.framework_adapter.as_deref() { + for dep in crate::dynamic::framework::runtime_deps::deps_for_adapter(adapter).rust_crates { + if seen.insert(dep.name.to_owned()) { + versioned.push(*dep); + } + } + } for d in &env.direct_deps { if is_rust_stdlib(d) { continue; @@ -166,6 +174,12 @@ pub fn materialize_rust(env: &Environment) -> RuntimeArtifacts { body.push_str("name = \"nyx_harness\"\n"); body.push_str("path = \"src/main.rs\"\n\n"); body.push_str("[dependencies]\n"); + for dep in &versioned { + body.push_str(dep.name); + body.push_str(" = \""); + body.push_str(dep.version); + body.push_str("\"\n"); + } for d in &deps { body.push_str(d); body.push_str(" = \"*\"\n"); @@ -2023,7 +2037,7 @@ pub fn emit(spec: &HarnessSpec) -> Result { _ => return Err(UnsupportedReason::PayloadSlotUnsupported), } - let cargo_toml = generate_cargo_toml_for_shape(spec.expected_cap, shape); + let cargo_toml = generate_cargo_toml_for_spec(spec.expected_cap, shape, spec); let main_rs = generate_main_rs(spec, shape); Ok(HarnessSource { @@ -2348,7 +2362,7 @@ fn emit_graphql_resolver_harness( field: &str, ) -> HarnessSource { let shim = probe_shim(); - let cargo_toml = generate_cargo_toml(spec.expected_cap); + let cargo_toml = generate_cargo_toml_for_spec(spec.expected_cap, RustShape::Generic, spec); let handler = &spec.entry_name; let label = format!("{type_name}.{field}"); let body = format!( @@ -2571,6 +2585,36 @@ fn generate_cargo_toml_for_shape(cap: Cap, shape: RustShape) -> String { cargo } +fn generate_cargo_toml_for_spec(cap: Cap, shape: RustShape, spec: &HarnessSpec) -> String { + let mut cargo = generate_cargo_toml_for_shape(cap, shape); + let Some(adapter) = spec + .framework + .as_ref() + .map(|binding| binding.adapter.as_str()) + else { + return cargo; + }; + let deps = crate::dynamic::framework::runtime_deps::deps_for_adapter(adapter); + if deps.rust_crates.is_empty() { + return cargo; + } + let mut seen = std::collections::HashSet::new(); + for line in cargo.lines() { + if let Some((name, _)) = line.split_once(" = ") { + seen.insert(name.trim().to_owned()); + } + } + for dep in deps.rust_crates { + if seen.insert(dep.name.to_owned()) { + cargo.push_str(dep.name); + cargo.push_str(" = \""); + cargo.push_str(dep.version); + cargo.push_str("\"\n"); + } + } + cargo +} + /// Variant of [`generate_cargo_toml`] that conditionally pulls in /// `percent-encoding` for the HEADER_INJECTION benign control fixture /// (it routes the value through `utf8_percent_encode` to land CRLF as diff --git a/tests/env_capture_flask.rs b/tests/env_capture_flask.rs index 75c5ca93..75721401 100644 --- a/tests/env_capture_flask.rs +++ b/tests/env_capture_flask.rs @@ -26,6 +26,7 @@ use nyx_scanner::dynamic::environment::{ MAX_WORKDIR_BYTES, capture_project_dependencies, capture_project_dependencies_with_context, stage_workdir_full, }; +use nyx_scanner::dynamic::framework::FrameworkBinding; use nyx_scanner::dynamic::lang::materialize_runtime; use nyx_scanner::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot, SpecDerivationStrategy}; use nyx_scanner::labels::Cap; @@ -190,6 +191,144 @@ fn materialize_runtime_synthesises_pinned_manifest() { assert!(content.contains(&spec.spec_hash)); } +fn adapter_bound_spec( + lang: Lang, + entry_file: &str, + adapter: &str, + entry_kind: EntryKind, +) -> HarnessSpec { + HarnessSpec { + finding_id: format!("adapter-{adapter}"), + entry_file: entry_file.to_owned(), + entry_name: "run".to_owned(), + entry_kind: entry_kind.clone(), + lang, + toolchain_id: match lang { + Lang::Python => "python-3.11", + Lang::JavaScript | Lang::TypeScript => "node-20", + Lang::Java => "java-21", + Lang::Go => "go-1.21", + Lang::Rust => "rust-stable", + Lang::Php => "php-8.2", + Lang::Ruby => "ruby-3.2", + _ => "toolchain", + } + .to_owned(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::CODE_EXEC, + constraint_hints: vec![], + sink_file: entry_file.to_owned(), + sink_line: 1, + spec_hash: format!("hash-{adapter}"), + derivation: SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + framework: Some(FrameworkBinding { + adapter: adapter.to_owned(), + kind: entry_kind, + route: None, + request_params: vec![], + response_writer: None, + middleware: vec![], + }), + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), + } +} + +#[test] +fn materialize_runtime_adds_framework_adapter_deps_without_imports() { + let root = TempDir::new().unwrap(); + let cases = [ + ( + Lang::Python, + "task.py", + "scheduled-celery", + EntryKind::ScheduledJob { + schedule: Some("* * * * *".to_owned()), + }, + "requirements.txt", + "celery", + ), + ( + Lang::JavaScript, + "resolver.js", + "graphql-apollo", + EntryKind::GraphQLResolver { + type_name: "Query".to_owned(), + field: "user".to_owned(), + }, + "package.json", + "@apollo/server", + ), + ( + Lang::Ruby, + "worker.rb", + "scheduled-sidekiq", + EntryKind::ScheduledJob { schedule: None }, + "Gemfile", + "sidekiq", + ), + ( + Lang::Php, + "Middleware.php", + "middleware-laravel", + EntryKind::Middleware { + name: "AuthMiddleware".to_owned(), + }, + "composer.json", + "laravel/framework", + ), + ( + Lang::Java, + "QuartzJob.java", + "scheduled-quartz", + EntryKind::ScheduledJob { schedule: None }, + "pom.xml", + "org.quartz-scheduler", + ), + ( + Lang::Go, + "resolver.go", + "graphql-gqlgen", + EntryKind::GraphQLResolver { + type_name: "Query".to_owned(), + field: "user".to_owned(), + }, + "go.mod", + "github.com/99designs/gqlgen", + ), + ( + Lang::Rust, + "resolver.rs", + "graphql-juniper", + EntryKind::GraphQLResolver { + type_name: "Query".to_owned(), + field: "user".to_owned(), + }, + "Cargo.toml", + "juniper = \"0.16\"", + ), + ]; + + for (lang, entry_file, adapter, entry_kind, manifest, needle) in cases { + std::fs::write(root.path().join(entry_file), "/* marker-only fixture */\n").unwrap(); + let spec = adapter_bound_spec(lang, entry_file, adapter, entry_kind); + let captured = capture_project_dependencies(root.path(), &spec); + let stage = TempDir::new().unwrap(); + let env = stage_workdir_full(&captured, stage.path(), &spec.spec_hash, lang) + .expect("stage workdir"); + let artifacts = materialize_runtime(&env); + let (_, content) = artifacts + .files + .iter() + .find(|(rel, _)| rel == manifest) + .unwrap_or_else(|| panic!("{adapter} did not materialize {manifest}")); + assert!( + content.contains(needle), + "{adapter} manifest {manifest} missing {needle}: {content}", + ); + } +} + #[test] fn workdir_is_importable_when_python_available() { // Acceptance bullet: "the route boots and the verifier reaches the diff --git a/tests/phase21_corpus.rs b/tests/phase21_corpus.rs index c19a41be..2c010692 100644 --- a/tests/phase21_corpus.rs +++ b/tests/phase21_corpus.rs @@ -98,6 +98,33 @@ fn run_adapter( .unwrap_or_else(|| panic!("{} did not fire on {fixture}", adapter.name())) } +fn framework_bound_spec( + lang: Lang, + kind: EvEntryKind, + entry_name: &str, + entry_file: &str, + adapter: &str, +) -> HarnessSpec { + let mut spec = make_spec(lang, kind, entry_name, entry_file); + spec.framework = Some(FrameworkBinding { + adapter: adapter.to_owned(), + kind: spec.entry_kind.clone(), + route: None, + request_params: vec![], + response_writer: None, + middleware: vec![], + }); + spec +} + +fn extra_file_content<'a>(files: &'a [(String, String)], rel: &str) -> &'a str { + files + .iter() + .find(|(path, _)| path == rel) + .map(|(_, content)| content.as_str()) + .unwrap_or_else(|| panic!("{rel} missing from extra files: {files:?}")) +} + fn detect_phase21_fp_fixture( adapter: &dyn FrameworkAdapter, lang: Lang, @@ -920,6 +947,98 @@ fn migration_php_harness_carries_sentinel_and_handler() { assert!(h.source.contains("AddUsers")); } +#[test] +fn phase21_harness_emitters_stage_framework_dependency_manifests() { + let cases = [ + ( + Lang::Python, + EvEntryKind::ScheduledJob { + schedule: Some("*/5 * * * *".into()), + }, + "tick", + "tests/dynamic_fixtures/scheduled_job/celery/vuln.py", + "scheduled-celery", + "requirements.txt", + "celery", + ), + ( + Lang::JavaScript, + EvEntryKind::GraphQLResolver { + type_name: "Query".into(), + field: "user".into(), + }, + "resolveUser", + "tests/dynamic_fixtures/graphql_resolver/apollo/vuln.js", + "graphql-apollo", + "package.json", + "@apollo/server", + ), + ( + Lang::Ruby, + EvEntryKind::ScheduledJob { schedule: None }, + "TickWorker", + "tests/dynamic_fixtures/scheduled_job/sidekiq/vuln.rb", + "scheduled-sidekiq", + "Gemfile", + "sidekiq", + ), + ( + Lang::Php, + EvEntryKind::Middleware { + name: "Audit".into(), + }, + "Audit", + "tests/dynamic_fixtures/middleware/laravel/vuln.php", + "middleware-laravel", + "composer.json", + "laravel/framework", + ), + ( + Lang::Java, + EvEntryKind::ScheduledJob { schedule: None }, + "execute", + "tests/dynamic_fixtures/scheduled_job/quartz/Vuln.java", + "scheduled-quartz", + "pom.xml", + "org.quartz-scheduler", + ), + ( + Lang::Go, + EvEntryKind::GraphQLResolver { + type_name: "Query".into(), + field: "user".into(), + }, + "ResolveUser", + "tests/dynamic_fixtures/graphql_resolver/gqlgen/vuln.go", + "graphql-gqlgen", + "go.mod", + "github.com/99designs/gqlgen", + ), + ( + Lang::Rust, + EvEntryKind::GraphQLResolver { + type_name: "Query".into(), + field: "user".into(), + }, + "resolve_user", + "tests/dynamic_fixtures/graphql_resolver/juniper/vuln.rs", + "graphql-juniper", + "Cargo.toml", + "juniper = \"0.16\"", + ), + ]; + + for (lang, kind, entry_name, entry_file, adapter, manifest, needle) in cases { + let spec = framework_bound_spec(lang, kind, entry_name, entry_file, adapter); + let harness = lang::emit(&spec).expect("emit ok"); + let manifest_content = extra_file_content(&harness.extra_files, manifest); + assert!( + manifest_content.contains(needle), + "{adapter} manifest {manifest} missing {needle}: {manifest_content}", + ); + } +} + // ── Phase 21 acceptance: ≥75% Confirmed on each fixture set ────────────────── // // The synthetic harnesses + adapter pairings give a 100% binding rate