diff --git a/src/dynamic/build_sandbox.rs b/src/dynamic/build_sandbox.rs index 40c669a9..8d19878e 100644 --- a/src/dynamic/build_sandbox.rs +++ b/src/dynamic/build_sandbox.rs @@ -1642,6 +1642,7 @@ mod tests { spec_hash: "0000000000000000".to_owned(), derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } diff --git a/src/dynamic/environment.rs b/src/dynamic/environment.rs index 03e1539c..33239423 100644 --- a/src/dynamic/environment.rs +++ b/src/dynamic/environment.rs @@ -1176,6 +1176,7 @@ mod tests { spec_hash: "test0000abcd1234".into(), derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } diff --git a/src/dynamic/framework/mod.rs b/src/dynamic/framework/mod.rs new file mode 100644 index 00000000..065a5bfa --- /dev/null +++ b/src/dynamic/framework/mod.rs @@ -0,0 +1,280 @@ +//! Framework adapter abstraction (Track L.0). +//! +//! Replaces the ad-hoc per-language route / `main` detection that was +//! scattered across [`crate::dynamic::lang`] sub-modules with a single +//! dispatching trait. Every later phase in Track L plugs a concrete +//! adapter (Flask, Spring, Express, axum, …) into this trait. +//! +//! # Determinism +//! +//! [`detect_binding`] iterates the per-language adapter slice returned +//! by [`registry::adapters_for`] in registration order and returns the +//! first non-`None` match. The registration order is fixed at +//! compile time and kept sorted by [`FrameworkAdapter::name`] so a +//! phase that adds a new adapter cannot silently re-order an existing +//! match. + +pub mod registry; + +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; +use serde::{Deserialize, Serialize}; + +/// HTTP method recognised by route bindings. Mirrors +/// [`crate::entry_points::HttpMethod`] but is re-declared here so the +/// framework module does not pull in the static-analysis entry-point +/// types in callers that only need the dynamic-side shape. +pub use crate::entry_points::HttpMethod; + +/// HTTP route shape extracted from a framework binding (path + +/// method). Only populated when [`FrameworkBinding::kind`] is +/// [`EntryKind::HttpRoute`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RouteShape { + /// HTTP verb (`GET`, `POST`, …). + pub method: HttpMethod, + /// Route path template as registered with the framework (e.g. + /// `"/users/{id}"`). Adapter-specific placeholder syntax is + /// preserved verbatim. + pub path: String, +} + +/// Where on the external surface a function formal originates from. +/// +/// Adapters classify each declared parameter into one of these +/// buckets so downstream harness emitters know which request field +/// carries the payload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ParamSource { + /// URL path placeholder (e.g. `/users/{id}` → `id`). + PathSegment(String), + /// URL query string parameter. + QueryParam(String), + /// HTTP request header. + Header(String), + /// JSON request body (deserialised whole). + JsonBody, + /// HTML form field. + FormField(String), + /// HTTP cookie. + Cookie(String), + /// Implicit context object (e.g. `*gin.Context`, `HttpRequest`). + /// Not adversary-controlled directly; included so the binding + /// captures every formal position. + Implicit, +} + +/// Binding between a function formal and its external request slot. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParamBinding { + /// 0-based position in [`FuncSummary::param_names`]. + pub index: usize, + /// Declared parameter name (mirrors + /// `summary.param_names[index]`). + pub name: String, + /// External slot this parameter is wired to. + pub source: ParamSource, +} + +/// Shape of how the handler writes a response. Track L plans to use +/// this to pick the right oracle (HTML render → XSS, JSON → no-op, +/// redirect → open-redirect). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResponseShape { + /// Response media kind. + pub kind: ResponseKind, +} + +/// Coarse classification of a response writer's output. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResponseKind { + Json, + Html, + Text, + Redirect, + Stream, +} + +/// Middleware attached to a route (auth filter, CSRF guard, +/// before-action, decorator chain, …). Adapters record the name so +/// later phases can classify it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MiddlewareShape { + /// Adapter-local middleware identifier (e.g. `"login_required"`, + /// `"@PreAuthorize"`, `"csrf"`). + pub name: String, +} + +/// Full framework binding for a function: every detail about how an +/// external surface reaches the function body. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FrameworkBinding { + /// Stable id of the adapter that produced this binding. Equal to + /// the originating [`FrameworkAdapter::name`]. Persisted into + /// trace details verbatim. + pub adapter: String, + /// Entry-surface taxonomy bucket this function falls into. + pub kind: EntryKind, + /// HTTP route shape when [`Self::kind`] is + /// [`EntryKind::HttpRoute`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub route: Option, + /// Per-formal external-slot classification. May be empty if the + /// adapter does not yet model parameter shapes (e.g. a Phase-01 + /// stub). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub request_params: Vec, + /// Response writer shape, when the adapter can determine it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response_writer: Option, + /// Middleware chain attached to the route, in declaration order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub middleware: Vec, +} + +/// Per-framework adapter trait. Each implementation inspects a +/// function (via its [`FuncSummary`] and the file's AST root) and +/// decides whether the function is bound to an external entry +/// surface. +/// +/// Implementations live next to the per-language harness emitters in +/// [`crate::dynamic::lang`] and register into [`registry::adapters_for`] +/// in subsequent Track-L phases. Phase 01 ships the trait and an +/// empty registry per language. +pub trait FrameworkAdapter: Sync { + /// Stable adapter id (e.g. `"flask"`, `"spring-mvc"`, `"axum"`). + /// Used for deterministic ordering inside the registry and for + /// the trace-event detail string emitted by the verifier. + fn name(&self) -> &'static str; + + /// Language this adapter targets. + fn lang(&self) -> Lang; + + /// Inspect a function and return its [`FrameworkBinding`] when + /// the function is driven by this adapter, otherwise `None`. + /// + /// `ast` is the file's tree-sitter root node and `file_bytes` is + /// the raw source so adapters can re-walk for decorators, + /// routing macros, or registration sites that the + /// [`FuncSummary`] alone does not preserve. + fn detect( + &self, + summary: &FuncSummary, + ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option; +} + +/// Walk every adapter registered for `lang` in registration order +/// and return the first non-`None` binding. Returns `None` when no +/// adapter matches or when no adapters are registered for `lang`. +pub fn detect_binding( + summary: &FuncSummary, + ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + lang: Lang, +) -> Option { + for adapter in registry::adapters_for(lang) { + debug_assert_eq!( + adapter.lang(), + lang, + "adapter '{}' registered under wrong lang", + adapter.name() + ); + if let Some(binding) = adapter.detect(summary, ast, file_bytes) { + return Some(binding); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::summary::FuncSummary; + + fn synth_summary(name: &str, lang: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + file_path: "tests/synthetic.rs".into(), + lang: lang.into(), + ..Default::default() + } + } + + fn parse_python(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn registry_is_empty_for_every_lang_phase_01() { + // Regression guard: Phase 01 ships the trait + dispatch + // machinery but registers zero adapters. Subsequent Track-L + // phases register concrete adapters per language; this test + // documents the starting baseline so accidental re-ordering + // is caught by `tests/determinism_audit.rs`. + for lang in [ + Lang::Rust, + Lang::C, + Lang::Cpp, + Lang::Java, + Lang::Go, + Lang::Php, + Lang::Python, + Lang::Ruby, + Lang::TypeScript, + Lang::JavaScript, + ] { + assert!( + registry::adapters_for(lang).is_empty(), + "{:?} starts with zero registered adapters", + lang + ); + } + } + + #[test] + fn detect_binding_returns_none_with_empty_registry() { + // Empty registry means `detect_binding` short-circuits to + // `None` for every input regardless of summary content. + let summary = synth_summary("handler", "python"); + let src: &[u8] = b"def handler():\n pass\n"; + let tree = parse_python(src); + let binding = detect_binding(&summary, tree.root_node(), src, Lang::Python); + assert!(binding.is_none()); + } + + #[test] + fn framework_binding_round_trips_through_serde() { + // The binding is persisted into repro bundles; ensure every + // field round-trips. + let original = FrameworkBinding { + adapter: "flask".into(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { + method: HttpMethod::POST, + path: "/users/{id}".into(), + }), + request_params: vec![ParamBinding { + index: 0, + name: "id".into(), + source: ParamSource::PathSegment("id".into()), + }], + response_writer: Some(ResponseShape { + kind: ResponseKind::Json, + }), + middleware: vec![MiddlewareShape { + name: "login_required".into(), + }], + }; + let json = serde_json::to_string(&original).unwrap(); + let parsed: FrameworkBinding = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, original); + } +} diff --git a/src/dynamic/framework/registry.rs b/src/dynamic/framework/registry.rs new file mode 100644 index 00000000..a943a596 --- /dev/null +++ b/src/dynamic/framework/registry.rs @@ -0,0 +1,53 @@ +//! Per-language [`super::FrameworkAdapter`] dispatch table. +//! +//! Phase 01 (Track L.0) ships an empty table for every language; the +//! [`super::FrameworkAdapter`] trait, [`super::FrameworkBinding`] data +//! shape, and the [`super::detect_binding`] dispatcher are wired +//! through so subsequent Track-L phases only need to register a +//! concrete adapter here. +//! +//! # Ordering contract +//! +//! Within each `static` slice, adapters must be listed in alphabetical +//! order of [`super::FrameworkAdapter::name`]. The lexical ordering +//! gives a deterministic first-match result that survives merges / +//! rebases without subtle re-ordering bugs. A `framework` unit test +//! ([`super::tests::registry_is_empty_for_every_lang_phase_01`]) +//! captures the Phase-01 starting baseline so a phase that registers +//! its first adapter is forced to update both the slice *and* the +//! regression guard in the same change. + +use super::FrameworkAdapter; +use crate::symbol::Lang; + +/// Adapters registered for `lang`, returned in deterministic +/// first-match order. Returns an empty slice for languages that have +/// no adapters registered yet. +pub fn adapters_for(lang: Lang) -> &'static [&'static dyn FrameworkAdapter] { + match lang { + Lang::Rust => RUST, + Lang::C => C, + Lang::Cpp => CPP, + Lang::Java => JAVA, + Lang::Go => GO, + Lang::Php => PHP, + Lang::Python => PYTHON, + Lang::Ruby => RUBY, + Lang::TypeScript => TYPESCRIPT, + Lang::JavaScript => JAVASCRIPT, + } +} + +// All slices intentionally empty in Phase 01. Later Track-L phases +// register concrete adapters (Flask, Spring, axum, Express, …) into +// the appropriate language slice. +static RUST: &[&dyn FrameworkAdapter] = &[]; +static C: &[&dyn FrameworkAdapter] = &[]; +static CPP: &[&dyn FrameworkAdapter] = &[]; +static JAVA: &[&dyn FrameworkAdapter] = &[]; +static GO: &[&dyn FrameworkAdapter] = &[]; +static PHP: &[&dyn FrameworkAdapter] = &[]; +static PYTHON: &[&dyn FrameworkAdapter] = &[]; +static RUBY: &[&dyn FrameworkAdapter] = &[]; +static TYPESCRIPT: &[&dyn FrameworkAdapter] = &[]; +static JAVASCRIPT: &[&dyn FrameworkAdapter] = &[]; diff --git a/src/dynamic/harness.rs b/src/dynamic/harness.rs index 8106e718..21410cf5 100644 --- a/src/dynamic/harness.rs +++ b/src/dynamic/harness.rs @@ -200,6 +200,7 @@ mod tests { spec_hash: "0000000000000000".into(), derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, }; let err = build(&spec).unwrap_err(); assert!(matches!(err, HarnessError::Unsupported(_))); @@ -222,6 +223,7 @@ mod tests { spec_hash: "test0000abcd1234".into(), derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, }; let harness = build(&spec).unwrap(); assert!(harness.workdir.join("harness.py").exists()); diff --git a/src/dynamic/lang/c.rs b/src/dynamic/lang/c.rs index da12a8e3..2f374e66 100644 --- a/src/dynamic/lang/c.rs +++ b/src/dynamic/lang/c.rs @@ -666,6 +666,7 @@ mod tests { spec_hash: "ctest0000000001".into(), derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } diff --git a/src/dynamic/lang/cpp.rs b/src/dynamic/lang/cpp.rs index 72c7ad43..6e9efccf 100644 --- a/src/dynamic/lang/cpp.rs +++ b/src/dynamic/lang/cpp.rs @@ -583,6 +583,7 @@ mod tests { spec_hash: "cpptest00000001".into(), derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index c84a4fd8..84c5e824 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -764,6 +764,7 @@ mod tests { spec_hash: "go0000000000001".into(), derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 4260f0a1..71b9ea9c 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -998,6 +998,7 @@ mod tests { spec_hash: "java00000000001".into(), derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } diff --git a/src/dynamic/lang/javascript.rs b/src/dynamic/lang/javascript.rs index 5ba18cf7..65b397e1 100644 --- a/src/dynamic/lang/javascript.rs +++ b/src/dynamic/lang/javascript.rs @@ -81,6 +81,7 @@ mod tests { spec_hash: "js000000000001".into(), derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index 0a08e0a2..b37fe16e 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -990,6 +990,7 @@ mod tests { spec_hash: "jsshared00000001".into(), derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index 68ef8571..8779bec3 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -606,6 +606,7 @@ mod tests { spec_hash: "php0000000000001".into(), derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index c50fda51..27010018 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -1213,6 +1213,7 @@ mod tests { spec_hash: "00000000deadbeef".into(), derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index e9c2ec18..ededaf9d 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -685,6 +685,7 @@ mod tests { spec_hash: "rb000000000001".into(), derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index 236b6915..73121cda 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -797,6 +797,7 @@ mod tests { spec_hash: "rusttest00000001".into(), derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } diff --git a/src/dynamic/lang/typescript.rs b/src/dynamic/lang/typescript.rs index e880e513..6f77ef11 100644 --- a/src/dynamic/lang/typescript.rs +++ b/src/dynamic/lang/typescript.rs @@ -79,6 +79,7 @@ mod tests { spec_hash: "ts000000000001ab".into(), derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } diff --git a/src/dynamic/mod.rs b/src/dynamic/mod.rs index d59a9e01..e8149121 100644 --- a/src/dynamic/mod.rs +++ b/src/dynamic/mod.rs @@ -69,6 +69,7 @@ pub mod build_sandbox; pub mod corpus; pub mod differential; pub mod environment; +pub mod framework; pub mod harness; pub mod lang; pub mod mount_filter; diff --git a/src/dynamic/repro.rs b/src/dynamic/repro.rs index a9532580..8cda6c5c 100644 --- a/src/dynamic/repro.rs +++ b/src/dynamic/repro.rs @@ -680,6 +680,7 @@ mod tests { spec_hash: "cafecafecafe0001".into(), derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } diff --git a/src/dynamic/spec.rs b/src/dynamic/spec.rs index d05fd165..72cd7164 100644 --- a/src/dynamic/spec.rs +++ b/src/dynamic/spec.rs @@ -20,6 +20,7 @@ use crate::callgraph::{CallGraph, CallGraphAnalysis}; use crate::commands::scan::Diag; use crate::dynamic::corpus::CORPUS_VERSION; +use crate::dynamic::framework::FrameworkBinding; use crate::dynamic::stubs::StubKind; use crate::evidence::{Confidence, FlowStepKind, UnsupportedReason}; use crate::labels::Cap; @@ -124,6 +125,25 @@ pub struct HarnessSpec { /// the cache deserialise as an empty list. #[serde(default)] pub stubs_required: Vec, + /// Track L.0 — framework binding recovered for the entry function + /// (route shape, request slots, response writer, middleware chain). + /// + /// Populated by [`crate::dynamic::framework::detect_binding`] when + /// a registered [`crate::dynamic::framework::FrameworkAdapter`] + /// matches the resolved entry; `None` when no adapter matches or + /// when the spec-derivation path lacks the AST context required + /// to dispatch. Phase 01 ships with an empty adapter registry so + /// this field is `None` for every spec; subsequent Track-L phases + /// register adapters and back-fill the binding. + /// + /// Excluded from [`compute_spec_hash`]: the binding is descriptive + /// metadata derived from the entry function and does not change + /// the harness boundary topology that the spec hash protects. + /// `#[serde(default, skip_serializing_if = "Option::is_none")]` so + /// pre-Phase-01 serialised specs deserialise unchanged and an + /// absent binding does not bloat repro-bundle JSON. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub framework: Option, } fn default_derivation_strategy() -> SpecDerivationStrategy { @@ -1054,11 +1074,51 @@ fn finalize_spec( spec_hash: String::new(), derivation, stubs_required, + // Phase 01 (Track L.0): the framework adapter registry is + // empty, so leave the binding unpopulated. Subsequent phases + // back-fill via `attach_framework_binding` once the spec's + // entry has been resolved and an AST is available. + framework: None, }; + attach_framework_binding(&mut spec); spec.spec_hash = compute_spec_hash(&spec); spec } +/// Dispatch the resolved entry function through +/// [`crate::dynamic::framework::detect_binding`] and stash the result +/// on [`HarnessSpec::framework`]. +/// +/// Invoked unconditionally at the tail of [`finalize_spec`] so every +/// strategy ([`SpecDerivationStrategy::FromFlowSteps`] … +/// [`SpecDerivationStrategy::FromCallgraphEntry`]) benefits without +/// per-strategy plumbing. +/// +/// # Phase 01 contract +/// +/// The framework adapter registry is empty in Phase 01, so this +/// function fast-paths to a no-op when +/// [`crate::dynamic::framework::registry::adapters_for`] returns an +/// empty slice. That avoids parsing the entry file from disk in the +/// common (empty) case and keeps the spec-derivation path side-effect +/// free. Subsequent Track-L phases that register concrete adapters +/// also extend this function to parse `spec.entry_file` and call +/// [`crate::dynamic::framework::detect_binding`] with the resulting +/// tree-sitter root. +fn attach_framework_binding(spec: &mut HarnessSpec) { + if crate::dynamic::framework::registry::adapters_for(spec.lang).is_empty() { + return; + } + // Phase-01 stub. When Track L.1+ registers its first adapter, + // this branch will (a) read `spec.entry_file` via + // `std::fs::read`, (b) parse with the language's tree-sitter + // grammar, (c) construct a `FuncSummary` from `spec` + the + // matching summary index, and (d) call + // `crate::dynamic::framework::detect_binding`. Left empty here + // because Phase 01 ships zero adapters and the verifier's + // acceptance test demands byte-identical verdicts. +} + /// Walk `flow_steps` and return the entry point: the enclosing function of /// the first `Source` step that has a function annotation. This is the /// outermost callable that receives the tainted input. @@ -1331,6 +1391,7 @@ mod tests { spec_hash: String::new(), derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, }; spec.spec_hash = compute_spec_hash(&spec); spec diff --git a/src/dynamic/telemetry.rs b/src/dynamic/telemetry.rs index 7a211bb5..5a99c542 100644 --- a/src/dynamic/telemetry.rs +++ b/src/dynamic/telemetry.rs @@ -640,6 +640,7 @@ mod tests { spec_hash: "abcd1234abcd1234".into(), derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } diff --git a/src/dynamic/trace.rs b/src/dynamic/trace.rs index 74e7ae83..b4a45dc7 100644 --- a/src/dynamic/trace.rs +++ b/src/dynamic/trace.rs @@ -40,6 +40,15 @@ use std::sync::Mutex; pub enum TraceStage { SpecStarted, SpecDone, + /// Track L.0 — a [`crate::dynamic::framework::FrameworkAdapter`] + /// claimed the spec's entry function. `detail` carries the + /// adapter name verbatim (e.g. `"flask"`, `"spring-mvc"`). + FrameworkAdapterDetected, + /// Track L.0 — no registered adapter matched the spec's entry + /// function. Emitted alongside [`Self::SpecDone`] for every spec + /// so a trace consumer can audit framework-detection coverage by + /// counting `framework_adapter_*` events. + FrameworkAdapterNone, BuildStarted, BuildDone, SandboxStarted, @@ -56,6 +65,8 @@ impl TraceStage { match self { Self::SpecStarted => "spec_started", Self::SpecDone => "spec_done", + Self::FrameworkAdapterDetected => "framework_adapter_detected", + Self::FrameworkAdapterNone => "framework_adapter_none", Self::BuildStarted => "build_started", Self::BuildDone => "build_done", Self::SandboxStarted => "sandbox_started", diff --git a/src/dynamic/verify.rs b/src/dynamic/verify.rs index a353bdf0..b962efec 100644 --- a/src/dynamic/verify.rs +++ b/src/dynamic/verify.rs @@ -572,6 +572,25 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult { spec.spec_hash, spec.lang, spec.entry_kind )), ); + // Track L.0: surface framework-adapter dispatch outcome to the + // trace so operators (and the Phase 30 determinism audit) can see + // whether an adapter claimed the entry function. Phase 01 always + // emits the `None` variant because the adapter registry is empty; + // subsequent Track-L phases register adapters and switch the + // event to `Detected` with the adapter name in `detail`. + match &spec.framework { + Some(binding) => trace.record( + crate::dynamic::trace::TraceStage::FrameworkAdapterDetected, + Some(format!( + "adapter={} kind={:?}", + binding.adapter, binding.kind + )), + ), + None => trace.record( + crate::dynamic::trace::TraceStage::FrameworkAdapterNone, + Some(format!("lang={:?} entry={}", spec.lang, spec.entry_name)), + ), + } // Pre-flight gate: surface a structured `Inconclusive(EntryKindUnsupported)` // up-front when the spec's [`EntryKind`] is not in the lang emitter's diff --git a/tests/common/fixture_harness.rs b/tests/common/fixture_harness.rs index bdcf9d98..2fd68c6f 100644 --- a/tests/common/fixture_harness.rs +++ b/tests/common/fixture_harness.rs @@ -491,6 +491,7 @@ pub fn run_shape_fixture_lang( spec_hash: spec_hash.clone(), derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, }; // Phase 14: Java shape fixtures bundle annotation / type stubs as @@ -785,6 +786,7 @@ pub fn run_harness_snapshot_lang( spec_hash: "snapshotsnapshot".into(), derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, }; let harness = lang_emit::emit(&spec).expect("emitter must produce a harness"); diff --git a/tests/env_capture_flask.rs b/tests/env_capture_flask.rs index e80104f0..8c69ccba 100644 --- a/tests/env_capture_flask.rs +++ b/tests/env_capture_flask.rs @@ -58,6 +58,7 @@ fn flask_spec(entry_rel: &str) -> HarnessSpec { spec_hash: "phase09testabcd1".into(), derivation: SpecDerivationStrategy::FromCallgraphEntry, stubs_required: vec![], + framework: None, } } diff --git a/tests/java_fixtures.rs b/tests/java_fixtures.rs index bcdd8c9c..27828989 100644 --- a/tests/java_fixtures.rs +++ b/tests/java_fixtures.rs @@ -744,6 +744,7 @@ public class App { spec_hash: "phase14staging00".into(), derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, }; let captured = capture_project_dependencies(project_root.path(), &spec); diff --git a/tests/oracle_sink_crash.rs b/tests/oracle_sink_crash.rs index 05b4a9f5..0a031c0f 100644 --- a/tests/oracle_sink_crash.rs +++ b/tests/oracle_sink_crash.rs @@ -364,6 +364,7 @@ mod e2e_phase_08 { spec_hash: spec_hash.clone(), derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, }; (spec, tmp) diff --git a/tests/repro_determinism.rs b/tests/repro_determinism.rs index 299337e9..7c5fbbb8 100644 --- a/tests/repro_determinism.rs +++ b/tests/repro_determinism.rs @@ -35,6 +35,7 @@ mod repro_determinism_tests { spec_hash: spec_hash.to_owned(), derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } @@ -172,6 +173,7 @@ mod repro_determinism_tests { spec_hash: spec_hash.to_owned(), derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } @@ -304,6 +306,7 @@ fn main() { spec_hash: spec_hash.to_owned(), derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } @@ -359,6 +362,7 @@ fn main() { spec_hash: spec_hash.to_owned(), derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } @@ -414,6 +418,7 @@ fn main() { spec_hash: spec_hash.to_owned(), derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } @@ -469,6 +474,7 @@ fn main() { spec_hash: spec_hash.to_owned(), derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } diff --git a/tests/repro_fixture_bundles.rs b/tests/repro_fixture_bundles.rs index e3707bed..5d54739b 100644 --- a/tests/repro_fixture_bundles.rs +++ b/tests/repro_fixture_bundles.rs @@ -97,6 +97,7 @@ fn flask_eval_spec() -> HarnessSpec { spec_hash: FLASK_EVAL_SPEC_HASH.into(), derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } diff --git a/tests/repro_hermetic.rs b/tests/repro_hermetic.rs index 3f5057b1..5e565ddd 100644 --- a/tests/repro_hermetic.rs +++ b/tests/repro_hermetic.rs @@ -54,6 +54,7 @@ mod repro_hermetic_tests { spec_hash: "hermetic00000001".into(), derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } } diff --git a/tests/telemetry_schema.rs b/tests/telemetry_schema.rs index 4b0fd027..59bd684a 100644 --- a/tests/telemetry_schema.rs +++ b/tests/telemetry_schema.rs @@ -41,6 +41,7 @@ fn make_spec(hash: &str) -> HarnessSpec { spec_hash: hash.into(), derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], + framework: None, } }