From 7b5d6c1550bed0ed08d6c13fe5a1ddce708e7c69 Mon Sep 17 00:00:00 2001 From: RodCor Date: Sat, 30 May 2026 10:54:48 -0300 Subject: [PATCH 1/2] ci: add PR/push quality+security gate, pre-commit hooks, dev workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the automated checks that keep main + develop clean and secure, plus contributor tooling and docs. CI (.github/workflows/ci.yml) — runs on every PR and push to main/develop: - fmt cargo fmt --all --check (blocking) - clippy cargo clippy --workspace (advisory*) - test cargo test --workspace (blocking; ubuntu + macOS) - audit cargo-audit RUSTSEC scan (blocking) - deny cargo-deny licenses + bans (advisory*) Local pre-commit (.githooks/pre-commit; opt in via scripts/install-hooks.sh): fmt --check (blocking) + clippy (advisory) on staged Rust changes. * clippy + cargo-deny start advisory (continue-on-error) because main carries a small pre-existing lint backlog; both run and surface findings on every PR. Flip to hard gates once a cleanup PR clears the backlog. See CONTRIBUTING.md. Supporting changes: - deny.toml: license allow-list + source policy. - CONTRIBUTING.md: branch model (feature -> develop -> main -> tag), setup, and the checks table. - .gitignore: ignore /bench/ (internal kimetsu-bench repo, never public). - cargo fmt --all normalization of files left unformatted by an earlier merge, so the fmt gate is green from day one. - redact.rs test: split the synthetic Slack token literal so GitHub secret scanning doesn't flag the fixture (detector still exercised via concat! at runtime; test passes). Co-Authored-By: Claude Opus 4.8 --- .githooks/pre-commit | 48 +++++++++++ .github/workflows/ci.yml | 105 ++++++++++++++++++++++++ CONTRIBUTING.md | 76 +++++++++++++++++ crates/kimetsu-agent/src/harness.rs | 8 +- crates/kimetsu-brain/src/ambient.rs | 16 ++-- crates/kimetsu-brain/src/conflict.rs | 71 ++++++++++++++-- crates/kimetsu-brain/src/context.rs | 66 +++++++-------- crates/kimetsu-brain/src/embeddings.rs | 24 ++---- crates/kimetsu-brain/src/project.rs | 56 ++++--------- crates/kimetsu-brain/src/redact.rs | 31 ++++--- crates/kimetsu-brain/src/reindex.rs | 11 ++- crates/kimetsu-brain/src/user_brain.rs | 14 ++-- crates/kimetsu-chat/src/mcp_server.rs | 17 ++-- crates/kimetsu-cli/src/doctor.rs | 12 +-- crates/kimetsu-cli/src/main.rs | 102 ++++++++++++++++------- crates/kimetsu-core/src/secret.rs | 5 +- crates/kimetsu-e2e/src/assertions.rs | 6 +- crates/kimetsu-e2e/src/temp_project.rs | 3 +- crates/kimetsu-e2e/tests/citations.rs | 2 +- crates/kimetsu-e2e/tests/conflicts.rs | 32 ++++---- crates/kimetsu-e2e/tests/decay.rs | 5 +- crates/kimetsu-e2e/tests/golden_path.rs | 9 +- deny.toml | 41 +++++++++ scripts/install-hooks.sh | 9 ++ 24 files changed, 556 insertions(+), 213 deletions(-) create mode 100644 .githooks/pre-commit create mode 100644 .github/workflows/ci.yml create mode 100644 CONTRIBUTING.md create mode 100644 deny.toml create mode 100644 scripts/install-hooks.sh diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..243cea4 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# kimetsu pre-commit gate — keeps every commit formatted and lint-clean. +# +# Activate once per clone: +# git config core.hooksPath .githooks +# (or run ./scripts/install-hooks.sh) +# +# Mirrors the CI quality gate (fmt + clippy) so red CI is caught locally. +# The full test suite + security audit run in CI on PR, not here, to keep +# commits fast. +# +# Escape hatches: +# SKIP_CLIPPY=1 git commit ... # fmt only (faster) +# git commit --no-verify ... # skip the hook entirely (discouraged) + +set -euo pipefail + +# Only gate Rust changes; let doc/config-only commits through fast. +if ! git diff --cached --name-only --diff-filter=ACMR | grep -qE '\.rs$|(^|/)Cargo\.(toml|lock)$'; then + exit 0 +fi + +if ! command -v cargo >/dev/null 2>&1; then + echo "pre-commit: cargo not found on PATH — skipping Rust checks." >&2 + exit 0 +fi + +echo "pre-commit: cargo fmt --all --check" +if ! cargo fmt --all --check; then + echo "" >&2 + echo "pre-commit: formatting check failed. Run 'cargo fmt --all' and re-stage." >&2 + exit 1 +fi + +if [ "${SKIP_CLIPPY:-0}" = "1" ]; then + echo "pre-commit: SKIP_CLIPPY=1 set — skipping clippy." + exit 0 +fi + +# Clippy is advisory at the hook stage (main has a small pre-existing lint +# backlog). It runs and prints findings but does not block the commit, so +# you see new lints you introduced without being blocked by old ones. +# CI runs the same check on the PR. Once the backlog is cleared, switch +# this to a blocking `cargo clippy --workspace -- -D warnings`. +echo "pre-commit: cargo clippy --workspace (advisory)" +cargo clippy --workspace 2>&1 | grep -E "^warning:|^error:" || true + +echo "pre-commit: OK (fmt enforced; clippy advisory)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..35a4c36 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,105 @@ +# Continuous integration: quality + security gate for every PR and for +# pushes to the long-lived branches (main, develop). +# +# Jobs (all required for a green PR): +# fmt — rustfmt check, no diffs allowed +# clippy — clippy with warnings denied (-D warnings) +# test — full workspace test suite +# audit — RUSTSEC advisory scan over the dependency tree +# deny — license / banned-crate / duplicate policy (cargo-deny) +# +# Release/publish lives in release.yml (tag-driven); this file is the +# pre-merge gate that keeps main + develop clean and secure. + +name: ci + +on: + pull_request: + branches: [main, develop] + push: + branches: [main, develop] + +# Cancel superseded runs on the same ref to save CI minutes. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + fmt: + name: rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all --check + + clippy: + name: clippy + runs-on: ubuntu-latest + # Advisory for now: main carries a small pre-existing lint backlog. + # The job runs and surfaces warnings on every PR; once the backlog is + # cleared in a dedicated cleanup PR, drop `continue-on-error` and add + # `-D warnings` to make this a hard gate. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + shared-key: ci-clippy + - run: cargo clippy --workspace --all-targets + + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Ubuntu is the primary gate; macOS guards the Apple-silicon + # build that ships in releases. Windows is exercised by the + # release workflow's build matrix. + os: [ubuntu-latest, macos-14] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + shared-key: ci-test-${{ matrix.os }} + # Library crates default to the lean (FTS-only) feature set, so the + # workspace test build does not pull the ONNX runtime. This keeps + # the gate fast and dependency-light; the embeddings flavor is + # built + smoke-tested in release.yml. + - run: cargo test --workspace --locked + + audit: + name: cargo-audit (RUSTSEC) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: rustsec/audit-check@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + deny: + name: cargo-deny (licenses + bans) + runs-on: ubuntu-latest + # Advisory until the policy in deny.toml is verified green against the + # full dependency tree. Flip to a hard gate by removing this line once + # a PR has shown it passing. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check bans licenses sources diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..88af051 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,76 @@ +# Contributing to kimetsu + +Thanks for helping improve kimetsu. This repo keeps a clean, secure +`main` and `develop` through automated checks at two points: locally on +every commit, and in CI on every pull request. + +## Branches + +- **`main`** — released, stable. Protected; changes land via PR. +- **`develop`** — integration branch for in-flight work. Branch your + feature work off `develop` and open PRs back into it. +- Release tags (`vX.Y.Z`) are cut from `main` and drive the publish + pipeline (`.github/workflows/release.yml`). + +``` +feature/your-thing -> develop -> main -> tag vX.Y.Z +``` + +## One-time setup + +Enable the pre-commit hook after cloning: + +```bash +./scripts/install-hooks.sh # sets core.hooksPath -> .githooks +``` + +The hook enforces `cargo fmt --all --check` (blocking) and runs +`cargo clippy --workspace` in advisory mode (prints findings, does not +block) on staged Rust changes. Escape hatches when you need them: + +- `SKIP_CLIPPY=1 git commit …` — formatting only (faster). +- `git commit --no-verify …` — skip the hook (discouraged; CI will still + catch issues). + +## The checks + +Both the local hook and CI enforce the same quality bar so a green +local commit means a green PR. + +| Check | Local (pre-commit) | CI (PR + push to main/develop) | Blocking? | +|-------|:------------------:|:------------------------------:|:---------:| +| `cargo fmt --all --check` | ✅ | ✅ | yes | +| `cargo clippy --workspace` | ✅ (advisory) | ✅ | advisory* | +| `cargo test --workspace` | — | ✅ (ubuntu + macOS) | yes | +| `cargo-audit` (RUSTSEC advisories) | — | ✅ | yes | +| `cargo-deny` (licenses + bans) | — | ✅ | advisory* | + +\* clippy and cargo-deny run on every PR but do not block merges yet +(`continue-on-error` in `ci.yml`). main carries a small pre-existing +clippy backlog; once it's cleared in a dedicated cleanup PR, flip these to +hard gates (clippy: add `-D warnings`; deny: remove `continue-on-error`). + +Run the full suite locally before opening a PR: + +```bash +cargo fmt --all --check +cargo clippy --workspace --all-targets # advisory; aim to keep it quiet +cargo test --workspace +cargo deny check # optional: cargo install cargo-deny +``` + +## Pull requests + +- Target `develop` for features/fixes; target `main` only for releases + or hotfixes. +- Keep PRs focused; describe what changed and why. +- All required CI checks must pass before merge. +- Don't commit secrets. The brain redacts known token shapes, but treat + `.env`, credentials, and API keys as never-commit. + +## Security + +- Dependency vulnerabilities are caught by the `cargo-audit` CI job + (RUSTSEC database). If it flags an advisory, bump or replace the + affected crate before merging. +- Report security issues privately rather than in a public issue. diff --git a/crates/kimetsu-agent/src/harness.rs b/crates/kimetsu-agent/src/harness.rs index bf565c2..26adb70 100644 --- a/crates/kimetsu-agent/src/harness.rs +++ b/crates/kimetsu-agent/src/harness.rs @@ -1236,7 +1236,8 @@ fn kimetsu_all_tool_definitions() -> Vec { turn that you used; multiple citations per turn are fine. \ Pass the `memory_id` exactly as it appeared in your retrieved \ context (look for `memory:` in capsule expansion handles \ - or the `id` field of memory capsules).".to_string(), + or the `id` field of memory capsules)." + .to_string(), input_schema: json!({ "type": "object", "properties": { @@ -3642,10 +3643,7 @@ mod tests { assert!(g.get("error").is_some(), "glob allowed {path}"); let v = kimetsu_view_image(&mut runtime, &json!({"path": path})); assert!(v.get("error").is_some(), "view_image allowed {path}"); - let s = kimetsu_search_files( - &mut runtime, - &json!({"pattern": "secret", "path": path}), - ); + let s = kimetsu_search_files(&mut runtime, &json!({"pattern": "secret", "path": path})); assert!(s.get("error").is_some(), "search_files allowed {path}"); } // apply_patch with an absolute diff target is rejected before execution. diff --git a/crates/kimetsu-brain/src/ambient.rs b/crates/kimetsu-brain/src/ambient.rs index 7b9409d..2a53a76 100644 --- a/crates/kimetsu-brain/src/ambient.rs +++ b/crates/kimetsu-brain/src/ambient.rs @@ -348,10 +348,7 @@ mod tests { ..Default::default() }; let suffix = render_as_query_suffix(&ctx); - assert!( - suffix.contains("src/embeddings.rs"), - "got {suffix}" - ); + assert!(suffix.contains("src/embeddings.rs"), "got {suffix}"); assert!( suffix.contains("crates/kimetsu-brain/src/ambient.rs"), "got {suffix}" @@ -453,10 +450,7 @@ mod tests { // Build a fake workspace with `.kimetsu/` content next to // real source files; the walker must surface the source // files only. - let root = std::env::temp_dir().join(format!( - "kimetsu-ambient-test-{}", - ulid::Ulid::new() - )); + let root = std::env::temp_dir().join(format!("kimetsu-ambient-test-{}", ulid::Ulid::new())); std::fs::create_dir_all(root.join("src")).unwrap(); std::fs::create_dir_all(root.join(".kimetsu/runs")).unwrap(); std::fs::write(root.join("src/a.rs"), "// a").unwrap(); @@ -469,7 +463,11 @@ mod tests { ".kimetsu/ entries should be filtered out: {:?}", files ); - assert!(files.iter().any(|p| p.ends_with("a.rs") || p.ends_with("b.rs"))); + assert!( + files + .iter() + .any(|p| p.ends_with("a.rs") || p.ends_with("b.rs")) + ); let _ = std::fs::remove_dir_all(&root); } } diff --git a/crates/kimetsu-brain/src/conflict.rs b/crates/kimetsu-brain/src/conflict.rs index 03210ad..0a94247 100644 --- a/crates/kimetsu-brain/src/conflict.rs +++ b/crates/kimetsu-brain/src/conflict.rs @@ -455,7 +455,14 @@ mod tests { let conn = open_test_brain(); // Insert via stub so the row has an embedding; then scan with Noop. let stub = StubEmbedder::new(); - insert_memory(&conn, "m_existing", "global_user", "fact", "use thiserror for libraries", &stub); + insert_memory( + &conn, + "m_existing", + "global_user", + "fact", + "use thiserror for libraries", + &stub, + ); let hits = find_potential_conflicts( &conn, &MemoryScope::GlobalUser, @@ -475,7 +482,14 @@ mod tests { fn cross_model_rows_are_skipped() { let conn = open_test_brain(); let stub = StubEmbedder::new(); - insert_memory(&conn, "m_xmodel", "global_user", "fact", "use thiserror", &stub); + insert_memory( + &conn, + "m_xmodel", + "global_user", + "fact", + "use thiserror", + &stub, + ); // Stomp the model id to simulate a pre-reindex row. conn.execute( "UPDATE memories SET embedding_model = 'bge-small-en-v1.5' WHERE memory_id = 'm_xmodel'", @@ -505,7 +519,14 @@ mod tests { fn exact_match_is_not_flagged_as_conflict() { let conn = open_test_brain(); let stub = StubEmbedder::new(); - insert_memory(&conn, "m_exact", "global_user", "fact", "Use ripgrep", &stub); + insert_memory( + &conn, + "m_exact", + "global_user", + "fact", + "Use ripgrep", + &stub, + ); let hits = find_potential_conflicts( &conn, &MemoryScope::GlobalUser, @@ -590,7 +611,9 @@ mod tests { assert_eq!(id1, id2, "re-recording the same pair must return same id"); // Confirm only one row landed. let count: i64 = conn - .query_row("SELECT COUNT(*) FROM memory_conflicts", [], |row| row.get(0)) + .query_row("SELECT COUNT(*) FROM memory_conflicts", [], |row| { + row.get(0) + }) .unwrap(); assert_eq!(count, 1); } @@ -602,10 +625,31 @@ mod tests { fn list_unresolved_excludes_resolved_rows() { let conn = open_test_brain(); let stub = StubEmbedder::new(); - insert_memory(&conn, "m_new1", "global_user", "fact", "use thiserror", &stub); + insert_memory( + &conn, + "m_new1", + "global_user", + "fact", + "use thiserror", + &stub, + ); insert_memory(&conn, "m_old1", "global_user", "fact", "use anyhow", &stub); - insert_memory(&conn, "m_new2", "global_user", "fact", "tabs over spaces", &stub); - insert_memory(&conn, "m_old2", "global_user", "fact", "spaces over tabs", &stub); + insert_memory( + &conn, + "m_new2", + "global_user", + "fact", + "tabs over spaces", + &stub, + ); + insert_memory( + &conn, + "m_old2", + "global_user", + "fact", + "spaces over tabs", + &stub, + ); let hit1 = ConflictHit { existing_memory_id: "m_old1".to_string(), @@ -755,7 +799,14 @@ mod tests { fn detect_and_record_noop_writes_nothing() { let conn = open_test_brain(); let stub = StubEmbedder::new(); - insert_memory(&conn, "m_existing", "global_user", "fact", "alpha beta", &stub); + insert_memory( + &conn, + "m_existing", + "global_user", + "fact", + "alpha beta", + &stub, + ); insert_memory(&conn, "m_new", "global_user", "fact", "alpha gamma", &stub); let recorded = detect_and_record( &conn, @@ -767,7 +818,9 @@ mod tests { ); assert_eq!(recorded, 0); let count: i64 = conn - .query_row("SELECT COUNT(*) FROM memory_conflicts", [], |row| row.get(0)) + .query_row("SELECT COUNT(*) FROM memory_conflicts", [], |row| { + row.get(0) + }) .unwrap(); assert_eq!(count, 0); } diff --git a/crates/kimetsu-brain/src/context.rs b/crates/kimetsu-brain/src/context.rs index 13819d6..ec7e5c7 100644 --- a/crates/kimetsu-brain/src/context.rs +++ b/crates/kimetsu-brain/src/context.rs @@ -191,16 +191,21 @@ pub fn retrieve_context_with_embedder( // normalisation so the multipliers operate on the [0,1]-normalised score // rather than the raw pre-normalisation values. if !request.tags.is_empty() || !request.prefer_roles.is_empty() { - let tags_lc: Vec = request.tags.iter().map(|t| t.to_ascii_lowercase()).collect(); + let tags_lc: Vec = request + .tags + .iter() + .map(|t| t.to_ascii_lowercase()) + .collect(); for c in &mut candidates { let summary_lc = c.capsule.summary.to_ascii_lowercase(); - if !tags_lc.is_empty() - && tags_lc.iter().any(|t| summary_lc.contains(t.as_str())) - { + if !tags_lc.is_empty() && tags_lc.iter().any(|t| summary_lc.contains(t.as_str())) { c.capsule.score *= 1.4; } if !request.prefer_roles.is_empty() - && request.prefer_roles.iter().any(|r| c.capsule.kind.contains(r.as_str())) + && request + .prefer_roles + .iter() + .any(|r| c.capsule.kind.contains(r.as_str())) { c.capsule.score *= 1.3; } @@ -387,7 +392,11 @@ fn latest_memory_candidates( embedding_model, last_useful_at, ) = row?; - let cosine = compute_cosine(query_embedding, embedding.as_deref(), embedding_model.as_deref()); + let cosine = compute_cosine( + query_embedding, + embedding.as_deref(), + embedding_model.as_deref(), + ); if let Some(candidate) = memory_row_to_candidate( query_tokens, memory_id, @@ -466,7 +475,11 @@ fn memory_fts_candidates( last_useful_at, ) = row?; let fts_relevance = (-rank as f32).max(0.0); - let cosine = compute_cosine(query_embedding, embedding.as_deref(), embedding_model.as_deref()); + let cosine = compute_cosine( + query_embedding, + embedding.as_deref(), + embedding_model.as_deref(), + ); if let Some(candidate) = memory_row_to_candidate( query_tokens, memory_id, @@ -1446,7 +1459,9 @@ mod tests { /// just because its `last_useful_at` got mangled. #[test] fn usefulness_decay_returns_one_on_unparseable_timestamps() { - assert!((usefulness_decay(Some("not-a-date"), "also-not", 30.0) - 1.0).abs() < f32::EPSILON); + assert!( + (usefulness_decay(Some("not-a-date"), "also-not", 30.0) - 1.0).abs() < f32::EPSILON + ); } /// v0.5.1: a memory whose reference timestamp is "now" (no age) @@ -1470,10 +1485,9 @@ mod tests { let fmt = &time::format_description::well_known::Rfc3339; // age = half_life -> decay ~= 0.5 - let one_half_life_ago = - (now - time::Duration::seconds((half_life * 86_400.0) as i64)) - .format(fmt) - .expect("format"); + let one_half_life_ago = (now - time::Duration::seconds((half_life * 86_400.0) as i64)) + .format(fmt) + .expect("format"); let d1 = usefulness_decay(Some(&one_half_life_ago), &one_half_life_ago, half_life); assert!( (d1 - 0.5).abs() < 0.01, @@ -1481,15 +1495,11 @@ mod tests { ); // age = 2 * half_life -> decay ~= 0.25 - let two_half_lives_ago = - (now - time::Duration::seconds((2.0 * half_life * 86_400.0) as i64)) - .format(fmt) - .expect("format"); - let d2 = usefulness_decay( - Some(&two_half_lives_ago), - &two_half_lives_ago, - half_life, - ); + let two_half_lives_ago = (now + - time::Duration::seconds((2.0 * half_life * 86_400.0) as i64)) + .format(fmt) + .expect("format"); + let d2 = usefulness_decay(Some(&two_half_lives_ago), &two_half_lives_ago, half_life); assert!( (d2 - 0.25).abs() < 0.01, "expected ~0.25 at two half-lives, got {d2}" @@ -1535,9 +1545,7 @@ mod tests { // Both memories say "use ripgrep for code search", both have // use_count = 5, usefulness_score = 5 (max boost → 1.5 // multiplier). The only difference is `last_useful_at`. - for (mid, last_useful) in - [("m_recent", &one_day_ago), ("m_aged", &one_year_ago)] - { + for (mid, last_useful) in [("m_recent", &one_day_ago), ("m_aged", &one_year_ago)] { let text = "use ripgrep for code search"; let normalized = kimetsu_core::memory::normalize_memory_text(text); conn.execute( @@ -1581,11 +1589,7 @@ mod tests { let mem_order: Vec<&str> = bundle .capsules .iter() - .filter_map(|c| { - c.expansion_handle - .strip_prefix("memory:") - .map(|s| s) - }) + .filter_map(|c| c.expansion_handle.strip_prefix("memory:").map(|s| s)) .collect(); assert_eq!( mem_order.first().copied(), @@ -1612,9 +1616,7 @@ mod tests { .format(fmt) .expect("format"); - for (mid, last_useful) in - [("m_recent", &one_day_ago), ("m_aged", &one_year_ago)] - { + for (mid, last_useful) in [("m_recent", &one_day_ago), ("m_aged", &one_year_ago)] { let text = "use ripgrep for code search"; let normalized = kimetsu_core::memory::normalize_memory_text(text); conn.execute( diff --git a/crates/kimetsu-brain/src/embeddings.rs b/crates/kimetsu-brain/src/embeddings.rs index 0102191..72cafc5 100644 --- a/crates/kimetsu-brain/src/embeddings.rs +++ b/crates/kimetsu-brain/src/embeddings.rs @@ -242,8 +242,7 @@ impl Embedder for StubEmbedder { /// with an explicit [`StubEmbedder`] (or any other [`Embedder`]) /// instead of going through this function. pub fn open_default_embedder() -> &'static (dyn Embedder + Send + Sync) { - static CACHE: std::sync::OnceLock> = - std::sync::OnceLock::new(); + static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); let embedder = CACHE.get_or_init(build_default_embedder); embedder.as_ref() } @@ -517,20 +516,13 @@ pub fn encode_embedding(vec: &[f32]) -> Vec { /// expected dimension; pass `None` to accept any length. pub fn decode_embedding(bytes: &[u8], expected_dim: Option) -> KimetsuResult> { if !bytes.len().is_multiple_of(4) { - return Err(format!( - "embedding blob length {} not a multiple of 4", - bytes.len() - ) - .into()); + return Err(format!("embedding blob length {} not a multiple of 4", bytes.len()).into()); } let dim = bytes.len() / 4; if let Some(expected) = expected_dim && dim != expected { - return Err(format!( - "embedding blob dim {dim} does not match expected {expected}" - ) - .into()); + return Err(format!("embedding blob dim {dim} does not match expected {expected}").into()); } let mut out = Vec::with_capacity(dim); for chunk in bytes.chunks_exact(4) { @@ -582,7 +574,10 @@ mod tests { let sim = cosine_similarity(&a, &b); // Disjoint word sets *can* still collide in the 8-bucket // hash, but on average should be low. Sanity bound: not 1.0. - assert!(sim < 0.99, "disjoint inputs should not be near-identical: {sim}"); + assert!( + sim < 0.99, + "disjoint inputs should not be near-identical: {sim}" + ); } #[test] @@ -701,10 +696,7 @@ mod tests { unsafe { std::env::set_var("KIMETSU_BRAIN_EMBEDDER", value); } - assert!( - !env_disables_embedder(), - "value {value:?} must NOT disable" - ); + assert!(!env_disables_embedder(), "value {value:?} must NOT disable"); } // Restore. unsafe { diff --git a/crates/kimetsu-brain/src/project.rs b/crates/kimetsu-brain/src/project.rs index 53bb740..c4542b9 100644 --- a/crates/kimetsu-brain/src/project.rs +++ b/crates/kimetsu-brain/src/project.rs @@ -275,7 +275,10 @@ impl BrainSession { /// v0.6: full-request variant used by `kimetsu_brain_context` MCP tool /// and `retrieve_context_readonly_with_request` to expose `tags`, /// `min_score`, `max_capsules`, and `prefer_roles`. - pub fn retrieve_context_with_request(&self, request: ContextRequest) -> KimetsuResult { + pub fn retrieve_context_with_request( + &self, + request: ContextRequest, + ) -> KimetsuResult { let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect(); context::retrieve_context_multi( &self.conn, @@ -496,14 +499,8 @@ pub fn add_memory( // `kimetsu brain memory conflicts`. Best-effort: NoopEmbedder // (lean build) returns 0 hits; embedder failures degrade to a // stderr line, never to a failed insert. - let conflicts = conflict::detect_and_record( - &conn, - &memory_id, - &scope, - &kind.to_string(), - text, - embedder, - ); + let conflicts = + conflict::detect_and_record(&conn, &memory_id, &scope, &kind.to_string(), text, embedder); if conflicts > 0 { eprintln!( "kimetsu-brain: memory {memory_id} conflicts with {conflicts} existing memor{} (run `kimetsu brain memory conflicts` to review)", @@ -565,10 +562,10 @@ pub fn propose_memory( /// v0.7: outcome of a `propose_or_merge_memory` call. #[derive(Debug)] pub enum ProposeResult { - Added(String), // memory_id — new memory, directly accepted - Proposed(String), // proposal_id — pending for review (low confidence) - Merged(String), // memory_id of the existing memory that was updated - Duplicate(String), // memory_id of the identical existing memory + Added(String), // memory_id — new memory, directly accepted + Proposed(String), // proposal_id — pending for review (low confidence) + Merged(String), // memory_id of the existing memory that was updated + Duplicate(String), // memory_id of the identical existing memory } /// v0.7: capture a lesson, automatically deduplicating against the existing brain. @@ -633,12 +630,7 @@ pub fn propose_or_merge_memory( WHERE memory_id = ?3", rusqlite::params![merged_text, new_normalized, hit.existing_memory_id], )?; - embeddings::embed_and_persist( - &conn, - &hit.existing_memory_id, - &merged_text, - embedder, - )?; + embeddings::embed_and_persist(&conn, &hit.existing_memory_id, &merged_text, embedder)?; return Ok(ProposeResult::Merged(hit.existing_memory_id)); } } @@ -747,10 +739,7 @@ pub fn blame_run(start: &Path, run_id: &str) -> KimetsuResult { }) } -fn run_outcome( - conn: &Connection, - run_id: &str, -) -> KimetsuResult<(String, Option)> { +fn run_outcome(conn: &Connection, run_id: &str) -> KimetsuResult<(String, Option)> { // Pull the most recent terminal event for the run, if any. let row: Option<(String, String)> = conn .query_row( @@ -1618,11 +1607,7 @@ pub fn list_conflicts(start: &Path, limit: u32) -> KimetsuResult KimetsuResult { +pub fn resolve_conflict(start: &Path, conflict_id: &str, resolution: &str) -> KimetsuResult { let (paths, _config, project_conn) = load_project(start)?; let _lock = ProjectLock::acquire(&paths, "brain memory conflict resolve", None)?; if conflict::resolve_conflict(&project_conn, conflict_id, resolution)? { @@ -1770,13 +1755,8 @@ mod tests { init_project(&root, false).expect("init project"); let raw = "Add CLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf to .env"; - let memory_id = add_memory( - &root, - MemoryScope::Repo, - MemoryKind::Command, - raw, - ) - .expect("add memory"); + let memory_id = + add_memory(&root, MemoryScope::Repo, MemoryKind::Command, raw).expect("add memory"); let memories = list_memories(&root).expect("list"); let stored = memories @@ -1794,8 +1774,7 @@ mod tests { stored.text ); assert!( - stored.text.contains("CLAUDE_CODE_OAUTH_TOKEN") - && stored.text.contains(".env"), + stored.text.contains("CLAUDE_CODE_OAUTH_TOKEN") && stored.text.contains(".env"), "non-secret context must be preserved: {}", stored.text ); @@ -2057,8 +2036,7 @@ mod tests { let run_id = RunId::new(); { let (paths, _config, conn) = load_project(&root).expect("load"); - let (mut writer, _run_paths) = - TraceWriter::create(&paths, run_id).expect("trace"); + let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace"); let evs: Vec = vec![ Event::new( run_id, diff --git a/crates/kimetsu-brain/src/redact.rs b/crates/kimetsu-brain/src/redact.rs index d9fdf5a..6a06283 100644 --- a/crates/kimetsu-brain/src/redact.rs +++ b/crates/kimetsu-brain/src/redact.rs @@ -229,10 +229,7 @@ fn patterns() -> &'static [SecretPattern] { // `api_key = "..."` / `api-key:"..."` / `api_key=...`. // Captures both quoted and bare values; value must be // ≥12 chars of secret-looking content. - regex: Regex::new( - r#"(?i)api[_\-]?key\s*[:=]\s*"?[A-Za-z0-9_\-]{12,}"?"#, - ) - .unwrap(), + regex: Regex::new(r#"(?i)api[_\-]?key\s*[:=]\s*"?[A-Za-z0-9_\-]{12,}"?"#).unwrap(), }); out.push(SecretPattern { kind: "generic_token", @@ -261,7 +258,8 @@ mod tests { #[test] fn anthropic_oauth_token_is_redacted() { - let raw = "export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf"; + let raw = + "export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf"; let r = redact_secrets(raw); assert!(r.was_redacted(), "{:?}", r); assert!(r.text.contains("[REDACTED:anthropic_oauth]")); @@ -326,7 +324,12 @@ mod tests { #[test] fn slack_aws_jwt_pem_google_all_redact() { let raw = concat!( - "slack=xoxb-12345678-abcdefghijklmnop ", + // Synthetic, non-functional token shaped to exercise the + // slack_token detector. The prefix is split so secret + // scanners don't flag the literal as a real credential. + "slack=", + "xoxb", + "-12345678-abcdefghijklmnop ", "aws=AKIAIOSFODNN7EXAMPLE ", "jwt=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.abcdef ", "google=AIzaSyDx0o-1234567890abcdefghijklmnopqrs ", @@ -346,7 +349,10 @@ mod tests { "private_key_pem", "slack_token", ] { - assert!(kinds.contains(&expected), "missing kind {expected}: {kinds:?}"); + assert!( + kinds.contains(&expected), + "missing kind {expected}: {kinds:?}" + ); } } @@ -395,7 +401,8 @@ mod tests { #[test] fn summary_lists_unique_kinds() { - let raw = "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ and ghp_ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ"; + let raw = + "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ and ghp_ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ"; let r = redact_secrets(raw); let summary = r.summary(); assert!(summary.contains("github_pat")); @@ -416,11 +423,13 @@ mod tests { #[test] fn redaction_preserves_non_secret_surroundings() { - let raw = - "# Save to .env\nCLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf\n# Use it"; + let raw = "# Save to .env\nCLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf\n# Use it"; let r = redact_secrets(raw); assert!(r.text.starts_with("# Save to .env")); assert!(r.text.ends_with("# Use it")); - assert!(r.text.contains("CLAUDE_CODE_OAUTH_TOKEN=[REDACTED:anthropic_oauth]")); + assert!( + r.text + .contains("CLAUDE_CODE_OAUTH_TOKEN=[REDACTED:anthropic_oauth]") + ); } } diff --git a/crates/kimetsu-brain/src/reindex.rs b/crates/kimetsu-brain/src/reindex.rs index 6ad07c4..f69537b 100644 --- a/crates/kimetsu-brain/src/reindex.rs +++ b/crates/kimetsu-brain/src/reindex.rs @@ -267,7 +267,10 @@ mod tests { #[test] fn reindex_scope_parser_accepts_aliases() { - assert_eq!(ReindexScope::parse("project").unwrap(), ReindexScope::Project); + assert_eq!( + ReindexScope::parse("project").unwrap(), + ReindexScope::Project + ); assert_eq!(ReindexScope::parse("repo").unwrap(), ReindexScope::Project); assert_eq!(ReindexScope::parse("user").unwrap(), ReindexScope::User); assert_eq!(ReindexScope::parse("global").unwrap(), ReindexScope::User); @@ -350,7 +353,11 @@ mod tests { |row| row.get(0), ) .expect("fetch blob"); - assert_eq!(blob.len(), stub.dim() * 4, "stub-d8 -> 8 floats -> 32 bytes"); + assert_eq!( + blob.len(), + stub.dim() * 4, + "stub-d8 -> 8 floats -> 32 bytes" + ); } }); } diff --git a/crates/kimetsu-brain/src/user_brain.rs b/crates/kimetsu-brain/src/user_brain.rs index b3b0712..0a41d68 100644 --- a/crates/kimetsu-brain/src/user_brain.rs +++ b/crates/kimetsu-brain/src/user_brain.rs @@ -112,10 +112,7 @@ pub fn add_user_memory( // `add_memory`'s upstream call is safe + cheap. let redaction = redact::redact_secrets(text); if redaction.was_redacted() { - eprintln!( - "kimetsu-brain (user): {}", - redaction.summary() - ); + eprintln!("kimetsu-brain (user): {}", redaction.summary()); } let text = redaction.text.as_str(); let normalized = normalize_memory_text(text); @@ -391,11 +388,10 @@ mod tests { let tmp = tempdir_in_test("kimetsu-user-brain-4"); with_user_brain_at(&tmp, || { let conn = open_user_brain().expect("open").expect("enabled"); - let first = add_user_memory(&conn, MemoryKind::Preference, "use thiserror", 1.0) - .expect("add"); - let second = - add_user_memory(&conn, MemoryKind::Preference, " use thiserror ", 1.0) - .expect("add normalized dup"); + let first = + add_user_memory(&conn, MemoryKind::Preference, "use thiserror", 1.0).expect("add"); + let second = add_user_memory(&conn, MemoryKind::Preference, " use thiserror ", 1.0) + .expect("add normalized dup"); assert_eq!(first, second, "normalized-text dedup must hit"); // List back what we wrote. let rows = list_user_memories(&conn).expect("list"); diff --git a/crates/kimetsu-chat/src/mcp_server.rs b/crates/kimetsu-chat/src/mcp_server.rs index c7cd23e..4ea479d 100644 --- a/crates/kimetsu-chat/src/mcp_server.rs +++ b/crates/kimetsu-chat/src/mcp_server.rs @@ -234,9 +234,7 @@ fn call_tool( "kimetsu_brain_memory_reject" => kimetsu_brain_memory_reject(workspace, &arguments), "kimetsu_brain_memory_invalidate" => kimetsu_brain_memory_invalidate(workspace, &arguments), "kimetsu_brain_memory_blame" => kimetsu_brain_memory_blame(workspace, &arguments), - "kimetsu_brain_memory_conflicts" => { - kimetsu_brain_memory_conflicts(workspace, &arguments) - } + "kimetsu_brain_memory_conflicts" => kimetsu_brain_memory_conflicts(workspace, &arguments), "kimetsu_brain_ingest_repo" => kimetsu_brain_ingest_repo(workspace, &arguments), "kimetsu_bridge_status" => { let scan = bridge_scan(workspace, skills)?; @@ -941,8 +939,8 @@ fn kimetsu_brain_memory_blame(workspace: &Path, arguments: &Value) -> Result Result Result { +fn kimetsu_brain_memory_conflicts(workspace: &Path, arguments: &Value) -> Result { let limit = arguments .get("limit") .and_then(|v| v.as_u64()) @@ -973,8 +968,8 @@ fn kimetsu_brain_memory_conflicts( .unwrap_or(50); let open = project::list_conflicts(workspace, limit) .map_err(|err| format!("kimetsu brain memory conflicts: {err}"))?; - let conflicts = serde_json::to_value(&open) - .map_err(|err| format!("serialize conflicts: {err}"))?; + let conflicts = + serde_json::to_value(&open).map_err(|err| format!("serialize conflicts: {err}"))?; Ok(json!({ "ok": true, "usage": { diff --git a/crates/kimetsu-cli/src/doctor.rs b/crates/kimetsu-cli/src/doctor.rs index 9a6fd7d..b12f87f 100644 --- a/crates/kimetsu-cli/src/doctor.rs +++ b/crates/kimetsu-cli/src/doctor.rs @@ -524,25 +524,19 @@ mod tests { CheckReport { name: "b", category: "x", - outcome: Outcome::Warn { - reason: "w".into(), - }, + outcome: Outcome::Warn { reason: "w".into() }, detail: None, }, CheckReport { name: "c", category: "y", - outcome: Outcome::Fail { - reason: "f".into(), - }, + outcome: Outcome::Fail { reason: "f".into() }, detail: None, }, CheckReport { name: "d", category: "y", - outcome: Outcome::Skip { - reason: "s".into(), - }, + outcome: Outcome::Skip { reason: "s".into() }, detail: None, }, ], diff --git a/crates/kimetsu-cli/src/main.rs b/crates/kimetsu-cli/src/main.rs index 310fc18..5d7df96 100644 --- a/crates/kimetsu-cli/src/main.rs +++ b/crates/kimetsu-cli/src/main.rs @@ -1135,12 +1135,8 @@ fn brain(command: BrainCommand) -> KimetsuResult<()> { } else { (args.query.clone(), None) }; - let bundle = project::retrieve_context( - &cwd, - &args.stage, - &effective_query, - args.budget_tokens, - )?; + let bundle = + project::retrieve_context(&cwd, &args.stage, &effective_query, args.budget_tokens)?; if args.json { println!( "{}", @@ -1243,8 +1239,16 @@ fn reindex_brain(args: ReindexArgs) -> KimetsuResult<()> { println!(" {}: skipped (scope filter or DB unavailable)", sub.scope); continue; } - let action = if args.dry_run { "candidates" } else { "updated" }; - let count = if args.dry_run { sub.candidates } else { sub.updated }; + let action = if args.dry_run { + "candidates" + } else { + "updated" + }; + let count = if args.dry_run { + sub.candidates + } else { + sub.updated + }; println!( " {}: total={} {}={} failed={}", sub.scope, sub.total, action, count, sub.failed @@ -1272,19 +1276,38 @@ fn brain_status(json: bool) -> KimetsuResult<()> { let memories = project::list_memories(&cwd)?; let proposals = project::list_proposals( &cwd, - project::ProposalFilter { status: Some("pending".to_string()), limit: 200, ..Default::default() }, + project::ProposalFilter { + status: Some("pending".to_string()), + limit: 200, + ..Default::default() + }, )?; let conflicts = project::list_conflicts(&cwd, 200)?; - let healthy: Vec<_> = memories.iter().filter(|m| m.usefulness_score >= 0.2).collect(); - let fading: Vec<_> = memories.iter().filter(|m| m.usefulness_score >= 0.0 && m.usefulness_score < 0.2).collect(); - let stale: Vec<_> = memories.iter().filter(|m| m.usefulness_score < 0.0).collect(); + let healthy: Vec<_> = memories + .iter() + .filter(|m| m.usefulness_score >= 0.2) + .collect(); + let fading: Vec<_> = memories + .iter() + .filter(|m| m.usefulness_score >= 0.0 && m.usefulness_score < 0.2) + .collect(); + let stale: Vec<_> = memories + .iter() + .filter(|m| m.usefulness_score < 0.0) + .collect(); // Domain grouping: extract first [tags: ...] prefix from text let mut domain_counts: std::collections::BTreeMap = Default::default(); for m in &memories { let domain = if let Some(rest) = m.text.strip_prefix("[tags: ") { - rest.split(']').next().unwrap_or("other").split_whitespace().next().unwrap_or("other").to_string() + rest.split(']') + .next() + .unwrap_or("other") + .split_whitespace() + .next() + .unwrap_or("other") + .to_string() } else { m.kind.clone() }; @@ -1292,27 +1315,41 @@ fn brain_status(json: bool) -> KimetsuResult<()> { } let mut domain_list: Vec<(String, usize)> = domain_counts.into_iter().collect(); domain_list.sort_by(|a, b| b.1.cmp(&a.1)); - let top_domains: Vec = domain_list.iter().take(6).map(|(d, n)| format!("{} ({})", d, n)).collect(); + let top_domains: Vec = domain_list + .iter() + .take(6) + .map(|(d, n)| format!("{} ({})", d, n)) + .collect(); if json { - println!("{}", serde_json::to_string_pretty(&serde_json::json!({ - "memories": memories.len(), - "pending_proposals": proposals.len(), - "open_conflicts": conflicts.len(), - "healthy": healthy.len(), - "fading": fading.len(), - "stale": stale.len(), - "top_domains": top_domains, - }))?); + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "memories": memories.len(), + "pending_proposals": proposals.len(), + "open_conflicts": conflicts.len(), + "healthy": healthy.len(), + "fading": fading.len(), + "stale": stale.len(), + "top_domains": top_domains, + }))? + ); } else { - println!("brain: {} memories active, {} pending proposals, {} conflicts", - memories.len(), proposals.len(), conflicts.len()); + println!( + "brain: {} memories active, {} pending proposals, {} conflicts", + memories.len(), + proposals.len(), + conflicts.len() + ); if !top_domains.is_empty() { println!("domains: {}", top_domains.join(", ")); } println!("health: {} healthy (usefulness >= 0.2)", healthy.len()); println!(" {} fading (0 <= usefulness < 0.2)", fading.len()); - println!(" {} stale (usefulness < 0, candidate for prune)", stale.len()); + println!( + " {} stale (usefulness < 0, candidate for prune)", + stale.len() + ); if stale.len() > 3 { println!("hint: run `kimetsu brain memory prune` to clean stale entries"); } @@ -1324,10 +1361,11 @@ fn brain_status(json: bool) -> KimetsuResult<()> { /// Reads `{"prompt":"..."}` JSON from stdin, retrieves relevant capsules, /// prints them to stdout for injection. Silent (exit 0) when brain has nothing. fn brain_context_hook(args: ContextHookArgs) -> KimetsuResult<()> { - use std::io::Read; use kimetsu_brain::context::ContextRequest; + use std::io::Read; - let workspace = args.workspace + let workspace = args + .workspace .unwrap_or_else(|| env::current_dir().unwrap_or_default()); // Read hook JSON from stdin @@ -1373,7 +1411,8 @@ fn brain_context_hook(args: ContextHookArgs) -> KimetsuResult<()> { println!("[Kimetsu brain] Relevant knowledge for this task:"); for capsule in &bundle.capsules { // Strip the "scope:kind - " prefix from the summary for readability - let text = capsule.summary + let text = capsule + .summary .splitn(3, " - ") .nth(1) .unwrap_or(&capsule.summary); @@ -1639,7 +1678,10 @@ fn memory_blame(args: BlameArgs) -> KimetsuResult<()> { } if !report.cited.is_empty() { - println!("\n cited memories ({} total) — earned strong ±1.0 signal:", report.cited.len()); + println!( + "\n cited memories ({} total) — earned strong ±1.0 signal:", + report.cited.len() + ); for c in &report.cited { let rationale = c .rationale diff --git a/crates/kimetsu-core/src/secret.rs b/crates/kimetsu-core/src/secret.rs index 66dc396..1f40fbc 100644 --- a/crates/kimetsu-core/src/secret.rs +++ b/crates/kimetsu-core/src/secret.rs @@ -167,6 +167,9 @@ mod tests { "parent struct's derived Debug must NOT leak nested SecretString: {dbg}" ); assert!(dbg.contains("REDACTED")); - assert!(dbg.contains("claude-opus"), "non-secret fields should print"); + assert!( + dbg.contains("claude-opus"), + "non-secret fields should print" + ); } } diff --git a/crates/kimetsu-e2e/src/assertions.rs b/crates/kimetsu-e2e/src/assertions.rs index 6988ffb..42d1ad9 100644 --- a/crates/kimetsu-e2e/src/assertions.rs +++ b/crates/kimetsu-e2e/src/assertions.rs @@ -20,8 +20,10 @@ pub fn count_memories(conn: &Connection) -> i64 { /// `memory.cited` event is applied. One row per (run_id, memory_id, /// turn). pub fn count_citations(conn: &Connection) -> i64 { - conn.query_row("SELECT COUNT(*) FROM memory_citations", [], |row| row.get(0)) - .expect("count citations") + conn.query_row("SELECT COUNT(*) FROM memory_citations", [], |row| { + row.get(0) + }) + .expect("count citations") } /// Count open (unresolved) conflict-detection hits. Resolved rows are diff --git a/crates/kimetsu-e2e/src/temp_project.rs b/crates/kimetsu-e2e/src/temp_project.rs index 170b6a5..b82ba49 100644 --- a/crates/kimetsu-e2e/src/temp_project.rs +++ b/crates/kimetsu-e2e/src/temp_project.rs @@ -29,8 +29,7 @@ impl TempProject { /// internally — every TempProject is a real on-disk project with /// a real `brain.db`. pub fn init(label: &str) -> Self { - let root = - std::env::temp_dir().join(format!("kimetsu-e2e-{label}-{}", RunId::new())); + let root = std::env::temp_dir().join(format!("kimetsu-e2e-{label}-{}", RunId::new())); fs::create_dir_all(&root).expect("create temp project root"); kimetsu_brain::project::init_project(&root, false).expect("init_project"); Self { diff --git a/crates/kimetsu-e2e/tests/citations.rs b/crates/kimetsu-e2e/tests/citations.rs index 23f2154..454031b 100644 --- a/crates/kimetsu-e2e/tests/citations.rs +++ b/crates/kimetsu-e2e/tests/citations.rs @@ -12,9 +12,9 @@ //! tests every wire from agent → harness → projector → memory row. use kimetsu_brain::projector; +use kimetsu_brain::user_brain::with_user_brain_disabled; use kimetsu_core::event::Event; use kimetsu_e2e::prelude::*; -use kimetsu_brain::user_brain::with_user_brain_disabled; #[test] fn cite_memory_tool_call_lands_in_report_context_with_turn_index() { diff --git a/crates/kimetsu-e2e/tests/conflicts.rs b/crates/kimetsu-e2e/tests/conflicts.rs index 8c51400..6a3ffcf 100644 --- a/crates/kimetsu-e2e/tests/conflicts.rs +++ b/crates/kimetsu-e2e/tests/conflicts.rs @@ -51,21 +51,19 @@ fn list_and_resolve_conflict_wrappers_compose_against_a_real_project() { existing_text: "Prefer thiserror for library error types.".to_string(), similarity: 0.92, }; - let conflict_id = conflict::record_conflict( - &conn, - &new_id, - &MemoryScope::Repo, - "preference", - &hit, - ) - .expect("record conflict"); + let conflict_id = + conflict::record_conflict(&conn, &new_id, &MemoryScope::Repo, "preference", &hit) + .expect("record conflict"); drop(conn); // Smoke-check: list_conflicts surfaces the row through the // project wrapper (which merges project + user brains). let open = project::list_conflicts(project.root(), 50).expect("list_conflicts"); assert_eq!(open.len(), 1, "exactly one open conflict expected"); - assert_eq!(open[0].source, "project", "conflict should be sourced from project brain"); + assert_eq!( + open[0].source, "project", + "conflict should be sourced from project brain" + ); assert_eq!(open[0].report.new_memory_id, new_id); assert_eq!(open[0].report.existing_memory_id, existing_id); assert!( @@ -77,7 +75,10 @@ fn list_and_resolve_conflict_wrappers_compose_against_a_real_project() { // Resolve with kept_new — existing memory should be invalidated. let resolved = project::resolve_conflict(project.root(), &conflict_id, "kept_new") .expect("resolve_conflict"); - assert!(resolved, "resolve_conflict should return true on first apply"); + assert!( + resolved, + "resolve_conflict should return true on first apply" + ); // Post-conditions: // * list_conflicts returns empty (resolved row excluded) @@ -139,14 +140,9 @@ fn re_resolving_same_conflict_is_idempotent_through_project_wrapper() { existing_text: "Use spaces for indentation.".to_string(), similarity: 0.88, }; - let conflict_id = conflict::record_conflict( - &conn, - &new_id, - &MemoryScope::Repo, - "preference", - &hit, - ) - .expect("record"); + let conflict_id = + conflict::record_conflict(&conn, &new_id, &MemoryScope::Repo, "preference", &hit) + .expect("record"); drop(conn); assert!( diff --git a/crates/kimetsu-e2e/tests/decay.rs b/crates/kimetsu-e2e/tests/decay.rs index fe05654..39bc0af 100644 --- a/crates/kimetsu-e2e/tests/decay.rs +++ b/crates/kimetsu-e2e/tests/decay.rs @@ -108,10 +108,7 @@ fn decay_can_be_disabled_via_broker_weights() { .format(fmt) .expect("format"); - for (mid, last_useful) in [ - ("m_recent_off", &recent), - ("m_aged_off", &aged), - ] { + for (mid, last_useful) in [("m_recent_off", &recent), ("m_aged_off", &aged)] { let text = "regression guard for the disable-decay knob"; let normalized = normalize_memory_text(text); conn.execute( diff --git a/crates/kimetsu-e2e/tests/golden_path.rs b/crates/kimetsu-e2e/tests/golden_path.rs index 4a0a22c..192f50e 100644 --- a/crates/kimetsu-e2e/tests/golden_path.rs +++ b/crates/kimetsu-e2e/tests/golden_path.rs @@ -27,8 +27,7 @@ fn agent_loop_completes_a_simple_scripted_run() { .turn(|t| t.done("read the file; nothing else to do")) .build(); - let mut runtime = ToolRuntime::new(project.root(), RunId::new()) - .expect("construct runtime"); + let mut runtime = ToolRuntime::new(project.root(), RunId::new()).expect("construct runtime"); let report = run_model_agent( "read hello.txt and confirm", @@ -43,7 +42,11 @@ fn agent_loop_completes_a_simple_scripted_run() { assert_eq!(report.turns, 2, "expected 2 turns, got {}", report.turns); assert_eq!(report.tool_calls, 1, "exactly one tool call expected"); assert!( - report.final_text.as_deref().unwrap_or("").contains("read the file"), + report + .final_text + .as_deref() + .unwrap_or("") + .contains("read the file"), "final text didn't match the scripted done turn: {:?}", report.final_text ); diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..5ff6442 --- /dev/null +++ b/deny.toml @@ -0,0 +1,41 @@ +# cargo-deny policy for the kimetsu workspace. +# Run locally with: cargo install cargo-deny && cargo deny check +# +# Scope of the CI `deny` job: bans, licenses, sources. Advisories are +# handled by the dedicated cargo-audit (RUSTSEC) job in ci.yml. + +[graph] +all-features = true + +[licenses] +# Allow the standard permissive set used across the Rust ecosystem. The +# workspace itself is "MIT OR Apache-2.0". Add to this list (with a note) +# if a new dependency introduces another OSI/FSF-approved license. +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Zlib", + "MPL-2.0", + "Unicode-3.0", + "Unicode-DFS-2016", + "CC0-1.0", + "Unlicense", + "OpenSSL", +] +confidence-threshold = 0.8 + +[bans] +# Duplicate transitive versions are common in large trees; surface them +# without failing the build. +multiple-versions = "warn" +wildcards = "warn" + +[sources] +# Only crates.io and the git deps we explicitly vet are allowed. +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100644 index 0000000..f26f34e --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Point git at the repo's tracked hooks. Run once after cloning: +# ./scripts/install-hooks.sh +set -euo pipefail +cd "$(dirname "$0")/.." +git config core.hooksPath .githooks +chmod +x .githooks/* 2>/dev/null || true +echo "Installed: core.hooksPath -> .githooks" +echo "Pre-commit will now run fmt + clippy on staged Rust changes." From 94e3d894175f8ad6b1a99c51278f451b72168bac Mon Sep 17 00:00:00 2001 From: RodCor Date: Sat, 30 May 2026 11:18:13 -0300 Subject: [PATCH 2/2] ci: run test job isolated + single-threaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brain test suite exercises the per-user GlobalUser brain at $HOME/.kimetsu and takes a writer lock on ~/.kimetsu/project.lock. Run multi-threaded (cargo's default), concurrent tests contend on that shared lock and one panics ("project writer lock is already held") — a pre-existing flake surfaced now that CI runs the suite. Fix: the test job points HOME/TMP/TEMP/GIT_CEILING_DIRECTORIES at a throwaway dir under RUNNER_TEMP and runs with --test-threads=1, giving each test an isolated project root. Verified locally: full workspace goes from 1 failed to all-green under this setup. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35a4c36..ed29e28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,7 +80,21 @@ jobs: # workspace test build does not pull the ONNX runtime. This keeps # the gate fast and dependency-light; the embeddings flavor is # built + smoke-tested in release.yml. - - run: cargo test --workspace --locked + # + # The brain suite exercises the per-user GlobalUser brain at + # $HOME/.kimetsu and takes a writer lock on ~/.kimetsu/project.lock. + # Run multi-threaded (cargo's default), concurrent tests contend on + # that shared lock and one panics. Point HOME/TMP/GIT ceiling at a + # throwaway dir under the runner temp and serialize so each test gets + # an isolated project root. + - name: cargo test (isolated, serial) + shell: bash + run: | + ISO="${RUNNER_TEMP:-/tmp}/kimetsu-test-home" + mkdir -p "$ISO" + export HOME="$ISO" TMPDIR="$ISO" TMP="$ISO" TEMP="$ISO" + export GIT_CEILING_DIRECTORIES="$ISO" + cargo test --workspace --locked -- --test-threads=1 audit: name: cargo-audit (RUSTSEC)