diff --git a/src/memory/diff/carve_out_tests.rs b/src/memory/diff/carve_out_tests.rs new file mode 100644 index 0000000..dccc2a2 --- /dev/null +++ b/src/memory/diff/carve_out_tests.rs @@ -0,0 +1,38 @@ +//! The `git-diff`-disabled half of the diff module's contract. +//! +//! Sibling test file rather than an inline `mod`, matching this directory's +//! convention (`ledger_tests.rs`, `source_tests.rs`, `types_tests.rs`, …). + +use super::types::{ChangeKind, CrossSourceDiff, DiffSummary, SnapshotItem}; +use super::SnapshotItemSource; + +#[test] +fn inert_diff_types_are_available_without_the_git_diff_feature() { + // Round-tripped, not just serialised: these types exist to cross a + // boundary, so a `Deserialize` derive that got gated away has to fail here + // too. Serialising alone would only exercise half the pair. + let diff = CrossSourceDiff { + checkpoint_id: Some("ckpt_1".into()), + computed_at_ms: 0, + summary: DiffSummary::default(), + per_source: Vec::new(), + }; + let json = serde_json::to_string(&diff).expect("CrossSourceDiff serialises"); + let restored: CrossSourceDiff = + serde_json::from_str(&json).expect("CrossSourceDiff deserialises"); + assert_eq!(restored.checkpoint_id.as_deref(), Some("ckpt_1")); + assert_eq!(restored.computed_at_ms, 0); + assert!(restored.per_source.is_empty()); + let _kind = ChangeKind::Added; +} + +#[test] +fn the_item_source_trait_can_still_be_implemented_without_git() { + struct Empty; + impl SnapshotItemSource for Empty { + fn items_for_source(&self, _source_id: &str) -> Vec { + Vec::new() + } + } + assert!(Empty.items_for_source("anything").is_empty()); +} diff --git a/src/memory/diff/mod.rs b/src/memory/diff/mod.rs index 04c4a37..4736239 100644 --- a/src/memory/diff/mod.rs +++ b/src/memory/diff/mod.rs @@ -40,18 +40,47 @@ //! - `DiffEngine::diff_since_checkpoint` (cross-source) //! - `DiffEngine::cleanup` +//! ## Type carve-out — what compiles WITHOUT `git-diff` +//! +//! `types` and `source` are `serde`/`std`-only: they name no git concept and +//! reach no `git2` symbol. They stay **ungated**, so a host that does not want +//! libgit2 in its dependency graph can still describe a diff — pass a +//! `CrossSourceDiff` around, match on a `ChangeKind`, implement a +//! `SnapshotItemSource` — it simply cannot *compute* one. +//! +//! That distinction is what makes the gate usable downstream. OpenHuman's +//! always-on subconscious profile renders `CrossSourceDiff` / `ChangeKind` in +//! prompts, and stubbing those types rather than sharing them would mean two +//! definitions of the same serde shape drifting apart silently. The rule +//! (OpenHuman's AGENTS.md, "Compile-time domain gates") is: put a domain's +//! inert types in a dependency-free submodule and leave it ungated; gate only +//! the behaviour. +//! +//! Gated on `git-diff`: `ledger` + `ledger_helpers` (the only two modules that +//! touch `git2` directly), `checkpoint` / `diff` / `snapshot` (whose impls are +//! all written against `Ledger`), and `DiffEngine` itself — the engine's inherent +//! methods live in those modules, so an ungated `DiffEngine` would be a handle +//! with nothing to call. + +#[cfg(feature = "git-diff")] use std::path::PathBuf; +#[cfg(feature = "git-diff")] pub mod checkpoint; // Keep the established `memory::diff::diff` path for downstream callers. +#[cfg(feature = "git-diff")] #[allow(clippy::module_inception)] pub mod diff; +#[cfg(feature = "git-diff")] pub mod ledger; +#[cfg(feature = "git-diff")] mod ledger_helpers; +#[cfg(feature = "git-diff")] pub mod snapshot; pub mod source; pub mod types; +#[cfg(feature = "git-diff")] pub use ledger::{Ledger, SnapshotMeta}; pub use source::{extract_item_id, InMemoryItemSource, SnapshotItemSource}; pub use types::{ @@ -66,11 +95,13 @@ pub use types::{ /// injected [`SnapshotItemSource`] that yields a source's already-ingested /// items. All operations are synchronous; git mutations serialise through a /// process-global lock inside the [`Ledger`]. +#[cfg(feature = "git-diff")] pub struct DiffEngine { workspace: PathBuf, items: S, } +#[cfg(feature = "git-diff")] impl DiffEngine { /// Construct an engine rooted at `workspace`, reading items from `items`. pub fn new(workspace: impl Into, items: S) -> Self { @@ -96,6 +127,17 @@ impl DiffEngine { } } -#[cfg(test)] +#[cfg(all(test, feature = "git-diff"))] #[path = "engine_tests.rs"] mod tests; + +/// Proves the type carve-out holds in the build that motivates it. +/// +/// The whole point of leaving `types`/`source` ungated is that a host without +/// libgit2 can still name a diff. Only the disabled build can catch a +/// regression here — re-gating them would compile fine everywhere else and +/// only break downstream, which is exactly the failure this exists to make +/// loud. +#[cfg(all(test, not(feature = "git-diff")))] +#[path = "carve_out_tests.rs"] +mod carve_out_tests; diff --git a/src/memory/mod.rs b/src/memory/mod.rs index 6614394..a07a56e 100644 --- a/src/memory/mod.rs +++ b/src/memory/mod.rs @@ -14,8 +14,10 @@ //! - [`tree`]: summary-tree mechanics (append, seal, summarise, retrieve). //! - [`queue`]: async job model (extract, append, seal, flush, backfill). //! - [`retrieval`]: vector / keyword / graph / tree / hybrid search. -//! - `diff`: git-backed source snapshots, diffs, checkpoints, read markers -//! (feature `git-diff`; gates the heavy native `git2`/libgit2 dependency). +//! - `diff`: git-backed source snapshots, diffs, checkpoints, read markers. +//! Its inert `types`/`source` submodules are always compiled; computing a +//! diff needs feature `git-diff`, which gates the heavy native +//! `git2`/libgit2 dependency. //! - [`entities`] / [`graph`]: entity files and derived co-occurrence graph. //! - [`goals`] / [`tool_memory`]: specialized long-term memory surfaces. //! - [`conversations`] / [`archivist`]: transcript storage and tree archival. @@ -41,10 +43,11 @@ //! any unrecognised persisted value decodes as //! [`types::MemoryTaint::ExternalSync`] — the more restrictive setting — so //! policy gates never under-trust content of unknown provenance. -//! - **Feature-gated modules add no default-build cost.** `diff`, `providers`, -//! and `persona` are compiled out entirely unless their feature is enabled (see -//! the crate-level feature-flag docs in `lib.rs`); code in this module must -//! not assume they are present. +//! - **Feature-gated modules add no default-build cost.** `providers` and +//! `persona` are compiled out entirely unless their feature is enabled, and +//! `diff` keeps only its inert `types`/`source` submodules without `git-diff` +//! (see the crate-level feature-flag docs in `lib.rs`). Code in this module +//! must not assume any of them is present. // ── Shared contracts ──────────────────────────────────────────────────────── pub mod config; @@ -64,9 +67,12 @@ pub mod chunks; pub mod conversations; /// Git-backed source snapshots, diffs, checkpoints, and read markers. /// -/// Gated behind the `git-diff` feature: the entire module (and the heavy native -/// `git2`/libgit2 dependency it needs) compiles out when the feature is off. -#[cfg(feature = "git-diff")] +/// Always compiled, but mostly hollow without the `git-diff` feature: the inert +/// `types` and `source` submodules are `serde`/`std`-only and stay available so +/// a host can still *describe* a diff, while everything that computes one — +/// `Ledger`, `DiffEngine`, and the checkpoint/snapshot/diff operations — is +/// gated along with the heavy native `git2`/libgit2 dependency it needs. See +/// the module's own docs for why the split falls where it does. pub mod diff; pub mod entities; /// Shared filesystem primitives (crash-safe atomic writes).