diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80f41cc..38744fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,19 +40,8 @@ jobs: - name: Checkout raysense uses: actions/checkout@v6 - - name: Checkout rayforce - uses: actions/checkout@v6 - with: - repository: RayforceDB/rayforce - path: deps/rayforce - - - name: Build rayforce library - run: make -C deps/rayforce lib - - name: Check formatting run: cargo fmt --check - name: Run tests run: cargo test - env: - RAYFORCE_DIR: ${{ github.workspace }}/deps/rayforce diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c1aca36..80bbb3a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -45,19 +45,8 @@ jobs: - name: Checkout raysense uses: actions/checkout@v6 - - name: Checkout rayforce - uses: actions/checkout@v6 - with: - repository: RayforceDB/rayforce - path: deps/rayforce - - - name: Build rayforce library - run: make -C deps/rayforce lib - - - name: Test workspace + - name: Test run: cargo test - env: - RAYFORCE_DIR: ${{ github.workspace }}/deps/rayforce - name: Package and publish crates shell: bash @@ -119,30 +108,22 @@ jobs: wait_for_crate "$package" "$version" } - publish_crate rayforce-sys - publish_crate raysense-core - publish_crate raysense-memory - publish_crate raysense-cli publish_crate raysense env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - RAYFORCE_DIR: ${{ github.workspace }}/deps/rayforce - name: Post-release smoke if: ${{ github.event_name != 'workflow_dispatch' || inputs.dry_run != true }} shell: bash run: | set -euo pipefail - version="$(sed -n 's/^version = "\(.*\)"/\1/p' crates/raysense/Cargo.toml | head -n 1)" + version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1)" smoke_dir="$(mktemp -d)" - RAYFORCE_DIR="${{ github.workspace }}/deps/rayforce" \ - cargo install raysense --version "$version" --root "$smoke_dir/install" - "$smoke_dir/install/bin/raysense" rayforce-version + cargo install raysense --version "$version" --root "$smoke_dir/install" + "$smoke_dir/install/bin/raysense" --version cargo new "$smoke_dir/library-smoke" cd "$smoke_dir/library-smoke" cargo add "raysense@$version" - RAYFORCE_DIR="${{ github.workspace }}/deps/rayforce" cargo check - env: - RAYFORCE_DIR: ${{ github.workspace }}/deps/rayforce + cargo check diff --git a/Cargo.toml b/Cargo.toml index afb972e..3f5d72a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,23 +19,23 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -[workspace] -members = [ - "crates/rayforce-sys", - "crates/raysense", - "crates/raysense-cli", - "crates/raysense-core", - "crates/raysense-memory", -] -resolver = "2" - -[workspace.package] +[package] +name = "raysense" +version = "0.2.0" edition = "2021" license = "MIT" repository = "https://github.com/RayforceDB/raysense" +description = "Architectural X-ray for your codebase. Live, local, agent-ready." +readme = "README.md" +links = "rayforce" + +[[bin]] +name = "raysense" +path = "src/main.rs" -[workspace.dependencies] +[dependencies] anyhow = "1" +axum = "0.7" clap = { version = "4", features = ["derive"] } ignore = "0.4" libloading = "0.8" @@ -44,4 +44,16 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" thiserror = "2" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "signal"] } +tokio-stream = { version = "0.1", features = ["sync"] } +toml = "1.1.2" +tree-sitter = "0.26.8" +tree-sitter-c = "0.24.2" +tree-sitter-cpp = "0.23.4" tree-sitter-language = "0.1" +tree-sitter-python = "0.25.0" +tree-sitter-rust = "0.24.2" +tree-sitter-typescript = "0.23.2" + +[build-dependencies] +cc = "1" diff --git a/README.md b/README.md index c313d4c..f7c6420 100644 --- a/README.md +++ b/README.md @@ -23,347 +23,81 @@ # Raysense -Raysense is local architectural telemetry for AI coding agents. +**Architectural X-ray for your codebase. Live, local, agent-ready.** -It scans a repository, extracts files/functions/imports, resolves local -dependency edges, classifies imports, computes graph health, and can materialize -the scan into Rayforce-backed memory tables. +Point Raysense at a repository and it tells you, in seconds, where the +load-bearing files are, which modules are tangled, where complexity is +hiding, and which parts of the codebase are bus-factor-of-one. It runs +locally, ships zero data anywhere, and exposes everything to AI coding +agents through MCP. -## Current Test Commands +## Why -```bash -cargo run -q -p raysense-cli -- health . -cargo run -q -p raysense-cli -- edges . -cargo run -q -p raysense-cli -- observe . --memory -``` +LLM coding agents read source one file at a time. They don't see the +*shape* of your project: the cycles, the god files, the dead code, the +files that change together every commit. Raysense computes that shape +once and serves it back as queryable structure — to your agents, to +your CI, and to a live dashboard you can keep open while you work. -Against Rayforce from this workspace layout: +## Install ```bash -cargo run -q -p raysense-cli -- health ../rayforce -cargo run -q -p raysense-cli -- edges ../rayforce | head -cargo run -q -p raysense-cli -- observe ../rayforce --memory -cargo run -q -p raysense-cli -- baseline save ../rayforce -cargo run -q -p raysense-cli -- baseline diff ../rayforce -``` - -Current Rayforce baseline: - -```text -score 77 -quality_signal 7708 -coverage_score 100 -structural_score 72 -facts files=190 functions=2662 calls=25704 call_edges=15492 imports=1039 -entry_points total=50 binaries=6 examples=4 tests=40 -imports local=657 external=0 system=382 unresolved=0 -graph resolved_edges=657 cycles=0 -coupling local_edges=657 cross_module_edges=240 cross_module_ratio=0.365 cross_unstable_edges=200 cross_unstable_ratio=0.304 entropy=0.824 entropy_bits=3.201 entropy_pairs=15 average_module_cohesion=0.667 cohesive_module_count=18 god_files=2 unstable_hotspots=4 -calls total=25704 resolved_edges=15492 resolution_ratio=0.603 max_function_fan_in=2537 max_function_fan_out=293 -size max_file_lines=6329 max_function_lines=2334 large_files=63 long_functions=209 -test_gap production_files=150 test_files=40 files_without_nearby_tests=150 -dsm modules=5 module_edges=240 -root_causes modularity=0.635 acyclicity=1.000 depth=1.000 equality=0.450 redundancy=0.952 -architecture depth=3 max_blast_radius=25 max_blast_radius_file=src/ops/query.c max_non_foundation_blast_radius=12 max_non_foundation_blast_radius_file=src/runtime/eval.c attack_surface_files=45 attack_surface_ratio=0.703 upward_violations=3 upward_violation_ratio=0.012 average_distance_from_main_sequence=0.214 -complexity max=131 avg=3.904 gini=0.550 dead_functions=50 duplicate_groups=20 redundancy_ratio=0.048 -evolution available=true commits_sampled=500 changed_files=190 -rules warnings=7 info=31 -``` - -## Commands - -Install from crates.io after building a local Rayforce library: - -```sh -git clone git@github.com:RayforceDB/rayforce.git -make -C rayforce lib -RAYFORCE_DIR="$PWD/rayforce" cargo install raysense -``` - -For library use: - -```sh -cargo add raysense -``` - -```text -raysense observe [--json] [--memory] [--config ] -raysense health [--json] [--config ] -raysense edges [--all] [--config ] -raysense memory [--config ] -raysense check [path] [--json] [--sarif ] [--config ] -raysense gate [path] [--save] [--baseline ] [--json] [--config ] -raysense watch [path] [--interval ] [--config ] -raysense visualize [path] [--watch] [--interval ] [--output ] [--config ] -raysense plugin list [path] [--config ] -raysense plugin add [--file-name ] [--path ] [--config ] -raysense plugin add-standard [--path ] [--config ] -raysense plugin remove [--path ] [--config ] -raysense plugin validate [--json] -raysense plugin scaffold [--path ] -raysense plugin init [--path ] [--config ] -raysense policy list -raysense policy init [path] [--config ] -raysense trend record [path] [--config ] -raysense trend show [path] [--json] [--config ] -raysense remediate [path] [--json] [--config ] -raysense what-if [path] [--ignore ] [--generated ] [--json] [--config ] -raysense baseline save [--output ] [--config ] -raysense baseline diff [--baseline ] [--config ] [--json] -raysense baseline tables [--baseline ] [--json] -raysense baseline table [--baseline ] [--columns ] [--filter ] [--filter-mode ] [--sort ] [--desc] [--offset ] [--limit ] [--json] -raysense mcp -raysense rayforce-version +cargo install raysense ``` -If `/.raysense.toml` exists, health-producing commands load it -automatically. `--config` overrides that path. -Project-local plugin manifests under `.raysense/plugins/*/plugin.toml` are also -loaded during scans, using the same fields as `[[scan.plugins]]`. -When `.raysense/plugins//queries/tags.scm` is present and the plugin -selects a compiled grammar with `grammar = "rust"`, `c`, `cpp`, `python`, or -`typescript`, or with `grammar_path` and optional `grammar_symbol`, Raysense -uses query captures for functions and imports before falling back to token -prefixes. - -`raysense mcp` runs a stdio MCP server for agents. It exposes tools to read and -write config, run health, inspect scan facts, list dependency edges, read -hotspots, read rule findings, read DSM module edges, inspect architecture, -coupling, cycles, hottest files/functions, blast radius, module levels, run -what-if config simulations, and materialize memory table summaries. It can also -write visualization dashboards, emit SARIF reports, apply policy presets, -save/diff baselines, and query saved baseline tables with projection, filters, -sorting, and pagination. Agent session tools can save an in-memory baseline, -rescan, end the session, check rules, inspect evolution, inspect DSM data, -inspect test gaps, list configured language plugins, and add generic or -standard plugin profiles, remove plugin profiles, or validate local plugin -directories. It can also scaffold project-local plugin templates. - -`raysense visualize` writes a self-refreshing local HTML dashboard with file -size blocks, module graph edges, hotspots, rules, complexity, test gaps, and an -embedded telemetry JSON payload. Use `--watch` to keep regenerating the page -from fresh scans. - -Baselines are stored under `/.raysense/baseline` by default. The manifest -is JSON for fast agent diffs, and baseline tables are written under `tables/` -in Rayforce splayed-table format. +Or build from source — see [Building](#building) below. -Baseline table filters use `column:op:value`, where `op` is one of `eq`, `ne`, -`in`, `not_in`, `contains`, `starts_with`, `ends_with`, `regex`, `not_regex`, -`gt`, `gte`, `lt`, or `lte`. Filters default to AND semantics; use -`--filter-mode any` for OR. -Repeat `--sort` to apply ordered multi-column sorting. - -CLI examples: - -```sh -raysense baseline save . -raysense baseline tables --baseline .raysense/baseline -raysense baseline table files --baseline .raysense/baseline --columns path,language,lines --filter 'language:in:["c","rust"]' --sort language:asc --sort lines:desc --limit 10 -raysense baseline table files --baseline .raysense/baseline --columns path --filter 'path:regex:^src/ops/.*\.c$' --filter 'path:not_regex:query' --limit 10 -``` +## Use -MCP query example: +One command, a few flags. The default is a health report. -```json -{ - "name": "raysense_baseline_table_read", - "arguments": { - "baseline_path": ".raysense/baseline", - "table": "files", - "columns": ["path", "language", "lines"], - "filters": [ - {"column": "language", "op": "in", "value": ["c", "rust"]}, - {"column": "path", "op": "regex", "value": "^src/.*\\.(c|rs)$"} - ], - "filter_mode": "all", - "sort": [ - {"column": "language", "direction": "asc"}, - {"column": "lines", "direction": "desc"} - ], - "limit": 10 - } -} -``` - -Release checks: - -```sh -cargo package -p rayforce-sys -cargo package -p raysense-core -cargo package -p raysense-memory -cargo package -p raysense-cli -cargo package -p raysense +```bash +raysense . # health report +raysense . --json # machine-readable JSON +raysense . --check # CI gate, exits non-zero on rule failures +raysense . --watch # rescan + reprint on a 2s loop +raysense . --ui # live dashboard at http://localhost:7000 +raysense --mcp # stdio MCP server for agents ``` -Run the `publish` workflow manually with `dry_run=true` before publishing a -release. The workflow publishes packages in dependency order, waits for each -new package to appear in the registry index, and then runs a post-release -install and library smoke check. +Power-user operations live as subcommands: `baseline save|diff`, +`plugin sync`, `policy init`, `trend record|show`, `whatif`. See +`raysense --help` for the full surface. -Example config: +## What it measures -```toml -[scan] -ignored_paths = ["target", "fixtures/generated"] -generated_paths = ["**/generated/*"] -enabled_languages = [] -disabled_languages = [] -module_roots = ["crates", "src"] -test_roots = ["tests"] -public_api_paths = ["src/lib.rs"] +- **Coupling, cohesion, instability** — Robert Martin's stable-foundation + model, plus blast radius and main-sequence distance. +- **Complexity** — cyclomatic and cognitive, per function and aggregated. +- **Cycles and depth** — strongly-connected components, longest acyclic + path, upward-layer violations. +- **Evolution** — bus factor, change-coupling pairs, temporal hotspots + (churn × complexity), file age. +- **Types and inheritance** — type facts with base-class extraction + (Python and TypeScript via tree-sitter, others via line parsing). +- **Test gaps** — files without nearby tests, ranked by risk. +- **Six A–F dimensions** — modularity, acyclicity, depth, equality, + redundancy, structural uniformity. One 0–100 quality signal. -[[scan.plugins]] -name = "foo" -grammar = "rust" -grammar_path = "grammars/foo.so" -grammar_symbol = "tree_sitter_foo" -extensions = ["foo"] -file_names = ["Foofile"] -function_prefixes = ["function "] -import_prefixes = ["load "] -call_suffixes = ["("] -abstract_type_prefixes = ["interface "] -concrete_type_prefixes = ["class ", "type "] -tags_query = """ -(function_item - name: (identifier) @name) @definition.function -""" -package_index_files = ["index.foo"] -test_path_patterns = ["tests/*", "*_test.foo"] -source_roots = ["src"] -ignored_paths = ["build/*"] -local_import_prefixes = ["."] -max_function_complexity = 15 -max_cognitive_complexity = 20 -max_file_lines = 500 -max_function_lines = 80 -resolver_alias_files = ["foo.config.json"] -namespace_separator = "." -module_prefix_files = ["mod.foo"] -module_prefix_directives = ["package "] -entry_point_patterns = ["main"] -test_module_patterns = ["tests/*"] -test_attribute_patterns = ["@Test"] -parameter_node_kinds = ["parameter"] -complexity_node_kinds = ["if_statement", "while_statement"] -logical_operator_kinds = ["&&", "||"] -abstract_base_classes = ["Base"] +## Configuration -[rules] -min_quality_signal = 0 -min_modularity = 0.0 -min_acyclicity = 0.0 -min_depth = 0.0 -min_equality = 0.0 -min_redundancy = 0.0 -max_cycles = 0 -max_coupling_ratio = 1.0 -max_function_complexity = 15 -max_cognitive_complexity = 0 -max_file_lines = 0 -max_function_lines = 0 -no_god_files = true -high_file_fan_in = 50 -high_file_fan_out = 15 -large_file_lines = 500 -max_large_file_findings = 20 -low_call_resolution_min_calls = 100 -low_call_resolution_ratio = 0.5 -high_function_fan_in = 200 -high_function_fan_out = 100 -max_call_hotspot_findings = 5 -max_upward_layer_violations = 0 -no_tests_detected = true +Everything is overridable in `.raysense.toml` at the repo root: rule +thresholds, plugin language definitions, baseline scoring, what-if +ignored paths. Per-language rule overrides let one language demand +stricter caps than another. `raysense --help` lists every flag. -[[boundaries.forbidden_edges]] -from = "src" -to = "test" -reason = "runtime code must not depend on tests" +## Building from source -[[boundaries.layers]] -name = "core" -path = "src/core/*" -order = 0 +The C dependency is vendored. Clone and build — that's it: -[score] -modularity_weight = 1.0 -acyclicity_weight = 1.0 -depth_weight = 1.0 -equality_weight = 1.0 -redundancy_weight = 1.0 -structural_uniformity_weight = 0.0 +```bash +git clone https://github.com/RayforceDB/raysense.git +cd raysense +cargo build --release ``` -## Status - -The first testable version has grammar-backed support for Rust, C/C++, Python, -and TypeScript, plus a built-in generic catalog for common project languages -and formats: +No external setup, no submodules, no environment variables. -- Configurable scan filtering by ignored paths and enabled/disabled languages. -- Configurable module roots for DSM and architecture grouping. -- Generic configured language plugins by file extension with configurable - function, import, and call token extraction. -- Standard language plugin profiles can be listed through MCP or materialized - into project config with `raysense plugin add-standard`. -- Project-local plugin manifests can be loaded from - `.raysense/plugins/*/plugin.toml`. -- Built-in generic analyzers for Go, Java, Kotlin, Scala, C#, PHP, Ruby, Swift, - shell, SQL, Lua, Perl, Dart, Elixir, Haskell, OCaml, F#, Clojure, Solidity, - protobuf, GraphQL, build/config formats, and other common file types. -- Tree-sitter-backed Rust, C, C++, Python, and TypeScript function discovery - with lightweight fallback extraction. -- Tree-sitter-backed Rust `use`/`mod`, C/C++ include, Python import, and - TypeScript import extraction with lightweight fallback extraction. -- Tree-sitter-backed Rust, C, C++, Python, and TypeScript call facts with - enclosing function ids. -- Conservative call-edge resolution for unambiguous function names. -- Function-level call metrics: resolution ratio, fan-in/fan-out, and top - called/calling functions. -- Project profile inference for reusable include-root discovery. -- Entry point facts for binaries, examples, and tests. -- Local, external, system, and unresolved import classification. -- Graph metrics: resolved edges, cycles, fan-in, fan-out. -- Health summary with score, 0-10000 quality signal, root-cause scores, - import breakdown, hotspots, coupling, size, entry point, test-gap, DSM, - architecture, complexity, and evolution metrics. -- Source-aware complexity, duplicate-body grouping, and public API aware - dead-function filtering. -- Semantic-shape duplicate grouping for code that is structurally similar after - names and literals are normalized. -- Ecosystem-aware module grouping for common monorepo, Rust, Python, Java, and - Kotlin layouts. -- Test-gap candidates include expected test file paths for each unmatched - production file. -- Framework-aware test-gap naming for Rust, Python, TypeScript, Go, Java, and - .NET-style projects. -- Built-in policy presets for Rust crates, monorepos, backend services, and - libraries. -- Remediation suggestions are exposed through the CLI and MCP. -- Persisted trend samples can be recorded and read back for score/rule deltas. -- Score calibration weights can be configured for the root-cause dimensions. -- Built-in rules for high fan-in, production dependencies on test paths, - large-file/no-test findings, call-resolution/function-call hotspots, max - cycles, max coupling, max function complexity, god-file pressure, and ordered - layer constraints. -- Rule thresholds can be configured with TOML. -- Forbidden top-level module dependencies can be configured with TOML. -- Config read/write, health runs, scan facts, edges, hotspots, rule findings, - module edges, architecture, coupling, cycles, hottest files/functions, blast - radius, module levels, what-if simulations, session start/end, rescans, rule - checks, evolution, DSM, test gaps, plugin listing, remediation suggestions, - trend metrics, policy presets, memory summaries, and saved baseline table - queries are exposed through the MCP interface. -- Baseline save/diff is available through the CLI and MCP, with Rayforce - splayed-table storage for baseline tables. -- MCP session baselines are persisted by default and can be compared across - process restarts. -- CLI quality gate, watch loop, plugin management, and generated self-refreshing - local HTML architecture visualization are available. -- Rayforce table materialization for scan facts, call facts, call edges, - health summary, hotspots, rules, module edges, and changed-file evolution - metrics. +## License -CI runs on pushes and pull requests. Publish runs when a release is published -and can also be started manually. +MIT. See [LICENSE](LICENSE). diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..fb3183d --- /dev/null +++ b/build.rs @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2025-2026 Anton Kundenko + * All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +//! Compile the vendored C library directly via `cc`. No external checkout +//! required — `cargo build` works from a fresh clone with no extra steps. +//! Set `RAYFORCE_DIR` only if you want to link against an outside build for +//! development. + +use std::env; +use std::path::{Path, PathBuf}; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + + if let Some(external_dir) = env::var_os("RAYFORCE_DIR") { + link_external(PathBuf::from(external_dir)); + } else { + compile_vendored(&manifest_dir.join("vendor/rayforce")); + } + + if env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") { + println!("cargo:rustc-link-lib=m"); + println!("cargo:rustc-link-lib=pthread"); + } else { + println!("cargo:rustc-link-lib=m"); + } + + println!("cargo:rerun-if-env-changed=RAYFORCE_DIR"); +} + +/// Default path: build the vendored sources with `cc::Build`. Excludes the +/// REPL binary entry (`src/app/main.c`) since we only need the library. +fn compile_vendored(vendor_dir: &Path) { + let include_dir = vendor_dir.join("include"); + let src_dir = vendor_dir.join("src"); + let mut build = cc::Build::new(); + build + .std("c17") + .include(&include_dir) + .include(&src_dir) + .flag_if_supported("-fPIC") + .flag_if_supported("-Wno-unused-parameter") + .flag_if_supported("-Wno-unused-but-set-variable") + .flag_if_supported("-Wno-unused-variable") + .flag_if_supported("-Wno-unused-function"); + + if let Ok(profile) = env::var("PROFILE") { + if profile == "release" { + build + .opt_level(3) + .flag_if_supported("-funroll-loops") + .flag_if_supported("-fomit-frame-pointer") + .flag_if_supported("-fno-math-errno"); + } + } + + let mut count = 0usize; + for entry in walk_c_sources(&src_dir) { + if entry.ends_with(Path::new("app/main.c")) + || entry.ends_with(Path::new("app/repl.c")) + || entry.ends_with(Path::new("app/term.c")) + { + continue; + } + println!("cargo:rerun-if-changed={}", entry.display()); + build.file(&entry); + count += 1; + } + if count == 0 { + panic!( + "no C sources found under {} — vendor/ is empty?", + src_dir.display() + ); + } + println!("cargo:rerun-if-changed={}", include_dir.display()); + println!("cargo:include={}", include_dir.display()); + build.compile("rayforce"); +} + +/// Optional: link against an externally-built `librayforce.a`. Used only for +/// rayforce development; everyone else gets the vendored compile path above. +fn link_external(rayforce_dir: PathBuf) { + let include_dir = rayforce_dir.join("include"); + let lib_path = rayforce_dir.join("librayforce.a"); + if !lib_path.exists() { + panic!( + "RAYFORCE_DIR={} but {} is missing — build with `make -C {} lib`", + rayforce_dir.display(), + lib_path.display(), + rayforce_dir.display(), + ); + } + println!("cargo:include={}", include_dir.display()); + println!("cargo:rustc-link-search=native={}", rayforce_dir.display()); + println!("cargo:rustc-link-lib=static=rayforce"); + println!("cargo:rerun-if-changed={}", lib_path.display()); + println!( + "cargo:rerun-if-changed={}", + include_dir.join("rayforce.h").display() + ); +} + +/// Walk a directory tree collecting all `*.c` files. Pure-std (no walkdir +/// dep) to keep build-deps minimal. +fn walk_c_sources(root: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().and_then(|s| s.to_str()) == Some("c") { + out.push(path); + } + } + } + out.sort(); + out +} diff --git a/crates/rayforce-sys/Cargo.toml b/crates/rayforce-sys/Cargo.toml deleted file mode 100644 index 0726ddd..0000000 --- a/crates/rayforce-sys/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -# Copyright (c) 2025-2026 Anton Kundenko -# All rights reserved. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -[package] -name = "rayforce-sys" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Rust FFI bindings for Rayforce used by Raysense" -links = "rayforce" - -[build-dependencies] diff --git a/crates/rayforce-sys/build.rs b/crates/rayforce-sys/build.rs deleted file mode 100644 index a6ad1af..0000000 --- a/crates/rayforce-sys/build.rs +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (c) 2025-2026 Anton Kundenko - * All rights reserved. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -use std::env; -use std::path::PathBuf; - -fn main() { - let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); - let repo_root = manifest_dir.join("../.."); - let checkout_dir = repo_root.join("deps/rayforce"); - let sibling_dir = repo_root.join("../rayforce"); - let rayforce_dir = env::var_os("RAYFORCE_DIR").map(PathBuf::from).unwrap_or({ - if checkout_dir.exists() { - checkout_dir - } else { - sibling_dir - } - }); - - let include_dir = rayforce_dir.join("include"); - let lib_dir = rayforce_dir.clone(); - let lib_path = lib_dir.join("librayforce.a"); - - if !lib_path.exists() { - panic!( - "missing {}; build Rayforce with `make -C {} lib` or set RAYFORCE_DIR", - lib_path.display(), - rayforce_dir.display() - ); - } - - println!("cargo:include={}", include_dir.display()); - println!("cargo:rustc-link-search=native={}", lib_dir.display()); - println!("cargo:rustc-link-lib=static=rayforce"); - - if env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") { - println!("cargo:rustc-link-lib=m"); - println!("cargo:rustc-link-lib=pthread"); - } else { - println!("cargo:rustc-link-lib=m"); - } - - println!("cargo:rerun-if-env-changed=RAYFORCE_DIR"); - println!("cargo:rerun-if-changed={}", lib_path.display()); - println!( - "cargo:rerun-if-changed={}", - include_dir.join("rayforce.h").display() - ); -} diff --git a/crates/raysense-cli/Cargo.toml b/crates/raysense-cli/Cargo.toml deleted file mode 100644 index d92e2de..0000000 --- a/crates/raysense-cli/Cargo.toml +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright (c) 2025-2026 Anton Kundenko -# All rights reserved. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -[package] -name = "raysense-cli" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Command line interface for Raysense" - -[[bin]] -name = "raysense" -path = "src/main.rs" - -[dependencies] -anyhow.workspace = true -clap.workspace = true -rayforce-sys = { path = "../rayforce-sys", version = "0.1.0" } -raysense-core = { path = "../raysense-core", version = "0.1.0" } -raysense-memory = { path = "../raysense-memory", version = "0.1.0" } -serde.workspace = true -serde_json.workspace = true -toml = "1.1.2" diff --git a/crates/raysense-core/Cargo.toml b/crates/raysense-core/Cargo.toml deleted file mode 100644 index 7fe02f2..0000000 --- a/crates/raysense-core/Cargo.toml +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright (c) 2025-2026 Anton Kundenko -# All rights reserved. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -[package] -name = "raysense-core" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Core scanner and architectural fact model for Raysense" -readme = "README.md" - -[dependencies] -ignore.workspace = true -libloading.workspace = true -serde.workspace = true -serde_json.workspace = true -sha2.workspace = true -thiserror.workspace = true -tree-sitter-language.workspace = true -toml = "1.1.2" -tree-sitter = "0.26.8" -tree-sitter-c = "0.24.2" -tree-sitter-cpp = "0.23.4" -tree-sitter-python = "0.25.0" -tree-sitter-rust = "0.24.2" -tree-sitter-typescript = "0.23.2" diff --git a/crates/raysense-core/README.md b/crates/raysense-core/README.md deleted file mode 100644 index 178a018..0000000 --- a/crates/raysense-core/README.md +++ /dev/null @@ -1,32 +0,0 @@ - - -# Raysense Core - -Core scanner and architectural fact model for Raysense. - -```rust -let report = raysense_core::scan_path(".")?; -println!("imports: {}", report.imports.len()); -# Ok::<(), raysense_core::ScanError>(()) -``` diff --git a/crates/raysense-memory/Cargo.toml b/crates/raysense-memory/Cargo.toml deleted file mode 100644 index 061a06e..0000000 --- a/crates/raysense-memory/Cargo.toml +++ /dev/null @@ -1,36 +0,0 @@ -# Copyright (c) 2025-2026 Anton Kundenko -# All rights reserved. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -[package] -name = "raysense-memory" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Rayforce-backed memory tables for Raysense" - -[dependencies] -rayforce-sys = { path = "../rayforce-sys", version = "0.1.0" } -raysense-core = { path = "../raysense-core", version = "0.1.0" } -regex.workspace = true -serde.workspace = true -serde_json.workspace = true -thiserror.workspace = true diff --git a/crates/raysense/Cargo.toml b/crates/raysense/Cargo.toml deleted file mode 100644 index 5f5a6c2..0000000 --- a/crates/raysense/Cargo.toml +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright (c) 2025-2026 Anton Kundenko -# All rights reserved. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -[package] -name = "raysense" -version = "0.1.1" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Local architectural telemetry for AI coding agents" -readme = "README.md" - -[lib] -path = "src/lib.rs" - -[[bin]] -name = "raysense" -path = "src/main.rs" - -[dependencies] -anyhow.workspace = true -raysense-core = { path = "../raysense-core", version = "0.1.0" } -raysense-cli = { path = "../raysense-cli", version = "0.1.0" } diff --git a/crates/raysense/README.md b/crates/raysense/README.md deleted file mode 100644 index 0121df0..0000000 --- a/crates/raysense/README.md +++ /dev/null @@ -1,35 +0,0 @@ - - -# Raysense - -Raysense is local architectural telemetry for AI coding agents. - -The crate exposes the owned scanner and architectural fact model, and installs -the `raysense` command line tool. - -```rust -let report = raysense::scan_path(".")?; -println!("files: {}", report.files.len()); -# Ok::<(), raysense::ScanError>(()) -``` diff --git a/crates/raysense-core/src/baseline.rs b/src/baseline.rs similarity index 100% rename from crates/raysense-core/src/baseline.rs rename to src/baseline.rs diff --git a/crates/raysense-cli/src/lib.rs b/src/cli.rs similarity index 86% rename from crates/raysense-cli/src/lib.rs rename to src/cli.rs index 6522996..a89a61a 100644 --- a/crates/raysense-cli/src/lib.rs +++ b/src/cli.rs @@ -21,131 +21,105 @@ * SOFTWARE. */ -#![recursion_limit = "256"] - -use anyhow::{anyhow, Context, Result}; -use clap::{Parser, Subcommand}; -use raysense_core::{ - build_baseline, compute_health_with_config, diff_baselines, scan_path_with_config, - BaselineDiff, ImportResolution, ProjectBaseline, RaysenseConfig, -}; -use raysense_memory::{ +use crate::memory::{ BaselineFilterMode, BaselineFilterOp, BaselineSortDirection, BaselineTableFilter, BaselineTableQuery, BaselineTableSort, }; +use crate::{ + build_baseline, compute_health_with_config, diff_baselines, scan_path_with_config, + BaselineDiff, ProjectBaseline, RaysenseConfig, +}; +use anyhow::{anyhow, Context, Result}; +use clap::{Parser, Subcommand}; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; use std::fs; -use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::process; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -mod mcp; +use crate::mcp; +/// One-tool CLI: `raysense [path]` runs a health report by default. +/// Top-level flags pick a different mode (json, ui, watch, check, mcp). +/// Advanced operations (baseline / plugin / policy / trend / whatif) live as +/// subcommands so their multi-arg shapes don't pollute the simple path. #[derive(Debug, Parser)] #[command(name = "raysense")] -#[command(about = "Local architectural telemetry for AI coding agents")] +#[command(version)] +#[command(about = "Architectural X-ray for your codebase. Live, local, agent-ready.")] struct Args { + /// Path to scan. Default: current directory. + #[arg(default_value = ".")] + path: PathBuf, + + /// Emit machine-readable JSON instead of human-readable text. + #[arg(long)] + json: bool, + + /// Run the rule gate. Exits non-zero if any rule fails. + #[arg(long)] + check: bool, + + /// With `--check`: also write a SARIF code-scanning report here. + #[arg(long, value_name = "PATH")] + sarif: Option, + + /// Watch mode: rescan on a fixed interval and reprint. + #[arg(long)] + watch: bool, + + /// Start the live UI HTTP server. Optional port (default 7000). + #[arg(long, value_name = "PORT", num_args = 0..=1, default_missing_value = "7000")] + ui: Option, + + /// Re-scan interval in seconds (used by `--watch` and `--ui`). + #[arg(long, default_value_t = 2)] + interval: u64, + + /// Run as a stdio MCP server. Path is ignored. + #[arg(long)] + mcp: bool, + + /// Print the linked C library version and exit. + #[arg(long)] + rayforce_version: bool, + + /// Optional explicit `.raysense.toml` path. + #[arg(long, value_name = "FILE")] + config: Option, + #[command(subcommand)] - command: Command, + advanced: Option, } +/// Advanced subcommands. Most users never need these — the top-level flags +/// cover the common 90 %. #[derive(Debug, Subcommand)] enum Command { - Observe { - path: PathBuf, - #[arg(long)] - json: bool, - #[arg(long)] - memory: bool, - #[arg(long)] - config: Option, - }, - Health { - path: PathBuf, - #[arg(long)] - json: bool, - #[arg(long)] - config: Option, - }, - Edges { - path: PathBuf, - #[arg(long)] - all: bool, - #[arg(long)] - config: Option, - }, - RayforceVersion, - Memory { - path: PathBuf, - #[arg(long)] - config: Option, - }, - Check { - #[arg(default_value = ".")] - path: PathBuf, - #[arg(long)] - config: Option, - #[arg(long)] - json: bool, - #[arg(long)] - sarif: Option, - }, - Gate { - #[arg(default_value = ".")] - path: PathBuf, - #[arg(long)] - save: bool, - #[arg(long)] - baseline: Option, - #[arg(long)] - config: Option, - #[arg(long)] - json: bool, - }, - Watch { - #[arg(default_value = ".")] - path: PathBuf, - #[arg(long, default_value_t = 2)] - interval: u64, - #[arg(long)] - config: Option, - }, - Visualize { - #[arg(default_value = ".")] - path: PathBuf, - #[arg(long)] - output: Option, - #[arg(long)] - watch: bool, - #[arg(long, default_value_t = 2)] - interval: u64, - #[arg(long)] - config: Option, + /// Save / diff / query a baseline of the current scan. + Baseline { + #[command(subcommand)] + command: BaselineCommand, }, + /// Manage language plugins (list / add / sync / validate / scaffold). Plugin { #[command(subcommand)] command: PluginCommand, }, + /// Apply or list rule policy presets. Policy { #[command(subcommand)] command: PolicyCommand, }, + /// Record / show health-score trend snapshots. Trend { #[command(subcommand)] command: TrendCommand, }, - Remediate { - #[arg(default_value = ".")] - path: PathBuf, - #[arg(long)] - config: Option, - #[arg(long)] - json: bool, - }, - WhatIf { + /// What-if simulation: rescan with extra ignored / generated paths. + Whatif { #[arg(default_value = ".")] path: PathBuf, #[arg(long)] @@ -157,11 +131,6 @@ enum Command { #[arg(long)] json: bool, }, - Baseline { - #[command(subcommand)] - command: BaselineCommand, - }, - Mcp, } #[derive(Debug, Subcommand)] @@ -306,79 +275,47 @@ enum BaselineCommand { pub fn run() -> Result<()> { let args = Args::parse(); - match args.command { - Command::Observe { - path, - json, - memory, - config, - } => { - let config = config_for_root(&path, config.as_deref())?; - let report = scan_path_with_config(path, &config)?; - if json { - println!("{}", serde_json::to_string_pretty(&report)?); - } else if memory { - let memory = raysense_memory::RayMemory::from_report_with_config(&report, &config)?; - print_memory_summary(&memory.summary()); - } else { - print_summary(&report, &config); - } - } - Command::Health { path, json, config } => { - let config = config_for_root(&path, config.as_deref())?; - let report = scan_path_with_config(path, &config)?; - let health = compute_health_with_config(&report, &config); - if json { - println!("{}", serde_json::to_string_pretty(&health)?); - } else { - print_health(&report, &health); - } - } - Command::Edges { path, all, config } => { - let config = config_for_root(&path, config.as_deref())?; - let report = scan_path_with_config(path, &config)?; - print_edges(&report, all)?; - } - Command::RayforceVersion => { - println!("{}", rayforce_sys::version_string()); - } - Command::Memory { path, config } => { - let config = config_for_root(&path, config.as_deref())?; - let report = scan_path_with_config(path, &config)?; - let memory = raysense_memory::RayMemory::from_report_with_config(&report, &config)?; - print_memory_summary(&memory.summary()); - } - Command::Check { - path, - config, - json, - sarif, - } => { - let exit = check_project(&path, config.as_deref(), json, sarif.as_deref())?; - process::exit(exit); - } - Command::Gate { - path, - save, - baseline, - config, - json, - } => { - let exit = gate_project(&path, baseline, config.as_deref(), save, json)?; - process::exit(exit); - } - Command::Watch { - path, - interval, - config, - } => watch_project(&path, config.as_deref(), interval)?, - Command::Visualize { - path, - output, - watch, - interval, - config, - } => visualize_project(&path, output, config.as_deref(), watch, interval)?, + if let Some(command) = args.advanced { + return run_advanced(command); + } + + if args.rayforce_version { + println!("{}", crate::sys::version_string()); + return Ok(()); + } + if args.mcp { + return mcp::run(); + } + if let Some(port) = args.ui { + return serve_visualization(&args.path, args.config.as_deref(), args.interval, port); + } + if args.watch { + return watch_project(&args.path, args.config.as_deref(), args.interval); + } + if args.check { + let exit = check_project( + &args.path, + args.config.as_deref(), + args.json, + args.sarif.as_deref(), + )?; + process::exit(exit); + } + + // Default mode: health report. + let config = config_for_root(&args.path, args.config.as_deref())?; + let report = scan_path_with_config(&args.path, &config)?; + let health = compute_health_with_config(&report, &config); + if args.json { + println!("{}", serde_json::to_string_pretty(&health)?); + } else { + print_health(&report, &health); + } + Ok(()) +} + +fn run_advanced(command: Command) -> Result<()> { + match command { Command::Plugin { command } => match command { PluginCommand::List { path, config } => list_plugins(&path, config.as_deref())?, PluginCommand::Add { @@ -448,10 +385,7 @@ pub fn run() -> Result<()> { show_trend(&path, config.as_deref(), json)? } }, - Command::Remediate { path, config, json } => { - print_remediations(&path, config.as_deref(), json)? - } - Command::WhatIf { + Command::Whatif { path, config, ignore_paths, @@ -492,7 +426,7 @@ pub fn run() -> Result<()> { let baseline = baseline.unwrap_or_else(default_baseline_dir); let tables_dir = baseline.join("tables"); let tables = - raysense_memory::list_baseline_tables(&tables_dir).with_context(|| { + crate::memory::list_baseline_tables(&tables_dir).with_context(|| { format!("failed to list baseline tables {}", tables_dir.display()) })?; if json { @@ -523,7 +457,7 @@ pub fn run() -> Result<()> { filter_mode: parse_filter_mode(&filter_mode)?, sort: parse_sort(&sort, desc)?, }; - let rows = raysense_memory::query_baseline_table(&tables_dir, &table, query) + let rows = crate::memory::query_baseline_table(&tables_dir, &table, query) .with_context(|| { format!("failed to read baseline table {}", tables_dir.display()) })?; @@ -534,9 +468,6 @@ pub fn run() -> Result<()> { } } }, - Command::Mcp => { - mcp::run()?; - } } Ok(()) @@ -588,14 +519,11 @@ fn check_project( let has_errors = health .rules .iter() - .any(|rule| matches!(rule.severity, raysense_core::RuleSeverity::Error)); + .any(|rule| matches!(rule.severity, crate::RuleSeverity::Error)); Ok(if has_errors { 1 } else { 0 }) } -fn sarif_report( - report: &raysense_core::ScanReport, - health: &raysense_core::HealthSummary, -) -> Value { +pub(crate) fn sarif_report(report: &crate::ScanReport, health: &crate::HealthSummary) -> Value { let mut seen_rules = BTreeSet::new(); let rules = health .rules @@ -666,11 +594,11 @@ fn sarif_report( }) } -fn sarif_level(severity: raysense_core::RuleSeverity) -> &'static str { +fn sarif_level(severity: crate::RuleSeverity) -> &'static str { match severity { - raysense_core::RuleSeverity::Error => "error", - raysense_core::RuleSeverity::Warning => "warning", - raysense_core::RuleSeverity::Info => "note", + crate::RuleSeverity::Error => "error", + crate::RuleSeverity::Warning => "warning", + crate::RuleSeverity::Info => "note", } } @@ -684,32 +612,6 @@ fn sarif_uri(root: &Path, path: &str) -> String { } } -fn gate_project( - root: &Path, - baseline: Option, - config_path: Option<&Path>, - save: bool, - json: bool, -) -> Result { - let baseline = baseline.unwrap_or_else(|| root.join(".raysense/baseline")); - if save { - save_baseline(root, &baseline, config_path)?; - println!("baseline {}", baseline.display()); - return Ok(0); - } - let diff = diff_baseline(root, &baseline, config_path)?; - if json { - println!("{}", serde_json::to_string_pretty(&diff)?); - } else { - print_baseline_diff(&diff); - } - Ok(if diff.score_delta < 0 || !diff.added_rules.is_empty() { - 1 - } else { - 0 - }) -} - fn watch_project(root: &Path, config_path: Option<&Path>, interval: u64) -> Result<()> { let mut last_snapshot = String::new(); loop { @@ -731,41 +633,172 @@ fn watch_project(root: &Path, config_path: Option<&Path>, interval: u64) -> Resu } } -fn visualize_project( +/// Run a tokio HTTP server that hosts the live visualization. The server +/// re-scans on a fixed interval, only emits an SSE `data-changed` event when +/// the new snapshot's content hash differs from the previous one, and serves +/// the HTML page without any meta-refresh. Browsers connected to `/events` +/// reload the page on each change; other state (filter selections, scroll, +/// expanded panels) survives whenever data didn't actually change. +fn serve_visualization( root: &Path, - output: Option, config_path: Option<&Path>, - watch: bool, interval: u64, + port: u16, ) -> Result<()> { - let output = output.unwrap_or_else(|| root.join(".raysense/visualization.html")); - if let Some(parent) = output.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - loop { - let config = config_for_root(root, config_path)?; - let report = scan_path_with_config(root, &config)?; - let health = compute_health_with_config(&report, &config); - fs::write(&output, visualization_html(&report, &health)) - .with_context(|| format!("failed to write {}", output.display()))?; + let root = root.to_path_buf(); + let config_path = config_path.map(Path::to_path_buf); + let interval = interval.max(1); + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .context("failed to start tokio runtime")?; + + runtime.block_on(async move { + use axum::{ + response::sse::{Event, KeepAlive, Sse}, + response::{Html, IntoResponse}, + routing::get, + Json, Router, + }; + use std::sync::Arc; + use tokio::sync::{broadcast, RwLock}; + use tokio_stream::wrappers::BroadcastStream; + use tokio_stream::StreamExt; + + let initial = scan_now(&root, config_path.as_deref())?; + let state = Arc::new(LiveState { + inner: RwLock::new(initial), + tx: broadcast::channel::<()>(16).0, + }); + + let scanner_state = state.clone(); + let scanner_root = root.clone(); + let scanner_config = config_path.clone(); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(std::time::Duration::from_secs(interval)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + ticker.tick().await; // first tick fires immediately; we already scanned. + loop { + ticker.tick().await; + let scan = match tokio::task::spawn_blocking({ + let root = scanner_root.clone(); + let cfg = scanner_config.clone(); + move || scan_now(&root, cfg.as_deref()) + }) + .await + { + Ok(Ok(snap)) => snap, + Ok(Err(err)) => { + eprintln!("rescan failed: {err}"); + continue; + } + Err(err) => { + eprintln!("rescan task panicked: {err}"); + continue; + } + }; + let mut current = scanner_state.inner.write().await; + if current.hash != scan.hash { + *current = scan; + let _ = scanner_state.tx.send(()); + } + } + }); + + let html_state = state.clone(); + let data_state = state.clone(); + let events_state = state.clone(); + + let app = Router::new() + .route( + "/", + get(move || async move { + let snap = html_state.inner.read().await; + Html(snap.html.clone()).into_response() + }), + ) + .route( + "/data", + get(move || async move { + let snap = data_state.inner.read().await; + Json(snap.payload.clone()).into_response() + }), + ) + .route( + "/events", + get(move || async move { + let rx = events_state.tx.subscribe(); + let stream = BroadcastStream::new(rx).map(|item| match item { + Ok(()) => Ok(Event::default().event("data-changed")), + Err(_) => Ok::<_, std::convert::Infallible>( + Event::default().event("data-changed"), + ), + }); + Sse::new(stream).keep_alive(KeepAlive::default()) + }), + ); + + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + let listener = tokio::net::TcpListener::bind(addr) + .await + .with_context(|| format!("failed to bind {addr}"))?; println!( - "visualization {} snapshot={} quality_signal={}", - output.display(), - report.snapshot.snapshot_id, - health.quality_signal + "visualization http://{addr} interval={interval}s — Ctrl+C to stop", + addr = addr, + interval = interval, ); - if !watch { - break; - } - thread::sleep(Duration::from_secs(interval.max(1))); - } - Ok(()) + + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = tokio::signal::ctrl_c().await; + }) + .await + .context("server error")?; + + Ok::<(), anyhow::Error>(()) + }) +} + +struct LiveState { + inner: tokio::sync::RwLock, + tx: tokio::sync::broadcast::Sender<()>, +} + +struct LiveSnapshot { + hash: String, + html: String, + payload: serde_json::Value, +} + +fn scan_now(root: &Path, config_path: Option<&Path>) -> Result { + use sha2::{Digest, Sha256}; + let config = config_for_root(root, config_path)?; + let report = scan_path_with_config(root, &config)?; + let health = compute_health_with_config(&report, &config); + let html = visualization_html(&report, &health); + let payload = serde_json::json!({ + "snapshot_id": report.snapshot.snapshot_id, + "score": health.score, + "quality_signal": health.quality_signal, + "files": report.files.len(), + "functions": report.functions.len(), + "rules": health.rules.len(), + }); + let mut hasher = Sha256::new(); + hasher.update(report.snapshot.snapshot_id.as_bytes()); + hasher.update(serde_json::to_vec(&payload).unwrap_or_default()); + let hash = format!("{:x}", hasher.finalize()); + Ok(LiveSnapshot { + hash, + html, + payload, + }) } -fn visualization_html( - report: &raysense_core::ScanReport, - health: &raysense_core::HealthSummary, +pub(crate) fn visualization_html( + report: &crate::ScanReport, + health: &crate::HealthSummary, ) -> String { let max_lines = report .files @@ -1089,7 +1122,7 @@ fn visualization_html( .unwrap_or_else(|_| "{}".to_string()); format!( r#" -Raysense +Raysense