From 83f0fe20d73477e15476ce7bca57947cd48ec0c4 Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Fri, 5 Jun 2026 11:59:37 -0300 Subject: [PATCH 1/2] fix review findings and streamline updates --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 16 ++ README.md | 9 +- crates/splus-engine/Cargo.toml | 2 - .../splus-engine/src/collectors/external.rs | 175 ++++++++++++++---- docs/ARCHITECTURE.md | 6 +- install.sh | 137 +++++++++++--- packages/triage/src/index.test.ts | 8 + packages/triage/src/index.ts | 5 + scripts/test-install.sh | 53 ++++++ 10 files changed, 350 insertions(+), 63 deletions(-) create mode 100755 scripts/test-install.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2df5a9..081b9bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,8 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: cargo test run: cargo test --locked + - name: installer smoke tests + run: sh scripts/test-install.sh packages: name: TS packages · build · typecheck · test · bundle diff --git a/CHANGELOG.md b/CHANGELOG.md index 7479a44..f428d03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ this project uses [semantic versioning](https://semver.org) (pre-1.0: minor vers ## [Unreleased] +### Fixed +- **Installer supply-chain verification.** Optional gitleaks and osv-scanner + downloads are now verified against their upstream release SHA-256 manifests + before installation; verification failure skips the adapter instead of + installing untrusted bytes. +- **OSV change attribution.** Lockfile findings are diffed against the base + lockfile by advisory, ecosystem, package, and version, so an unrelated + lockfile edit no longer labels pre-existing vulnerabilities as introduced. +- **Triage summary consistency.** The optional triage API now recomputes its + canonical finding and tier counts from the final kept set. + +### Changed +- **Quieter updates.** Existing installations use compact update output, preserve + agent wiring by default, omit first-run onboarding, and install a lightweight + `splus update` / `splus version` command for future upgrades. + ## [0.9.0] — agent-led: the engine on tap Splus flips from engine-*led* (push a finding list through a gate) to agent-*led* diff --git a/README.md b/README.md index d978a03..56e3e6f 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,14 @@ agent it finds (Claude Code, Codex, OpenCode). Then, in your agent: > "review my staged changes with splus" -Requirements: **git** and **node ≥ 20**. Update anytime by re-running the one-liner. +Requirements: **git** and **node ≥ 20**. Update anytime with: + +```sh +splus update +``` + +Updates preserve existing agent wiring and use compact output. Re-run the install +one-liner if upgrading from a release that predates the `splus` update command.
Wire an agent manually diff --git a/crates/splus-engine/Cargo.toml b/crates/splus-engine/Cargo.toml index 8249fdb..77f6f41 100644 --- a/crates/splus-engine/Cargo.toml +++ b/crates/splus-engine/Cargo.toml @@ -43,6 +43,4 @@ tree-sitter-bash = "0.25" # SCIP precise tier: decode index.scip (Sourcegraph Code Intelligence Protocol). # We hand-declare the minimal message subset, so no protoc/build.rs is needed. prost = "0.13" - -[dev-dependencies] tempfile = "3" diff --git a/crates/splus-engine/src/collectors/external.rs b/crates/splus-engine/src/collectors/external.rs index f928b3c..4c88454 100644 --- a/crates/splus-engine/src/collectors/external.rs +++ b/crates/splus-engine/src/collectors/external.rs @@ -10,9 +10,11 @@ use super::{Collector, ReviewContext}; use crate::model::{Anchor, AnchorKind, Category, Finding, Region, Severity}; use serde_json::Value; -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashSet}; +use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; +use tempfile::tempdir; /// Optional adapters, in priority order. pub const ADAPTERS: &[&str] = &["semgrep", "ast-grep", "gitleaks", "osv-scanner"]; @@ -293,7 +295,8 @@ fn run_gitleaks(ctx: &ReviewContext) -> Vec { // --- osv-scanner (JSON) ---------------------------------------------------- fn run_osv(ctx: &ReviewContext) -> Vec { - // Scan only changed lockfiles for newly-relevant known vulns. + // A changed lockfile is only the trigger. Diff scanner results against the + // base lockfile so long-standing vulnerabilities are not called introduced. let lockfiles: Vec<&str> = ctx .files .iter() @@ -303,46 +306,107 @@ fn run_osv(ctx: &ReviewContext) -> Vec { let mut findings = Vec::new(); for lf in lockfiles { let abs = ctx.root.join(lf); - let Ok(out) = Command::new("osv-scanner") - .args(["--format", "json", "--lockfile"]) - .arg(&abs) - .output() - else { + let Some(head) = scan_osv_lockfile(&abs) else { continue; }; - let Ok(doc): Result = serde_json::from_slice(&out.stdout) else { - continue; - }; - for result in doc.get("results").and_then(|r| r.as_array()).into_iter().flatten() { - for pkg in result.get("packages").and_then(|p| p.as_array()).into_iter().flatten() { - let name = pkg - .get("package") - .and_then(|p| p.get("name")) - .and_then(|v| v.as_str()) - .unwrap_or("dependency"); - for vuln in pkg.get("vulnerabilities").and_then(|v| v.as_array()).into_iter().flatten() { - let id = vuln.get("id").and_then(|v| v.as_str()).unwrap_or("VULN"); - let summary = vuln.get("summary").and_then(|v| v.as_str()).unwrap_or("Known vulnerability"); - findings.push(Finding::new( - &format!("supplychain.{id}"), - Category::Supplychain, - Severity::High, - lf, - Region::line(1), - &format!("Vulnerable dependency: {name}"), - &format!("{id}: {summary} (introduced/updated in this change to {lf})"), - Anchor { kind: AnchorKind::Vuln, detail: format!("osv:{id}") }, - 0.85, - "osv-scanner", - &format!("{lf}:{id}:{name}"), - )); - } + let base_keys: HashSet = match ctx.base_content(lf) { + Some(content) => { + let Some(base) = scan_osv_content(&content, lf) else { + continue; + }; + base.into_iter().map(|v| v.key).collect() } + None => HashSet::new(), + }; + + for vuln in only_new_osv(head, &base_keys) { + findings.push(Finding::new( + &format!("supplychain.{}", vuln.key.id), + Category::Supplychain, + Severity::High, + lf, + Region::line(1), + &format!("Vulnerable dependency: {}", vuln.key.name), + &format!( + "{}: {} (introduced/updated {}@{} in this change to {lf})", + vuln.key.id, vuln.summary, vuln.key.name, vuln.key.version + ), + Anchor { kind: AnchorKind::Vuln, detail: format!("osv:{}", vuln.key.id) }, + 0.85, + "osv-scanner", + &format!( + "{lf}:{}:{}:{}:{}", + vuln.key.id, vuln.key.ecosystem, vuln.key.name, vuln.key.version + ), + )); } } findings } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct OsvKey { + id: String, + ecosystem: String, + name: String, + version: String, +} + +#[derive(Debug, Clone)] +struct OsvVulnerability { + key: OsvKey, + summary: String, +} + +fn scan_osv_content(content: &str, original_path: &str) -> Option> { + let dir = tempdir().ok()?; + let name = Path::new(original_path).file_name()?; + let path = dir.path().join(name); + let mut file = std::fs::File::create(&path).ok()?; + file.write_all(content.as_bytes()).ok()?; + scan_osv_lockfile(&path) +} + +fn scan_osv_lockfile(path: &Path) -> Option> { + let out = Command::new("osv-scanner") + .args(["--format", "json", "--lockfile"]) + .arg(path) + .output() + .ok()?; + parse_osv(&out.stdout) +} + +fn parse_osv(bytes: &[u8]) -> Option> { + let doc: Value = serde_json::from_slice(bytes).ok()?; + let mut out = Vec::new(); + for result in doc.get("results").and_then(|r| r.as_array()).into_iter().flatten() { + for pkg in result.get("packages").and_then(|p| p.as_array()).into_iter().flatten() { + let package = pkg.get("package"); + let name = package.and_then(|p| p.get("name")).and_then(|v| v.as_str()).unwrap_or("dependency"); + let ecosystem = package.and_then(|p| p.get("ecosystem")).and_then(|v| v.as_str()).unwrap_or("unknown"); + let version = package.and_then(|p| p.get("version")).and_then(|v| v.as_str()).unwrap_or("unknown"); + for vuln in pkg.get("vulnerabilities").and_then(|v| v.as_array()).into_iter().flatten() { + let id = vuln.get("id").and_then(|v| v.as_str()).unwrap_or("VULN"); + let summary = vuln.get("summary").and_then(|v| v.as_str()).unwrap_or("Known vulnerability"); + out.push(OsvVulnerability { + key: OsvKey { + id: id.to_string(), + ecosystem: ecosystem.to_string(), + name: name.to_string(), + version: version.to_string(), + }, + summary: summary.to_string(), + }); + } + } + } + Some(out) +} + +fn only_new_osv(head: Vec, base: &HashSet) -> Vec { + head.into_iter().filter(|v| !base.contains(&v.key)).collect() +} + fn is_lockfile(path: &str) -> bool { let base = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase(); matches!( @@ -358,3 +422,46 @@ fn is_lockfile(path: &str) -> bool { | "requirements.txt" ) } + +#[cfg(test)] +mod tests { + use super::*; + + fn vuln(id: &str, name: &str, version: &str) -> OsvVulnerability { + OsvVulnerability { + key: OsvKey { + id: id.into(), + ecosystem: "npm".into(), + name: name.into(), + version: version.into(), + }, + summary: "advisory".into(), + } + } + + #[test] + fn parses_osv_results_with_package_identity() { + let json = br#"{"results":[{"packages":[{ + "package":{"name":"esbuild","version":"0.24.2","ecosystem":"npm"}, + "vulnerabilities":[{"id":"GHSA-test","summary":"test advisory"}] + }]}]}"#; + let parsed = parse_osv(json).expect("valid osv json"); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].key, vuln("GHSA-test", "esbuild", "0.24.2").key); + } + + #[test] + fn keeps_only_vulnerability_tuples_absent_from_base() { + let existing = vuln("GHSA-old", "pkg", "1.0.0"); + let introduced = vuln("GHSA-new", "other", "2.0.0"); + let base = HashSet::from([existing.key.clone()]); + let kept = only_new_osv(vec![existing, introduced.clone()], &base); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].key, introduced.key); + } + + #[test] + fn package_version_is_part_of_osv_identity() { + assert_ne!(vuln("GHSA-test", "pkg", "1.0.0").key, vuln("GHSA-test", "pkg", "1.1.0").key); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0e42356..3329368 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -146,8 +146,10 @@ flowchart LR ## Distribution `install.sh` downloads the engine + the bundled MCP server (`dist-release/mcp.cjs`) -into `~/.splus`, provisions the gitleaks/osv-scanner adapters, and wires the MCP -server into every coding agent it finds. Releases are cut by tagging `v*` +into `~/.splus`, verifies the optional gitleaks/osv-scanner adapters against their +upstream SHA-256 manifests, and wires the MCP server into every coding agent it +finds. Existing installs enter compact update mode and preserve agent wiring unless +`SPLUS_REWIRE=1` is set. Releases are cut by tagging `v*` (`.github/workflows/release.yml`). ## Measuring quality diff --git a/install.sh b/install.sh index ab635a4..7c3e3ac 100755 --- a/install.sh +++ b/install.sh @@ -16,6 +16,8 @@ # SPLUS_NO_MODIFY_PATH=1 don't touch shell rc files # SPLUS_NO_WIRE=1 don't auto-wire coding agents # SPLUS_NO_ADAPTERS=1 don't download the optional gitleaks/osv-scanner adapters +# SPLUS_UPDATE=1 force compact update mode (auto-detected for existing installs) +# SPLUS_REWIRE=1 re-wire coding agents during an update set -eu REPO="kiwi-init/splus" @@ -23,13 +25,38 @@ INSTALL_DIR="${SPLUS_INSTALL_DIR:-$HOME/.splus}" BIN_DIR="$INSTALL_DIR/bin" LIB_DIR="$INSTALL_DIR/lib" MCP_BIN="$BIN_DIR/splus-mcp" +previous_version="" +[ -f "$INSTALL_DIR/version" ] && previous_version=$(cat "$INSTALL_DIR/version" 2>/dev/null || true) +updating=0 +if [ -n "${SPLUS_UPDATE:-}" ] || [ -x "$BIN_DIR/splus-engine" ] || [ -f "$LIB_DIR/mcp.cjs" ]; then + updating=1 +fi c_b='\033[1m'; c_dim='\033[2m'; c_grn='\033[32m'; c_red='\033[31m'; c_0='\033[0m' say() { printf '%b\n' "${c_dim}splus${c_0} $*"; } +detail() { [ "$updating" -eq 1 ] || say "$@"; } ok() { printf '%b\n' " ${c_grn}✓${c_0} $*"; } warn() { printf '%b\n' " ${c_red}!${c_0} $*"; } die() { printf '%b\n' "${c_red}splus: $*${c_0}" >&2; exit 1; } +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' + else return 1 + fi +} + +verify_manifest() { + file=$1 manifest=$2 name=$3 + if [ -n "${SPLUS_INSECURE:-}" ]; then + warn "SPLUS_INSECURE=1 — skipping checksum verification for $name" + return 0 + fi + sum=$(sha256_file "$file") || return 1 + want=$(awk -v a="$name" '{ n=$2; sub(/^\*/, "", n); if (n == a) { print $1; exit } }' "$manifest") + [ -n "$want" ] && [ "$sum" = "$want" ] +} + # --- preflight ------------------------------------------------------------- command -v git >/dev/null 2>&1 || die "git is required but not found. Install git and re-run." command -v node >/dev/null 2>&1 || die "node is required but not found. Install Node.js (>=20) and re-run." @@ -48,14 +75,23 @@ case "$arch" in esac asset="splus-${os}-${arch}.tar.gz" -say "installing for ${c_b}${os}-${arch}${c_0} → ${INSTALL_DIR}" +if [ "$updating" -eq 1 ]; then + from=${previous_version:-installed} + if [ "$from" = "latest" ]; then + say "updating on ${os}-${arch}" + else + say "updating ${c_b}${from}${c_0} on ${os}-${arch}" + fi +else + say "installing for ${c_b}${os}-${arch}${c_0} → ${INSTALL_DIR}" +fi mkdir -p "$BIN_DIR" "$LIB_DIR" tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT # --- fetch artifacts ------------------------------------------------------- if [ -n "${SPLUS_LOCAL_DIST:-}" ]; then - say "using local dist: $SPLUS_LOCAL_DIST" + detail "using local dist: $SPLUS_LOCAL_DIST" cp "$SPLUS_LOCAL_DIST/splus-engine" "$tmp/" || die "missing splus-engine in SPLUS_LOCAL_DIST" cp "$SPLUS_LOCAL_DIST/mcp.cjs" "$tmp/" || die "missing mcp.cjs in SPLUS_LOCAL_DIST" version="local" @@ -69,7 +105,7 @@ else else base="https://github.com/$REPO/releases/download/$ver"; fi version="$ver" - say "downloading $asset ($ver)" + detail "downloading $asset ($ver)" dl "$base/$asset" "$tmp/$asset" || die "download failed: $base/$asset (no release for ${os}-${arch}?)" # Verify integrity. For a real release this is MANDATORY — never install an @@ -80,12 +116,8 @@ else else dl "$base/SHA256SUMS" "$tmp/SHA256SUMS" \ || die "could not fetch SHA256SUMS for $ver — refusing to install unverified (SPLUS_INSECURE=1 to override)" - if command -v sha256sum >/dev/null 2>&1; then sum=$(sha256sum "$tmp/$asset" | awk '{print $1}') - elif command -v shasum >/dev/null 2>&1; then sum=$(shasum -a 256 "$tmp/$asset" | awk '{print $1}') - else die "no sha256sum/shasum found to verify the download (SPLUS_INSECURE=1 to override)"; fi - want=$(awk -v a="$asset" '$2 == a { print $1 }' "$tmp/SHA256SUMS") - [ -n "$want" ] || die "no checksum listed for $asset — refusing to install unverified" - [ "$sum" = "$want" ] || die "checksum mismatch for $asset (expected ${want}, got ${sum})" + verify_manifest "$tmp/$asset" "$tmp/SHA256SUMS" "$asset" \ + || die "checksum mismatch or missing checksum for $asset" ok "checksum verified" fi tar -xzf "$tmp/$asset" -C "$tmp" || die "extract failed" @@ -101,8 +133,45 @@ export SPLUS_ENGINE="\${SPLUS_ENGINE:-$BIN_DIR/splus-engine}" exec node "$LIB_DIR/mcp.cjs" "\$@" EOF chmod 0755 "$BIN_DIR/splus-mcp" + +# Keep a tiny update command even though the old full CLI was retired in v0.9. +# It enters compact update mode directly, avoiding the old URL preamble and +# first-install onboarding on every upgrade. +cat > "$BIN_DIR/splus" </dev/null 2>&1; then + curl -fsSL https://splus.sh/install.sh -o "\$tmp" + elif command -v wget >/dev/null 2>&1; then + wget -qO "\$tmp" https://splus.sh/install.sh + else + echo "splus: curl or wget is required to update" >&2 + exit 1 + fi + sh "\$tmp" + ;; + --version|version) + cat "\${SPLUS_INSTALL_DIR:-$INSTALL_DIR}/version" + ;; + *) + echo "usage: splus update | splus version" >&2 + exit 2 + ;; +esac +EOF +chmod 0755 "$BIN_DIR/splus" printf '%s\n' "$version" > "$INSTALL_DIR/version" -ok "installed splus-mcp, splus-engine → $BIN_DIR" +if [ "$updating" -eq 1 ]; then + ok "core updated" +else + ok "installed splus, splus-mcp, splus-engine → $BIN_DIR" +fi # --- optional: provision external adapters (best-effort) ------------------- # The engine ships native, local detectors (secrets + injection/deser/TLS sinks). @@ -114,28 +183,44 @@ ok "installed splus-mcp, splus-engine → $BIN_DIR" if [ -z "${SPLUS_NO_ADAPTERS:-}" ] && { command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1; }; then if command -v curl >/dev/null 2>&1; then afetch() { curl -fsSL "$1" -o "$2"; } else afetch() { wget -qO "$2" "$1"; }; fi - say "provisioning adapters (skip with SPLUS_NO_ADAPTERS=1)" + detail "provisioning adapters (skip with SPLUS_NO_ADAPTERS=1)" + adapters_installed="" # gitleaks — diff-scoped secret scanning (asset arch matches ours: arm64/x64). gl_ver="8.30.1" + gl_asset="gitleaks_${gl_ver}_${os}_${arch}.tar.gz" atmp=$(mktemp -d) - if afetch "https://github.com/gitleaks/gitleaks/releases/download/v${gl_ver}/gitleaks_${gl_ver}_${os}_${arch}.tar.gz" "$atmp/gl.tgz" 2>/dev/null \ - && tar -xzf "$atmp/gl.tgz" -C "$atmp" gitleaks 2>/dev/null; then - install -m 0755 "$atmp/gitleaks" "$BIN_DIR/gitleaks" && ok "adapter: gitleaks (secrets)" + gl_base="https://github.com/gitleaks/gitleaks/releases/download/v${gl_ver}" + if afetch "$gl_base/$gl_asset" "$atmp/$gl_asset" 2>/dev/null \ + && afetch "$gl_base/gitleaks_${gl_ver}_checksums.txt" "$atmp/checksums.txt" 2>/dev/null \ + && verify_manifest "$atmp/$gl_asset" "$atmp/checksums.txt" "$gl_asset" \ + && tar -xzf "$atmp/$gl_asset" -C "$atmp" gitleaks 2>/dev/null \ + && install -m 0755 "$atmp/gitleaks" "$BIN_DIR/gitleaks"; then + adapters_installed="gitleaks" else - warn "adapter: gitleaks not installed for ${os}-${arch} (engine's native secret rules still apply)" + rm -f "$BIN_DIR/gitleaks" + warn "gitleaks adapter skipped (download or checksum verification failed)" fi - rm -rf "$atmp" + rm -rf "${atmp:?}" # osv-scanner — dependency CVEs (osv uses amd64; we call x64 → map it). osv_arch="$arch"; [ "$arch" = "x64" ] && osv_arch="amd64" - if afetch "https://github.com/google/osv-scanner/releases/download/v2.3.8/osv-scanner_${os}_${osv_arch}" "$BIN_DIR/osv-scanner" 2>/dev/null \ - && [ -s "$BIN_DIR/osv-scanner" ]; then - chmod 0755 "$BIN_DIR/osv-scanner" && ok "adapter: osv-scanner (dependency CVEs)" + osv_ver="2.3.8" + osv_asset="osv-scanner_${os}_${osv_arch}" + atmp=$(mktemp -d) + osv_base="https://github.com/google/osv-scanner/releases/download/v${osv_ver}" + if afetch "$osv_base/$osv_asset" "$atmp/$osv_asset" 2>/dev/null \ + && afetch "$osv_base/osv-scanner_SHA256SUMS" "$atmp/checksums.txt" 2>/dev/null \ + && verify_manifest "$atmp/$osv_asset" "$atmp/checksums.txt" "$osv_asset" \ + && install -m 0755 "$atmp/$osv_asset" "$BIN_DIR/osv-scanner"; then + if [ -n "$adapters_installed" ]; then adapters_installed="$adapters_installed, osv-scanner" + else adapters_installed="osv-scanner"; fi else rm -f "$BIN_DIR/osv-scanner" - warn "adapter: osv-scanner not installed (dependency-CVE checks unavailable)" + warn "osv-scanner adapter skipped (download or checksum verification failed)" fi + rm -rf "${atmp:?}" + [ -z "$adapters_installed" ] || ok "adapters verified: $adapters_installed" fi # --- PATH ------------------------------------------------------------------ @@ -157,7 +242,7 @@ esac # --- wire coding agents ---------------------------------------------------- wired=0 -if [ -z "${SPLUS_NO_WIRE:-}" ]; then +if [ -z "${SPLUS_NO_WIRE:-}" ] && { [ "$updating" -eq 0 ] || [ -n "${SPLUS_REWIRE:-}" ]; }; then say "wiring coding agents" # Claude Code — use its CLI (idempotent: remove then add). @@ -217,6 +302,10 @@ EOF fi # --- done ------------------------------------------------------------------ -printf '\n%b\n' "${c_grn}${c_b}Splus is installed.${c_0}" -printf '%b\n' " ${c_dim}then, in your agent:${c_0} \"review my staged changes with splus\"" -printf '%b\n' " ${c_dim}update:${c_0} re-run curl -fsSL https://splus.sh/install.sh | sh" +if [ "$updating" -eq 1 ]; then + printf '\n%b\n' "${c_grn}${c_b}Splus is up to date.${c_0}" +else + printf '\n%b\n' "${c_grn}${c_b}Splus is installed.${c_0}" + printf '%b\n' " ${c_dim}then, in your agent:${c_0} \"review my staged changes with splus\"" + printf '%b\n' " ${c_dim}update:${c_0} splus update" +fi diff --git a/packages/triage/src/index.test.ts b/packages/triage/src/index.test.ts index 4823294..602acca 100644 --- a/packages/triage/src/index.test.ts +++ b/packages/triage/src/index.test.ts @@ -86,6 +86,10 @@ test("splits keep/suppress and fails open on missing verdicts", async () => { assert.match(f3?.rationale ?? "", /no LLM verdict/); assert.equal(out.suppressed[0]?.id, "f2"); + assert.equal(out.summary.findings_total, 2); + assert.equal(out.summary.must_fix, 2); + assert.equal(out.summary.concern, 0); + assert.equal(out.summary.nit, 0); assert.equal(out.summary.suppressed, 1); assert.equal(out.llm.triaged, 3); assert.equal(out.llm.inputTokens, 100); @@ -242,6 +246,10 @@ test("signal budget caps low/medium discoveries per file to the most-confident f assert.equal(out.llm.discovered, 5, "five discovered"); assert.equal(out.llm.budgeted, 2, "two demoted by the per-file budget"); assert.equal(out.findings.length, 3, "only the 3 most-confident surface"); + assert.equal(out.summary.findings_total, 3, "summary reflects the final kept set"); + assert.equal(out.summary.must_fix, 0); + assert.equal(out.summary.concern, 0); + assert.equal(out.summary.nit, 3); const surfaced = out.findings.map((f) => f.title).sort(); assert.deepEqual(surfaced, ["nit-1", "nit-3", "nit-5"], "kept the top-3 by confidence (0.9/0.8/0.7)"); const demoted = out.suppressed.filter((f) => /signal budget/.test(f.rationale ?? "")); diff --git a/packages/triage/src/index.ts b/packages/triage/src/index.ts index 52dc7f3..3bb8678 100644 --- a/packages/triage/src/index.ts +++ b/packages/triage/src/index.ts @@ -353,12 +353,17 @@ export async function triage(report: Report, opts: TriageOptions): Promise severityRank(b.severity) - severityRank(a.severity) || b.llmConfidence - a.llmConfidence); + const countTier = (tier: Finding["tier"]) => kept.filter((f) => f.tier === tier).length; return { tool: report.tool, version: report.version, summary: { ...report.summary, + findings_total: kept.length, + must_fix: countTier("must-fix"), + concern: countTier("concern"), + nit: countTier("nit"), suppressed: suppressed.length, notes: [...report.summary.notes, ...extraNotes], }, diff --git a/scripts/test-install.sh b/scripts/test-install.sh new file mode 100755 index 0000000..1adabf9 --- /dev/null +++ b/scripts/test-install.sh @@ -0,0 +1,53 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +HOME_DIR="$TMP/home" +DIST="$TMP/dist" +mkdir -p "$HOME_DIR" "$DIST" +printf '#!/bin/sh\nexit 0\n' > "$DIST/splus-engine" +printf '#!/usr/bin/env node\n' > "$DIST/mcp.cjs" +chmod +x "$DIST/splus-engine" + +fresh=$( + HOME="$HOME_DIR" \ + SPLUS_LOCAL_DIST="$DIST" \ + SPLUS_NO_ADAPTERS=1 \ + SPLUS_NO_MODIFY_PATH=1 \ + SPLUS_NO_WIRE=1 \ + sh "$ROOT/install.sh" +) +printf '%s\n' "$fresh" | grep -q "Splus is installed." +printf '%s\n' "$fresh" | grep -q "then, in your agent:" +printf '%s\n' "$fresh" | grep -q "update:.*splus update" + +updated=$( + HOME="$HOME_DIR" \ + SPLUS_LOCAL_DIST="$DIST" \ + SPLUS_NO_ADAPTERS=1 \ + SPLUS_NO_MODIFY_PATH=1 \ + sh "$ROOT/install.sh" +) +printf '%s\n' "$updated" | grep -q "core updated" +printf '%s\n' "$updated" | grep -q "Splus is up to date." +if printf '%s\n' "$updated" | grep -q "wiring coding agents"; then + echo "update unexpectedly rewired coding agents" >&2 + exit 1 +fi +if printf '%s\n' "$updated" | grep -q "then, in your agent:"; then + echo "update unexpectedly printed first-install onboarding" >&2 + exit 1 +fi + +[ "$("$HOME_DIR/.splus/bin/splus" version)" = "local" ] +grep -q "SPLUS_UPDATE=1" "$HOME_DIR/.splus/bin/splus" +grep -q "SPLUS_INSTALL_DIR=" "$HOME_DIR/.splus/bin/splus" +if grep -q '| sh' "$HOME_DIR/.splus/bin/splus"; then + echo "update wrapper unexpectedly streams remote code into a shell" >&2 + exit 1 +fi + +echo "installer smoke tests passed" From 36851187e2fe200d633c0c738f7ca6084b528025 Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Fri, 5 Jun 2026 12:02:35 -0300 Subject: [PATCH 2/2] chore: bump all versions to 0.9.1 --- CHANGELOG.md | 2 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- package.json | 2 +- packages/mcp/package.json | 2 +- packages/shared/package.json | 2 +- packages/suppression/package.json | 2 +- packages/triage/package.json | 2 +- 8 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f428d03..7a151bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ this project uses [semantic versioning](https://semver.org) (pre-1.0: minor vers ## [Unreleased] +## [0.9.1] — 2026-06-05 + ### Fixed - **Installer supply-chain verification.** Optional gitleaks and osv-scanner downloads are now verified against their upstream release SHA-256 manifests diff --git a/Cargo.lock b/Cargo.lock index 432e923..1fd1f6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -453,7 +453,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "splus-engine" -version = "0.9.0" +version = "0.9.1" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 9e9d03c..0895f0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ resolver = "2" members = ["crates/splus-engine"] [workspace.package] -version = "0.9.0" +version = "0.9.1" edition = "2021" license = "MIT" repository = "https://github.com/kiwi-init/splus" diff --git a/package.json b/package.json index 77a11d8..401130f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "splus", - "version": "0.9.0", + "version": "0.9.1", "private": true, "description": "Splus — the precision-first, local-first code reviewer. A deterministic Rust engine your coding agent (Claude Code, Codex, OpenCode) calls over MCP. Open source, runs entirely on your machine.", "license": "MIT", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index bdca52d..7df7b3d 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@splus/mcp", - "version": "0.9.0", + "version": "0.9.1", "type": "module", "description": "Splus MCP server (local) — a stdio MCP server your coding agent (Claude Code, Codex, OpenCode) connects to. Runs the deterministic review engine on your local checkout and applies this repo's learned suppressions. No account, no token, nothing leaves your machine.", "bin": { diff --git a/packages/shared/package.json b/packages/shared/package.json index 1244d4e..d6bd3ff 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@splus/shared", - "version": "0.1.0", + "version": "0.9.1", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/suppression/package.json b/packages/suppression/package.json index ada379f..690feb9 100644 --- a/packages/suppression/package.json +++ b/packages/suppression/package.json @@ -1,6 +1,6 @@ { "name": "@splus/suppression", - "version": "0.1.0", + "version": "0.9.1", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/triage/package.json b/packages/triage/package.json index 3042d5a..0d52cdb 100644 --- a/packages/triage/package.json +++ b/packages/triage/package.json @@ -1,6 +1,6 @@ { "name": "@splus/triage", - "version": "0.1.0", + "version": "0.9.1", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts",