diff --git a/src/dynamic/lang/c.rs b/src/dynamic/lang/c.rs index c3e5cbdf..94236627 100644 --- a/src/dynamic/lang/c.rs +++ b/src/dynamic/lang/c.rs @@ -28,7 +28,7 @@ //! - `PayloadSlot::Argv(n)` — `main(argc, argv)` shape: appended to argv. use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; -use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; +use crate::dynamic::spec::{EntryKindTag, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; @@ -40,10 +40,10 @@ pub struct CEmitter; /// `Function` covers free functions (libfuzzer-style + plain (const /// char*, size_t)). `CliSubcommand` covers `main(argc, argv)`. /// `LibraryApi` covers libFuzzer `LLVMFuzzerTestOneInput`. -const SUPPORTED: &[EntryKind] = &[ - EntryKind::Function, - EntryKind::CliSubcommand, - EntryKind::LibraryApi, +const SUPPORTED: &[EntryKindTag] = &[ + EntryKindTag::Function, + EntryKindTag::CliSubcommand, + EntryKindTag::LibraryApi, ]; // ── Phase 16: shape detector ───────────────────────────────────────────────── @@ -66,7 +66,7 @@ impl CShape { /// Detect the shape from `(spec, source)`. pub fn detect(spec: &HarnessSpec, source: &str) -> Self { let entry = spec.entry_name.as_str(); - let kind = spec.entry_kind; + let kind = spec.entry_kind.tag(); let has_main_argv = (source.contains("int main(") || source.contains("int main (")) && (source.contains("argc") || source.contains("char *argv") @@ -80,8 +80,8 @@ impl CShape { return Self::MainArgv; } match kind { - EntryKind::CliSubcommand => Self::MainArgv, - EntryKind::LibraryApi => Self::LibfuzzerEntry, + EntryKindTag::CliSubcommand => Self::MainArgv, + EntryKindTag::LibraryApi => Self::LibfuzzerEntry, _ => Self::FreeFn, } } @@ -362,13 +362,13 @@ impl LangEmitter for CEmitter { emit(spec) } - fn entry_kinds_supported(&self) -> &'static [EntryKind] { + fn entry_kinds_supported(&self) -> &'static [EntryKindTag] { SUPPORTED } - fn entry_kind_hint(&self, attempted: EntryKind) -> String { + fn entry_kind_hint(&self, attempted: EntryKindTag) -> String { format!( - "c emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 16 shape dispatch (main / libFuzzer / free function)" + "c emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 16 / 19 / 20 / 21 shape dispatch (main / libFuzzer / free function + future class / msg / job adapters)" ) } @@ -646,7 +646,7 @@ clean: #[cfg(test)] mod tests { use super::*; - use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; + use crate::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot}; use crate::labels::Cap; use crate::symbol::Lang; @@ -674,14 +674,14 @@ mod tests { #[test] fn entry_kinds_supported_is_non_empty() { assert!(!CEmitter.entry_kinds_supported().is_empty()); - assert!(CEmitter.entry_kinds_supported().contains(&EntryKind::Function)); - assert!(CEmitter.entry_kinds_supported().contains(&EntryKind::CliSubcommand)); - assert!(CEmitter.entry_kinds_supported().contains(&EntryKind::LibraryApi)); + assert!(CEmitter.entry_kinds_supported().contains(&EntryKindTag::Function)); + assert!(CEmitter.entry_kinds_supported().contains(&EntryKindTag::CliSubcommand)); + assert!(CEmitter.entry_kinds_supported().contains(&EntryKindTag::LibraryApi)); } #[test] fn entry_kind_hint_names_attempted_and_phase() { - let hint = CEmitter.entry_kind_hint(EntryKind::LibraryApi); + let hint = CEmitter.entry_kind_hint(EntryKindTag::LibraryApi); assert!(hint.contains("LibraryApi")); assert!(hint.contains("Phase 16")); } diff --git a/src/dynamic/lang/cpp.rs b/src/dynamic/lang/cpp.rs index 9501f7c4..c28e3ce0 100644 --- a/src/dynamic/lang/cpp.rs +++ b/src/dynamic/lang/cpp.rs @@ -16,7 +16,7 @@ //! `g++ -O0 -std=c++17 -o nyx_harness main.cpp` in the workdir. use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; -use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; +use crate::dynamic::spec::{EntryKindTag, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; @@ -24,10 +24,10 @@ use std::path::PathBuf; pub struct CppEmitter; /// Entry kinds the C++ emitter understands after Phase 16. -const SUPPORTED: &[EntryKind] = &[ - EntryKind::Function, - EntryKind::CliSubcommand, - EntryKind::LibraryApi, +const SUPPORTED: &[EntryKindTag] = &[ + EntryKindTag::Function, + EntryKindTag::CliSubcommand, + EntryKindTag::LibraryApi, ]; // ── Phase 16: shape detector ───────────────────────────────────────────────── @@ -47,7 +47,7 @@ pub enum CppShape { impl CppShape { pub fn detect(spec: &HarnessSpec, source: &str) -> Self { let entry = spec.entry_name.as_str(); - let kind = spec.entry_kind; + let kind = spec.entry_kind.tag(); let has_main_argv = (source.contains("int main(") || source.contains("int main (")) && (source.contains("argc") || source.contains("char *argv") @@ -62,8 +62,8 @@ impl CppShape { return Self::MainArgv; } match kind { - EntryKind::CliSubcommand => Self::MainArgv, - EntryKind::LibraryApi => Self::LibfuzzerEntry, + EntryKindTag::CliSubcommand => Self::MainArgv, + EntryKindTag::LibraryApi => Self::LibfuzzerEntry, _ => Self::FreeFn, } } @@ -315,13 +315,13 @@ impl LangEmitter for CppEmitter { emit(spec) } - fn entry_kinds_supported(&self) -> &'static [EntryKind] { + fn entry_kinds_supported(&self) -> &'static [EntryKindTag] { SUPPORTED } - fn entry_kind_hint(&self, attempted: EntryKind) -> String { + fn entry_kind_hint(&self, attempted: EntryKindTag) -> String { format!( - "cpp emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 16 shape dispatch (main / libFuzzer / free function)" + "cpp emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 16 / 19 / 20 / 21 shape dispatch (main / libFuzzer / free function + future class / msg / job adapters)" ) } @@ -563,7 +563,7 @@ add_executable(nyx_harness main.cpp) #[cfg(test)] mod tests { use super::*; - use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; + use crate::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot}; use crate::labels::Cap; use crate::symbol::Lang; @@ -591,14 +591,14 @@ mod tests { #[test] fn entry_kinds_supported_is_non_empty() { assert!(!CppEmitter.entry_kinds_supported().is_empty()); - assert!(CppEmitter.entry_kinds_supported().contains(&EntryKind::Function)); - assert!(CppEmitter.entry_kinds_supported().contains(&EntryKind::CliSubcommand)); - assert!(CppEmitter.entry_kinds_supported().contains(&EntryKind::LibraryApi)); + assert!(CppEmitter.entry_kinds_supported().contains(&EntryKindTag::Function)); + assert!(CppEmitter.entry_kinds_supported().contains(&EntryKindTag::CliSubcommand)); + assert!(CppEmitter.entry_kinds_supported().contains(&EntryKindTag::LibraryApi)); } #[test] fn entry_kind_hint_names_attempted_and_phase() { - let hint = CppEmitter.entry_kind_hint(EntryKind::CliSubcommand); + let hint = CppEmitter.entry_kind_hint(EntryKindTag::CliSubcommand); assert!(hint.contains("CliSubcommand")); assert!(hint.contains("Phase 16")); } diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index 6887a03d..12e95818 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -38,7 +38,7 @@ use crate::dynamic::environment::{Environment, RuntimeArtifacts}; use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; -use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; +use crate::dynamic::spec::{EntryKindTag, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; @@ -51,10 +51,10 @@ pub struct GoEmitter; /// `HttpRoute` covers `net/http` and gin handlers. `CliSubcommand` /// covers `flag.Parse` CLIs. `Function` covers plain functions and /// fuzz harnesses. -const SUPPORTED: &[EntryKind] = &[ - EntryKind::Function, - EntryKind::HttpRoute, - EntryKind::CliSubcommand, +const SUPPORTED: &[EntryKindTag] = &[ + EntryKindTag::Function, + EntryKindTag::HttpRoute, + EntryKindTag::CliSubcommand, ]; impl LangEmitter for GoEmitter { @@ -62,13 +62,13 @@ impl LangEmitter for GoEmitter { emit(spec) } - fn entry_kinds_supported(&self) -> &'static [EntryKind] { + fn entry_kinds_supported(&self) -> &'static [EntryKindTag] { SUPPORTED } - fn entry_kind_hint(&self, attempted: EntryKind) -> String { + fn entry_kind_hint(&self, attempted: EntryKindTag) -> String { format!( - "go emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 15 shape dispatch" + "go emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 15 / 19 / 20 / 21 shape dispatch" ) } @@ -215,7 +215,7 @@ impl GoShape { /// to [`Self::Generic`]). pub fn detect(spec: &HarnessSpec, source: &str) -> Self { let entry = spec.entry_name.as_str(); - let kind = spec.entry_kind; + let kind = spec.entry_kind.tag(); let has_http_handler = source.contains("http.ResponseWriter") && source.contains("*http.Request"); @@ -265,10 +265,10 @@ impl GoShape { if has_fuzz_signature { return Self::FuzzVariadic; } - if kind == EntryKind::HttpRoute { + if kind == EntryKindTag::HttpRoute { return Self::HttpHandlerFunc; } - if kind == EntryKind::CliSubcommand { + if kind == EntryKindTag::CliSubcommand { return Self::FlagParseCli; } Self::Generic @@ -1098,7 +1098,7 @@ pub fn capitalize_first(s: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; + use crate::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot}; use crate::labels::Cap; use crate::symbol::Lang; @@ -1168,14 +1168,14 @@ mod tests { #[test] fn entry_kinds_supported_is_non_empty() { assert!(!GoEmitter.entry_kinds_supported().is_empty()); - assert!(GoEmitter.entry_kinds_supported().contains(&EntryKind::Function)); - assert!(GoEmitter.entry_kinds_supported().contains(&EntryKind::HttpRoute)); - assert!(GoEmitter.entry_kinds_supported().contains(&EntryKind::CliSubcommand)); + assert!(GoEmitter.entry_kinds_supported().contains(&EntryKindTag::Function)); + assert!(GoEmitter.entry_kinds_supported().contains(&EntryKindTag::HttpRoute)); + assert!(GoEmitter.entry_kinds_supported().contains(&EntryKindTag::CliSubcommand)); } #[test] fn entry_kind_hint_names_attempted_and_phase() { - let hint = GoEmitter.entry_kind_hint(EntryKind::LibraryApi); + let hint = GoEmitter.entry_kind_hint(EntryKindTag::LibraryApi); assert!(hint.contains("LibraryApi")); assert!(hint.contains("Phase 15")); } diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 66140106..e4f132df 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -37,7 +37,7 @@ use crate::dynamic::environment::{Environment, RuntimeArtifacts}; use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; -use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; +use crate::dynamic::spec::{EntryKindTag, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; @@ -50,10 +50,10 @@ pub struct JavaEmitter; /// `HttpRoute` covers servlet / Spring / Quarkus shapes. `CliSubcommand` /// covers `public static void main(String[])`. `Function` covers JUnit /// tests and plain static methods. -const SUPPORTED: &[EntryKind] = &[ - EntryKind::Function, - EntryKind::HttpRoute, - EntryKind::CliSubcommand, +const SUPPORTED: &[EntryKindTag] = &[ + EntryKindTag::Function, + EntryKindTag::HttpRoute, + EntryKindTag::CliSubcommand, ]; impl LangEmitter for JavaEmitter { @@ -61,13 +61,13 @@ impl LangEmitter for JavaEmitter { emit(spec) } - fn entry_kinds_supported(&self) -> &'static [EntryKind] { + fn entry_kinds_supported(&self) -> &'static [EntryKindTag] { SUPPORTED } - fn entry_kind_hint(&self, attempted: EntryKind) -> String { + fn entry_kind_hint(&self, attempted: EntryKindTag) -> String { format!( - "java emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 14 shape dispatch" + "java emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 14 / 19 / 20 / 21 shape dispatch" ) } @@ -204,7 +204,7 @@ impl JavaShape { /// pipeline tagged the entry kind as [`EntryKind::Function`]. pub fn detect(spec: &HarnessSpec, source: &str) -> Self { let entry = spec.entry_name.as_str(); - let kind = spec.entry_kind; + let kind = spec.entry_kind.tag(); let has_servlet = source.contains("HttpServlet") || source.contains("javax.servlet") @@ -256,10 +256,10 @@ impl JavaShape { return Self::JunitTest; } - if kind == EntryKind::CliSubcommand { + if kind == EntryKindTag::CliSubcommand { return Self::StaticMain; } - if kind == EntryKind::HttpRoute { + if kind == EntryKindTag::HttpRoute { return Self::SpringController; } Self::StaticMethod @@ -1810,7 +1810,7 @@ const JUNIT_HELPER: &str = r#" #[cfg(test)] mod tests { use super::*; - use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; + use crate::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot}; use crate::labels::Cap; use crate::symbol::Lang; @@ -1883,18 +1883,18 @@ mod tests { assert!(!JavaEmitter.entry_kinds_supported().is_empty()); assert!(JavaEmitter .entry_kinds_supported() - .contains(&EntryKind::Function)); + .contains(&EntryKindTag::Function)); assert!(JavaEmitter .entry_kinds_supported() - .contains(&EntryKind::HttpRoute)); + .contains(&EntryKindTag::HttpRoute)); assert!(JavaEmitter .entry_kinds_supported() - .contains(&EntryKind::CliSubcommand)); + .contains(&EntryKindTag::CliSubcommand)); } #[test] fn entry_kind_hint_names_attempted_and_phase() { - let hint = JavaEmitter.entry_kind_hint(EntryKind::LibraryApi); + let hint = JavaEmitter.entry_kind_hint(EntryKindTag::LibraryApi); assert!(hint.contains("LibraryApi")); assert!(hint.contains("Phase 14")); } @@ -2380,7 +2380,7 @@ mod tests { for (name, body, entry_name, kind, expected) in cases { let path = dir.join(name); std::fs::write(&path, body).expect("write fixture"); - let spec = make_spec_with(*kind, entry_name, path.to_str().unwrap()); + let spec = make_spec_with(kind.clone(), entry_name, path.to_str().unwrap()); assert_eq!(detect_shape(&spec), *expected, "case {name}"); } let _ = std::fs::remove_dir_all(&dir); diff --git a/src/dynamic/lang/javascript.rs b/src/dynamic/lang/javascript.rs index 619481a4..cd1240b4 100644 --- a/src/dynamic/lang/javascript.rs +++ b/src/dynamic/lang/javascript.rs @@ -16,7 +16,7 @@ use crate::dynamic::environment::{Environment, RuntimeArtifacts}; use crate::dynamic::lang::{js_shared, ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; -use crate::dynamic::spec::{EntryKind, HarnessSpec}; +use crate::dynamic::spec::{EntryKindTag, HarnessSpec}; use crate::evidence::UnsupportedReason; pub use js_shared::{detect_shape, materialize_node, probe_shim, JsShape}; @@ -29,13 +29,13 @@ impl LangEmitter for JavaScriptEmitter { emit(spec) } - fn entry_kinds_supported(&self) -> &'static [EntryKind] { + fn entry_kinds_supported(&self) -> &'static [EntryKindTag] { js_shared::SUPPORTED } - fn entry_kind_hint(&self, attempted: EntryKind) -> String { + fn entry_kind_hint(&self, attempted: EntryKindTag) -> String { format!( - "javascript emitter supports {supported:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 13 shape dispatch in `js_shared`", + "javascript emitter supports {supported:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 13 / 19 / 20 / 21 shape dispatch in `js_shared`", supported = js_shared::SUPPORTED, ) } @@ -61,7 +61,7 @@ pub fn emit(spec: &HarnessSpec) -> Result { #[cfg(test)] mod tests { use super::*; - use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; + use crate::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot}; use crate::labels::Cap; use crate::symbol::Lang; @@ -144,14 +144,14 @@ mod tests { #[test] fn entry_kinds_supported_includes_http_and_cli_after_phase_13() { let kinds = JavaScriptEmitter.entry_kinds_supported(); - assert!(kinds.contains(&EntryKind::Function)); - assert!(kinds.contains(&EntryKind::HttpRoute)); - assert!(kinds.contains(&EntryKind::CliSubcommand)); + assert!(kinds.contains(&EntryKindTag::Function)); + assert!(kinds.contains(&EntryKindTag::HttpRoute)); + assert!(kinds.contains(&EntryKindTag::CliSubcommand)); } #[test] fn entry_kind_hint_names_attempted_and_phase() { - let hint = JavaScriptEmitter.entry_kind_hint(EntryKind::HttpRoute); + let hint = JavaScriptEmitter.entry_kind_hint(EntryKindTag::HttpRoute); assert!(hint.contains("HttpRoute")); assert!(hint.contains("Phase 13")); } diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index 2aa9ace8..855c3a12 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -25,7 +25,7 @@ use crate::dynamic::environment::{Environment, RuntimeArtifacts}; use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource}; -use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; +use crate::dynamic::spec::{EntryKindTag, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use crate::utils::project::DetectedFramework; use std::path::PathBuf; @@ -73,7 +73,7 @@ impl JsShape { /// Detect the shape from `(spec, source)`. Framework / runtime /// markers in the source win over `spec.entry_kind`. pub fn detect(spec: &HarnessSpec, source: &str) -> Self { - let kind = spec.entry_kind; + let kind = spec.entry_kind.tag(); let entry = spec.entry_name.as_str(); // ── Framework / runtime markers ───────────────────────────── @@ -155,7 +155,7 @@ impl JsShape { return Self::BrowserEvent; } - if kind == EntryKind::HttpRoute { + if kind == EntryKindTag::HttpRoute { return Self::Express; } @@ -1629,11 +1629,11 @@ fn resolve_http_payload(slot: &PayloadSlot) -> (&'static str, String, &'static s } /// Supported entry kinds for both JS + TS after Phase 13. -pub const SUPPORTED: &[EntryKind] = &[ - EntryKind::Function, - EntryKind::HttpRoute, - EntryKind::CliSubcommand, - EntryKind::LibraryApi, +pub const SUPPORTED: &[EntryKindTag] = &[ + EntryKindTag::Function, + EntryKindTag::HttpRoute, + EntryKindTag::CliSubcommand, + EntryKindTag::LibraryApi, ]; #[cfg(test)] diff --git a/src/dynamic/lang/mod.rs b/src/dynamic/lang/mod.rs index 23330036..148a62f0 100644 --- a/src/dynamic/lang/mod.rs +++ b/src/dynamic/lang/mod.rs @@ -27,7 +27,7 @@ pub mod rust; pub mod typescript; use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::spec::{EntryKind, HarnessSpec}; +use crate::dynamic::spec::{EntryKindTag, HarnessSpec}; use crate::evidence::UnsupportedReason; use crate::symbol::Lang; @@ -132,14 +132,17 @@ pub trait LangEmitter { /// Build a harness source bundle for `spec`. fn emit(&self, spec: &HarnessSpec) -> Result; - /// The set of [`EntryKind`] variants this emitter understands. + /// The set of [`EntryKind`] variants this emitter understands, + /// projected to the [`EntryKindTag`] discriminant so the slice can + /// live in `'static` storage even after Phase 18 extended + /// `EntryKind` with data-bearing variants. /// /// Must be non-empty: every emitter advertises at least one shape it can /// (or will) drive — even stub modules whose `emit` returns /// `LangUnsupported`. Empty would be indistinguishable from "language /// not in the dispatch table" and would defeat the structured /// advertisement that callers consume. - fn entry_kinds_supported(&self) -> &'static [EntryKind]; + fn entry_kinds_supported(&self) -> &'static [EntryKindTag]; /// Human-actionable hint produced when `attempted` is not in /// [`entry_kinds_supported`](LangEmitter::entry_kinds_supported). @@ -149,7 +152,7 @@ pub trait LangEmitter { /// surfaces directly to operators triaging dynamic verification gaps; /// keep it specific (name the supported kinds, name the phase that will /// extend support). - fn entry_kind_hint(&self, attempted: EntryKind) -> String; + fn entry_kind_hint(&self, attempted: EntryKindTag) -> String; /// Synthesise the language-specific manifest / lockfile contents that /// pin the [`Environment`]'s direct deps + toolchain into a file the @@ -251,7 +254,7 @@ pub fn materialize_runtime(env: &Environment) -> RuntimeArtifacts { /// in (instead of producing a never-runnable harness). pub fn emit(spec: &HarnessSpec) -> Result { let supported = entry_kinds_supported(spec.lang); - if !supported.is_empty() && !supported.contains(&spec.entry_kind) { + if !supported.is_empty() && !supported.contains(&spec.entry_kind.tag()) { return Err(UnsupportedReason::EntryKindUnsupported); } dispatch(spec.lang, |e| e.emit(spec)) @@ -263,7 +266,7 @@ pub fn emit(spec: &HarnessSpec) -> Result { /// Returns an empty slice when `lang` has no registered emitter — callers /// distinguish that from "emitter exists but advertises none" by treating /// empty as "language unsupported". -pub fn entry_kinds_supported(lang: Lang) -> &'static [EntryKind] { +pub fn entry_kinds_supported(lang: Lang) -> &'static [EntryKindTag] { dispatch(lang, |e| e.entry_kinds_supported()).unwrap_or(&[]) } @@ -271,7 +274,7 @@ pub fn entry_kinds_supported(lang: Lang) -> &'static [EntryKind] { /// /// Falls back to a generic message when `lang` has no registered emitter so /// callers do not need to special-case that path. -pub fn entry_kind_hint(lang: Lang, attempted: EntryKind) -> String { +pub fn entry_kind_hint(lang: Lang, attempted: EntryKindTag) -> String { dispatch(lang, |e| e.entry_kind_hint(attempted)).unwrap_or_else(|| { format!( "no harness emitter is registered for {lang:?}; attempted {attempted}" @@ -300,6 +303,7 @@ fn dispatch(lang: Lang, f: impl FnOnce(&dyn LangEmitter) -> R) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::dynamic::spec::EntryKind; /// Every registered emitter must advertise at least one entry kind so the /// verifier never produces an empty `supported` list in @@ -328,10 +332,110 @@ mod tests { #[test] fn entry_kind_hint_mentions_attempted() { - let hint = entry_kind_hint(Lang::Python, EntryKind::HttpRoute); + let hint = entry_kind_hint(Lang::Python, EntryKindTag::HttpRoute); assert!( hint.contains("HttpRoute"), "hint must mention the attempted entry kind, got: {hint:?}" ); } + + /// Phase 18 (Track M.0) — every Phase 18 variant resolves to a + /// distinct [`EntryKindTag`] via [`EntryKind::tag`], and the + /// per-language emitters short-circuit those tags with a typed + /// `Inconclusive(EntryKindUnsupported)` hint that mentions the + /// follow-up phase that will close the gap. + #[test] + fn entry_kind_tag_round_trips_for_phase_18_variants() { + use crate::evidence::EntryKindTag as T; + assert_eq!(EntryKind::Function.tag(), T::Function); + assert_eq!(EntryKind::HttpRoute.tag(), T::HttpRoute); + assert_eq!(EntryKind::CliSubcommand.tag(), T::CliSubcommand); + assert_eq!(EntryKind::LibraryApi.tag(), T::LibraryApi); + assert_eq!( + EntryKind::ClassMethod { + class: "Cls".into(), + method: "do".into(), + } + .tag(), + T::ClassMethod + ); + assert_eq!( + EntryKind::MessageHandler { + queue: "q".into(), + message_schema: None, + } + .tag(), + T::MessageHandler + ); + assert_eq!( + EntryKind::ScheduledJob { schedule: None }.tag(), + T::ScheduledJob + ); + assert_eq!( + EntryKind::GraphQLResolver { + type_name: "User".into(), + field: "name".into(), + } + .tag(), + T::GraphQLResolver + ); + assert_eq!( + EntryKind::WebSocket { path: "/ws".into() }.tag(), + T::WebSocket + ); + assert_eq!( + EntryKind::Middleware { name: "auth".into() }.tag(), + T::Middleware + ); + assert_eq!( + EntryKind::Migration { version: None }.tag(), + T::Migration + ); + assert_eq!(EntryKind::Unknown.tag(), T::Unknown); + } + + /// Phase 18 (Track M.0) — none of the Phase 18 variants are wired + /// into any per-language emitter yet (those land in Phase 19 / + /// 20 / 21). Confirm every lang routes them through the + /// supported-set gate so the verifier produces a structured + /// `Inconclusive(EntryKindUnsupported)` rather than degrading + /// silently. + #[test] + fn entry_kind_phase_18_variants_are_unsupported_everywhere() { + use crate::evidence::EntryKindTag as T; + let new = [ + T::ClassMethod, + T::MessageHandler, + T::ScheduledJob, + T::GraphQLResolver, + T::WebSocket, + T::Middleware, + T::Migration, + ]; + for lang in [ + Lang::Python, + Lang::Rust, + Lang::JavaScript, + Lang::TypeScript, + Lang::Go, + Lang::Java, + Lang::Php, + Lang::Ruby, + Lang::C, + Lang::Cpp, + ] { + let supported = entry_kinds_supported(lang); + for tag in new { + assert!( + !supported.contains(&tag), + "{lang:?} prematurely advertised {tag:?} — Phase 18 keeps the new variants unsupported until Phase 19 / 20 / 21 lands the per-lang adapters" + ); + let hint = entry_kind_hint(lang, tag); + assert!( + hint.contains(tag.as_str()), + "{lang:?} hint must mention {tag:?}, got: {hint:?}" + ); + } + } + } } diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index 9e73d0e2..a68e5265 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -30,7 +30,7 @@ use crate::dynamic::environment::{Environment, RuntimeArtifacts}; use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; -use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; +use crate::dynamic::spec::{EntryKindTag, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; @@ -43,10 +43,10 @@ pub struct PhpEmitter; /// `HttpRoute` covers Slim / Laravel / Symfony route closures. /// `CliSubcommand` covers `$argv`-driven CLI scripts. `Function` /// covers plain functions and top-level scripts. -const SUPPORTED: &[EntryKind] = &[ - EntryKind::Function, - EntryKind::HttpRoute, - EntryKind::CliSubcommand, +const SUPPORTED: &[EntryKindTag] = &[ + EntryKindTag::Function, + EntryKindTag::HttpRoute, + EntryKindTag::CliSubcommand, ]; impl LangEmitter for PhpEmitter { @@ -54,13 +54,13 @@ impl LangEmitter for PhpEmitter { emit(spec) } - fn entry_kinds_supported(&self) -> &'static [EntryKind] { + fn entry_kinds_supported(&self) -> &'static [EntryKindTag] { SUPPORTED } - fn entry_kind_hint(&self, attempted: EntryKind) -> String { + fn entry_kind_hint(&self, attempted: EntryKindTag) -> String { format!( - "php emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 15 shape dispatch" + "php emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 15 / 19 / 20 / 21 shape dispatch" ) } @@ -174,7 +174,7 @@ impl PhpShape { /// the source win over `spec.entry_kind`. pub fn detect(spec: &HarnessSpec, source: &str) -> Self { let entry = spec.entry_name.as_str(); - let kind = spec.entry_kind; + let kind = spec.entry_kind.tag(); let has_symfony_marker = source.contains("#[Route(") || source.contains("Symfony\\Component\\Routing") @@ -231,10 +231,10 @@ impl PhpShape { if has_argv && !entry_named_function { return Self::CliArgvScript; } - if kind == EntryKind::HttpRoute { + if kind == EntryKindTag::HttpRoute { return Self::RouteClosure; } - if kind == EntryKind::CliSubcommand { + if kind == EntryKindTag::CliSubcommand { return Self::CliArgvScript; } // TopLevelScript only fires when we actually saw the source @@ -1215,7 +1215,7 @@ fn function_exists_call(_func: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; + use crate::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot}; use crate::labels::Cap; use crate::symbol::Lang; @@ -1294,18 +1294,18 @@ mod tests { assert!(!PhpEmitter.entry_kinds_supported().is_empty()); assert!(PhpEmitter .entry_kinds_supported() - .contains(&EntryKind::Function)); + .contains(&EntryKindTag::Function)); assert!(PhpEmitter .entry_kinds_supported() - .contains(&EntryKind::HttpRoute)); + .contains(&EntryKindTag::HttpRoute)); assert!(PhpEmitter .entry_kinds_supported() - .contains(&EntryKind::CliSubcommand)); + .contains(&EntryKindTag::CliSubcommand)); } #[test] fn entry_kind_hint_names_attempted_and_phase() { - let hint = PhpEmitter.entry_kind_hint(EntryKind::LibraryApi); + let hint = PhpEmitter.entry_kind_hint(EntryKindTag::LibraryApi); assert!(hint.contains("LibraryApi")); assert!(hint.contains("Phase 15")); } diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index 1f607947..48ec9ba6 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -24,7 +24,7 @@ use crate::dynamic::environment::{Environment, RuntimeArtifacts}; use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; -use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; +use crate::dynamic::spec::{EntryKindTag, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use crate::utils::project::DetectedFramework; use std::path::PathBuf; @@ -41,10 +41,10 @@ pub struct PythonEmitter; /// argparse `main()` functions. `Function` covers pytest, async /// coroutines, Celery tasks, and generic module-level functions /// (positional + kwargs). -const SUPPORTED: &[EntryKind] = &[ - EntryKind::Function, - EntryKind::HttpRoute, - EntryKind::CliSubcommand, +const SUPPORTED: &[EntryKindTag] = &[ + EntryKindTag::Function, + EntryKindTag::HttpRoute, + EntryKindTag::CliSubcommand, ]; impl LangEmitter for PythonEmitter { @@ -52,13 +52,13 @@ impl LangEmitter for PythonEmitter { emit(spec) } - fn entry_kinds_supported(&self) -> &'static [EntryKind] { + fn entry_kinds_supported(&self) -> &'static [EntryKindTag] { SUPPORTED } - fn entry_kind_hint(&self, attempted: EntryKind) -> String { + fn entry_kind_hint(&self, attempted: EntryKindTag) -> String { format!( - "python emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 12 shape dispatch" + "python emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 12 / 19 / 20 / 21 shape dispatch" ) } @@ -177,7 +177,7 @@ impl PythonShape { /// the legacy substring-only entry-kind heuristic. pub fn detect(spec: &HarnessSpec, source: &str) -> Self { let entry = spec.entry_name.as_str(); - let kind = spec.entry_kind; + let kind = spec.entry_kind.tag(); // ── Framework-first detection ──────────────────────────────── let has_flask = @@ -224,14 +224,14 @@ impl PythonShape { return Self::FlaskRoute; } - if kind == EntryKind::HttpRoute { + if kind == EntryKindTag::HttpRoute { // The flow-step said HTTP but no framework import was // detected — fall back to Flask which has the most forgiving // test client wiring. return Self::FlaskRoute; } - if kind == EntryKind::CliSubcommand + if kind == EntryKindTag::CliSubcommand || entry == "main" || entry == "__main__" || source.contains("if __name__ == \"__main__\"") @@ -1925,7 +1925,7 @@ fn module_name(entry_file: &str) -> &str { #[cfg(test)] mod tests { use super::*; - use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; + use crate::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot}; use crate::labels::Cap; use crate::symbol::Lang; @@ -1992,14 +1992,14 @@ mod tests { #[test] fn entry_kinds_supported_includes_http_and_cli() { let kinds = PythonEmitter.entry_kinds_supported(); - assert!(kinds.contains(&EntryKind::Function)); - assert!(kinds.contains(&EntryKind::HttpRoute)); - assert!(kinds.contains(&EntryKind::CliSubcommand)); + assert!(kinds.contains(&EntryKindTag::Function)); + assert!(kinds.contains(&EntryKindTag::HttpRoute)); + assert!(kinds.contains(&EntryKindTag::CliSubcommand)); } #[test] fn entry_kind_hint_names_attempted() { - let hint = PythonEmitter.entry_kind_hint(EntryKind::LibraryApi); + let hint = PythonEmitter.entry_kind_hint(EntryKindTag::LibraryApi); assert!(hint.contains("LibraryApi")); } diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index 0622a986..5b98ae6c 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -28,7 +28,7 @@ use crate::dynamic::environment::{Environment, RuntimeArtifacts}; use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; -use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; +use crate::dynamic::spec::{EntryKindTag, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; @@ -40,10 +40,10 @@ pub struct RubyEmitter; /// `HttpRoute` covers Sinatra / Rails / Rack. `CliSubcommand` covers /// `ARGV`-driven scripts. `Function` covers plain methods and /// controller method shapes. -const SUPPORTED: &[EntryKind] = &[ - EntryKind::Function, - EntryKind::HttpRoute, - EntryKind::CliSubcommand, +const SUPPORTED: &[EntryKindTag] = &[ + EntryKindTag::Function, + EntryKindTag::HttpRoute, + EntryKindTag::CliSubcommand, ]; impl LangEmitter for RubyEmitter { @@ -51,13 +51,13 @@ impl LangEmitter for RubyEmitter { emit(spec) } - fn entry_kinds_supported(&self) -> &'static [EntryKind] { + fn entry_kinds_supported(&self) -> &'static [EntryKindTag] { SUPPORTED } - fn entry_kind_hint(&self, attempted: EntryKind) -> String { + fn entry_kind_hint(&self, attempted: EntryKindTag) -> String { format!( - "ruby emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 15 shape dispatch" + "ruby emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 15 / 19 / 20 / 21 shape dispatch" ) } @@ -154,7 +154,7 @@ impl RubyShape { /// the source win over `spec.entry_kind`. pub fn detect(spec: &HarnessSpec, source: &str) -> Self { let entry = spec.entry_name.as_str(); - let kind = spec.entry_kind; + let kind = spec.entry_kind.tag(); let has_sinatra = source.contains("require 'sinatra'") || source.contains("require \"sinatra\"") @@ -188,7 +188,7 @@ impl RubyShape { if has_rack { return Self::RackMiddleware; } - if kind == EntryKind::HttpRoute && has_class { + if kind == EntryKindTag::HttpRoute && has_class { return Self::ControllerMethod; } if has_class && has_def && !entry.is_empty() && !entry_named_class { @@ -959,7 +959,7 @@ fn parse_first_class_name(source: &str) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; + use crate::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot}; use crate::labels::Cap; use crate::symbol::Lang; @@ -989,18 +989,18 @@ mod tests { assert!(!RubyEmitter.entry_kinds_supported().is_empty()); assert!(RubyEmitter .entry_kinds_supported() - .contains(&EntryKind::Function)); + .contains(&EntryKindTag::Function)); assert!(RubyEmitter .entry_kinds_supported() - .contains(&EntryKind::HttpRoute)); + .contains(&EntryKindTag::HttpRoute)); assert!(RubyEmitter .entry_kinds_supported() - .contains(&EntryKind::CliSubcommand)); + .contains(&EntryKindTag::CliSubcommand)); } #[test] fn entry_kind_hint_names_attempted_and_phase() { - let hint = RubyEmitter.entry_kind_hint(EntryKind::LibraryApi); + let hint = RubyEmitter.entry_kind_hint(EntryKindTag::LibraryApi); assert!(hint.contains("LibraryApi")); assert!(hint.contains("Phase 15")); } diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index 4fb53b3f..666f5c54 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -23,7 +23,7 @@ use crate::dynamic::environment::{Environment, RuntimeArtifacts}; use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; -use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; +use crate::dynamic::spec::{EntryKindTag, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use crate::labels::Cap; use std::path::PathBuf; @@ -38,11 +38,11 @@ pub struct RustEmitter; /// covers clap-driven CLIs. `LibraryApi` covers libfuzzer /// `fuzz_target!` entry points. `Function` covers plain free functions /// and is the fallback when shape detection is inconclusive. -const SUPPORTED: &[EntryKind] = &[ - EntryKind::Function, - EntryKind::HttpRoute, - EntryKind::CliSubcommand, - EntryKind::LibraryApi, +const SUPPORTED: &[EntryKindTag] = &[ + EntryKindTag::Function, + EntryKindTag::HttpRoute, + EntryKindTag::CliSubcommand, + EntryKindTag::LibraryApi, ]; impl LangEmitter for RustEmitter { @@ -50,13 +50,13 @@ impl LangEmitter for RustEmitter { emit(spec) } - fn entry_kinds_supported(&self) -> &'static [EntryKind] { + fn entry_kinds_supported(&self) -> &'static [EntryKindTag] { SUPPORTED } - fn entry_kind_hint(&self, attempted: EntryKind) -> String { + fn entry_kind_hint(&self, attempted: EntryKindTag) -> String { format!( - "rust emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 16 shape dispatch (actix / axum / clap / libfuzzer)" + "rust emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 16 / 19 / 20 / 21 shape dispatch (actix / axum / clap / libfuzzer + future class / msg / job adapters)" ) } @@ -527,7 +527,7 @@ impl RustShape { /// bytes of the entry file (best-effort — empty string falls back /// to [`Self::Generic`]). pub fn detect(spec: &HarnessSpec, source: &str) -> Self { - let kind = spec.entry_kind; + let kind = spec.entry_kind.tag(); let entry = spec.entry_name.as_str(); let has_warp = source.contains("use warp::") @@ -598,9 +598,9 @@ impl RustShape { return Self::LibfuzzerTarget; } match kind { - EntryKind::HttpRoute => Self::ActixWebRoute, - EntryKind::CliSubcommand => Self::ClapCli, - EntryKind::LibraryApi => Self::LibfuzzerTarget, + EntryKindTag::HttpRoute => Self::ActixWebRoute, + EntryKindTag::CliSubcommand => Self::ClapCli, + EntryKindTag::LibraryApi => Self::LibfuzzerTarget, _ => Self::Generic, } } @@ -1050,7 +1050,7 @@ fn clap_invocation(spec: &HarnessSpec, func: &str) -> (String, String) { #[cfg(test)] mod tests { use super::*; - use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; + use crate::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot}; use crate::labels::Cap; use crate::symbol::Lang; @@ -1140,12 +1140,12 @@ mod tests { assert!(!RustEmitter.entry_kinds_supported().is_empty()); assert!(RustEmitter .entry_kinds_supported() - .contains(&EntryKind::Function)); + .contains(&EntryKindTag::Function)); } #[test] fn entry_kind_hint_names_attempted_and_phase() { - let hint = RustEmitter.entry_kind_hint(EntryKind::LibraryApi); + let hint = RustEmitter.entry_kind_hint(EntryKindTag::LibraryApi); assert!(hint.contains("LibraryApi")); assert!(hint.contains("Phase 16")); } diff --git a/src/dynamic/lang/typescript.rs b/src/dynamic/lang/typescript.rs index f754e73a..26535ca1 100644 --- a/src/dynamic/lang/typescript.rs +++ b/src/dynamic/lang/typescript.rs @@ -16,7 +16,7 @@ use crate::dynamic::environment::{Environment, RuntimeArtifacts}; use crate::dynamic::lang::{js_shared, ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; -use crate::dynamic::spec::{EntryKind, HarnessSpec}; +use crate::dynamic::spec::{EntryKindTag, HarnessSpec}; use crate::evidence::UnsupportedReason; /// Zero-sized [`LangEmitter`] handle for TypeScript. @@ -32,13 +32,13 @@ impl LangEmitter for TypeScriptEmitter { js_shared::emit(spec, true) } - fn entry_kinds_supported(&self) -> &'static [EntryKind] { + fn entry_kinds_supported(&self) -> &'static [EntryKindTag] { js_shared::SUPPORTED } - fn entry_kind_hint(&self, attempted: EntryKind) -> String { + fn entry_kind_hint(&self, attempted: EntryKindTag) -> String { format!( - "typescript emitter supports {supported:?} (shared dispatch with javascript via `js_shared`); this finding's enclosing context is `EntryKind::{attempted}` — see Phase 13 shape dispatch", + "typescript emitter supports {supported:?} (shared dispatch with javascript via `js_shared`); this finding's enclosing context is `EntryKind::{attempted}` — see Phase 13 / 19 / 20 / 21 shape dispatch", supported = js_shared::SUPPORTED, ) } @@ -59,7 +59,7 @@ impl LangEmitter for TypeScriptEmitter { #[cfg(test)] mod tests { use super::*; - use crate::dynamic::spec::{HarnessSpec, PayloadSlot, SpecDerivationStrategy}; + use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot, SpecDerivationStrategy}; use crate::labels::Cap; use crate::symbol::Lang; @@ -89,12 +89,12 @@ mod tests { assert!(!TypeScriptEmitter.entry_kinds_supported().is_empty()); assert!(TypeScriptEmitter .entry_kinds_supported() - .contains(&EntryKind::HttpRoute)); + .contains(&EntryKindTag::HttpRoute)); } #[test] fn entry_kind_hint_names_attempted_and_phase() { - let hint = TypeScriptEmitter.entry_kind_hint(EntryKind::HttpRoute); + let hint = TypeScriptEmitter.entry_kind_hint(EntryKindTag::HttpRoute); assert!(hint.contains("HttpRoute")); assert!(hint.contains("Phase 13")); } diff --git a/src/dynamic/spec.rs b/src/dynamic/spec.rs index c059d531..b66e6d73 100644 --- a/src/dynamic/spec.rs +++ b/src/dynamic/spec.rs @@ -58,6 +58,14 @@ pub struct EntryRef { /// attempted / supported variants without depending on the `dynamic` feature. pub use crate::evidence::EntryKind; +/// Re-export of [`crate::evidence::EntryKindTag`]. +/// +/// The discriminant tag used by every site that needs a `Copy + Hash` +/// handle to an `EntryKind`: supported-set lookups, the +/// [`crate::evidence::InconclusiveReason::EntryKindUnsupported`] fields, +/// the lang-emitter trait surface. +pub use crate::evidence::EntryKindTag; + /// Where the payload goes when the harness fires. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum PayloadSlot { @@ -363,7 +371,7 @@ impl HarnessSpec { /// `Unsupported`. pub fn entry_kind_is_supported(&self) -> bool { let supported = crate::dynamic::lang::entry_kinds_supported(self.lang); - supported.contains(&self.entry_kind) + supported.contains(&self.entry_kind.tag()) } /// Returns the ordered list of derivation strategies that @@ -1222,6 +1230,29 @@ fn attach_framework_binding(spec: &mut HarnessSpec, summaries: Option<&GlobalSum if spec.lang == Lang::Java && binding.adapter == "java-spring" { spec.java_toolchain.with_spring_test = true; } + // Phase 18 (Track M.0): the binding carries the adapter's view + // of the entry shape — when the adapter stamps one of the new + // data-bearing variants (`ClassMethod`, `MessageHandler`, + // `ScheduledJob`, …), propagate that onto the spec so the + // verifier's `entry_kind_is_supported` gate sees the structural + // shape and short-circuits to a typed + // `Inconclusive(EntryKindUnsupported)`. We deliberately do not + // overwrite the legacy unit variants here: every adapter + // shipped through Phase 17 stamps `Function` / `HttpRoute` and + // the derivation pipeline already routes those correctly. + if matches!( + binding.kind.tag(), + crate::evidence::EntryKindTag::ClassMethod + | crate::evidence::EntryKindTag::MessageHandler + | crate::evidence::EntryKindTag::ScheduledJob + | crate::evidence::EntryKindTag::GraphQLResolver + | crate::evidence::EntryKindTag::WebSocket + | crate::evidence::EntryKindTag::Middleware + | crate::evidence::EntryKindTag::Migration + ) { + spec.entry_kind = binding.kind.clone(); + spec.spec_hash = compute_spec_hash(spec); + } spec.framework = Some(binding); } } diff --git a/src/dynamic/verify.rs b/src/dynamic/verify.rs index d9819096..53803563 100644 --- a/src/dynamic/verify.rs +++ b/src/dynamic/verify.rs @@ -338,7 +338,7 @@ fn entry_kind_unsupported_verdict( diag: Option<&Diag>, spec_entry_path: &str, lang: crate::symbol::Lang, - attempted: crate::dynamic::spec::EntryKind, + attempted: crate::dynamic::spec::EntryKindTag, policy: &SamplingPolicy, ) -> VerifyResult { let supported = crate::dynamic::lang::entry_kinds_supported(lang).to_vec(); @@ -618,7 +618,7 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult { Some(diag), &spec.entry_file, spec.lang, - spec.entry_kind, + spec.entry_kind.tag(), &opts.telemetry_policy, ); } @@ -1210,13 +1210,13 @@ fn build_verdict( ) = &e { let supported = crate::dynamic::lang::entry_kinds_supported(spec.lang); - if !supported.contains(&spec.entry_kind) { + if !supported.contains(&spec.entry_kind.tag()) { return entry_kind_unsupported_verdict( finding_id.to_owned(), None, &spec.entry_file, spec.lang, - spec.entry_kind, + spec.entry_kind.tag(), &opts.telemetry_policy, ); } diff --git a/src/evidence.rs b/src/evidence.rs index 02cb1b6c..49c45c23 100644 --- a/src/evidence.rs +++ b/src/evidence.rs @@ -216,13 +216,88 @@ pub enum UnsupportedReason { }, } +/// Discriminant tag for [`EntryKind`]. +/// +/// Phase 18 (Track M.0) extends [`EntryKind`] with data-bearing variants +/// (`ClassMethod`, `MessageHandler`, `ScheduledJob`, …) so the enum can no +/// longer be `Copy` and cannot appear in `&'static [EntryKind]` slices. +/// `EntryKindTag` is the unit-only sibling used for: the per-emitter +/// supported-set declaration (`LangEmitter::entry_kinds_supported` returns +/// `&'static [EntryKindTag]`), the supported / attempted fields on +/// [`InconclusiveReason::EntryKindUnsupported`], and any other site that +/// needs a `Copy + Hash` discriminant. +/// +/// `Unknown` is the back-compat fallback: a future variant that an older +/// binary doesn't recognise round-trips as `Unknown` rather than failing +/// deserialisation. Mirrors the `#[serde(other)]` shape on the +/// data-bearing enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum EntryKindTag { + Function, + HttpRoute, + CliSubcommand, + LibraryApi, + ClassMethod, + MessageHandler, + ScheduledJob, + GraphQLResolver, + WebSocket, + Middleware, + Migration, + /// Back-compat fallback for unrecognised variants from future bundles. + #[serde(other)] + Unknown, +} + +impl fmt::Display for EntryKindTag { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl EntryKindTag { + /// Stable string form (matches the Serde PascalCase representation). + pub fn as_str(&self) -> &'static str { + match self { + Self::Function => "Function", + Self::HttpRoute => "HttpRoute", + Self::CliSubcommand => "CliSubcommand", + Self::LibraryApi => "LibraryApi", + Self::ClassMethod => "ClassMethod", + Self::MessageHandler => "MessageHandler", + Self::ScheduledJob => "ScheduledJob", + Self::GraphQLResolver => "GraphQLResolver", + Self::WebSocket => "WebSocket", + Self::Middleware => "Middleware", + Self::Migration => "Migration", + Self::Unknown => "Unknown", + } + } +} + /// What kind of entry point a harness should call. /// /// Lives in `evidence.rs` (not `dynamic::spec`) so that /// [`InconclusiveReason::EntryKindUnsupported`] can name the attempted / /// supported variants without depending on the `dynamic` feature. The /// canonical accessor is `crate::dynamic::spec::EntryKind` (re-export). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +/// +/// Phase 18 (Track M.0) extends the enum with seven data-bearing variants +/// (`ClassMethod`, `MessageHandler`, `ScheduledJob`, `GraphQLResolver`, +/// `WebSocket`, `Middleware`, `Migration`) plus an `Unknown` back-compat +/// fallback. Each new variant carries the language-agnostic minimum +/// context the per-language adapter needs to stand the entry up; lang +/// emitters opt in per follow-up phase (19 / 20 / 21) and unsupported +/// kinds short-circuit to `Inconclusive(EntryKindUnsupported)` with a +/// hint pointing at the phase that will close the gap. +/// +/// Because the new variants own `String` / `serde_json::Value` payloads +/// the enum is no longer `Copy` (or `Hash`). The sibling +/// [`EntryKindTag`] discriminant is the right type for any site that +/// needs a `Copy + Hash` handle (supported-set lookups, hashmap keys, +/// `InconclusiveReason::EntryKindUnsupported` fields). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub enum EntryKind { /// Free function. Build a `main` that calls it directly. Function, @@ -232,17 +307,212 @@ pub enum EntryKind { CliSubcommand, /// Library API surface. Build an in-process consumer. LibraryApi, + /// Method on a class / struct / module type. Carries the qualified + /// class name and the method to drive so the lang emitter can build + /// a `Cls().method()` invocation. Land in + /// Phase 19. + ClassMethod { + class: String, + method: String, + }, + /// Message-queue subscriber / consumer. `queue` is the topic / + /// stream / channel name; `message_schema`, when present, is a + /// free-form JSON description of the expected message body that the + /// harness can use to mint a fresh envelope around the payload. + /// Land in Phase 20. + MessageHandler { + queue: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + message_schema: Option, + }, + /// Scheduled job / cron handler. `schedule`, when present, is the + /// raw schedule expression as it appears in source (cron syntax, + /// rate string, etc.) — kept opaque because each scheduler library + /// uses a slightly different grammar. Land in Phase 21. + ScheduledJob { + #[serde(default, skip_serializing_if = "Option::is_none")] + schedule: Option, + }, + /// GraphQL resolver — `type_name.field` pair the harness drives via + /// an in-process GraphQL execution layer. Land in Phase 21. + GraphQLResolver { + type_name: String, + field: String, + }, + /// WebSocket handler — `path` is the canonical mount point; the + /// harness opens a loopback ws connection and sends the payload as + /// the first message frame. Land in Phase 21. + WebSocket { + path: String, + }, + /// HTTP / framework middleware — `name` is the middleware identifier + /// (class name, function name, registration key) the harness mounts + /// on a synthetic pipeline before invoking it with a crafted + /// request. Land in Phase 21. + Middleware { + name: String, + }, + /// Database migration / schema-change script — `version`, when + /// present, is the migration revision identifier (Alembic / Flyway / + /// Rails string) so the harness can pin the apply step. Land in + /// Phase 21. + Migration { + #[serde(default, skip_serializing_if = "Option::is_none")] + version: Option, + }, + /// Back-compat fallback. An older binary that does not yet + /// recognise a future variant deserialises it into `Unknown` rather + /// than failing the bundle load. Mirrors the + /// `#[serde(other)]` shape on [`EntryKindTag`]. + Unknown, +} + +impl EntryKind { + /// Discriminant tag — used for supported-set lookups and any other + /// site that needs a `Copy + Hash` handle. + pub fn tag(&self) -> EntryKindTag { + match self { + Self::Function => EntryKindTag::Function, + Self::HttpRoute => EntryKindTag::HttpRoute, + Self::CliSubcommand => EntryKindTag::CliSubcommand, + Self::LibraryApi => EntryKindTag::LibraryApi, + Self::ClassMethod { .. } => EntryKindTag::ClassMethod, + Self::MessageHandler { .. } => EntryKindTag::MessageHandler, + Self::ScheduledJob { .. } => EntryKindTag::ScheduledJob, + Self::GraphQLResolver { .. } => EntryKindTag::GraphQLResolver, + Self::WebSocket { .. } => EntryKindTag::WebSocket, + Self::Middleware { .. } => EntryKindTag::Middleware, + Self::Migration { .. } => EntryKindTag::Migration, + Self::Unknown => EntryKindTag::Unknown, + } + } } impl fmt::Display for EntryKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - Self::Function => "Function", - Self::HttpRoute => "HttpRoute", - Self::CliSubcommand => "CliSubcommand", - Self::LibraryApi => "LibraryApi", - }; - f.write_str(s) + f.write_str(self.tag().as_str()) + } +} + +impl<'de> Deserialize<'de> for EntryKind { + /// Back-compat deserialiser. Externally-tagged enums do not + /// support `#[serde(other)]` on Serde 1.0.228, so we route through + /// `serde_json::Value` and fall through to [`EntryKind::Unknown`] + /// for any tag the current binary does not recognise. Older + /// bundles whose `entry_kind` is a bare PascalCase string (the + /// pre-Phase-18 wire format for the four unit variants) continue + /// to decode unchanged. + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error as _; + + let value = serde_json::Value::deserialize(deserializer) + .map_err(D::Error::custom)?; + + // Bare-string form (legacy unit variants). + if let Some(tag) = value.as_str() { + return Ok(match tag { + "Function" => Self::Function, + "HttpRoute" => Self::HttpRoute, + "CliSubcommand" => Self::CliSubcommand, + "LibraryApi" => Self::LibraryApi, + "Unknown" => Self::Unknown, + _ => Self::Unknown, + }); + } + + // Externally-tagged struct form: { "ClassMethod": { ... } }. + if let Some(map) = value.as_object() { + if map.len() == 1 { + let (tag, body) = map.iter().next().expect("len == 1"); + let body = body.clone(); + let parsed = match tag.as_str() { + "Function" => Some(Self::Function), + "HttpRoute" => Some(Self::HttpRoute), + "CliSubcommand" => Some(Self::CliSubcommand), + "LibraryApi" => Some(Self::LibraryApi), + "Unknown" => Some(Self::Unknown), + "ClassMethod" => { + #[derive(Deserialize)] + struct F { + class: String, + method: String, + } + serde_json::from_value::(body).ok().map(|f| Self::ClassMethod { + class: f.class, + method: f.method, + }) + } + "MessageHandler" => { + #[derive(Deserialize)] + struct F { + queue: String, + #[serde(default)] + message_schema: Option, + } + serde_json::from_value::(body).ok().map(|f| Self::MessageHandler { + queue: f.queue, + message_schema: f.message_schema, + }) + } + "ScheduledJob" => { + #[derive(Deserialize)] + struct F { + #[serde(default)] + schedule: Option, + } + serde_json::from_value::(body) + .ok() + .map(|f| Self::ScheduledJob { schedule: f.schedule }) + } + "GraphQLResolver" => { + #[derive(Deserialize)] + struct F { + type_name: String, + field: String, + } + serde_json::from_value::(body).ok().map(|f| Self::GraphQLResolver { + type_name: f.type_name, + field: f.field, + }) + } + "WebSocket" => { + #[derive(Deserialize)] + struct F { + path: String, + } + serde_json::from_value::(body) + .ok() + .map(|f| Self::WebSocket { path: f.path }) + } + "Middleware" => { + #[derive(Deserialize)] + struct F { + name: String, + } + serde_json::from_value::(body) + .ok() + .map(|f| Self::Middleware { name: f.name }) + } + "Migration" => { + #[derive(Deserialize)] + struct F { + #[serde(default)] + version: Option, + } + serde_json::from_value::(body) + .ok() + .map(|f| Self::Migration { version: f.version }) + } + _ => None, + }; + return Ok(parsed.unwrap_or(Self::Unknown)); + } + } + + Ok(Self::Unknown) } } @@ -314,10 +584,15 @@ pub enum InconclusiveReason { /// [`EntryKind`]. Carries the language, the attempted entry kind, the /// list of entry kinds the emitter currently understands, and a /// human-actionable hint pointing at the phase that will add support. + /// + /// Phase 18: `attempted` / `supported` use the [`EntryKindTag`] + /// discriminant rather than the (now data-bearing) [`EntryKind`] so + /// the verdict stays cheap to copy and the serialised form remains + /// a list of PascalCase strings. EntryKindUnsupported { lang: Lang, - attempted: EntryKind, - supported: Vec, + attempted: EntryKindTag, + supported: Vec, hint: String, }, /// The capability's corpus lacks a paired benign control payload, so @@ -1917,4 +2192,166 @@ mod tests { let json = serde_json::to_string(&crate::labels::SourceKind::UserInput).unwrap(); assert_eq!(json, "\"user_input\""); } + + // ── Phase 18 (Track M.0) — EntryKind data-bearing variants ────────────── + + /// Legacy unit variants round-trip as bare PascalCase strings — the + /// pre-Phase-18 wire format an older binary expects. + #[test] + fn entry_kind_legacy_unit_variants_round_trip() { + for (kind, json) in [ + (EntryKind::Function, "\"Function\""), + (EntryKind::HttpRoute, "\"HttpRoute\""), + (EntryKind::CliSubcommand, "\"CliSubcommand\""), + (EntryKind::LibraryApi, "\"LibraryApi\""), + ] { + let serialised = serde_json::to_string(&kind).unwrap(); + assert_eq!(serialised, json, "serialise {kind:?}"); + let parsed: EntryKind = serde_json::from_str(json).unwrap(); + assert_eq!(parsed, kind, "deserialise {json}"); + } + } + + /// New Phase 18 variants serialise as externally-tagged objects and + /// round-trip with their data payloads intact. + #[test] + fn entry_kind_phase_18_variants_round_trip() { + let cases: Vec = vec![ + EntryKind::ClassMethod { + class: "UserController".into(), + method: "show".into(), + }, + EntryKind::MessageHandler { + queue: "orders.new".into(), + message_schema: Some(serde_json::json!({"type":"object"})), + }, + EntryKind::MessageHandler { + queue: "orders.new".into(), + message_schema: None, + }, + EntryKind::ScheduledJob { + schedule: Some("0 */6 * * *".into()), + }, + EntryKind::ScheduledJob { schedule: None }, + EntryKind::GraphQLResolver { + type_name: "Query".into(), + field: "user".into(), + }, + EntryKind::WebSocket { path: "/ws/feed".into() }, + EntryKind::Middleware { name: "auth_filter".into() }, + EntryKind::Migration { + version: Some("0042_user_table".into()), + }, + EntryKind::Migration { version: None }, + EntryKind::Unknown, + ]; + for kind in cases { + let json = serde_json::to_string(&kind).unwrap(); + let parsed: EntryKind = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, kind, "round-trip {json}"); + } + } + + /// Back-compat: a bundle that mentions a future variant the current + /// binary does not recognise deserialises to [`EntryKind::Unknown`] + /// instead of failing the parse. Mirrors the + /// `#[serde(other)]` shape promised in the Phase 18 brief. + #[test] + fn entry_kind_unknown_future_variant_falls_back_to_unknown() { + // Externally-tagged object form. + let unknown_obj = r#"{"FutureKind":{"foo":42}}"#; + let parsed: EntryKind = serde_json::from_str(unknown_obj).unwrap(); + assert_eq!(parsed, EntryKind::Unknown); + + // Bare-string form (e.g. older binary writes a future name as a + // unit tag rather than a struct). + let unknown_str = "\"FutureKind\""; + let parsed: EntryKind = serde_json::from_str(unknown_str).unwrap(); + assert_eq!(parsed, EntryKind::Unknown); + } + + /// Tag discriminant projection — used by every supported-set lookup + /// path so the slice can stay `'static` after Phase 18. + #[test] + fn entry_kind_tag_matches_variant_for_each_phase_18_variant() { + assert_eq!(EntryKind::Function.tag(), EntryKindTag::Function); + assert_eq!(EntryKind::HttpRoute.tag(), EntryKindTag::HttpRoute); + assert_eq!(EntryKind::CliSubcommand.tag(), EntryKindTag::CliSubcommand); + assert_eq!(EntryKind::LibraryApi.tag(), EntryKindTag::LibraryApi); + assert_eq!( + EntryKind::ClassMethod { + class: String::new(), + method: String::new() + } + .tag(), + EntryKindTag::ClassMethod + ); + assert_eq!( + EntryKind::MessageHandler { + queue: String::new(), + message_schema: None + } + .tag(), + EntryKindTag::MessageHandler + ); + assert_eq!( + EntryKind::ScheduledJob { schedule: None }.tag(), + EntryKindTag::ScheduledJob + ); + assert_eq!( + EntryKind::GraphQLResolver { + type_name: String::new(), + field: String::new() + } + .tag(), + EntryKindTag::GraphQLResolver + ); + assert_eq!( + EntryKind::WebSocket { + path: String::new() + } + .tag(), + EntryKindTag::WebSocket + ); + assert_eq!( + EntryKind::Middleware { + name: String::new() + } + .tag(), + EntryKindTag::Middleware + ); + assert_eq!( + EntryKind::Migration { version: None }.tag(), + EntryKindTag::Migration + ); + assert_eq!(EntryKind::Unknown.tag(), EntryKindTag::Unknown); + } + + /// [`EntryKindTag`] round-trips through the externally-tagged wire + /// format used by [`InconclusiveReason::EntryKindUnsupported`] and + /// honours `#[serde(other)]` for unknown tags. + #[test] + fn entry_kind_tag_serde_round_trip_and_unknown_fallback() { + for tag in [ + EntryKindTag::Function, + EntryKindTag::HttpRoute, + EntryKindTag::CliSubcommand, + EntryKindTag::LibraryApi, + EntryKindTag::ClassMethod, + EntryKindTag::MessageHandler, + EntryKindTag::ScheduledJob, + EntryKindTag::GraphQLResolver, + EntryKindTag::WebSocket, + EntryKindTag::Middleware, + EntryKindTag::Migration, + EntryKindTag::Unknown, + ] { + let json = serde_json::to_string(&tag).unwrap(); + let rt: EntryKindTag = serde_json::from_str(&json).unwrap(); + assert_eq!(rt, tag); + } + // Future tag → Unknown via `#[serde(other)]`. + let parsed: EntryKindTag = serde_json::from_str("\"FutureKind\"").unwrap(); + assert_eq!(parsed, EntryKindTag::Unknown); + } } diff --git a/tests/spec_derivation_strategies.rs b/tests/spec_derivation_strategies.rs index ad3830e5..133206e4 100644 --- a/tests/spec_derivation_strategies.rs +++ b/tests/spec_derivation_strategies.rs @@ -20,7 +20,7 @@ mod spec_strategies { use nyx_scanner::commands::scan::Diag; use nyx_scanner::dynamic::spec::{ derive_from_callgraph_entry, derive_from_func_summary, derive_from_rule_namespace, - EntryKind, HarnessSpec, PayloadSlot, SpecDerivationStrategy, + EntryKind, EntryKindTag, HarnessSpec, PayloadSlot, SpecDerivationStrategy, }; use nyx_scanner::dynamic::verify::{verify_finding, VerifyOptions}; use nyx_scanner::evidence::{ @@ -360,13 +360,13 @@ mod spec_strategies { hint, }) => { assert_eq!(lang, nyx_scanner::symbol::Lang::C); - assert!(matches!(attempted, EntryKind::HttpRoute)); + assert!(matches!(attempted, EntryKindTag::HttpRoute)); assert!( !supported.is_empty(), "supported list must be non-empty so operators can triage" ); assert!( - supported.contains(&EntryKind::Function), + supported.contains(&EntryKindTag::Function), "C emitter must advertise Function support; got {supported:?}" ); assert!(