hallouminate 0.2.3

A markdown corpus indexer for LLMs to build and query their own per-repo wikis.
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
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
//! Cross-manifest drift test for the plugin pack (issue #107).
//!
//! The plugin ships manifests for two harnesses (Claude Code under
//! `.claude-plugin/`, Codex under `.codex-plugin/`) plus a declarative MCP
//! registration (`.mcp.json`) and two marketplace files. These must agree
//! with each other and with the crate version in `Cargo.toml`, or installs
//! silently drift (the pack shipped 0.1.0 while the crate was at 0.1.3).
//!
//! opencode has no plugin-manifest convention — it loads the MCP server and
//! skills directly (`opencode.json` + `~/.config/opencode/skills/`), so the
//! pack ships no `.opencode-plugin/` manifest for this test to pin.

use std::path::{Path, PathBuf};

use hallouminate::domain::corpus;
use hallouminate::domain::corpus::index_md::{INDEX_END_MARKER, INDEX_START_MARKER};

fn repo_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}

fn read_json(relative: &str) -> serde_json::Value {
    let path = repo_root().join(relative);
    let text =
        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
    serde_json::from_str(&text).unwrap_or_else(|e| panic!("parse {}: {e}", path.display()))
}

fn str_at<'a>(value: &'a serde_json::Value, pointer: &str, file: &str) -> &'a str {
    value
        .pointer(pointer)
        .and_then(|v| v.as_str())
        .unwrap_or_else(|| panic!("{file}: missing string at {pointer}"))
}

#[test]
fn plugin_manifests_share_the_crate_name() {
    let claude = read_json("plugins/hallouminate/.claude-plugin/plugin.json");
    let codex = read_json("plugins/hallouminate/.codex-plugin/plugin.json");
    assert_eq!(
        str_at(&claude, "/name", "claude plugin.json"),
        "hallouminate"
    );
    assert_eq!(str_at(&codex, "/name", "codex plugin.json"), "hallouminate");
}

#[test]
fn plugin_versions_match_the_crate_version() {
    let crate_version = env!("CARGO_PKG_VERSION");
    let claude = read_json("plugins/hallouminate/.claude-plugin/plugin.json");
    let codex = read_json("plugins/hallouminate/.codex-plugin/plugin.json");
    assert_eq!(
        str_at(&claude, "/version", "claude plugin.json"),
        crate_version,
        "claude plugin.json version drifted from Cargo.toml"
    );
    assert_eq!(
        str_at(&codex, "/version", "codex plugin.json"),
        crate_version,
        "codex plugin.json version drifted from Cargo.toml"
    );
}

#[test]
fn claude_marketplace_resolves_to_the_plugin_directory() {
    // The installer resolves `plugins[].source` relative to the repo root and
    // ignores `metadata.pluginRoot` (issue #118), so `source` must carry the
    // full payload path. Mirror the codex sibling's flat resolution; do not
    // reintroduce `pluginRoot`.
    let marketplace = read_json(".claude-plugin/marketplace.json");
    let entry = marketplace
        .pointer("/plugins")
        .and_then(|p| p.as_array())
        .and_then(|plugins| {
            plugins
                .iter()
                .find(|p| p.pointer("/name").and_then(|n| n.as_str()) == Some("hallouminate"))
        })
        .expect("marketplace.json: no plugin entry named hallouminate");
    let source = str_at(entry, "/source", "marketplace.json");
    assert_eq!(source, "./plugins/hallouminate");
    assert_is_plugin_dir(&repo_root().join(source));
}

#[test]
fn codex_marketplace_resolves_to_the_plugin_directory() {
    let marketplace = read_json(".agents/plugins/marketplace.json");
    assert_eq!(
        str_at(&marketplace, "/name", ".agents/plugins/marketplace.json"),
        "hallouminate",
        "codex marketplace must have a stable install selector"
    );
    assert_eq!(
        str_at(
            &marketplace,
            "/plugins/0/name",
            ".agents/plugins/marketplace.json",
        ),
        "hallouminate",
        "codex marketplace entry must name the plugin"
    );
    let path = str_at(
        &marketplace,
        "/plugins/0/source/path",
        ".agents/plugins/marketplace.json",
    );
    assert_eq!(path, "./plugins/hallouminate");
    assert_eq!(
        str_at(
            &marketplace,
            "/plugins/0/source/source",
            ".agents/plugins/marketplace.json",
        ),
        "local",
        "codex marketplace must keep a local path source"
    );
    assert_is_plugin_dir(&repo_root().join(path));
}

#[test]
fn codex_plugin_manifest_declares_bundled_components() {
    let codex = read_json("plugins/hallouminate/.codex-plugin/plugin.json");
    assert_eq!(
        str_at(&codex, "/skills", "codex plugin.json"),
        "./skills/",
        "codex plugin must expose the bundled skills"
    );
    assert_eq!(
        str_at(&codex, "/mcpServers", "codex plugin.json"),
        "./.mcp.json",
        "codex plugin must expose the bundled MCP config"
    );
    assert_eq!(
        str_at(&codex, "/author/name", "codex plugin.json"),
        "Paul Sorensen",
        "codex plugin must include required author metadata"
    );
    assert_eq!(
        str_at(&codex, "/interface/displayName", "codex plugin.json"),
        "Hallouminate",
        "codex plugin must include required interface metadata"
    );
}

#[test]
fn payload_mcp_json_registers_the_path_binary_serve_command() {
    let mcp = read_json("plugins/hallouminate/.mcp.json");
    assert_eq!(
        str_at(&mcp, "/mcpServers/hallouminate/command", ".mcp.json"),
        "hallouminate",
        ".mcp.json must invoke the PATH binary, not an absolute path"
    );
    assert_eq!(
        mcp.pointer("/mcpServers/hallouminate/args"),
        Some(&serde_json::json!(["serve"])),
        ".mcp.json args must be exactly [\"serve\"]"
    );
}

fn assert_is_plugin_dir(resolved: &Path) {
    assert!(
        resolved.join(".claude-plugin/plugin.json").is_file(),
        "{} does not contain .claude-plugin/plugin.json",
        resolved.display()
    );
    assert!(
        resolved.join(".codex-plugin/plugin.json").is_file(),
        "{} does not contain .codex-plugin/plugin.json",
        resolved.display()
    );
    assert!(
        resolved.join("skills/install/SKILL.md").is_file(),
        "{} does not contain the install skill",
        resolved.display()
    );
}

#[test]
fn release_workflow_tag_trigger_stays_anchored_at_v() {
    // The double-publish risk: a `skills-v*` or greedy `**` tag trigger would
    // re-fire release-skills.yml on the `skills-v` tag this workflow itself
    // creates, publishing a second release. Only the `v[0-9]+` pattern is safe.
    let path = repo_root().join(".github/workflows/release-skills.yml");
    let text =
        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
    let workflow: serde_yaml_ng::Value =
        serde_yaml_ng::from_str(&text).unwrap_or_else(|e| panic!("parse {}: {e}", path.display()));

    // serde_yaml_ng parses bare `on` as Value::String("on") (YAML 1.2 semantics —
    // only true/false are booleans, not on/off/yes/no).
    let tags = workflow
        .get("on")
        .and_then(|o| o.get("push"))
        .and_then(|p| p.get("tags"))
        .and_then(|t| t.as_sequence())
        .expect("release-skills.yml: on.push.tags must be a sequence");

    // Exactly the anchored crate-tag pattern must be present.
    let tag_strs: Vec<&str> = tags
        .iter()
        .map(|v| v.as_str().expect("tag pattern must be a string"))
        .collect();
    assert_eq!(
        tag_strs,
        ["v[0-9]+.[0-9]+.[0-9]+*"],
        "release-skills.yml on.push.tags must equal exactly [\"v[0-9]+.[0-9]+.[0-9]+*\"]"
    );

    // Every entry must start with `v` (anchored). No `skills-v` or `**` globs.
    for pattern in &tag_strs {
        assert!(
            pattern.starts_with('v'),
            "tag pattern {pattern:?} must start with 'v' to stay anchored"
        );
        assert!(
            !pattern.starts_with("skills-v"),
            "tag pattern {pattern:?} must not start with 'skills-v' (re-trigger risk)"
        );
        assert!(
            !pattern.starts_with("**"),
            "tag pattern {pattern:?} must not be a greedy ** glob"
        );
    }
}

#[test]
fn every_pack_skill_opens_with_description_frontmatter() {
    // Local mirror of the "Validate skill frontmatter" CI step in
    // release-skills.yml, so a broken SKILL.md fails `cargo test` before push.
    let skills_dir = repo_root().join("plugins/hallouminate/skills");
    let mut checked = Vec::new();
    for entry in std::fs::read_dir(&skills_dir).expect("read skills dir") {
        let entry = entry.expect("dir entry").path();
        let skill = entry.join("SKILL.md");
        if !skill.is_file() {
            continue;
        }
        let text = std::fs::read_to_string(&skill)
            .unwrap_or_else(|e| panic!("read {}: {e}", skill.display()));
        let mut lines = text.lines();
        assert_eq!(
            lines.next(),
            Some("---"),
            "{}: SKILL.md must open with a --- frontmatter block",
            skill.display()
        );
        let frontmatter: Vec<&str> = lines.take_while(|line| *line != "---").collect();
        assert!(
            frontmatter.iter().any(|l| l.starts_with("description:")),
            "{}: frontmatter is missing a description:",
            skill.display()
        );
        checked.push(
            entry
                .file_name()
                .expect("skill dir name")
                .to_string_lossy()
                .into_owned(),
        );
    }
    for required in [
        "install",
        "wiki-ingest",
        "wiki-init",
        "wiki-query",
        "wiki-roadmap",
    ] {
        assert!(
            checked.contains(&required.to_string()),
            "pack skill {required} is missing its SKILL.md (found: {checked:?})"
        );
    }
}

fn read_pack_file(relative: &str) -> String {
    let path = repo_root().join(relative);
    std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
}

/// First non-blank line after the closing `---` fence — the H1-first rule's
/// subject for both hallouminate's chunker and milknado's `extract_title`.
fn first_body_line(text: &str) -> &str {
    let body = text
        .splitn(3, "---\n")
        .nth(2)
        .expect("template must open with a ---fenced frontmatter block");
    body.lines().find(|l| !l.trim().is_empty()).unwrap_or("")
}

/// Content between the two `---` fences.
fn frontmatter_block(text: &str) -> &str {
    text.split("---\n")
        .nth(1)
        .expect("template must open with a ---fenced frontmatter block")
}

/// True when the frontmatter block (not the body) carries a `key:` line.
fn frontmatter_has_key(text: &str, key: &str) -> bool {
    frontmatter_block(text).lines().any(|l| l.starts_with(key))
}

#[test]
fn roadmap_templates_match_the_milknado_import_contract() {
    // Pins the shape milknado's `roadmap import` consumes (milknado
    // src/milknado/domains/wiki/: importer.py + _serialize.py): a leading
    // frontmatter block (stamping `created` via set_frontmatter_field errors
    // without one), a `created:` key that keys the deterministic wiki_ref,
    // and an H1-first title that becomes the node description. Harvest
    // markers stay out — `milknado roadmap export` appends and owns them.
    for relative in [
        "plugins/hallouminate/templates/roadmap/index.md",
        "plugins/hallouminate/templates/roadmap/goal.md",
    ] {
        let text = read_pack_file(relative);
        assert!(
            text.starts_with("---\n"),
            "{relative}: must open with a frontmatter block"
        );
        assert!(
            frontmatter_has_key(&text, "created:"),
            "{relative}: frontmatter must carry created:"
        );
        assert!(
            first_body_line(&text).starts_with("# "),
            "{relative}: first body line must be the H1 title"
        );
        assert!(
            !text.contains("milknado:harvest"),
            "{relative}: harvest markers are milknado-owned"
        );
    }
    let goal = read_pack_file("plugins/hallouminate/templates/roadmap/goal.md");
    assert!(
        frontmatter_has_key(&goal, "prereqs:"),
        "goal template frontmatter must carry prereqs:"
    );
    for required in ["\n## Intent", "\n## Acceptance"] {
        assert!(
            goal.contains(required),
            "goal template must carry {required:?}"
        );
    }
    assert!(
        !goal.contains("## Outcome"),
        "goal template must not pre-add the milknado-owned Outcome section"
    );
}

#[test]
fn wiki_entry_template_follows_the_authoring_conventions() {
    // The conventions SERVER_INSTRUCTIONS pins (src/adapters/mcp/tools.rs):
    // optional frontmatter with the recognized lifecycle keys, H1 as the
    // first body line, footnote citations.
    let text = read_pack_file("plugins/hallouminate/templates/wiki-entry.md");
    assert!(text.starts_with("---\n"), "must demo the frontmatter block");
    for key in ["status:", "last_verified:", "sources:"] {
        assert!(
            frontmatter_has_key(&text, key),
            "frontmatter must demo {key}"
        );
    }
    assert!(
        first_body_line(&text).starts_with("# "),
        "first body line must be the H1 title"
    );
    assert!(
        text.contains("[^1]"),
        "must demo the footnote citation convention"
    );
}

#[test]
fn wiki_roadmap_skill_points_at_the_shipped_templates() {
    let skill = read_pack_file("plugins/hallouminate/skills/wiki-roadmap/SKILL.md");
    assert!(
        skill.contains("templates/roadmap/"),
        "skill must reference the roadmap templates"
    );
    assert!(
        skill.contains("milknado roadmap import"),
        "skill must name the import command the format serves"
    );
    // The skill inlines index/goal skeletons for harnesses that copy only
    // SKILL.md; pin the same contract tokens the template files carry so
    // skeleton drift fails this gate too.
    for token in ["created:", "prereqs:", "## Intent", "## Acceptance"] {
        assert!(
            skill.contains(token),
            "skill's inline skeletons must carry {token:?}"
        );
    }
}

#[test]
fn template_frontmatter_parses_with_the_crates_own_parser() {
    // The placeholders (`<YYYY-MM-DD>`, inline `#` comments) must still form a
    // valid YAML mapping: split_frontmatter is fail-soft, and a malformed
    // block stays in the body — polluting chunks at index time and defeating
    // milknado's `created` stamping (set_frontmatter_field needs a parseable
    // leading block).
    for relative in [
        "plugins/hallouminate/templates/wiki-entry.md",
        "plugins/hallouminate/templates/roadmap/index.md",
        "plugins/hallouminate/templates/roadmap/goal.md",
    ] {
        let text = read_pack_file(relative);
        let (frontmatter, body, _) = corpus::split_frontmatter(&text);
        assert!(
            frontmatter.is_some(),
            "{relative}: frontmatter must parse as a YAML mapping"
        );
        assert!(
            body.trim_start().starts_with("# "),
            "{relative}: the parser-stripped body must open with the H1"
        );
    }
    let entry = read_pack_file("plugins/hallouminate/templates/wiki-entry.md");
    let (frontmatter, _, _) = corpus::split_frontmatter(&entry);
    assert_eq!(
        frontmatter.expect("entry frontmatter").status,
        Some(corpus::LifecycleStatus::Draft),
        "wiki-entry must demo a recognized lifecycle status"
    );
}

#[test]
fn roadmap_index_template_carries_the_daemon_index_markers() {
    // With the marker pair present, the daemon maintains the goal link list
    // between them; if the markers drift from the constants the daemon
    // matches, the template silently opts out of auto link-list maintenance.
    let text = read_pack_file("plugins/hallouminate/templates/roadmap/index.md");
    assert!(
        text.contains(INDEX_START_MARKER) && text.contains(INDEX_END_MARKER),
        "roadmap index template must carry the daemon's exact marker pair"
    );
}

#[test]
fn release_workflow_guards_against_double_publish() {
    // Two safety invariants in release-skills.yml prevent a second `gh release
    // create` from running on a tag whose release already exists, and prevent a
    // version/tag mismatch from silently shipping the wrong pack.
    let path = repo_root().join(".github/workflows/release-skills.yml");
    let text =
        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
    let workflow: serde_yaml_ng::Value =
        serde_yaml_ng::from_str(&text).unwrap_or_else(|e| panic!("parse {}: {e}", path.display()));

    let steps = workflow
        .get("jobs")
        .and_then(|j| j.get("release-skills"))
        .and_then(|j| j.get("steps"))
        .and_then(|s| s.as_sequence())
        .expect("release-skills.yml: jobs.release-skills.steps must be a sequence");

    let release_run = steps
        .iter()
        .find(|s| s.get("name").and_then(|n| n.as_str()) == Some("Create GitHub Release"))
        .and_then(|s| s.get("run"))
        .and_then(|r| r.as_str())
        .expect("release-skills.yml: 'Create GitHub Release' step must have a run field");

    // The existing-release branch: check before creating, refresh assets with
    // --clobber instead of double-publishing.
    assert!(
        release_run.contains("gh release view"),
        "'Create GitHub Release' must check for an existing release with `gh release view`"
    );
    assert!(
        release_run.contains("--clobber"),
        "'Create GitHub Release' must refresh existing assets with --clobber (not double-publish)"
    );

    // The version/tag mismatch guard lives in the 'Read pack version' step.
    let ver_run = steps
        .iter()
        .find(|s| s.get("name").and_then(|n| n.as_str()) == Some("Read pack version"))
        .and_then(|s| s.get("run"))
        .and_then(|r| r.as_str())
        .expect("release-skills.yml: 'Read pack version' step must have a run field");

    // The comparison that aborts when the pushed tag doesn't match plugin.json.
    assert!(
        ver_run.contains("\"$GITHUB_REF_NAME\" != \"v$v\""),
        "'Read pack version' must guard against tag/plugin-version mismatch via GITHUB_REF_NAME != v$v"
    );
}