doctrine 0.4.8

Project tooling CLI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! SL-018 PHASE-03 EX-4/EX-5 — end-to-end over the built binary.
//!
//! Drives the real `doctrine` executable through the corpus-sync surface in temp
//! dirs: the populate-from-embed reach (PHASE-05 — the embed now carries the real
//! orientation corpus), the no-root clean no-op (Charge XI), the `memory sync
//! install` hook wiring (a SEPARATE `SessionStart` entry coexisting with `boot
//! install`'s, OQ-E), and the client gitignore denylist via the full installer.

#![allow(
    clippy::expect_used,
    clippy::tests_outside_test_module,
    reason = "integration test: `expect` is the idiomatic fail-fast, and test fns live at crate root by construction"
)]

use std::path::Path;
use std::process::Command;

const BIN: &str = env!("CARGO_BIN_EXE_doctrine");

/// Run `doctrine <args…>` rooted at `cwd`, returning (success, stdout). Does NOT
/// assert success — the no-root case must exit 0 too, but callers verify intent.
fn run(cwd: &Path, args: &[&str]) -> (bool, String) {
    let out = Command::new(BIN)
        .args(args)
        .current_dir(cwd)
        .output()
        .expect("spawn doctrine");
    (
        out.status.success(),
        String::from_utf8(out.stdout).expect("utf8 stdout"),
    )
}

/// A doctrine repo is anything `root::find` resolves — a `.git` marker suffices.
fn doctrine_repo() -> tempfile::TempDir {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::create_dir(dir.path().join(".git")).expect("mark repo");
    dir
}

#[test]
fn sync_populates_the_shipped_corpus_then_is_idempotent_and_retrievable() {
    // PHASE-05: the embed now carries the real orientation corpus, so an in-repo
    // sync lands every master under shipped/ (gitignored), a re-sync is inert, and
    // a shipped master surfaces through `retrieve` on its scope — the end-to-end
    // reach over the built binary. The `mem.<key>` alias symlinks beside each uid
    // dir are NOT shipped as duplicates (gather_assets admits canonical uids only).
    let repo = doctrine_repo();

    let (ok, stdout) = run(repo.path(), &["memory", "sync", "-y", "-p", &path(&repo)]);
    assert!(ok, "in-repo sync must exit 0: {stdout}");
    assert!(
        stdout.contains(" new, 0 changed") && !stdout.contains("0 new,"),
        "the populated embed must plan writes: {stdout}"
    );
    let shipped = repo.path().join(".doctrine/memory/shipped");
    assert!(shipped.is_dir(), "sync must create shipped/");
    let masters: Vec<_> = std::fs::read_dir(&shipped)
        .expect("read shipped/")
        .filter_map(|e| {
            e.ok()
                .map(|e| e.file_name().into_string().unwrap_or_default())
        })
        .collect();
    assert!(
        masters.len() >= 12,
        "the corpus must ship ≥12 masters (OQ-A skeleton), got {}: {masters:?}",
        masters.len()
    );
    assert!(
        masters.iter().all(|n| n.starts_with("mem_")),
        "only canonical uid dirs ship — no `mem.<key>` alias duplicates: {masters:?}"
    );

    // Re-sync is inert (idempotent) — identical embed vs disk plans no writes.
    let (ok, stdout) = run(repo.path(), &["memory", "sync", "-y", "-p", &path(&repo)]);
    assert!(ok, "re-sync must exit 0: {stdout}");
    assert!(
        stdout.contains("0 new, 0 changed"),
        "a re-sync of the identical corpus must be inert: {stdout}"
    );

    // A shipped master surfaces through retrieve on its command scope.
    let (ok, stdout) = run(
        repo.path(),
        &[
            "memory",
            "retrieve",
            "-p",
            &path(&repo),
            "--command",
            "doctrine",
        ],
    );
    assert!(ok, "retrieve must exit 0: {stdout}");
    assert!(
        stdout.contains("mem.signpost.doctrine.overview")
            && stdout.contains("staleness: reference"),
        "a shipped master must surface via its scope with non-decaying staleness: {stdout}"
    );
}

#[test]
fn sync_outside_a_doctrine_repo_writes_nothing() {
    // `root::find` walks CWD up to `/`, so a true no-root needs an ancestry with
    // zero markers — the default temp base may itself sit under a stray repo. Pick
    // a base whose chain to `/` is marker-free so this exercises the Charge XI
    // branch deterministically rather than an incidental empty-embed no-op.
    let base = marker_free_base();
    let bare = tempfile::Builder::new()
        .tempdir_in(&base)
        .expect("tempdir in marker-free base");
    let (ok, stdout) = run(bare.path(), &["memory", "sync"]);
    assert!(
        ok,
        "no-root sync must exit 0 (the M1 hook is harmless): {stdout}"
    );
    assert!(
        stdout.contains("Not in a doctrine repo"),
        "no-root sync must announce the no-op: {stdout}"
    );
    assert!(
        !bare.path().join(".doctrine").exists(),
        "no-root sync must not write anything"
    );
}

/// The first temp base whose ancestry to `/` carries no root marker, so a tempdir
/// under it resolves to no doctrine root. Panics if every candidate is polluted —
/// a loud, honest failure beats a silently mis-targeted assertion.
fn marker_free_base() -> std::path::PathBuf {
    let markers = [".git", ".jj", ".project", "Cargo.toml"];
    let candidates = [
        std::path::PathBuf::from("/dev/shm"),
        std::path::PathBuf::from("/var/tmp"),
        std::env::temp_dir(),
    ];
    for base in candidates {
        if base.is_dir()
            && base
                .ancestors()
                .all(|a| markers.iter().all(|m| !a.join(m).exists()))
        {
            return base;
        }
    }
    panic!("no marker-free temp base available to exercise the no-root path");
}

#[test]
fn dry_run_prints_the_plan_without_writing() {
    let repo = doctrine_repo();
    let (ok, stdout) = run(
        repo.path(),
        &["memory", "sync", "--dry-run", "-p", &path(&repo)],
    );
    assert!(ok, "{stdout}");
    assert!(
        stdout.contains("[dry-run]"),
        "dry-run must tag its output: {stdout}"
    );
    assert!(!repo.path().join(".doctrine/memory/shipped").exists());
}

#[test]
fn sync_install_wires_a_separate_session_hook_coexisting_with_boot() {
    let repo = doctrine_repo();
    let settings = repo.path().join(".claude/settings.local.json");

    // boot install first (claude harness explicit — a bare repo auto-detects none).
    let (ok, out) = run(
        repo.path(),
        &[
            "boot",
            "install",
            "-p",
            &path(&repo),
            "--agent",
            "claude",
            "-y",
        ],
    );
    assert!(ok, "boot install: {out}");

    // then sync install — a SEPARATE SessionStart entry.
    let (ok, out) = run(
        repo.path(),
        &["memory", "sync", "install", "-p", &path(&repo), "-y"],
    );
    assert!(ok, "sync install: {out}");

    let json = std::fs::read_to_string(&settings).expect("settings written");
    assert!(json.contains(" boot\""), "boot hook present: {json}");
    assert!(
        json.contains(" memory sync\""),
        "sync hook present as a distinct command: {json}"
    );

    // re-running sync install is idempotent — no second sync entry.
    let (ok, _) = run(
        repo.path(),
        &["memory", "sync", "install", "-p", &path(&repo), "-y"],
    );
    assert!(ok);
    let json = std::fs::read_to_string(&settings).expect("settings");
    assert_eq!(
        json.matches("memory sync\"").count(),
        1,
        "sync hook must not duplicate on re-run: {json}"
    );
}

#[test]
fn full_install_gitignores_the_shipped_corpus() {
    let repo = doctrine_repo();
    let (ok, out) = run(repo.path(), &["install", "-p", &path(&repo), "-y"]);
    assert!(ok, "install: {out}");
    let gitignore = std::fs::read_to_string(repo.path().join(".gitignore")).expect("gitignore");
    assert!(
        gitignore.contains(".doctrine/memory/shipped/"),
        "the client denylist must ignore the shipped corpus: {gitignore}"
    );
}

/// The repo path as a `&str` arg (tempdirs are UTF-8 here).
fn path(dir: &tempfile::TempDir) -> String {
    dir.path().to_str().expect("utf8 path").to_owned()
}

// ===========================================================================
// SL-069 PHASE-04 — integration: embed, sync, retrieval surface
// ===========================================================================

/// VT-1 (SL-069): sync from clean state materialises every INV-signatured
/// shipped dir under `.doctrine/memory/shipped/`. Count is derived from the
/// source `memory/` tree (the embed authority) — no hard-coded cardinality.
#[test]
fn sync_produces_all_shipped_dirs() {
    let repo = doctrine_repo();
    let (ok, _) = run(repo.path(), &["memory", "sync", "-y", "-p", &path(&repo)]);
    assert!(ok, "sync must exit 0");
    let shipped = repo.path().join(".doctrine/memory/shipped");
    assert!(shipped.is_dir(), "sync must create shipped/");

    // Count the INV masters materialised.
    let masters: Vec<_> = std::fs::read_dir(&shipped)
        .expect("read shipped/")
        .filter_map(|e| {
            let e = e.ok()?;
            let name = e.file_name().into_string().ok()?;
            if name.starts_with("mem_") && e.file_type().ok()?.is_dir() {
                Some(name)
            } else {
                None
            }
        })
        .collect();

    // Count expected masters from the source `memory/` tree — the embed
    // authority. Symlinks (`mem.<key>`) are skipped; only `mem_*` dirs count.
    let memory_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("memory");
    let expected: Vec<_> = std::fs::read_dir(&memory_root)
        .expect("read memory/")
        .filter_map(|e| {
            let e = e.ok()?;
            let name = e.file_name().into_string().ok()?;
            if name.starts_with("mem_") && e.file_type().ok()?.is_dir() {
                Some(name)
            } else {
                None
            }
        })
        .collect();

    assert!(
        !expected.is_empty(),
        "source memory/ must have at least one master"
    );
    assert_eq!(
        masters.len(),
        expected.len(),
        "sync must materialise every source master ({}), got {}: {masters:?}",
        expected.len(),
        masters.len()
    );
}

/// VT-2 (SL-069): each of the 13 new shipped memories is retrievable via scoped
/// `memory find`, and carries the ADR-002 shipped signature (`repo=""`,
/// `anchor_kind=none`).
#[test]
fn each_new_shipped_memory_finds_by_scoped_search_and_has_shipped_signature() {
    let repo = doctrine_repo();
    let (ok, _) = run(repo.path(), &["memory", "sync", "-y", "-p", &path(&repo)]);
    assert!(ok, "sync must exit 0");

    // Each entry: (memory_key, scope_args for `memory find`). The scope_args
    // MUST match a scope entry in the memory's TOML — the test validates that
    // scoped retrieval actually works, not just UID lookup.
    //
    // When adding a shipped memory: append its entry here.
    let new_memories: &[(&str, &[&str])] = &[
        (
            "mem.signpost.doctrine.install",
            &["--command", "doctrine install"],
        ),
        (
            "mem.concept.doctrine.boot-snapshot",
            &["--command", "doctrine boot"],
        ),
        (
            "mem.concept.doctrine.reading-entities",
            &["--command", "doctrine slice"],
        ),
        (
            "mem.signpost.doctrine.reference-docs",
            &["--path-scope", ".doctrine/using-doctrine.md"],
        ),
        (
            "mem.signpost.doctrine.relating-entities",
            &["--command", "doctrine link"],
        ),
        (
            "mem.signpost.doctrine.recording-memories",
            &["--command", "doctrine memory record"],
        ),
        (
            "mem.signpost.doctrine.backlog",
            &["--command", "doctrine backlog"],
        ),
        ("mem.signpost.doctrine.adrs", &["--command", "doctrine adr"]),
        (
            "mem.signpost.doctrine.specs",
            &["--command", "doctrine spec"],
        ),
        (
            "mem.signpost.doctrine.requirements",
            &["--command", "doctrine coverage"],
        ),
        (
            "mem.signpost.doctrine.audit",
            &["--command", "doctrine review"],
        ),
        (
            "mem.signpost.doctrine.revisions",
            &["--command", "doctrine revision"],
        ),
        (
            "mem.signpost.doctrine.policies-standards",
            &["--command", "doctrine policy"],
        ),
    ];

    let p = path(&repo);
    let shipped = repo.path().join(".doctrine/memory/shipped");

    // Build a uid→key map from the shipped corpus TOML files, parsing with the
    // `toml` crate rather than fragile line-by-line string surgery.
    let mut uid_by_key: std::collections::BTreeMap<String, String> =
        std::collections::BTreeMap::new();
    for entry in std::fs::read_dir(&shipped).expect("read shipped/") {
        let entry = entry.expect("entry");
        let name = entry.file_name().into_string().unwrap_or_default();
        if !name.starts_with("mem_") || !entry.file_type().expect("file_type").is_dir() {
            continue;
        }
        let toml_path = shipped.join(&name).join("memory.toml");
        let toml_text = std::fs::read_to_string(&toml_path).expect("read memory.toml");
        let val: toml::Value =
            toml::from_str(&toml_text).unwrap_or_else(|e| panic!("parse {toml_path:?}: {e}"));
        let key = val["memory_key"]
            .as_str()
            .unwrap_or_else(|| panic!("memory_key missing or non-string in {toml_path:?}"));
        uid_by_key.insert(key.to_string(), name);
    }

    for (key, scope_args) in new_memories {
        let uid = uid_by_key
            .get(*key)
            .unwrap_or_else(|| panic!("key {key} not found in shipped corpus"));

        // Read the shipped TOML to verify ADR-002 signature, using structured
        // access rather than substring grep.
        let toml_path = shipped.join(uid).join("memory.toml");
        let toml_text = std::fs::read_to_string(&toml_path)
            .unwrap_or_else(|e| panic!("read {toml_path:?}: {e}"));
        let val: toml::Value =
            toml::from_str(&toml_text).unwrap_or_else(|e| panic!("parse {toml_path:?}: {e}"));
        assert_eq!(
            val["scope"]["repo"].as_str(),
            Some(""),
            "{key} must have empty repo in shipped TOML"
        );
        assert_eq!(
            val["git"]["anchor_kind"].as_str(),
            Some("none"),
            "{key} must have anchor_kind=none in shipped TOML"
        );

        // Verify findable via scoped search — drives `memory find` with the
        // same scope args that the memory's TOML declares.
        let mut args = vec!["memory", "find", "-p", &p];
        args.extend_from_slice(scope_args);
        let (ok, stdout) = run(repo.path(), &args);
        assert!(ok, "find for {key} (uid {uid}) must exit 0: {stdout}");
        assert!(
            stdout.contains(uid),
            "scoped find for {key} must return uid {uid}:\n{stdout}"
        );
    }
}