Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/cli-std/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ There is no phase ladder here. `leg.leg_root` resolves `apps/<project>` and
nothing else, so a change to this crate is authored and committed directly
rather than driven through `e2e -> impl`.

## CLI llm convention

The convention for [Standard Agent CLI Commands](README.md#standard-agent-cli-commands):

1. Fact-bearing content is a `TopicSection::Generated` section or it does not appear.
2. Hand-written prose must not restate machine-derivable facts.
3. `llms.txt` is generated output, checked by `assert_snapshot_matches`, never hand-edited.
4. Each CLI's tests call `assert_topics_render` and `assert_snapshot_matches`.

## Verification

| Gate | Command |
Expand Down
7 changes: 5 additions & 2 deletions crates/cli-std/src/api/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@
//! flags, ...) belong in a `TopicSection::Generated` section so `<tool> llm`
//! always reports what's true right now. `Topic` + `render` are unchanged —
//! every existing CLI keeps compiling and behaving exactly as before.
//!
//! Use [`assert_snapshot_matches`] to verify that committed documentation
//! snapshots (such as `llms.txt`) stay identical to live generation.

pub use crate::application::llm::{
assert_topics_render, render, render_sectioned, Format, RenderableTopic, RenderedSection,
SectionedTopic, Topic, TopicSection,
assert_snapshot_matches, assert_topics_render, render, render_sectioned, Format,
RenderableTopic, RenderedSection, SectionedTopic, Topic, TopicSection,
};

/// Typed, task-navigation protocol for tools that need stronger machine
Expand Down
38 changes: 38 additions & 0 deletions crates/cli-std/src/application/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,44 @@ pub fn assert_topics_render<T: RenderableTopic>(topics: &[T]) {
}
}

/// Assert that a committed snapshot (such as a committed `llms.txt`) matches
/// generated output.
///
/// Normalizes line endings (`\r\n` -> `\n`) and a single trailing newline
/// before comparing. On a mismatch, panics naming the first differing line
/// number and including `regen_hint`.
pub fn assert_snapshot_matches(committed: &str, generated: &str, regen_hint: &str) {
let norm_committed = normalize_snapshot(committed);
let norm_generated = normalize_snapshot(generated);

if norm_committed == norm_generated {
return;
}

let committed_lines: Vec<&str> = norm_committed.lines().collect();
let generated_lines: Vec<&str> = norm_generated.lines().collect();
let max_len = committed_lines.len().max(generated_lines.len());
let mut diff_line = 1;
for i in 0..max_len {
let c = committed_lines.get(i);
let g = generated_lines.get(i);
if c != g {
diff_line = i + 1;
break;
}
}

panic!(
"snapshot mismatch at line {diff_line}: committed and generated content differ. Regenerate with: {regen_hint}"
);
}

fn normalize_snapshot(s: &str) -> String {
let s = s.replace("\r\n", "\n");
let s = s.strip_suffix('\n').unwrap_or(&s);
format!("{s}\n")
}

pub(crate) mod v2;

/// Output format for `llm`.
Expand Down
68 changes: 68 additions & 0 deletions crates/cli-std/src/application/llm/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,71 @@ fn golden_sectioned_topic_output_bytes() {
"{\n \"project\": \"lumen\",\n \"topic\": \"workflow\",\n \"summary\": \"how it works\",\n \"body\": \"# intro prose\\n\\nthe sky is blue\",\n \"sections\": [\n {\n \"id\": \"prose-0\",\n \"kind\": \"prose\",\n \"content\": \"# intro prose\"\n },\n {\n \"id\": \"fact\",\n \"kind\": \"generated\",\n \"content\": \"the sky is blue\"\n }\n ]\n}"
);
}

// --- assert_snapshot_matches (#9) ---

#[test]
fn snapshot_matches_equal_strings_pass() {
assert_snapshot_matches("alpha\nbeta\n", "alpha\nbeta\n", "regen-cmd");
}

#[test]
fn snapshot_matches_line_endings_and_single_trailing_newline_pass() {
// Differing only in \r\n vs \n
assert_snapshot_matches("alpha\r\nbeta\r\n", "alpha\nbeta\n", "regen-cmd");
// Differing only in presence of a single trailing newline
assert_snapshot_matches("alpha\nbeta", "alpha\nbeta\n", "regen-cmd");
assert_snapshot_matches("alpha\nbeta\n", "alpha\nbeta", "regen-cmd");
// Both line endings and trailing newline differences
assert_snapshot_matches("alpha\r\nbeta", "alpha\nbeta\n", "regen-cmd");
}

#[test]
fn snapshot_matches_differing_line_panics_with_line_number_and_hint() {
let result = std::panic::catch_unwind(|| {
assert_snapshot_matches(
"line 1\nline 2\nline 3\n",
"line 1\nline 2 differing\nline 3\n",
"cargo run -- regen",
);
});
assert!(result.is_err(), "differing line must panic");
let err = result.unwrap_err();
let msg = err
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| err.downcast_ref::<&'static str>().copied())
.expect("panic message is string");
assert!(
msg.contains("2"),
"message must mention line number 2: {msg}"
);
assert!(
msg.contains("cargo run -- regen"),
"message must mention regen hint: {msg}"
);

// Mismatch when one string has an extra line
let result_extra = std::panic::catch_unwind(|| {
assert_snapshot_matches(
"line 1\nline 2\n",
"line 1\nline 2\nline 3\n",
"my-tool regen",
);
});
assert!(result_extra.is_err(), "extra line must panic");
let err_extra = result_extra.unwrap_err();
let msg_extra = err_extra
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| err_extra.downcast_ref::<&'static str>().copied())
.expect("panic message is string");
assert!(
msg_extra.contains("3"),
"message must mention line number 3: {msg_extra}"
);
assert!(
msg_extra.contains("my-tool regen"),
"message must mention regen hint: {msg_extra}"
);
}
Loading