diff --git a/src/cli.rs b/src/cli.rs index 9e0fa2d8..ecc0b2a1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -625,9 +625,13 @@ pub enum Commands { /// Loads the SurfaceMap persisted by the most recent indexed scan /// when available, otherwise builds an entry-point-only map by /// running the per-language framework probes against the on-disk - /// source. Use `--format dot` and pipe through `dot -Tsvg` to - /// produce a renderable graph; `--format svg` does the same in one - /// step when graphviz is installed locally. + /// source. Pass `--build` to force a full inline build (pass-1 + /// summary extraction + call-graph construction) when no indexed + /// scan exists; that populates DataStore / ExternalService / + /// DangerousLocal nodes the entry-points-only fallback omits. + /// Use `--format dot` and pipe through `dot -Tsvg` to produce a + /// renderable graph; `--format svg` does the same in one step when + /// graphviz is installed locally. Surface { /// Path to inspect (defaults to current directory) #[arg(default_value = ".")] @@ -636,6 +640,15 @@ pub enum Commands { /// Output format: text (default), json, dot, svg #[arg(long, value_enum, default_value_t = SurfaceFormat::Text)] format: SurfaceFormat, + + /// Build the full SurfaceMap from source even when no indexed + /// scan exists. Runs pass-1 summary extraction + call-graph + /// build inline (same cost as `nyx index build`), then renders + /// data-store / external-service / dangerous-local nodes plus + /// reach edges. Without this flag, an unscanned project + /// produces an entry-points-only map. + #[arg(long)] + build: bool, }, /// Start the local web UI for browsing scan results diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 50c0c524..599a8dd6 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -427,9 +427,13 @@ pub fn handle_command( Commands::Rules { action } => { self::rules::handle(action, config)?; } - Commands::Surface { path, format } => { + Commands::Surface { + path, + format, + build, + } => { install_from_config(config); - surface::handle(&path, format, database_dir, config)?; + surface::handle(&path, format, build, database_dir, config)?; } Commands::Serve { path, diff --git a/src/commands/surface.rs b/src/commands/surface.rs index 402384b3..7d28f5e2 100644 --- a/src/commands/surface.rs +++ b/src/commands/surface.rs @@ -16,7 +16,15 @@ //! map first; if none exists (no `nyx scan` ever ran, or the index was //! cleaned) it falls back to building a fresh entry-point-only map by //! running the framework probes against the on-disk source. +//! +//! Pass `--build` to force a full inline build that runs pass-1 +//! summary extraction + call-graph construction. That populates the +//! same DataStore / ExternalService / DangerousLocal nodes and Reaches +//! edges that an indexed scan would have persisted, at the cost of +//! parsing the project tree once (same wall-clock as `nyx index +//! build`). +use crate::ast::extract_all_summaries_from_bytes; use crate::callgraph; use crate::cli::SurfaceFormat; use crate::database::index::Indexer; @@ -30,6 +38,7 @@ use crate::utils::Config; use crate::utils::project::get_project_info; use crate::walk::spawn_file_walker; use crossbeam_channel::TryRecvError; +use rayon::prelude::*; use std::collections::BTreeMap; use std::io::Write; use std::path::{Path, PathBuf}; @@ -37,14 +46,25 @@ use std::process::{Command, Stdio}; /// Top-level CLI handler. Resolves the scan root, loads or builds a /// [`SurfaceMap`], renders it in `format`, and writes to stdout. +/// +/// When `build_inline` is `true`, the persisted SurfaceMap (if any) is +/// ignored and the full map is built by running pass-1 summary +/// extraction + call-graph construction against the on-disk source. +/// This populates DataStore / ExternalService / DangerousLocal nodes +/// and Reaches edges that the entry-points-only fallback omits. pub fn handle( path: &str, format: SurfaceFormat, + build_inline: bool, database_dir: &Path, config: &Config, ) -> NyxResult<()> { let scan_root = Path::new(path).canonicalize()?; - let map = load_or_build(&scan_root, database_dir, config)?; + let map = if build_inline { + build_full_from_filesystem(&scan_root, config)? + } else { + load_or_build(&scan_root, database_dir, config)? + }; let stdout = std::io::stdout(); let mut out = stdout.lock(); match format { @@ -108,6 +128,76 @@ fn build_from_filesystem(scan_root: &Path, config: &Config) -> NyxResult NyxResult { + let files = collect_files(scan_root, config)?; + let mut summaries = build_summaries_inline(&files, scan_root, config); + summaries.install_hierarchy(); + let call_graph = callgraph::build_call_graph(&summaries, &[]); + let inputs = SurfaceBuildInputs { + files: &files, + scan_root: Some(scan_root), + global_summaries: &summaries, + call_graph: &call_graph, + config, + }; + Ok(build_surface_map(&inputs)) +} + +/// Run pass-1 summary extraction across `files` in parallel and merge +/// the per-thread results into a single [`GlobalSummaries`]. Mirrors +/// the `scan_filesystem_with_observer` pass-1 fold/reduce shape but +/// strips out the progress / metrics / logs threading the surface +/// command does not need. +/// +/// Per-file errors are swallowed so a single bad file does not kill +/// the whole map. +fn build_summaries_inline( + files: &[PathBuf], + scan_root: &Path, + config: &Config, +) -> GlobalSummaries { + let root_str = scan_root.to_string_lossy().into_owned(); + let mg = config.module_graph.as_deref(); + files + .par_iter() + .fold(GlobalSummaries::new, |mut local_gs, path| { + let Ok(bytes) = std::fs::read(path) else { + return local_gs; + }; + let Ok((func_summaries, ssa_summaries, ssa_bodies, auth_summaries, cross_pkg)) = + extract_all_summaries_from_bytes(&bytes, path, config, Some(scan_root)) + else { + return local_gs; + }; + for s in func_summaries { + let key = s.func_key_with_resolver(Some(&root_str), mg); + local_gs.insert(key, s); + } + for (key, ssa_sum) in ssa_summaries { + local_gs.insert_ssa(key, ssa_sum); + } + for (key, body) in ssa_bodies { + local_gs.insert_body(key, body); + } + for (key, auth_sum) in auth_summaries { + local_gs.insert_auth(key, auth_sum); + } + if let Some((ns, map)) = cross_pkg { + local_gs.insert_cross_package_imports(ns, map); + } + local_gs + }) + .reduce(GlobalSummaries::new, |mut a, b| { + a.merge(b); + a + }) +} + fn collect_files(root: &Path, config: &Config) -> NyxResult> { let (rx, handle) = spawn_file_walker(root, config); let mut out = Vec::new(); @@ -541,4 +631,127 @@ mod tests { assert!(text.contains("reaches:")); assert!(text.contains("dangerous: eval")); } + + #[test] + fn build_summaries_inline_extracts_function_summaries() { + // Establishes that the inline pass-1 path produces the same + // `GlobalSummaries` shape that an indexed scan would have + // persisted — at minimum, one FuncSummary per top-level + // function in the fixture. Without this guarantee the surface + // build downstream falls back to entry-points-only because + // `detect_data_stores` / `detect_external_services` / + // `detect_dangerous_locals` walk the summaries map. + let td = tempfile::tempdir().unwrap(); + let project_dir = td.path(); + std::fs::write( + project_dir.join("app.py"), + "from flask import Flask, request\n\ + app = Flask(__name__)\n\ + \n\ + @app.route('/run')\n\ + def run():\n\ + cmd = request.args.get('cmd')\n\ + return str(eval(cmd))\n\ + \n\ + def helper(x):\n\ + return eval(x)\n", + ) + .unwrap(); + + let cfg = Config::default(); + let canon = project_dir.canonicalize().unwrap(); + let files = collect_files(&canon, &cfg).unwrap(); + let summaries = build_summaries_inline(&files, &canon, &cfg); + let names: Vec = summaries + .iter() + .map(|(k, _)| k.qualified_name()) + .collect(); + assert!( + names.iter().any(|n| n.ends_with("run")), + "summaries should contain `run`, got {names:?}" + ); + assert!( + names.iter().any(|n| n.ends_with("helper")), + "summaries should contain `helper`, got {names:?}" + ); + } + + #[test] + fn build_full_from_filesystem_walks_pass1_pipeline() { + // End-to-end smoke for `surface::handle(..., build=true)`: the + // inline-build path must produce a non-empty SurfaceMap on a + // project with a recognisable framework route. Equivalent to + // running `nyx surface --build .` on a single-file Flask app. + let td = tempfile::tempdir().unwrap(); + let project_dir = td.path(); + std::fs::write( + project_dir.join("app.py"), + "from flask import Flask, request\n\ + app = Flask(__name__)\n\ + \n\ + @app.route('/run')\n\ + def run():\n\ + cmd = request.args.get('cmd')\n\ + return str(eval(cmd))\n", + ) + .unwrap(); + + let cfg = Config::default(); + let canon = project_dir.canonicalize().unwrap(); + let map = build_full_from_filesystem(&canon, &cfg).expect("inline build succeeds"); + + let has_entry = map + .nodes + .iter() + .any(|n| matches!(n, SurfaceNode::EntryPoint(_))); + assert!(has_entry, "Flask /run route should be detected"); + } + + #[test] + fn build_from_filesystem_entry_points_only_runs_with_empty_summaries() { + // Locks in the fallback contract: `build_from_filesystem` runs + // framework probes against an empty `GlobalSummaries` and + // produces only entry-point nodes. Any future change that + // accidentally widens the fallback to populate sinks should + // either ship through `--build` or update this test. + let td = tempfile::tempdir().unwrap(); + let project_dir = td.path(); + std::fs::write( + project_dir.join("app.py"), + "from flask import Flask\n\ + app = Flask(__name__)\n\ + \n\ + @app.route('/run')\n\ + def run():\n\ + return 'ok'\n", + ) + .unwrap(); + + let cfg = Config::default(); + let canon = project_dir.canonicalize().unwrap(); + let map = build_from_filesystem(&canon, &cfg).expect("fallback build succeeds"); + + // Entry point should still appear (framework probes run in the + // fallback path too). + assert!( + map.nodes + .iter() + .any(|n| matches!(n, SurfaceNode::EntryPoint(_))), + "Flask route should land via framework probe" + ); + // No DataStore / ExternalService / DangerousLocal because the + // fallback path feeds an empty GlobalSummaries to the detectors. + let non_entry = map.nodes.iter().any(|n| { + matches!( + n, + SurfaceNode::DataStore(_) + | SurfaceNode::ExternalService(_) + | SurfaceNode::DangerousLocal(_) + ) + }); + assert!( + !non_entry, + "entry-points-only fallback should not produce non-entry nodes" + ); + } } diff --git a/tests/eval_corpus/check_surface.sh b/tests/eval_corpus/check_surface.sh new file mode 100755 index 00000000..05b51a2d --- /dev/null +++ b/tests/eval_corpus/check_surface.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# Phase 31 acceptance walker: assert `nyx surface` produces a usable +# map on every downloaded eval-corpus fixture root. +# +# Walks the project trees under $NYX_EVAL_CORPUS_DIR plus the in-house +# `tests/benchmark/corpus` and `tests/dynamic_fixtures` trees, runs +# `nyx surface --build --format json ` against each, and asserts +# the resulting JSON contains at least one EntryPoint plus at least +# one DataStore / ExternalService / DangerousLocal node. +# +# `--build` forces the inline pass-1 + call-graph path so the walker +# does not depend on a prior `nyx index build` or `nyx scan`. +# +# Usage: +# tests/eval_corpus/check_surface.sh [--nyx BIN] [--corpus-dir DIR] +# [--also-inhouse] +# [--report FILE] +# +# Environment: +# NYX_EVAL_CORPUS_DIR — path to pre-downloaded corpus roots +# (default: ~/.cache/nyx/eval_corpus). When +# missing or empty the walker still scans the +# in-house corpus and exits 0 so CI without a +# corpus mirror does not block on Phase 31. +# +# Exit codes: +# 0 every walked project produced a usable SurfaceMap (or no +# projects were available — see corpus-missing note above). +# 1 setup / I/O / missing-binary error. +# 2 one or more projects produced an empty or unusable SurfaceMap. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +NYX_BIN="${NYX_BIN:-${REPO_ROOT}/target/release/nyx}" +CORPUS_CACHE="${NYX_EVAL_CORPUS_DIR:-${HOME}/.cache/nyx/eval_corpus}" +ALSO_INHOUSE="false" +REPORT_FILE="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --nyx) NYX_BIN="$2"; shift 2 ;; + --corpus-dir) CORPUS_CACHE="$2"; shift 2 ;; + --also-inhouse) ALSO_INHOUSE="true"; shift ;; + --report) REPORT_FILE="$2"; shift 2 ;; + -h|--help) + sed -n '1,40p' "$0" + exit 0 + ;; + *) + echo "unknown flag: $1" >&2 + exit 1 + ;; + esac +done + +die() { echo "error: $*" >&2; exit 1; } +info() { echo "[surface-check] $*"; } +warn() { echo "[surface-check] WARN: $*" >&2; } + +[[ -x "$NYX_BIN" ]] || die "nyx binary not found or not executable: $NYX_BIN" +command -v jq >/dev/null 2>&1 || die "required command not found: jq" + +# Collect project roots. Each corpus directory is treated as a single +# project; the in-house corpus trees are handled the same way (each +# language vertical is a project root). +PROJECTS=() +if [[ -d "$CORPUS_CACHE" ]]; then + for entry in "$CORPUS_CACHE"/*; do + [[ -d "$entry" ]] && PROJECTS+=("$entry") + done +else + warn "corpus directory missing: $CORPUS_CACHE (run tests/eval_corpus/run.sh to bootstrap)" +fi +if [[ "$ALSO_INHOUSE" == "true" ]]; then + for dir in \ + "${REPO_ROOT}/tests/benchmark/corpus" \ + "${REPO_ROOT}/tests/dynamic_fixtures" + do + [[ -d "$dir" ]] && PROJECTS+=("$dir") + done +fi + +if [[ ${#PROJECTS[@]} -eq 0 ]]; then + info "no project roots to walk (eval corpus not downloaded, in-house trees absent)" + exit 0 +fi + +PASS_COUNT=0 +FAIL_COUNT=0 +FAIL_PROJECTS=() +declare -a REPORT_ROWS=() + +for project in "${PROJECTS[@]}"; do + info "walking: $project" + set +e + out="$("$NYX_BIN" surface --build --format json "$project" 2>/dev/null)" + rc=$? + set -e + if [[ $rc -ne 0 ]]; then + warn "nyx surface --build exited $rc on $project" + FAIL_COUNT=$((FAIL_COUNT + 1)) + FAIL_PROJECTS+=("$project (nyx exit=$rc)") + REPORT_ROWS+=("$(printf '{"project":%s,"status":"nyx-error","exit":%d}' \ + "$(jq -Rn --arg p "$project" '$p')" "$rc")") + continue + fi + if [[ -z "$out" ]]; then + warn "empty output on $project" + FAIL_COUNT=$((FAIL_COUNT + 1)) + FAIL_PROJECTS+=("$project (empty output)") + REPORT_ROWS+=("$(printf '{"project":%s,"status":"empty-output"}' \ + "$(jq -Rn --arg p "$project" '$p')")") + continue + fi + # Count nodes by kind. SurfaceMap serialises each node as a flat + # object with a `node` discriminator: `entry_point`, `data_store`, + # `external_service`, `dangerous_local`. + entry_count="$(echo "$out" | jq '[.nodes[] | select(.node == "entry_point")] | length')" + ds_count="$(echo "$out" | jq '[.nodes[] | select(.node == "data_store")] | length')" + es_count="$(echo "$out" | jq '[.nodes[] | select(.node == "external_service")] | length')" + dl_count="$(echo "$out" | jq '[.nodes[] | select(.node == "dangerous_local")] | length')" + sink_count=$((ds_count + es_count + dl_count)) + if [[ "$entry_count" -lt 1 ]]; then + warn "no EntryPoint nodes on $project" + FAIL_COUNT=$((FAIL_COUNT + 1)) + FAIL_PROJECTS+=("$project (no entry-points)") + REPORT_ROWS+=("$(printf '{"project":%s,"status":"no-entry-points","entry_count":%d}' \ + "$(jq -Rn --arg p "$project" '$p')" "$entry_count")") + continue + fi + if [[ "$sink_count" -lt 1 ]]; then + warn "no DataStore / ExternalService / DangerousLocal nodes on $project" + FAIL_COUNT=$((FAIL_COUNT + 1)) + FAIL_PROJECTS+=("$project (no sinks: ds=$ds_count es=$es_count dl=$dl_count)") + REPORT_ROWS+=("$(printf '{"project":%s,"status":"no-sinks","entry_count":%d,"ds":%d,"es":%d,"dl":%d}' \ + "$(jq -Rn --arg p "$project" '$p')" "$entry_count" "$ds_count" "$es_count" "$dl_count")") + continue + fi + info " ok: ${entry_count} entry-points, ${ds_count} data stores, ${es_count} external, ${dl_count} dangerous" + PASS_COUNT=$((PASS_COUNT + 1)) + REPORT_ROWS+=("$(printf '{"project":%s,"status":"ok","entry_count":%d,"ds":%d,"es":%d,"dl":%d}' \ + "$(jq -Rn --arg p "$project" '$p')" "$entry_count" "$ds_count" "$es_count" "$dl_count")") +done + +if [[ -n "$REPORT_FILE" ]]; then + { + echo "{" + echo " \"pass\": $PASS_COUNT," + echo " \"fail\": $FAIL_COUNT," + echo " \"projects\": [" + for i in "${!REPORT_ROWS[@]}"; do + sep="," + [[ $i -eq $((${#REPORT_ROWS[@]} - 1)) ]] && sep="" + echo " ${REPORT_ROWS[$i]}$sep" + done + echo " ]" + echo "}" + } > "$REPORT_FILE" + info "report written: $REPORT_FILE" +fi + +info "" +info "summary: ${PASS_COUNT} pass, ${FAIL_COUNT} fail (of $((PASS_COUNT + FAIL_COUNT)) projects)" +if [[ $FAIL_COUNT -gt 0 ]]; then + for p in "${FAIL_PROJECTS[@]}"; do + info " fail: $p" + done + exit 2 +fi +exit 0