From 95496c12f2f9f9ea7f70340bf7742257b6d20922 Mon Sep 17 00:00:00 2001 From: chrischeng-c4 Date: Sat, 24 Oct 2026 22:23:50 +0800 Subject: [PATCH] feat(cli-std): add assert_snapshot_matches helper and document CLI llm convention Add `assert_snapshot_matches` to `cli_std::llm` to compare committed snapshots against generated content, normalizing line endings and a single trailing newline. Re-export the helper in `api::llm` and add unit tests. Document the CLI llm convention in `crates/cli-std/CONTRIBUTING.md`. --- crates/cli-std/CONTRIBUTING.md | 9 +++ crates/cli-std/src/api/llm.rs | 7 ++- crates/cli-std/src/application/llm.rs | 38 ++++++++++++ crates/cli-std/src/application/llm/tests.rs | 68 +++++++++++++++++++++ 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/crates/cli-std/CONTRIBUTING.md b/crates/cli-std/CONTRIBUTING.md index 8d7459be..deb75f32 100644 --- a/crates/cli-std/CONTRIBUTING.md +++ b/crates/cli-std/CONTRIBUTING.md @@ -10,6 +10,15 @@ There is no phase ladder here. `leg.leg_root` resolves `apps/` 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 | diff --git a/crates/cli-std/src/api/llm.rs b/crates/cli-std/src/api/llm.rs index bc1205a5..18eb1fc8 100644 --- a/crates/cli-std/src/api/llm.rs +++ b/crates/cli-std/src/api/llm.rs @@ -11,10 +11,13 @@ //! flags, ...) belong in a `TopicSection::Generated` section so ` 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 diff --git a/crates/cli-std/src/application/llm.rs b/crates/cli-std/src/application/llm.rs index e0de0161..a1963cdd 100644 --- a/crates/cli-std/src/application/llm.rs +++ b/crates/cli-std/src/application/llm.rs @@ -200,6 +200,44 @@ pub fn assert_topics_render(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`. diff --git a/crates/cli-std/src/application/llm/tests.rs b/crates/cli-std/src/application/llm/tests.rs index b8b05200..63adbd93 100644 --- a/crates/cli-std/src/application/llm/tests.rs +++ b/crates/cli-std/src/application/llm/tests.rs @@ -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::() + .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::() + .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}" + ); +}