diff --git a/src/commands/scan.rs b/src/commands/scan.rs index a52771f5..f6dc1a82 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -439,7 +439,7 @@ pub fn handle( let preview_tier_seen = Arc::new(AtomicBool::new(false)); let mut diags: Vec = if index_mode == IndexMode::Off { - scan_filesystem_with_observer( + let (diags, _surface_map) = scan_filesystem_with_observer( &scan_path, config, show_progress, @@ -447,7 +447,8 @@ pub fn handle( None, None, Some(&preview_tier_seen), - )? + )?; + diags } else { if index_mode == IndexMode::Rebuild || !db_path.exists() { tracing::debug!("Scanning filesystem index filesystem"); @@ -1756,6 +1757,20 @@ pub(crate) fn scan_filesystem( cfg: &Config, show_progress: bool, ) -> NyxResult> { + scan_filesystem_with_observer(root, cfg, show_progress, None, None, None, None) + .map(|(diags, _surface_map)| diags) +} + +/// Same as [`scan_filesystem`] but additionally returns the `SurfaceMap` +/// built from the post-pass-2 view. The non-indexed path used to drop +/// the surface map on the floor; this entry-point lets `nyx surface` (and +/// other consumers that need the attack-surface model alongside the +/// findings) avoid running the analysis twice. +pub(crate) fn scan_filesystem_with_surface_map( + root: &Path, + cfg: &Config, + show_progress: bool, +) -> NyxResult<(Vec, crate::surface::SurfaceMap)> { scan_filesystem_with_observer(root, cfg, show_progress, None, None, None, None) } @@ -1774,7 +1789,7 @@ pub(crate) fn scan_filesystem_with_observer( metrics: Option<&Arc>, logs: Option<&Arc>, preview_tier_seen: Option<&Arc>, -) -> NyxResult> { +) -> NyxResult<(Vec, crate::surface::SurfaceMap)> { // Ensure framework context is available (handle sets it, but direct // callers like scan_no_index may not). let owned_cfg = ensure_framework_ctx(root, cfg); @@ -1905,7 +1920,8 @@ pub(crate) fn scan_filesystem_with_observer( p.set_stage(ScanStage::Complete); } post_process_diags(&mut diags, cfg); - return Ok(diags); + // AST-only mode does not produce a SurfaceMap (no CFG / summaries). + return Ok((diags, crate::surface::SurfaceMap::new())); } // ── Taint mode: two-pass with fused pass 1 ────────────────────────── @@ -2180,9 +2196,10 @@ pub(crate) fn scan_filesystem_with_observer( // Phase 21: build the SurfaceMap from the post-pass-2 view. // No persistence here; the index-backed path persists into the - // `surface_map` SQLite table. Errors here are swallowed: the - // surface map is an additive Phase F deliverable, not a gate. - let _surface_map = crate::surface::build::build_surface_map( + // `surface_map` SQLite table. The map is returned alongside the + // diagnostics so consumers (e.g. `nyx surface`) can avoid scanning + // twice. + let surface_map = crate::surface::build::build_surface_map( &crate::surface::build::SurfaceBuildInputs { files: &all_paths, scan_root: Some(root), @@ -2225,7 +2242,7 @@ pub(crate) fn scan_filesystem_with_observer( ); } - Ok(diags) + Ok((diags, surface_map)) } // -------------------------------------------------------------------------------------------- diff --git a/src/commands/surface.rs b/src/commands/surface.rs index 6179bbce..402384b3 100644 --- a/src/commands/surface.rs +++ b/src/commands/surface.rs @@ -141,12 +141,20 @@ pub fn render_text(map: &SurfaceMap, scan_root: Option<&Path>) -> String { } else { out.push_str("Surface map\n"); } + let entry_count = count_kind(map, |n| matches!(n, SurfaceNode::EntryPoint(_))); + let ds_count = count_kind(map, |n| matches!(n, SurfaceNode::DataStore(_))); + let es_count = count_kind(map, |n| matches!(n, SurfaceNode::ExternalService(_))); + let dl_count = count_kind(map, |n| matches!(n, SurfaceNode::DangerousLocal(_))); out.push_str(&format!( - " {} entry-points, {} data stores, {} external services, {} dangerous locals\n\n", - count_kind(map, |n| matches!(n, SurfaceNode::EntryPoint(_))), - count_kind(map, |n| matches!(n, SurfaceNode::DataStore(_))), - count_kind(map, |n| matches!(n, SurfaceNode::ExternalService(_))), - count_kind(map, |n| matches!(n, SurfaceNode::DangerousLocal(_))), + " {} {}, {} {}, {} {}, {} {}\n\n", + entry_count, + plural(entry_count, "entry-point", "entry-points"), + ds_count, + plural(ds_count, "data store", "data stores"), + es_count, + plural(es_count, "external service", "external services"), + dl_count, + plural(dl_count, "dangerous local", "dangerous locals"), )); if map.nodes.is_empty() { @@ -305,6 +313,10 @@ fn count_kind bool>(map: &SurfaceMap, f: F) -> usize { map.nodes.iter().filter(|n| f(n)).count() } +fn plural(count: usize, singular: &'static str, plural: &'static str) -> &'static str { + if count == 1 { singular } else { plural } +} + fn method_str(m: crate::entry_points::HttpMethod) -> &'static str { use crate::entry_points::HttpMethod::*; match m { diff --git a/src/lib.rs b/src/lib.rs index c4528394..adbd3ec3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -148,3 +148,22 @@ use utils::config::Config; pub fn scan_no_index(root: &Path, cfg: &Config) -> NyxResult> { commands::scan::scan_filesystem(root, cfg, false) } + +/// Same as [`scan_no_index`] but additionally returns the [`SurfaceMap`] +/// built from the post-pass-2 view. +/// +/// The non-indexed scan path used to drop the surface map on the floor, +/// which forced `nyx surface` (and any other consumer that wanted both +/// findings and the attack-surface model) to either run the analysis +/// twice or fall back to an entry-point-only build with no DataStore / +/// ExternalService / DangerousLocal nodes and no `Reaches` edges. +/// +/// Use this entry point when you need both halves of the analysis. +/// +/// [`SurfaceMap`]: surface::SurfaceMap +pub fn scan_no_index_with_surface_map( + root: &Path, + cfg: &Config, +) -> NyxResult<(Vec, surface::SurfaceMap)> { + commands::scan::scan_filesystem_with_surface_map(root, cfg, false) +} diff --git a/src/surface/lang/common.rs b/src/surface/lang/common.rs index a95dd5c1..22ef07da 100644 --- a/src/surface/lang/common.rs +++ b/src/surface/lang/common.rs @@ -90,6 +90,119 @@ pub fn child_or_named<'tree>(parent: Node<'tree>, kind: &str) -> Option bool { + let text = match std::str::from_utf8(bytes) { + Ok(s) => s, + Err(_) => return false, + }; + for line in text.lines() { + let line = line.trim_start(); + let pkg = if let Some(rest) = line.strip_prefix("from ") { + rest.split_whitespace().next().unwrap_or("") + } else if let Some(rest) = line.strip_prefix("import ") { + rest.split([',', ' ', ';']) + .next() + .unwrap_or("") + .trim() + } else { + continue; + }; + if pkg.is_empty() { + continue; + } + let head = pkg.split('.').next().unwrap_or(pkg); + if matches_prefix_ci(head, modules) { + return true; + } + } + false +} + +fn matches_prefix_ci(head: &str, prefixes: &[&str]) -> bool { + let head_lc = head.to_ascii_lowercase(); + prefixes + .iter() + .any(|p| head_lc.starts_with(&p.to_ascii_lowercase())) +} + +/// Return `true` when `bytes` contains a top-level Rust `use` (or +/// `extern crate`) statement whose leading path segment matches one of +/// `crates` (case-insensitive). Optional `pub` / `pub(crate)` / +/// `pub(super)` visibility prefixes are stripped before the `use` +/// keyword check. +pub fn rust_uses_any(bytes: &[u8], crates: &[&str]) -> bool { + let text = match std::str::from_utf8(bytes) { + Ok(s) => s, + Err(_) => return false, + }; + for line in text.lines() { + let mut line = line.trim_start(); + if let Some(rest) = line.strip_prefix("pub") { + let rest = rest.trim_start(); + line = if let Some(r) = rest.strip_prefix("(crate)") { + r.trim_start() + } else if let Some(r) = rest.strip_prefix("(super)") { + r.trim_start() + } else if let Some(r) = rest.strip_prefix("(self)") { + r.trim_start() + } else { + rest + }; + } + let rest = if let Some(r) = line.strip_prefix("use ") { + r + } else if let Some(r) = line.strip_prefix("extern crate ") { + r + } else { + continue; + }; + let head = rest + .split(['{', ';', ' ', ':', '/']) + .next() + .unwrap_or("") + .trim(); + if head.is_empty() { + continue; + } + if matches_prefix_ci(head, crates) { + return true; + } + } + false +} + +/// Return `true` when `bytes` contains a top-level Java `import` +/// statement (including `import static`) whose package path begins +/// with one of `prefixes`. Comment-only mentions do *not* match. +pub fn java_imports_any(bytes: &[u8], prefixes: &[&str]) -> bool { + let text = match std::str::from_utf8(bytes) { + Ok(s) => s, + Err(_) => return false, + }; + for line in text.lines() { + let line = line.trim_start(); + let Some(rest) = line.strip_prefix("import ") else { + continue; + }; + let path = rest + .strip_prefix("static ") + .unwrap_or(rest) + .trim() + .trim_end_matches(';') + .trim(); + if prefixes.iter().any(|p| path.starts_with(p)) { + return true; + } + } + false +} + /// Walk every descendant of `root`, invoking `visit` once per node. /// Useful when a probe needs to look at multiple node kinds in a single /// pass (e.g. annotations + method declarations on the same walk). @@ -128,4 +241,63 @@ mod tests { assert!(leaf_matches("Auth::JwtRequired", &["JwtRequired"])); assert!(!leaf_matches("OtherDecorator", &["login_required"])); } + + #[test] + fn python_imports_any_matches_actual_imports() { + assert!(python_imports_any(b"from flask import Flask\n", &["flask"])); + assert!(python_imports_any(b"import flask\n", &["flask"])); + assert!(python_imports_any(b"from flask.app import Flask\n", &["flask"])); + assert!(python_imports_any(b"import django.urls\n", &["django"])); + // Comment-only mention must not match. + assert!(!python_imports_any(b"# flask is great\n", &["flask"])); + // String-only mention must not match. + assert!(!python_imports_any(b"x = 'flask'\n", &["flask"])); + // Wrong module. + assert!(!python_imports_any(b"import os\n", &["flask"])); + } + + #[test] + fn rust_uses_any_matches_use_statements() { + assert!(rust_uses_any(b"use actix_web::web;\n", &["actix_web"])); + assert!(rust_uses_any(b"use actix_web;\n", &["actix_web"])); + assert!(rust_uses_any( + b"pub use axum::Router;\n", + &["axum"] + )); + assert!(rust_uses_any( + b"pub(crate) use axum::extract::Path;\n", + &["axum"] + )); + assert!(rust_uses_any(b"extern crate axum;\n", &["axum"])); + // Comment-only mention must not match. + assert!(!rust_uses_any(b"// use actix_web::web;\n", &["actix_web"])); + // Wrong crate. + assert!(!rust_uses_any(b"use serde::Deserialize;\n", &["actix_web"])); + } + + #[test] + fn java_imports_any_matches_package_prefix() { + assert!(java_imports_any( + b"import io.quarkus.runtime.Quarkus;\n", + &["io.quarkus"] + )); + assert!(java_imports_any( + b"import jakarta.ws.rs.GET;\n", + &["jakarta.ws.rs"] + )); + assert!(java_imports_any( + b"import static io.quarkus.runtime.Quarkus.run;\n", + &["io.quarkus"] + )); + // Comment-only mention must not match. + assert!(!java_imports_any( + b"// import io.quarkus.runtime.Quarkus;\n", + &["io.quarkus"] + )); + // Wrong prefix. + assert!(!java_imports_any( + b"import org.springframework.web.bind.annotation.GetMapping;\n", + &["io.quarkus"] + )); + } } diff --git a/src/surface/lang/java_quarkus.rs b/src/surface/lang/java_quarkus.rs index 04ba91d8..445b4a74 100644 --- a/src/surface/lang/java_quarkus.rs +++ b/src/surface/lang/java_quarkus.rs @@ -16,7 +16,7 @@ //! `@DenyAll` (Quarkus Security). use crate::entry_points::HttpMethod; -use crate::surface::lang::common::{loc_for, rel_file}; +use crate::surface::lang::common::{java_imports_any, loc_for, rel_file}; use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode}; use std::path::Path; use tree_sitter::{Node, Tree}; @@ -53,7 +53,10 @@ pub fn detect_quarkus_routes( scan_root: Option<&Path>, ) -> Vec { let file_rel = rel_file(path, scan_root); - if !file_uses_quarkus(tree.root_node(), bytes) { + // Phase 23 follow-up: tighten witness to top-level `import` + // statements with the strict package prefix, replacing the + // previous AST `import_declaration.contains(...)` substring scan. + if !java_imports_any(bytes, &["io.quarkus", "jakarta.ws.rs"]) { return Vec::new(); } let mut out = Vec::new(); @@ -94,19 +97,6 @@ pub fn detect_quarkus_routes( out } -fn file_uses_quarkus(root: Node, bytes: &[u8]) -> bool { - let mut cursor = root.walk(); - for child in root.children(&mut cursor) { - if child.kind() == "import_declaration" - && let Ok(text) = child.utf8_text(bytes) - && (text.contains("io.quarkus") || text.contains("jakarta.ws.rs")) - { - return true; - } - } - false -} - fn class_is_quarkus_resource(class: Node, bytes: &[u8]) -> bool { let modifiers = match crate::surface::lang::common::child_or_named(class, "modifiers") { Some(m) => m, diff --git a/src/surface/lang/python_django.rs b/src/surface/lang/python_django.rs index 5cc25900..e6d82b43 100644 --- a/src/surface/lang/python_django.rs +++ b/src/surface/lang/python_django.rs @@ -19,7 +19,7 @@ use crate::entry_points::HttpMethod; use crate::surface::lang::common::{ - leaf_matches, loc_for, rel_file, string_node_value, + leaf_matches, loc_for, python_imports_any, rel_file, string_node_value, }; use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode}; use std::collections::HashMap; @@ -59,12 +59,10 @@ pub fn detect_django_routes( scan_root: Option<&Path>, ) -> Vec { // File-level gate: only fire when the file actually imports - // django (or extends the Django CBV bases via name witness). - let file_text = std::str::from_utf8(bytes).unwrap_or(""); - let has_django_witness = file_text.contains("django") - || file_text.contains("rest_framework") - || CBV_BASES.iter().any(|b| file_text.contains(b)); - if !has_django_witness { + // django or DRF. Phase 23 follow-up tightens the witness to + // top-level `import` / `from` statements so a comment or string + // mention of "django" / "rest_framework" cannot trigger detection. + if !python_imports_any(bytes, &["django", "rest_framework"]) { return Vec::new(); } let file_rel = rel_file(path, scan_root); @@ -356,7 +354,7 @@ mod tests { #[test] fn detects_class_based_view() { - let src = "class UserList(APIView):\n def get(self, request): pass\n def post(self, request): pass\n"; + let src = "from rest_framework.views import APIView\n\nclass UserList(APIView):\n def get(self, request): pass\n def post(self, request): pass\n"; let (tree, bytes) = parse(src); let nodes = detect_django_routes(&tree, &bytes, &PathBuf::from("views.py"), None); assert_eq!(nodes.len(), 2); diff --git a/src/surface/lang/python_fastapi.rs b/src/surface/lang/python_fastapi.rs index a4171986..f574658b 100644 --- a/src/surface/lang/python_fastapi.rs +++ b/src/surface/lang/python_fastapi.rs @@ -12,7 +12,9 @@ //! decorator-stack guards drawn from [`AUTH_DECORATORS`]. use crate::entry_points::HttpMethod; -use crate::surface::lang::common::{leaf_matches, loc_for, rel_file, string_node_value}; +use crate::surface::lang::common::{ + leaf_matches, loc_for, python_imports_any, rel_file, string_node_value, +}; use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode}; use std::path::Path; use tree_sitter::{Node, Tree}; @@ -51,13 +53,10 @@ pub fn detect_fastapi_routes( scan_root: Option<&Path>, ) -> Vec { // File-level gate: avoid double-detection on Flask files that - // also use `app.get(...)` shape. FastAPI / Starlette / APIRouter - // require an explicit import of the relevant package. - let file_text = std::str::from_utf8(bytes).unwrap_or(""); - let has_fastapi_witness = file_text.contains("fastapi") - || file_text.contains("starlette") - || file_text.contains("APIRouter"); - if !has_fastapi_witness { + // also use `app.get(...)` shape. Phase 23 follow-up tightens the + // witness to actual top-level `import` / `from` statements so a + // comment or string mention of "fastapi" cannot trigger detection. + if !python_imports_any(bytes, &["fastapi", "starlette"]) { return Vec::new(); } let file_rel = rel_file(path, scan_root); @@ -314,7 +313,7 @@ mod tests { #[test] fn detects_router_post() { - let src = "router = APIRouter()\n@router.post('/items')\ndef create(): pass\n"; + let src = "from fastapi import APIRouter\nrouter = APIRouter()\n@router.post('/items')\ndef create(): pass\n"; let (tree, bytes) = parse(src); let nodes = detect_fastapi_routes(&tree, &bytes, &PathBuf::from("api.py"), None); let SurfaceNode::EntryPoint(ep) = &nodes[0] else { diff --git a/src/surface/lang/python_flask.rs b/src/surface/lang/python_flask.rs index ae7caa1a..d4defef7 100644 --- a/src/surface/lang/python_flask.rs +++ b/src/surface/lang/python_flask.rs @@ -16,6 +16,7 @@ //! and -JWT-Extended). use crate::entry_points::HttpMethod; +use crate::surface::lang::common::python_imports_any; use crate::surface::{ EntryPoint, Framework, SourceLocation, SurfaceNode, relative_path_string, }; @@ -52,13 +53,11 @@ pub fn detect_flask_routes( ) -> Vec { // File-level gate: avoid double-detection on FastAPI files where // `app.get(...)` shape overlaps. Phase 21 was lenient because no - // sibling probe existed; Phase 22 splits per-framework, so each - // probe only fires when its framework witness is present. - let file_text = std::str::from_utf8(bytes).unwrap_or(""); - let has_flask_witness = file_text.contains("flask") - || file_text.contains("Flask") - || file_text.contains("Blueprint"); - if !has_flask_witness { + // sibling probe existed; Phase 22 split per-framework via free + // text witness; Phase 23 follow-up tightens the witness to actual + // top-level `import` / `from` statements so a comment or vendored + // license header that names "flask" cannot trigger detection. + if !python_imports_any(bytes, &["flask"]) { return Vec::new(); } let file_rel = relative_path_string(path, scan_root); diff --git a/src/surface/lang/rust_actix.rs b/src/surface/lang/rust_actix.rs index e27ee2e0..382b8bd2 100644 --- a/src/surface/lang/rust_actix.rs +++ b/src/surface/lang/rust_actix.rs @@ -11,7 +11,7 @@ //! `BearerAuth`, `JwtClaims`, etc.). use crate::entry_points::HttpMethod; -use crate::surface::lang::common::{loc_for, rel_file}; +use crate::surface::lang::common::{loc_for, rel_file, rust_uses_any}; use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode}; use std::path::Path; use tree_sitter::{Node, Tree}; @@ -42,11 +42,11 @@ pub fn detect_actix_routes( path: &Path, scan_root: Option<&Path>, ) -> Vec { - let file_text = std::str::from_utf8(bytes).unwrap_or(""); - if !file_text.contains("actix_web::") && !file_text.contains("use actix_web") { - // Best-effort gate so the actix probe does not over-fire on - // Rocket / generic Rust files that also define a `#[get]` - // macro from a user crate. + // Phase 23 follow-up: gate on a real top-level `use actix_web…` / + // `extern crate actix_web` so a comment or string literal + // mentioning actix_web cannot trigger detection on a Rocket / + // generic Rust file that also defines a `#[get]` user macro. + if !rust_uses_any(bytes, &["actix_web"]) { return Vec::new(); } let file_rel = rel_file(path, scan_root); diff --git a/src/surface/lang/rust_axum.rs b/src/surface/lang/rust_axum.rs index dfd412c8..715d72db 100644 --- a/src/surface/lang/rust_axum.rs +++ b/src/surface/lang/rust_axum.rs @@ -9,7 +9,7 @@ //! `Router::route(...)` registration in the same file references it). use crate::entry_points::HttpMethod; -use crate::surface::lang::common::{loc_for, rel_file, string_node_value}; +use crate::surface::lang::common::{loc_for, rel_file, rust_uses_any, string_node_value}; use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode}; use std::collections::HashMap; use std::path::Path; @@ -39,8 +39,10 @@ pub fn detect_axum_routes( path: &Path, scan_root: Option<&Path>, ) -> Vec { - let file_text = std::str::from_utf8(bytes).unwrap_or(""); - if !file_text.contains("axum::") && !file_text.contains("use axum") { + // Phase 23 follow-up: gate on a real top-level `use axum…` / + // `extern crate axum` so a comment / string literal mentioning + // axum cannot trigger detection. + if !rust_uses_any(bytes, &["axum"]) { return Vec::new(); } let file_rel = rel_file(path, scan_root); diff --git a/src/surface/reachability.rs b/src/surface/reachability.rs index 095f0451..89ce3535 100644 --- a/src/surface/reachability.rs +++ b/src/surface/reachability.rs @@ -60,16 +60,25 @@ pub fn populate_reaches_edges( // call graph cannot resolve the seed FuncKey. reachable_files.insert(ep.handler_location.file.clone()); - // Locate seed FuncKeys whose `namespace` matches the entry's - // file and whose `name` matches the handler. More than one - // seed is possible (overloaded methods, duplicate definitions). + // Locate seed FuncKeys whose `namespace` (project-relative + // POSIX path, optionally prefixed with `@pkg/name::`) matches + // the entry's file and whose `name` matches the handler. More + // than one seed is possible (overloaded methods, duplicate + // definitions). + // + // Phase 23 follow-up: this used to be an `ends_with` substring + // check on both sides, which silently aliased same-basename + // files in sibling directories — `subdir/app.py` and + // `other/app.py` would both seed when the entry-point pointed + // at `app.py`. We now compare the file part exactly so a + // handler in `subdir/app.py` only seeds the FuncKey whose + // namespace strips to `subdir/app.py`. let seeds = call_graph .index .iter() .filter(|(k, _)| k.name == ep.handler_name) .filter(|(k, _)| { - k.namespace.ends_with(&ep.handler_location.file) - || ep.handler_location.file.ends_with(&k.namespace) + file_part_of_namespace(&k.namespace) == ep.handler_location.file }) .map(|(_, idx)| *idx) .collect::>(); @@ -108,6 +117,15 @@ pub fn populate_reaches_edges( map.edges.extend(new_edges); } +/// Strip the optional `@pkg/name::` package prefix from a `FuncKey` +/// namespace, returning the project-relative POSIX file path part. +/// `namespace_with_package` produces `"@scope/name::src/file.ts"` for +/// JS/TS files inside resolved packages; the file part is what +/// matches an entry-point's `handler_location.file`. +fn file_part_of_namespace(ns: &str) -> &str { + ns.rsplit_once("::").map(|(_, rest)| rest).unwrap_or(ns) +} + /// Build a lookup from destination node index → destination file. /// Restricted to the three reachable-from-entry-point variants. fn build_destination_index(map: &SurfaceMap) -> Vec<(usize, String)> { @@ -189,4 +207,19 @@ mod tests { assert_eq!(map.edges[0].from, 0); assert_eq!(map.edges[0].to, 1); } + + #[test] + fn file_part_of_namespace_strips_package_prefix() { + assert_eq!(file_part_of_namespace("app.py"), "app.py"); + assert_eq!(file_part_of_namespace("src/main.rs"), "src/main.rs"); + assert_eq!( + file_part_of_namespace("@scope/name::src/file.ts"), + "src/file.ts" + ); + // Last `::` wins, matching `namespace_with_package`'s shape. + assert_eq!( + file_part_of_namespace("@a/b::@c/d::lib/x.ts"), + "lib/x.ts" + ); + } } diff --git a/tests/dynamic_fixtures/surface/cli_output.golden.txt b/tests/dynamic_fixtures/surface/cli_output.golden.txt index bbdcb329..524ef321 100644 --- a/tests/dynamic_fixtures/surface/cli_output.golden.txt +++ b/tests/dynamic_fixtures/surface/cli_output.golden.txt @@ -1,5 +1,5 @@ Surface map - 1 entry-points, 0 data stores, 0 external services, 0 dangerous locals + 1 entry-point, 0 data stores, 0 external services, 0 dangerous locals app.py GET /users (Flask) diff --git a/tests/surface_cli.rs b/tests/surface_cli.rs index 2a609dae..db89d9f2 100644 --- a/tests/surface_cli.rs +++ b/tests/surface_cli.rs @@ -118,3 +118,23 @@ fn load_or_build_falls_back_to_filesystem_when_no_db() { "expected at least one entry-point in fallback path" ); } + +/// Phase 21 follow-up: the non-indexed scan path now returns the +/// SurfaceMap built during pass 2 alongside the diagnostics, so +/// consumers can avoid re-running the analysis to render the surface. +#[test] +fn scan_no_index_with_surface_map_returns_entry_points() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("app.py"), + "from flask import Flask\napp = Flask(__name__)\n@app.get('/x')\ndef x(): pass\n", + ) + .unwrap(); + let cfg = Config::default(); + let (_diags, map) = nyx_scanner::scan_no_index_with_surface_map(tmp.path(), &cfg) + .expect("scan_no_index_with_surface_map should succeed"); + assert!( + map.entry_points().next().is_some(), + "expected at least one entry-point in returned SurfaceMap" + ); +}