From e5028017382121de3b966af5819012383d545632 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 13 Oct 2026 20:52:42 +0900 Subject: [PATCH 1/4] Rename portable documentation SDK to lgtmd --- .oxfmtrc.json | 2 +- AGENTS.md | 4 +-- Cargo.lock | 28 +++++++++---------- Cargo.toml | 4 +-- crates/doc-example-config/Cargo.toml | 2 +- crates/doc-example-config/README.md | 6 ++-- crates/doc-example-config/examples/docs.rs | 2 +- .../doc-example-config/examples/serde_json.rs | 6 ++-- .../examples/support/mod.rs | 2 +- crates/doc-example-config/tests/guide.rs | 2 +- crates/{doc-harness => lgtmd}/.gitignore | 0 crates/{doc-harness => lgtmd}/Cargo.toml | 2 +- crates/{doc-harness => lgtmd}/LICENSE | 0 crates/{doc-harness => lgtmd}/README.md | 16 +++++++---- .../dev/tests/test_verify.py | 6 ++-- crates/{doc-harness => lgtmd}/dev/verify.py | 8 +++--- .../examples/configured.rs | 4 +-- .../examples/preview.rs | 16 +++++------ crates/{doc-harness => lgtmd}/src/artifact.rs | 0 .../{doc-harness => lgtmd}/src/authoring.rs | 1 + crates/{doc-harness => lgtmd}/src/layout.rs | 0 crates/{doc-harness => lgtmd}/src/lib.rs | 0 .../{doc-harness => lgtmd}/src/lifecycle.rs | 0 crates/{doc-harness => lgtmd}/src/manifest.rs | 0 crates/{doc-harness => lgtmd}/src/markdown.rs | 0 crates/{doc-harness => lgtmd}/src/model.rs | 0 crates/{doc-harness => lgtmd}/src/paths.rs | 0 crates/{doc-harness => lgtmd}/src/prose.rs | 0 crates/{doc-harness => lgtmd}/src/render.rs | 0 crates/{doc-harness => lgtmd}/src/runner.rs | 0 crates/{doc-harness => lgtmd}/src/store.rs | 0 .../{doc-harness => lgtmd}/src/validation.rs | 0 .../tests/acceptance.rs | 2 +- .../tests/json_review.rs | 2 +- crates/{doc-harness => lgtmd}/tests/layout.rs | 4 +-- .../{doc-harness => lgtmd}/tests/lifecycle.rs | 2 +- crates/{doc-harness => lgtmd}/tests/model.rs | 6 ++-- crates/{doc-harness => lgtmd}/tests/prose.rs | 2 +- .../tests/render_review.rs | 2 +- .../tests/runner_aliases.rs | 2 +- .../tests/runner_config.rs | 2 +- .../tests/section_review.rs | 2 +- crates/{doc-harness => lgtmd}/tests/store.rs | 11 +++----- .../tests/store_layout.rs | 2 +- docs/architecture/executable-documents.md | 10 +++---- docs/development.md | 6 ++-- justfile | 4 +-- ...0003-executable-documentation-framework.md | 5 ++-- src/documentation/artifacts.rs | 4 +-- src/documentation/guide.rs | 13 ++++----- src/documentation/mod.rs | 4 +-- src/documentation/scenarios/hand_tool.rs | 4 +-- src/documentation/scenarios/navigation.rs | 2 +- tools/ci_runner.py | 2 +- tools/tests/test_ci_runner.py | 4 +-- 55 files changed, 104 insertions(+), 104 deletions(-) rename crates/{doc-harness => lgtmd}/.gitignore (100%) rename crates/{doc-harness => lgtmd}/Cargo.toml (95%) rename crates/{doc-harness => lgtmd}/LICENSE (100%) rename crates/{doc-harness => lgtmd}/README.md (98%) rename crates/{doc-harness => lgtmd}/dev/tests/test_verify.py (97%) rename crates/{doc-harness => lgtmd}/dev/verify.py (95%) rename crates/{doc-harness => lgtmd}/examples/configured.rs (98%) rename crates/{doc-harness => lgtmd}/examples/preview.rs (97%) rename crates/{doc-harness => lgtmd}/src/artifact.rs (100%) rename crates/{doc-harness => lgtmd}/src/authoring.rs (99%) rename crates/{doc-harness => lgtmd}/src/layout.rs (100%) rename crates/{doc-harness => lgtmd}/src/lib.rs (100%) rename crates/{doc-harness => lgtmd}/src/lifecycle.rs (100%) rename crates/{doc-harness => lgtmd}/src/manifest.rs (100%) rename crates/{doc-harness => lgtmd}/src/markdown.rs (100%) rename crates/{doc-harness => lgtmd}/src/model.rs (100%) rename crates/{doc-harness => lgtmd}/src/paths.rs (100%) rename crates/{doc-harness => lgtmd}/src/prose.rs (100%) rename crates/{doc-harness => lgtmd}/src/render.rs (100%) rename crates/{doc-harness => lgtmd}/src/runner.rs (100%) rename crates/{doc-harness => lgtmd}/src/store.rs (100%) rename crates/{doc-harness => lgtmd}/src/validation.rs (100%) rename crates/{doc-harness => lgtmd}/tests/acceptance.rs (99%) rename crates/{doc-harness => lgtmd}/tests/json_review.rs (98%) rename crates/{doc-harness => lgtmd}/tests/layout.rs (99%) rename crates/{doc-harness => lgtmd}/tests/lifecycle.rs (99%) rename crates/{doc-harness => lgtmd}/tests/model.rs (99%) rename crates/{doc-harness => lgtmd}/tests/prose.rs (97%) rename crates/{doc-harness => lgtmd}/tests/render_review.rs (98%) rename crates/{doc-harness => lgtmd}/tests/runner_aliases.rs (99%) rename crates/{doc-harness => lgtmd}/tests/runner_config.rs (99%) rename crates/{doc-harness => lgtmd}/tests/section_review.rs (98%) rename crates/{doc-harness => lgtmd}/tests/store.rs (98%) rename crates/{doc-harness => lgtmd}/tests/store_layout.rs (98%) diff --git a/.oxfmtrc.json b/.oxfmtrc.json index f3f81eb..88774eb 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -7,7 +7,7 @@ "/Cargo.lock", "/docs/guide/**", "/crates/doc-example-config/baseline/**", - "/crates/doc-harness/docs/guide/**", + "/crates/lgtmd/docs/guide/**", "/docs/preview/vendor/**", "/vendor/**", "/examples/**", diff --git a/AGENTS.md b/AGENTS.md index e872502..6d10b6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ boundaries. The current Editor still depends on egui and is not a public kernel ## Code organization Keep N3 as one application crate. The workspace also maintains the portable -`executable-docs` SDK in `crates/doc-harness` and its consumer examples in +`lgtmd` SDK in `crates/lgtmd` and its consumer examples in `crates/doc-example-config`; N3's documentation adapter is a real SDK consumer. This extraction is justified by a tested boundary between application replay and document composition/publication. It does not make the editor a public kernel. @@ -96,7 +96,7 @@ capabilities, performance policy, or verification baseline. | `src/native.rs` | Window lifecycle, native events, dialogs, filesystem effects | | `src/settings/` | Typed global preferences, validation, merge policy, storage interface | | `src/documentation/` | Headless replay, virtual input, annotations, capture, scenarios | -| `crates/doc-harness/` | Portable document composition, evidence, audience exports, artifact lifecycle | +| `crates/lgtmd/` | Portable document composition, evidence, audience exports, artifact lifecycle | | `crates/doc-example-config/` | Standalone CLI and library consumers of the documentation SDK | Keep code, identifiers, comments, tests, logs, and authored UI strings in English. diff --git a/Cargo.lock b/Cargo.lock index cc91d0d..c7fe715 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -557,7 +557,7 @@ dependencies = [ name = "doc-example-config" version = "0.0.0" dependencies = [ - "executable-docs", + "lgtmd", "serde", "serde_json", "tempfile", @@ -738,18 +738,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "executable-docs" -version = "0.0.0" -dependencies = [ - "pulldown-cmark", - "same-file", - "serde", - "serde_json", - "sha2", - "tempfile", -] - [[package]] name = "fastrand" version = "2.5.0" @@ -1375,6 +1363,18 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lgtmd" +version = "0.0.0" +dependencies = [ + "pulldown-cmark", + "same-file", + "serde", + "serde_json", + "sha2", + "tempfile", +] + [[package]] name = "libc" version = "0.2.189" @@ -1520,11 +1520,11 @@ dependencies = [ "egui", "egui-wgpu", "egui-winit", - "executable-docs", "glam", "gltf", "image", "js-sys", + "lgtmd", "libc", "percent-encoding", "pollster 1.0.1", diff --git a/Cargo.toml b/Cargo.toml index 682681d..e940b8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ rust-version = "1.95" publish = false [workspace] -members = ["crates/doc-harness", "crates/doc-example-config"] +members = ["crates/lgtmd", "crates/doc-example-config"] default-members = ["."] resolver = "3" @@ -53,7 +53,7 @@ wgpu = "30.0.1" winit = "=0.30.13" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -executable-docs = { path = "crates/doc-harness" } +lgtmd = { path = "crates/lgtmd" } alacritty_terminal = "=0.26.0" egui-winit = { version = "=0.36.2", default-features = false, features = [ "clipboard", diff --git a/crates/doc-example-config/Cargo.toml b/crates/doc-example-config/Cargo.toml index 586e57a..c4837b7 100644 --- a/crates/doc-example-config/Cargo.toml +++ b/crates/doc-example-config/Cargo.toml @@ -20,5 +20,5 @@ serde = { version = "=1.0.229", features = ["derive"] } serde_json = "=1.0.151" [dev-dependencies] -executable-docs = { path = "../doc-harness" } +lgtmd = { path = "../lgtmd" } tempfile = "=3.27.0" diff --git a/crates/doc-example-config/README.md b/crates/doc-example-config/README.md index 1f7e4aa..0fd4882 100644 --- a/crates/doc-example-config/README.md +++ b/crates/doc-example-config/README.md @@ -26,7 +26,7 @@ this package next to the SDK so the relative development dependency resolves: ```text crates/ - doc-harness/ + lgtmd/ doc-example-config/ ``` @@ -54,10 +54,10 @@ commands. After dependencies are cached, `--offline` can verify both consumers without network access. For the combined SDK and consumer development gate, run -`python3 ../doc-harness/dev/verify.py`. Its standalone mode copies both packages +`python3 ../lgtmd/dev/verify.py`. Its standalone mode copies both packages and these retained baselines into a temporary workspace, independently resolving only their dependencies. See the SDK's -[development instructions](../doc-harness/README.md#develop-and-verify-the-portable-packages) +[development instructions](../lgtmd/README.md#develop-and-verify-the-portable-packages) for the minimum-toolchain check, caches, and formatting scope. ## Review intentional documentation changes diff --git a/crates/doc-example-config/examples/docs.rs b/crates/doc-example-config/examples/docs.rs index 73cab64..9e89b4b 100644 --- a/crates/doc-example-config/examples/docs.rs +++ b/crates/doc-example-config/examples/docs.rs @@ -3,7 +3,7 @@ mod support; -use executable_docs::runner::run_cli; +use lgtmd::runner::run_cli; use std::path::Path; fn main() { diff --git a/crates/doc-example-config/examples/serde_json.rs b/crates/doc-example-config/examples/serde_json.rs index 634fce5..a6aacf4 100644 --- a/crates/doc-example-config/examples/serde_json.rs +++ b/crates/doc-example-config/examples/serde_json.rs @@ -1,8 +1,8 @@ //! An executable user explanation built against the existing serde_json crate. //! This is our integration example, not documentation adopted by its maintainers. -use executable_docs::runner::run_cli; -use executable_docs::{Artifact, Doc, Document, Result}; +use lgtmd::runner::run_cli; +use lgtmd::{Artifact, Doc, Document, Result}; use serde_json::error::Category; use std::path::Path; @@ -148,7 +148,7 @@ fn input_json(text: &str) -> Result { #[cfg(test)] mod tests { use super::*; - use executable_docs::{Audience, store}; + use lgtmd::{Audience, store}; #[test] fn both_serde_json_views_match_the_retained_contract() { diff --git a/crates/doc-example-config/examples/support/mod.rs b/crates/doc-example-config/examples/support/mod.rs index 577830a..7b83b78 100644 --- a/crates/doc-example-config/examples/support/mod.rs +++ b/crates/doc-example-config/examples/support/mod.rs @@ -1,5 +1,5 @@ use config_demo::Config; -use executable_docs::{Artifact, Doc, Document, Result}; +use lgtmd::{Artifact, Doc, Document, Result}; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; diff --git a/crates/doc-example-config/tests/guide.rs b/crates/doc-example-config/tests/guide.rs index c77a9dc..0c6e297 100644 --- a/crates/doc-example-config/tests/guide.rs +++ b/crates/doc-example-config/tests/guide.rs @@ -2,7 +2,7 @@ #[allow(dead_code)] mod support; -use executable_docs::{Audience, Document, store}; +use lgtmd::{Audience, Document, store}; use std::path::Path; #[test] diff --git a/crates/doc-harness/.gitignore b/crates/lgtmd/.gitignore similarity index 100% rename from crates/doc-harness/.gitignore rename to crates/lgtmd/.gitignore diff --git a/crates/doc-harness/Cargo.toml b/crates/lgtmd/Cargo.toml similarity index 95% rename from crates/doc-harness/Cargo.toml rename to crates/lgtmd/Cargo.toml index 75874e2..745e80d 100644 --- a/crates/doc-harness/Cargo.toml +++ b/crates/lgtmd/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "executable-docs" +name = "lgtmd" version = "0.0.0" edition = "2024" rust-version = "1.95" diff --git a/crates/doc-harness/LICENSE b/crates/lgtmd/LICENSE similarity index 100% rename from crates/doc-harness/LICENSE rename to crates/lgtmd/LICENSE diff --git a/crates/doc-harness/README.md b/crates/lgtmd/README.md similarity index 98% rename from crates/doc-harness/README.md rename to crates/lgtmd/README.md index ce4e280..3366b8c 100644 --- a/crates/doc-harness/README.md +++ b/crates/lgtmd/README.md @@ -1,4 +1,4 @@ -# Executable documents +# lgtmd Author a document while executing the behavior it explains. Ordinary Rust code supplies the actions and checks; the document collects prose, frozen observations, @@ -15,7 +15,7 @@ documentation website. The example below is also a Rust documentation test. It needs only this crate: ```rust -use executable_docs::{Audience, Doc, Document}; +use lgtmd::{Audience, Doc, Document}; let mut doc = Doc::new("trim-text", "Trim surrounding whitespace")?; doc.heading(2, "Normalize a string")?; @@ -84,6 +84,10 @@ tainted and `finish()` rejects it. A completed document needs at least one successful check. Public block IDs are currently positional; inserting public content can change later block anchors. Check and resource IDs are author-owned. +The built-in encoder IDs remain `executable-docs/text` and `executable-docs/json` +for compatibility with retained evidence receipts. These serialized producer IDs +are independent of the `lgtmd` package and Rust import name. + `finish()` freezes and validates the authored document independently of its future file layout. Export then validates resource use, page and artifact paths, local links, and the complete selected audience. A finished document alone is not a @@ -98,7 +102,7 @@ supported. Each binding expression runs once, even when its name appears more than once. This composition lives in the scenario, without a separate template. ```rust -use executable_docs::{prose, Doc}; +use lgtmd::{prose, Doc}; let mut doc = Doc::new("retry", "Retry an operation")?; let section = doc.heading_with_id("retry-limit", 2, "Choose a retry limit")?; @@ -116,7 +120,7 @@ convenient for shorter sequences. Prefer ordinary blocks for new documents; `markdown_parts` preserves exact Markdown bytes for an existing host format. ```rust -use executable_docs::{prose, Doc}; +use lgtmd::{prose, Doc}; let mut doc = Doc::new("retry-limit", "Retry limit")?; let count = doc.expect_eq("attempts", 3, 3)?; @@ -161,7 +165,7 @@ document functions, fixtures, skills, or application binaries live. Your callbac supplies completed documents. Configure export paths separately from authoring: ```rust,no_run -use executable_docs::{Audience, Doc, ExportLayout, runner::RunnerConfig}; +use lgtmd::{Audience, Doc, ExportLayout, runner::RunnerConfig}; # let project_root = std::env::current_dir().unwrap(); let layout = ExportLayout::default() @@ -174,7 +178,7 @@ let config = RunnerConfig::new(&project_root)? .output(Audience::Contributor, "review/evidence", "internal")? .layout(layout); -executable_docs::runner::run_cli_with(std::env::args().skip(1), &config, || { +lgtmd::runner::run_cli_with(std::env::args().skip(1), &config, || { let mut doc = Doc::new("setup", "Set up the application")?; let value = doc.expect_eq("trimmed", " ready ".trim(), "ready")?; doc.paragraph(("The normalized value is ", &value, "."))?; diff --git a/crates/doc-harness/dev/tests/test_verify.py b/crates/lgtmd/dev/tests/test_verify.py similarity index 97% rename from crates/doc-harness/dev/tests/test_verify.py rename to crates/lgtmd/dev/tests/test_verify.py index 0466136..6e95291 100644 --- a/crates/doc-harness/dev/tests/test_verify.py +++ b/crates/lgtmd/dev/tests/test_verify.py @@ -18,7 +18,7 @@ def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) self.root = Path(temporary.name) / "host with spaces $(not-a-command)" - self.sdk = self.root / "crates/doc-harness" + self.sdk = self.root / "crates/lgtmd" self.example = self.root / "crates/doc-example-config" for package in (self.sdk, self.example): (package / "src").mkdir(parents=True) @@ -47,13 +47,13 @@ def test_copy_preserves_baselines_and_license_without_host_dependencies_or_outpu (self.sdk / "Cargo.lock").write_text("package lockfile is not portable input\n") destination = self.root.parent / "isolated" copied = verify.payload(self.sdk, destination) - self.assertEqual(copied, destination / "crates/doc-harness") + self.assertEqual(copied, destination / "crates/lgtmd") self.assertNotIn("host", (destination / "Cargo.toml").read_text()) self.assertFalse((destination / "Cargo.lock").exists()) self.assertFalse((destination / "rust-toolchain.toml").exists()) for excluded in (".cache", "target", "__pycache__", "Cargo.lock"): self.assertFalse((copied / excluded).exists()) - for package in ("doc-harness", "doc-example-config"): + for package in ("lgtmd", "doc-example-config"): self.assertEqual((destination / "crates" / package / "LICENSE").read_bytes(), b"MIT License\nCopyright notice\n") copied_baseline = destination / "crates/doc-example-config/baseline/reader" for name in ("manifest.json", ".ownership.json"): diff --git a/crates/doc-harness/dev/verify.py b/crates/lgtmd/dev/verify.py similarity index 95% rename from crates/doc-harness/dev/verify.py rename to crates/lgtmd/dev/verify.py index 8523521..2ea85a9 100644 --- a/crates/doc-harness/dev/verify.py +++ b/crates/lgtmd/dev/verify.py @@ -10,9 +10,9 @@ SDK = Path(__file__).resolve().parents[1] -PACKAGES = ("executable-docs", "doc-example-config") +PACKAGES = ("lgtmd", "doc-example-config") WORKSPACE = """[workspace] -members = ["crates/doc-harness", "crates/doc-example-config"] +members = ["crates/lgtmd", "crates/doc-example-config"] resolver = "3" """ @@ -48,7 +48,7 @@ def disposable(directory, names): ignore=disposable, ) (destination / "Cargo.toml").write_text(WORKSPACE, encoding="utf-8") - return destination / "crates/doc-harness" + return destination / "crates/lgtmd" def environment_for(sdk, standalone): @@ -101,7 +101,7 @@ def main(arguments=None, *, sdk=SDK): environment = environment_for(sdk, options.standalone) if not options.standalone: return checks(sdk, cargo, environment, options.offline) - with tempfile.TemporaryDirectory(prefix="executable-docs-") as temporary: + with tempfile.TemporaryDirectory(prefix="lgtmd-") as temporary: isolated = payload(sdk, Path(temporary)) # Resolve only portable dependencies into a temporary lockfile. Never # copy or modify the application's lockfile, patches, or toolchain pin. diff --git a/crates/doc-harness/examples/configured.rs b/crates/lgtmd/examples/configured.rs similarity index 98% rename from crates/doc-harness/examples/configured.rs rename to crates/lgtmd/examples/configured.rs index ae13898..795248d 100644 --- a/crates/doc-harness/examples/configured.rs +++ b/crates/lgtmd/examples/configured.rs @@ -1,7 +1,7 @@ //! The host owns its project root, fixtures, audience destinations and export layout. //! Run: configured PROJECT_ROOT BASELINE_ROOT check|update|build [SDK options] //! PROJECT_ROOT contains fixtures/ports.txt with two nonzero ports, one per line. -use executable_docs::{ +use lgtmd::{ Audience, Doc, Document, ExportLayout, Result, prose, runner::{RunnerConfig, run_cli_with}, }; @@ -76,7 +76,7 @@ fn guide(project: &Path) -> Result> { #[cfg(test)] mod tests { use super::*; - use executable_docs::{lifecycle, runner::RunMode, runner::run}; + use lgtmd::{lifecycle, runner::RunMode, runner::run}; use std::sync::atomic::{AtomicUsize, Ordering}; #[test] diff --git a/crates/doc-harness/examples/preview.rs b/crates/lgtmd/examples/preview.rs similarity index 97% rename from crates/doc-harness/examples/preview.rs rename to crates/lgtmd/examples/preview.rs index 992550c..25c79a4 100644 --- a/crates/doc-harness/examples/preview.rs +++ b/crates/lgtmd/examples/preview.rs @@ -1,5 +1,5 @@ //! Disposable static rendering for human review, separate from guide generation. -use executable_docs::{ +use lgtmd::{ Result, lifecycle::{self, Files, Ownership}, }; @@ -35,7 +35,7 @@ fn preview(source: &Path, destination: &Path) -> Result<()> { files.remove(".ownership.json"); // Validate the complete tree before rendering. In particular, a private or // unrecorded file added to a Reader export must never become a preview asset. - executable_docs::store::check(&source, &files)?; + lgtmd::store::check(&source, &files)?; let output = preview_files(&files)?; lifecycle::build(&destination, &output, Ownership::Dedicated) } @@ -207,7 +207,7 @@ img { max-width: 100%; } table { border-collapse: collapse; width: 100%; } th, t #[cfg(test)] mod tests { use super::*; - use executable_docs::{Artifact, Audience, Doc, Document, ExportLayout}; + use lgtmd::{Artifact, Audience, Doc, Document, ExportLayout}; use std::fs; fn doc(id: &str) -> Doc { @@ -224,7 +224,7 @@ mod tests { let files = document .render_with(Audience::Reader, &ExportLayout::default()) .unwrap(); - executable_docs::store::build(source, &files).unwrap(); + lgtmd::store::build(source, &files).unwrap(); } #[test] @@ -303,8 +303,8 @@ mod tests { ] { let source = root.join(format!("{name}-export")); let destination = root.join(format!("{name}-preview")); - let files = executable_docs::render_with(&documents, audience, &layout).unwrap(); - executable_docs::store::build(&source, &files).unwrap(); + let files = lgtmd::render_with(&documents, audience, &layout).unwrap(); + lgtmd::store::build(&source, &files).unwrap(); let retained = lifecycle::read_tree(&source).unwrap(); preview(&source, &destination).unwrap(); let html = fs::read_to_string(destination.join("manual/start.html")).unwrap(); @@ -411,8 +411,8 @@ mod tests { .unwrap() .page("second", "guide.source") .unwrap(); - let files = executable_docs::render_with(&documents, Audience::Reader, &layout).unwrap(); - executable_docs::store::build(&source, &files).unwrap(); + let files = lgtmd::render_with(&documents, Audience::Reader, &layout).unwrap(); + lgtmd::store::build(&source, &files).unwrap(); assert!( preview(&source, &destination) .unwrap_err() diff --git a/crates/doc-harness/src/artifact.rs b/crates/lgtmd/src/artifact.rs similarity index 100% rename from crates/doc-harness/src/artifact.rs rename to crates/lgtmd/src/artifact.rs diff --git a/crates/doc-harness/src/authoring.rs b/crates/lgtmd/src/authoring.rs similarity index 99% rename from crates/doc-harness/src/authoring.rs rename to crates/lgtmd/src/authoring.rs index 657a27f..075edfd 100644 --- a/crates/doc-harness/src/authoring.rs +++ b/crates/lgtmd/src/authoring.rs @@ -319,6 +319,7 @@ impl Doc { /// Freeze UTF-8 text with the built-in text producer. pub fn text(&mut self, id: &str, text: impl Into) -> Result { + // Persisted encoder IDs remain stable across package renames, as for JSON below. let artifact = Artifact::new( text.into().into_bytes(), "text/plain", diff --git a/crates/doc-harness/src/layout.rs b/crates/lgtmd/src/layout.rs similarity index 100% rename from crates/doc-harness/src/layout.rs rename to crates/lgtmd/src/layout.rs diff --git a/crates/doc-harness/src/lib.rs b/crates/lgtmd/src/lib.rs similarity index 100% rename from crates/doc-harness/src/lib.rs rename to crates/lgtmd/src/lib.rs diff --git a/crates/doc-harness/src/lifecycle.rs b/crates/lgtmd/src/lifecycle.rs similarity index 100% rename from crates/doc-harness/src/lifecycle.rs rename to crates/lgtmd/src/lifecycle.rs diff --git a/crates/doc-harness/src/manifest.rs b/crates/lgtmd/src/manifest.rs similarity index 100% rename from crates/doc-harness/src/manifest.rs rename to crates/lgtmd/src/manifest.rs diff --git a/crates/doc-harness/src/markdown.rs b/crates/lgtmd/src/markdown.rs similarity index 100% rename from crates/doc-harness/src/markdown.rs rename to crates/lgtmd/src/markdown.rs diff --git a/crates/doc-harness/src/model.rs b/crates/lgtmd/src/model.rs similarity index 100% rename from crates/doc-harness/src/model.rs rename to crates/lgtmd/src/model.rs diff --git a/crates/doc-harness/src/paths.rs b/crates/lgtmd/src/paths.rs similarity index 100% rename from crates/doc-harness/src/paths.rs rename to crates/lgtmd/src/paths.rs diff --git a/crates/doc-harness/src/prose.rs b/crates/lgtmd/src/prose.rs similarity index 100% rename from crates/doc-harness/src/prose.rs rename to crates/lgtmd/src/prose.rs diff --git a/crates/doc-harness/src/render.rs b/crates/lgtmd/src/render.rs similarity index 100% rename from crates/doc-harness/src/render.rs rename to crates/lgtmd/src/render.rs diff --git a/crates/doc-harness/src/runner.rs b/crates/lgtmd/src/runner.rs similarity index 100% rename from crates/doc-harness/src/runner.rs rename to crates/lgtmd/src/runner.rs diff --git a/crates/doc-harness/src/store.rs b/crates/lgtmd/src/store.rs similarity index 100% rename from crates/doc-harness/src/store.rs rename to crates/lgtmd/src/store.rs diff --git a/crates/doc-harness/src/validation.rs b/crates/lgtmd/src/validation.rs similarity index 100% rename from crates/doc-harness/src/validation.rs rename to crates/lgtmd/src/validation.rs diff --git a/crates/doc-harness/tests/acceptance.rs b/crates/lgtmd/tests/acceptance.rs similarity index 99% rename from crates/doc-harness/tests/acceptance.rs rename to crates/lgtmd/tests/acceptance.rs index 4a72629..c0cec95 100644 --- a/crates/doc-harness/tests/acceptance.rs +++ b/crates/lgtmd/tests/acceptance.rs @@ -1,4 +1,4 @@ -use executable_docs::{Artifact, Audience, Doc, Document}; +use lgtmd::{Artifact, Audience, Doc, Document}; use std::collections::BTreeMap; use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/crates/doc-harness/tests/json_review.rs b/crates/lgtmd/tests/json_review.rs similarity index 98% rename from crates/doc-harness/tests/json_review.rs rename to crates/lgtmd/tests/json_review.rs index 68efe70..3df1ef3 100644 --- a/crates/doc-harness/tests/json_review.rs +++ b/crates/lgtmd/tests/json_review.rs @@ -1,4 +1,4 @@ -use executable_docs::{Audience, Doc, Document}; +use lgtmd::{Audience, Doc, Document}; use serde_json::{Map, Value, json}; use std::collections::{BTreeMap, HashMap}; diff --git a/crates/doc-harness/tests/layout.rs b/crates/lgtmd/tests/layout.rs similarity index 99% rename from crates/doc-harness/tests/layout.rs rename to crates/lgtmd/tests/layout.rs index 2e81160..59e1c1f 100644 --- a/crates/doc-harness/tests/layout.rs +++ b/crates/lgtmd/tests/layout.rs @@ -1,4 +1,4 @@ -use executable_docs::{Artifact, Audience, Doc, Document, ExportLayout}; +use lgtmd::{Artifact, Audience, Doc, Document, ExportLayout}; use pulldown_cmark::{Event, Parser, Tag}; use serde_json::Value; @@ -29,7 +29,7 @@ fn default_layout_preserves_all_export_bytes_and_freezes_no_repository_paths() { ); assert_eq!( ordinary, - executable_docs::render_with( + lgtmd::render_with( std::slice::from_ref(&document), audience, &ExportLayout::default() diff --git a/crates/doc-harness/tests/lifecycle.rs b/crates/lgtmd/tests/lifecycle.rs similarity index 99% rename from crates/doc-harness/tests/lifecycle.rs rename to crates/lgtmd/tests/lifecycle.rs index 26add60..eb6592d 100644 --- a/crates/doc-harness/tests/lifecycle.rs +++ b/crates/lgtmd/tests/lifecycle.rs @@ -1,4 +1,4 @@ -use executable_docs::{ +use lgtmd::{ Result, lifecycle::{self, Files, Ownership}, }; diff --git a/crates/doc-harness/tests/model.rs b/crates/lgtmd/tests/model.rs similarity index 99% rename from crates/doc-harness/tests/model.rs rename to crates/lgtmd/tests/model.rs index ac0f8b4..85d4a44 100644 --- a/crates/doc-harness/tests/model.rs +++ b/crates/lgtmd/tests/model.rs @@ -1,4 +1,4 @@ -use executable_docs::{Artifact, Audience, BindingValue, Doc, Document, IntoProse}; +use lgtmd::{Artifact, Audience, BindingValue, Doc, Document, IntoProse}; use serde_json::{Value, json}; fn doc() -> Doc { @@ -20,7 +20,7 @@ fn prose_macro_combines_many_mixed_parts_and_evaluates_each_once_in_order() { .unwrap(); let text = doc.text("result", "saved").unwrap(); let mut evaluated = Vec::new(); - doc.markdown_parts(executable_docs::prose![ + doc.markdown_parts(lgtmd::prose![ { evaluated.push(1); "First: " @@ -56,7 +56,7 @@ fn prose_macro_combines_many_mixed_parts_and_evaluates_each_once_in_order() { .map(|reference| reference["id"].as_str().unwrap()) .collect(); assert_eq!(references, ["count", "save", "result", "count", "save"]); - let empty = executable_docs::prose![]; + let empty = lgtmd::prose![]; assert!(empty.is_empty()); } diff --git a/crates/doc-harness/tests/prose.rs b/crates/lgtmd/tests/prose.rs similarity index 97% rename from crates/doc-harness/tests/prose.rs rename to crates/lgtmd/tests/prose.rs index 3e9494e..f859e35 100644 --- a/crates/doc-harness/tests/prose.rs +++ b/crates/lgtmd/tests/prose.rs @@ -1,4 +1,4 @@ -use executable_docs::{Audience, BindingValue, Doc, Document, IntoProse, Prose, prose}; +use lgtmd::{Audience, BindingValue, Doc, Document, IntoProse, Prose, prose}; use std::cell::Cell; #[test] diff --git a/crates/doc-harness/tests/render_review.rs b/crates/lgtmd/tests/render_review.rs similarity index 98% rename from crates/doc-harness/tests/render_review.rs rename to crates/lgtmd/tests/render_review.rs index b3f7dcc..1831ffd 100644 --- a/crates/doc-harness/tests/render_review.rs +++ b/crates/lgtmd/tests/render_review.rs @@ -1,4 +1,4 @@ -use executable_docs::{Artifact, Audience, BindingValue, Doc, Document, ExportLayout}; +use lgtmd::{Artifact, Audience, BindingValue, Doc, Document, ExportLayout}; use pulldown_cmark::{Event, Parser, Tag, html}; fn doc() -> Doc { diff --git a/crates/doc-harness/tests/runner_aliases.rs b/crates/lgtmd/tests/runner_aliases.rs similarity index 99% rename from crates/doc-harness/tests/runner_aliases.rs rename to crates/lgtmd/tests/runner_aliases.rs index 7677084..d4c6566 100644 --- a/crates/doc-harness/tests/runner_aliases.rs +++ b/crates/lgtmd/tests/runner_aliases.rs @@ -1,4 +1,4 @@ -use executable_docs::{ +use lgtmd::{ Audience, Doc, Document, runner::{RunMode, RunnerConfig, run}, }; diff --git a/crates/doc-harness/tests/runner_config.rs b/crates/lgtmd/tests/runner_config.rs similarity index 99% rename from crates/doc-harness/tests/runner_config.rs rename to crates/lgtmd/tests/runner_config.rs index 22037a6..312251d 100644 --- a/crates/doc-harness/tests/runner_config.rs +++ b/crates/lgtmd/tests/runner_config.rs @@ -1,4 +1,4 @@ -use executable_docs::{ +use lgtmd::{ Audience, Doc, Document, ExportLayout, runner::{RunMode, RunnerConfig, run, run_cli_with}, }; diff --git a/crates/doc-harness/tests/section_review.rs b/crates/lgtmd/tests/section_review.rs similarity index 98% rename from crates/doc-harness/tests/section_review.rs rename to crates/lgtmd/tests/section_review.rs index a9305b0..d2a83df 100644 --- a/crates/doc-harness/tests/section_review.rs +++ b/crates/lgtmd/tests/section_review.rs @@ -1,4 +1,4 @@ -use executable_docs::{Audience, Doc, Document}; +use lgtmd::{Audience, Doc, Document}; use serde_json::Value; fn doc() -> Doc { diff --git a/crates/doc-harness/tests/store.rs b/crates/lgtmd/tests/store.rs similarity index 98% rename from crates/doc-harness/tests/store.rs rename to crates/lgtmd/tests/store.rs index ee1211a..e80c817 100644 --- a/crates/doc-harness/tests/store.rs +++ b/crates/lgtmd/tests/store.rs @@ -1,4 +1,4 @@ -use executable_docs::{Artifact, Audience, Doc, Document, runner::run_cli, store}; +use lgtmd::{Artifact, Audience, Doc, Document, runner::run_cli, store}; use std::{ collections::BTreeMap, fs, @@ -48,10 +48,7 @@ fn retained_receipts_accept_unknown_fields_and_null_or_omitted_check_payloads() let observation = doc.expect_eq("nothing", Option::::None, None).unwrap(); doc.require("private-check", true).unwrap(); let label = doc - .binding( - "label", - executable_docs::BindingValue::Code("result".into()), - ) + .binding("label", lgtmd::BindingValue::Code("result".into())) .unwrap(); doc.paragraph(("The ", label, " is ", observation)).unwrap(); let resource = doc @@ -676,8 +673,8 @@ fn a_case_alias_of_existing_baseline_fails_before_generation() { assert_eq!(snapshot(&baseline), saved); } -fn document() -> executable_docs::Document { - let mut doc = executable_docs::Doc::new("example", "Example").unwrap(); +fn document() -> lgtmd::Document { + let mut doc = lgtmd::Doc::new("example", "Example").unwrap(); let value = doc.expect_eq("value", 8080, 8080).unwrap(); doc.paragraph(("Port ", &value, ".")).unwrap(); doc.note("Contributor-only explanation").unwrap(); diff --git a/crates/doc-harness/tests/store_layout.rs b/crates/lgtmd/tests/store_layout.rs similarity index 98% rename from crates/doc-harness/tests/store_layout.rs rename to crates/lgtmd/tests/store_layout.rs index a3e5fc2..55a1b83 100644 --- a/crates/doc-harness/tests/store_layout.rs +++ b/crates/lgtmd/tests/store_layout.rs @@ -1,4 +1,4 @@ -use executable_docs::{Audience, Doc, Document, ExportLayout, lifecycle, store}; +use lgtmd::{Audience, Doc, Document, ExportLayout, lifecycle, store}; use serde_json::Value; use std::{fs, path::PathBuf}; diff --git a/docs/architecture/executable-documents.md b/docs/architecture/executable-documents.md index 433fb3e..990e239 100644 --- a/docs/architecture/executable-documents.md +++ b/docs/architecture/executable-documents.md @@ -1,8 +1,8 @@ # Executable documents and the N3 adapter -The maintained `executable-docs` SDK separates document composition and artifact +The maintained `lgtmd` SDK separates document composition and artifact publication from the application being documented. N3 remains one application -crate. The SDK lives in [crates/doc-harness](../../crates/doc-harness/README.md), +crate. The SDK lives in [crates/lgtmd](../../crates/lgtmd/README.md), and [crates/doc-example-config](../../crates/doc-example-config/README.md) provides a real CLI consumer and an integration with the existing serde_json library. Neither SDK use nor those examples requires N3's renderer, UI stack, website, @@ -28,7 +28,7 @@ correct application behavior from an artifact's existence or a declared MIME type. Meaningful assertions, producer metadata, fixtures, runtime drivers, review, and documentation organization remain the consumer's responsibility. -The [SDK README](../../crates/doc-harness/README.md) is the API and manifest +The [SDK README](../../crates/lgtmd/README.md) is the API and manifest reference. N3's [documentation pipeline](documentation-pipeline.md) owns product replay, timing, captures, and renderer-specific evidence. This document records the boundary between them. @@ -86,7 +86,7 @@ paths need distinct ASCII components beyond any shared ancestors. Case-only or Unicode-only distinctions require preexisting separate directories rather than guessing a filesystem's alias rules. Unicode parent directories and later path components remain supported. -The [configured SDK example](../../crates/doc-harness/examples/configured.rs) +The [configured SDK example](../../crates/lgtmd/examples/configured.rs) exercises a source-only host, external baselines, nested pages, renamed audience directories, private resources, and preservation of unrelated files. This boundary does not add a configuration file format, plugin loader, or repository abstraction. @@ -192,7 +192,7 @@ review. It does not approve product behavior, publication, or a release. ## Verification and remaining validation `just doc-framework-test` delegates to the SDK-owned -[verification entry point](../../crates/doc-harness/dev/verify.py): scoped +[verification entry point](../../crates/lgtmd/dev/verify.py): scoped formatting, Clippy, tooling regressions, SDK and consumer tests, doctests, and API docs without N3's GPU harness. Each consumer checks retained Reader and Contributor outputs. `just doc-framework-smoke --offline` copies those packages diff --git a/docs/development.md b/docs/development.md index 6cf9e57..e9e0eeb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,7 +1,7 @@ # Developing N3 The application is a single Rust package named `n3`, with private implementation -modules. The workspace also maintains the portable `executable-docs` SDK and its +modules. The workspace also maintains the portable `lgtmd` SDK and its consumer examples under `crates/`; the root package remains the default Cargo target. Read [AGENTS.md](../AGENTS.md) for development principles and iteration guidance. [Testing](../TESTING.md) indexes verification workflows, reference suites, @@ -422,7 +422,7 @@ navigation adapters, and scene renderer. Assertions verify the intended outcome. The portable SDK owns document composition, frozen evidence, and shared artifact publication; it does not own N3's input or renderer. See [executable documents](architecture/executable-documents.md) for the maintained -boundary and [the SDK README](../crates/doc-harness/README.md) for its API. +boundary and [the SDK README](../crates/lgtmd/README.md) for its API. ```sh just docs @@ -508,7 +508,7 @@ and API documentation without starting N3's renderer. This gate also runs inside standalone copy on the declared Rust 1.95 minimum, once that toolchain and the portable dependencies are cached. Its fresh temporary lockfile excludes N3's dependencies, patches, and workspace configuration. See the -[SDK development workflow](../crates/doc-harness/README.md#develop-and-verify-the-portable-packages) +[SDK development workflow](../crates/lgtmd/README.md#develop-and-verify-the-portable-packages) for direct commands and cache handling. The [consumer README](../crates/doc-example-config/README.md) documents direct Cargo commands, package-local baselines, and candidate review. These examples exercise diff --git a/justfile b/justfile index 04cdd11..4edee2c 100644 --- a/justfile +++ b/justfile @@ -183,11 +183,11 @@ test *args: # Verify the maintained documentation SDK, consumers, examples, and exact baselines. # Default application build/run/test routing remains on N3. doc-framework-test: - python3 crates/doc-harness/dev/verify.py + python3 crates/lgtmd/dev/verify.py # Verify a disposable portable workspace, including the declared Rust minimum. doc-framework-smoke *args: - python3 crates/doc-harness/dev/verify.py --standalone --toolchain 1.95.0 "$@" + python3 crates/lgtmd/dev/verify.py --standalone --toolchain 1.95.0 "$@" # Open the local guide preview, check it, or intentionally regenerate it. docs mode="serve" *args: diff --git a/rfcs/0003-executable-documentation-framework.md b/rfcs/0003-executable-documentation-framework.md index b3baed1..82c9bb6 100644 --- a/rfcs/0003-executable-documentation-framework.md +++ b/rfcs/0003-executable-documentation-framework.md @@ -9,7 +9,8 @@ The user authorized local implementation after reviewing the initial plan. The maintained SDK and representative integrations now live in this repository; [the architecture contract](../docs/architecture/executable-documents.md) records the implemented boundary. Broader design sketches below remain proposals where -they exceed that boundary. The provisional package is unpublished. +they exceed that boundary. The maintained SDK is now named `lgtmd` and remains +unpublished; historical sections retain the prototype's names. Authoring requirements: concise, honest authoring and scenarios that write the document's text, resources, and explicit contributor notes themselves. The primary @@ -679,7 +680,7 @@ infrastructure, publication, and the repository move remain deferred. The local implementation now includes: -- [An unpublished SDK](../crates/doc-harness/README.md) with Rust-authored prose, +- [An unpublished SDK](../crates/lgtmd/README.md) with Rust-authored prose, checked values, observed bindings, frozen resources, contributor notes, strict audience filtering, a structured manifest, and a project-owned runner. - One filesystem lifecycle used by the SDK and N3. Dedicated trees preserve N3's diff --git a/src/documentation/artifacts.rs b/src/documentation/artifacts.rs index 0016fdc..b77c511 100644 --- a/src/documentation/artifacts.rs +++ b/src/documentation/artifacts.rs @@ -1,6 +1,6 @@ //! Strict generated-tree ownership, bindings, validation and publication. use super::*; -use executable_docs::{ +use lgtmd::{ Audience, lifecycle::{self, Ownership}, }; @@ -414,7 +414,7 @@ mod renderer_tests { } assert!(candidate_destination(&repository.join("Cargo.toml"), &repository).is_err()); // This is a read-only preflight: the fresh destination remains absent. - let fresh = repository.join(".cache/executable-docs-never-created-by-preflight"); + let fresh = repository.join(".cache/lgtmd-never-created-by-preflight"); assert_eq!(candidate_destination(&fresh, &repository).unwrap(), fresh); assert!(!fresh.exists()); } diff --git a/src/documentation/guide.rs b/src/documentation/guide.rs index 165a618..f4f3806 100644 --- a/src/documentation/guide.rs +++ b/src/documentation/guide.rs @@ -2,7 +2,7 @@ //! stay in Session; the portable document owns composition and immutable evidence. use super::{Result, Session}; use crate::controls::Control; -use executable_docs::{Artifact, Binding, BindingValue, Doc, Resource}; +use lgtmd::{Artifact, Binding, BindingValue, Doc, Resource}; use std::collections::{BTreeMap, BTreeSet}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -138,7 +138,7 @@ impl Guide { pub(super) fn finish(mut self, session: &mut Session<'_>) -> Result<()> { let registered: BTreeSet<_> = self.media.keys().cloned().collect(); let captured: BTreeSet<_> = session.images.keys().cloned().collect(); - executable_docs::lifecycle::check_inventory(&captured, ®istered)?; + lgtmd::lifecycle::check_inventory(&captured, ®istered)?; if session.authored.is_some() { return Err("A scenario must finish exactly one authored guide".into()); } @@ -154,11 +154,8 @@ impl Guide { // supports public captured media plus private prose notes; a private // capture must fail instead of leaking through that legacy inventory. // Full portable exports support private resources without this limit. - for audience in [ - executable_docs::Audience::Reader, - executable_docs::Audience::Contributor, - ] { - executable_docs::lifecycle::compare( + for audience in [lgtmd::Audience::Reader, lgtmd::Audience::Contributor] { + lgtmd::lifecycle::compare( &session.images, &document.resource_files(audience)?, ).map_err(|error| format!("N3 authored guides require every capture in the reader narrative and no extra resource files: {error}"))?; @@ -172,7 +169,7 @@ impl Guide { mod tests { use super::*; use crate::documentation::{Capture, GENERATED, HEIGHT, WIDTH, hand_tool, root}; - use executable_docs::Audience; + use lgtmd::Audience; #[test] fn authored_hand_tool_reuses_frozen_evidence_and_keeps_notes_private() { diff --git a/src/documentation/mod.rs b/src/documentation/mod.rs index 829e020..b890bad 100644 --- a/src/documentation/mod.rs +++ b/src/documentation/mod.rs @@ -140,7 +140,7 @@ pub type Result = std::result::Result; #[cfg(test)] #[path = "tests/doc_navigation_tests.rs"] mod doc_navigation_tests; -type Artifacts = executable_docs::lifecycle::Files; +type Artifacts = lgtmd::lifecycle::Files; // Keep the actual editor canvas spacious at Full HD resolution. The shared // documentation frame adds its window chrome around this unscaled capture. const WIDTH: u32 = 1920; @@ -220,7 +220,7 @@ pub struct Session<'a> { annotations: Vec, recorder: Option, animations: BTreeSet, - authored: Option, + authored: Option, #[cfg(test)] extra_layout_pass: bool, } diff --git a/src/documentation/scenarios/hand_tool.rs b/src/documentation/scenarios/hand_tool.rs index 66b354f..108f1c1 100644 --- a/src/documentation/scenarios/hand_tool.rs +++ b/src/documentation/scenarios/hand_tool.rs @@ -1,8 +1,8 @@ use super::{Result, Session, guide::Guide, pointer}; use crate::{controls::Control, doc_harness::ClipSpec, editor::Tool}; -use executable_docs::Resource; -use executable_docs::prose; use glam::{Vec2, Vec3}; +use lgtmd::Resource; +use lgtmd::prose; use std::time::Duration; fn geometry(s: &Session<'_>) -> (Vec2, f32, Vec3) { diff --git a/src/documentation/scenarios/navigation.rs b/src/documentation/scenarios/navigation.rs index d343eb2..8a215db 100644 --- a/src/documentation/scenarios/navigation.rs +++ b/src/documentation/scenarios/navigation.rs @@ -1,7 +1,7 @@ use super::{Result, Session, guide::Guide}; use crate::{controls::Control, pointer_policy::DRAG_THRESHOLD, scroll_input::ScrollPhase}; -use executable_docs::prose; use glam::{Mat4, Vec2, Vec3}; +use lgtmd::prose; use std::time::Duration; fn matrix(s: &Session<'_>) -> Mat4 { diff --git a/tools/ci_runner.py b/tools/ci_runner.py index 55ca2aa..64ace8a 100644 --- a/tools/ci_runner.py +++ b/tools/ci_runner.py @@ -55,7 +55,7 @@ cargo test --locked --features viewport-measure --lib scene_only_presentation_matches_egui_pixels_and_restores_texture_after_resize """ DOC_FRAMEWORK_CHECKS = """\ -python3 crates/doc-harness/dev/verify.py +python3 crates/lgtmd/dev/verify.py """ CI_SCRIPT = CI_CHECKS_PREFIX + DOC_FRAMEWORK_CHECKS + BENCHMARK_RUST_CHECKS + "exec cargo test --locked\n" CI_PARTITION_SCRIPTS = { diff --git a/tools/tests/test_ci_runner.py b/tools/tests/test_ci_runner.py index 8c54fde..acd0819 100644 --- a/tools/tests/test_ci_runner.py +++ b/tools/tests/test_ci_runner.py @@ -107,7 +107,7 @@ def test_ci_runs_full_checks_without_recursing_through_just_or_runner(self): "node --test tools/tests/web_wrapper.test.mjs", "python3 -m unittest discover -s tools/benchmark/tests -t . -p 'test_*.py'", "node --test tools/benchmark/tests/*.test.mjs", - "python3 crates/doc-harness/dev/verify.py", + "python3 crates/lgtmd/dev/verify.py", "cargo test --locked --features viewport-measure --lib measurement::", "cargo test --locked --features viewport-measure --lib scene_only_presentation_matches_egui_pixels_and_restores_texture_after_resize", "exec cargo test --locked", @@ -169,7 +169,7 @@ def test_documentation_framework_failure_stops_the_remaining_rust_gates(self): python = commands / "python3" python.write_text( "#!/bin/sh\n" - "if [ \"$1\" = crates/doc-harness/dev/verify.py ]; then\n" + "if [ \"$1\" = crates/lgtmd/dev/verify.py ]; then\n" " printf '%s\\n' 'portable framework gate' >> \"$N3_TEST_RECORD\"\n" " exit 19\n" "fi\n" From a4cf59b8955b00eadfb61d19fecff4a99559f288 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 13 Oct 2026 20:53:11 +0900 Subject: [PATCH 2/4] Incubate Rust and TypeScript executable documentation with React example --- .github/workflows/ci.yml | 20 + .oxfmtrc.json | 1 + AGENTS.md | 12 + Cargo.lock | 9 + Cargo.toml | 2 +- LTGMD-TODO.md | 34 + TODO.md | 2 +- crates/lgtmd-node/Cargo.toml | 17 + crates/lgtmd-node/LICENSE | 21 + crates/lgtmd-node/src/main.rs | 100 ++ crates/lgtmd/README.md | 37 +- crates/lgtmd/examples/conformance.rs | 38 + crates/lgtmd/src/artifact.rs | 16 + crates/lgtmd/src/authoring.rs | 168 +- crates/lgtmd/src/lib.rs | 5 +- crates/lgtmd/src/manifest.rs | 28 +- crates/lgtmd/src/model.rs | 60 +- crates/lgtmd/src/render.rs | 30 +- crates/lgtmd/src/runner.rs | 13 +- crates/lgtmd/src/transport.rs | 588 +++++++ crates/lgtmd/tests/code_views.rs | 108 ++ crates/lgtmd/tests/fixtures/portable.json | 529 +++++++ crates/lgtmd/tests/observations.rs | 266 ++++ crates/lgtmd/tests/transport.rs | 103 ++ docs/architecture/executable-documents.md | 48 +- docs/development.md | 10 + examples/lgtmd-web/.gitignore | 3 + examples/lgtmd-web/README.md | 188 +++ .../lgtmd-web/browser/application-test.ts | 30 + .../lgtmd-web/browser/filter-records.spec.ts | 59 + examples/lgtmd-web/guides/01-record-names.ts | 23 + examples/lgtmd-web/guides/02-resources.ts | 105 ++ examples/lgtmd-web/guides/shared.ts | 16 + examples/lgtmd-web/guides/supplied-context.ts | 12 + examples/lgtmd-web/index.html | 12 + examples/lgtmd-web/package.json | 25 + examples/lgtmd-web/playwright.config.ts | 41 + examples/lgtmd-web/run.ts | 30 + examples/lgtmd-web/server/index.mjs | 79 + examples/lgtmd-web/src/App.tsx | 86 ++ examples/lgtmd-web/src/main.tsx | 13 + examples/lgtmd-web/src/records.ts | 11 + examples/lgtmd-web/src/styles.css | 64 + examples/lgtmd-web/tsconfig.json | 14 + examples/lgtmd-web/vite.config.ts | 25 + justfile | 13 + package-lock.json | 1364 +++++++++++++++++ package.json | 22 + packages/lgtmd/.gitignore | 3 + packages/lgtmd/LICENSE | 21 + packages/lgtmd/README.md | 138 ++ packages/lgtmd/dev/tests/test_verify.py | 55 + packages/lgtmd/dev/verify.py | 342 +++++ packages/lgtmd/package.json | 40 + packages/lgtmd/src/authoring.ts | 481 ++++++ packages/lgtmd/src/compiler.ts | 133 ++ packages/lgtmd/src/index.ts | 5 + packages/lgtmd/src/lifecycle.ts | 133 ++ packages/lgtmd/src/model.ts | 200 +++ packages/lgtmd/src/playwright/guard.ts | 67 + packages/lgtmd/src/playwright/index.ts | 46 + packages/lgtmd/src/playwright/reporter.ts | 237 +++ packages/lgtmd/src/playwright/runner.ts | 129 ++ packages/lgtmd/tests/authoring.test.ts | 340 ++++ packages/lgtmd/tests/compiler.test.ts | 168 ++ packages/lgtmd/tests/lifecycle.test.ts | 247 +++ packages/lgtmd/tests/playwright.test.ts | 413 +++++ packages/lgtmd/tsconfig.json | 15 + rfcs/0004-lgtmd-web-incubation.md | 345 +++++ rfcs/README.md | 11 +- 70 files changed, 7990 insertions(+), 79 deletions(-) create mode 100644 LTGMD-TODO.md create mode 100644 crates/lgtmd-node/Cargo.toml create mode 100644 crates/lgtmd-node/LICENSE create mode 100644 crates/lgtmd-node/src/main.rs create mode 100644 crates/lgtmd/examples/conformance.rs create mode 100644 crates/lgtmd/src/transport.rs create mode 100644 crates/lgtmd/tests/code_views.rs create mode 100644 crates/lgtmd/tests/fixtures/portable.json create mode 100644 crates/lgtmd/tests/observations.rs create mode 100644 crates/lgtmd/tests/transport.rs create mode 100644 examples/lgtmd-web/.gitignore create mode 100644 examples/lgtmd-web/README.md create mode 100644 examples/lgtmd-web/browser/application-test.ts create mode 100644 examples/lgtmd-web/browser/filter-records.spec.ts create mode 100644 examples/lgtmd-web/guides/01-record-names.ts create mode 100644 examples/lgtmd-web/guides/02-resources.ts create mode 100644 examples/lgtmd-web/guides/shared.ts create mode 100644 examples/lgtmd-web/guides/supplied-context.ts create mode 100644 examples/lgtmd-web/index.html create mode 100644 examples/lgtmd-web/package.json create mode 100644 examples/lgtmd-web/playwright.config.ts create mode 100644 examples/lgtmd-web/run.ts create mode 100644 examples/lgtmd-web/server/index.mjs create mode 100644 examples/lgtmd-web/src/App.tsx create mode 100644 examples/lgtmd-web/src/main.tsx create mode 100644 examples/lgtmd-web/src/records.ts create mode 100644 examples/lgtmd-web/src/styles.css create mode 100644 examples/lgtmd-web/tsconfig.json create mode 100644 examples/lgtmd-web/vite.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/lgtmd/.gitignore create mode 100644 packages/lgtmd/LICENSE create mode 100644 packages/lgtmd/README.md create mode 100644 packages/lgtmd/dev/tests/test_verify.py create mode 100644 packages/lgtmd/dev/verify.py create mode 100644 packages/lgtmd/package.json create mode 100644 packages/lgtmd/src/authoring.ts create mode 100644 packages/lgtmd/src/compiler.ts create mode 100644 packages/lgtmd/src/index.ts create mode 100644 packages/lgtmd/src/lifecycle.ts create mode 100644 packages/lgtmd/src/model.ts create mode 100644 packages/lgtmd/src/playwright/guard.ts create mode 100644 packages/lgtmd/src/playwright/index.ts create mode 100644 packages/lgtmd/src/playwright/reporter.ts create mode 100644 packages/lgtmd/src/playwright/runner.ts create mode 100644 packages/lgtmd/tests/authoring.test.ts create mode 100644 packages/lgtmd/tests/compiler.test.ts create mode 100644 packages/lgtmd/tests/lifecycle.test.ts create mode 100644 packages/lgtmd/tests/playwright.test.ts create mode 100644 packages/lgtmd/tsconfig.json create mode 100644 rfcs/0004-lgtmd-web-incubation.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d567be..66cd3d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,26 @@ concurrency: cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: + lgtmd-web: + name: Portable lgtmd Node and Chromium contracts + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + package-manager-cache: false + - name: Install the declared Rust minimum and explicit browser dependencies + run: | + rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy + npm ci --no-audit --no-fund --cache .cache/npm + npm exec -- playwright install --with-deps chromium + - name: Verify independent sources and clean Cargo/npm consumers + run: python3 packages/lgtmd/dev/verify.py --standalone --toolchain 1.95.0 + ubuntu-checks: name: Ubuntu checks and focused regressions runs-on: ubuntu-24.04 diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 88774eb..607703a 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -11,6 +11,7 @@ "/docs/preview/vendor/**", "/vendor/**", "/examples/**", + "!/examples/lgtmd-web/**", "/fixtures/gltf/*/**", "/fixtures/benchmarks/*/source/**" ] diff --git a/AGENTS.md b/AGENTS.md index 6d10b6e..3fd0027 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,9 @@ Keep N3 as one application crate. The workspace also maintains the portable `crates/doc-example-config`; N3's documentation adapter is a real SDK consumer. This extraction is justified by a tested boundary between application replay and document composition/publication. It does not make the editor a public kernel. +Node authoring is incubated in `packages/lgtmd`, using the thin `crates/lgtmd-node` +compiler/store bridge. `examples/lgtmd-web` owns its independent application and +fixtures; the SDK must never discover an N3 checkout or own application state. Prefer focused modules over further speculative crates or APIs. See [executable documents](docs/architecture/executable-documents.md) for this boundary. @@ -98,6 +101,9 @@ capabilities, performance policy, or verification baseline. | `src/documentation/` | Headless replay, virtual input, annotations, capture, scenarios | | `crates/lgtmd/` | Portable document composition, evidence, audience exports, artifact lifecycle | | `crates/doc-example-config/` | Standalone CLI and library consumers of the documentation SDK | +| `crates/lgtmd-node/` | Coarse native compiler/store bridge for Node authoring | +| `packages/lgtmd/` | Node SDK, project-owned lifecycle, and optional Playwright integration | +| `examples/lgtmd-web/` | Independent web app, fixtures, and three authored example documents | Keep code, identifiers, comments, tests, logs, and authored UI strings in English. Explain non-obvious behavior and platform limitations near the owning code. @@ -206,6 +212,12 @@ For changes limited to hand-authored documentation such as this file or the README, check links and factual claims; unchanged application/media output does not require rebuilding the renderer or regenerating the guide. +For Node SDK or web adapter changes, also run `just doc-web-test --offline` after +the explicit `just doc-web-setup`. `just doc-web-smoke --offline` verifies portable +sources and installed Cargo/npm consumers independently on Rust 1.95. These gates +do not replace N3's native replay. Preserve the distinction between a fresh +candidate, repeated-run determinism, and an explicitly accepted project baseline. + Use the root `justfile` for development commands (`just` 1.46 is the verified version). Useful commands from the repository root: diff --git a/Cargo.lock b/Cargo.lock index c7fe715..5165ea6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1375,6 +1375,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "lgtmd-node" +version = "0.0.0" +dependencies = [ + "lgtmd", + "serde", + "serde_json", +] + [[package]] name = "libc" version = "0.2.189" diff --git a/Cargo.toml b/Cargo.toml index e940b8e..a97984e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ rust-version = "1.95" publish = false [workspace] -members = ["crates/lgtmd", "crates/doc-example-config"] +members = ["crates/lgtmd", "crates/doc-example-config", "crates/lgtmd-node"] default-members = ["."] resolver = "3" diff --git a/LTGMD-TODO.md b/LTGMD-TODO.md new file mode 100644 index 0000000..a308822 --- /dev/null +++ b/LTGMD-TODO.md @@ -0,0 +1,34 @@ +# lgtmd TODO + +Carry this backlog with lgtmd when it moves to its dedicated repository. Items +record follow-up work; they are not claims of current support or completed setup. + +## 1. User-facing adoption guides and a complete server/client example + +- [ ] Write a user-facing guide explaining what lgtmd provides, what developers + author, what readers receive, setup requirements, limitations, and when adopting + it is worthwhile. Show the workflow from developing a feature and writing its + guide through reviewing generated output and checking an accepted baseline. +- [ ] Provide distinct paths for **new projects** and **existing projects**. For + new projects, recommend an **lgtmd-native setup for the best experience**: + establish executable guides, project-owned fixtures and state preparation, and + documentation checks from the start. Explain this as a development convention + using ordinary application and test tooling, with no required frontend/backend + topology or lgtmd dependency in the application's runtime. +- [ ] Treat existing-project adoption as the trickier path. Demonstrate starting + with one valuable workflow, reusing existing tests and fixtures, and adding + documentation incrementally. Explain the work that may be needed around + authentication, mocks or seeds, cleanup, selectors, and reproducible captures; + do not imply that installing the SDK solves these concerns. +- [ ] Develop a complete **server + client example working together** and use it + to answer the major practical adoption questions. Build on the current React + + Vite and HTTP API example. Show who starts each service, how the client reaches + the server, how scenario data is prepared and isolated, how behavior is checked + and documented, how cleanup works, and how the same workflow runs locally and + in CI. Explain the boundaries of mocked state and a seeded backend, including + what each example actually verifies. + +Application architecture, authentication, backend/database provisioning, mocks, +seeds, and deployment remain project responsibilities. The guide and examples +should make these integration choices understandable while keeping lgtmd's +authoring and lifecycle interfaces agnostic to the project's stack and topology. diff --git a/TODO.md b/TODO.md index ac259f5..d526888 100644 --- a/TODO.md +++ b/TODO.md @@ -18,7 +18,7 @@ and document the implemented behavior in the executable user guide. | Paired selection in 2D | **Deferred:** consider verified front/back vertex pairing in aligned views; exact geometry does not prove selection intent. See [RFC 0001](rfcs/0001-paired-selection-in-2d.md). | | Grouping and hierarchy entities | Add an explicit document entity for organizing objects; evaluate groups, collections, or folders, including nesting, selection, and shared transform semantics. | | Multiple authored scenes | **Draft; implementation deferred.** Independent N3 scenes in one document/editor. Revisit [RFC 0002](rfcs/0002-authored-scenes.md), starting with a small native-scene proof of switching, history/recovery, and persistence before broader import work. | -| Reusable executable documentation framework | **Local implementation complete:** unpublished SDK, shared N3 lifecycle, Rust-authored hand-tool/navigation guides, and CLI/serde_json consumers. Next: independent developer adoption and authoring ergonomics; 26 N3 narratives still use templates. See [RFC 0003](rfcs/0003-executable-documentation-framework.md). | +| Reusable executable documentation framework | **Rust/web incubation implemented locally:** portable Node authoring, shared Rust compiler/store, Playwright integration, and independent Cargo/npm checks. Extraction, release packaging, and crates.io consumption remain later gates. See [RFC 0004](rfcs/0004-lgtmd-web-incubation.md) and the existing [Rust framework](rfcs/0003-executable-documentation-framework.md). Independent adoption and 26 remaining N3 template migrations remain follow-ups. | | 3D format I/O | Build on read-only glTF/GLB viewing: scene export/authoring, OBJ export, compression and advanced extensions; evaluate OpenUSD composition/schema support. See the [viewer milestone](docs/milestones/modern-scene-viewer.md). | | Materials and PBR | Extend imported PBR viewing into editable material assignment; HDR environment import, shadows, advanced materials and color management. See the [viewer milestone](docs/milestones/modern-scene-viewer.md). | | Node Canvas UI Kit | Establish reusable UI primitives for node blocks, ports, connections, selection, dragging, pan, and zoom. Validate interactions in isolated workbench examples so future editors can adopt them confidently when a concrete consumer exists. Scope this as UI foundation work; shader/modifier semantics and evaluation remain separate. | diff --git a/crates/lgtmd-node/Cargo.toml b/crates/lgtmd-node/Cargo.toml new file mode 100644 index 0000000..f00eabf --- /dev/null +++ b/crates/lgtmd-node/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "lgtmd-node" +version = "0.0.0" +edition = "2024" +rust-version = "1.95" +publish = false +license = "MIT" +description = "Coarse Node host bridge for the lgtmd compiler and artifact store" + +[[bin]] +name = "lgtmd-node" +path = "src/main.rs" + +[dependencies] +lgtmd = { path = "../lgtmd", version = "0.0.0" } +serde = { version = "=1.0.229", features = ["derive"] } +serde_json = "=1.0.151" diff --git a/crates/lgtmd-node/LICENSE b/crates/lgtmd-node/LICENSE new file mode 100644 index 0000000..943658a --- /dev/null +++ b/crates/lgtmd-node/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Grida + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/lgtmd-node/src/main.rs b/crates/lgtmd-node/src/main.rs new file mode 100644 index 0000000..b95395f --- /dev/null +++ b/crates/lgtmd-node/src/main.rs @@ -0,0 +1,100 @@ +//! One request per process, never one request per authoring operation. The Node +//! facade owns execution; Rust owns compilation and the existing strict store. +use lgtmd::{ + Audience, Result, + runner::{self, RunMode, RunnerConfig}, + transport::{self, Input, Layout}, +}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::{ + io::{self, Read}, + path::PathBuf, +}; + +#[derive(Deserialize)] +#[serde(tag = "operation", rename_all = "lowercase", deny_unknown_fields)] +enum Request { + Compile { + input: Input, + #[serde(default)] + layout: Layout, + }, + Publish { + input: Input, + command: String, + reader: PathBuf, + contributor: PathBuf, + #[serde(default)] + layout: Layout, + }, +} + +fn execute(request: Request) -> Result { + match request { + Request::Compile { input, layout } => { + let exports = transport::compile(&input, &layout.export_layout()?)?; + serde_json::to_value(exports).map_err(|e| e.to_string()) + } + Request::Publish { + input, + command, + reader, + contributor, + layout, + } => { + let base = std::env::current_dir().map_err(|e| e.to_string())?; + let config = RunnerConfig::new(base)? + .output(Audience::Reader, reader, "reader")? + .output(Audience::Contributor, contributor, "contributor")? + .layout(layout.export_layout()?); + // Explicit per-audience paths apply to build as well. Runner's + // update preflight validates their full transaction namespaces; + // compilation prepares both trees before store publication below. + match command.as_str() { + "check" | "update" => { + let mode = if command == "check" { + RunMode::Check + } else { + RunMode::Update + }; + runner::run( + &config, + mode, + &[Audience::Reader, Audience::Contributor], + || input.thaw(), + )?; + } + "build" => { + runner::run( + &config, + RunMode::BuildConfigured, + &[Audience::Reader, Audience::Contributor], + || input.thaw(), + )?; + } + _ => return Err("Command must be build, check, or update".into()), + } + Ok(json!({"command":command})) + } + } +} + +fn main() { + let outcome = (|| { + let mut input = String::new(); + io::stdin() + .read_to_string(&mut input) + .map_err(|e| e.to_string())?; + let request = + serde_json::from_str(&input).map_err(|e| format!("Invalid bridge request: {e}"))?; + execute(request) + })(); + match outcome { + Ok(value) => println!("{}", json!({"ok":true,"result":value})), + Err(error) => { + println!("{}", json!({"ok":false,"error":error})); + std::process::exit(1); + } + } +} diff --git a/crates/lgtmd/README.md b/crates/lgtmd/README.md index 3366b8c..3889751 100644 --- a/crates/lgtmd/README.md +++ b/crates/lgtmd/README.md @@ -60,11 +60,17 @@ tests, and its license; no workspace-inherited package fields are required. serialized observation. Its returned value handle can appear in prose. `require(id, condition)` records a predicate check. Meaningful checks remain the author's responsibility; the framework cannot prove arbitrary prose. +- `observe(id, value)` freezes a serialized value without recording a check. + Its `Observed` handle supports the same prose and note targets as checked + values. Explanation-only and observation-only documents are valid; successful + completion does not imply their prose was verified. - `resource(id, artifact)` registers owned bytes. `code` or `embed` places an existing resource. `resource_code` registers and places text while retaining its custom MIME, producer and comparison profile. The same handle can be reused in prose, placed again, or annotated without rerunning its producer. `Resource::image(alt)` and `Resource::link(label)` provide inline views; + `Resource::code(language)` places text/JSON within `markdown_parts`, occupying + its own blank-line-separated block. Code views cannot nest in code syntax or notes. image views require a declared image MIME type. `resource_at` preserves an existing safe artifact path, while complete exports reject duplicate owners across documents and generated pages. @@ -80,8 +86,8 @@ tests, and its license; no workspace-inherited package fields are required. Handles belong to one document run. Duplicate evidence IDs, foreign handles, wrong text types, unreferenced resources, and failed checks fail explicitly. Ignoring a failed builder operation does not make the document valid: it remains -tainted and `finish()` rejects it. A completed document needs at least one -successful check. Public block IDs are currently positional; inserting public +tainted and `finish()` rejects it. Checks are optional, and recorded checks must +succeed. Public block IDs are currently positional; inserting public content can change later block anchors. Check and resource IDs are author-owned. The built-in encoder IDs remain `executable-docs/text` and `executable-docs/json` @@ -94,6 +100,33 @@ links, and the complete selected audience. A finished document alone is not a publishable bundle; every export performs those remaining checks before returning bytes, and the runner prepares every requested export before publication begins. +## Shared compiler and Node authoring + +[`transport`](src/transport.rs) exposes schema-v1 frozen document input and a +coarse `compile(input, layout)` operation returning both audience exports. +`Input::from_documents` and `Input::thaw` preserve native document semantics, +including literal Markdown, typed parts, observations, checks, bindings, and owned +resource bytes. Process-local handle owners are never serialized. This input is +an interoperability contract, separate from the inventory manifest and a future +viewer format. Unknown fields, unsupported versions, invalid identities and +references, failed checks, and invalid resource paths fail explicitly. + +Portable JSON accepts null, booleans, strings, finite numbers, arrays and objects; +integers must fit JavaScript's safe range. JavaScript rejects unsupported values +before freezing, including undefined, cycles, getters, nonplain objects, negative +zero, and nonfinite numbers. Native Rust authoring retains serde_json's existing +serialization behavior (including normalization of nonfinite floats to null); +the transport cannot recover information already normalized by a serializer. +The built-in JSON resource encoder is shared Rust code, including for frozen +Node JSON values; integer-like object keys and fractional numbers have the same +encoding from the same portable input. Custom producers own their byte encoding. + +The incubating [Node SDK](../../packages/lgtmd/README.md) offers TypeScript tagged +Markdown, optional native assertions, async preparation/cleanup, and an optional +Playwright adapter. Its thin native bridge uses this compiler and the existing +Rust runner/store. The [web example](../../examples/lgtmd-web/README.md) supplies +three complete documents without application-specific engine behavior. + For readable inline sentences, the optional named form of `prose!` keeps the text together and inserts typed handles. It returns a result because missing, duplicate, unused, or malformed bindings fail rather than silently dropping text diff --git a/crates/lgtmd/examples/conformance.rs b/crates/lgtmd/examples/conformance.rs new file mode 100644 index 0000000..d8f1a73 --- /dev/null +++ b/crates/lgtmd/examples/conformance.rs @@ -0,0 +1,38 @@ +//! Emit common frozen input and exports from actual native authoring. The Node +//! conformance test authors the same document independently and compares bytes. +use lgtmd::{ + Artifact, Doc, ExportLayout, Result, + transport::{self, Input}, +}; +use serde_json::json; + +fn main() -> Result<()> { + let mut doc = Doc::new("portable", "Portable document")?; + let count = doc.observe("count", 3)?; + let private = doc.observe("internal-only", "private observation")?; + doc.require("verified-count", true)?; + doc.heading_with_id("overview", 2, "Overview")?; + let passage = doc.markdown_parts(("Contains ", &count, " records.\n\n"))?; + doc.note_on(&passage, ("Internal: ", &private))?; + let data = doc.json("data", &json!({"z":2,"a":1}))?; + doc.markdown_parts(( + "Data:\n\n", + data.code("json"), + "\n\n", + data.link("Download"), + "\n\n", + ))?; + let image = doc.resource( + "image", + Artifact::new(vec![1, 2, 3], "image/png", "png", "fixture", "exact-v1")?, + )?; + doc.markdown_parts((image.image("Fixed fixture bytes"), "\n\n"))?; + let input = Input::from_documents(&[doc.finish()?])?; + let exports = transport::compile(&input, &ExportLayout::default())?; + println!( + "{}", + serde_json::to_string_pretty(&json!({"input":input,"exports":exports})) + .map_err(|e| e.to_string())? + ); + Ok(()) +} diff --git a/crates/lgtmd/src/artifact.rs b/crates/lgtmd/src/artifact.rs index f5bbb16..72f5e08 100644 --- a/crates/lgtmd/src/artifact.rs +++ b/crates/lgtmd/src/artifact.rs @@ -14,6 +14,22 @@ pub struct Artifact { } impl Artifact { + /// The one canonical built-in JSON encoder, shared by native authoring and + /// portable input. Encoding happens only from an already owned snapshot. + pub(crate) fn json(value: &impl serde::Serialize) -> Result { + let mut value = serde_json::to_value(value).map_err(|e| e.to_string())?; + value.sort_all_objects(); + let mut bytes = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?; + bytes.push(b'\n'); + Self::new( + bytes, + "application/json", + "json", + "executable-docs/json", + "json-pretty-v1", + ) + } + pub fn new( bytes: impl Into>, mime: &str, diff --git a/crates/lgtmd/src/authoring.rs b/crates/lgtmd/src/authoring.rs index 075edfd..b2a44eb 100644 --- a/crates/lgtmd/src/authoring.rs +++ b/crates/lgtmd/src/authoring.rs @@ -4,7 +4,7 @@ use crate::{ Artifact, Result, model::{ Binding, BindingValue, Block, Check, Checked, Claim, Content, Document, Inline, IntoProse, - Kind, Node, Reference, Resource, Target, valid_id, + Kind, Node, Observed, Reference, Resource, Target, valid_id, }, }; use serde::Serialize; @@ -40,6 +40,7 @@ impl Doc { title: title.into(), nodes: Vec::new(), checks: BTreeMap::new(), + observations: BTreeMap::new(), artifacts: BTreeMap::new(), resource_paths: BTreeMap::new(), bindings: BTreeMap::new(), @@ -85,7 +86,7 @@ impl Doc { self.append_with_id(None, content) } - fn append_with_id(&mut self, id: Option<&str>, content: Content) -> Result { + pub(crate) fn append_with_id(&mut self, id: Option<&str>, content: Content) -> Result { let result = (|| { let note = matches!(content, Content::Note { .. }); let count = self @@ -104,22 +105,42 @@ impl Doc { for reference in node.references() { self.validate_reference(reference)?; } + match &node.content { + Content::Code(reference, language) => { + self.validate_code_resource(reference, language)?; + } + Content::Embed(reference, _) if reference.kind != Kind::Resource => { + return Err("Embedded content requires a resource reference".into()); + } + _ => {} + } if let Content::Paragraph(prose) | Content::MarkdownParts(prose) | Content::Note { prose, .. } = &node.content { for part in &prose.0 { if let Inline::Image(reference, _) = part - && !self.document.artifacts[&reference.id] - .mime - .starts_with("image/") + && (reference.kind != Kind::Resource + || !self.document.artifacts[&reference.id] + .mime + .starts_with("image/")) { return Err(format!( "Resource {} is not declared as an image", reference.id )); } + if let Inline::Code(reference, language) = part { + if !matches!(node.content, Content::MarkdownParts(_)) { + return Err( + "Code resource views require Markdown parts; use Doc::code for a standalone block" + .into(), + ); + } + self.validate_code_resource(reference, language)?; + } } + validate_code_placement(prose)?; } let reference = self.reserve(&node.id, Kind::Block)?; self.document.nodes.push(node); @@ -253,6 +274,28 @@ impl Doc { self.guarded(result) } + /// Freeze an observed value for prose and contributor notes. This records no + /// check and makes no verification claim. Later mutation of the source value + /// cannot change its serialized snapshot. + pub fn observe(&mut self, id: &str, value: T) -> Result> { + let result = (|| { + let reference = self.reserve(id, Kind::Observation)?; + let mut observed = serde_json::to_value(value) + .map_err(|e| format!("Cannot freeze observed value {id}: {e}"))?; + // A host may enable preserve_order; canonicalize nested objects too. + observed.sort_all_objects(); + self.document + .observations + .insert(id.into(), observed.clone()); + Ok(Observed { + reference, + observed, + marker: PhantomData, + }) + })(); + self.guarded(result) + } + pub fn require(&mut self, id: &str, condition: bool) -> Result { let result = (|| { let reference = self.reserve(id, Kind::Claim)?; @@ -333,22 +376,7 @@ impl Doc { /// Freeze a JSON value with the built-in deterministic JSON producer. pub fn json(&mut self, id: &str, value: &impl Serialize) -> Result { - let result = (|| { - // Value alone is not sorted when a host enables preserve_order. - // Sort recursively, including objects inside arrays, before freezing. - let mut value = serde_json::to_value(value).map_err(|e| e.to_string())?; - value.sort_all_objects(); - let mut bytes = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?; - bytes.push(b'\n'); - Artifact::new( - bytes, - "application/json", - "json", - "executable-docs/json", - "json-pretty-v1", - ) - })(); - let artifact = self.guarded(result)?; + let artifact = self.guarded(Artifact::json(value))?; self.resource(id, artifact) } @@ -374,31 +402,36 @@ impl Doc { } pub fn code(&mut self, resource: &Resource, language: &str) -> Result { - let result = (|| { - self.validate_reference(&resource.0)?; - if !language - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b"_+-".contains(&b)) - { - return Err("Code language must be an ASCII identifier".into()); - } - let artifact = &self.document.artifacts[resource.id()]; - if !(artifact.mime.starts_with("text/") || artifact.mime == "application/json") { - return Err(format!( - "Resource {} is not declared as text/JSON", - resource.id() - )); - } - std::str::from_utf8(&artifact.bytes) - .map_err(|_| format!("Resource {} is not UTF-8", resource.id()))?; - Ok(()) - })(); - self.guarded(result)?; self.append(Content::Code(resource.reference(), language.into())) } - /// Freeze successful execution and intrinsic references. Export validates - /// layout-dependent resource ownership and local links against its complete + fn validate_code_resource(&self, reference: &Reference, language: &str) -> Result<()> { + self.validate_reference(reference)?; + if reference.kind != Kind::Resource { + return Err("Code content requires a resource reference".into()); + } + if !language + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_+-".contains(&b)) + { + return Err("Code language must be an ASCII identifier".into()); + } + let artifact = &self.document.artifacts[&reference.id]; + if !(artifact.mime.starts_with("text/") || artifact.mime == "application/json") { + return Err(format!( + "Resource {} is not declared as text/JSON", + reference.id + )); + } + std::str::from_utf8(&artifact.bytes) + .map_err(|_| format!("Resource {} is not UTF-8", reference.id))?; + Ok(()) + } + + /// Freeze authored content and intrinsic references. Explanations and + /// observations do not require checks; successful completion never invents + /// verification evidence. Any failed operation still prevents finishing. + /// Export validates layout-dependent resource ownership and local links against its complete /// bundle; finishing alone does not mean the document is publishable. pub fn finish(self) -> Result { if !self.errors.is_empty() { @@ -408,14 +441,51 @@ impl Doc { self.errors.join("; ") )); } - if self.document.checks.is_empty() { - return Err(format!( - "Document {} has no executed checks", - self.document.id - )); - } // Publication paths and cross-document links depend on the selected export layout. crate::validation::validate_document(&self.document)?; Ok(self.document) } } + +fn validate_code_placement(prose: &crate::model::Prose) -> Result<()> { + use pulldown_cmark::{Event, Parser, Tag}; + if !prose.0.iter().any(|part| matches!(part, Inline::Code(..))) { + return Ok(()); + } + let mut template = String::new(); + let mut code_offsets = Vec::new(); + for part in &prose.0 { + if matches!(part, Inline::Code(..)) { + code_offsets.push(template.len()); + } + template.push_str(match part { + Inline::Text(text) => text, + _ => "LGTMD_REFERENCE", + }); + } + for &offset in &code_offsets { + let before = &template[..offset]; + let after = &template[offset + "LGTMD_REFERENCE".len()..]; + let separated = |text: &str| text.is_empty() || text.bytes().all(|byte| byte == b'\n'); + if !(separated(before) || before.ends_with("\n\n")) + || !(separated(after) || after.starts_with("\n\n")) + { + return Err( + "Code resource views must occupy their own blank-line-separated Markdown block" + .into(), + ); + } + } + for (event, range) in Parser::new(&template).into_offset_iter() { + if matches!( + event, + Event::Code(_) | Event::Start(Tag::CodeBlock(_) | Tag::HtmlBlock) + ) && code_offsets.iter().any(|offset| range.contains(offset)) + { + return Err( + "Code resource views cannot be placed inside existing code or HTML syntax".into(), + ); + } + } + Ok(()) +} diff --git a/crates/lgtmd/src/lib.rs b/crates/lgtmd/src/lib.rs index 915532b..e5ab67a 100644 --- a/crates/lgtmd/src/lib.rs +++ b/crates/lgtmd/src/lib.rs @@ -12,14 +12,15 @@ mod prose; mod render; pub mod runner; pub mod store; +pub mod transport; mod validation; pub use artifact::Artifact; pub use authoring::Doc; pub use layout::ExportLayout; pub use model::{ - Audience, Binding, BindingValue, Block, Checked, Claim, Document, IntoProse, Prose, Resource, - Target, + Audience, Binding, BindingValue, Block, Checked, Claim, Document, IntoProse, Observed, Prose, + Resource, Target, }; pub use render::{render, render_with}; diff --git a/crates/lgtmd/src/manifest.rs b/crates/lgtmd/src/manifest.rs index 1ccf7ea..4d7a193 100644 --- a/crates/lgtmd/src/manifest.rs +++ b/crates/lgtmd/src/manifest.rs @@ -33,11 +33,18 @@ struct DocumentRecord { id: String, title: String, checks: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + observations: Vec, blocks: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] bindings: Option>, } #[derive(Serialize, Deserialize)] +struct ObservationRecord { + id: String, + value: Value, +} +#[derive(Serialize, Deserialize)] struct CheckRecord { id: String, kind: String, @@ -166,6 +173,15 @@ impl Manifest { } }) .collect(); + let observations = document + .observations + .iter() + .filter(|(id, _)| audience == Audience::Contributor || reader_refs.contains(id)) + .map(|(id, value)| ObservationRecord { + id: id.clone(), + value: value.clone(), + }) + .collect(); let blocks = visible_nodes(document, audience) .map(|node| { let kind = match &node.content { @@ -217,6 +233,7 @@ impl Manifest { id: document.id.clone(), title: document.title.clone(), checks, + observations, blocks, bindings: (!bindings.is_empty()).then_some(bindings), }); @@ -350,9 +367,6 @@ pub(crate) fn validate_tree(files: &Files, retired_from: Option<&Files>) -> Resu field(document, "title")?; let mut local = BTreeMap::new(); let checks = array(document, "checks")?; - if checks.is_empty() { - return Err(format!("Manifest document {id} has no checks")); - } for check in checks { let kind = match field(check, "kind")? { "equality" => "value", @@ -372,6 +386,14 @@ pub(crate) fn validate_tree(files: &Files, retired_from: Option<&Files>) -> Resu ); } } + if document.get("observations").is_some() { + for observation in array(document, "observations")? { + identity(&mut local, field(observation, "id")?, "observation")?; + if observation.get("value").is_none() { + return Err("Manifest observation requires a value; null is a value".into()); + } + } + } for block in array(document, "blocks")? { let kind = field(block, "kind")?; if !matches!( diff --git a/crates/lgtmd/src/model.rs b/crates/lgtmd/src/model.rs index 5ce57ea..78bedcc 100644 --- a/crates/lgtmd/src/model.rs +++ b/crates/lgtmd/src/model.rs @@ -1,7 +1,7 @@ //! Frozen document data, typed handles, and prose values. use crate::{Artifact, Result}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{collections::BTreeMap, marker::PhantomData}; @@ -18,6 +18,7 @@ pub(crate) enum Kind { Block, Claim, Value, + Observation, Resource, Binding, } @@ -82,8 +83,13 @@ impl Block { /// An observed application label, not a behavioral assertion. The host adapter /// must establish that the control or canonical shortcut was actually observed. -#[derive(Clone, Debug, Serialize)] -#[serde(tag = "kind", content = "value", rename_all = "lowercase")] +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde( + tag = "kind", + content = "value", + rename_all = "lowercase", + deny_unknown_fields +)] pub enum BindingValue { Text(String), Code(String), @@ -100,6 +106,13 @@ impl Resource { pub fn link(&self, label: &str) -> Prose { Prose(vec![Inline::Link(self.reference(), label.into())]) } + + /// Present a UTF-8 text or JSON resource as a fenced code block in Markdown + /// parts. The view must occupy its own block, separated by blank lines. + /// The owning builder validates the language and resource kind. + pub fn code(&self, language: &str) -> Prose { + Prose(vec![Inline::Code(self.reference(), language.into())]) + } } /// The serialized observation is frozen when the equality check succeeds. @@ -135,12 +148,47 @@ impl IntoProse for Checked { } } +/// A serialized value frozen at registration, without a behavioral assertion. +/// Rendering this handle reports the observation; it never implies a check ran. +#[derive(Clone, Debug)] +pub struct Observed { + pub(crate) reference: Reference, + pub(crate) observed: Value, + pub(crate) marker: PhantomData T>, +} + +impl Observed { + pub fn id(&self) -> &str { + &self.reference.id + } + pub fn observed(&self) -> &Value { + &self.observed + } +} +impl private::Sealed for Observed {} +impl Target for Observed { + fn reference(&self) -> Reference { + self.reference.clone() + } +} +impl IntoProse for &Observed { + fn into_prose(self) -> Prose { + Prose(vec![Inline::Reference(self.reference())]) + } +} +impl IntoProse for Observed { + fn into_prose(self) -> Prose { + (&self).into_prose() + } +} + #[derive(Clone, Debug)] pub(crate) enum Inline { Text(String), Reference(Reference), Image(Reference, String), Link(Reference, String), + Code(Reference, String), } #[derive(Clone, Debug)] @@ -238,7 +286,10 @@ impl Node { | Content::MarkdownParts(prose) | Content::Note { prose, .. } => { refs.extend(prose.0.iter().filter_map(|part| match part { - Inline::Reference(r) | Inline::Image(r, _) | Inline::Link(r, _) => Some(r), + Inline::Reference(r) + | Inline::Image(r, _) + | Inline::Link(r, _) + | Inline::Code(r, _) => Some(r), _ => None, })); } @@ -263,6 +314,7 @@ pub struct Document { pub(crate) title: String, pub(crate) nodes: Vec, pub(crate) checks: BTreeMap, + pub(crate) observations: BTreeMap, pub(crate) artifacts: BTreeMap, pub(crate) resource_paths: BTreeMap, pub(crate) bindings: BTreeMap, diff --git a/crates/lgtmd/src/render.rs b/crates/lgtmd/src/render.rs index 2504efa..960ece6 100644 --- a/crates/lgtmd/src/render.rs +++ b/crates/lgtmd/src/render.rs @@ -123,8 +123,11 @@ fn inline(document: &Document, reference: &Reference, layout: &ExportLayout) -> resource_url(document, reference, layout) ), Kind::Claim => format!("`{}`", reference.id), - Kind::Value => { - let actual = &document.checks[&reference.id].actual; + Kind::Value | Kind::Observation => { + let actual = match reference.kind { + Kind::Value => &document.checks[&reference.id].actual, + _ => &document.observations[&reference.id], + }; escape( &actual .as_str() @@ -189,6 +192,11 @@ fn render_prose( escape(label), resource_url(document, reference, layout) )), + Inline::Code(reference, language) => { + let text = std::str::from_utf8(&document.artifacts[&reference.id].bytes) + .expect("Authoring validates code resource UTF-8"); + Rendered::text(format!("{}\n\n", render_code(text, language))) + } }); } rendered @@ -199,6 +207,14 @@ fn code_fence(text: &str) -> String { "`".repeat(longest.max(2) + 1) } +fn render_code(text: &str, language: &str) -> String { + let fence = code_fence(text); + format!( + "{fence}{language}\n{text}{}{fence}", + if text.ends_with('\n') { "" } else { "\n" } + ) +} + fn render_page( document: &Document, audience: Audience, @@ -236,18 +252,16 @@ fn render_page( Content::Code(reference, language) => { let text = std::str::from_utf8(&document.artifacts[&reference.id].bytes) .map_err(|_| "Code resource must be UTF-8")?; - let fence = code_fence(text); - Rendered::text(format!( - "{fence}{language}\n{text}{}{fence}", - if text.ends_with('\n') { "" } else { "\n" } - )) + Rendered::text(render_code(text, language)) } Content::Note { target, prose } => { let mut note = Rendered::text("**Contributor note"); if let Some(reference) = target { note.push(Rendered::text(" on ")); note.push(match reference.kind { - Kind::Claim | Kind::Value => Rendered::text(format!("`{}`", reference.id)), + Kind::Claim | Kind::Value | Kind::Observation => { + Rendered::text(format!("`{}`", reference.id)) + } _ => inline(document, reference, layout), }); } diff --git a/crates/lgtmd/src/runner.rs b/crates/lgtmd/src/runner.rs index e2fa3e5..edfb477 100644 --- a/crates/lgtmd/src/runner.rs +++ b/crates/lgtmd/src/runner.rs @@ -13,6 +13,9 @@ pub enum RunMode { Check, Update, Build(PathBuf), + /// Build fresh trees at the explicitly configured audience output paths. + /// Useful for hosts that choose each candidate path rather than a build root. + BuildConfigured, } struct Options { @@ -324,6 +327,14 @@ pub fn run( for (_, other) in &destinations { ensure_distinct_destinations(&path, other)?; } + if matches!(mode, RunMode::BuildConfigured) + && path.try_exists().map_err(|e| e.to_string())? + { + return Err(format!( + "Build destination already exists: {}", + path.display() + )); + } destinations.push((audience, path)); } let prepared = lifecycle::prepare(|| { @@ -342,7 +353,7 @@ pub fn run( for (name, destination, files) in prepared { let result = match mode { RunMode::Check => store::check(&destination, &files), - RunMode::Build(_) => store::build(&destination, &files), + RunMode::Build(_) | RunMode::BuildConfigured => store::build(&destination, &files), RunMode::Update => store::update(&destination, &files), }; if let Err(error) = result { diff --git a/crates/lgtmd/src/transport.rs b/crates/lgtmd/src/transport.rs new file mode 100644 index 0000000..d7018a7 --- /dev/null +++ b/crates/lgtmd/src/transport.rs @@ -0,0 +1,588 @@ +//! Versioned, language-neutral frozen document input. No callbacks or host state +//! cross this boundary. Compilation uses the same validation and exporters as +//! native Rust authoring; this is not the export inventory or a viewer schema. + +use crate::{ + Artifact, Audience, BindingValue, Doc, Document, ExportLayout, Result, Target, + lifecycle::Files, + model::{Content, Inline, Kind, Prose, Reference}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Input { + pub schema_version: u32, + pub documents: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FrozenDocument { + pub id: String, + pub title: String, + pub nodes: Vec, + #[serde(default)] + pub observations: Vec, + #[serde(default)] + pub checks: Vec, + #[serde(default)] + pub resources: Vec, + #[serde(default)] + pub bindings: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Observation { + pub id: String, + pub value: Value, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Check { + pub id: String, + pub kind: String, + pub passed: bool, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "present_value" + )] + pub actual: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "present_value" + )] + pub expected: Option, +} + +fn present_value<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> std::result::Result, D::Error> { + Value::deserialize(deserializer).map(Some) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Resource { + pub id: String, + pub mime: String, + pub extension: String, + pub producer: String, + pub profile: String, + #[serde(default)] + pub bytes: Vec, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "present_value" + )] + pub json: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Binding { + pub id: String, + pub value: BindingValue, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Ref { + pub id: String, + pub kind: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)] +pub enum Part { + Text { text: String }, + Markdown { text: String }, + Reference { target: Ref }, + Image { target: Ref, label: String }, + Link { target: Ref, label: String }, + Code { target: Ref, language: String }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)] +pub enum Node { + Heading { + id: String, + level: u8, + text: String, + }, + Paragraph { + id: String, + parts: Vec, + }, + Markdown { + id: String, + parts: Vec, + }, + #[serde(rename = "raw-markdown")] + RawMarkdown { + id: String, + text: String, + }, + Embed { + id: String, + target: Ref, + label: String, + }, + Code { + id: String, + target: Ref, + language: String, + }, + Note { + id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + target: Option, + parts: Vec, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Layout { + #[serde(default)] + pub pages: String, + #[serde(default = "default_resources")] + pub resources: String, + #[serde(default)] + pub page_overrides: BTreeMap, +} + +fn default_resources() -> String { + "assets".into() +} + +impl Default for Layout { + fn default() -> Self { + Self { + pages: String::new(), + resources: default_resources(), + page_overrides: BTreeMap::new(), + } + } +} + +impl Layout { + pub fn export_layout(&self) -> Result { + let mut layout = ExportLayout::default() + .pages_under(&self.pages)? + .resources_under(&self.resources)?; + for (id, path) in &self.page_overrides { + layout = layout.page(id, path)?; + } + Ok(layout) + } +} + +/// Both audiences are prepared from one frozen input, before any publication. +#[derive(Serialize)] +pub struct Exports { + pub reader: Files, + pub contributor: Files, +} + +pub fn compile(input: &Input, layout: &ExportLayout) -> Result { + let documents = input.thaw()?; + Ok(Exports { + reader: crate::render_with(&documents, Audience::Reader, layout)?, + contributor: crate::render_with(&documents, Audience::Contributor, layout)?, + }) +} + +impl Input { + pub fn from_documents(documents: &[Document]) -> Result { + Ok(Self { + schema_version: 1, + documents: documents + .iter() + .map(FrozenDocument::from_document) + .collect::>()?, + }) + } + + pub fn thaw(&self) -> Result> { + if self.schema_version != 1 { + return Err(format!( + "Unsupported frozen document schema: {}", + self.schema_version + )); + } + self.documents + .iter() + .map(|document| { + document + .thaw() + .map_err(|error| format!("Document {}: {error}", document.id)) + }) + .collect() + } +} + +// The JS common-value domain is explicit. Rust-only APIs retain their existing +// serde value domain; crossing the portable boundary rejects lossy integers. +fn portable_value(value: &Value) -> Result<()> { + match value { + Value::Number(number) => { + const LIMIT: u64 = 9_007_199_254_740_991; + if number.as_u64().is_some_and(|n| n > LIMIT) + || number.as_i64().is_some_and(|n| n.unsigned_abs() > LIMIT) + || number + .as_f64() + .is_some_and(|n| n.fract() == 0.0 && n.abs() > LIMIT as f64) + { + return Err( + "Portable JSON integers must be within JavaScript's safe integer range".into(), + ); + } + } + Value::Array(values) => { + for value in values { + portable_value(value)?; + } + } + Value::Object(values) => { + for value in values.values() { + portable_value(value)?; + } + } + _ => {} + } + Ok(()) +} + +fn resolve(reference: &Ref, handles: &BTreeMap) -> Result { + let handle = handles + .get(&reference.id) + .ok_or_else(|| format!("Missing reference: {}", reference.id))?; + if serde_json::to_value(&handle.kind).map_err(|e| e.to_string())? != reference.kind { + return Err(format!( + "Wrong-kind reference: {} ({})", + reference.id, reference.kind + )); + } + Ok(handle.clone()) +} + +fn parts(parts: &[Part], handles: &BTreeMap, markdown: bool) -> Result { + Ok(Prose( + parts + .iter() + .map(|part| { + Ok(match part { + Part::Text { text } => Inline::Text(if markdown { + crate::markdown::escape(text) + } else { + text.clone() + }), + Part::Markdown { text } => { + if !markdown { + return Err("Literal Markdown parts require a Markdown block".into()); + } + Inline::Text(text.clone()) + } + Part::Reference { target } => Inline::Reference(resolve(target, handles)?), + Part::Image { target, label } => { + let reference = resolve(target, handles)?; + if reference.kind != Kind::Resource { + return Err("Image target must be a resource".into()); + } + Inline::Image(reference, label.clone()) + } + Part::Link { target, label } => { + let reference = resolve(target, handles)?; + if !matches!(reference.kind, Kind::Resource | Kind::Block) { + return Err("Link target must be a resource or block".into()); + } + Inline::Link(reference, label.clone()) + } + Part::Code { target, language } => { + let reference = resolve(target, handles)?; + if reference.kind != Kind::Resource { + return Err("Code target must be a resource".into()); + } + Inline::Code(reference, language.clone()) + } + }) + }) + .collect::>()?, + )) +} + +impl FrozenDocument { + fn thaw(&self) -> Result { + let mut doc = Doc::new(&self.id, &self.title)?; + let mut handles = BTreeMap::new(); + for value in &self.observations { + portable_value(&value.value)?; + handles.insert( + value.id.clone(), + doc.observe(&value.id, &value.value)?.reference(), + ); + } + for check in &self.checks { + if !check.passed { + return Err(format!("Failed check: {}", check.id)); + } + let handle = match check.kind.as_str() { + "predicate" => { + if check.actual.is_some() || check.expected.is_some() { + return Err("Predicate check must not include equality payloads".into()); + } + doc.require(&check.id, true)?.reference() + } + "equality" => { + let actual = check + .actual + .as_ref() + .ok_or("Equality check requires actual")?; + let expected = check + .expected + .as_ref() + .ok_or("Equality check requires expected")?; + portable_value(actual)?; + portable_value(expected)?; + doc.expect_eq(&check.id, actual, expected)?.reference() + } + other => return Err(format!("Unknown check kind: {other}")), + }; + handles.insert(check.id.clone(), handle); + } + for binding in &self.bindings { + handles.insert( + binding.id.clone(), + doc.binding(&binding.id, binding.value.clone())?.reference(), + ); + } + for resource in &self.resources { + let artifact = if let Some(value) = &resource.json { + if !resource.bytes.is_empty() + || resource.mime != "application/json" + || resource.extension != "json" + || resource.producer != "executable-docs/json" + || resource.profile != "json-pretty-v1" + { + return Err("JSON resources require the canonical JSON producer and no competing byte payload".into()); + } + portable_value(value)?; + Artifact::json(value)? + } else { + Artifact::new( + resource.bytes.clone(), + &resource.mime, + &resource.extension, + &resource.producer, + &resource.profile, + )? + }; + let handle = match &resource.path { + Some(path) => doc.resource_at(&resource.id, artifact, path)?, + None => doc.resource(&resource.id, artifact)?, + }; + handles.insert(resource.id.clone(), handle.reference()); + } + for node in &self.nodes { + let (id, content) = match node { + Node::Heading { id, level, text } => { + let handle = doc.heading_with_id(id, *level, text)?; + handles.insert(id.clone(), handle.reference()); + continue; + } + Node::Paragraph { id, parts: prose } => { + (id, Content::Paragraph(parts(prose, &handles, false)?)) + } + Node::Markdown { id, parts: prose } => { + let prose = parts(prose, &handles, true)?; + crate::markdown::validate_markdown_parts(&prose)?; + (id, Content::MarkdownParts(prose)) + } + Node::RawMarkdown { id, text } => { + crate::markdown::markdown_links(text)?; + (id, Content::Markdown(text.clone())) + } + Node::Embed { id, target, label } => { + let reference = resolve(target, &handles)?; + if reference.kind != Kind::Resource { + return Err("Embed target must be a resource".into()); + } + (id, Content::Embed(reference, label.clone())) + } + Node::Code { + id, + target, + language, + } => { + let reference = resolve(target, &handles)?; + if reference.kind != Kind::Resource { + return Err("Code target must be a resource".into()); + } + (id, Content::Code(reference, language.clone())) + } + Node::Note { + id, + target, + parts: prose, + } => ( + id, + Content::Note { + target: target + .as_ref() + .map(|target| resolve(target, &handles)) + .transpose()?, + prose: parts(prose, &handles, false)?, + }, + ), + }; + let handle = doc + .append_with_id(Some(id), content) + .map_err(|error| format!("Block {id}: {error}"))?; + handles.insert(id.clone(), handle.reference()); + } + doc.finish() + } + + fn from_document(document: &Document) -> Result { + fn reference(reference: &Reference) -> Result { + Ok(Ref { + id: reference.id.clone(), + kind: serde_json::to_value(&reference.kind) + .map_err(|e| e.to_string())? + .as_str() + .ok_or("Invalid kind")? + .into(), + }) + } + fn prose(prose: &Prose, markdown: bool) -> Result> { + prose + .0 + .iter() + .map(|part| { + Ok(match part { + Inline::Text(text) if markdown => Part::Markdown { text: text.clone() }, + Inline::Text(text) => Part::Text { text: text.clone() }, + Inline::Reference(target) => Part::Reference { + target: reference(target)?, + }, + Inline::Image(target, label) => Part::Image { + target: reference(target)?, + label: label.clone(), + }, + Inline::Link(target, label) => Part::Link { + target: reference(target)?, + label: label.clone(), + }, + Inline::Code(target, language) => Part::Code { + target: reference(target)?, + language: language.clone(), + }, + }) + }) + .collect() + } + let nodes = document + .nodes + .iter() + .map(|node| { + let id = node.id.clone(); + Ok(match &node.content { + Content::Heading(level, text) => Node::Heading { + id, + level: *level, + text: text.clone(), + }, + Content::Paragraph(parts) => Node::Paragraph { + id, + parts: prose(parts, false)?, + }, + Content::Markdown(text) => Node::RawMarkdown { + id, + text: text.clone(), + }, + Content::MarkdownParts(parts) => Node::Markdown { + id, + parts: prose(parts, true)?, + }, + Content::Embed(target, label) => Node::Embed { + id, + target: reference(target)?, + label: label.clone(), + }, + Content::Code(target, language) => Node::Code { + id, + target: reference(target)?, + language: language.clone(), + }, + Content::Note { + target, + prose: parts, + } => Node::Note { + id, + target: target.as_ref().map(reference).transpose()?, + parts: prose(parts, false)?, + }, + }) + }) + .collect::>()?; + let result = Self { + id: document.id.clone(), + title: document.title.clone(), + nodes, + observations: document + .observations + .iter() + .map(|(id, value)| Observation { + id: id.clone(), + value: value.clone(), + }) + .collect(), + checks: document + .checks + .values() + .map(|check| Check { + id: check.id.clone(), + kind: check.kind.clone(), + passed: true, + actual: (check.kind == "equality").then(|| check.actual.clone()), + expected: (check.kind == "equality").then(|| check.expected.clone()), + }) + .collect(), + resources: document + .artifacts + .iter() + .map(|(id, artifact)| Resource { + id: id.clone(), + bytes: artifact.bytes.clone(), + mime: artifact.mime.clone(), + extension: artifact.extension.clone(), + producer: artifact.producer.clone(), + profile: artifact.profile.clone(), + path: document.resource_paths.get(id).cloned(), + json: None, + }) + .collect(), + bindings: document + .bindings + .iter() + .map(|(id, value)| Binding { + id: id.clone(), + value: value.clone(), + }) + .collect(), + }; + result.thaw()?; + Ok(result) + } +} diff --git a/crates/lgtmd/tests/code_views.rs b/crates/lgtmd/tests/code_views.rs new file mode 100644 index 0000000..2c598e9 --- /dev/null +++ b/crates/lgtmd/tests/code_views.rs @@ -0,0 +1,108 @@ +use lgtmd::{Artifact, Audience, Doc, Document}; +use pulldown_cmark::{Event, Parser, Tag}; +use serde_json::Value; + +#[test] +fn a_typed_code_view_shares_one_frozen_resource_with_its_download() { + let mut doc = Doc::new("configuration", "Configuration").unwrap(); + let text = "```\n[not a link](missing.md)\n`````\n"; + let output = doc.text("output", text).unwrap(); + let passage = doc + .markdown_parts(( + "The command produced:\n\n", + output.code("text"), + "\n\nDownload ", + output.link("the output"), + ".\n", + )) + .unwrap(); + doc.note_on(&passage, "The producer runs once.").unwrap(); + let files = Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap(); + assert_eq!(files["assets/configuration/output.txt"], text.as_bytes()); + assert_eq!(files.len(), 3); + let page = String::from_utf8_lossy(&files["configuration.md"]); + let mut code_blocks = 0; + let mut code_text = String::new(); + let mut in_code = false; + for event in Parser::new(&page) { + match event { + Event::Start(Tag::CodeBlock(_)) => { + code_blocks += 1; + in_code = true; + } + Event::End(pulldown_cmark::TagEnd::CodeBlock) => in_code = false, + Event::Text(text) if in_code => code_text.push_str(&text), + _ => {} + } + } + assert_eq!(code_blocks, 1); + assert_eq!(code_text, text); + let manifest: Value = serde_json::from_slice(&files["manifest.json"]).unwrap(); + assert_eq!( + manifest["documents"][0]["blocks"][0]["references"], + serde_json::json!([ + {"id": "output", "kind": "resource"}, + {"id": "output", "kind": "resource"} + ]) + ); +} + +#[test] +fn isolated_code_views_end_before_the_following_document_block() { + let mut doc = Doc::new("output", "Output").unwrap(); + let output = doc.text("result", "result").unwrap(); + doc.markdown_parts(output.code("text")).unwrap(); + doc.paragraph("The next explanation remains prose.") + .unwrap(); + let files = Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap(); + let page = String::from_utf8_lossy(&files["output.md"]); + assert!(page.contains("```text\nresult\n```\n\n")); +} + +#[test] +fn invalid_code_view_placement_or_kind_taints_completion() { + for (before, after) in [ + ("A value ", " follows."), + (" ", ""), + ("```text\n\n", "\n\n```"), + ("", "\nThe following paragraph"), + ] { + let mut doc = Doc::new("placement", "Placement").unwrap(); + let text = doc.text("result", "frozen output").unwrap(); + assert!( + doc.markdown_parts((before, text.code("text"), after)) + .is_err(), + "Accepted code view between {before:?} and {after:?}" + ); + assert!(doc.finish().is_err()); + } + let mut paragraph = Doc::new("paragraph", "Paragraph").unwrap(); + let text = paragraph.text("result", "frozen output").unwrap(); + assert!(paragraph.paragraph(text.code("text")).is_err()); + assert!(paragraph.finish().is_err()); + + let mut image = Doc::new("image", "Image").unwrap(); + let image_resource = image + .resource( + "result", + Artifact::new(b"image", "image/png", "png", "test", "v1").unwrap(), + ) + .unwrap(); + assert!(image.markdown_parts(image_resource.code("text")).is_err()); + assert!(image.finish().is_err()); + + let mut bytes = Doc::new("bytes", "Bytes").unwrap(); + let text = bytes + .resource( + "result", + Artifact::new([0xff], "text/plain", "txt", "test", "v1").unwrap(), + ) + .unwrap(); + assert!(bytes.markdown_parts(text.code("text")).is_err()); + assert!(bytes.finish().is_err()); + + let mut language = Doc::new("language", "Language").unwrap(); + let text = language.text("result", "output").unwrap(); + assert!(language.markdown_parts(text.code("text\n```\n")).is_err()); + assert!(language.finish().is_err()); +} diff --git a/crates/lgtmd/tests/fixtures/portable.json b/crates/lgtmd/tests/fixtures/portable.json new file mode 100644 index 0000000..38d0fe3 --- /dev/null +++ b/crates/lgtmd/tests/fixtures/portable.json @@ -0,0 +1,529 @@ +{ + "exports": { + "contributor": { + "assets/portable/data.json": [ + 123, 10, 32, 32, 34, 97, 34, 58, 32, 49, 44, 10, 32, 32, 34, 122, 34, + 58, 32, 50, 10, 125, 10 + ], + "assets/portable/image.png": [1, 2, 3], + "manifest.json": [ + 123, 10, 32, 32, 34, 97, 114, 116, 105, 102, 97, 99, 116, 115, 34, 58, + 32, 91, 10, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 34, 98, + 121, 116, 101, 115, 34, 58, 32, 50, 51, 44, 10, 32, 32, 32, 32, 32, 32, + 34, 100, 111, 99, 117, 109, 101, 110, 116, 34, 58, 32, 34, 112, 111, + 114, 116, 97, 98, 108, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 105, + 100, 34, 58, 32, 34, 100, 97, 116, 97, 34, 44, 10, 32, 32, 32, 32, 32, + 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 114, 101, 115, 111, 117, + 114, 99, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 109, 105, 109, + 101, 34, 58, 32, 34, 97, 112, 112, 108, 105, 99, 97, 116, 105, 111, 110, + 47, 106, 115, 111, 110, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 112, 97, + 116, 104, 34, 58, 32, 34, 97, 115, 115, 101, 116, 115, 47, 112, 111, + 114, 116, 97, 98, 108, 101, 47, 100, 97, 116, 97, 46, 106, 115, 111, + 110, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 112, 114, 111, 100, 117, + 99, 101, 114, 34, 58, 32, 34, 101, 120, 101, 99, 117, 116, 97, 98, 108, + 101, 45, 100, 111, 99, 115, 47, 106, 115, 111, 110, 34, 44, 10, 32, 32, + 32, 32, 32, 32, 34, 112, 114, 111, 102, 105, 108, 101, 34, 58, 32, 34, + 106, 115, 111, 110, 45, 112, 114, 101, 116, 116, 121, 45, 118, 49, 34, + 44, 10, 32, 32, 32, 32, 32, 32, 34, 115, 104, 97, 50, 53, 54, 34, 58, + 32, 34, 56, 51, 102, 97, 52, 102, 51, 98, 55, 97, 52, 50, 101, 100, 49, + 54, 57, 98, 55, 55, 100, 49, 54, 52, 101, 50, 54, 102, 51, 56, 53, 51, + 48, 54, 50, 50, 52, 97, 97, 52, 97, 54, 56, 52, 49, 51, 51, 97, 54, 98, + 52, 48, 102, 49, 56, 97, 56, 48, 56, 53, 102, 97, 55, 53, 34, 10, 32, + 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, + 32, 34, 98, 121, 116, 101, 115, 34, 58, 32, 51, 44, 10, 32, 32, 32, 32, + 32, 32, 34, 100, 111, 99, 117, 109, 101, 110, 116, 34, 58, 32, 34, 112, + 111, 114, 116, 97, 98, 108, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, + 105, 100, 34, 58, 32, 34, 105, 109, 97, 103, 101, 34, 44, 10, 32, 32, + 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 114, 101, 115, + 111, 117, 114, 99, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 109, + 105, 109, 101, 34, 58, 32, 34, 105, 109, 97, 103, 101, 47, 112, 110, + 103, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 112, 97, 116, 104, 34, 58, + 32, 34, 97, 115, 115, 101, 116, 115, 47, 112, 111, 114, 116, 97, 98, + 108, 101, 47, 105, 109, 97, 103, 101, 46, 112, 110, 103, 34, 44, 10, 32, + 32, 32, 32, 32, 32, 34, 112, 114, 111, 100, 117, 99, 101, 114, 34, 58, + 32, 34, 102, 105, 120, 116, 117, 114, 101, 34, 44, 10, 32, 32, 32, 32, + 32, 32, 34, 112, 114, 111, 102, 105, 108, 101, 34, 58, 32, 34, 101, 120, + 97, 99, 116, 45, 118, 49, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 115, + 104, 97, 50, 53, 54, 34, 58, 32, 34, 48, 51, 57, 48, 53, 56, 99, 54, + 102, 50, 99, 48, 99, 98, 52, 57, 50, 99, 53, 51, 51, 98, 48, 97, 52, + 100, 49, 52, 101, 102, 55, 55, 99, 99, 48, 102, 55, 56, 97, 98, 99, 99, + 99, 101, 100, 53, 50, 56, 55, 100, 56, 52, 97, 49, 97, 50, 48, 49, 49, + 99, 102, 98, 56, 49, 34, 10, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, + 32, 123, 10, 32, 32, 32, 32, 32, 32, 34, 98, 121, 116, 101, 115, 34, 58, + 32, 51, 57, 55, 44, 10, 32, 32, 32, 32, 32, 32, 34, 100, 111, 99, 117, + 109, 101, 110, 116, 34, 58, 32, 34, 112, 111, 114, 116, 97, 98, 108, + 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, + 32, 34, 112, 97, 103, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 109, + 105, 109, 101, 34, 58, 32, 34, 116, 101, 120, 116, 47, 109, 97, 114, + 107, 100, 111, 119, 110, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 112, + 97, 116, 104, 34, 58, 32, 34, 112, 111, 114, 116, 97, 98, 108, 101, 46, + 109, 100, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 115, 104, 97, 50, 53, + 54, 34, 58, 32, 34, 56, 55, 52, 54, 101, 101, 54, 99, 99, 55, 57, 57, + 48, 57, 99, 57, 98, 98, 99, 99, 51, 101, 56, 52, 48, 52, 100, 57, 98, + 102, 99, 54, 98, 97, 97, 49, 97, 99, 100, 57, 101, 51, 54, 52, 54, 102, + 48, 48, 55, 99, 98, 50, 99, 98, 52, 55, 100, 51, 50, 101, 102, 55, 56, + 57, 34, 10, 32, 32, 32, 32, 125, 10, 32, 32, 93, 44, 10, 32, 32, 34, 97, + 117, 100, 105, 101, 110, 99, 101, 34, 58, 32, 34, 99, 111, 110, 116, + 114, 105, 98, 117, 116, 111, 114, 34, 44, 10, 32, 32, 34, 100, 111, 99, + 117, 109, 101, 110, 116, 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, 123, + 10, 32, 32, 32, 32, 32, 32, 34, 98, 108, 111, 99, 107, 115, 34, 58, 32, + 91, 10, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, + 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 111, 118, 101, 114, 118, + 105, 101, 119, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, + 107, 105, 110, 100, 34, 58, 32, 34, 104, 101, 97, 100, 105, 110, 103, + 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 114, 101, 102, + 101, 114, 101, 110, 99, 101, 115, 34, 58, 32, 91, 93, 10, 32, 32, 32, + 32, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 123, + 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, + 34, 98, 108, 111, 99, 107, 45, 48, 48, 48, 50, 34, 44, 10, 32, 32, 32, + 32, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 109, + 97, 114, 107, 100, 111, 119, 110, 45, 112, 97, 114, 116, 115, 34, 44, + 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 114, 101, 102, 101, 114, + 101, 110, 99, 101, 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 99, 111, 117, 110, 116, 34, + 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, + 105, 110, 100, 34, 58, 32, 34, 111, 98, 115, 101, 114, 118, 97, 116, + 105, 111, 110, 34, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 125, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 93, 10, 32, 32, 32, 32, + 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, + 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, + 110, 111, 116, 101, 45, 48, 48, 48, 49, 34, 44, 10, 32, 32, 32, 32, 32, + 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 110, 111, + 116, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 114, + 101, 102, 101, 114, 101, 110, 99, 101, 115, 34, 58, 32, 91, 10, 32, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 105, 110, + 116, 101, 114, 110, 97, 108, 45, 111, 110, 108, 121, 34, 44, 10, 32, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, + 34, 58, 32, 34, 111, 98, 115, 101, 114, 118, 97, 116, 105, 111, 110, 34, + 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 125, 44, 10, 32, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 98, 108, + 111, 99, 107, 45, 48, 48, 48, 50, 34, 44, 10, 32, 32, 32, 32, 32, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, + 98, 108, 111, 99, 107, 34, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 125, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 93, 10, 32, 32, + 32, 32, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, + 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, + 32, 34, 98, 108, 111, 99, 107, 45, 48, 48, 48, 51, 34, 44, 10, 32, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, + 109, 97, 114, 107, 100, 111, 119, 110, 45, 112, 97, 114, 116, 115, 34, + 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 114, 101, 102, 101, + 114, 101, 110, 99, 101, 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, 32, 32, + 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 100, 97, 116, 97, 34, 44, + 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, + 105, 110, 100, 34, 58, 32, 34, 114, 101, 115, 111, 117, 114, 99, 101, + 34, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 125, 44, 10, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 100, + 97, 116, 97, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 114, 101, 115, 111, 117, + 114, 99, 101, 34, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 125, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 93, 10, 32, 32, 32, 32, + 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, + 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, + 98, 108, 111, 99, 107, 45, 48, 48, 48, 52, 34, 44, 10, 32, 32, 32, 32, + 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 109, 97, + 114, 107, 100, 111, 119, 110, 45, 112, 97, 114, 116, 115, 34, 44, 10, + 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 114, 101, 102, 101, 114, + 101, 110, 99, 101, 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 105, 109, 97, 103, 101, 34, + 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, + 105, 110, 100, 34, 58, 32, 34, 114, 101, 115, 111, 117, 114, 99, 101, + 34, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 125, 10, 32, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 93, 10, 32, 32, 32, 32, 32, 32, 32, 32, + 125, 10, 32, 32, 32, 32, 32, 32, 93, 44, 10, 32, 32, 32, 32, 32, 32, 34, + 99, 104, 101, 99, 107, 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, 32, 32, + 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 97, 99, + 116, 117, 97, 108, 34, 58, 32, 116, 114, 117, 101, 44, 10, 32, 32, 32, + 32, 32, 32, 32, 32, 32, 32, 34, 101, 120, 112, 101, 99, 116, 101, 100, + 34, 58, 32, 116, 114, 117, 101, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 34, 105, 100, 34, 58, 32, 34, 118, 101, 114, 105, 102, 105, 101, + 100, 45, 99, 111, 117, 110, 116, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 112, 114, 101, 100, + 105, 99, 97, 116, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 34, 112, 97, 115, 115, 101, 100, 34, 58, 32, 116, 114, 117, 101, 10, + 32, 32, 32, 32, 32, 32, 32, 32, 125, 10, 32, 32, 32, 32, 32, 32, 93, 44, + 10, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 112, 111, 114, + 116, 97, 98, 108, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 111, 98, + 115, 101, 114, 118, 97, 116, 105, 111, 110, 115, 34, 58, 32, 91, 10, 32, + 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 34, 105, 100, 34, 58, 32, 34, 99, 111, 117, 110, 116, 34, 44, 10, + 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 118, 97, 108, 117, 101, 34, + 58, 32, 51, 10, 32, 32, 32, 32, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, + 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, + 105, 100, 34, 58, 32, 34, 105, 110, 116, 101, 114, 110, 97, 108, 45, + 111, 110, 108, 121, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 34, 118, 97, 108, 117, 101, 34, 58, 32, 34, 112, 114, 105, 118, 97, 116, + 101, 32, 111, 98, 115, 101, 114, 118, 97, 116, 105, 111, 110, 34, 10, + 32, 32, 32, 32, 32, 32, 32, 32, 125, 10, 32, 32, 32, 32, 32, 32, 93, 44, + 10, 32, 32, 32, 32, 32, 32, 34, 116, 105, 116, 108, 101, 34, 58, 32, 34, + 80, 111, 114, 116, 97, 98, 108, 101, 32, 100, 111, 99, 117, 109, 101, + 110, 116, 34, 10, 32, 32, 32, 32, 125, 10, 32, 32, 93, 44, 10, 32, 32, + 34, 112, 114, 111, 102, 105, 108, 101, 115, 34, 58, 32, 91, 10, 32, 32, + 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 34, 112, 114, 111, 100, 117, + 99, 101, 114, 34, 58, 32, 34, 101, 120, 101, 99, 117, 116, 97, 98, 108, + 101, 45, 100, 111, 99, 115, 47, 106, 115, 111, 110, 34, 44, 10, 32, 32, + 32, 32, 32, 32, 34, 112, 114, 111, 102, 105, 108, 101, 34, 58, 32, 34, + 106, 115, 111, 110, 45, 112, 114, 101, 116, 116, 121, 45, 118, 49, 34, + 10, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 123, 10, 32, 32, 32, + 32, 32, 32, 34, 112, 114, 111, 100, 117, 99, 101, 114, 34, 58, 32, 34, + 102, 105, 120, 116, 117, 114, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, + 34, 112, 114, 111, 102, 105, 108, 101, 34, 58, 32, 34, 101, 120, 97, 99, + 116, 45, 118, 49, 34, 10, 32, 32, 32, 32, 125, 10, 32, 32, 93, 44, 10, + 32, 32, 34, 115, 99, 104, 101, 109, 97, 95, 118, 101, 114, 115, 105, + 111, 110, 34, 58, 32, 49, 10, 125, 10 + ], + "portable.md": [ + 35, 32, 80, 111, 114, 116, 97, 98, 108, 101, 32, 100, 111, 99, 117, 109, + 101, 110, 116, 10, 10, 60, 97, 32, 105, 100, 61, 34, 111, 118, 101, 114, + 118, 105, 101, 119, 34, 62, 60, 47, 97, 62, 10, 10, 35, 35, 32, 79, 118, + 101, 114, 118, 105, 101, 119, 10, 10, 60, 97, 32, 105, 100, 61, 34, 98, + 108, 111, 99, 107, 45, 48, 48, 48, 50, 34, 62, 60, 47, 97, 62, 10, 10, + 67, 111, 110, 116, 97, 105, 110, 115, 32, 51, 32, 114, 101, 99, 111, + 114, 100, 115, 46, 10, 10, 60, 97, 32, 105, 100, 61, 34, 110, 111, 116, + 101, 45, 48, 48, 48, 49, 34, 62, 60, 47, 97, 62, 10, 10, 62, 32, 42, 42, + 67, 111, 110, 116, 114, 105, 98, 117, 116, 111, 114, 32, 110, 111, 116, + 101, 32, 111, 110, 32, 91, 98, 108, 111, 99, 107, 92, 45, 48, 48, 48, + 50, 93, 40, 35, 98, 108, 111, 99, 107, 45, 48, 48, 48, 50, 41, 58, 42, + 42, 32, 73, 110, 116, 101, 114, 110, 97, 108, 58, 32, 112, 114, 105, + 118, 97, 116, 101, 32, 111, 98, 115, 101, 114, 118, 97, 116, 105, 111, + 110, 10, 10, 60, 97, 32, 105, 100, 61, 34, 98, 108, 111, 99, 107, 45, + 48, 48, 48, 51, 34, 62, 60, 47, 97, 62, 10, 10, 68, 97, 116, 97, 58, 10, + 10, 96, 96, 96, 106, 115, 111, 110, 10, 123, 10, 32, 32, 34, 97, 34, 58, + 32, 49, 44, 10, 32, 32, 34, 122, 34, 58, 32, 50, 10, 125, 10, 96, 96, + 96, 10, 10, 10, 10, 91, 68, 111, 119, 110, 108, 111, 97, 100, 93, 40, + 97, 115, 115, 101, 116, 115, 47, 112, 111, 114, 116, 97, 98, 108, 101, + 47, 100, 97, 116, 97, 46, 106, 115, 111, 110, 41, 10, 10, 60, 97, 32, + 105, 100, 61, 34, 98, 108, 111, 99, 107, 45, 48, 48, 48, 52, 34, 62, 60, + 47, 97, 62, 10, 10, 33, 91, 70, 105, 120, 101, 100, 32, 102, 105, 120, + 116, 117, 114, 101, 32, 98, 121, 116, 101, 115, 93, 40, 97, 115, 115, + 101, 116, 115, 47, 112, 111, 114, 116, 97, 98, 108, 101, 47, 105, 109, + 97, 103, 101, 46, 112, 110, 103, 41, 10, 10 + ] + }, + "reader": { + "assets/portable/data.json": [ + 123, 10, 32, 32, 34, 97, 34, 58, 32, 49, 44, 10, 32, 32, 34, 122, 34, + 58, 32, 50, 10, 125, 10 + ], + "assets/portable/image.png": [1, 2, 3], + "manifest.json": [ + 123, 10, 32, 32, 34, 97, 114, 116, 105, 102, 97, 99, 116, 115, 34, 58, + 32, 91, 10, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 34, 98, + 121, 116, 101, 115, 34, 58, 32, 50, 51, 44, 10, 32, 32, 32, 32, 32, 32, + 34, 100, 111, 99, 117, 109, 101, 110, 116, 34, 58, 32, 34, 112, 111, + 114, 116, 97, 98, 108, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 105, + 100, 34, 58, 32, 34, 100, 97, 116, 97, 34, 44, 10, 32, 32, 32, 32, 32, + 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 114, 101, 115, 111, 117, + 114, 99, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 109, 105, 109, + 101, 34, 58, 32, 34, 97, 112, 112, 108, 105, 99, 97, 116, 105, 111, 110, + 47, 106, 115, 111, 110, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 112, 97, + 116, 104, 34, 58, 32, 34, 97, 115, 115, 101, 116, 115, 47, 112, 111, + 114, 116, 97, 98, 108, 101, 47, 100, 97, 116, 97, 46, 106, 115, 111, + 110, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 112, 114, 111, 100, 117, + 99, 101, 114, 34, 58, 32, 34, 101, 120, 101, 99, 117, 116, 97, 98, 108, + 101, 45, 100, 111, 99, 115, 47, 106, 115, 111, 110, 34, 44, 10, 32, 32, + 32, 32, 32, 32, 34, 112, 114, 111, 102, 105, 108, 101, 34, 58, 32, 34, + 106, 115, 111, 110, 45, 112, 114, 101, 116, 116, 121, 45, 118, 49, 34, + 44, 10, 32, 32, 32, 32, 32, 32, 34, 115, 104, 97, 50, 53, 54, 34, 58, + 32, 34, 56, 51, 102, 97, 52, 102, 51, 98, 55, 97, 52, 50, 101, 100, 49, + 54, 57, 98, 55, 55, 100, 49, 54, 52, 101, 50, 54, 102, 51, 56, 53, 51, + 48, 54, 50, 50, 52, 97, 97, 52, 97, 54, 56, 52, 49, 51, 51, 97, 54, 98, + 52, 48, 102, 49, 56, 97, 56, 48, 56, 53, 102, 97, 55, 53, 34, 10, 32, + 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, + 32, 34, 98, 121, 116, 101, 115, 34, 58, 32, 51, 44, 10, 32, 32, 32, 32, + 32, 32, 34, 100, 111, 99, 117, 109, 101, 110, 116, 34, 58, 32, 34, 112, + 111, 114, 116, 97, 98, 108, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, + 105, 100, 34, 58, 32, 34, 105, 109, 97, 103, 101, 34, 44, 10, 32, 32, + 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 114, 101, 115, + 111, 117, 114, 99, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 109, + 105, 109, 101, 34, 58, 32, 34, 105, 109, 97, 103, 101, 47, 112, 110, + 103, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 112, 97, 116, 104, 34, 58, + 32, 34, 97, 115, 115, 101, 116, 115, 47, 112, 111, 114, 116, 97, 98, + 108, 101, 47, 105, 109, 97, 103, 101, 46, 112, 110, 103, 34, 44, 10, 32, + 32, 32, 32, 32, 32, 34, 112, 114, 111, 100, 117, 99, 101, 114, 34, 58, + 32, 34, 102, 105, 120, 116, 117, 114, 101, 34, 44, 10, 32, 32, 32, 32, + 32, 32, 34, 112, 114, 111, 102, 105, 108, 101, 34, 58, 32, 34, 101, 120, + 97, 99, 116, 45, 118, 49, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 115, + 104, 97, 50, 53, 54, 34, 58, 32, 34, 48, 51, 57, 48, 53, 56, 99, 54, + 102, 50, 99, 48, 99, 98, 52, 57, 50, 99, 53, 51, 51, 98, 48, 97, 52, + 100, 49, 52, 101, 102, 55, 55, 99, 99, 48, 102, 55, 56, 97, 98, 99, 99, + 99, 101, 100, 53, 50, 56, 55, 100, 56, 52, 97, 49, 97, 50, 48, 49, 49, + 99, 102, 98, 56, 49, 34, 10, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, + 32, 123, 10, 32, 32, 32, 32, 32, 32, 34, 98, 121, 116, 101, 115, 34, 58, + 32, 50, 56, 56, 44, 10, 32, 32, 32, 32, 32, 32, 34, 100, 111, 99, 117, + 109, 101, 110, 116, 34, 58, 32, 34, 112, 111, 114, 116, 97, 98, 108, + 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, + 32, 34, 112, 97, 103, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 109, + 105, 109, 101, 34, 58, 32, 34, 116, 101, 120, 116, 47, 109, 97, 114, + 107, 100, 111, 119, 110, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 112, + 97, 116, 104, 34, 58, 32, 34, 112, 111, 114, 116, 97, 98, 108, 101, 46, + 109, 100, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 115, 104, 97, 50, 53, + 54, 34, 58, 32, 34, 100, 51, 98, 54, 102, 56, 57, 98, 100, 98, 57, 48, + 101, 101, 56, 54, 99, 99, 54, 102, 54, 49, 51, 48, 99, 102, 52, 49, 54, + 99, 57, 54, 101, 97, 51, 53, 50, 50, 99, 99, 99, 56, 51, 48, 101, 52, + 102, 101, 57, 51, 53, 51, 49, 52, 100, 97, 49, 50, 48, 99, 50, 99, 57, + 49, 34, 10, 32, 32, 32, 32, 125, 10, 32, 32, 93, 44, 10, 32, 32, 34, 97, + 117, 100, 105, 101, 110, 99, 101, 34, 58, 32, 34, 114, 101, 97, 100, + 101, 114, 34, 44, 10, 32, 32, 34, 100, 111, 99, 117, 109, 101, 110, 116, + 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, + 32, 34, 98, 108, 111, 99, 107, 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, + 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, + 105, 100, 34, 58, 32, 34, 111, 118, 101, 114, 118, 105, 101, 119, 34, + 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, + 34, 58, 32, 34, 104, 101, 97, 100, 105, 110, 103, 34, 44, 10, 32, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 34, 114, 101, 102, 101, 114, 101, 110, + 99, 101, 115, 34, 58, 32, 91, 93, 10, 32, 32, 32, 32, 32, 32, 32, 32, + 125, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, + 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 98, 108, 111, 99, + 107, 45, 48, 48, 48, 50, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 109, 97, 114, 107, 100, 111, + 119, 110, 45, 112, 97, 114, 116, 115, 34, 44, 10, 32, 32, 32, 32, 32, + 32, 32, 32, 32, 32, 34, 114, 101, 102, 101, 114, 101, 110, 99, 101, 115, + 34, 58, 32, 91, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 123, + 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, + 100, 34, 58, 32, 34, 99, 111, 117, 110, 116, 34, 44, 10, 32, 32, 32, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, + 32, 34, 111, 98, 115, 101, 114, 118, 97, 116, 105, 111, 110, 34, 10, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 125, 10, 32, 32, 32, 32, 32, + 32, 32, 32, 32, 32, 93, 10, 32, 32, 32, 32, 32, 32, 32, 32, 125, 44, 10, + 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 34, 105, 100, 34, 58, 32, 34, 98, 108, 111, 99, 107, 45, 48, 48, + 48, 51, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, + 105, 110, 100, 34, 58, 32, 34, 109, 97, 114, 107, 100, 111, 119, 110, + 45, 112, 97, 114, 116, 115, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 34, 114, 101, 102, 101, 114, 101, 110, 99, 101, 115, 34, 58, 32, + 91, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, + 32, 34, 100, 97, 116, 97, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 114, + 101, 115, 111, 117, 114, 99, 101, 34, 10, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 34, 105, 100, 34, 58, 32, 34, 100, 97, 116, 97, 34, 44, 10, 32, 32, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, + 58, 32, 34, 114, 101, 115, 111, 117, 114, 99, 101, 34, 10, 32, 32, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 32, 125, 10, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 32, 93, 10, 32, 32, 32, 32, 32, 32, 32, 32, 125, 44, 10, 32, 32, + 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 34, 105, 100, 34, 58, 32, 34, 98, 108, 111, 99, 107, 45, 48, 48, 48, 52, + 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, + 100, 34, 58, 32, 34, 109, 97, 114, 107, 100, 111, 119, 110, 45, 112, 97, + 114, 116, 115, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, + 114, 101, 102, 101, 114, 101, 110, 99, 101, 115, 34, 58, 32, 91, 10, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 105, + 109, 97, 103, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 114, 101, 115, + 111, 117, 114, 99, 101, 34, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 125, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 93, 10, 32, 32, + 32, 32, 32, 32, 32, 32, 125, 10, 32, 32, 32, 32, 32, 32, 93, 44, 10, 32, + 32, 32, 32, 32, 32, 34, 99, 104, 101, 99, 107, 115, 34, 58, 32, 91, 10, + 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 34, 105, 100, 34, 58, 32, 34, 118, 101, 114, 105, 102, 105, 101, + 100, 45, 99, 111, 117, 110, 116, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 112, 114, 101, 100, + 105, 99, 97, 116, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 34, 112, 97, 115, 115, 101, 100, 34, 58, 32, 116, 114, 117, 101, 10, + 32, 32, 32, 32, 32, 32, 32, 32, 125, 10, 32, 32, 32, 32, 32, 32, 93, 44, + 10, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 112, 111, 114, + 116, 97, 98, 108, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 111, 98, + 115, 101, 114, 118, 97, 116, 105, 111, 110, 115, 34, 58, 32, 91, 10, 32, + 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 34, 105, 100, 34, 58, 32, 34, 99, 111, 117, 110, 116, 34, 44, 10, + 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 118, 97, 108, 117, 101, 34, + 58, 32, 51, 10, 32, 32, 32, 32, 32, 32, 32, 32, 125, 10, 32, 32, 32, 32, + 32, 32, 93, 44, 10, 32, 32, 32, 32, 32, 32, 34, 116, 105, 116, 108, 101, + 34, 58, 32, 34, 80, 111, 114, 116, 97, 98, 108, 101, 32, 100, 111, 99, + 117, 109, 101, 110, 116, 34, 10, 32, 32, 32, 32, 125, 10, 32, 32, 93, + 44, 10, 32, 32, 34, 112, 114, 111, 102, 105, 108, 101, 115, 34, 58, 32, + 91, 10, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 34, 112, 114, + 111, 100, 117, 99, 101, 114, 34, 58, 32, 34, 101, 120, 101, 99, 117, + 116, 97, 98, 108, 101, 45, 100, 111, 99, 115, 47, 106, 115, 111, 110, + 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 112, 114, 111, 102, 105, 108, + 101, 34, 58, 32, 34, 106, 115, 111, 110, 45, 112, 114, 101, 116, 116, + 121, 45, 118, 49, 34, 10, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, + 123, 10, 32, 32, 32, 32, 32, 32, 34, 112, 114, 111, 100, 117, 99, 101, + 114, 34, 58, 32, 34, 102, 105, 120, 116, 117, 114, 101, 34, 44, 10, 32, + 32, 32, 32, 32, 32, 34, 112, 114, 111, 102, 105, 108, 101, 34, 58, 32, + 34, 101, 120, 97, 99, 116, 45, 118, 49, 34, 10, 32, 32, 32, 32, 125, 10, + 32, 32, 93, 44, 10, 32, 32, 34, 115, 99, 104, 101, 109, 97, 95, 118, + 101, 114, 115, 105, 111, 110, 34, 58, 32, 49, 10, 125, 10 + ], + "portable.md": [ + 35, 32, 80, 111, 114, 116, 97, 98, 108, 101, 32, 100, 111, 99, 117, 109, + 101, 110, 116, 10, 10, 60, 97, 32, 105, 100, 61, 34, 111, 118, 101, 114, + 118, 105, 101, 119, 34, 62, 60, 47, 97, 62, 10, 10, 35, 35, 32, 79, 118, + 101, 114, 118, 105, 101, 119, 10, 10, 60, 97, 32, 105, 100, 61, 34, 98, + 108, 111, 99, 107, 45, 48, 48, 48, 50, 34, 62, 60, 47, 97, 62, 10, 10, + 67, 111, 110, 116, 97, 105, 110, 115, 32, 51, 32, 114, 101, 99, 111, + 114, 100, 115, 46, 10, 10, 60, 97, 32, 105, 100, 61, 34, 98, 108, 111, + 99, 107, 45, 48, 48, 48, 51, 34, 62, 60, 47, 97, 62, 10, 10, 68, 97, + 116, 97, 58, 10, 10, 96, 96, 96, 106, 115, 111, 110, 10, 123, 10, 32, + 32, 34, 97, 34, 58, 32, 49, 44, 10, 32, 32, 34, 122, 34, 58, 32, 50, 10, + 125, 10, 96, 96, 96, 10, 10, 10, 10, 91, 68, 111, 119, 110, 108, 111, + 97, 100, 93, 40, 97, 115, 115, 101, 116, 115, 47, 112, 111, 114, 116, + 97, 98, 108, 101, 47, 100, 97, 116, 97, 46, 106, 115, 111, 110, 41, 10, + 10, 60, 97, 32, 105, 100, 61, 34, 98, 108, 111, 99, 107, 45, 48, 48, 48, + 52, 34, 62, 60, 47, 97, 62, 10, 10, 33, 91, 70, 105, 120, 101, 100, 32, + 102, 105, 120, 116, 117, 114, 101, 32, 98, 121, 116, 101, 115, 93, 40, + 97, 115, 115, 101, 116, 115, 47, 112, 111, 114, 116, 97, 98, 108, 101, + 47, 105, 109, 97, 103, 101, 46, 112, 110, 103, 41, 10, 10 + ] + } + }, + "input": { + "documents": [ + { + "bindings": [], + "checks": [ + { + "id": "verified-count", + "kind": "predicate", + "passed": true + } + ], + "id": "portable", + "nodes": [ + { + "id": "overview", + "kind": "heading", + "level": 2, + "text": "Overview" + }, + { + "id": "block-0002", + "kind": "markdown", + "parts": [ + { + "kind": "markdown", + "text": "Contains " + }, + { + "kind": "reference", + "target": { + "id": "count", + "kind": "observation" + } + }, + { + "kind": "markdown", + "text": " records.\n\n" + } + ] + }, + { + "id": "note-0001", + "kind": "note", + "parts": [ + { + "kind": "text", + "text": "Internal: " + }, + { + "kind": "reference", + "target": { + "id": "internal-only", + "kind": "observation" + } + } + ], + "target": { + "id": "block-0002", + "kind": "block" + } + }, + { + "id": "block-0003", + "kind": "markdown", + "parts": [ + { + "kind": "markdown", + "text": "Data:\n\n" + }, + { + "kind": "code", + "language": "json", + "target": { + "id": "data", + "kind": "resource" + } + }, + { + "kind": "markdown", + "text": "\n\n" + }, + { + "kind": "link", + "label": "Download", + "target": { + "id": "data", + "kind": "resource" + } + }, + { + "kind": "markdown", + "text": "\n\n" + } + ] + }, + { + "id": "block-0004", + "kind": "markdown", + "parts": [ + { + "kind": "image", + "label": "Fixed fixture bytes", + "target": { + "id": "image", + "kind": "resource" + } + }, + { + "kind": "markdown", + "text": "\n\n" + } + ] + } + ], + "observations": [ + { + "id": "count", + "value": 3 + }, + { + "id": "internal-only", + "value": "private observation" + } + ], + "resources": [ + { + "bytes": [ + 123, 10, 32, 32, 34, 97, 34, 58, 32, 49, 44, 10, 32, 32, 34, 122, + 34, 58, 32, 50, 10, 125, 10 + ], + "extension": "json", + "id": "data", + "mime": "application/json", + "producer": "executable-docs/json", + "profile": "json-pretty-v1" + }, + { + "bytes": [1, 2, 3], + "extension": "png", + "id": "image", + "mime": "image/png", + "producer": "fixture", + "profile": "exact-v1" + } + ], + "title": "Portable document" + } + ], + "schema_version": 1 + } +} diff --git a/crates/lgtmd/tests/observations.rs b/crates/lgtmd/tests/observations.rs new file mode 100644 index 0000000..edcab7a --- /dev/null +++ b/crates/lgtmd/tests/observations.rs @@ -0,0 +1,266 @@ +use lgtmd::{Audience, Doc, Document, store}; +use serde::{Serialize, Serializer, ser::Error}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; + +fn render(doc: Doc, audience: Audience) -> BTreeMap> { + Document::render_many(&[doc.finish().unwrap()], audience).unwrap() +} + +fn manifest(files: &BTreeMap>) -> Value { + serde_json::from_slice(&files["manifest.json"]).unwrap() +} + +#[test] +fn explanations_publish_without_manufacturing_checks_or_observations() { + let mut doc = Doc::new("saved-view", "Save a useful view").unwrap(); + let section = doc.heading(2, "Name your view").unwrap(); + doc.paragraph("Give the view a name you will recognize later.") + .unwrap(); + doc.note_on( + §ion, + "This page makes no behavioral verification claim.", + ) + .unwrap(); + let files = render(doc, Audience::Reader); + let receipt = manifest(&files); + assert_eq!(receipt["documents"][0]["checks"], json!([])); + assert!(receipt["documents"][0].get("observations").is_none()); + let directory = tempfile::tempdir().unwrap(); + store::build( + &directory.path().canonicalize().unwrap().join("reader"), + &files, + ) + .unwrap(); + assert!(String::from_utf8_lossy(&files["saved-view.md"]).contains("recognize later")); + assert!(!String::from_utf8_lossy(&files["saved-view.md"]).contains("verification claim")); +} + +#[test] +fn observations_freeze_at_registration_and_are_distinct_from_executed_checks() { + let mut doc = Doc::new("values", "Values").unwrap(); + let mut source = json!({"nested": [{"z": "last", "a": "first"}], "count": 2}); + let observed = doc.observe("response", &source).unwrap(); + assert_eq!(observed.id(), "response"); + assert_eq!(observed.observed(), &source); + doc.paragraph(("The response was ", &observed, ".")) + .unwrap(); + let count = doc.expect_eq("expected-count", 2, 2).unwrap(); + doc.paragraph(("The checked count is ", &count, ".")) + .unwrap(); + source["count"] = 99.into(); + source["nested"][0]["a"] = "changed".into(); + let files = render(doc, Audience::Reader); + let receipt = manifest(&files); + assert_eq!( + receipt["documents"][0]["observations"], + json!([{"id": "response", "value": { + "nested": [{"z": "last", "a": "first"}], "count": 2 + }}]) + ); + assert_eq!( + receipt["documents"][0]["checks"].as_array().unwrap().len(), + 1 + ); + assert_eq!(receipt["documents"][0]["checks"][0]["id"], "expected-count"); + assert_eq!( + receipt["documents"][0]["blocks"][0]["references"][0]["kind"], + "observation" + ); + let page = String::from_utf8_lossy(&files["values.md"]); + assert!(page.contains("first")); + assert!(!page.contains("changed")); + assert!(!page.contains("99")); + let directory = tempfile::tempdir().unwrap(); + store::build( + &directory.path().canonicalize().unwrap().join("reader"), + &files, + ) + .unwrap(); +} + +#[test] +fn observing_does_not_require_an_assertion_and_json_null_remains_present() { + let mut doc = Doc::new("optional", "An optional response").unwrap(); + let response = doc.observe("response", Option::::None).unwrap(); + doc.paragraph(("The response was ", response, ".")).unwrap(); + let files = render(doc, Audience::Reader); + let receipt = manifest(&files); + assert_eq!(receipt["documents"][0]["checks"], json!([])); + assert_eq!( + receipt["documents"][0]["observations"][0].get("value"), + Some(&Value::Null) + ); + assert!(String::from_utf8_lossy(&files["optional.md"]).contains("response was null")); + let directory = tempfile::tempdir().unwrap(); + store::build( + &directory.path().canonicalize().unwrap().join("reader"), + &files, + ) + .unwrap(); +} + +#[test] +fn adding_private_observations_and_notes_preserves_every_reader_byte() { + fn document(private: bool) -> Document { + let mut doc = Doc::new("records", "Records").unwrap(); + let heading = doc.heading(2, "Review records").unwrap(); + doc.paragraph("Use the name to identify a record.").unwrap(); + if private { + let token = doc.observe("private-token", "PRIVATE SNAPSHOT").unwrap(); + let details = doc.text("private-resource", "PRIVATE BYTES").unwrap(); + doc.note_on(&heading, ("Internal: ", &token, " ", &details)) + .unwrap(); + doc.note_on(&token, "Keep this observation for contributor review.") + .unwrap(); + } + doc.finish().unwrap() + } + let ordinary = Document::render_many(&[document(false)], Audience::Reader).unwrap(); + let annotated = Document::render_many(&[document(true)], Audience::Reader).unwrap(); + assert_eq!(ordinary, annotated); + let contributor = Document::render_many(&[document(true)], Audience::Contributor).unwrap(); + let receipt = manifest(&contributor); + assert_eq!( + receipt["documents"][0]["observations"], + json!([{"id": "private-token", "value": "PRIVATE SNAPSHOT"}]) + ); + assert!( + String::from_utf8_lossy(&contributor["records.md"]) + .contains("Contributor note on `private-token`") + ); + assert_eq!( + contributor["assets/records/private-resource.txt"], + b"PRIVATE BYTES" + ); +} + +#[test] +fn observation_registration_order_and_nested_json_keys_are_canonical() { + struct OutOfOrder; + impl Serialize for OutOfOrder { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeMap; + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry("z", &json!([{"z": 2, "a": 1}]))?; + map.serialize_entry("a", &"first")?; + map.end() + } + } + fn document(reverse: bool) -> Document { + let mut doc = Doc::new("canonical", "Canonical observations").unwrap(); + let mut ids = ["alpha", "zeta"]; + if reverse { + ids.reverse(); + } + let mut observations = BTreeMap::new(); + for id in ids { + let observed = doc.observe(id, OutOfOrder).unwrap(); + assert_eq!( + observed.observed().to_string(), + r#"{"a":"first","z":[{"a":1,"z":2}]}"# + ); + observations.insert(id, observed); + } + for observed in observations.values() { + doc.paragraph(observed).unwrap(); + } + doc.finish().unwrap() + } + assert_eq!( + Document::render_many(&[document(false)], Audience::Reader).unwrap(), + Document::render_many(&[document(true)], Audience::Reader).unwrap() + ); +} + +#[test] +fn swallowed_observation_failures_and_failed_checks_still_prevent_completion() { + struct CannotSerialize; + impl Serialize for CannotSerialize { + fn serialize(&self, _: S) -> Result { + Err(S::Error::custom("snapshot failed")) + } + } + let mut failed = Doc::new("failed", "Failed").unwrap(); + let _ = failed.observe("snapshot", CannotSerialize); + failed + .paragraph("An explanation cannot hide a failed operation.") + .unwrap(); + assert!(failed.finish().unwrap_err().contains("snapshot failed")); + + let mut duplicate = Doc::new("duplicate", "Duplicate").unwrap(); + duplicate.observe("shared", 1).unwrap(); + let _ = duplicate.require("shared", true); + assert!(duplicate.finish().unwrap_err().contains("Duplicate")); + + let mut first = Doc::new("first", "First").unwrap(); + let observed = first.observe("value", 1).unwrap(); + let mut second = Doc::new("second", "Second").unwrap(); + second.observe("value", 1).unwrap(); + let _ = second.paragraph(&observed); + assert!( + second + .finish() + .unwrap_err() + .contains("Foreign document/run") + ); + + let mut check = Doc::new("check", "Failed check").unwrap(); + check.observe("value", 1).unwrap(); + let _ = check.expect_eq("incorrect", 1, 2); + assert!( + check + .finish() + .unwrap_err() + .contains("Check incorrect failed") + ); +} + +#[test] +fn malformed_observation_receipts_fail_before_writing_output() { + let mut doc = Doc::new("values", "Values").unwrap(); + let observed = doc.observe("response", 2).unwrap(); + doc.paragraph(&observed).unwrap(); + let original = render(doc, Audience::Reader); + let directory = tempfile::tempdir().unwrap(); + let parent = directory.path().canonicalize().unwrap(); + store::build(&parent.join("valid"), &original).unwrap(); + for (index, edit) in [ + (|receipt: &mut Value| { + receipt["documents"][0]["observations"] = Value::Null; + }) as fn(&mut Value), + |receipt| { + receipt["documents"][0]["observations"][0] + .as_object_mut() + .unwrap() + .remove("value"); + }, + |receipt| { + let duplicate = receipt["documents"][0]["observations"][0].clone(); + receipt["documents"][0]["observations"] + .as_array_mut() + .unwrap() + .push(duplicate); + }, + |receipt| { + receipt["documents"][0]["observations"][0]["id"] = "block-0001".into(); + }, + |receipt| { + receipt["documents"][0]["blocks"][0]["references"][0]["kind"] = "value".into(); + }, + ] + .into_iter() + .enumerate() + { + let mut candidate = original.clone(); + let mut receipt = manifest(&candidate); + edit(&mut receipt); + candidate.insert( + "manifest.json".into(), + serde_json::to_vec(&receipt).unwrap(), + ); + let output = parent.join(index.to_string()); + assert!(store::build(&output, &candidate).is_err()); + assert!(!output.exists()); + } +} diff --git a/crates/lgtmd/tests/transport.rs b/crates/lgtmd/tests/transport.rs new file mode 100644 index 0000000..3f4a5ae --- /dev/null +++ b/crates/lgtmd/tests/transport.rs @@ -0,0 +1,103 @@ +use lgtmd::{ + Artifact, Audience, Doc, ExportLayout, Result, render, + transport::{self, Input}, +}; +use serde_json::{Value, json}; + +fn documents() -> Result> { + let mut doc = Doc::new("portable", "Portable document")?; + let value = doc.observe("count", 3)?; + let private = doc.observe("internal-only", "private observation")?; + let checked = doc.expect_eq("null-value", Value::Null, Value::Null)?; + doc.require("verified-count", true)?; + doc.heading_with_id("overview", 2, "Overview")?; + let passage = doc.markdown_parts(("Contains ", &value, " records and ", &checked, ".\n\n"))?; + doc.note_on(&passage, ("Internal: ", &private))?; + let json = doc.json("data", &json!({"z":2,"a":1}))?; + doc.markdown_parts(( + "Data:\n\n", + json.code("json"), + "\n\n", + json.link("Download"), + "\n\n", + ))?; + let image = doc.resource( + "image", + Artifact::new(vec![1, 2, 3], "image/png", "png", "fixture", "exact-v1")?, + )?; + doc.paragraph(image.image("Fixed fixture bytes"))?; + doc.code(&json, "json")?; + doc.markdown("A **literal** Markdown block.")?; + Ok(vec![doc.finish()?]) +} + +#[test] +fn frozen_transport_preserves_both_native_exports_and_null_checks() { + let docs = documents().unwrap(); + let input = Input::from_documents(&docs).unwrap(); + let serialized = serde_json::to_vec(&input).unwrap(); + let input: Input = serde_json::from_slice(&serialized).unwrap(); + let exports = transport::compile(&input, &ExportLayout::default()).unwrap(); + assert_eq!(exports.reader, render(&docs, Audience::Reader).unwrap()); + assert_eq!( + exports.contributor, + render(&docs, Audience::Contributor).unwrap() + ); + assert!( + !String::from_utf8(exports.reader["manifest.json"].clone()) + .unwrap() + .contains("internal-only") + ); +} + +#[test] +fn transport_rejects_invalid_schema_references_outcomes_and_numbers() { + let input = + serde_json::to_value(Input::from_documents(&documents().unwrap()).unwrap()).unwrap(); + for mutate in [ + |v: &mut Value| v["schema_version"] = json!(2), + |v: &mut Value| v["documents"][0]["checks"][0]["passed"] = json!(false), + |v: &mut Value| { + v["documents"][0]["observations"][0]["value"] = json!(9_007_199_254_740_992_u64) + }, + |v: &mut Value| { + v["documents"][0]["nodes"][1]["parts"][1]["target"]["id"] = json!("missing") + }, + |v: &mut Value| { + v["documents"][0]["nodes"][1]["parts"][1]["target"]["kind"] = json!("resource") + }, + |v: &mut Value| v["documents"][0]["resources"][0]["path"] = json!("../escape.json"), + |v: &mut Value| v["documents"][0]["nodes"][0]["id"] = json!("count"), + ] { + let mut candidate = input.clone(); + mutate(&mut candidate); + let candidate: Input = serde_json::from_value(candidate).unwrap(); + assert!(transport::compile(&candidate, &ExportLayout::default()).is_err()); + } + let mut unknown = input.clone(); + unknown["documents"][0]["host_state"] = json!(true); + assert!(serde_json::from_value::(unknown).is_err()); + let mut unknown_binding = input.clone(); + unknown_binding["documents"][0]["bindings"] = json!([{ + "id":"label", "value":{"kind":"text", "value":"Visible", "unknown":true} + }]); + assert!(serde_json::from_value::(unknown_binding).is_err()); + let mut invalid_bytes = input; + invalid_bytes["documents"][0]["resources"][0]["bytes"] = json!([256]); + assert!(serde_json::from_value::(invalid_bytes).is_err()); +} + +#[test] +fn native_values_outside_portable_numeric_domain_fail_at_boundary() { + let mut doc = Doc::new("large-number", "Large number").unwrap(); + doc.observe("large", u64::MAX).unwrap(); + assert!(Input::from_documents(&[doc.finish().unwrap()]).is_err()); +} + +#[test] +fn shared_fixture_exports_remain_exact() { + let fixture: Value = serde_json::from_str(include_str!("fixtures/portable.json")).unwrap(); + let input: Input = serde_json::from_value(fixture["input"].clone()).unwrap(); + let exports = transport::compile(&input, &ExportLayout::default()).unwrap(); + assert_eq!(serde_json::to_value(exports).unwrap(), fixture["exports"]); +} diff --git a/docs/architecture/executable-documents.md b/docs/architecture/executable-documents.md index 990e239..f9256ee 100644 --- a/docs/architecture/executable-documents.md +++ b/docs/architecture/executable-documents.md @@ -14,9 +14,11 @@ future repository move. No registry publication or release is implied. ## The shared contract -An author writes a user explanation while ordinary Rust code executes behavior -and checks important promises. The completed document owns its prose, checked -observations, resources, and optional contributor notes. Reader and Contributor +An author writes a user explanation while ordinary Rust or Node code executes +behavior and checks important promises. Explanation-only pages are valid. +`observe` freezes a value without implying a check; successful named checks and +host outcomes remain distinct. The completed document owns its prose, +observations, checks, resources, and optional contributor notes. Reader and Contributor exports reuse those frozen results; rendering another audience does not rerun the application or recapture resources. @@ -216,3 +218,43 @@ maintainer adoption. Validation with independent project owners, the remaining future work. The framework detects encoded behavioral and artifact drift; prose clarity, meaningful coverage, visual legibility, and physical interaction still need human review. + +## Node and web incubation + +[packages/lgtmd](../../packages/lgtmd/README.md) authors documents in Node 24 ESM; +[crates/lgtmd-node](../../crates/lgtmd-node/Cargo.toml) is its coarse native bridge. +One serialized request per compile/publication carries schema-v1 frozen input to +the same Rust validation, Markdown exporters, runner and owned store. The wire +contains complete ordered content and resource snapshots, never process-local +handle owners or application callbacks. Rust transport round trips preserve +existing native exports, including literal Markdown versus typed parts. Built-in +JSON resources use one Rust encoder, including those registered from Node. + +The [three React/Vite examples](../../examples/lgtmd-web/README.md) exercise prose-only +authoring, application-owned async preparation/cleanup, supplied context, +observations, native checks, private notes/resources, JSON and CSV downloads, and +Playwright screenshot evidence. The React app uses ordinary Vite development and +production builds, with a project-owned HTTP API and isolated test workspaces. +Documentation captures run the built app through Vite preview on separate test +ports; the development servers can remain open. +The browser bundle imports no SDK code, and the examples import no N3 application +code. The optional +adapter extends a project's existing Playwright fixtures and assertions. It +collects candidate attachments and publishes only after the real runner process +and guarded reporters complete successfully. It initially supports one project, +no retries/repetition/sharding, and clean passed outcomes for all discovered tests. +Application state, services, seeds and DOM query correctness remain project-owned. + +WASM was probed with the same compiler input and produced equal exports, but its +filesystem store returned an unsupported-platform error. The selected native +route retains the existing strict filesystem contract. It requires platform +binaries in npm tarballs; installation performs no Rust build. Node 24 ESM on +macOS arm64 is the executed local scope. Linux x64 has CI wiring, not a completed +runtime claim. A full viewer and browser-hosted SDK are deferred. + +`just doc-web-test --offline` checks the native bridge, Node authoring, failure +semantics, real Chromium replay, exact repeated exports, and clean Cargo/npm +consumers. `just doc-web-smoke --offline` repeats that gate in an independent +workspace on Rust 1.95. The npm consumer installs with Rust absent from PATH and +imports the core before installing the optional Playwright peer. Browser setup +is explicit through `just doc-web-setup`; web gates supplement N3's native verifier. diff --git a/docs/development.md b/docs/development.md index e9e0eeb..b85b831 100644 --- a/docs/development.md +++ b/docs/development.md @@ -516,6 +516,16 @@ two integration shapes; the serde_json example is our use of an existing library not adoption by its maintainers. N3 adapter or scenario changes still require the native replay and `just verify`. +For the incubating Node SDK and independent web example, run +`just doc-web-setup` with Node 24/npm. This explicitly installs npm dependencies +and pinned Chromium, builds the Rust host binding, and compiles TypeScript. +`just doc-web-test --offline` verifies authoring, the optional Playwright adapter, +real browser replay, and clean Cargo/npm consumers. `just doc-web-smoke --offline` +copies only portable sources to an independent workspace and repeats the gate on +Rust 1.95. These checks require the installed browser, run natively, and never +silently accept a baseline. They supplement `just verify`; the N3 app's existing +`web/` host is separate from [lgtmd's web example](../examples/lgtmd-web/README.md). + ## User settings Global preferences live at `~/Library/Application Support/N3/settings.json` on diff --git a/examples/lgtmd-web/.gitignore b/examples/lgtmd-web/.gitignore new file mode 100644 index 0000000..2400018 --- /dev/null +++ b/examples/lgtmd-web/.gitignore @@ -0,0 +1,3 @@ +/.cache/ +/node_modules/ +/dist/ diff --git a/examples/lgtmd-web/README.md b/examples/lgtmd-web/README.md new file mode 100644 index 0000000..4c5e181 --- /dev/null +++ b/examples/lgtmd-web/README.md @@ -0,0 +1,188 @@ +# lgtmd web example + +Build a small records application with React, TypeScript, and Vite, then develop +its filtering feature through executable documentation. The application uses an +ordinary HTTP API and Playwright fixtures. lgtmd authors and publishes its guide; +the application and fixture own their state, preparation, and cleanup. + +Three complete documents use the same SDK and Rust compiler: + +| Source | What it demonstrates | +| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Record names](guides/01-record-names.ts) | Explanation-only prose, a reused ordinary function, and a note attached to a passage. It makes no executed verification claim. | +| [Record resources](guides/02-resources.ts) | Async preparation and cleanup, a frozen observation, a named native assertion, JSON as code and a download, CSV bytes, and a private text resource. | +| [Browser filtering](browser/filter-records.spec.ts) | Normal Playwright queries and assertions, filtering and clearing, a screenshot, and a targeted contributor note. | + +[Supplied context](guides/supplied-context.ts) calls the same resource authoring +function when another application runner already owns preparation. It is an +alternative execution path for the same document, not a fourth page. The SDK +does not acquire or dispose of that supplied context. + +## Run the application + +Use Node.js 24 ESM and the pinned workspace dependencies. From the repository root, +install dependencies, build the local Rust binding and TypeScript SDK, and install +Chromium explicitly: + +```sh +just doc-web-setup +``` + +Start the API in one terminal and the Vite development server in another: + +```sh +npm run dev:api --workspace @lgtmd/example-web +``` + +```sh +npm run dev --workspace @lgtmd/example-web +``` + +Open . The API listens on port 4174; Vite proxies its +requests. The root page loads the sample records immediately, so viewing the +application requires no test fixture or seed command. Playwright creates isolated +workspaces through the same API for its tests. + +Documentation runs build the app, then start production preview and a test API +on ports 4183 and 4184. +The development servers can stay open; the test configuration owns its services +and uses the same React application. Vite excludes the generated `.cache/` tree +from its dev watcher, so writing Playwright HTML traces does not reset an open +development session. + +These are conventional application commands: + +```sh +npm run build --workspace @lgtmd/example-web +npm run preview --workspace @lgtmd/example-web +``` + +`build` checks TypeScript and writes Vite's production bundle to ignored `dist/`. +`preview` serves that bundle at and still requires the API. React's browser bundle +does not import lgtmd; the SDK is a development dependency used by the authoring +and test processes. + +## Develop a feature with its guide + +The filtering example keeps the reader's instructions beside the actions and +assertions that support them. Its development loop is: + +1. Describe the promise in [the browser document](browser/filter-records.spec.ts): + entering part of a name narrows the list, and clearing the field restores it. + Write ordinary Playwright assertions for both outcomes alongside the prose. +2. Implement the interaction in [the React application](src/App.tsx). Use the + development server to inspect and interact with the normal application. +3. Run the document to produce a fresh review candidate: + + ```sh + npm run docs --workspace @lgtmd/example-web -- build "$PWD/.cache/lgtmd-web-review" + ``` + +4. Read the `reader/` and `contributor/` Markdown exports and inspect the filtered + list image. Contributor notes explain fixture conditions and verification + limits without entering the reader's guide. Bring feedback into the same + React implementation and browser document, then build another candidate at a + fresh output path. `build` rejects an existing destination. +5. After reviewing an intentional change, accept it into a project-owned baseline + directory explicitly: + + ```sh + npm run docs --workspace @lgtmd/example-web -- update "$PWD/.cache/lgtmd-web-baseline" + npm run docs --workspace @lgtmd/example-web -- check "$PWD/.cache/lgtmd-web-baseline" + ``` + +`check` runs the same documents and compares the retained trees exactly. It does +not accept drift. A project can choose its own baseline path and commit the +reviewed tree. Fresh candidates, repeatable runs, and accepted baselines serve +different purposes. + +The browser document is the narrative source: there is no separate prose +template to keep synchronized. Assertions remain normal Playwright assertions; +they do not need a lgtmd wrapper. The guide gives product feedback a concrete +place to land alongside the implementation and executable promises. + +## Run the documents + +The commands above run all three authoring sources and use one completed browser +run for both audiences. The browser dependency is pinned to Playwright 1.64.0. + +For the two documents that do not require a browser, use a separate output root: + +```sh +npm run docs --workspace @lgtmd/example-web -- build "$PWD/.cache/lgtmd-node-review" --without-browser +``` + +That subset is useful for authoring or binding checks. It does not establish the +browser example's behavior or screenshot baseline. + +`just doc-web-test --offline` runs the complete local web and package checks. +`just doc-web-smoke --offline` repeats the portable gate in an independent copy, +including the Rust 1.95 baseline. Setup is separate: checks do not fetch a browser +or require Docker. The current native package and browser capture have been +verified on macOS arm64; other platforms need their own binding and capture gate. + +## Application and runner ownership + +[The application fixture](browser/application-test.ts) creates an isolated +workspace through the example's HTTP API and removes it during ordinary +Playwright teardown. [The API server](server/index.mjs) stores the supplied records +in memory; [the React application](src/App.tsx) renders the accessible search +page. A real project can replace this fixture with its existing mocks, seeds, +services, or already-prepared state. + +React with Vite is one complete integration shape. A Next.js project can keep its +own application and Playwright configuration and use the same SDK boundary; +this example does not exercise or establish support for a particular Next.js +configuration. + +`withDocs(applicationTest)` adds a `doc` fixture while preserving the application's +fixtures and assertions. Calling `doc.assets.image()` registers the bytes returned +by a normal Playwright screenshot; it does not control browser input or alter the +application. Record names and the CSV buffer are intentionally mutated after +registration in the resources example to exercise snapshot ownership. The exports +must retain their original contents. + +The browser document uses unwrapped Playwright assertions. Their success belongs +to the host test result; the SDK does not manufacture named checks for them. The +resources document separately shows the optional `doc.check()` wrapper around a +native assertion and an observation that is not an assertion. + +## Completion and supported scope + +Documentation captures use the production Vite bundle. Keeping hot reload and +development dependency optimization out of that process gives captures a stable +application build while ordinary development still uses Vite's dev server. +The Playwright configuration owns these commands; lgtmd does not invoke or know +about Vite itself. + + +[The configuration](playwright.config.ts) selects one Chromium project, no retries, +and a fixed viewport, locale, timezone, color scheme, and reduced-motion preference. +The image receipt includes the pinned Playwright version and host platform. Exact +screenshots still depend on their capture environment; this example makes no +cross-platform pixel-identity claim. + +Use `docsReporter()` as the sole configured reporter and run through +`runPlaywright()`, as [the entry point](run.ts) does. The SDK stages document +attachments during the test and publishes only after the complete Playwright +process exits successfully and a unique completed receipt validates. Test or +worker teardown failures, global teardown failures, timeouts, interruption, caught +named-check failures, and document ID collisions block publication. + +Playwright swallows exceptions thrown by custom reporter methods. To preserve +their failures, pass publicly imported reporter instances to the guard: + +```ts +reporter: docsReporter({ reporters: [new ProjectReporter()] }) +``` + +The guard awaits returned promises through `onExit`. Adding other reporters +outside it is rejected. Built-in reporter name tuples are not delegated by this +initial adapter; it provides its own concise terminal output. Running the +Playwright CLI directly can produce test diagnostics and document attachments, +but never publishes documentation. + +The initial adapter explicitly rejects retries, repeated runs, multiple projects, +dependency/teardown projects, and shards. It requires every discovered test to +pass cleanly. These limits keep execution attempts unambiguous while the shared +document contract is incubated. diff --git a/examples/lgtmd-web/browser/application-test.ts b/examples/lgtmd-web/browser/application-test.ts new file mode 100644 index 0000000..3c70a7c --- /dev/null +++ b/examples/lgtmd-web/browser/application-test.ts @@ -0,0 +1,30 @@ +import { test as base, expect } from "@playwright/test"; +import { exampleRecords } from "../src/records.ts"; + +interface Workspace { + url: string; + conditions: string; +} + +/** A normal project fixture: lgtmd knows nothing about this API or its state. */ +export const test = base.extend<{ workspace: Workspace }>({ + workspace: async ({ request, baseURL }, use) => { + const created = await request.post("/api/workspaces", { + data: exampleRecords, + }); + expect(created.status()).toBe(201); + const { id, path } = await created.json(); + try { + await use({ + url: new URL(path, baseURL).href, + conditions: + "An isolated project fixture supplies exactly three sample records.", + }); + } finally { + const deleted = await request.delete(`/api/workspaces/${id}`); + expect(deleted.status()).toBe(204); + } + }, +}); + +export { expect }; diff --git a/examples/lgtmd-web/browser/filter-records.spec.ts b/examples/lgtmd-web/browser/filter-records.spec.ts new file mode 100644 index 0000000..ef99ff4 --- /dev/null +++ b/examples/lgtmd-web/browser/filter-records.spec.ts @@ -0,0 +1,59 @@ +import { withDocs } from "lgtmd/playwright"; +import { test as applicationTest, expect } from "./application-test.ts"; + +const test = withDocs(applicationTest); + +test("Find records by name", async ({ page, workspace, doc }) => { + doc.page({ id: "filter-records", title: "Find a record" }); + doc.md` + Search when you know part of a record's name. This example starts with + three sample records. + `; + + await page.goto(workspace.url); + const search = page.getByRole("searchbox", { name: "Search records" }); + const results = page.getByRole("list", { name: "Records" }); + await expect(results.getByRole("listitem")).toHaveText([ + "Alpha contract", + "Alpha renewal", + "Beta proposal", + ]); + + await search.fill("Alpha"); + await expect(results.getByRole("listitem")).toHaveText([ + "Alpha contract", + "Alpha renewal", + ]); + + const screenshot = doc.assets.image( + "matching-records", + await results.screenshot(), + { + producer: "playwright/chromium", + profile: `playwright-1.64.0-${process.platform}-${process.arch}-800x600`, + }, + ); + const explanation = doc.md` + Enter **Alpha** in **Search records**. The list now contains only the two + records whose names include that text. + + ${screenshot.embed("Alpha contract and Alpha renewal in the filtered list")} + + Clear the search field to see the other records again. + `; + + await search.fill(""); + await expect(results.getByRole("listitem")).toHaveText([ + "Alpha contract", + "Alpha renewal", + "Beta proposal", + ]); + + doc.noteOn(explanation)` + ${workspace.conditions} + The existing project fixture owns preparation and cleanup. These native + Playwright assertions verify filtering and clearing the displayed results; + they do not verify a production backend. They remain host test outcomes, + without manufactured named SDK checks. + `; +}); diff --git a/examples/lgtmd-web/guides/01-record-names.ts b/examples/lgtmd-web/guides/01-record-names.ts new file mode 100644 index 0000000..a08b092 --- /dev/null +++ b/examples/lgtmd-web/guides/01-record-names.ts @@ -0,0 +1,23 @@ +import { guide } from "lgtmd"; +import { explainRecordNames } from "./shared.ts"; + +export default guide( + { id: "record-names", title: "Choose useful record names" }, + (doc) => { + const introduction = doc.md` + A record's name helps you recognize it in the list. Include words that + describe the work, such as the project and the kind of document. + + Names like **Alpha contract** and **Alpha renewal** share a project name + while distinguishing the work. Each record also has an identifier for + referring to it in an export. + `; + + explainRecordNames(doc); + + doc.noteOn(introduction)` + This page explains naming. It does not run the application or record an + executed check. The browser document separately exercises name filtering. + `; + }, +); diff --git a/examples/lgtmd-web/guides/02-resources.ts b/examples/lgtmd-web/guides/02-resources.ts new file mode 100644 index 0000000..48f40ed --- /dev/null +++ b/examples/lgtmd-web/guides/02-resources.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { guide, type Doc } from "lgtmd"; +import { exampleRecords } from "../src/records.ts"; +import type { RecordsContext } from "./shared.ts"; + +/** Preparation belongs to the application; the SDK receives only the result. */ +export async function prepareRecords(): Promise<{ + context: RecordsContext; + cleanup: () => Promise; +}> { + const directory = await mkdtemp(join(tmpdir(), "lgtmd-records-")); + try { + const filename = join(directory, "records.json"); + await writeFile(filename, JSON.stringify(exampleRecords)); + const records = JSON.parse(await readFile(filename, "utf8")); + return { + context: { + records, + preparationNote: + "Records came from an isolated, application-owned file.", + }, + cleanup: () => rm(directory, { recursive: true }), + }; + } catch (error) { + // Until acquisition returns, the project owns partial setup cleanup. + try { + await rm(directory, { recursive: true }); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Record setup and cleanup failed", + ); + } + throw error; + } +} + +/** Also usable with a context that an existing runner has already prepared. */ +export async function writeResources( + doc: Doc, + context: RecordsContext, +): Promise { + const recordCount = doc.observe("record-count", context.records.length); + const countCheck = await doc.check("three-records", () => { + assert.equal(context.records.length, 3); + }); + + doc.md` + This export contains ${recordCount} sample records. Keep a JSON copy when + you need the record identifiers as well as their names. + `; + + doc.section("download-records", "Read or download an export"); + const exportData = { + records: context.records.map((record) => ({ ...record })), + }; + const json = doc.assets.json("records", exportData); + const csvBytes = new TextEncoder().encode( + "id,name\n" + + context.records.map((record) => `${record.id},${record.name}\n`).join(""), + ); + const csv = doc.assets.file("records-table", { + bytes: csvBytes, + mediaType: "text/csv", + extension: "csv", + }); + const preparation = doc.assets.text("preparation", context.preparationNote); + + // Values and bytes have already been captured. Later source mutation must + // never rewrite the document or a previously registered download. + exportData.records[0]!.name = "Changed after registration"; + csvBytes.fill(0); + + const passage = doc.md` + The JSON includes the same three records: + + ${json.code()} + + ${json.link("Download the JSON export")} or + ${csv.link("download a CSV table")} for use in another tool. + `; + + doc.noteOn(passage)` + ${preparation.link("Preparation details")} + This resource belongs to the contributor explanation and is excluded from + the Reader export. The source values and buffer are deliberately changed + after registration to exercise snapshot ownership. + `; + doc.noteOn(countCheck)` + The named check verifies the supplied sample count. It does not exercise + a production export endpoint. The observed count alone is not an assertion. + `; +} + +export default guide( + { + id: "record-resources", + title: "Read and download record data", + prepare: prepareRecords, + }, + writeResources, +); diff --git a/examples/lgtmd-web/guides/shared.ts b/examples/lgtmd-web/guides/shared.ts new file mode 100644 index 0000000..fb0caec --- /dev/null +++ b/examples/lgtmd-web/guides/shared.ts @@ -0,0 +1,16 @@ +import type { Doc } from "lgtmd"; +import type { RecordEntry } from "../src/records.ts"; + +/** Ordinary functions are enough to reuse prose between authored documents. */ +export function explainRecordNames(doc: Doc): void { + doc.section("recognizable-names", "Use words you will recognize"); + doc.md` + **Alpha contract** is easier to recognize than **Record 3**. + Use a project name and the purpose of the record when both are useful. + `; +} + +export interface RecordsContext { + records: RecordEntry[]; + preparationNote: string; +} diff --git a/examples/lgtmd-web/guides/supplied-context.ts b/examples/lgtmd-web/guides/supplied-context.ts new file mode 100644 index 0000000..4268ce9 --- /dev/null +++ b/examples/lgtmd-web/guides/supplied-context.ts @@ -0,0 +1,12 @@ +import { runGuideWithContext } from "lgtmd"; +import { writeResources } from "./02-resources.ts"; +import type { RecordsContext } from "./shared.ts"; + +/** An existing application runner retains ownership of its context and cleanup. */ +export function documentPreparedRecords(context: RecordsContext) { + return runGuideWithContext( + { id: "record-resources", title: "Read and download record data" }, + context, + writeResources, + ); +} diff --git a/examples/lgtmd-web/index.html b/examples/lgtmd-web/index.html new file mode 100644 index 0000000..6630b99 --- /dev/null +++ b/examples/lgtmd-web/index.html @@ -0,0 +1,12 @@ + + + + + + Example records + + +
+ + + diff --git a/examples/lgtmd-web/package.json b/examples/lgtmd-web/package.json new file mode 100644 index 0000000..c9e3a31 --- /dev/null +++ b/examples/lgtmd-web/package.json @@ -0,0 +1,25 @@ +{ + "name": "@lgtmd/example-web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "docs": "node run.ts", + "dev": "vite", + "dev:api": "node server/index.mjs", + "build": "npm run typecheck && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "19.3.0", + "react-dom": "19.3.0" + }, + "devDependencies": { + "vite": "8.3.4", + "@vitejs/plugin-react": "6.1.2", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "lgtmd": "0.0.0" + } +} diff --git a/examples/lgtmd-web/playwright.config.ts b/examples/lgtmd-web/playwright.config.ts new file mode 100644 index 0000000..bf0d409 --- /dev/null +++ b/examples/lgtmd-web/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig } from "@playwright/test"; +import { fileURLToPath } from "node:url"; +import { docsReporter } from "lgtmd/playwright"; + +export default defineConfig({ + testDir: "./browser", + outputDir: "./.cache/playwright", + fullyParallel: false, + workers: 1, + retries: 0, + timeout: 15_000, + reporter: docsReporter(), + use: { + baseURL: "http://127.0.0.1:4183", + browserName: "chromium", + viewport: { width: 800, height: 600 }, + locale: "en-US", + timezoneId: "UTC", + colorScheme: "light", + reducedMotion: "reduce", + trace: "retain-on-failure", + }, + projects: [{ name: "chromium" }], + webServer: [ + { + command: "npm run dev:api -- --port 4184", + cwd: fileURLToPath(new URL(".", import.meta.url)), + url: "http://127.0.0.1:4184/api/health", + reuseExistingServer: false, + timeout: 10_000, + }, + { + command: "npm run build && npm run preview -- --port 4183", + env: { RECORDS_API_URL: "http://127.0.0.1:4184" }, + cwd: fileURLToPath(new URL(".", import.meta.url)), + url: "http://127.0.0.1:4183", + reuseExistingServer: false, + timeout: 10_000, + }, + ], +}); diff --git a/examples/lgtmd-web/run.ts b/examples/lgtmd-web/run.ts new file mode 100644 index 0000000..e768b2e --- /dev/null +++ b/examples/lgtmd-web/run.ts @@ -0,0 +1,30 @@ +import { runGuide, publish } from "lgtmd"; +import { runPlaywright } from "lgtmd/playwright"; +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; +import recordNames from "./guides/01-record-names.ts"; +import resources from "./guides/02-resources.ts"; + +const [command, output] = process.argv.slice(2); +if (!(command === "build" || command === "check" || command === "update") || !output) { + throw new Error("Usage: node run.ts build|check|update OUTPUT [--without-browser]"); +} +const root = fileURLToPath(new URL(".", import.meta.url)); +const destination = resolve(output); +const options = { + command, + reader: `${destination}/reader`, + contributor: `${destination}/contributor`, +} as const; +const documents = await Promise.all([runGuide(recordNames), runGuide(resources)]); +if (process.argv.includes("--without-browser")) { + await publish(documents, options); +} else { + await runPlaywright({ + config: `${root}playwright.config.ts`, + cwd: root, + ...options, + documents, + }); +} +console.log(`lgtmd ${command} completed: ${output}`); diff --git a/examples/lgtmd-web/server/index.mjs b/examples/lgtmd-web/server/index.mjs new file mode 100644 index 0000000..a149541 --- /dev/null +++ b/examples/lgtmd-web/server/index.mjs @@ -0,0 +1,79 @@ +import { randomUUID } from "node:crypto"; +import { exampleRecords } from "../src/records.ts"; +import { createServer } from "node:http"; + +const portIndex = process.argv.indexOf("--port"); +const port = Number(portIndex === -1 ? 4174 : process.argv[portIndex + 1]); +if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("Provide --port with an integer between 1 and 65535"); +} + +const workspaces = new Map(); + +function send(response, status, value) { + response.writeHead(status, { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }); + response.end(JSON.stringify(value)); +} + +const server = createServer(async (request, response) => { + const path = new URL(request.url, `http://127.0.0.1:${port}`).pathname; + if (request.method === "GET" && path === "/api/health") { + send(response, 200, { ready: true }); + return; + } + if (request.method === "GET" && path === "/api/records") { + send(response, 200, exampleRecords); + return; + } + if (request.method === "POST" && path === "/api/workspaces") { + try { + let source = ""; + for await (const chunk of request) { + source += chunk; + if (source.length > 65536) + throw new Error("Workspace payload is too large"); + } + const records = JSON.parse(source); + if ( + !Array.isArray(records) || + !records.every( + (record) => + record && + typeof record.id === "string" && + typeof record.name === "string", + ) + ) + throw new Error("Expected records with string id and name fields"); + const id = randomUUID(); + workspaces.set(id, records); + send(response, 201, { id, path: `/?workspace=${id}` }); + } catch (error) { + send(response, 400, { error: error.message }); + } + return; + } + const match = /^\/api\/workspaces\/([^/]+)$/.exec(path); + if (match) { + const id = match[1]; + if (request.method === "GET" && workspaces.has(id)) { + send(response, 200, workspaces.get(id)); + return; + } + if (request.method === "DELETE" && workspaces.delete(id)) { + response.writeHead(204); + response.end(); + return; + } + } + send(response, 404, { error: "Not found" }); +}); + +server.listen(port, "127.0.0.1", () => { + process.stdout.write(`Records API listening at http://127.0.0.1:${port}\n`); +}); +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => server.close(() => process.exit(0))); +} diff --git a/examples/lgtmd-web/src/App.tsx b/examples/lgtmd-web/src/App.tsx new file mode 100644 index 0000000..c38aa0d --- /dev/null +++ b/examples/lgtmd-web/src/App.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from "react"; +import type { RecordEntry } from "./records.ts"; + +function RecordList({ records }: { records: readonly RecordEntry[] }) { + return ( +
    + {records.map((record) => ( +
  • {record.name}
  • + ))} +
+ ); +} + +export function App() { + const [search, setSearch] = useState(""); + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + const workspace = new URLSearchParams(window.location.search).get( + "workspace", + ); + + useEffect(() => { + const controller = new AbortController(); + const endpoint = + workspace === null + ? "/api/records" + : `/api/workspaces/${encodeURIComponent(workspace)}`; + + async function loadRecords() { + setLoading(true); + setError(undefined); + try { + const response = await fetch(endpoint, { signal: controller.signal }); + if (!response.ok) throw new Error("The records could not be loaded."); + const loaded: RecordEntry[] = await response.json(); + if (!controller.signal.aborted) setRecords(loaded); + } catch (failure) { + if (!controller.signal.aborted) { + setError( + failure instanceof Error + ? failure.message + : "The records could not be loaded.", + ); + } + } finally { + if (!controller.signal.aborted) setLoading(false); + } + } + + void loadRecords(); + return () => controller.abort(); + }, [workspace]); + + const query = search.toLocaleLowerCase("en-US"); + const matches = records.filter((record) => + record.name.toLocaleLowerCase("en-US").includes(query), + ); + const count = loading + ? "Loading records…" + : error + ? "Records unavailable" + : `${matches.length} of ${records.length} records`; + + return ( +
+

Records

+

Find a record by part of its name.

+ + setSearch(event.target.value)} + /> +

+ {count} +

+ + +
+ ); +} diff --git a/examples/lgtmd-web/src/main.tsx b/examples/lgtmd-web/src/main.tsx new file mode 100644 index 0000000..9c3e228 --- /dev/null +++ b/examples/lgtmd-web/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.tsx"; +import "./styles.css"; + +const root = document.getElementById("root"); +if (!root) throw new Error("The application root is missing."); + +createRoot(root).render( + + + , +); diff --git a/examples/lgtmd-web/src/records.ts b/examples/lgtmd-web/src/records.ts new file mode 100644 index 0000000..e623fe6 --- /dev/null +++ b/examples/lgtmd-web/src/records.ts @@ -0,0 +1,11 @@ +export interface RecordEntry { + id: string; + name: string; +} + +/** Application-owned input reused by standalone preparation and browser fixtures. */ +export const exampleRecords: RecordEntry[] = [ + { id: "alpha-contract", name: "Alpha contract" }, + { id: "alpha-renewal", name: "Alpha renewal" }, + { id: "beta-proposal", name: "Beta proposal" }, +]; diff --git a/examples/lgtmd-web/src/styles.css b/examples/lgtmd-web/src/styles.css new file mode 100644 index 0000000..b78df7c --- /dev/null +++ b/examples/lgtmd-web/src/styles.css @@ -0,0 +1,64 @@ +* { + box-sizing: border-box; +} +body { + margin: 0; + background: #f6f7f9; + color: #172033; + font: + 16px/1.5 Arial, + sans-serif; +} +main { + width: 640px; + max-width: calc(100% - 48px); + margin: 48px auto; +} +h1 { + margin: 0 0 8px; + font-size: 30px; +} +p { + margin: 0 0 24px; + color: #46546b; +} +label { + display: block; + margin-bottom: 8px; + font-weight: bold; +} +input { + width: 100%; + padding: 10px 12px; + border: 1px solid #aab4c2; + border-radius: 6px; + background: white; + color: inherit; + font: inherit; +} +input:focus-visible { + outline: 3px solid #bfd3ff; + border-color: #325fc6; +} +#count { + margin: 16px 0 8px; + font-size: 14px; +} +ul { + margin: 0; + padding: 0; + list-style: none; + border: 1px solid #ccd3de; + border-radius: 8px; + overflow: hidden; + background: white; +} +li { + padding: 16px 20px; +} +li + li { + border-top: 1px solid #e0e5ed; +} +#error { + color: #a12020; +} diff --git a/examples/lgtmd-web/tsconfig.json b/examples/lgtmd-web/tsconfig.json new file mode 100644 index 0000000..073a8d3 --- /dev/null +++ b/examples/lgtmd-web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "types": ["vite/client", "node"], + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "guides/**/*.ts", "browser/**/*.ts", "*.ts"] +} diff --git a/examples/lgtmd-web/vite.config.ts b/examples/lgtmd-web/vite.config.ts new file mode 100644 index 0000000..ad7c864 --- /dev/null +++ b/examples/lgtmd-web/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +const apiProxy = { + "/api": process.env.RECORDS_API_URL ?? "http://127.0.0.1:4174", +}; + +export default defineConfig({ + plugins: [react()], + server: { + host: "127.0.0.1", + port: 4173, + strictPort: true, + // Playwright traces contain HTML snapshots. Writing review artifacts must + // not reload the developer's open application. + watch: { ignored: ["**/.cache/**"] }, + proxy: apiProxy, + }, + preview: { + host: "127.0.0.1", + port: 4175, + strictPort: true, + proxy: apiProxy, + }, +}); diff --git a/justfile b/justfile index 4edee2c..098e559 100644 --- a/justfile +++ b/justfile @@ -189,6 +189,19 @@ doc-framework-test: doc-framework-smoke *args: python3 crates/lgtmd/dev/verify.py --standalone --toolchain 1.95.0 "$@" +# Explicit setup: Node dependencies, native compiler, TS build, pinned Chromium. +doc-web-setup: + python3 packages/lgtmd/dev/verify.py --prepare + npm exec -- playwright install chromium + +# Portable web contracts, real browser replay, and clean Cargo/npm consumers. +doc-web-test *args: + python3 packages/lgtmd/dev/verify.py "$@" + +# Prove the web SDK without the N3 source tree, renderer, or workspace patches. +doc-web-smoke *args: + python3 packages/lgtmd/dev/verify.py --standalone --toolchain 1.95.0 "$@" + # Open the local guide preview, check it, or intentionally regenerate it. docs mode="serve" *args: #!/usr/bin/env bash diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..31d426d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1364 @@ +{ + "name": "n3-lgtmd-incubation", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "n3-lgtmd-incubation", + "workspaces": [ + "packages/lgtmd", + "examples/lgtmd-web" + ], + "devDependencies": { + "@playwright/test": "1.64.0", + "@types/node": "24.19.2", + "typescript": "7.0.2" + }, + "engines": { + "node": ">=24 <25" + } + }, + "examples/lgtmd-web": { + "name": "@lgtmd/example-web", + "version": "0.0.0", + "dependencies": { + "react": "19.3.0", + "react-dom": "19.3.0" + }, + "devDependencies": { + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.2", + "lgtmd": "0.0.0", + "vite": "8.3.4" + } + }, + "node_modules/@lgtmd/example-web": { + "resolved": "examples/lgtmd-web", + "link": true + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lgtmd": { + "resolved": "packages/lgtmd", + "link": true + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "packages/lgtmd": { + "version": "0.0.0", + "license": "MIT", + "engines": { + "node": ">=24 <25" + }, + "peerDependencies": { + "@playwright/test": "1.64.0" + }, + "peerDependenciesMeta": { + "@playwright/test": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9537f37 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "n3-lgtmd-incubation", + "private": true, + "workspaces": [ + "packages/lgtmd", + "examples/lgtmd-web" + ], + "type": "module", + "scripts": { + "build": "npm run build --workspace lgtmd", + "test": "npm run test --workspace lgtmd", + "typecheck": "npm run typecheck --workspace @lgtmd/example-web" + }, + "devDependencies": { + "@playwright/test": "1.64.0", + "@types/node": "24.19.2", + "typescript": "7.0.2" + }, + "engines": { + "node": ">=24 <25" + } +} diff --git a/packages/lgtmd/.gitignore b/packages/lgtmd/.gitignore new file mode 100644 index 0000000..c4180d1 --- /dev/null +++ b/packages/lgtmd/.gitignore @@ -0,0 +1,3 @@ +/dist/ +/native/ +__pycache__/ diff --git a/packages/lgtmd/LICENSE b/packages/lgtmd/LICENSE new file mode 100644 index 0000000..943658a --- /dev/null +++ b/packages/lgtmd/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Grida + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/lgtmd/README.md b/packages/lgtmd/README.md new file mode 100644 index 0000000..4e2af76 --- /dev/null +++ b/packages/lgtmd/README.md @@ -0,0 +1,138 @@ +# lgtmd for Node + +Write documentation beside ordinary application code. Combine prose, frozen +values, downloads, images, and contributor notes; use native assertions when +a claim should be verified. The shared Rust compiler exports Reader and +Contributor Markdown/resources from one completed run. + +This SDK is incubating locally. The npm package is private and unpublishable; +these APIs and schema-v1 input are not a stable release promise. + +```ts +import assert from "node:assert/strict"; +import { guide, runGuide, publish } from "lgtmd"; + +const example = guide({ id: "records", title: "Read an export" }, async (doc) => { + const count = doc.observe("record-count", 3); + await doc.check("three-records", () => assert.equal(count.value, 3)); + const data = doc.assets.json("records", { count: count.value }); + const passage = doc.md` + The export contains ${count} records. + + ${data.code()} + + ${data.link("Download JSON")} + `; + doc.noteOn(passage)`This example uses supplied data.`; +}); + +await publish([await runGuide(example)], { + command: "build", + reader: "/absolute/new-review/reader", + contributor: "/absolute/new-review/contributor", +}); +``` + +`guide` declares content without executing it. `runGuide` executes authoring and +returns frozen input only after acquired cleanup succeeds. An optional `prepare` +returns `{context, cleanup?}`; the project owns partial preparation failure. +`runGuideWithContext(page, context, write)` accepts an existing runner's context +without taking over cleanup. Primary and cleanup failures retain both errors. + +`doc.md` dedents its literal Markdown and ends each block automatically. Scalar +substitutions and observation values are escaped. Typed image/link/code views +retain references; code views occupy their own blank-line-separated block. +Use ordinary functions for reuse and `section(id, title)` for a stable anchor. +Paragraph IDs are automatic. Resource registration appends no prose. + +`observe` records a snapshot, and `check` executes a native assertion returning +void. Checks are optional. Caught check failures still taint the document; pending +checks prevent finishing. An ordinary assertion outside `check` remains the +host's outcome and does not manufacture a named SDK check. Values and bytes copy +at registration. Unsupported JSON fails rather than silently disappearing. +The [Rust SDK](../../crates/lgtmd/README.md#shared-compiler-and-node-authoring) +describes the portable value domain and host serialization limits. + +Notes are plain contributor prose. Reader exports omit notes, exclusively private +resources/observations, and their metadata. Check IDs and status remain public +metadata; their values appear only when referenced publicly. Notes use a separate +counter, so adding them does not change Reader anchors or bytes. + +`compile(documents, layout?)` returns both audiences without writing files. +`publish` uses Rust's owned artifact store: `build` requires fresh destinations, +`check` compares exactly without acceptance, and `update` explicitly accepts a +compatible baseline. `layout` selects page/resource prefixes and exact page +overrides. Destinations must be separate, including transaction siblings; symlink +paths are rejected. Audiences publish independently, not as one atomic operation. + +## Playwright + +```ts +import { test as applicationTest, expect } from "@playwright/test"; +import { withDocs } from "lgtmd/playwright"; + +const test = withDocs(applicationTest); +test("Find a record", async ({ page, doc }) => { + doc.page({ id: "find-record", title: "Find a record" }); + // Navigate and prepare using the project's existing fixtures. + await page.getByRole("searchbox", { name: "Search records" }).fill("Alpha"); + const results = page.getByRole("list", { name: "Records" }); + await expect(results.getByRole("listitem")).toHaveCount(2); + const image = doc.assets.image("matches", await results.screenshot()); + doc.md`The two matching records appear below. + +${image.embed("Two matching records")}`; +}); +``` + +Configure `reporter: docsReporter()` and run through `runPlaywright(options)`. +Existing fixtures, queries, assertions, app state, and backend preparation stay +project-owned. A fixture only attaches candidate input. The parent publishes +after the runner process exits successfully and its unique completed receipt +confirms all tests, teardown and reporters succeeded. Timeout, interruption, +worker/global teardown failure, swallowed named check failure, duplicate document +IDs, and missing receipts block publication. + +Playwright swallows reporter exceptions, so `docsReporter({reporters: [...]})` +guards imported custom reporter instances through their final callbacks. It must +be the sole configured reporter; unguarded extra reporters are rejected. Built-in +reporter-name delegation is not supported. Direct `playwright test` can collect +attachments but never publishes docs. Initial support requires one project, +retries zero, repeatEach one, no shards or dependency/teardown projects, and clean +passed outcomes for every discovered test. Unsupported modes reject acceptance. + +See the [complete example](../../examples/lgtmd-web/README.md), including its +server, preparation fixture, config, and finalizer. DOM queries remain subject to +application changes; documentation does not make them unbreakable. + +## Development and support + +From the incubating repository root: + +```sh +just doc-web-setup +just doc-web-test --offline +just doc-web-smoke --offline +``` + +Setup explicitly installs npm dependencies and Chromium, builds the native bridge, +and compiles TypeScript. Checks do not silently download browsers or require +Docker. The standalone gate copies only portable sources, tests them in their own +workspace, packages Cargo/npm, and installs the npm tarball with Rust absent from +PATH. Playwright is an optional peer; importing the core does not require it. + +Initial runtime: **Node 24, ESM**. Native `darwin-arm64` is the locally verified +target; `linux-x64` has a configured CI lane and remains unverified until that lane +runs. Other targets, CommonJS, and browser-hosted SDK execution have no support +claim. Installing a prepared npm tarball requires no Rust compiler; contributors +build the platform binding before packing. There is no install-time build or +fallback download. Unsupported platforms fail clearly. Distributed binaries, +signing, release versions, registry installation, and broader platform testing +remain release work. + +The compiler and strict artifact store are Rust; authoring and host lifecycle are +native Node APIs. WASM compilation was exercised against the same input, but its +filesystem publication is unsupported. The initial coarse native bridge reuses +the existing store without implementing a second filesystem contract. A full +canonical viewer, cursor/key overlays, AI-assisted execution, and repository +extraction remain later work. diff --git a/packages/lgtmd/dev/tests/test_verify.py b/packages/lgtmd/dev/tests/test_verify.py new file mode 100644 index 0000000..6c8fed1 --- /dev/null +++ b/packages/lgtmd/dev/tests/test_verify.py @@ -0,0 +1,55 @@ +import importlib.util +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest import mock + + +SPEC = importlib.util.spec_from_file_location("lgtmd_web_verify", Path(__file__).resolve().parents[1] / "verify.py") +VERIFY = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(VERIFY) + + +class PortablePayloadTests(unittest.TestCase): + def test_copy_has_only_portable_sources_and_preserves_fixture_paths(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "source" + destination = Path(temporary) / "copy" + root.mkdir() + (root / "src").mkdir() + (root / "src/private-n3.rs").write_text("not portable", encoding="utf-8") + (root / "Cargo.lock").write_text("host lock", encoding="utf-8") + (root / "package.json").write_text(json.dumps({"workspaces": ["packages/lgtmd", "examples/lgtmd-web"]}), encoding="utf-8") + (root / "package-lock.json").write_text("{}", encoding="utf-8") + for relative in VERIFY.SOURCES: + package = root / relative + package.mkdir(parents=True) + (package / "LICENSE").write_text("license", encoding="utf-8") + for disposable in (".cache", "target", "node_modules", "dist", "native"): + (package / disposable).mkdir() + (package / disposable / "stale").write_text("do not copy", encoding="utf-8") + fixture = root / "crates/lgtmd/tests/fixtures/target/.ownership.json" + fixture.parent.mkdir(parents=True) + fixture.write_text("fixture bytes", encoding="utf-8") + VERIFY.payload(root, destination) + self.assertEqual((destination / "Cargo.toml").read_text(encoding="utf-8"), VERIFY.WORKSPACE) + self.assertFalse((destination / "Cargo.lock").exists()) + self.assertFalse((destination / "src").exists()) + self.assertEqual((destination / "crates/lgtmd/tests/fixtures/target/.ownership.json").read_text(encoding="utf-8"), "fixture bytes") + for relative in VERIFY.SOURCES: + self.assertTrue((destination / relative / "LICENSE").exists()) + for disposable in (".cache", "target", "node_modules", "dist", "native"): + self.assertFalse((destination / relative / disposable).exists()) + + def test_relative_explicit_cargo_overrides_keep_the_invocation_base(self): + with tempfile.TemporaryDirectory() as temporary, mock.patch.dict(os.environ, {"CARGO_HOME": "cache/cargo", "CARGO_TARGET_DIR": "cache/target", "npm_config_cache": "cache/npm"}): + environment = VERIFY.environment_for(Path(temporary)) + self.assertEqual(environment["CARGO_HOME"], str(Path.cwd() / "cache/cargo")) + self.assertEqual(environment["CARGO_TARGET_DIR"], str(Path.cwd() / "cache/target")) + self.assertEqual(environment["npm_config_cache"], str(Path.cwd() / "cache/npm")) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/lgtmd/dev/verify.py b/packages/lgtmd/dev/verify.py new file mode 100644 index 0000000..da65b7e --- /dev/null +++ b/packages/lgtmd/dev/verify.py @@ -0,0 +1,342 @@ +"""Verify lgtmd's web SDK, real browser example, and clean package consumers.""" + +import argparse +import json +import os +from pathlib import Path, PurePosixPath +import shutil +import subprocess +import sys +import tarfile +import tempfile + + +SDK = Path(__file__).resolve().parents[1] +SOURCES = ( + "crates/lgtmd", + "crates/lgtmd-node", + "packages/lgtmd", + "examples/lgtmd-web", +) +WORKSPACE = """[workspace] +members = ["crates/lgtmd", "crates/lgtmd-node"] +resolver = "3" +""" + + +class VerificationError(Exception): + pass + + +def run(command, *, cwd, environment, capture=False): + command = [str(part) for part in command] + print("Web check: " + " ".join(command), flush=True) + result = subprocess.run( + command, cwd=cwd, env=environment, check=False, + stdout=subprocess.PIPE if capture else None, + text=True, + ) + if result.returncode: + if capture and result.stdout: + print(result.stdout, file=sys.stderr) + raise VerificationError(f"Command failed with exit {result.returncode}: {command[0]}") + return result.stdout if capture else None + + +def payload(root, destination): + """Copy portable maintained sources; never copy N3 or prebuilt binaries.""" + for relative in SOURCES: + source = root / relative + if not source.is_dir(): + raise OSError(f"Portable web payload is missing {source}") + + def disposable(directory, names): + directory = Path(directory) + excluded = set() + if directory == source: + excluded.update((".cache", "target", "Cargo.lock", "node_modules", "dist", "native")) + if directory == source / "dev" or source / "dev" in directory.parents: + excluded.add("__pycache__") + return excluded.intersection(names) + + shutil.copytree(source, destination / relative, ignore=disposable) + for filename in ("package.json", "package-lock.json"): + shutil.copyfile(root / filename, destination / filename) + npm = json.loads((destination / "package.json").read_text(encoding="utf-8")) + if npm.get("workspaces") != ["packages/lgtmd", "examples/lgtmd-web"]: + raise VerificationError("The portable npm manifest must contain only the SDK and web example") + (destination / "Cargo.toml").write_text(WORKSPACE, encoding="utf-8") + return destination + + +def environment_for(root): + environment = os.environ.copy() + environment["PYTHONDONTWRITEBYTECODE"] = "1" + invocation = Path.cwd() + for name, default in ( + ("CARGO_HOME", root / ".cache/cargo"), + ("CARGO_TARGET_DIR", root / ".cache/lgtmd-web/target"), + ("npm_config_cache", root / ".cache/npm"), + ): + inherited = environment.get(name) + if name == "npm_config_cache": + inherited = inherited or environment.get("NPM_CONFIG_CACHE") + value = Path(inherited or str(default)) + environment[name] = str(value if value.is_absolute() else invocation / value) + if name == "npm_config_cache" and "NPM_CONFIG_CACHE" in environment: + environment["NPM_CONFIG_CACHE"] = environment[name] + return environment + + +def extract_package(archive, destination, package_name): + """Extract ordinary Cargo files without accepting links or traversal paths.""" + with tarfile.open(archive, "r:gz") as contents: + for member in contents: + path = PurePosixPath(member.name) + if path.is_absolute() or ".." in path.parts or path.parts[0] != package_name: + raise VerificationError(f"Unexpected Cargo archive path: {member.name}") + output = destination.joinpath(*path.parts) + if member.isdir(): + output.mkdir(parents=True, exist_ok=True) + elif member.isfile(): + output.parent.mkdir(parents=True, exist_ok=True) + with contents.extractfile(member) as source, output.open("wb") as target: + shutil.copyfileobj(source, target) + else: + raise VerificationError(f"Cargo archive contains a non-file entry: {member.name}") + return destination / package_name + + +def cargo_consumer(root, cargo, environment, offline): + flags = ["--locked", *(["--offline"] if offline else [])] + # Cargo's default package verification builds the actual normalized archive. + run([*cargo, "package", "-p", "lgtmd", "--allow-dirty", *flags], cwd=root, environment=environment) + metadata = json.loads(run( + [*cargo, "metadata", "--no-deps", "--format-version", "1", *flags], + cwd=root, environment=environment, capture=True, + )) + package = next(package for package in metadata["packages"] if package["name"] == "lgtmd") + name = f"lgtmd-{package['version']}" + archive = Path(metadata["target_directory"]) / "package" / f"{name}.crate" + with tempfile.TemporaryDirectory(prefix="lgtmd-cargo-consumer-") as temporary: + directory = Path(temporary).resolve() + extracted = extract_package(archive, directory / "extracted", name) + consumer = directory / "consumer" + (consumer / "src").mkdir(parents=True) + dependency_path = json.dumps(str(extracted), ensure_ascii=False) + (consumer / "Cargo.toml").write_text( + "[package]\nname = \"lgtmd-clean-consumer\"\nversion = \"0.0.0\"\nedition = \"2024\"\n" + f"[dependencies]\nlgtmd = {{ path = {dependency_path} }}\n", + encoding="utf-8", + ) + (consumer / "src/main.rs").write_text( + "use lgtmd::{Audience, Doc, Document};\n" + "fn main() {\n" + " let mut doc = Doc::new(\"packaged\", \"Packaged SDK\").unwrap();\n" + " let value = doc.observe(\"count\", 3).unwrap();\n" + " doc.paragraph((\"There are \", &value, \" records.\")).unwrap();\n" + " let files = Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap();\n" + " assert!(String::from_utf8_lossy(&files[\"packaged.md\"]).contains(\"3 records\"));\n" + "}\n", + encoding="utf-8", + ) + run([*cargo, "generate-lockfile", *(["--offline"] if offline else [])], cwd=consumer, environment=environment) + run([*cargo, "run", *flags], cwd=consumer, environment=environment) + + +CORE_SMOKE = """import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { Doc, compile, publish } from 'lgtmd'; +const require = createRequire(import.meta.url); +assert.throws(() => require.resolve('@playwright/test'), { code: 'MODULE_NOT_FOUND' }); +const doc = new Doc('packaged', 'Packaged SDK'); +const count = doc.observe('count', 3); +const data = doc.assets.json('data', { count: 3 }); +const passage = doc.md`There are ${count} records. + +${data.code()} + +${data.link('Download JSON')}`; +doc.noteOn(passage)`This package runs without Rust on PATH.`; +const document = doc.finish(); +const exports = await compile([document]); +assert.match(new TextDecoder().decode(exports.reader['packaged.md']), /3 records/); +assert.ok(exports.reader['assets/packaged/data.json']); +const options = { reader: resolve('output/reader'), contributor: resolve('output/contributor') }; +await publish([document], { ...options, command: 'build' }); +await publish([document], { ...options, command: 'check' }); +console.log('Packaged core compiled and published with Rust absent from PATH and Playwright not installed.'); +""" + +PLAYWRIGHT_SMOKE = """import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { test } from '@playwright/test'; +import { withDocs, docsReporter, runPlaywright } from 'lgtmd/playwright'; +const extended = withDocs(test); +assert.equal(typeof extended.extend, 'function'); +assert.equal(typeof runPlaywright, 'function'); +assert.equal(docsReporter().length, 1); +const options = { + config: new URL('./playwright.config.mjs', import.meta.url), + reader: resolve('playwright-output/reader'), + contributor: resolve('playwright-output/contributor'), +}; +await runPlaywright({ ...options, command: 'build' }); +await runPlaywright({ ...options, command: 'check' }); +assert.match(await readFile('playwright-output/reader/packaged-runner.md', 'utf8'), /The installed runner executed/); +console.log('Packaged optional Playwright fixture, reporter, finalizer, and publication passed.'); +""" + +PLAYWRIGHT_TEST = """import assert from 'node:assert/strict'; +import { test as base } from '@playwright/test'; +import { withDocs } from 'lgtmd/playwright'; +const test = withDocs(base); +test('document an ordinary Node result', async ({ doc }) => { + doc.page({ id: 'packaged-runner', title: 'Packaged runner' }); + await doc.check('sum', () => { assert.equal(1 + 1, 2); }); + doc.md`The installed runner executed the documented assertion.`; +}); +""" + +PLAYWRIGHT_CONFIG = """import { docsReporter } from 'lgtmd/playwright'; +export default { + testDir: '.', testMatch: 'packaged.spec.mjs', + outputDir: '.cache/playwright', + workers: 1, retries: 0, timeout: 5000, + reporter: docsReporter(), +}; +""" + + +def npm_consumer(root, node, npm, environment, offline): + with tempfile.TemporaryDirectory(prefix="lgtmd-npm-consumer-") as temporary: + directory = Path(temporary).resolve() + archives = directory / "archives" + archives.mkdir() + packed = json.loads(run( + [npm, "pack", "--json", "--pack-destination", archives], + cwd=root / "packages/lgtmd", environment=environment, capture=True, + )) + archive = archives / packed[0]["filename"] + consumer = directory / "consumer" + consumer.mkdir() + (consumer / "package.json").write_text(json.dumps({ + "name": "lgtmd-clean-consumer", "version": "0.0.0", "private": True, + "type": "module", "dependencies": {"lgtmd": f"file:{archive}"}, + }), encoding="utf-8") + only_node = directory / "node-only-bin" + only_node.mkdir() + (only_node / "node").symlink_to(Path(node).resolve()) + isolated = environment.copy() + isolated["PATH"] = str(only_node) + isolated.pop("LGTMD_CONFORMANCE_FIXTURE", None) + isolated.pop("NODE_PATH", None) + isolated.pop("NODE_OPTIONS", None) + if shutil.which("cargo", path=isolated["PATH"]) or shutil.which("rustc", path=isolated["PATH"]): + raise VerificationError("The npm consumer must have no Rust toolchain on PATH") + install = [npm, "install", "--global=false", "--prefix", consumer, "--no-audit", "--no-fund", *(["--offline"] if offline else [])] + # Installation itself has only Node on PATH; no ignored install scripts + # or local source paths can conceal a build-time Rust requirement. + run(install, cwd=consumer, environment=isolated) + (consumer / "core.mjs").write_text(CORE_SMOKE, encoding="utf-8") + run([node, "core.mjs"], cwd=consumer, environment=isolated) + version = json.loads((root / "package.json").read_text(encoding="utf-8"))["devDependencies"]["@playwright/test"] + run([*install, f"@playwright/test@{version}"], cwd=consumer, environment=isolated) + (consumer / "playwright.mjs").write_text(PLAYWRIGHT_SMOKE, encoding="utf-8") + (consumer / "playwright.config.mjs").write_text(PLAYWRIGHT_CONFIG, encoding="utf-8") + (consumer / "packaged.spec.mjs").write_text(PLAYWRIGHT_TEST, encoding="utf-8") + run([node, "playwright.mjs"], cwd=consumer, environment=isolated) + + +def node_tools(root, environment): + node = shutil.which("node", path=environment.get("PATH")) + npm = shutil.which("npm", path=environment.get("PATH")) + if not node or not npm: + raise VerificationError("Web verification requires Node.js 24 and npm") + version = run([node, "-p", "process.versions.node"], cwd=root, environment=environment, capture=True).strip() + if version.split(".")[0] != "24": + raise VerificationError(f"Web verification requires Node.js 24; found {version}") + tag = run([node, "-p", "process.platform + '-' + process.arch"], cwd=root, environment=environment, capture=True).strip() + return node, npm, tag + + +def prepare(root, cargo, environment, offline, npm, tag): + flags = ["--locked", *(["--offline"] if offline else [])] + run([*cargo, "build", "-p", "lgtmd-node", "--release", *flags], cwd=root, environment=environment) + executable = "lgtmd-node.exe" if tag.startswith("win32-") else "lgtmd-node" + binary = Path(environment["CARGO_TARGET_DIR"]) / "release" / executable + native = root / "packages/lgtmd/native" / tag / executable + native.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(binary, native) + run([npm, "ci", "--no-audit", "--no-fund", *(["--offline"] if offline else [])], cwd=root, environment=environment) + run([npm, "run", "build"], cwd=root, environment=environment) + + +def checks(root, cargo, environment, offline, install_browser): + node, npm, tag = node_tools(root, environment) + environment = environment.copy() + environment["LGTMD_CONFORMANCE_FIXTURE"] = str(root / "crates/lgtmd/tests/fixtures/portable.json") + selection = ["-p", "lgtmd", "-p", "lgtmd-node"] + flags = ["--locked", *(["--offline"] if offline else [])] + run([sys.executable, "-m", "unittest", "discover", "-s", root / "packages/lgtmd/dev/tests"], cwd=root, environment=environment) + run([*cargo, "fmt", *selection, "--check"], cwd=root, environment=environment) + run([*cargo, "clippy", *selection, *flags, "--all-targets", "--", "-D", "warnings"], cwd=root, environment=environment) + run([*cargo, "test", *selection, *flags, "--all-targets"], cwd=root, environment=environment) + prepare(root, cargo, environment, offline, npm, tag) + if install_browser: + run([npm, "exec", "--", "playwright", "install", "chromium"], cwd=root, environment=environment) + run([npm, "test"], cwd=root, environment=environment) + run([npm, "run", "typecheck"], cwd=root, environment=environment) + with tempfile.TemporaryDirectory(prefix="lgtmd-web-review-") as temporary: + candidate = Path(temporary).resolve() / "candidate" + command = [node, "examples/lgtmd-web/run.ts"] + run([*command, "build", candidate], cwd=root, environment=environment) + run([*command, "check", candidate], cwd=root, environment=environment) + cargo_consumer(root, cargo, environment, offline) + npm_consumer(root, node, npm, environment, offline) + + +def main(arguments=None, *, sdk=SDK): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--standalone", action="store_true", help="copy only portable sources to a minimal temporary workspace") + parser.add_argument("--prepare", action="store_true", help="install npm dependencies and build the local binding without verification or browser installation") + parser.add_argument("--toolchain", help="select an installed Rust toolchain, e.g. 1.95.0") + parser.add_argument("--offline", action="store_true", help="use warm Cargo and npm caches; Chromium must already be installed") + parser.add_argument("--install-browser", action="store_true", help="explicitly install the pinned Chromium browser before real browser checks") + options = parser.parse_args(arguments) + if options.offline and options.install_browser: + parser.error("--offline cannot install a browser; run the explicit browser setup separately") + if options.prepare and options.install_browser: + parser.error("--prepare does not install a browser; run the explicit browser setup separately") + cargo = ["cargo", *([f"+{options.toolchain}"] if options.toolchain else [])] + try: + root = Path(sdk).resolve().parents[1] + environment = environment_for(root) + if options.standalone: + with tempfile.TemporaryDirectory(prefix="lgtmd-web-standalone-") as temporary: + isolated = payload(root, Path(temporary).resolve()) + run([*cargo, "generate-lockfile", *(["--offline"] if options.offline else [])], cwd=isolated, environment=environment) + if options.prepare: + _, npm, tag = node_tools(isolated, environment) + prepare(isolated, cargo, environment, options.offline, npm, tag) + else: + checks(isolated, cargo, environment, options.offline, options.install_browser) + else: + if options.prepare: + _, npm, tag = node_tools(root, environment) + prepare(root, cargo, environment, options.offline, npm, tag) + else: + checks(root, cargo, environment, options.offline, options.install_browser) + print("Web SDK prepared." if options.prepare else "Portable web verification passed.", flush=True) + return 0 + except (OSError, ValueError, KeyError, VerificationError) as error: + print(f"Portable web verification failed: {error}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/lgtmd/package.json b/packages/lgtmd/package.json new file mode 100644 index 0000000..220ac1b --- /dev/null +++ b/packages/lgtmd/package.json @@ -0,0 +1,40 @@ +{ + "name": "lgtmd", + "version": "0.0.0", + "private": true, + "description": "Code-authored documents compiled by lgtmd's shared Rust engine", + "license": "MIT", + "files": [ + "dist/src", + "native", + "LICENSE", + "README.md" + ], + "type": "module", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + }, + "./playwright": { + "types": "./dist/src/playwright/index.d.ts", + "import": "./dist/src/playwright/index.js" + }, + "./playwright/reporter": "./dist/src/playwright/reporter.js" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "node --test dist/tests/*.test.js" + }, + "peerDependencies": { + "@playwright/test": "1.64.0" + }, + "peerDependenciesMeta": { + "@playwright/test": { + "optional": true + } + }, + "engines": { + "node": ">=24 <25" + } +} diff --git a/packages/lgtmd/src/authoring.ts b/packages/lgtmd/src/authoring.ts new file mode 100644 index 0000000..0396457 --- /dev/null +++ b/packages/lgtmd/src/authoring.ts @@ -0,0 +1,481 @@ +import { deepFreeze, freezeJson, singleLine, validId } from "./model.js"; +import type { + FrozenDocument, + FrozenResource, + Json, + Node, + Page, + Part, + Reference, + Scalar, +} from "./model.js"; + +const identity = Symbol("lgtmd target"); +const presentation = Symbol("lgtmd presentation"); +export interface Target { + readonly [identity]: Reference["kind"]; +} +export interface Block extends Target { + readonly [identity]: "block"; +} +type FrozenValue = T extends object + ? { readonly [Key in keyof T]: FrozenValue } + : T; +export interface Observation extends Target { + readonly [identity]: "observation"; + readonly value: FrozenValue; +} +export interface Check extends Target { + readonly [identity]: "claim"; +} +export interface Presentation { + readonly [presentation]: true; +} +export interface Asset extends Target { + readonly [identity]: "resource"; + link(label: string): Presentation; +} +export interface ImageAsset extends Asset { + embed(alt: string): Presentation; +} +export interface CodeAsset extends Asset { + code(): Presentation; +} +export type Content = Scalar | Observation | Presentation; +export type Markdown = ( + strings: TemplateStringsArray, + ...values: Content[] +) => Block; +export type Note = ( + strings: TemplateStringsArray, + ...values: Content[] +) => void; + +export interface ResourceOptions { + readonly producer?: string; + readonly profile?: string; +} +export interface ImageOptions extends ResourceOptions { + readonly mediaType?: + | "image/png" + | "image/jpeg" + | "image/webp" + | "image/gif" + | "image/svg+xml"; +} +export interface FileOptions extends ResourceOptions { + readonly bytes: Uint8Array; + readonly mediaType: string; + readonly extension: string; +} + +const targets = new WeakMap(); +const presentations = new WeakMap(); + +/** Dedent template literals without inspecting or changing interpolated values. */ +function dedent(strings: readonly string[]): string[] { + let index = 0; + let marker = `\uE000${index}\uE001`; + while (strings.some((text) => text.includes(marker))) { + index += 1; + marker = `\uE000${index}\uE001`; + } + const lines = strings.join(marker).replace(/\r\n?/g, "\n").split("\n"); + if (lines[0]?.trim() === "") lines.shift(); + if (lines.at(-1)?.trim() === "") lines.pop(); + const nonempty = lines.filter((line) => line.trim() !== ""); + const indent = + nonempty.length === 0 + ? 0 + : Math.min(...nonempty.map((line) => /^[ \t]*/.exec(line)![0].length)); + return lines + .map((line) => + line.slice(Math.min(indent, /^[ \t]*/.exec(line)![0].length)), + ) + .join("\n") + .split(marker); +} + +export class Doc { + #page?: Page; + #finished = false; + #ids = new Map(); + #nodes: Node[] = []; + #observations: { id: string; value: Json }[] = []; + #checks: { id: string; kind: "predicate"; passed: true }[] = []; + #resources: FrozenResource[] = []; + #errors: unknown[] = []; + #pending = 0; + #blocks = 0; + #notes = 0; + + constructor(id?: string, title?: string) { + if (id !== undefined || title !== undefined) + this.page({ id: id!, title: title! }); + Object.freeze(this); + } + + #open(): void { + if (this.#finished) throw new Error("Document is already finished"); + } + + #guard(operation: () => T): T { + this.#open(); + try { + return operation(); + } catch (error) { + this.#errors.push(error); + throw error; + } + } + + page(page: Page): void { + this.#guard(() => { + if (this.#page) throw new Error("Document identity can only be set once"); + if (!validId(page.id)) + throw new TypeError(`Invalid document ID: ${String(page.id)}`); + singleLine(page.title, "Document title"); + this.#page = Object.freeze({ id: page.id, title: page.title }); + }); + } + + #reserve(id: string, kind: Reference["kind"]): Reference { + if (!validId(id)) throw new TypeError(`Invalid evidence ID: ${String(id)}`); + if (this.#ids.has(id)) throw new Error(`Duplicate evidence ID: ${id}`); + this.#ids.set(id, kind); + return Object.freeze({ id, kind }); + } + + #target(reference: Reference, properties: object = {}): T { + const target = Object.freeze({ [identity]: reference.kind, ...properties }); + targets.set(target, { owner: this, reference }); + return target as T; + } + + #reference(target: Target): Reference { + const found = + typeof target === "object" && target !== null + ? targets.get(target) + : undefined; + if (!found || found.owner !== this) + throw new Error("Foreign or forged document handle"); + return found.reference; + } + + #presentation(part: Part): Presentation { + const value = Object.freeze({ [presentation]: true as const }); + presentations.set(value, { owner: this, part }); + return value; + } + + #parts( + strings: TemplateStringsArray, + values: readonly Content[], + literalKind: "markdown" | "text" = "markdown", + ): Part[] { + if ( + !Array.isArray(strings) || + strings.length !== values.length + 1 || + !strings.every((value) => typeof value === "string") + ) { + throw new TypeError( + "Markdown and notes must be called as tagged templates", + ); + } + const literals = dedent(strings); + const parts: Part[] = []; + for (let index = 0; index < literals.length; index += 1) { + const literal = literals[index]!; + if (literal !== "") parts.push({ kind: literalKind, text: literal }); + if (index >= values.length) continue; + const value = values[index]!; + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + freezeJson(value); + parts.push({ kind: "text", text: String(value) }); + } else if (typeof value === "object" && value !== null) { + const view = presentations.get(value); + if (view) { + if (view.owner !== this) + throw new Error("Foreign document presentation"); + if (literalKind === "text" && view.part.kind === "code") + throw new TypeError( + "Code resource presentations belong in Markdown blocks, not contributor notes", + ); + parts.push(view.part); + } else { + const reference = this.#reference(value as Observation); + if (reference.kind !== "observation") + throw new TypeError( + "Interpolate an observation or an explicit resource presentation", + ); + parts.push({ kind: "reference", target: reference }); + } + } else { + throw new TypeError( + "Unsupported Markdown interpolation; observe JSON values or use a typed resource presentation", + ); + } + } + return parts; + } + + readonly md: Markdown = (strings, ...values) => + this.#guard(() => { + const parts = this.#parts(strings, values); + // A template is one authored block. The native Markdown-parts API also + // supports fragments, so terminate this block before compilation. + parts.push({ kind: "markdown", text: "\n\n" }); + const reference = this.#reserve( + `block-${String(++this.#blocks).padStart(4, "0")}`, + "block", + ); + this.#nodes.push({ id: reference.id, kind: "markdown", parts }); + return this.#target(reference); + }); + + section(id: string, title: string): Block { + return this.#guard(() => { + singleLine(title, "Section title"); + const reference = this.#reserve(id, "block"); + this.#blocks += 1; + this.#nodes.push({ id, kind: "heading", level: 2, text: title }); + return this.#target(reference); + }); + } + + observe(id: string, value: T): Observation { + return this.#guard(() => { + const frozen = freezeJson(value); + const reference = this.#reserve(id, "observation"); + this.#observations.push({ id, value: frozen }); + return this.#target>(reference, { value: frozen }); + }); + } + + async check( + id: string, + assertion: () => void | Promise, + ): Promise { + const reference = this.#guard(() => { + if (typeof assertion !== "function") + throw new TypeError("Check requires an assertion callback"); + return this.#reserve(id, "claim"); + }); + this.#pending += 1; + try { + const result: unknown = await assertion(); + if (result !== undefined) + throw new TypeError( + "Check callbacks must return void; assert behavior inside the callback", + ); + this.#open(); + this.#checks.push({ id, kind: "predicate", passed: true }); + return this.#target(reference); + } catch (error) { + this.#errors.push(error); + throw error; + } finally { + this.#pending -= 1; + } + } + + readonly note: Note = (strings, ...values) => { + this.#appendNote(undefined, strings, values); + }; + + noteOn(target: Target): Note { + const reference = this.#guard(() => this.#reference(target)); + return (strings, ...values) => { + this.#appendNote(reference, strings, values); + }; + } + + #appendNote( + target: Reference | undefined, + strings: TemplateStringsArray, + values: readonly Content[], + ): void { + this.#guard(() => { + const parts = this.#parts(strings, values, "text"); + const reference = this.#reserve( + `note-${String(++this.#notes).padStart(4, "0")}`, + "block", + ); + this.#nodes.push( + target + ? { id: reference.id, kind: "note", target, parts } + : { id: reference.id, kind: "note", parts }, + ); + }); + } + + #resource( + id: string, + bytes: Uint8Array, + mime: string, + extension: string, + producer: string, + profile: string, + json?: Json, + ): Reference { + if (!(bytes instanceof Uint8Array)) + throw new TypeError("Resource bytes must be a Uint8Array"); + if ( + typeof mime !== "string" || + !/^[a-zA-Z0-9!#$&^_.+-]+\/[a-zA-Z0-9!#$&^_.+-]+$/.test(mime) + ) + throw new TypeError("Resource requires a MIME type without parameters"); + if (typeof extension !== "string" || !/^[a-z0-9]{1,16}$/.test(extension)) + throw new TypeError( + "Resource extension must be 1..16 lowercase letters or digits", + ); + singleLine(producer, "Resource producer"); + singleLine(profile, "Resource profile"); + const reference = this.#reserve(id, "resource"); + this.#resources.push({ + id, + mime, + extension, + producer, + profile, + bytes: Array.from(bytes), + ...(json !== undefined ? { json } : {}), + }); + return reference; + } + + #asset(reference: Reference, extras: object = {}): Asset { + return this.#target(reference, { + link: (label: string) => + this.#guard(() => { + singleLine(label, "Resource link label"); + return this.#presentation({ kind: "link", target: reference, label }); + }), + ...extras, + }); + } + + #codeAsset(reference: Reference, language: string): CodeAsset { + if (typeof language !== "string" || !/^[a-zA-Z0-9_+-]*$/.test(language)) + throw new TypeError("Code language must be a plain fence label"); + return this.#asset(reference, { + code: () => + this.#guard(() => + this.#presentation({ kind: "code", target: reference, language }), + ), + }) as CodeAsset; + } + + readonly assets = Object.freeze({ + image: ( + id: string, + bytes: Uint8Array, + options: ImageOptions = {}, + ): ImageAsset => + this.#guard(() => { + const mime = options.mediaType ?? "image/png"; + const extensions = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", + "image/gif": "gif", + "image/svg+xml": "svg", + }; + const extension = extensions[mime]; + if (!extension) + throw new TypeError(`Unsupported image MIME type: ${mime}`); + const reference = this.#resource( + id, + bytes, + mime, + extension, + options.producer ?? "lgtmd/image", + options.profile ?? "exact-v1", + ); + return this.#asset(reference, { + embed: (alt: string) => + this.#guard(() => { + singleLine(alt, "Image alternative text"); + return this.#presentation({ + kind: "image", + target: reference, + label: alt, + }); + }), + }) as ImageAsset; + }), + json: (id: string, value: Json): CodeAsset => + this.#guard(() => { + const frozen = freezeJson(value); + return this.#codeAsset( + this.#resource( + id, + new Uint8Array(), + "application/json", + "json", + "executable-docs/json", + "json-pretty-v1", + frozen, + ), + "json", + ); + }), + text: (id: string, value: string, language = "text"): CodeAsset => + this.#guard(() => { + if (typeof value !== "string") + throw new TypeError("Text resource requires a string"); + freezeJson(value); + return this.#codeAsset( + this.#resource( + id, + new TextEncoder().encode(value), + "text/plain", + "txt", + "executable-docs/text", + "utf8-v1", + ), + language, + ); + }), + file: (id: string, file: FileOptions): Asset => + this.#guard(() => + this.#asset( + this.#resource( + id, + file.bytes, + file.mediaType, + file.extension, + file.producer ?? "lgtmd/file", + file.profile ?? "exact-v1", + ), + ), + ), + }); + + /** Freeze once. Application/runner success and publication remain host responsibilities. */ + finish(): FrozenDocument { + this.#open(); + this.#finished = true; + if (this.#errors.length > 0) + throw new AggregateError( + this.#errors, + "Document authoring failed; caught failures cannot produce accepted evidence", + ); + if (this.#pending > 0) + throw new Error("Document has pending checks; await every named check"); + if (!this.#page) + throw new Error("Document identity is missing; call doc.page once"); + return deepFreeze({ + ...this.#page, + nodes: this.#nodes, + observations: this.#observations, + checks: this.#checks, + resources: this.#resources, + bindings: [] as never[], + }); + } +} diff --git a/packages/lgtmd/src/compiler.ts b/packages/lgtmd/src/compiler.ts new file mode 100644 index 0000000..a7284ed --- /dev/null +++ b/packages/lgtmd/src/compiler.ts @@ -0,0 +1,133 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import type { FrozenDocument } from "./model.js"; +import { freezeJson } from "./model.js"; + +export interface ExportLayout { + pages?: string; + resources?: string; + pageOverrides?: Readonly>; +} + +export interface PublishOptions { + command: "build" | "check" | "update"; + reader: string; + contributor: string; + layout?: ExportLayout; +} + +export interface Exports { + reader: Readonly>; + contributor: Readonly>; +} + +function layout(value?: ExportLayout): Record { + return { + pages: value?.pages ?? "", + resources: value?.resources ?? "assets", + page_overrides: value?.pageOverrides ?? {}, + }; +} + +async function request(value: unknown): Promise { + const executable = fileURLToPath( + new URL( + `../../native/${process.platform}-${process.arch}/lgtmd-node${process.platform === "win32" ? ".exe" : ""}`, + import.meta.url, + ), + ); + // Freeze the complete request before starting a process. Host execution has + // already ended; the bridge never calls application or authoring callbacks. + const input = JSON.stringify(freezeJson(value)); + return new Promise((resolve, reject) => { + const child = spawn(executable, [], { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8").on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.setEncoding("utf8").on("data", (chunk: string) => { + stderr += chunk; + }); + child.on("error", (error) => + reject( + new Error( + `lgtmd native bridge unavailable for ${process.platform}-${process.arch}. Build the local binding or install a package containing that platform.`, + { cause: error }, + ), + ), + ); + child.stdin.on("error", (error) => { + if (error.message !== "write EPIPE") reject(error); + }); + child.on("close", (code, signal) => { + try { + const response = JSON.parse(stdout) as { + ok?: boolean; + result?: unknown; + error?: string; + }; + if (code !== 0 || signal || response.ok !== true) { + reject( + new Error( + response.error ?? + `lgtmd bridge failed (${signal ?? code}): ${stderr}`, + ), + ); + } else resolve(response.result); + } catch (cause) { + reject( + new Error( + `Invalid lgtmd bridge response (${signal ?? code}): ${stderr}`, + { cause }, + ), + ); + } + }); + child.stdin.end(input); + }); +} + +/** Compile both audiences from immutable document input. This writes no files. */ +export async function compile( + documents: readonly FrozenDocument[], + options?: ExportLayout, +): Promise { + const result = (await request({ + operation: "compile", + input: { schema_version: 1, documents }, + layout: layout(options), + })) as { + reader: Record; + contributor: Record; + }; + const bytes = (files: Record): Record => + Object.fromEntries( + Object.entries(files).map(([path, content]) => [ + path, + Uint8Array.from(content), + ]), + ); + return { + reader: bytes(result.reader), + contributor: bytes(result.contributor), + }; +} + +/** Build fresh review trees, check exactly, or explicitly accept a baseline. + * Both audiences compile before publication. Their filesystem transactions are + * independent; a later audience failure can leave the earlier one published. + */ +export async function publish( + documents: readonly FrozenDocument[], + options: PublishOptions, +): Promise { + await request({ + operation: "publish", + input: { schema_version: 1, documents }, + command: options.command, + reader: options.reader, + contributor: options.contributor, + layout: layout(options.layout), + }); +} diff --git a/packages/lgtmd/src/index.ts b/packages/lgtmd/src/index.ts new file mode 100644 index 0000000..25858e2 --- /dev/null +++ b/packages/lgtmd/src/index.ts @@ -0,0 +1,5 @@ +export * from "./authoring.js"; +export type { FrozenDocument, Json, Page } from "./model.js"; +export * from "./lifecycle.js"; +export { compile, publish } from "./compiler.js"; +export type { Exports, ExportLayout, PublishOptions } from "./compiler.js"; diff --git a/packages/lgtmd/src/lifecycle.ts b/packages/lgtmd/src/lifecycle.ts new file mode 100644 index 0000000..f761eea --- /dev/null +++ b/packages/lgtmd/src/lifecycle.ts @@ -0,0 +1,133 @@ +import { Doc } from "./authoring.js"; +import { singleLine, validId } from "./model.js"; +import type { FrozenDocument, Page } from "./model.js"; + +export interface Prepared { + readonly context: T; + /** Omit cleanup when preparation acquires no resources. */ + readonly cleanup?: () => void | Promise; +} + +const definition = Symbol("lgtmd guide definition"); +export interface GuideDefinition { + readonly [definition]: true; +} +type Write = (doc: Doc, context: T) => unknown | Promise; +interface Definition { + readonly page: Page; + readonly prepare?: () => Prepared | Promise>; + readonly write: Write; +} +const definitions = new WeakMap(); + +function copyPage(page: Page): Page { + if (!validId(page.id)) + throw new TypeError(`Invalid document ID: ${String(page.id)}`); + singleLine(page.title, "Document title"); + return Object.freeze({ id: page.id, title: page.title }); +} + +/** Declare a document. Importing does not execute application code. + * Authoring callback return values are ignored; only explicit checks are evidence. + */ +export function guide( + page: Page & { readonly prepare: () => Prepared | Promise> }, + write: Write, +): GuideDefinition; +export function guide( + page: Page & { readonly prepare?: never }, + write: (doc: Doc) => unknown | Promise, +): GuideDefinition; +export function guide( + page: Page & { + readonly prepare?: () => Prepared | Promise>; + }, + write: Write, +): GuideDefinition { + const identity = copyPage(page); + if (typeof write !== "function") + throw new TypeError("Guide requires an authoring callback"); + if (page.prepare !== undefined && typeof page.prepare !== "function") + throw new TypeError("Guide preparation must be a callback"); + const result = Object.freeze({ [definition]: true as const }); + definitions.set(result, { page: identity, prepare: page.prepare, write }); + return result; +} + +/** Both failures stay available in their original order and identity. */ +export class GuideRunError extends AggregateError { + readonly primary: unknown; + readonly cleanup: unknown; + + constructor(primary: unknown, cleanup: unknown) { + super([primary, cleanup], "Guide authoring and cleanup both failed", { + cause: primary, + }); + this.name = "GuideRunError"; + this.primary = primary; + this.cleanup = cleanup; + } +} + +/** Execute native authoring, then cleanup, then freeze an eligible document. */ +export async function runGuide( + guideDefinition: GuideDefinition, +): Promise { + const registered = + typeof guideDefinition === "object" && guideDefinition !== null + ? definitions.get(guideDefinition) + : undefined; + if (!registered) + throw new TypeError("Expected a guide declared by this SDK instance"); + const doc = new Doc(registered.page.id, registered.page.title); + // Preparation owns its own partial failure. No cleanup has been acquired yet. + const prepared = registered.prepare ? await registered.prepare() : undefined; + if ( + prepared !== undefined && + (prepared === null || + typeof prepared !== "object" || + !Object.hasOwn(prepared, "context") || + (prepared.cleanup !== undefined && + typeof prepared.cleanup !== "function")) + ) { + throw new TypeError("Preparation must return { context, cleanup? }"); + } + if (registered.prepare && prepared === undefined) + throw new TypeError("Preparation must return { context, cleanup? }"); + // Acquire cleanup once. Author code may still hold and mutate the project's + // preparation result, but cannot replace this acquired capability. + const context = prepared?.context; + const cleanup = prepared?.cleanup; + + let primary: unknown; + let failed = false; + let document: FrozenDocument | undefined; + try { + await registered.write(doc, context); + document = doc.finish(); + } catch (error) { + failed = true; + primary = error; + } + try { + await cleanup?.call(prepared); + } catch (cleanupError) { + if (failed) throw new GuideRunError(primary, cleanupError); + throw cleanupError; + } + if (failed) throw primary; + return document!; +} + +/** The supplying host owns this context's setup and teardown. */ +export async function runGuideWithContext( + page: Page, + context: T, + write: Write, +): Promise { + const definition = guide( + { ...copyPage(page), prepare: () => ({ context }) }, + write, + ); + return runGuide(definition); +} diff --git a/packages/lgtmd/src/model.ts b/packages/lgtmd/src/model.ts new file mode 100644 index 0000000..ecc5cce --- /dev/null +++ b/packages/lgtmd/src/model.ts @@ -0,0 +1,200 @@ +/** JSON values are copied at registration; unsupported JavaScript values fail. */ +export type Scalar = string | number | boolean; +export type Json = + | null + | Scalar + | readonly Json[] + | { readonly [key: string]: Json }; + +export interface Page { + readonly id: string; + readonly title: string; +} + +/** Internal compiler input. Schema v1 is the incubation interoperability boundary. */ +export interface Reference { + readonly kind: + | "observation" + | "resource" + | "block" + | "claim" + | "value" + | "binding"; + readonly id: string; +} + +export type Part = + | { readonly kind: "text" | "markdown"; readonly text: string } + | { readonly kind: "reference"; readonly target: Reference } + | { + readonly kind: "image" | "link"; + readonly target: Reference; + readonly label: string; + } + | { + readonly kind: "code"; + readonly target: Reference; + readonly language: string; + }; + +export type Node = + | { + readonly id: string; + readonly kind: "markdown"; + readonly parts: readonly Part[]; + } + | { + readonly id: string; + readonly kind: "heading"; + readonly level: number; + readonly text: string; + } + | { + readonly id: string; + readonly kind: "note"; + readonly target?: Reference; + readonly parts: readonly Part[]; + }; + +export interface FrozenResource { + readonly id: string; + readonly mime: string; + readonly extension: string; + readonly producer: string; + readonly profile: string; + readonly bytes: readonly number[]; + /** JSON assets use the shared Rust encoder instead of a host serializer. */ + readonly json?: Json; +} + +export interface FrozenDocument extends Page { + readonly nodes: readonly Node[]; + readonly observations: readonly { + readonly id: string; + readonly value: Json; + }[]; + readonly checks: readonly { + readonly id: string; + readonly kind: "predicate"; + readonly passed: true; + }[]; + readonly resources: readonly FrozenResource[]; + readonly bindings: readonly never[]; +} + +export interface CompilerInput { + readonly schema_version: 1; + readonly documents: readonly FrozenDocument[]; +} + +export function validId(id: unknown): id is string { + return typeof id === "string" && /^[a-z0-9_-]{1,100}$/.test(id); +} + +export function singleLine( + value: unknown, + name: string, +): asserts value is string { + if ( + typeof value !== "string" || + value.trim() === "" || + /[\u0000-\u001f\u007f]/u.test(value) + ) { + throw new TypeError(`${name} must be nonempty single-line text`); + } +} + +/** Avoid JSON.stringify's silent omission, coercion, getters, and toJSON hooks. */ +export function freezeJson(value: unknown): Json { + const ancestors = new Set(); + const copy = (input: unknown, path: string): Json => { + if (input === null || typeof input === "boolean") return input; + if (typeof input === "string") { + // Under Unicode regexp semantics a valid surrogate pair is one scalar, + // outside this range. TextEncoder would silently replace an unpaired one. + if (/[\uD800-\uDFFF]/u.test(input)) { + throw new TypeError(`Cannot freeze ${path}: unpaired UTF-16 surrogate`); + } + return input; + } + if (typeof input === "number") { + if ( + !Number.isFinite(input) || + (Number.isInteger(input) && !Number.isSafeInteger(input)) || + Object.is(input, -0) + ) { + throw new TypeError( + `Cannot freeze ${path}: number must be finite, safely represented, and not negative zero`, + ); + } + return input; + } + if (typeof input !== "object") + throw new TypeError(`Cannot freeze ${path}: expected a JSON value`); + if (ancestors.has(input)) + throw new TypeError(`Cannot freeze ${path}: cyclic value`); + if (Object.getOwnPropertySymbols(input).length !== 0) + throw new TypeError(`Cannot freeze ${path}: symbol keys are unsupported`); + ancestors.add(input); + try { + const descriptors = Object.getOwnPropertyDescriptors(input); + if (Array.isArray(input)) { + if ( + Object.keys(descriptors).some( + (key) => + key !== "length" && + (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= input.length), + ) + ) { + throw new TypeError( + `Cannot freeze ${path}: array has extra properties`, + ); + } + const result: Json[] = []; + for (let index = 0; index < input.length; index += 1) { + const descriptor = descriptors[String(index)]; + if ( + !descriptor || + !("value" in descriptor) || + !descriptor.enumerable + ) { + throw new TypeError( + `Cannot freeze ${path}[${index}]: sparse arrays and accessors are unsupported`, + ); + } + result.push(copy(descriptor.value, `${path}[${index}]`)); + } + return Object.freeze(result); + } + const prototype = Object.getPrototypeOf(input); + if (prototype !== Object.prototype && prototype !== null) + throw new TypeError(`Cannot freeze ${path}: expected a plain object`); + const result: Record = {}; + for (const key of Object.keys(descriptors).sort()) { + copy(key, `${path} property name`); + const descriptor = descriptors[key]!; + if (!("value" in descriptor) || !descriptor.enumerable) { + throw new TypeError( + `Cannot freeze ${path}.${key}: nonenumerable properties and accessors are unsupported`, + ); + } + Object.defineProperty(result, key, { + value: copy(descriptor.value, `${path}.${key}`), + enumerable: true, + }); + } + return Object.freeze(result); + } finally { + ancestors.delete(input); + } + }; + return copy(value, "value"); +} + +export function deepFreeze(value: T): T { + if (value !== null && typeof value === "object" && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); + } + return value; +} diff --git a/packages/lgtmd/src/playwright/guard.ts b/packages/lgtmd/src/playwright/guard.ts new file mode 100644 index 0000000..c252b85 --- /dev/null +++ b/packages/lgtmd/src/playwright/guard.ts @@ -0,0 +1,67 @@ +import type { Reporter } from "@playwright/test/reporter"; + +function describe(error: unknown): string { + return error instanceof Error + ? `${error.name}: ${error.message}` + : String(error); +} + +/** Playwright swallows reporter exceptions, so delegate errors become receipt failures. */ +export class ReporterGuard { + readonly errors: string[] = []; + #pending = new Set>(); + + constructor(readonly reporters: readonly Reporter[] = []) {} + + #result(method: keyof Reporter, value: unknown): void { + if ( + method === "onEnd" && + value !== null && + typeof value === "object" && + "status" in value && + value.status !== undefined && + value.status !== "passed" + ) { + this.errors.push( + `onEnd: reporter requested ${String(value.status)} status`, + ); + } + } + + invoke(method: keyof Reporter, ...args: unknown[]): void { + for (const reporter of this.reporters) { + try { + const callback = reporter[method]; + if (typeof callback !== "function") continue; + const result = Reflect.apply(callback, reporter, args) as unknown; + if ( + result && + typeof (result as PromiseLike).then === "function" + ) { + let pending: Promise; + pending = Promise.resolve(result) + .then((value: unknown) => { + this.#result(method, value); + }) + // Inspecting a returned status can itself throw (for example, a + // custom reporter getter). Retain it just like a callback failure. + .catch((error: unknown) => { + this.errors.push(`${method}: ${describe(error)}`); + }) + .finally(() => { + this.#pending.delete(pending); + }); + this.#pending.add(pending); + } else { + this.#result(method, result); + } + } catch (error) { + this.errors.push(`${method}: ${describe(error)}`); + } + } + } + + async settle(): Promise { + while (this.#pending.size > 0) await Promise.all([...this.#pending]); + } +} diff --git a/packages/lgtmd/src/playwright/index.ts b/packages/lgtmd/src/playwright/index.ts new file mode 100644 index 0000000..1d19ef7 --- /dev/null +++ b/packages/lgtmd/src/playwright/index.ts @@ -0,0 +1,46 @@ +import type { + Fixtures, + ReporterDescription, + TestInfo, + TestType, +} from "@playwright/test"; +import { Doc } from "../authoring.js"; +import { + ATTACHMENT_NAME, + ATTACHMENT_TYPE, + REPORTER_PATH, + type ReporterOptions, +} from "./reporter.js"; + +export { runPlaywright } from "./runner.js"; +export type { RunPlaywrightOptions } from "./runner.js"; + +/** Preserve an application's existing fixtures, assertions, and cleanup. */ +export function withDocs( + test: TestType, +): TestType { + return test.extend<{ doc: Doc }>({ + doc: async ({}, use: (value: Doc) => Promise, testInfo: TestInfo) => { + const document = new Doc(); + await use(document); + if ( + testInfo.status === "passed" && + testInfo.expectedStatus === "passed" + ) { + await testInfo.attach(ATTACHMENT_NAME, { + body: Buffer.from(JSON.stringify(document.finish()), "utf8"), + contentType: ATTACHMENT_TYPE, + }); + } + }, + // Playwright's mapped fixture type cannot resolve a new key for generic T/W. + // The adapter contributes only this documented `doc` fixture. + } as unknown as Fixtures<{ doc: Doc }, {}, T, W>); +} + +/** Guard every optional custom reporter, including its final onExit callback. */ +export function docsReporter( + options: ReporterOptions = {}, +): ReporterDescription[] { + return [[REPORTER_PATH, options]]; +} diff --git a/packages/lgtmd/src/playwright/reporter.ts b/packages/lgtmd/src/playwright/reporter.ts new file mode 100644 index 0000000..e569b71 --- /dev/null +++ b/packages/lgtmd/src/playwright/reporter.ts @@ -0,0 +1,237 @@ +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import type { + FullConfig, + FullResult, + Reporter, + Suite, + TestCase, + TestError, + TestResult, + TestStep, +} from "@playwright/test/reporter"; +import type { FrozenDocument } from "../model.js"; +import { ReporterGuard } from "./guard.js"; + +export const ATTACHMENT_NAME = "lgtmd-document-v1"; +export const ATTACHMENT_TYPE = "application/vnd.lgtmd.document+json"; +export const RECEIPT_ENV = "LGTMD_PLAYWRIGHT_RECEIPT"; +export const TOKEN_ENV = "LGTMD_PLAYWRIGHT_TOKEN"; +export const REPORTER_PATH = fileURLToPath( + new URL("./reporter.js", import.meta.url), +); + +export interface ReporterOptions { + readonly reporters?: readonly Reporter[]; +} + +export interface RunReceipt { + readonly version: 1; + readonly token: string; + readonly status: FullResult["status"]; + readonly errors: readonly string[]; + readonly testCount: number; + readonly attempts: readonly { + readonly testId: string; + readonly project: string; + readonly retry: number; + readonly status: TestResult["status"]; + }[]; + readonly documents: readonly FrozenDocument[]; +} + +/** The sole configured reporter; every optional project reporter is delegated here. */ +export default class DocsReporter implements Reporter { + #guard: ReporterGuard; + #errors: string[] = []; + #suite?: Suite; + #result?: FullResult; + #documents = new Map(); + #attempts: RunReceipt["attempts"][number][] = []; + + constructor(options: ReporterOptions = {}) { + this.#guard = new ReporterGuard(options.reporters ?? []); + } + + printsToStdio(): boolean { + // This reporter prints summaries, preventing Playwright from silently adding + // a terminal reporter outside the guard. + return true; + } + + async preprocess( + params: Parameters>[0], + ): Promise { + this.#guard.invoke("preprocess", params); + await this.#guard.settle(); + } + + onBegin(config: FullConfig, suite: Suite): void { + this.#suite = suite; + const reporters = config.reporter; + if (reporters.length !== 1 || reporters[0]?.[0] !== REPORTER_PATH) { + this.#errors.push( + "Use docsReporter() as the sole reporter; unguarded reporters cannot prove success", + ); + } + if (config.projects.length !== 1) + this.#errors.push( + "lgtmd currently requires exactly one Playwright project", + ); + if (config.shard !== null) + this.#errors.push("lgtmd does not support Playwright sharding yet"); + for (const project of config.projects) { + if (project.retries !== 0) + this.#errors.push("lgtmd currently requires retries: 0"); + if (project.repeatEach !== 1) + this.#errors.push("lgtmd currently requires repeatEach: 1"); + if (project.dependencies.length !== 0 || project.teardown !== undefined) { + this.#errors.push( + "lgtmd does not support dependency or teardown projects yet", + ); + } + } + if (suite.allTests().length === 0) + this.#errors.push("No Playwright tests were discovered"); + this.#guard.invoke("onBegin", config, suite); + } + + onTestBegin(test: TestCase, result: TestResult): void { + this.#guard.invoke("onTestBegin", test, result); + } + + onStepBegin(test: TestCase, result: TestResult, step: TestStep): void { + this.#guard.invoke("onStepBegin", test, result, step); + } + + onStepEnd(test: TestCase, result: TestResult, step: TestStep): void { + this.#guard.invoke("onStepEnd", test, result, step); + } + + onStdOut(chunk: string | Buffer, test?: TestCase, result?: TestResult): void { + process.stdout.write(chunk); + this.#guard.invoke("onStdOut", chunk, test, result); + } + + onStdErr(chunk: string | Buffer, test?: TestCase, result?: TestResult): void { + process.stderr.write(chunk); + this.#guard.invoke("onStdErr", chunk, test, result); + } + + onError(error: TestError, workerInfo?: unknown): void { + this.#errors.push( + error.message ?? + error.value ?? + "Playwright reported a global or worker error", + ); + this.#guard.invoke("onError", error, workerInfo); + } + + onTestEnd(test: TestCase, result: TestResult): void { + const project = test.parent.project()?.name ?? ""; + this.#attempts.push({ + testId: test.id, + project, + retry: result.retry, + status: result.status, + }); + process.stdout.write(`${result.status}: ${test.title}\n`); + if ( + test.expectedStatus !== "passed" || + result.status !== "passed" || + result.errors.length > 0 || + result.retry !== 0 + ) { + this.#errors.push( + `Test did not pass cleanly: ${test.title} (${result.status}, retry ${result.retry})`, + ); + } else { + const attachments = result.attachments.filter( + (attachment) => attachment.name === ATTACHMENT_NAME, + ); + if (attachments.length > 1) + this.#errors.push( + `Multiple documentation attachments for test: ${test.title}`, + ); + for (const attachment of attachments) { + try { + if (attachment.contentType !== ATTACHMENT_TYPE || !attachment.body) { + throw new Error( + "Documentation attachment must contain an in-memory frozen document", + ); + } + const document = JSON.parse( + attachment.body.toString("utf8"), + ) as FrozenDocument; + if (!document || typeof document.id !== "string") + throw new Error( + "Documentation attachment has no document identity", + ); + if (this.#documents.has(document.id)) + throw new Error(`Duplicate document identity: ${document.id}`); + this.#documents.set(document.id, document); + } catch (error) { + this.#errors.push( + error instanceof Error ? error.message : String(error), + ); + } + } + } + this.#guard.invoke("onTestEnd", test, result); + } + + async onEnd(result: FullResult): Promise<{ status: FullResult["status"] }> { + this.#result = result; + for (const test of this.#suite?.allTests() ?? []) { + if ( + test.results.length !== 1 || + test.results[0]?.status !== "passed" || + test.expectedStatus !== "passed" + ) { + this.#errors.push( + `Incomplete or unsupported test outcome: ${test.title}`, + ); + } + } + this.#guard.invoke("onEnd", result); + await this.#guard.settle(); + return { + status: + this.#errors.length === 0 && this.#guard.errors.length === 0 + ? result.status + : "failed", + }; + } + + async onExit(): Promise { + this.#guard.invoke("onExit"); + await this.#guard.settle(); + const filename = process.env[RECEIPT_ENV]; + const token = process.env[TOKEN_ENV]; + // Direct Playwright execution can collect diagnostic attachments; only the + // SDK parent process provides a receipt destination and can publish. + if (!filename || !token) return; + const errors = [...this.#errors, ...this.#guard.errors]; + if (!this.#suite || !this.#result) + errors.push("Playwright lifecycle did not complete"); + if (this.#documents.size === 0) + errors.push("No authored documents were collected"); + for (const error of errors) process.stderr.write(`lgtmd: ${error}\n`); + const receipt: RunReceipt = { + version: 1, + token, + status: this.#result?.status ?? "failed", + errors, + testCount: this.#suite?.allTests().length ?? 0, + attempts: this.#attempts, + documents: [...this.#documents.values()].sort((left, right) => + left.id.localeCompare(right.id, "en"), + ), + }; + // A swallowed write error leaves no valid receipt, which the parent rejects. + writeFileSync(filename, JSON.stringify(receipt), { + flag: "wx", + mode: 0o600, + }); + } +} diff --git a/packages/lgtmd/src/playwright/runner.ts b/packages/lgtmd/src/playwright/runner.ts new file mode 100644 index 0000000..ceabb7e --- /dev/null +++ b/packages/lgtmd/src/playwright/runner.ts @@ -0,0 +1,129 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { publish, type ExportLayout } from "../compiler.js"; +import type { FrozenDocument } from "../model.js"; +import { RECEIPT_ENV, TOKEN_ENV, type RunReceipt } from "./reporter.js"; + +export interface RunPlaywrightOptions { + readonly config: string | URL; + readonly cwd?: string; + readonly command: "build" | "check" | "update"; + readonly reader: string; + readonly contributor: string; + readonly layout?: ExportLayout; + /** Documents from other host runs whose preparation and cleanup completed. */ + readonly documents?: readonly FrozenDocument[]; +} + +function readReceipt(source: string, token: string): RunReceipt { + const value = JSON.parse(source) as RunReceipt; + if ( + !value || + value.version !== 1 || + value.token !== token || + value.status !== "passed" || + !Array.isArray(value.errors) || + value.errors.length !== 0 || + !Number.isInteger(value.testCount) || + value.testCount < 1 || + !Array.isArray(value.attempts) || + value.attempts.length !== value.testCount || + !value.attempts.every( + (attempt) => attempt.status === "passed" && attempt.retry === 0, + ) || + new Set(value.attempts.map((attempt) => attempt.testId)).size !== + value.testCount || + !Array.isArray(value.documents) || + value.documents.length === 0 + ) { + const errors = Array.isArray(value?.errors) + ? value.errors.join("\n") + : "Invalid or missing success receipt"; + throw new Error( + `Playwright output is not eligible for publication: ${errors}`, + ); + } + return value; +} + +/** Publish only after the actual runner process and all guarded reporters finish. */ +export async function runPlaywright(options: RunPlaywrightOptions) { + const config = + options.config instanceof URL + ? fileURLToPath(options.config) + : resolve(options.cwd ?? process.cwd(), options.config); + const cwd = resolve(options.cwd ?? dirname(config)); + const require = createRequire(config); + const cli = require.resolve("@playwright/test/cli"); + const directory = await mkdtemp(join(tmpdir(), "lgtmd-playwright-")); + const receiptPath = join(directory, "receipt.json"); + const token = randomUUID(); + try { + const child = spawn( + process.execPath, + [cli, "test", "--config", config, "--forbid-only"], + { + cwd, + env: { ...process.env, [RECEIPT_ENV]: receiptPath, [TOKEN_ENV]: token }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let interrupted = false; + const interrupt = () => { + interrupted = true; + child.kill("SIGINT"); + }; + const terminate = () => { + interrupted = true; + child.kill("SIGTERM"); + }; + process.on("SIGINT", interrupt); + process.on("SIGTERM", terminate); + child.stdout.on("data", (chunk: Buffer) => process.stdout.write(chunk)); + child.stderr.on("data", (chunk: Buffer) => process.stderr.write(chunk)); + let exit: { code: number | null; signal: NodeJS.Signals | null }; + try { + exit = await new Promise((fulfill, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => fulfill({ code, signal })); + }); + } finally { + process.off("SIGINT", interrupt); + process.off("SIGTERM", terminate); + } + if (interrupted || exit.code !== 0 || exit.signal !== null) { + throw new Error( + `Playwright did not finish successfully (exit ${String(exit.code)}, signal ${String(exit.signal)})`, + ); + } + let receipt: RunReceipt; + try { + receipt = readReceipt(await readFile(receiptPath, "utf8"), token); + } catch (error) { + throw new Error( + `Playwright produced no valid completed success receipt: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + const documents = [...(options.documents ?? []), ...receipt.documents]; + if ( + new Set(documents.map((document) => document.id)).size !== + documents.length + ) { + throw new Error("Duplicate document identity across completed host runs"); + } + return await publish(documents, { + command: options.command, + reader: resolve(cwd, options.reader), + contributor: resolve(cwd, options.contributor), + ...(options.layout === undefined ? {} : { layout: options.layout }), + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} diff --git a/packages/lgtmd/tests/authoring.test.ts b/packages/lgtmd/tests/authoring.test.ts new file mode 100644 index 0000000..f4644a6 --- /dev/null +++ b/packages/lgtmd/tests/authoring.test.ts @@ -0,0 +1,340 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Doc } from "../src/authoring.js"; +import type { Content, Target } from "../src/authoring.js"; +import { freezeJson } from "../src/model.js"; + +test("explanation and observations do not manufacture executed checks", () => { + const doc = new Doc("saved-view", "Save a view"); + const count = doc.observe("record-count", 3); + doc.md`The view contains ${count} records.`; + const frozen = doc.finish(); + assert.deepEqual(frozen.checks, []); + assert.deepEqual(frozen.observations, [{ id: "record-count", value: 3 }]); + assert.deepEqual(frozen.nodes, [ + { + id: "block-0001", + kind: "markdown", + parts: [ + { kind: "markdown", text: "The view contains " }, + { + kind: "reference", + target: { id: "record-count", kind: "observation" }, + }, + { kind: "markdown", text: " records." }, + { kind: "markdown", text: "\n\n" }, + ], + }, + ]); +}); + +test("template indentation and literal Markdown stay separate from escaped substitutions", () => { + const doc = new Doc("markdown", "Markdown"); + doc.md` + **A heading** + + A literal ${"[untrusted](wrong.md)"} and ${2}. + `; + assert.deepEqual(doc.finish().nodes, [ + { + id: "block-0001", + kind: "markdown", + parts: [ + { kind: "markdown", text: "**A heading**\n\nA literal " }, + { kind: "text", text: "[untrusted](wrong.md)" }, + { kind: "markdown", text: " and " }, + { kind: "text", text: "2" }, + { kind: "markdown", text: "." }, + { kind: "markdown", text: "\n\n" }, + ], + }, + ]); +}); + +test("Markdown templates terminate blocks before a following heading or paragraph", () => { + const doc = new Doc("boundaries", "Block boundaries"); + doc.md`Opening paragraph.`; + doc.section("next", "Next section"); + doc.md`Final paragraph.`; + const nodes = doc.finish().nodes; + for (const index of [0, 2]) { + const node = nodes[index]!; + assert.equal(node.kind, "markdown"); + if (node.kind === "markdown") { + assert.deepEqual(node.parts.at(-1), { kind: "markdown", text: "\n\n" }); + } + } + assert.equal(nodes[1]!.kind, "heading"); +}); + +test("template marker characters retain their literal position around substitutions", () => { + const doc = new Doc("markers", "Template markers"); + doc.md`\uE000${"value"}\uE001\uE0000\uE001`; + assert.deepEqual(doc.finish().nodes, [ + { + id: "block-0001", + kind: "markdown", + parts: [ + { kind: "markdown", text: "\uE000" }, + { kind: "text", text: "value" }, + { kind: "markdown", text: "\uE001\uE0000\uE001" }, + { kind: "markdown", text: "\n\n" }, + ], + }, + ]); +}); + +test("adding notes never changes reader block identities", () => { + const make = (notes: boolean) => { + const doc = new Doc("notes", "Notes"); + const first = doc.md`First.`; + if (notes) doc.noteOn(first)`Private **literal** explanation.`; + doc.section("next", "Next"); + if (notes) doc.note`A second note.`; + doc.md`Last.`; + return doc.finish(); + }; + const withNotes = make(true); + assert.deepEqual( + withNotes.nodes.filter((node) => node.kind !== "note"), + make(false).nodes, + ); + assert.deepEqual(withNotes.nodes[1], { + id: "note-0001", + kind: "note", + target: { kind: "block", id: "block-0001" }, + parts: [{ kind: "text", text: "Private **literal** explanation." }], + }); +}); + +test("observations, JSON, and bytes belong to their registration snapshot", () => { + const doc = new Doc("snapshot", "Snapshots"); + const mutable = { z: ["original"], a: 3 }; + const observed = doc.observe("state", mutable); + const json = doc.assets.json("json", mutable); + const bytes = new Uint8Array([7, 8, 9]); + const image = doc.assets.image("image", bytes); + mutable.z[0] = "changed"; + bytes.fill(0); + doc.md`${json.code()} + +${json.link("Download JSON")} + +${image.embed("Frozen bytes")}`; + assert.deepEqual(observed.value, { a: 3, z: ["original"] }); + if (false) { + // @ts-expect-error Snapshot arrays are recursively readonly. + observed.value.z.push("forbidden"); + // @ts-expect-error Snapshot properties are recursively readonly. + observed.value.a = 5; + } + assert.throws(() => { + (observed.value.z as string[])[0] = "forbidden"; + }, TypeError); + const frozen = doc.finish(); + assert.deepEqual(frozen.resources[1]!.bytes, [7, 8, 9]); + assert.deepEqual(frozen.resources[0]!.bytes, []); + assert.deepEqual(frozen.resources[0]!.json, { a: 3, z: ["original"] }); + assert.equal(frozen.resources[0]!.producer, "executable-docs/json"); + assert.equal(frozen.resources[0]!.profile, "json-pretty-v1"); + assert.throws(() => { + (frozen.resources[1]!.bytes as number[])[0] = 0; + }, TypeError); + assert.throws(() => doc.md`After finish.`, /already finished/); + assert.throws(() => doc.finish(), /already finished/); + assert.throws(() => { + (doc as unknown as { md: unknown }).md = () => undefined; + }, TypeError); +}); + +test("foreign handles and presentations taint even caught authoring failures", () => { + const first = new Doc("first", "First"); + const value = first.observe("value", 4); + const asset = first.assets.text("text", "Private"); + const second = new Doc("second", "Second"); + assert.throws(() => second.md`${value}`, /Foreign/); + assert.throws(() => second.md`${asset.link("Foreign")}`, /Foreign/); + assert.throws(() => second.noteOn({} as Target), /forged/); + assert.throws(() => second.finish(), /caught failures/); +}); + +test("resource registration appends no content and named ids share one namespace", () => { + const doc = new Doc("ids", "IDs"); + doc.assets.text("same", "Registered once"); + assert.throws(() => doc.observe("same", 1), /Duplicate evidence/); + assert.throws(() => doc.finish(), /authoring failed/); + const empty = new Doc("resources", "Resources"); + empty.assets.text("private", "No placement"); + assert.deepEqual(empty.finish().nodes, []); +}); + +test("native assertion wrappers execute once and retain caught failure", async () => { + const doc = new Doc("checks", "Checks"); + let calls = 0; + const error = new Error("Native assertion failed"); + await assert.rejects( + doc.check("failure", async () => { + calls += 1; + throw error; + }), + (candidate) => candidate === error, + ); + assert.equal(calls, 1); + assert.throws( + () => doc.finish(), + (candidate) => + candidate instanceof AggregateError && candidate.errors[0] === error, + ); +}); + +test("boolean check results cannot be mistaken for native assertions", async () => { + for (const result of [true, false]) { + const doc = new Doc("boolean", "Boolean"); + await assert.rejects( + doc.check("wrong", (() => result) as unknown as () => void), + /must return void/, + ); + assert.throws(() => doc.finish(), /authoring failed/); + } +}); + +test("pending checks prevent publication eligibility", async () => { + const doc = new Doc("pending", "Pending"); + let settle: () => void = () => { + throw new Error("Missing resolver"); + }; + const check = doc.check( + "wait", + () => + new Promise((resolve) => { + settle = resolve; + }), + ); + assert.throws(() => doc.finish(), /pending checks/); + settle(); + await assert.rejects(check, /already finished/); +}); + +test("successful checks can receive targeted contributor notes", async () => { + const doc = new Doc("success", "Success"); + const checked = await doc.check("count", () => assert.equal(3, 3)); + doc.noteOn(checked)`This check covers the sample count.`; + const frozen = doc.finish(); + assert.deepEqual(frozen.checks, [ + { id: "count", kind: "predicate", passed: true }, + ]); + assert.deepEqual(frozen.nodes[0], { + id: "note-0001", + kind: "note", + target: { kind: "claim", id: "count" }, + parts: [{ kind: "text", text: "This check covers the sample count." }], + }); +}); + +test("unsupported JSON never silently omits, coerces, invokes getters, or loses precision", () => { + const cycle: Record = {}; + cycle.self = cycle; + let getterCalls = 0; + const getter = Object.defineProperty({}, "value", { + enumerable: true, + get: () => { + getterCalls += 1; + return 1; + }, + }); + const cases = [ + undefined, + NaN, + Infinity, + -0, + Number.MAX_SAFE_INTEGER + 1, + 1n, + new Date(), + new Map(), + { missing: undefined }, + [undefined], + new Array(1), + cycle, + getter, + Object.defineProperty({}, "hidden", { value: 1 }), + { [Symbol("hidden")]: 1 }, + () => 1, + ]; + for (const value of cases) assert.throws(() => freezeJson(value), TypeError); + assert.equal(getterCalls, 0); + const shared = { value: "same" }; + assert.deepEqual(freezeJson([shared, shared]), [ + { value: "same" }, + { value: "same" }, + ]); + const special = JSON.parse( + '{"__proto__":{"safe":true},"constructor":"value"}', + ); + assert.deepEqual(freezeJson(special), special); + assert.equal(Object.getPrototypeOf(freezeJson(special)), Object.prototype); +}); + +test("numeric-looking properties outside an array's index range are never omitted", () => { + const array: unknown[] = []; + Object.defineProperty(array, "4294967295", { + value: "not an array index", + enumerable: true, + }); + assert.equal(array.length, 0); + assert.throws(() => freezeJson(array), /array has extra properties/); + assert.deepEqual(freezeJson(["ordinary", "indices"]), [ + "ordinary", + "indices", + ]); +}); + +test("ill-formed Unicode is rejected before JSON or text bytes can change it", () => { + for (const value of [ + "\uD800", + "\uDFFF", + "before\uD800after", + "\uD800\uD800", + ]) { + assert.throws(() => freezeJson(value), /unpaired UTF-16 surrogate/); + assert.throws(() => freezeJson({ value }), /unpaired UTF-16 surrogate/); + assert.throws( + () => freezeJson({ [value]: "property" }), + /unpaired UTF-16 surrogate/, + ); + const doc = new Doc("invalid-unicode", "Invalid Unicode"); + assert.throws( + () => doc.assets.text("text", value), + /unpaired UTF-16 surrogate/, + ); + assert.throws(() => doc.finish(), /authoring failed/); + } + const valid = "A record 📄 with a valid pair \uD83D\uDE00"; + assert.equal(freezeJson(valid), valid); + const doc = new Doc("unicode", "Unicode"); + doc.assets.text("text", valid); + const bytes = doc.finish().resources[0]!.bytes; + assert.equal( + new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(bytes)), + valid, + ); +}); + +test("raw objects and code in notes fail rather than flattening typed structure", () => { + const doc = new Doc("invalid", "Invalid"); + assert.throws(() => doc.md`${{ value: 1 } as unknown as Content}`, /forged/); + const code = doc.assets.text("code", "text"); + assert.throws(() => doc.note`${code.code()}`, /not contributor notes/); + assert.throws(() => doc.finish(), /authoring failed/); +}); + +test("runner identity is set once and validated", () => { + const doc = new Doc(); + doc.page({ id: "browser", title: "Browser" }); + doc.md`Ready.`; + assert.throws( + () => doc.page({ id: "second", title: "Second" }), + /only be set once/, + ); + assert.throws(() => doc.finish(), /authoring failed/); + assert.throws(() => new Doc("Invalid ID", "Title"), /Invalid document ID/); +}); diff --git a/packages/lgtmd/tests/compiler.test.ts b/packages/lgtmd/tests/compiler.test.ts new file mode 100644 index 0000000..958a656 --- /dev/null +++ b/packages/lgtmd/tests/compiler.test.ts @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import { readFile, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Doc } from "../src/authoring.js"; +import { compile, publish } from "../src/compiler.js"; +import type { FrozenDocument } from "../src/model.js"; +import { test } from "node:test"; + +test("JSON resources use Rust's canonical key and number encoding", async () => { + const doc = new Doc("canonical-json", "Canonical JSON"); + const resource = doc.assets.json("data", { "2": 2, "10": 1, tiny: 0.000001 }); + doc.md`${resource.code()}`; + const exports = await compile([doc.finish()]); + assert.equal( + new TextDecoder().decode(exports.reader["assets/canonical-json/data.json"]), + '{\n "10": 1,\n "2": 2,\n "tiny": 1e-6\n}\n', + ); +}); + +async function portableDocument(): Promise { + const doc = new Doc("portable", "Portable document"); + const count = doc.observe("count", 3); + const privateValue = doc.observe("internal-only", "private observation"); + await doc.check("verified-count", () => assert.equal(count.value, 3)); + doc.section("overview", "Overview"); + const passage = doc.md`Contains ${count} records.`; + doc.noteOn(passage)`Internal: ${privateValue}`; + const data = doc.assets.json("data", { z: 2, a: 1 }); + doc.md`Data: + +${data.code()} + +${data.link("Download")}`; + const image = doc.assets.image("image", Uint8Array.of(1, 2, 3), { + producer: "fixture", + profile: "exact-v1", + }); + doc.md`${image.embed("Fixed fixture bytes")}`; + return doc.finish(); +} + +test("Rust and TypeScript authoring produce identical common exports", async () => { + const fixture = + process.env.LGTMD_CONFORMANCE_FIXTURE ?? + fileURLToPath( + new URL( + "../../../../crates/lgtmd/tests/fixtures/portable.json", + import.meta.url, + ), + ); + const expected = JSON.parse(await readFile(fixture, "utf8")) as { + exports: { + reader: Record; + contributor: Record; + }; + }; + const actual = await compile([await portableDocument()]); + for (const audience of ["reader", "contributor"] as const) { + assert.deepEqual( + Object.fromEntries( + Object.entries(actual[audience]).map(([path, bytes]) => [ + path, + Array.from(bytes), + ]), + ), + expected.exports[audience], + ); + } + assert.ok( + !new TextDecoder() + .decode(actual.reader["manifest.json"]) + .includes("internal-only"), + ); +}); + +test("the native store checks exactly, preserves ownership, and requires explicit updates", async () => { + const directory = await mkdtemp(join(tmpdir(), "lgtmd-store-")); + // macOS /var is a symlink; use real paths for the store's explicit policy. + const { realpath } = await import("node:fs/promises"); + const root = await realpath(directory); + const options = { + reader: join(root, "public"), + contributor: join(root, "private"), + }; + try { + const document = await portableDocument(); + await publish([document], { ...options, command: "build" }); + await publish([document], { ...options, command: "check" }); + await assert.rejects( + publish([document], { ...options, command: "build" }), + /already exists/, + ); + const changed = new Doc("portable", "Portable document"); + changed.md`Changed prose.`; + await assert.rejects( + publish([changed.finish()], { ...options, command: "check" }), + /differ|Missing|Orphan|Changed|mismatch/i, + ); + await writeFile(join(options.reader, "unowned.txt"), "keep me"); + await assert.rejects( + publish([document], { ...options, command: "update" }), + /Unowned/, + ); + await rm(join(options.reader, "unowned.txt")); + await publish([document], { ...options, command: "check" }); + const alternate = await portableDocument(); + const unsupported = { + ...alternate, + resources: alternate.resources.map((resource) => + resource.id === "image" + ? { ...resource, profile: "different-host" } + : resource, + ), + }; + await assert.rejects( + publish([unsupported], { ...options, command: "update" }), + /Incompatible producer/, + ); + await publish([document], { ...options, command: "check" }); + await assert.rejects( + publish([document], { + ...options, + contributor: options.reader, + command: "update", + }), + /overlap|distinct|separate/i, + ); + } finally { + await rm(root, { recursive: true }); + } +}); + +test("compiler rejects malformed frozen data instead of trusting host handles", async () => { + const doc = await portableDocument(); + const missing = structuredClone(doc) as unknown as { + nodes: { parts?: { target?: { id: string } }[] }[]; + }; + missing.nodes[1]!.parts![1]!.target!.id = "missing"; + await assert.rejects( + compile([missing as unknown as FrozenDocument]), + /Missing reference/, + ); + const orphan = { + ...doc, + resources: [...doc.resources, { ...doc.resources[0]!, id: "orphan" }], + }; + await assert.rejects(compile([orphan]), /Unreferenced resources/); + await assert.rejects( + compile([doc], { pageOverrides: { portable: "../outside.md" } }), + /path|Path/, + ); +}); + +test("untyped compiler callers cannot silently coerce unsupported JSON", async () => { + const doc = await portableDocument(); + const invalidNumber = { + ...doc, + observations: [{ id: "count", value: Number.NaN }], + }; + await assert.rejects(compile([invalidNumber]), /finite/); + const omitted = { ...doc, observations: [{ id: "count", value: undefined }] }; + await assert.rejects( + compile([omitted as unknown as FrozenDocument]), + /JSON value/, + ); +}); diff --git a/packages/lgtmd/tests/lifecycle.test.ts b/packages/lgtmd/tests/lifecycle.test.ts new file mode 100644 index 0000000..cc8ccbd --- /dev/null +++ b/packages/lgtmd/tests/lifecycle.test.ts @@ -0,0 +1,247 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + guide, + GuideRunError, + runGuide, + runGuideWithContext, +} from "../src/lifecycle.js"; + +test("declaring a guide does not execute it and execution cleans up before returning", async () => { + const order: string[] = []; + const definition = guide( + { + id: "prepared", + title: "Prepared", + prepare: async () => { + order.push("prepare"); + return { + context: { count: 3 }, + cleanup: async () => { + order.push("cleanup"); + }, + }; + }, + }, + (doc, context) => { + order.push("write"); + doc.md`There are ${context.count} records.`; + }, + ); + assert.equal(order.length, 0); + const document = await runGuide(definition); + order.push("returned"); + assert.deepEqual(order, ["prepare", "write", "cleanup", "returned"]); + assert.equal(document.id, "prepared"); + assert.deepEqual(document.checks, []); +}); + +test("partial preparation failure remains project-owned", async () => { + const error = new Error("Preparation failed before context acquisition"); + let wrote = false; + const definition = guide( + { + id: "partial", + title: "Partial", + prepare: async () => { + throw error; + }, + }, + () => { + wrote = true; + }, + ); + await assert.rejects( + runGuide(definition), + (candidate) => candidate === error, + ); + assert.equal(wrote, false); +}); + +test("successful acquisition runs cleanup exactly once after an authoring failure", async () => { + const error = new Error("Write failed"); + let cleanups = 0; + const definition = guide( + { + id: "write-failure", + title: "Write failure", + prepare: () => ({ + context: undefined, + cleanup: () => { + cleanups += 1; + }, + }), + }, + () => { + throw error; + }, + ); + await assert.rejects( + runGuide(definition), + (candidate) => candidate === error, + ); + assert.equal(cleanups, 1); +}); + +test("primary and cleanup failures retain both original errors", async () => { + const primary = new Error("Authoring failed"); + const cleanup = new Error("Cleanup failed"); + const definition = guide( + { + id: "both", + title: "Both failures", + prepare: () => ({ + context: null, + cleanup: () => { + throw cleanup; + }, + }), + }, + () => { + throw primary; + }, + ); + await assert.rejects(runGuide(definition), (candidate) => { + assert.ok(candidate instanceof GuideRunError); + assert.deepEqual(candidate.errors, [primary, cleanup]); + assert.equal(candidate.primary, primary); + assert.equal(candidate.cleanup, cleanup); + return true; + }); +}); + +test("cleanup failure prevents otherwise successful output", async () => { + const cleanup = new Error("Cleanup failed"); + const definition = guide( + { + id: "cleanup", + title: "Cleanup", + prepare: () => ({ + context: 3, + cleanup: () => { + throw cleanup; + }, + }), + }, + (doc) => { + doc.md`Successful authoring.`; + }, + ); + await assert.rejects( + runGuide(definition), + (candidate) => candidate === cleanup, + ); +}); + +test("swallowed native assertion failure and cleanup failure are both preserved", async () => { + const primary = new Error("Native assertion failed"); + const cleanup = new Error("Cleanup failed"); + const definition = guide( + { + id: "taint", + title: "Tainted authoring", + prepare: () => ({ + context: 3, + cleanup: () => { + throw cleanup; + }, + }), + }, + async (doc) => { + try { + await doc.check("failed", () => { + throw primary; + }); + } catch { + /* Deliberately test swallowed errors. */ + } + doc.md`This prose cannot conceal a failed assertion.`; + }, + ); + await assert.rejects(runGuide(definition), (candidate) => { + assert.ok(candidate instanceof GuideRunError); + assert.ok(candidate.primary instanceof AggregateError); + assert.equal(candidate.primary.errors[0], primary); + assert.equal(candidate.cleanup, cleanup); + return true; + }); +}); + +test("supplied context has no framework cleanup ownership", async () => { + let cleanups = 0; + const context = { + value: 7, + cleanup: () => { + cleanups += 1; + }, + }; + const document = await runGuideWithContext( + { id: "supplied", title: "Supplied" }, + context, + (doc, supplied) => { + assert.equal(supplied, context); + doc.md`The supplied value is ${supplied.value}.`; + }, + ); + assert.equal(document.id, "supplied"); + assert.equal(cleanups, 0); +}); + +test("declarations copy identity while preparation may omit cleanup", async () => { + const page = { + id: "original", + title: "Original", + prepare: () => ({ context: 5 }), + }; + const definition = guide(page, (doc, context) => { + doc.md`Value: ${context}.`; + }); + page.id = "changed"; + page.title = "Changed"; + assert.equal((await runGuide(definition)).id, "original"); +}); + +test("cleanup is acquired once even when authoring mutates the preparation result", async () => { + let original = 0; + let replacement = 0; + const prepared = { + context: 7, + cleanup: () => { + original += 1; + }, + }; + const definition = guide( + { id: "acquired", title: "Acquired cleanup", prepare: () => prepared }, + (doc) => { + prepared.cleanup = () => { + replacement += 1; + }; + doc.md`Cleanup ownership is already established.`; + }, + ); + await runGuide(definition); + assert.equal(original, 1); + assert.equal(replacement, 0); +}); + +test("concise expression callbacks author documents without return boilerplate", async () => { + const definition = guide( + { id: "concise", title: "Concise authoring" }, + (doc) => doc.md`A useful explanation.`, + ); + const document = await runGuide(definition); + assert.equal(document.nodes.length, 1); + assert.deepEqual(document.checks, []); +}); + +test("authoring callback return values do not establish verification claims", async () => { + for (const value of [true, false]) { + const definition = guide( + { id: "returned", title: "Returned value" }, + async () => value, + ); + const document = await runGuide(definition); + assert.deepEqual(document.nodes, []); + assert.deepEqual(document.checks, []); + } +}); diff --git a/packages/lgtmd/tests/playwright.test.ts b/packages/lgtmd/tests/playwright.test.ts new file mode 100644 index 0000000..84a914f --- /dev/null +++ b/packages/lgtmd/tests/playwright.test.ts @@ -0,0 +1,413 @@ +import assert from "node:assert/strict"; +import { + mkdtemp, + mkdir, + readFile, + realpath, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { runPlaywright } from "../src/playwright/index.js"; +import { ReporterGuard } from "../src/playwright/guard.js"; + +const require = createRequire(import.meta.url); +const playwrightDirectory = dirname( + require.resolve("@playwright/test/package.json"), +); +const packageDirectory = fileURLToPath(new URL("../../", import.meta.url)); + +async function project( + options: { + source?: string; + config?: string; + beforeConfig?: string; + reporter?: string; + files?: Record; + } = {}, +) { + const directory = await realpath( + await mkdtemp(join(tmpdir(), "lgtmd-runner-test-")), + ); + await mkdir(join(directory, "node_modules", "@playwright"), { + recursive: true, + }); + await symlink( + playwrightDirectory, + join(directory, "node_modules", "@playwright", "test"), + "dir", + ); + await symlink( + packageDirectory, + join(directory, "node_modules", "lgtmd"), + "dir", + ); + await writeFile( + join(directory, "package.json"), + JSON.stringify({ type: "module" }), + ); + await writeFile( + join(directory, "playwright.config.mjs"), + ` + import { defineConfig } from "@playwright/test"; + import { docsReporter } from "lgtmd/playwright"; + ${options.beforeConfig ?? ""} + export default defineConfig({ + testDir: ".", testMatch: "case.spec.mjs", workers: 1, + retries: 0, timeout: 3000, projects: [{name: "one"}], + reporter: ${options.reporter ?? "docsReporter()"}, + ${options.config ?? ""} + }); + `, + ); + await writeFile( + join(directory, "case.spec.mjs"), + ` + import {test as base, expect} from "@playwright/test"; + import {withDocs} from "lgtmd/playwright"; + ${ + options.source ?? + ` + const test = withDocs(base); + test("a documented check", async ({doc}) => { + doc.page({id: "sample", title: "Sample"}); + doc.md\`The sample is ready.\`; + expect(2 + 2).toBe(4); + }); + ` + } + `, + ); + for (const [filename, source] of Object.entries(options.files ?? {})) { + await writeFile(join(directory, filename), source); + } + return { + directory, + reader: join(directory, "reader"), + contributor: join(directory, "contributor"), + run() { + return runPlaywright({ + config: join(directory, "playwright.config.mjs"), + command: "build", + reader: join(directory, "reader"), + contributor: join(directory, "contributor"), + }); + }, + cleanup: () => rm(directory, { recursive: true, force: true }), + }; +} + +async function assertUnpublished(run: Awaited>) { + await assert.rejects(stat(run.reader), { code: "ENOENT" }); + await assert.rejects(stat(run.contributor), { code: "ENOENT" }); +} + +test("reporter guard retains synchronous, asynchronous, malformed, and explicit failing outcomes", async () => { + const guard = new ReporterGuard([ + { + onTestBegin() { + throw new Error("sync failure"); + }, + }, + { + async onExit() { + throw new Error("async failure"); + }, + }, + { + async onEnd() { + return Object.defineProperty({}, "status", { + get() { + throw new Error("reporter result getter failed"); + }, + }) as { status?: "passed" }; + }, + }, + { + async onEnd() { + return { status: "failed" as const }; + }, + }, + ]); + guard.invoke("onTestBegin"); + guard.invoke("onExit"); + guard.invoke("onEnd"); + await guard.settle(); + assert.deepEqual(guard.errors.sort(), [ + "onEnd: Error: reporter result getter failed", + "onEnd: reporter requested failed status", + "onExit: Error: async failure", + "onTestBegin: Error: sync failure", + ]); +}); + +test("real Playwright fixture composition publishes only after test and worker cleanup", async () => { + const run = await project({ + source: ` + import {appendFile} from "node:fs/promises"; + const applicationTest = base.extend({ + workspace: async ({}, use) => { + await use({name: "Prepared workspace"}); + await appendFile("cleanup.txt", "test cleanup\\n"); + }, + service: [async ({}, use) => { + await use("running"); + await appendFile("cleanup.txt", "worker cleanup\\n"); + }, {scope: "worker"}], + }); + const test = withDocs(applicationTest); + test("native fixtures stay available", async ({workspace, service, doc}) => { + expect(service).toBe("running"); + doc.page({id: "sample", title: "Sample"}); + doc.md\`Use \${workspace.name}.\`; + expect(workspace.name).toBe("Prepared workspace"); + }); + `, + }); + try { + await run.run(); + assert.equal( + await readFile(join(run.directory, "cleanup.txt"), "utf8"), + "test cleanup\nworker cleanup\n", + ); + assert.match( + await readFile(join(run.reader, "sample.md"), "utf8"), + /Prepared workspace/, + ); + } finally { + await run.cleanup(); + } +}); + +test("caught named check failure cannot become accepted browser evidence", async () => { + const run = await project({ + source: ` + const test = withDocs(base); + test("caught assertion", async ({doc}) => { + doc.page({id: "sample", title: "Sample"}); + doc.md\`A claim.\`; + try { await doc.check("false-claim", () => expect(1).toBe(2)); } catch {} + }); + `, + }); + try { + await assert.rejects(run.run(), /did not finish successfully/); + await assertUnpublished(run); + } finally { + await run.cleanup(); + } +}); + +for (const [name, source, config, files] of [ + [ + "test fixture teardown", + ` + const test = withDocs(base.extend({ + context: async ({}, use) => { await use({}); throw new Error("test teardown failed"); }, + })); + test("test teardown", async ({doc, context}) => { + doc.page({id: "sample", title: "Sample"}); doc.md\`Candidate.\`; expect(context).toEqual({}); + }); + `, + "", + {}, + ], + [ + "worker fixture teardown", + ` + const test = withDocs(base.extend({ + service: [async ({}, use) => { await use("ready"); throw new Error("worker teardown failed"); }, {scope: "worker"}], + })); + test("worker teardown", async ({doc, service}) => { + doc.page({id: "sample", title: "Sample"}); doc.md\`Candidate.\`; expect(service).toBe("ready"); + }); + `, + "", + {}, + ], + [ + "global teardown", + undefined, + 'globalTeardown: "./teardown.mjs",', + { + "teardown.mjs": + 'export default async () => { throw new Error("global teardown failed"); };', + }, + ], + [ + "test timeout", + ` + const test = withDocs(base); + test("timeout", async ({doc}) => { + doc.page({id: "sample", title: "Sample"}); doc.md\`Candidate.\`; + await new Promise(resolve => setTimeout(resolve, 500)); + }); + `, + "timeout: 100,", + {}, + ], + [ + "global timeout", + ` + const test = withDocs(base); + test("global timeout", async ({doc}) => { + doc.page({id: "sample", title: "Sample"}); doc.md\`Candidate.\`; + await new Promise(resolve => setTimeout(resolve, 3000)); + }); + `, + "timeout: 5000, globalTimeout: 1000,", + {}, + ], + [ + "SIGINT interruption", + ` + const test = withDocs(base); + test("interrupt the owned runner", async ({doc}) => { + doc.page({id: "sample", title: "Sample"}); doc.md\`Candidate.\`; + // Playwright forks this test worker from the CLI owned by runPlaywright. + setTimeout(() => process.kill(process.ppid, "SIGINT"), 50); + await new Promise(resolve => setTimeout(resolve, 1000)); + }); + `, + "", + {}, + ], +] as const) { + test(`real Playwright ${name} blocks publication`, async () => { + const run = await project({ source, config, files }); + try { + await assert.rejects(run.run(), /did not finish successfully/); + await assertUnpublished(run); + } finally { + await run.cleanup(); + } + }); +} + +test("async reporter result inspection failure blocks publication", async () => { + const run = await project({ + beforeConfig: + 'class BrokenReporter { async onEnd() { return Object.defineProperty({}, "status", {get() {throw new Error("reporter result getter failed");}}); } }', + reporter: "docsReporter({reporters: [new BrokenReporter()]})", + }); + try { + await assert.rejects(run.run(), /did not finish successfully/); + await assertUnpublished(run); + } finally { + await run.cleanup(); + } +}); + +test("late guarded onExit error blocks publication even when Playwright exits zero", async () => { + const run = await project({ + beforeConfig: + 'class LateReporter { async onExit() { await Promise.resolve(); throw new Error("late reporter failure"); } }', + reporter: "docsReporter({reporters: [new LateReporter()]})", + }); + try { + await assert.rejects( + run.run(), + /completed success receipt:.*[\s\S]*late reporter failure/, + ); + await assertUnpublished(run); + } finally { + await run.cleanup(); + } +}); + +test("late reporter failure preserves an existing explicitly accepted baseline", async () => { + const run = await project(); + try { + await runPlaywright({ + config: join(run.directory, "playwright.config.mjs"), + command: "update", + reader: run.reader, + contributor: run.contributor, + }); + const readerBefore = await readFile(join(run.reader, "sample.md")); + const contributorBefore = await readFile( + join(run.contributor, "sample.md"), + ); + const configPath = join(run.directory, "playwright.config.mjs"); + const config = await readFile(configPath, "utf8"); + await writeFile( + configPath, + config.replace( + "reporter: docsReporter(),", + 'reporter: docsReporter({reporters: [{async onExit() {throw new Error("late update failure");}}]}),', + ), + ); + await assert.rejects( + runPlaywright({ + config: configPath, + command: "update", + reader: run.reader, + contributor: run.contributor, + }), + /late update failure/, + ); + assert.deepEqual( + await readFile(join(run.reader, "sample.md")), + readerBefore, + ); + assert.deepEqual( + await readFile(join(run.contributor, "sample.md")), + contributorBefore, + ); + } finally { + await run.cleanup(); + } +}); + +test("unguarded custom reporter configuration is rejected", async () => { + const run = await project({ + reporter: '[...docsReporter(), ["./unguarded.mjs"]]', + files: { "unguarded.mjs": "export default class OtherReporter {}" }, + }); + try { + await assert.rejects(run.run(), /did not finish successfully/); + await assertUnpublished(run); + } finally { + await run.cleanup(); + } +}); + +for (const [name, config] of [ + ["retries", "retries: 1,"], + ["repeated runs", "repeatEach: 2,"], + ["multiple projects", 'projects: [{name: "one"}, {name: "two"}],'], + ["shards", "shard: {current: 1, total: 2},"], +] as const) { + test(`unsupported ${name} fails explicitly`, async () => { + const run = await project({ config }); + try { + await assert.rejects(run.run(), /did not finish successfully/); + await assertUnpublished(run); + } finally { + await run.cleanup(); + } + }); +} + +test("duplicate page identities are rejected rather than choosing by arrival", async () => { + const run = await project({ + source: ` + const test = withDocs(base); + for (const title of ["first", "second"]) test(title, async ({doc}) => { + doc.page({id: "same", title}); doc.md\`A document.\`; + }); + `, + }); + try { + await assert.rejects(run.run(), /did not finish successfully/); + await assertUnpublished(run); + } finally { + await run.cleanup(); + } +}); diff --git a/packages/lgtmd/tsconfig.json b/packages/lgtmd/tsconfig.json new file mode 100644 index 0000000..614c997 --- /dev/null +++ b/packages/lgtmd/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "declaration": true, + "outDir": "dist", + "rootDir": ".", + "types": ["node"], + "skipLibCheck": true, + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/rfcs/0004-lgtmd-web-incubation.md b/rfcs/0004-lgtmd-web-incubation.md new file mode 100644 index 0000000..027194b --- /dev/null +++ b/rfcs/0004-lgtmd-web-incubation.md @@ -0,0 +1,345 @@ +# RFC 0004: Incubate lgtmd's Rust and web contracts together + +Status: **Implemented locally; extraction and release deferred** + +Date: 2026-10-13 + +Incubate the portable Rust SDK and a small JavaScript/TypeScript integration +directly in N3, without a submodule. Keep N3 working as a real consumer while +the language and lifecycle boundaries develop. Then move the independently +verified SDK, examples, and development harness to the lgtmd repository. + +The preferred eventual N3 integration is a crates.io dependency once the package +passes its release gates. A submodule is optional contingency, not a required +stage. This proposal authorizes no repository move, publication, or release. + +The incubation below is implemented locally. The package/path/import rename is +recorded separately from the shared Rust/Node implementation and web examples. + +## Implementation record + +- `crates/lgtmd` now permits explanation-only documents, separate frozen + observations, and typed code resource views. N3 retains its own nonempty replay + fact requirement and unchanged guide artifacts. +- Schema-v1 frozen input and coarse compilation live in `lgtmd::transport`. + Native round trips preserve exports. JSON resources use a shared Rust encoder, + including integer-like keys and fractional numbers registered from Node. +- `packages/lgtmd` supplies Node 24 ESM authoring, native assertion wrappers, + project-owned async preparation/cleanup, supplied context, and optional + `lgtmd/playwright`. Ordinary callback return values are ignored; assertions + remain explicit. Tagged Markdown automatically separates complete blocks. +- The selected bridge is `crates/lgtmd-node`. A WASM probe compiled the same + prose/notes/JSON/image input and produced equal exports, but its filesystem + publication failed as unsupported. The native route reuses the existing strict + Rust store rather than implementing another filesystem contract in Node. +- The Playwright adapter requires a sole guarded `docsReporter`, a completed + receipt, and successful runner-process exit before publishing. Imported custom + reporter instances are supported; built-in reporter-name delegation, retries, + repeatEach greater than one, shards, and multiple/dependency projects are not. +- [Three complete React/Vite examples](../examples/lgtmd-web/README.md) include all project + setup, cleanup, application code, prose, observations, checks, downloads, + screenshots and contributor notes. React components and ordinary Vite scripts + model a real developer application; lgtmd is a development dependency. Browser + captures use the production bundle on isolated ports. No N3 runtime is required. +- The independent web gate passed on Rust 1.95, Node 24.14.0 and macOS arm64, + including real Chromium replay and exact repeated exports, Cargo packaging and + an extracted consumer, and npm installation/core/Playwright execution with + Rust absent from PATH. The final gate passed all 51 Node tests. Linux x64 CI + is configured but has not run here. +- Full native N3 verification passed against both the working SDK and its actual + packaged source in a disposable N3 copy, without authoring changes: 954 tests + passed, one pre-existing test remained ignored, and the required measurement + and real-renderer parity checks passed. Exact guide and consumer baselines are + unchanged. This rehearses package-content compatibility; registry installation + remains unverified until an explicitly authorized release. + +The web example gate builds a fresh temporary candidate and checks a second +execution against it. This establishes repeated-run determinism, not a retained +visual regression baseline or cross-platform pixel identity. The separately +reviewed local Reader/Contributor trees and screenshots are disposable evidence; +projects choose and explicitly accept their own baselines using `update`. + +See the [implemented architecture](../docs/architecture/executable-documents.md#node-and-web-incubation) +and [Node SDK contract](../packages/lgtmd/README.md). The original sequence below +records the reasoning and later extraction/release gates. + +## Outcome and rationale + +The goal is to discover major authoring, compiler, and host integration changes +while both Rust and JavaScript consumers can exercise them together. Extraction +should primarily change manifests, dependency location, and development wiring. +N3 scenarios should keep their authoring API and retained guide output when the +dependency becomes external. + +Readiness applies to a declared initial support scope. Incubation cannot promise +that no future breaking change will ever be needed. Package contents, clean +installation, supported runtimes, diagnostics, and compatibility are explicit +exit gates; moving files alone does not establish production readiness. + +[RFC 0003](0003-executable-documentation-framework.md) records the existing Rust +framework. The [implemented architecture](../docs/architecture/executable-documents.md) +and [SDK README](../crates/lgtmd/README.md) remain authoritative for current behavior. + +## Initial support scope + +- Rust authoring through `lgtmd`, with N3 and the existing CLI/library consumers. +- TypeScript and JavaScript authoring from Node.js, initially on the repository's + Node 24 development baseline. Pin actual package/tool versions when implemented. +- One optional Playwright Test integration, composed with a project's own test + fixtures, assertions, configuration, and browser lifecycle. +- Reader and Contributor Markdown/resource exports through the same Rust + compiler, with explicit build, check, and baseline acceptance behavior. + +The first web integration documents a web application from Node. Running the SDK +inside the application browser is a separate support target. The framework must +also work with supplied context and ordinary async code without Playwright. + +The project owns preparation, mock state, seeds, services, credentials, APIs, and +cleanup. An optional preparation callback returns context and cleanup; it carries +no assumptions about a database, frontend framework, server count, or deployment. +Application-owned fixtures may provide that context instead. DOM queries remain +subject to application changes, and the author must assert the behavior described. + +## Proposed source layout + +Create directories only when their implementation lands: + +```text +crates/lgtmd/ Rust SDK and shared document processing +crates/doc-example-config/ Existing portable Rust consumers and baselines +crates/lgtmd-node/ Selected coarse compiler/store host bridge +packages/lgtmd/ TypeScript SDK, Node host, optional Playwright adapter +examples/lgtmd-web/ Small independent web fixture and authored documents +fixtures/lgtmd/ Shared semantic inputs, exports, and failure cases +``` + +Keep N3's existing `web/` devoted to the N3 application. Use a private npm workspace +for the SDK and example when both exist, with explicit package scripts and a +lockfile. Its dependencies serve this incubation; the application remains Rust. +The Playwright adapter can be an optional `lgtmd/playwright` entry point with a +peer dependency rather than a requirement for every SDK consumer. + +If a native bridge proves preferable, create only the host package it actually +requires instead of maintaining an unused WASM package. Keep Rust processing in +the existing crate unless a demonstrated dependency boundary requires a split. + +Public APIs receive content, context, paths, and options explicitly. They must +not discover an N3 checkout or assume these source directories. Repository-only +verification may know the payload layout and must prove it in a disposable +independent workspace, following the existing Rust standalone gate. + +## Three small documents that exercise the contract + +Use one neutral example project. Existing interface sketches are design input; +their declarations and hand-authored previews are not runtime evidence. + +| Document | What the author writes | What it must establish | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Explain record names | Ordinary paragraphs, headings, a reused prose helper, and a targeted contributor note. | Useful documentation can exist without an executed check; it makes no verification claim. | +| Describe values and resources | Application-owned async preparation, an observation, an explicit assertion, JSON shown as code and linked as a download, and a private text resource. | Values and buffers freeze at registration; preparation and cleanup have honest failure semantics; non-image resources are first class. | +| Filter records in a browser | Existing Playwright fixtures and assertions, filter/clear interactions, a screenshot, prose, and a note on the illustrated passage. | Documentation adds to a normal browser test without owning the application or requiring action wrappers. | + +Start with a tiny deterministic records fixture whose data is supplied by the +example. A production backend is unnecessary to demonstrate the contract. +Include an already-prepared context path and ordinary helper reuse in these +documents, rather than adding more demo applications. + +Implemented authoring shape; the complete runnable example includes setup and finalization: + +```ts +test("Filter records", async ({ page, doc }) => { + doc.page({ id: "filter-records", title: "Find a record" }); + const search = page.getByRole("searchbox", { name: "Search records" }); + const results = page.getByRole("list", { name: "Records" }); + + await search.fill("Alpha"); + await expect(results.getByRole("listitem")) + .toHaveText(["Alpha contract", "Alpha renewal"]); + + const image = doc.assets.image("matches", await results.screenshot()); + const explanation = doc.md` + Search by name to narrow the list. + + ${image.embed("The two matching records")} + `; + doc.noteOn(explanation)`The project fixture owns the supplied records.`; + + await search.fill(""); + await expect(results.getByRole("listitem")) + .toHaveText(["Alpha contract", "Alpha renewal", "Beta proposal"]); +}); +``` + +The runnable example must include the test extension, navigation/setup fixture, +reporter or finalizer configuration, and application implementation omitted from +this sketch. Review the complete authoring cost, including helpers and fixture +configuration. Avoid IDs on every paragraph, duplicate resource declarations, +mandatory wrapping of existing assertions, or parallel prose templates. + +## Contracts to settle during incubation + +### 1. Native authoring and execution + +Rust and TypeScript keep idiomatic APIs over the same document semantics. Prose +segments, typed interpolations, resource presentations, and targeted notes retain +their meaning across the language boundary. Ordinary functions provide reuse. +Specify indentation handling, escaping, block placement, and reference failures +through examples before freezing the TypeScript surface. + +Separate authored explanation, frozen observation, explicitly executed check, +and host run outcome. A passed browser test must not manufacture an SDK check. +Named check wrappers are optional and must preserve native failures, including a +failure caught later by author code. Unwrapped assertions remain host outcomes. + +Before incubation, `Doc::finish` required successful checks and observations +were coupled to equality checks. The implementation deliberately separates those +semantics, with matching tests in both languages. N3 retains its stricter +behavioral requirements in its adapter. +Do not add fake `require(true)` calls to make generic documents pass. + +### 2. Frozen input and one compiler operation + +Use one coarse operation, conceptually +`compile(frozenDocuments, exportOptions) -> exports and diagnostics`. +Native SDKs execute application code and freeze its results before calling it. +The compiler receives no browser object, callback, promise, or application seed. + +Define a versioned input containing complete ordered content, typed references, +observations/check outcomes, notes, document/variant identities, immutable +resources, and useful source context. Current Rust handle ownership is +process-local; transport identity must be explicit and validated. Keep literal +Markdown and interpolated values distinguishable. Specify numeric and JSON +limits, null versus absence, invalid values, byte ownership, and unknown-version +errors rather than relying on lossy serialization defaults. + +Both language paths use the same Rust validation and export logic. A private +Rust model can evolve behind the versioned boundary. Do not expose Rust struct +layout or reconstruct authored content from the current inventory manifest. +Compare identical canonical fixtures across both frontends. + +Complete Markdown pages, resources, and their receipts are sufficient for this +milestone. The canonical interactive viewer remains future work. Its eventual +structured output contract is not claimed stable by this milestone; preserve +document structure internally so another exporter can be added without changing +how authors write their documents. + +### 3. Host lifecycle and acceptance + +Authoring may compile a preview before cleanup, but accepting a successful output +requires the relevant host run to finish successfully. Standalone preparation +owns partial failure before returning context; after successful acquisition, +framework-managed cleanup runs once. Preserve primary and cleanup errors together. +An adapter using project fixtures leaves their cleanup to the existing runner. + +Playwright has dependency-ordered fixture teardown and distinct test/worker +lifetimes. Use its fixture and reporter extension points, with regressions proving +when output becomes eligible. A doc fixture's own teardown alone is insufficient +proof that the entire run succeeded. Relevant worker/global teardown errors, +timeouts, interruption, and reporter failures must also block acceptance. +Prove the final success signal against the completed runner process. Candidate +staging plus a post-process finalizer may be necessary; one reporter callback +cannot establish that every other reporter completed successfully. Select and +test that mechanism during the binding and real-execution stages below. +See [fixture execution order](https://playwright.dev/docs/test-fixtures#execution-order) +and the [reporter lifecycle](https://playwright.dev/docs/api/class-reporter). + +Identify document, project variant, and execution attempt separately. Worker or +retry arrival order must not determine which evidence wins. Start the example +with one project and retries disabled; require an explicit rejection of unsupported +modes. Before broader support, test collision handling and final-attempt policy. +A retry success must retain its flaky status rather than becoming a clean first +pass; [Playwright exposes that distinction](https://playwright.dev/docs/test-retries#retries). +Transient run IDs and host diagnostics must not introduce nondeterminism into +canonical document bytes. + +### 4. Resources, privacy, and publication + +Freeze mutable values and resource bytes when registered. Reader and Contributor +exports reuse one completed run. Private notes and exclusively private resources +must stay out of Reader text, resource files, paths, and metadata; adding notes +must preserve Reader anchors and bytes. Existing check identifiers are public +metadata even when their values are hidden. Keep that policy explicit rather +than silently claiming every evidence identifier is private. + +Configuration selects output roots and layouts. Build a fresh review candidate; +check compares without accepting; update is explicit. Preserve strict ownership, +path separation, failure recovery, and producer/profile compatibility. Reuse the +Rust storage implementation where the selected host permits it; otherwise prove +the Node host against the same filesystem contract before claiming parity. +Publishing multiple audiences is currently not one atomic transaction. + +Use fixed resource bytes for cross-language conformance. Browser screenshots have +their own pinned capture environment and explicit reviewed baseline. Do not claim +cross-platform pixel identity, add hidden tolerances, or weaken N3's exact checks. + +## Binding and tooling decision + +Evaluate WASM first for pure processing, using a real frozen document with prose, +notes, JSON, and image bytes. Compare a coarse native bridge if startup, resource +transfer, diagnostics, packaging, or filesystem lifecycle makes it preferable. +Choose one initial shipping route behind the TypeScript facade. Neither route +should require crossing a process/binding boundary for every paragraph. + +WASM has target-specific deployment and loading behavior; a Node integration does +not establish browser compatibility. Use the +[wasm-bindgen deployment guide](https://wasm-bindgen.github.io/wasm-bindgen/reference/deployment.html) +when implementing the probe. Include actual host publication in the assessment; +a pure compilation demo alone does not settle the installation or storage design. + +Local development may build Rust artifacts. Installing the eventual npm package +must not require a Rust compiler. Pin the supported Node/module format and browser +test dependencies. Make browser/dependency installation an explicit setup step; +checks should not silently fetch a browser or depend on Docker. + +## Implementation sequence and acceptance gates + +1. **Authoring examples and semantics.** Make the three complete source examples + reviewable, including setup and cleanup. Agree on explanation-only pages, + observations, named checks, interpolation, and errors. Measure full authoring + overhead. No production packaging or broad framework support is needed here. +2. **Shared model and binding.** Implement the minimal versioned frozen input, + coarse compilation boundary, and selected bridge. Exercise both Rust and + TypeScript through common conformance cases. Preserve N3 native guide output. +3. **Real web execution.** Run the three examples, compose with real Playwright + fixtures, and prove post-run acceptance. Failure cases include mutable inputs, + foreign/missing references, bad schema, swallowed check failure, teardown + failure, timeout, and unsupported retry/variant configuration. Inspect the + generated prose, stills, and contributor notes in a browser preview. +4. **Independent and packaged verification.** Copy only the portable Rust/JS + sources, examples, conformance fixtures, and tooling to a temporary workspace. + Resolve its own dependencies and run it without N3 source or its renderer. + Run clean consumers against a Cargo package and npm tarball, exercising both + supported entry points and shipped binding assets. The npm consumer check + runs with Rust unavailable. Document the actual supported platform matrix. +5. **Extraction rehearsal and release readiness.** Use the packaged Rust source + in a disposable N3 checkout without changing its authoring code; run native + verification and exact guide comparison. Review licenses, package metadata, + installation, and version/compatibility policy. Then move the payload to + lgtmd, verify it there, and request authorization for an exact release version. + After publication, N3 can switch to crates.io and repeat its locked checks. + +Retain the existing `just doc-framework-test`, standalone Rust smoke, and N3 +`just verify` gates. Add an explicit portable web gate and setup documentation +when implemented, with a CI lane supplying the pinned browser environment. Run +the same Node/browser checks locally; the independent gate must not launch N3. +Optional modes cannot silently reduce the required checks for their declared scope. + +Cargo's packaging workflow checks the archive and builds from its packaged +contents; use that alongside installed-consumer tests, as described in the +[Cargo package reference](https://doc.rust-lang.org/cargo/commands/cargo-package.html). +Before publication, the N3 rehearsal establishes package-content compatibility; +only the later registry installation verifies actual crates.io consumption. + +## Deferred work and remaining decisions + +Defer a full canonical viewer, browser-hosted SDK execution, broad runner adapters, +backend orchestration, automatic seeding conventions, cursor/key compositing, +AI-assisted scenarios, Git/image diffs, and a public plugin protocol. Keep these +possible through clear host/content boundaries, without reserving speculative +APIs or implementing a framework around hypothetical extensions. + +The examples and probe have settled the initial TypeScript signatures, wire +encoding, native bridge, shared Rust publication, and strict single-attempt +Playwright scope. Broader platform verification, distributed release binaries, +version policy, and the initial release version remain later work. The intended exit is direct published-package +consumption from N3; use a submodule only if evidence creates a concrete need. diff --git a/rfcs/README.md b/rfcs/README.md index de7ba56..2d4cdc7 100644 --- a/rfcs/README.md +++ b/rfcs/README.md @@ -34,8 +34,9 @@ that contain no useful reasoning do not need an RFC. ## Index -| RFC | Status | Topic | -| -------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------ | -| [0001](0001-paired-selection-in-2d.md) | Deferred | Pair corresponding front/back vertices during selection in an aligned 2D view. | -| [0002](0002-authored-scenes.md) | Draft | N3-owned scenes, editor/history boundaries, migration, and interchange feasibility. | -| [0003](0003-executable-documentation-framework.md) | Implemented locally | A reusable Rust-first documentation SDK, evidence contract, and staged extraction from N3. | +| RFC | Status | Topic | +| -------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------- | +| [0001](0001-paired-selection-in-2d.md) | Deferred | Pair corresponding front/back vertices during selection in an aligned 2D view. | +| [0002](0002-authored-scenes.md) | Draft | N3-owned scenes, editor/history boundaries, migration, and interchange feasibility. | +| [0003](0003-executable-documentation-framework.md) | Implemented locally | A reusable Rust-first documentation SDK, evidence contract, and staged extraction from N3. | +| [0004](0004-lgtmd-web-incubation.md) | Implemented locally | Incubate Rust and web consumers together before extracting lgtmd and consuming its published SDK. | From 07948746287ed765eaac905a06dc817da5a841a2 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 13 Oct 2026 21:09:20 +0900 Subject: [PATCH 3/4] Extract lgtmd to its repository and pin N3 to the shared SDK Move the Rust core, Node bridge, portable consumers, React example and independent tooling to softmarshmallow/lgtmd at 8828c41eaf7fbb002044aea9f8bfd4a06ba76168. Keep N3 replay, scenarios, renderer and exact guide artifacts here. Remove SDK-owned gates from application tooling, preserve Git dependency caches in CI, and retain the incubation history. Full native verification and unchanged guide bytes validated against the GitHub revision. --- .../skills/docs-driven-development/SKILL.md | 6 +- .github/workflows/ci.yml | 34 +- .oxfmtrc.json | 3 - AGENTS.md | 58 +- Cargo.lock | 42 +- Cargo.toml | 7 +- LTGMD-TODO.md | 34 - TODO.md | 2 +- crates/doc-example-config/.gitignore | 3 - crates/doc-example-config/Cargo.toml | 24 - crates/doc-example-config/LICENSE | 21 - crates/doc-example-config/README.md | 142 -- .../config/contributor/.ownership.json | 14 - .../configure-a-port/execution-checks.json | 9 - .../configure-a-port/invalid-input.json | 3 - .../configure-a-port/recovered-command.txt | 3 - .../configure-a-port/rejected-command.txt | 3 - .../configure-a-port/saved-configuration.json | 3 - .../configure-a-port/successful-command.txt | 3 - .../assets/configure-a-port/valid-input.json | 3 - .../config/contributor/configure-a-port.md | 129 -- .../baseline/config/contributor/manifest.json | 380 ----- .../baseline/config/reader/.ownership.json | 13 - .../configure-a-port/invalid-input.json | 3 - .../configure-a-port/recovered-command.txt | 3 - .../configure-a-port/rejected-command.txt | 3 - .../configure-a-port/saved-configuration.json | 3 - .../configure-a-port/successful-command.txt | 3 - .../assets/configure-a-port/valid-input.json | 3 - .../config/reader/configure-a-port.md | 113 -- .../baseline/config/reader/manifest.json | 310 ---- .../serde-json/contributor/.ownership.json | 12 - .../parse-a-typed-list/error-observation.json | 6 - .../parse-a-typed-list/formatted-list.json | 5 - .../parse-a-typed-list/mismatched-input.json | 1 - .../assets/parse-a-typed-list/type-error.txt | 1 - .../parse-a-typed-list/valid-input.json | 1 - .../serde-json/contributor/manifest.json | 314 ---- .../contributor/parse-a-typed-list.md | 102 -- .../serde-json/reader/.ownership.json | 11 - .../parse-a-typed-list/formatted-list.json | 5 - .../parse-a-typed-list/mismatched-input.json | 1 - .../assets/parse-a-typed-list/type-error.txt | 1 - .../parse-a-typed-list/valid-input.json | 1 - .../baseline/serde-json/reader/manifest.json | 274 ---- .../serde-json/reader/parse-a-typed-list.md | 94 -- crates/doc-example-config/examples/docs.rs | 19 - .../doc-example-config/examples/serde_json.rs | 179 --- .../examples/support/mod.rs | 222 --- crates/doc-example-config/src/lib.rs | 210 --- crates/doc-example-config/src/main.rs | 40 - crates/doc-example-config/tests/cli.rs | 50 - crates/doc-example-config/tests/guide.rs | 62 - crates/lgtmd-node/Cargo.toml | 17 - crates/lgtmd-node/LICENSE | 21 - crates/lgtmd-node/src/main.rs | 100 -- crates/lgtmd/.gitignore | 4 - crates/lgtmd/Cargo.toml | 21 - crates/lgtmd/LICENSE | 21 - crates/lgtmd/README.md | 417 ----- crates/lgtmd/dev/tests/test_verify.py | 133 -- crates/lgtmd/dev/verify.py | 123 -- crates/lgtmd/examples/configured.rs | 144 -- crates/lgtmd/examples/conformance.rs | 38 - crates/lgtmd/examples/preview.rs | 488 ------ crates/lgtmd/src/artifact.rs | 87 -- crates/lgtmd/src/authoring.rs | 491 ------ crates/lgtmd/src/layout.rs | 110 -- crates/lgtmd/src/lib.rs | 46 - crates/lgtmd/src/lifecycle.rs | 686 --------- crates/lgtmd/src/manifest.rs | 536 ------- crates/lgtmd/src/markdown.rs | 198 --- crates/lgtmd/src/model.rs | 374 ----- crates/lgtmd/src/paths.rs | 123 -- crates/lgtmd/src/prose.rs | 55 - crates/lgtmd/src/render.rs | 417 ----- crates/lgtmd/src/runner.rs | 373 ----- crates/lgtmd/src/store.rs | 103 -- crates/lgtmd/src/transport.rs | 588 ------- crates/lgtmd/src/validation.rs | 35 - crates/lgtmd/tests/acceptance.rs | 186 --- crates/lgtmd/tests/code_views.rs | 108 -- crates/lgtmd/tests/fixtures/portable.json | 529 ------- crates/lgtmd/tests/json_review.rs | 65 - crates/lgtmd/tests/layout.rs | 375 ----- crates/lgtmd/tests/lifecycle.rs | 296 ---- crates/lgtmd/tests/model.rs | 597 -------- crates/lgtmd/tests/observations.rs | 266 ---- crates/lgtmd/tests/prose.rs | 107 -- crates/lgtmd/tests/render_review.rs | 162 -- crates/lgtmd/tests/runner_aliases.rs | 211 --- crates/lgtmd/tests/runner_config.rs | 343 ----- crates/lgtmd/tests/section_review.rs | 86 -- crates/lgtmd/tests/store.rs | 903 ----------- crates/lgtmd/tests/store_layout.rs | 171 --- crates/lgtmd/tests/transport.rs | 103 -- docs/architecture/executable-documents.md | 66 +- docs/development.md | 46 +- examples/lgtmd-web/.gitignore | 3 - examples/lgtmd-web/README.md | 188 --- .../lgtmd-web/browser/application-test.ts | 30 - .../lgtmd-web/browser/filter-records.spec.ts | 59 - examples/lgtmd-web/guides/01-record-names.ts | 23 - examples/lgtmd-web/guides/02-resources.ts | 105 -- examples/lgtmd-web/guides/shared.ts | 16 - examples/lgtmd-web/guides/supplied-context.ts | 12 - examples/lgtmd-web/index.html | 12 - examples/lgtmd-web/package.json | 25 - examples/lgtmd-web/playwright.config.ts | 41 - examples/lgtmd-web/run.ts | 30 - examples/lgtmd-web/server/index.mjs | 79 - examples/lgtmd-web/src/App.tsx | 86 -- examples/lgtmd-web/src/main.tsx | 13 - examples/lgtmd-web/src/records.ts | 11 - examples/lgtmd-web/src/styles.css | 64 - examples/lgtmd-web/tsconfig.json | 14 - examples/lgtmd-web/vite.config.ts | 25 - justfile | 24 +- package-lock.json | 1364 ----------------- package.json | 22 - packages/lgtmd/.gitignore | 3 - packages/lgtmd/LICENSE | 21 - packages/lgtmd/README.md | 138 -- packages/lgtmd/dev/tests/test_verify.py | 55 - packages/lgtmd/dev/verify.py | 342 ----- packages/lgtmd/package.json | 40 - packages/lgtmd/src/authoring.ts | 481 ------ packages/lgtmd/src/compiler.ts | 133 -- packages/lgtmd/src/index.ts | 5 - packages/lgtmd/src/lifecycle.ts | 133 -- packages/lgtmd/src/model.ts | 200 --- packages/lgtmd/src/playwright/guard.ts | 67 - packages/lgtmd/src/playwright/index.ts | 46 - packages/lgtmd/src/playwright/reporter.ts | 237 --- packages/lgtmd/src/playwright/runner.ts | 129 -- packages/lgtmd/tests/authoring.test.ts | 340 ---- packages/lgtmd/tests/compiler.test.ts | 168 -- packages/lgtmd/tests/lifecycle.test.ts | 247 --- packages/lgtmd/tests/playwright.test.ts | 413 ----- packages/lgtmd/tsconfig.json | 15 - ...0003-executable-documentation-framework.md | 21 +- rfcs/0004-lgtmd-web-incubation.md | 40 +- rfcs/README.md | 12 +- tools/ci_runner.py | 7 +- tools/tests/test_ci_runner.py | 33 - 145 files changed, 144 insertions(+), 18788 deletions(-) delete mode 100644 LTGMD-TODO.md delete mode 100644 crates/doc-example-config/.gitignore delete mode 100644 crates/doc-example-config/Cargo.toml delete mode 100644 crates/doc-example-config/LICENSE delete mode 100644 crates/doc-example-config/README.md delete mode 100644 crates/doc-example-config/baseline/config/contributor/.ownership.json delete mode 100644 crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/execution-checks.json delete mode 100644 crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/invalid-input.json delete mode 100644 crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/recovered-command.txt delete mode 100644 crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/rejected-command.txt delete mode 100644 crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/saved-configuration.json delete mode 100644 crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/successful-command.txt delete mode 100644 crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/valid-input.json delete mode 100644 crates/doc-example-config/baseline/config/contributor/configure-a-port.md delete mode 100644 crates/doc-example-config/baseline/config/contributor/manifest.json delete mode 100644 crates/doc-example-config/baseline/config/reader/.ownership.json delete mode 100644 crates/doc-example-config/baseline/config/reader/assets/configure-a-port/invalid-input.json delete mode 100644 crates/doc-example-config/baseline/config/reader/assets/configure-a-port/recovered-command.txt delete mode 100644 crates/doc-example-config/baseline/config/reader/assets/configure-a-port/rejected-command.txt delete mode 100644 crates/doc-example-config/baseline/config/reader/assets/configure-a-port/saved-configuration.json delete mode 100644 crates/doc-example-config/baseline/config/reader/assets/configure-a-port/successful-command.txt delete mode 100644 crates/doc-example-config/baseline/config/reader/assets/configure-a-port/valid-input.json delete mode 100644 crates/doc-example-config/baseline/config/reader/configure-a-port.md delete mode 100644 crates/doc-example-config/baseline/config/reader/manifest.json delete mode 100644 crates/doc-example-config/baseline/serde-json/contributor/.ownership.json delete mode 100644 crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/error-observation.json delete mode 100644 crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/formatted-list.json delete mode 100644 crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/mismatched-input.json delete mode 100644 crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/type-error.txt delete mode 100644 crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/valid-input.json delete mode 100644 crates/doc-example-config/baseline/serde-json/contributor/manifest.json delete mode 100644 crates/doc-example-config/baseline/serde-json/contributor/parse-a-typed-list.md delete mode 100644 crates/doc-example-config/baseline/serde-json/reader/.ownership.json delete mode 100644 crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/formatted-list.json delete mode 100644 crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/mismatched-input.json delete mode 100644 crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/type-error.txt delete mode 100644 crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/valid-input.json delete mode 100644 crates/doc-example-config/baseline/serde-json/reader/manifest.json delete mode 100644 crates/doc-example-config/baseline/serde-json/reader/parse-a-typed-list.md delete mode 100644 crates/doc-example-config/examples/docs.rs delete mode 100644 crates/doc-example-config/examples/serde_json.rs delete mode 100644 crates/doc-example-config/examples/support/mod.rs delete mode 100644 crates/doc-example-config/src/lib.rs delete mode 100644 crates/doc-example-config/src/main.rs delete mode 100644 crates/doc-example-config/tests/cli.rs delete mode 100644 crates/doc-example-config/tests/guide.rs delete mode 100644 crates/lgtmd-node/Cargo.toml delete mode 100644 crates/lgtmd-node/LICENSE delete mode 100644 crates/lgtmd-node/src/main.rs delete mode 100644 crates/lgtmd/.gitignore delete mode 100644 crates/lgtmd/Cargo.toml delete mode 100644 crates/lgtmd/LICENSE delete mode 100644 crates/lgtmd/README.md delete mode 100644 crates/lgtmd/dev/tests/test_verify.py delete mode 100644 crates/lgtmd/dev/verify.py delete mode 100644 crates/lgtmd/examples/configured.rs delete mode 100644 crates/lgtmd/examples/conformance.rs delete mode 100644 crates/lgtmd/examples/preview.rs delete mode 100644 crates/lgtmd/src/artifact.rs delete mode 100644 crates/lgtmd/src/authoring.rs delete mode 100644 crates/lgtmd/src/layout.rs delete mode 100644 crates/lgtmd/src/lib.rs delete mode 100644 crates/lgtmd/src/lifecycle.rs delete mode 100644 crates/lgtmd/src/manifest.rs delete mode 100644 crates/lgtmd/src/markdown.rs delete mode 100644 crates/lgtmd/src/model.rs delete mode 100644 crates/lgtmd/src/paths.rs delete mode 100644 crates/lgtmd/src/prose.rs delete mode 100644 crates/lgtmd/src/render.rs delete mode 100644 crates/lgtmd/src/runner.rs delete mode 100644 crates/lgtmd/src/store.rs delete mode 100644 crates/lgtmd/src/transport.rs delete mode 100644 crates/lgtmd/src/validation.rs delete mode 100644 crates/lgtmd/tests/acceptance.rs delete mode 100644 crates/lgtmd/tests/code_views.rs delete mode 100644 crates/lgtmd/tests/fixtures/portable.json delete mode 100644 crates/lgtmd/tests/json_review.rs delete mode 100644 crates/lgtmd/tests/layout.rs delete mode 100644 crates/lgtmd/tests/lifecycle.rs delete mode 100644 crates/lgtmd/tests/model.rs delete mode 100644 crates/lgtmd/tests/observations.rs delete mode 100644 crates/lgtmd/tests/prose.rs delete mode 100644 crates/lgtmd/tests/render_review.rs delete mode 100644 crates/lgtmd/tests/runner_aliases.rs delete mode 100644 crates/lgtmd/tests/runner_config.rs delete mode 100644 crates/lgtmd/tests/section_review.rs delete mode 100644 crates/lgtmd/tests/store.rs delete mode 100644 crates/lgtmd/tests/store_layout.rs delete mode 100644 crates/lgtmd/tests/transport.rs delete mode 100644 examples/lgtmd-web/.gitignore delete mode 100644 examples/lgtmd-web/README.md delete mode 100644 examples/lgtmd-web/browser/application-test.ts delete mode 100644 examples/lgtmd-web/browser/filter-records.spec.ts delete mode 100644 examples/lgtmd-web/guides/01-record-names.ts delete mode 100644 examples/lgtmd-web/guides/02-resources.ts delete mode 100644 examples/lgtmd-web/guides/shared.ts delete mode 100644 examples/lgtmd-web/guides/supplied-context.ts delete mode 100644 examples/lgtmd-web/index.html delete mode 100644 examples/lgtmd-web/package.json delete mode 100644 examples/lgtmd-web/playwright.config.ts delete mode 100644 examples/lgtmd-web/run.ts delete mode 100644 examples/lgtmd-web/server/index.mjs delete mode 100644 examples/lgtmd-web/src/App.tsx delete mode 100644 examples/lgtmd-web/src/main.tsx delete mode 100644 examples/lgtmd-web/src/records.ts delete mode 100644 examples/lgtmd-web/src/styles.css delete mode 100644 examples/lgtmd-web/tsconfig.json delete mode 100644 examples/lgtmd-web/vite.config.ts delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 packages/lgtmd/.gitignore delete mode 100644 packages/lgtmd/LICENSE delete mode 100644 packages/lgtmd/README.md delete mode 100644 packages/lgtmd/dev/tests/test_verify.py delete mode 100644 packages/lgtmd/dev/verify.py delete mode 100644 packages/lgtmd/package.json delete mode 100644 packages/lgtmd/src/authoring.ts delete mode 100644 packages/lgtmd/src/compiler.ts delete mode 100644 packages/lgtmd/src/index.ts delete mode 100644 packages/lgtmd/src/lifecycle.ts delete mode 100644 packages/lgtmd/src/model.ts delete mode 100644 packages/lgtmd/src/playwright/guard.ts delete mode 100644 packages/lgtmd/src/playwright/index.ts delete mode 100644 packages/lgtmd/src/playwright/reporter.ts delete mode 100644 packages/lgtmd/src/playwright/runner.ts delete mode 100644 packages/lgtmd/tests/authoring.test.ts delete mode 100644 packages/lgtmd/tests/compiler.test.ts delete mode 100644 packages/lgtmd/tests/lifecycle.test.ts delete mode 100644 packages/lgtmd/tests/playwright.test.ts delete mode 100644 packages/lgtmd/tsconfig.json diff --git a/.agents/skills/docs-driven-development/SKILL.md b/.agents/skills/docs-driven-development/SKILL.md index f6af715..29e6927 100644 --- a/.agents/skills/docs-driven-development/SKILL.md +++ b/.agents/skills/docs-driven-development/SKILL.md @@ -153,8 +153,10 @@ it does not establish cross-renderer pixel equivalence. Use `just test` for the full native suite and `just test-metal` for focused capture checks. For skill or hand-authored contributor-doc changes alone, validate instructions and links; unchanged app/media output does not require regeneration. -For portable framework changes, `just doc-framework-test` provides focused SDK -and consumer checks; N3 adapter changes still require native replay and `just verify`. +Portable framework changes belong in the +[lgtmd repository](https://github.com/softmarshmallow/lgtmd) and use its SDK and +consumer gates. N3 adapter or dependency revision changes require native replay +and `just verify` here. ## Evolve the harness when the user story needs it diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66cd3d0..3c10599 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,26 +11,6 @@ concurrency: cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: - lgtmd-web: - name: Portable lgtmd Node and Chromium contracts - runs-on: ubuntu-24.04 - timeout-minutes: 45 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "24" - package-manager-cache: false - - name: Install the declared Rust minimum and explicit browser dependencies - run: | - rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy - npm ci --no-audit --no-fund --cache .cache/npm - npm exec -- playwright install --with-deps chromium - - name: Verify independent sources and clean Cargo/npm consumers - run: python3 packages/lgtmd/dev/verify.py --standalone --toolchain 1.95.0 - ubuntu-checks: name: Ubuntu checks and focused regressions runs-on: ubuntu-24.04 @@ -54,6 +34,8 @@ jobs: path: | .cache/ci/linux-amd64/cargo/registry/cache .cache/ci/linux-amd64/cargo/registry/index + .cache/ci/linux-amd64/cargo/git/db + .cache/ci/linux-amd64/cargo/git/checkouts .cache/ci/linux-amd64/target/debug/deps .cache/ci/linux-amd64/target/debug/build .cache/ci/linux-amd64/target/debug/.fingerprint @@ -71,6 +53,8 @@ jobs: path: | .cache/ci/linux-amd64/cargo/registry/cache .cache/ci/linux-amd64/cargo/registry/index + .cache/ci/linux-amd64/cargo/git/db + .cache/ci/linux-amd64/cargo/git/checkouts .cache/ci/linux-amd64/target/debug/deps .cache/ci/linux-amd64/target/debug/build .cache/ci/linux-amd64/target/debug/.fingerprint @@ -110,6 +94,8 @@ jobs: path: | .cache/ci/linux-amd64/cargo/registry/cache .cache/ci/linux-amd64/cargo/registry/index + .cache/ci/linux-amd64/cargo/git/db + .cache/ci/linux-amd64/cargo/git/checkouts .cache/ci/linux-amd64/target/debug/deps .cache/ci/linux-amd64/target/debug/build .cache/ci/linux-amd64/target/debug/.fingerprint @@ -159,6 +145,8 @@ jobs: path: | .cache/cargo/registry/cache .cache/cargo/registry/index + .cache/cargo/git/db + .cache/cargo/git/checkouts target/debug/deps target/debug/build target/debug/.fingerprint @@ -184,6 +172,8 @@ jobs: path: | .cache/cargo/registry/cache .cache/cargo/registry/index + .cache/cargo/git/db + .cache/cargo/git/checkouts target/debug/deps target/debug/build target/debug/.fingerprint @@ -228,6 +218,8 @@ jobs: path: | .cache/cargo/registry/cache .cache/cargo/registry/index + .cache/cargo/git/db + .cache/cargo/git/checkouts target/debug/deps target/debug/build target/debug/.fingerprint @@ -279,6 +271,8 @@ jobs: path: | .cache/cargo/registry/cache .cache/cargo/registry/index + .cache/cargo/git/db + .cache/cargo/git/checkouts target/debug/deps target/debug/build target/debug/.fingerprint diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 607703a..c9935d6 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -6,12 +6,9 @@ "/TODO.md", "/Cargo.lock", "/docs/guide/**", - "/crates/doc-example-config/baseline/**", - "/crates/lgtmd/docs/guide/**", "/docs/preview/vendor/**", "/vendor/**", "/examples/**", - "!/examples/lgtmd-web/**", "/fixtures/gltf/*/**", "/fixtures/benchmarks/*/source/**" ] diff --git a/AGENTS.md b/AGENTS.md index 3fd0027..fecee10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,15 +69,14 @@ boundaries. The current Editor still depends on egui and is not a public kernel ## Code organization -Keep N3 as one application crate. The workspace also maintains the portable -`lgtmd` SDK in `crates/lgtmd` and its consumer examples in -`crates/doc-example-config`; N3's documentation adapter is a real SDK consumer. -This extraction is justified by a tested boundary between application replay and -document composition/publication. It does not make the editor a public kernel. -Node authoring is incubated in `packages/lgtmd`, using the thin `crates/lgtmd-node` -compiler/store bridge. `examples/lgtmd-web` owns its independent application and -fixtures; the SDK must never discover an N3 checkout or own application state. -Prefer focused modules over further speculative crates or APIs. See +Keep N3 as one application crate. Its documentation adapter consumes the portable +[lgtmd SDK](https://github.com/softmarshmallow/lgtmd), maintained in its own repository. +N3 pins an exact Git revision until a registry release is ready. The tested +boundary separates application replay from document composition and publication; +it does not make the editor a public kernel. Keep N3's replay, renderer, scenarios, +fixtures, and guide outputs here. SDK authoring, compiler/store changes, portable +Rust/Node examples, and SDK verification belong in lgtmd. Prefer focused modules +over further speculative crates or APIs. See [executable documents](docs/architecture/executable-documents.md) for this boundary. Read [platform boundaries and performance](docs/architecture/platform-boundaries.md) @@ -86,24 +85,19 @@ semantics, keep target selection at module boundaries, and preserve each host's execution policy. Browser constraints must not lower the primary native target's capabilities, performance policy, or verification baseline. -| Location | Responsibility | -| ---------------------------- | ----------------------------------------------------------------------------- | -| `src/model/` | Authored documents, units, native text codec, geometry evaluation | -| `src/asset_io/` | Format adapters, resource resolution, native document persistence | -| `src/scene/` | Internal imported scene model, validation and pose evaluation | -| `src/scene_view.rs` | Transient asset inspection and playback; separate from edit history | -| `src/editor/` | Selection, edits, sessions, history, transform policies | -| `src/input/` | Key mapping, semantic actions, pointer and navigation routing | -| `src/render/` | Camera, wgpu rendering, derived visual feedback | -| `src/ui/` | Panels, controls, gizmo, rulers | -| `src/native.rs` | Window lifecycle, native events, dialogs, filesystem effects | -| `src/settings/` | Typed global preferences, validation, merge policy, storage interface | -| `src/documentation/` | Headless replay, virtual input, annotations, capture, scenarios | -| `crates/lgtmd/` | Portable document composition, evidence, audience exports, artifact lifecycle | -| `crates/doc-example-config/` | Standalone CLI and library consumers of the documentation SDK | -| `crates/lgtmd-node/` | Coarse native compiler/store bridge for Node authoring | -| `packages/lgtmd/` | Node SDK, project-owned lifecycle, and optional Playwright integration | -| `examples/lgtmd-web/` | Independent web app, fixtures, and three authored example documents | +| Location | Responsibility | +| -------------------- | --------------------------------------------------------------------- | +| `src/model/` | Authored documents, units, native text codec, geometry evaluation | +| `src/asset_io/` | Format adapters, resource resolution, native document persistence | +| `src/scene/` | Internal imported scene model, validation and pose evaluation | +| `src/scene_view.rs` | Transient asset inspection and playback; separate from edit history | +| `src/editor/` | Selection, edits, sessions, history, transform policies | +| `src/input/` | Key mapping, semantic actions, pointer and navigation routing | +| `src/render/` | Camera, wgpu rendering, derived visual feedback | +| `src/ui/` | Panels, controls, gizmo, rulers | +| `src/native.rs` | Window lifecycle, native events, dialogs, filesystem effects | +| `src/settings/` | Typed global preferences, validation, merge policy, storage interface | +| `src/documentation/` | Headless replay, virtual input, annotations, capture, scenarios | Keep code, identifiers, comments, tests, logs, and authored UI strings in English. Explain non-obvious behavior and platform limitations near the owning code. @@ -212,11 +206,11 @@ For changes limited to hand-authored documentation such as this file or the README, check links and factual claims; unchanged application/media output does not require rebuilding the renderer or regenerating the guide. -For Node SDK or web adapter changes, also run `just doc-web-test --offline` after -the explicit `just doc-web-setup`. `just doc-web-smoke --offline` verifies portable -sources and installed Cargo/npm consumers independently on Rust 1.95. These gates -do not replace N3's native replay. Preserve the distinction between a fresh -candidate, repeated-run determinism, and an explicitly accepted project baseline. +Verify SDK or web adapter changes in the +[lgtmd repository](https://github.com/softmarshmallow/lgtmd). An N3 dependency +revision update still requires native `just verify`, including real replay and +exact guide checks. Keep dependency updates explicit; do not add local path +fallbacks or discover a sibling SDK checkout automatically. Use the root `justfile` for development commands (`just` 1.46 is the verified version). Useful commands from the repository root: diff --git a/Cargo.lock b/Cargo.lock index 5165ea6..a2b9c55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -553,16 +553,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "doc-example-config" -version = "0.0.0" -dependencies = [ - "lgtmd", - "serde", - "serde_json", - "tempfile", -] - [[package]] name = "document-features" version = "0.2.12" @@ -720,7 +710,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1366,22 +1356,13 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "lgtmd" version = "0.0.0" +source = "git+https://github.com/softmarshmallow/lgtmd?rev=8828c41eaf7fbb002044aea9f8bfd4a06ba76168#8828c41eaf7fbb002044aea9f8bfd4a06ba76168" dependencies = [ "pulldown-cmark", "same-file", "serde", "serde_json", "sha2", - "tempfile", -] - -[[package]] -name = "lgtmd-node" -version = "0.0.0" -dependencies = [ - "lgtmd", - "serde", - "serde_json", ] [[package]] @@ -1504,7 +1485,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2408,7 +2389,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2739,19 +2720,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix 1.1.5", - "windows-sys 0.61.2", -] - [[package]] name = "termcolor" version = "1.4.1" @@ -3478,7 +3446,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a97984e..c9ba632 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,11 +5,6 @@ edition = "2024" rust-version = "1.95" publish = false -[workspace] -members = ["crates/lgtmd", "crates/doc-example-config", "crates/lgtmd-node"] -default-members = ["."] -resolver = "3" - [features] # Contributor-only instrumentation; normal builds contain no clocks or recorder. viewport-measure = [] @@ -53,7 +48,7 @@ wgpu = "30.0.1" winit = "=0.30.13" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -lgtmd = { path = "crates/lgtmd" } +lgtmd = { git = "https://github.com/softmarshmallow/lgtmd", rev = "8828c41eaf7fbb002044aea9f8bfd4a06ba76168" } alacritty_terminal = "=0.26.0" egui-winit = { version = "=0.36.2", default-features = false, features = [ "clipboard", diff --git a/LTGMD-TODO.md b/LTGMD-TODO.md deleted file mode 100644 index a308822..0000000 --- a/LTGMD-TODO.md +++ /dev/null @@ -1,34 +0,0 @@ -# lgtmd TODO - -Carry this backlog with lgtmd when it moves to its dedicated repository. Items -record follow-up work; they are not claims of current support or completed setup. - -## 1. User-facing adoption guides and a complete server/client example - -- [ ] Write a user-facing guide explaining what lgtmd provides, what developers - author, what readers receive, setup requirements, limitations, and when adopting - it is worthwhile. Show the workflow from developing a feature and writing its - guide through reviewing generated output and checking an accepted baseline. -- [ ] Provide distinct paths for **new projects** and **existing projects**. For - new projects, recommend an **lgtmd-native setup for the best experience**: - establish executable guides, project-owned fixtures and state preparation, and - documentation checks from the start. Explain this as a development convention - using ordinary application and test tooling, with no required frontend/backend - topology or lgtmd dependency in the application's runtime. -- [ ] Treat existing-project adoption as the trickier path. Demonstrate starting - with one valuable workflow, reusing existing tests and fixtures, and adding - documentation incrementally. Explain the work that may be needed around - authentication, mocks or seeds, cleanup, selectors, and reproducible captures; - do not imply that installing the SDK solves these concerns. -- [ ] Develop a complete **server + client example working together** and use it - to answer the major practical adoption questions. Build on the current React + - Vite and HTTP API example. Show who starts each service, how the client reaches - the server, how scenario data is prepared and isolated, how behavior is checked - and documented, how cleanup works, and how the same workflow runs locally and - in CI. Explain the boundaries of mocked state and a seeded backend, including - what each example actually verifies. - -Application architecture, authentication, backend/database provisioning, mocks, -seeds, and deployment remain project responsibilities. The guide and examples -should make these integration choices understandable while keeping lgtmd's -authoring and lifecycle interfaces agnostic to the project's stack and topology. diff --git a/TODO.md b/TODO.md index d526888..c52a71a 100644 --- a/TODO.md +++ b/TODO.md @@ -18,7 +18,7 @@ and document the implemented behavior in the executable user guide. | Paired selection in 2D | **Deferred:** consider verified front/back vertex pairing in aligned views; exact geometry does not prove selection intent. See [RFC 0001](rfcs/0001-paired-selection-in-2d.md). | | Grouping and hierarchy entities | Add an explicit document entity for organizing objects; evaluate groups, collections, or folders, including nesting, selection, and shared transform semantics. | | Multiple authored scenes | **Draft; implementation deferred.** Independent N3 scenes in one document/editor. Revisit [RFC 0002](rfcs/0002-authored-scenes.md), starting with a small native-scene proof of switching, history/recovery, and persistence before broader import work. | -| Reusable executable documentation framework | **Rust/web incubation implemented locally:** portable Node authoring, shared Rust compiler/store, Playwright integration, and independent Cargo/npm checks. Extraction, release packaging, and crates.io consumption remain later gates. See [RFC 0004](rfcs/0004-lgtmd-web-incubation.md) and the existing [Rust framework](rfcs/0003-executable-documentation-framework.md). Independent adoption and 26 remaining N3 template migrations remain follow-ups. | +| Executable guide authoring | lgtmd has moved to its [own repository](https://github.com/softmarshmallow/lgtmd); N3 consumes an exact Git revision. Migrate the remaining 26 N3 templates through the existing adapter when scheduled, and switch to crates.io after an explicitly authorized SDK release. See [the boundary](docs/architecture/executable-documents.md) and historical [RFC 0004](rfcs/0004-lgtmd-web-incubation.md). | | 3D format I/O | Build on read-only glTF/GLB viewing: scene export/authoring, OBJ export, compression and advanced extensions; evaluate OpenUSD composition/schema support. See the [viewer milestone](docs/milestones/modern-scene-viewer.md). | | Materials and PBR | Extend imported PBR viewing into editable material assignment; HDR environment import, shadows, advanced materials and color management. See the [viewer milestone](docs/milestones/modern-scene-viewer.md). | | Node Canvas UI Kit | Establish reusable UI primitives for node blocks, ports, connections, selection, dragging, pan, and zoom. Validate interactions in isolated workbench examples so future editors can adopt them confidently when a concrete consumer exists. Scope this as UI foundation work; shader/modifier semantics and evaluation remain separate. | diff --git a/crates/doc-example-config/.gitignore b/crates/doc-example-config/.gitignore deleted file mode 100644 index ace0ac9..0000000 --- a/crates/doc-example-config/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Disposable Reader/Contributor review candidates. -/.cache/ -/target/ diff --git a/crates/doc-example-config/Cargo.toml b/crates/doc-example-config/Cargo.toml deleted file mode 100644 index c4837b7..0000000 --- a/crates/doc-example-config/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "doc-example-config" -version = "0.0.0" -edition = "2024" -rust-version = "1.95" -publish = false -license = "MIT" -description = "Executable documentation consumers for a configuration CLI and serde_json" -readme = "README.md" - -[lib] -name = "config_demo" - -[[bin]] -name = "config-demo" -path = "src/main.rs" - -[dependencies] -serde = { version = "=1.0.229", features = ["derive"] } -serde_json = "=1.0.151" - -[dev-dependencies] -lgtmd = { path = "../lgtmd" } -tempfile = "=3.27.0" diff --git a/crates/doc-example-config/LICENSE b/crates/doc-example-config/LICENSE deleted file mode 100644 index 943658a..0000000 --- a/crates/doc-example-config/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Grida - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/crates/doc-example-config/README.md b/crates/doc-example-config/README.md deleted file mode 100644 index 0fd4882..0000000 --- a/crates/doc-example-config/README.md +++ /dev/null @@ -1,142 +0,0 @@ -# Executable documentation consumers - -This package exercises the documentation SDK as a development dependency through -two concrete consumers: - -- A small configuration library and its real `config-demo` executable. Its guide - demonstrates saving valid input, rejecting an invalid replacement without - changing the output, and recovering after correction. -- The independently existing `serde_json` library. Its guide calls public APIs - to parse a typed list, inspect a type mismatch, correct the input, and serialize - the recovered values. - -The second consumer is our integration of an existing library. It does not -represent adoption or approval by the `serde_json` maintainers, and it changes no -upstream repository. - -Both guides are ordinary Rust programs containing multiline Markdown, typed -checked values, assertions, and captured resources. Each execution produces one -frozen document that can render into reader and contributor views. The contributor -view adds notes and private evidence to the same authored narrative. - -## Run the examples - -Use Rust 1.95 or later; the portable standalone gate verifies Rust 1.95.0. Keep -this package next to the SDK so the relative development dependency resolves: - -```text -crates/ - lgtmd/ - doc-example-config/ -``` - -No application renderer, graphics dependencies, service, or provider account is -required. From the `doc-example-config` directory: - -```sh -cargo build --bin config-demo -cargo run --example docs -- check --audience both -cargo run --example serde_json -- check --audience both -cargo test --all-targets -cargo clippy --all-targets -- -D warnings -``` - -The config guide finds `config-demo` beside the built example. For a separately -built executable, set `CONFIG_DEMO_BIN` to its path. Fixture files and subprocess -working directories are isolated temporary directories. The serde_json guide -needs no subprocess or fixture directory. -The configuration guide resolves the supplied CLI path before creating fixtures -or changing the subprocess directory. A missing executable fails with a useful -error even on emulated hosts that otherwise report only a child exit status. - -When a Cargo workspace supplies the lockfile, add `--locked` to these Cargo -commands. After dependencies are cached, `--offline` can verify both consumers -without network access. - -For the combined SDK and consumer development gate, run -`python3 ../lgtmd/dev/verify.py`. Its standalone mode copies both packages -and these retained baselines into a temporary workspace, independently resolving -only their dependencies. See the SDK's -[development instructions](../lgtmd/README.md#develop-and-verify-the-portable-packages) -for the minimum-toolchain check, caches, and formatting scope. - -## Review intentional documentation changes - -Generate a candidate into a new output directory, read both views and their -resources, then update the retained baseline only when the changes are intended: - -```sh -cargo run --example docs -- build --out .cache/config-review --audience both -cargo run --example serde_json -- build --out .cache/json-review --audience both - -cargo run --example docs -- update --audience both -cargo run --example serde_json -- update --audience both - -cargo run --example docs -- check --audience both -cargo run --example serde_json -- check --audience both -``` - -Choose a new candidate directory for each review; `build` preserves an existing -candidate by refusing to overwrite it. `check` is read-only. Each guide has its -own package-local baseline: - -| Guide | Authored source | Baseline | -| ----------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| Configuration CLI | [Runner](examples/docs.rs) and [complete narrative and helpers](examples/support/mod.rs) | [Reader](baseline/config/reader/configure-a-port.md), [contributor](baseline/config/contributor/configure-a-port.md) | -| Typed JSON list | [Single example source](examples/serde_json.rs) | [Reader](baseline/serde-json/reader/parse-a-typed-list.md), [contributor](baseline/serde-json/contributor/parse-a-typed-list.md) | - -The configuration baseline was carried forward from the reviewed implementation -without changing its document or resource bytes. The tests check both retained -views for both consumers, repeat generation for determinism, and ensure private -contributor evidence stays out of the reader output. - -## Authoring cost and boundaries - -Measured after Rust formatting, including blank lines and comments: - -| Consumer | Complete executable authoring | Tests kept in the example | -| ----------------- | ----------------------------------------------------------------------------------: | -----------------------------: | -| Configuration CLI | 241 lines: 19 runner + 222 narrative, fixture, subprocess, and capture helper lines | 0; separate integration tests | -| serde_json | 147 lines: runner, narrative, real API calls, checks, and capture helpers | 32 additional lines; 179 total | - -There are no separate Markdown templates or hidden scenario files. The complete -example source total is 420 lines including the serde_json example's tests. The -configuration application's implementation and its separate tests are additional -code, not part of those authoring counts. - -The CLI consumer has more setup because it runs an executable and checks actual -filesystem effects. The library consumer calls existing APIs directly and needs -less infrastructure. This comparison demonstrates two integration shapes; it is -not a study of outside developers' onboarding effort. - -`Doc::resource_code` combines resource registration and placement when they occur -together. Reused resources keep a typed handle. Captured library output retains -its original bytes and producer profile; input fixtures use a distinct producer. -The diagnostic record created by `Doc::json` belongs only to contributor notes. - -The framework checks the encoded claims and retained output. These examples do -not prove that every possible input works or that every sentence is automatically -verified. Review the explanation as well as the assertions and resource changes. - -## Library provenance - -The serde_json example uses the pinned `serde_json` 1.0.151 dependency through -its public API. Its behavior is grounded in the library's official documentation: - -- [`from_str`](https://docs.rs/serde_json/1.0.151/serde_json/fn.from_str.html) - deserializes text into the requested Rust type and reports incompatible input. -- [`Error`](https://docs.rs/serde_json/1.0.151/serde_json/struct.Error.html) - exposes error categories and source locations. The guide observes a `Data` - error from a string where `u32` is expected. -- [`to_string_pretty`](https://docs.rs/serde_json/1.0.151/serde_json/fn.to_string_pretty.html) - serializes the recovered vector into formatted JSON. The guide reparses that - actual output and checks that the values are preserved. - -All narrative and integration code in these examples is authored here. The guide -captures the library's actual error message and formatted result; no upstream -guide or example source was copied. - -This package's code and documentation use the repository's MIT license, copied -verbatim into [LICENSE](LICENSE) with the original `Copyright (c) 2026 Grida` -notice. Dependency code is not vendored, and upstream dependency terms remain -unchanged. diff --git a/crates/doc-example-config/baseline/config/contributor/.ownership.json b/crates/doc-example-config/baseline/config/contributor/.ownership.json deleted file mode 100644 index 620396e..0000000 --- a/crates/doc-example-config/baseline/config/contributor/.ownership.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "schema_version": 1, - "files": [ - "assets/configure-a-port/execution-checks.json", - "assets/configure-a-port/invalid-input.json", - "assets/configure-a-port/recovered-command.txt", - "assets/configure-a-port/rejected-command.txt", - "assets/configure-a-port/saved-configuration.json", - "assets/configure-a-port/successful-command.txt", - "assets/configure-a-port/valid-input.json", - "configure-a-port.md", - "manifest.json" - ] -} \ No newline at end of file diff --git a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/execution-checks.json b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/execution-checks.json deleted file mode 100644 index d303a7f..0000000 --- a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/execution-checks.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "driver": "actual config-demo subprocess", - "recovered_port": 8080, - "recovery_exit": 0, - "rejected_exit": 2, - "rejected_output_unchanged": true, - "saved_port": 9090, - "success_exit": 0 -} diff --git a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/invalid-input.json b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/invalid-input.json deleted file mode 100644 index 2e086da..0000000 --- a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/invalid-input.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "port": 0 -} diff --git a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/recovered-command.txt b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/recovered-command.txt deleted file mode 100644 index a688bc2..0000000 --- a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/recovered-command.txt +++ /dev/null @@ -1,3 +0,0 @@ -$ config-demo input.json --output settings.json -Saved validated configuration (port 8080) to settings.json. -[exit 0] diff --git a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/rejected-command.txt b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/rejected-command.txt deleted file mode 100644 index 41058b6..0000000 --- a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/rejected-command.txt +++ /dev/null @@ -1,3 +0,0 @@ -$ config-demo input.json --output settings.json -Configuration rejected: port must be between 1 and 65535 (received 0). -[exit 2] diff --git a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/saved-configuration.json b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/saved-configuration.json deleted file mode 100644 index a13d52b..0000000 --- a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/saved-configuration.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "port": 9090 -} diff --git a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/successful-command.txt b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/successful-command.txt deleted file mode 100644 index d3e999d..0000000 --- a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/successful-command.txt +++ /dev/null @@ -1,3 +0,0 @@ -$ config-demo input.json --output settings.json -Saved validated configuration (port 9090) to settings.json. -[exit 0] diff --git a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/valid-input.json b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/valid-input.json deleted file mode 100644 index a13d52b..0000000 --- a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/valid-input.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "port": 9090 -} diff --git a/crates/doc-example-config/baseline/config/contributor/configure-a-port.md b/crates/doc-example-config/baseline/config/contributor/configure-a-port.md deleted file mode 100644 index b746d63..0000000 --- a/crates/doc-example-config/baseline/config/contributor/configure-a-port.md +++ /dev/null @@ -1,129 +0,0 @@ -# Save a valid configuration - - - -Use **config-demo** to validate a JSON configuration before saving it. -The command reports a useful error when a value is invalid and keeps the last -valid output available while you correct the input. - -This walkthrough shows how to: - -- Save a valid configuration. -- Reject an invalid replacement without changing the saved file. -- Correct the input and retry successfully. - -Run the commands from a scratch directory so that `input.json` and -`settings.json` stay together. - - - -The configuration contains one setting: port\. Choose an integer from 1 to 65535\. The Rust API's default configuration uses port 8080\. The command line reads an explicit configuration file; it does not fill in a missing port\. - - - -> **Contributor note:** This guide exercises the public Config API and the built config\-demo executable in an isolated temporary directory\. No server is started; the example checks configuration behavior\. - - - -## Save a configuration - - - -Create input\.json with this content: - - - -```json -{ - "port": 9090 -} -``` - - - -Pass the input file and an output path\. A successful command saves the validated configuration: - - - -```text -$ config-demo input.json --output settings.json -Saved validated configuration (port 9090) to settings.json. -[exit 0] -``` - - - -The saved settings\.json now contains port 9090\. Its contents are shown below, and you can also open the [saved\-configuration](assets/configure-a-port/saved-configuration.json) file directly\. - - - -```json -{ - "port": 9090 -} -``` - - - -## Reject an invalid replacement - - - -A rejected input leaves an existing output unchanged\. To see this, replace input\.json with an invalid port: - - - -```json -{ - "port": 0 -} -``` - - - -Run the same command again\. The error explains which value needs correction: - - - -```text -$ config-demo input.json --output settings.json -Configuration rejected: port must be between 1 and 65535 (received 0). -[exit 2] -``` - - - -The command exits unsuccessfully and preserves every byte of settings\.json\. The [saved\-configuration](assets/configure-a-port/saved-configuration.json) from the first command is still the current configuration\. - - - -> **Contributor note on `rejected-exit`:** The CLI's rejection status is 2\. The scenario also checks that rejected input produces no success output\. - - - -> **Contributor note on `rejected-output-unchanged`:** The preservation check compares the complete output bytes before and after the rejected subprocess invocation\. - - - -## Correct the input and retry - - - -Change the port in input\.json to 8080 and rerun the command\. The same output path can now be replaced with a valid configuration: - - - -```text -$ config-demo input.json --output settings.json -Saved validated configuration (port 8080) to settings.json. -[exit 0] -``` - - - -The output now uses port 8080\. When validation fails, correct the input and retry; the last valid output remains available until the correction succeeds\. - - - -> **Contributor note on [rejected\-command](assets/configure-a-port/rejected-command.txt):** The transcript comes from the actual command's stdout, stderr, and exit status\. Review the [execution\-checks](assets/configure-a-port/execution-checks.json) for the recorded outcomes\. This contributor evidence is attached to the same reader artifact\. - diff --git a/crates/doc-example-config/baseline/config/contributor/manifest.json b/crates/doc-example-config/baseline/config/contributor/manifest.json deleted file mode 100644 index a525e98..0000000 --- a/crates/doc-example-config/baseline/config/contributor/manifest.json +++ /dev/null @@ -1,380 +0,0 @@ -{ - "artifacts": [ - { - "bytes": 198, - "document": "configure-a-port", - "id": "execution-checks", - "kind": "resource", - "mime": "application/json", - "path": "assets/configure-a-port/execution-checks.json", - "producer": "executable-docs/json", - "profile": "json-pretty-v1", - "sha256": "92bf03ab28d9bb016c92d37c2164782c148c639ff1e2853f7b3e9e460a6b245a" - }, - { - "bytes": 16, - "document": "configure-a-port", - "id": "invalid-input", - "kind": "resource", - "mime": "application/json", - "path": "assets/configure-a-port/invalid-input.json", - "producer": "config-demo", - "profile": "json-v1", - "sha256": "4f52f2c12102a1a7ee628a1c571d542080f9042ad41fe10a15073b4c67c9f6af" - }, - { - "bytes": 117, - "document": "configure-a-port", - "id": "recovered-command", - "kind": "resource", - "mime": "text/plain", - "path": "assets/configure-a-port/recovered-command.txt", - "producer": "config-demo-cli", - "profile": "transcript-v1", - "sha256": "60d4e34cb43759aededaee90a39a39f9758f9658670ba0f965bfb29f8aad9829" - }, - { - "bytes": 128, - "document": "configure-a-port", - "id": "rejected-command", - "kind": "resource", - "mime": "text/plain", - "path": "assets/configure-a-port/rejected-command.txt", - "producer": "config-demo-cli", - "profile": "transcript-v1", - "sha256": "b7e21baceed98258b963c4c72ad1874476a01890501d94c6c92b6a4ae7c93dc3" - }, - { - "bytes": 19, - "document": "configure-a-port", - "id": "saved-configuration", - "kind": "resource", - "mime": "application/json", - "path": "assets/configure-a-port/saved-configuration.json", - "producer": "config-demo", - "profile": "json-v1", - "sha256": "bbcbff1eebf2fbca8dd0f1a4023d4953f70488a4a2d54a5e8d229e3727618ff2" - }, - { - "bytes": 117, - "document": "configure-a-port", - "id": "successful-command", - "kind": "resource", - "mime": "text/plain", - "path": "assets/configure-a-port/successful-command.txt", - "producer": "config-demo-cli", - "profile": "transcript-v1", - "sha256": "a98fc8b82913db7ed8c07955e1dcdcad55697afa2d1ae1605349c435e5553eb5" - }, - { - "bytes": 19, - "document": "configure-a-port", - "id": "valid-input", - "kind": "resource", - "mime": "application/json", - "path": "assets/configure-a-port/valid-input.json", - "producer": "config-demo", - "profile": "json-v1", - "sha256": "bbcbff1eebf2fbca8dd0f1a4023d4953f70488a4a2d54a5e8d229e3727618ff2" - }, - { - "bytes": 3796, - "document": "configure-a-port", - "kind": "page", - "mime": "text/markdown", - "path": "configure-a-port.md", - "sha256": "eec0ab6442e553848b7a4f7b87edc8ed7db1eeeb1c605cb7fc1a3de011b233f4" - } - ], - "audience": "contributor", - "documents": [ - { - "blocks": [ - { - "id": "block-0001", - "kind": "markdown", - "references": [] - }, - { - "id": "block-0002", - "kind": "paragraph", - "references": [ - { - "id": "default-port", - "kind": "value" - } - ] - }, - { - "id": "note-0001", - "kind": "note", - "references": [] - }, - { - "id": "block-0003", - "kind": "heading", - "references": [] - }, - { - "id": "block-0004", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0005", - "kind": "code", - "references": [ - { - "id": "valid-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0006", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0007", - "kind": "code", - "references": [ - { - "id": "successful-command", - "kind": "resource" - } - ] - }, - { - "id": "block-0008", - "kind": "paragraph", - "references": [ - { - "id": "saved-port", - "kind": "value" - }, - { - "id": "saved-configuration", - "kind": "resource" - } - ] - }, - { - "id": "block-0009", - "kind": "code", - "references": [ - { - "id": "saved-configuration", - "kind": "resource" - } - ] - }, - { - "id": "block-0010", - "kind": "heading", - "references": [] - }, - { - "id": "block-0011", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0012", - "kind": "code", - "references": [ - { - "id": "invalid-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0013", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0014", - "kind": "code", - "references": [ - { - "id": "rejected-command", - "kind": "resource" - } - ] - }, - { - "id": "block-0015", - "kind": "paragraph", - "references": [ - { - "id": "saved-configuration", - "kind": "resource" - } - ] - }, - { - "id": "note-0002", - "kind": "note", - "references": [ - { - "id": "rejected-exit", - "kind": "value" - } - ] - }, - { - "id": "note-0003", - "kind": "note", - "references": [ - { - "id": "rejected-output-unchanged", - "kind": "claim" - } - ] - }, - { - "id": "block-0016", - "kind": "heading", - "references": [] - }, - { - "id": "block-0017", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0018", - "kind": "code", - "references": [ - { - "id": "recovered-command", - "kind": "resource" - } - ] - }, - { - "id": "block-0019", - "kind": "paragraph", - "references": [ - { - "id": "recovered-port", - "kind": "value" - } - ] - }, - { - "id": "note-0004", - "kind": "note", - "references": [ - { - "id": "execution-checks", - "kind": "resource" - }, - { - "id": "rejected-command", - "kind": "resource" - } - ] - } - ], - "checks": [ - { - "actual": 8080, - "expected": 8080, - "id": "default-port", - "kind": "equality", - "passed": true - }, - { - "actual": 8080, - "expected": 8080, - "id": "recovered-port", - "kind": "equality", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "recovery-exit", - "kind": "predicate", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "recovery-stderr-empty", - "kind": "predicate", - "passed": true - }, - { - "actual": 2, - "expected": 2, - "id": "rejected-exit", - "kind": "equality", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "rejected-output-unchanged", - "kind": "predicate", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "rejected-stdout-empty", - "kind": "predicate", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "rejection-explains-port-range", - "kind": "predicate", - "passed": true - }, - { - "actual": 9090, - "expected": 9090, - "id": "saved-port", - "kind": "equality", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "successful-exit", - "kind": "predicate", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "successful-stderr-empty", - "kind": "predicate", - "passed": true - } - ], - "id": "configure-a-port", - "title": "Save a valid configuration" - } - ], - "profiles": [ - { - "producer": "config-demo", - "profile": "json-v1" - }, - { - "producer": "config-demo-cli", - "profile": "transcript-v1" - }, - { - "producer": "executable-docs/json", - "profile": "json-pretty-v1" - } - ], - "schema_version": 1 -} diff --git a/crates/doc-example-config/baseline/config/reader/.ownership.json b/crates/doc-example-config/baseline/config/reader/.ownership.json deleted file mode 100644 index 61c1c53..0000000 --- a/crates/doc-example-config/baseline/config/reader/.ownership.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "schema_version": 1, - "files": [ - "assets/configure-a-port/invalid-input.json", - "assets/configure-a-port/recovered-command.txt", - "assets/configure-a-port/rejected-command.txt", - "assets/configure-a-port/saved-configuration.json", - "assets/configure-a-port/successful-command.txt", - "assets/configure-a-port/valid-input.json", - "configure-a-port.md", - "manifest.json" - ] -} \ No newline at end of file diff --git a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/invalid-input.json b/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/invalid-input.json deleted file mode 100644 index 2e086da..0000000 --- a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/invalid-input.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "port": 0 -} diff --git a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/recovered-command.txt b/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/recovered-command.txt deleted file mode 100644 index a688bc2..0000000 --- a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/recovered-command.txt +++ /dev/null @@ -1,3 +0,0 @@ -$ config-demo input.json --output settings.json -Saved validated configuration (port 8080) to settings.json. -[exit 0] diff --git a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/rejected-command.txt b/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/rejected-command.txt deleted file mode 100644 index 41058b6..0000000 --- a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/rejected-command.txt +++ /dev/null @@ -1,3 +0,0 @@ -$ config-demo input.json --output settings.json -Configuration rejected: port must be between 1 and 65535 (received 0). -[exit 2] diff --git a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/saved-configuration.json b/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/saved-configuration.json deleted file mode 100644 index a13d52b..0000000 --- a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/saved-configuration.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "port": 9090 -} diff --git a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/successful-command.txt b/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/successful-command.txt deleted file mode 100644 index d3e999d..0000000 --- a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/successful-command.txt +++ /dev/null @@ -1,3 +0,0 @@ -$ config-demo input.json --output settings.json -Saved validated configuration (port 9090) to settings.json. -[exit 0] diff --git a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/valid-input.json b/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/valid-input.json deleted file mode 100644 index a13d52b..0000000 --- a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/valid-input.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "port": 9090 -} diff --git a/crates/doc-example-config/baseline/config/reader/configure-a-port.md b/crates/doc-example-config/baseline/config/reader/configure-a-port.md deleted file mode 100644 index 98276d6..0000000 --- a/crates/doc-example-config/baseline/config/reader/configure-a-port.md +++ /dev/null @@ -1,113 +0,0 @@ -# Save a valid configuration - - - -Use **config-demo** to validate a JSON configuration before saving it. -The command reports a useful error when a value is invalid and keeps the last -valid output available while you correct the input. - -This walkthrough shows how to: - -- Save a valid configuration. -- Reject an invalid replacement without changing the saved file. -- Correct the input and retry successfully. - -Run the commands from a scratch directory so that `input.json` and -`settings.json` stay together. - - - -The configuration contains one setting: port\. Choose an integer from 1 to 65535\. The Rust API's default configuration uses port 8080\. The command line reads an explicit configuration file; it does not fill in a missing port\. - - - -## Save a configuration - - - -Create input\.json with this content: - - - -```json -{ - "port": 9090 -} -``` - - - -Pass the input file and an output path\. A successful command saves the validated configuration: - - - -```text -$ config-demo input.json --output settings.json -Saved validated configuration (port 9090) to settings.json. -[exit 0] -``` - - - -The saved settings\.json now contains port 9090\. Its contents are shown below, and you can also open the [saved\-configuration](assets/configure-a-port/saved-configuration.json) file directly\. - - - -```json -{ - "port": 9090 -} -``` - - - -## Reject an invalid replacement - - - -A rejected input leaves an existing output unchanged\. To see this, replace input\.json with an invalid port: - - - -```json -{ - "port": 0 -} -``` - - - -Run the same command again\. The error explains which value needs correction: - - - -```text -$ config-demo input.json --output settings.json -Configuration rejected: port must be between 1 and 65535 (received 0). -[exit 2] -``` - - - -The command exits unsuccessfully and preserves every byte of settings\.json\. The [saved\-configuration](assets/configure-a-port/saved-configuration.json) from the first command is still the current configuration\. - - - -## Correct the input and retry - - - -Change the port in input\.json to 8080 and rerun the command\. The same output path can now be replaced with a valid configuration: - - - -```text -$ config-demo input.json --output settings.json -Saved validated configuration (port 8080) to settings.json. -[exit 0] -``` - - - -The output now uses port 8080\. When validation fails, correct the input and retry; the last valid output remains available until the correction succeeds\. - diff --git a/crates/doc-example-config/baseline/config/reader/manifest.json b/crates/doc-example-config/baseline/config/reader/manifest.json deleted file mode 100644 index 33d30df..0000000 --- a/crates/doc-example-config/baseline/config/reader/manifest.json +++ /dev/null @@ -1,310 +0,0 @@ -{ - "artifacts": [ - { - "bytes": 16, - "document": "configure-a-port", - "id": "invalid-input", - "kind": "resource", - "mime": "application/json", - "path": "assets/configure-a-port/invalid-input.json", - "producer": "config-demo", - "profile": "json-v1", - "sha256": "4f52f2c12102a1a7ee628a1c571d542080f9042ad41fe10a15073b4c67c9f6af" - }, - { - "bytes": 117, - "document": "configure-a-port", - "id": "recovered-command", - "kind": "resource", - "mime": "text/plain", - "path": "assets/configure-a-port/recovered-command.txt", - "producer": "config-demo-cli", - "profile": "transcript-v1", - "sha256": "60d4e34cb43759aededaee90a39a39f9758f9658670ba0f965bfb29f8aad9829" - }, - { - "bytes": 128, - "document": "configure-a-port", - "id": "rejected-command", - "kind": "resource", - "mime": "text/plain", - "path": "assets/configure-a-port/rejected-command.txt", - "producer": "config-demo-cli", - "profile": "transcript-v1", - "sha256": "b7e21baceed98258b963c4c72ad1874476a01890501d94c6c92b6a4ae7c93dc3" - }, - { - "bytes": 19, - "document": "configure-a-port", - "id": "saved-configuration", - "kind": "resource", - "mime": "application/json", - "path": "assets/configure-a-port/saved-configuration.json", - "producer": "config-demo", - "profile": "json-v1", - "sha256": "bbcbff1eebf2fbca8dd0f1a4023d4953f70488a4a2d54a5e8d229e3727618ff2" - }, - { - "bytes": 117, - "document": "configure-a-port", - "id": "successful-command", - "kind": "resource", - "mime": "text/plain", - "path": "assets/configure-a-port/successful-command.txt", - "producer": "config-demo-cli", - "profile": "transcript-v1", - "sha256": "a98fc8b82913db7ed8c07955e1dcdcad55697afa2d1ae1605349c435e5553eb5" - }, - { - "bytes": 19, - "document": "configure-a-port", - "id": "valid-input", - "kind": "resource", - "mime": "application/json", - "path": "assets/configure-a-port/valid-input.json", - "producer": "config-demo", - "profile": "json-v1", - "sha256": "bbcbff1eebf2fbca8dd0f1a4023d4953f70488a4a2d54a5e8d229e3727618ff2" - }, - { - "bytes": 2822, - "document": "configure-a-port", - "kind": "page", - "mime": "text/markdown", - "path": "configure-a-port.md", - "sha256": "7ce900dc5b90d729cd3808d32ae17c4d032de4e2c250ffa50a4a7f3d6a8a1a8b" - } - ], - "audience": "reader", - "documents": [ - { - "blocks": [ - { - "id": "block-0001", - "kind": "markdown", - "references": [] - }, - { - "id": "block-0002", - "kind": "paragraph", - "references": [ - { - "id": "default-port", - "kind": "value" - } - ] - }, - { - "id": "block-0003", - "kind": "heading", - "references": [] - }, - { - "id": "block-0004", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0005", - "kind": "code", - "references": [ - { - "id": "valid-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0006", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0007", - "kind": "code", - "references": [ - { - "id": "successful-command", - "kind": "resource" - } - ] - }, - { - "id": "block-0008", - "kind": "paragraph", - "references": [ - { - "id": "saved-port", - "kind": "value" - }, - { - "id": "saved-configuration", - "kind": "resource" - } - ] - }, - { - "id": "block-0009", - "kind": "code", - "references": [ - { - "id": "saved-configuration", - "kind": "resource" - } - ] - }, - { - "id": "block-0010", - "kind": "heading", - "references": [] - }, - { - "id": "block-0011", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0012", - "kind": "code", - "references": [ - { - "id": "invalid-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0013", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0014", - "kind": "code", - "references": [ - { - "id": "rejected-command", - "kind": "resource" - } - ] - }, - { - "id": "block-0015", - "kind": "paragraph", - "references": [ - { - "id": "saved-configuration", - "kind": "resource" - } - ] - }, - { - "id": "block-0016", - "kind": "heading", - "references": [] - }, - { - "id": "block-0017", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0018", - "kind": "code", - "references": [ - { - "id": "recovered-command", - "kind": "resource" - } - ] - }, - { - "id": "block-0019", - "kind": "paragraph", - "references": [ - { - "id": "recovered-port", - "kind": "value" - } - ] - } - ], - "checks": [ - { - "actual": 8080, - "expected": 8080, - "id": "default-port", - "kind": "equality", - "passed": true - }, - { - "actual": 8080, - "expected": 8080, - "id": "recovered-port", - "kind": "equality", - "passed": true - }, - { - "id": "recovery-exit", - "kind": "predicate", - "passed": true - }, - { - "id": "recovery-stderr-empty", - "kind": "predicate", - "passed": true - }, - { - "id": "rejected-exit", - "kind": "equality", - "passed": true - }, - { - "id": "rejected-output-unchanged", - "kind": "predicate", - "passed": true - }, - { - "id": "rejected-stdout-empty", - "kind": "predicate", - "passed": true - }, - { - "id": "rejection-explains-port-range", - "kind": "predicate", - "passed": true - }, - { - "actual": 9090, - "expected": 9090, - "id": "saved-port", - "kind": "equality", - "passed": true - }, - { - "id": "successful-exit", - "kind": "predicate", - "passed": true - }, - { - "id": "successful-stderr-empty", - "kind": "predicate", - "passed": true - } - ], - "id": "configure-a-port", - "title": "Save a valid configuration" - } - ], - "profiles": [ - { - "producer": "config-demo", - "profile": "json-v1" - }, - { - "producer": "config-demo-cli", - "profile": "transcript-v1" - } - ], - "schema_version": 1 -} diff --git a/crates/doc-example-config/baseline/serde-json/contributor/.ownership.json b/crates/doc-example-config/baseline/serde-json/contributor/.ownership.json deleted file mode 100644 index a2c5e90..0000000 --- a/crates/doc-example-config/baseline/serde-json/contributor/.ownership.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "schema_version": 1, - "files": [ - "assets/parse-a-typed-list/error-observation.json", - "assets/parse-a-typed-list/formatted-list.json", - "assets/parse-a-typed-list/mismatched-input.json", - "assets/parse-a-typed-list/type-error.txt", - "assets/parse-a-typed-list/valid-input.json", - "manifest.json", - "parse-a-typed-list.md" - ] -} \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/error-observation.json b/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/error-observation.json deleted file mode 100644 index 67baec7..0000000 --- a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/error-observation.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "category": "Data", - "column": 11, - "line": 1, - "message": "invalid type: string \"three\", expected u32 at line 1 column 11" -} diff --git a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/formatted-list.json b/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/formatted-list.json deleted file mode 100644 index aa50563..0000000 --- a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/formatted-list.json +++ /dev/null @@ -1,5 +0,0 @@ -[ - 2, - 3, - 5 -] \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/mismatched-input.json b/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/mismatched-input.json deleted file mode 100644 index a47bcf6..0000000 --- a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/mismatched-input.json +++ /dev/null @@ -1 +0,0 @@ -[2, "three", 5] \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/type-error.txt b/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/type-error.txt deleted file mode 100644 index 8da8dd3..0000000 --- a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/type-error.txt +++ /dev/null @@ -1 +0,0 @@ -invalid type: string "three", expected u32 at line 1 column 11 \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/valid-input.json b/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/valid-input.json deleted file mode 100644 index 3ec03bc..0000000 --- a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/valid-input.json +++ /dev/null @@ -1 +0,0 @@ -[2, 3, 5] \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/contributor/manifest.json b/crates/doc-example-config/baseline/serde-json/contributor/manifest.json deleted file mode 100644 index 8cb137a..0000000 --- a/crates/doc-example-config/baseline/serde-json/contributor/manifest.json +++ /dev/null @@ -1,314 +0,0 @@ -{ - "artifacts": [ - { - "bytes": 135, - "document": "parse-a-typed-list", - "id": "error-observation", - "kind": "resource", - "mime": "application/json", - "path": "assets/parse-a-typed-list/error-observation.json", - "producer": "executable-docs/json", - "profile": "json-pretty-v1", - "sha256": "ceb55f83bc7cfa0f01ca0bf120ec95581583c60eb741b388b83f80e2740b85fa" - }, - { - "bytes": 17, - "document": "parse-a-typed-list", - "id": "formatted-list", - "kind": "resource", - "mime": "application/json", - "path": "assets/parse-a-typed-list/formatted-list.json", - "producer": "serde_json", - "profile": "serde-json-1.0.151", - "sha256": "80c83a54da142957b5a2b7b8ec8f8396d6cdb2daee1848129b19dba82c88c909" - }, - { - "bytes": 15, - "document": "parse-a-typed-list", - "id": "mismatched-input", - "kind": "resource", - "mime": "application/json", - "path": "assets/parse-a-typed-list/mismatched-input.json", - "producer": "serde-json-guide", - "profile": "json-fixture-v1", - "sha256": "e14fac803c516c4f81b9af6ac11db65e751802c0c495db102f14aa9df774458b" - }, - { - "bytes": 62, - "document": "parse-a-typed-list", - "id": "type-error", - "kind": "resource", - "mime": "text/plain", - "path": "assets/parse-a-typed-list/type-error.txt", - "producer": "serde_json", - "profile": "serde-json-1.0.151", - "sha256": "755ae51b66058706677805b97a062f14ecd1895103bd1dc942782a097aa7acd6" - }, - { - "bytes": 9, - "document": "parse-a-typed-list", - "id": "valid-input", - "kind": "resource", - "mime": "application/json", - "path": "assets/parse-a-typed-list/valid-input.json", - "producer": "serde-json-guide", - "profile": "json-fixture-v1", - "sha256": "40d28069123e2a7d89dd143fd210b66f8ed7fb19ebfc23032c051f4f6aed0381" - }, - { - "bytes": 2904, - "document": "parse-a-typed-list", - "kind": "page", - "mime": "text/markdown", - "path": "parse-a-typed-list.md", - "sha256": "1d31773366db1e8979454d03c15026a3abdee6a5d32f22a6cc5234a14fc82802" - } - ], - "audience": "contributor", - "documents": [ - { - "blocks": [ - { - "id": "block-0001", - "kind": "markdown", - "references": [] - }, - { - "id": "block-0002", - "kind": "heading", - "references": [] - }, - { - "id": "block-0003", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0004", - "kind": "code", - "references": [ - { - "id": "valid-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0005", - "kind": "paragraph", - "references": [ - { - "id": "parsed-length", - "kind": "value" - }, - { - "id": "parsed-sum", - "kind": "value" - } - ] - }, - { - "id": "block-0006", - "kind": "heading", - "references": [] - }, - { - "id": "block-0007", - "kind": "markdown", - "references": [] - }, - { - "id": "block-0008", - "kind": "code", - "references": [ - { - "id": "mismatched-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0009", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0010", - "kind": "code", - "references": [ - { - "id": "type-error", - "kind": "resource" - } - ] - }, - { - "id": "block-0011", - "kind": "paragraph", - "references": [ - { - "id": "error-line", - "kind": "value" - }, - { - "id": "error-column", - "kind": "value" - } - ] - }, - { - "id": "note-0001", - "kind": "note", - "references": [ - { - "id": "error-observation", - "kind": "resource" - }, - { - "id": "type-error", - "kind": "resource" - } - ] - }, - { - "id": "block-0012", - "kind": "heading", - "references": [] - }, - { - "id": "block-0013", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0014", - "kind": "code", - "references": [ - { - "id": "valid-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0015", - "kind": "paragraph", - "references": [ - { - "id": "corrected-values", - "kind": "value" - } - ] - }, - { - "id": "block-0016", - "kind": "code", - "references": [ - { - "id": "formatted-list", - "kind": "resource" - } - ] - }, - { - "id": "block-0017", - "kind": "paragraph", - "references": [ - { - "id": "formatted-list", - "kind": "resource" - } - ] - }, - { - "id": "note-0002", - "kind": "note", - "references": [] - } - ], - "checks": [ - { - "actual": [ - 2, - 3, - 5 - ], - "expected": [ - 2, - 3, - 5 - ], - "id": "corrected-values", - "kind": "equality", - "passed": true - }, - { - "actual": 11, - "expected": 11, - "id": "error-column", - "kind": "equality", - "passed": true - }, - { - "actual": 1, - "expected": 1, - "id": "error-line", - "kind": "equality", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "mismatched-input-has-valid-json-syntax", - "kind": "predicate", - "passed": true - }, - { - "actual": 3, - "expected": 3, - "id": "parsed-length", - "kind": "equality", - "passed": true - }, - { - "actual": 10, - "expected": 10, - "id": "parsed-sum", - "kind": "equality", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "pretty-json-preserves-values", - "kind": "predicate", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "type-mismatch-is-data-error", - "kind": "predicate", - "passed": true - } - ], - "id": "parse-a-typed-list", - "title": "Parse a typed JSON list" - } - ], - "profiles": [ - { - "producer": "executable-docs/json", - "profile": "json-pretty-v1" - }, - { - "producer": "serde-json-guide", - "profile": "json-fixture-v1" - }, - { - "producer": "serde_json", - "profile": "serde-json-1.0.151" - } - ], - "schema_version": 1 -} diff --git a/crates/doc-example-config/baseline/serde-json/contributor/parse-a-typed-list.md b/crates/doc-example-config/baseline/serde-json/contributor/parse-a-typed-list.md deleted file mode 100644 index 3cbc9bf..0000000 --- a/crates/doc-example-config/baseline/serde-json/contributor/parse-a-typed-list.md +++ /dev/null @@ -1,102 +0,0 @@ -# Parse a typed JSON list - - - -Use `serde_json::from_str::>()` to read a JSON array of -unsigned integers into a Rust vector. The Rust type supplies a concrete -expectation: a string inside the array is a type mismatch, even if the JSON -syntax is valid. - -This walkthrough parses a valid list, inspects an error, and retries with -corrected input. It calls the public `serde_json` API directly. - -The library documents [deserializing from a string](https://docs.rs/serde_json/1.0.151/serde_json/fn.from_str.html) -and [classifying errors](https://docs.rs/serde_json/1.0.151/serde_json/struct.Error.html). - - - -## Parse a valid list - - - -Start with this JSON array: - - - -```json -[2, 3, 5] -``` - - - -Parsing succeeds with 3 integers\. Their sum is 10\. The values are ordinary Rust integers after deserialization\. - - - -## Inspect a type mismatch - - - -Replace the second number with a string. This input is still valid JSON, -but it does not match `Vec`: - - - -```json -[2, "three", 5] -``` - - - -The failed parse returns an error\. Its display text identifies the incompatible value and the expected Rust type: - - - -```text -invalid type: string "three", expected u32 at line 1 column 11 -``` - - - -The error is classified as Data and is reported at line 1, column 11\. Use the category to distinguish a type mismatch from invalid JSON syntax\. - - - -> **Contributor note on [type\-error](assets/parse-a-typed-list/type-error.txt):** The error text is captured from serde\_json 1\.0\.151\. A dependency update must review both the asserted category and the captured message\. The [error\-observation](assets/parse-a-typed-list/error-observation.json) records the observed diagnostic fields for contributors\. - - - -## Correct the input and serialize the result - - - -Replace the string with the number 3 and parse the corrected source again\. The corrected input is the same valid array shown above: - - - -```json -[2, 3, 5] -``` - - - -The corrected parse returns \[2,3,5\]\. Pass that vector to serde\_json::to\_string\_pretty to obtain formatted JSON: - - - -```json -[ - 2, - 3, - 5 -] -``` - - - -The [formatted\-list](assets/parse-a-typed-list/formatted-list.json) can be parsed back into the same integers\. Formatting changes the presentation while preserving these values\. - - - -> **Contributor note:** This integration is maintained here to test the documentation framework against an independently existing Rust library\. It is not upstream serde\_json documentation or evidence of adoption by its maintainers\. - diff --git a/crates/doc-example-config/baseline/serde-json/reader/.ownership.json b/crates/doc-example-config/baseline/serde-json/reader/.ownership.json deleted file mode 100644 index a493293..0000000 --- a/crates/doc-example-config/baseline/serde-json/reader/.ownership.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "schema_version": 1, - "files": [ - "assets/parse-a-typed-list/formatted-list.json", - "assets/parse-a-typed-list/mismatched-input.json", - "assets/parse-a-typed-list/type-error.txt", - "assets/parse-a-typed-list/valid-input.json", - "manifest.json", - "parse-a-typed-list.md" - ] -} \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/formatted-list.json b/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/formatted-list.json deleted file mode 100644 index aa50563..0000000 --- a/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/formatted-list.json +++ /dev/null @@ -1,5 +0,0 @@ -[ - 2, - 3, - 5 -] \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/mismatched-input.json b/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/mismatched-input.json deleted file mode 100644 index a47bcf6..0000000 --- a/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/mismatched-input.json +++ /dev/null @@ -1 +0,0 @@ -[2, "three", 5] \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/type-error.txt b/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/type-error.txt deleted file mode 100644 index 8da8dd3..0000000 --- a/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/type-error.txt +++ /dev/null @@ -1 +0,0 @@ -invalid type: string "three", expected u32 at line 1 column 11 \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/valid-input.json b/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/valid-input.json deleted file mode 100644 index 3ec03bc..0000000 --- a/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/valid-input.json +++ /dev/null @@ -1 +0,0 @@ -[2, 3, 5] \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/reader/manifest.json b/crates/doc-example-config/baseline/serde-json/reader/manifest.json deleted file mode 100644 index cc722f6..0000000 --- a/crates/doc-example-config/baseline/serde-json/reader/manifest.json +++ /dev/null @@ -1,274 +0,0 @@ -{ - "artifacts": [ - { - "bytes": 17, - "document": "parse-a-typed-list", - "id": "formatted-list", - "kind": "resource", - "mime": "application/json", - "path": "assets/parse-a-typed-list/formatted-list.json", - "producer": "serde_json", - "profile": "serde-json-1.0.151", - "sha256": "80c83a54da142957b5a2b7b8ec8f8396d6cdb2daee1848129b19dba82c88c909" - }, - { - "bytes": 15, - "document": "parse-a-typed-list", - "id": "mismatched-input", - "kind": "resource", - "mime": "application/json", - "path": "assets/parse-a-typed-list/mismatched-input.json", - "producer": "serde-json-guide", - "profile": "json-fixture-v1", - "sha256": "e14fac803c516c4f81b9af6ac11db65e751802c0c495db102f14aa9df774458b" - }, - { - "bytes": 62, - "document": "parse-a-typed-list", - "id": "type-error", - "kind": "resource", - "mime": "text/plain", - "path": "assets/parse-a-typed-list/type-error.txt", - "producer": "serde_json", - "profile": "serde-json-1.0.151", - "sha256": "755ae51b66058706677805b97a062f14ecd1895103bd1dc942782a097aa7acd6" - }, - { - "bytes": 9, - "document": "parse-a-typed-list", - "id": "valid-input", - "kind": "resource", - "mime": "application/json", - "path": "assets/parse-a-typed-list/valid-input.json", - "producer": "serde-json-guide", - "profile": "json-fixture-v1", - "sha256": "40d28069123e2a7d89dd143fd210b66f8ed7fb19ebfc23032c051f4f6aed0381" - }, - { - "bytes": 2261, - "document": "parse-a-typed-list", - "kind": "page", - "mime": "text/markdown", - "path": "parse-a-typed-list.md", - "sha256": "92f2e8c2837c2111f329fbb6f532e14c2bb4b09d4910c9ced0e624f143e6bee7" - } - ], - "audience": "reader", - "documents": [ - { - "blocks": [ - { - "id": "block-0001", - "kind": "markdown", - "references": [] - }, - { - "id": "block-0002", - "kind": "heading", - "references": [] - }, - { - "id": "block-0003", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0004", - "kind": "code", - "references": [ - { - "id": "valid-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0005", - "kind": "paragraph", - "references": [ - { - "id": "parsed-length", - "kind": "value" - }, - { - "id": "parsed-sum", - "kind": "value" - } - ] - }, - { - "id": "block-0006", - "kind": "heading", - "references": [] - }, - { - "id": "block-0007", - "kind": "markdown", - "references": [] - }, - { - "id": "block-0008", - "kind": "code", - "references": [ - { - "id": "mismatched-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0009", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0010", - "kind": "code", - "references": [ - { - "id": "type-error", - "kind": "resource" - } - ] - }, - { - "id": "block-0011", - "kind": "paragraph", - "references": [ - { - "id": "error-line", - "kind": "value" - }, - { - "id": "error-column", - "kind": "value" - } - ] - }, - { - "id": "block-0012", - "kind": "heading", - "references": [] - }, - { - "id": "block-0013", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0014", - "kind": "code", - "references": [ - { - "id": "valid-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0015", - "kind": "paragraph", - "references": [ - { - "id": "corrected-values", - "kind": "value" - } - ] - }, - { - "id": "block-0016", - "kind": "code", - "references": [ - { - "id": "formatted-list", - "kind": "resource" - } - ] - }, - { - "id": "block-0017", - "kind": "paragraph", - "references": [ - { - "id": "formatted-list", - "kind": "resource" - } - ] - } - ], - "checks": [ - { - "actual": [ - 2, - 3, - 5 - ], - "expected": [ - 2, - 3, - 5 - ], - "id": "corrected-values", - "kind": "equality", - "passed": true - }, - { - "actual": 11, - "expected": 11, - "id": "error-column", - "kind": "equality", - "passed": true - }, - { - "actual": 1, - "expected": 1, - "id": "error-line", - "kind": "equality", - "passed": true - }, - { - "id": "mismatched-input-has-valid-json-syntax", - "kind": "predicate", - "passed": true - }, - { - "actual": 3, - "expected": 3, - "id": "parsed-length", - "kind": "equality", - "passed": true - }, - { - "actual": 10, - "expected": 10, - "id": "parsed-sum", - "kind": "equality", - "passed": true - }, - { - "id": "pretty-json-preserves-values", - "kind": "predicate", - "passed": true - }, - { - "id": "type-mismatch-is-data-error", - "kind": "predicate", - "passed": true - } - ], - "id": "parse-a-typed-list", - "title": "Parse a typed JSON list" - } - ], - "profiles": [ - { - "producer": "serde-json-guide", - "profile": "json-fixture-v1" - }, - { - "producer": "serde_json", - "profile": "serde-json-1.0.151" - } - ], - "schema_version": 1 -} diff --git a/crates/doc-example-config/baseline/serde-json/reader/parse-a-typed-list.md b/crates/doc-example-config/baseline/serde-json/reader/parse-a-typed-list.md deleted file mode 100644 index dae6fbc..0000000 --- a/crates/doc-example-config/baseline/serde-json/reader/parse-a-typed-list.md +++ /dev/null @@ -1,94 +0,0 @@ -# Parse a typed JSON list - - - -Use `serde_json::from_str::>()` to read a JSON array of -unsigned integers into a Rust vector. The Rust type supplies a concrete -expectation: a string inside the array is a type mismatch, even if the JSON -syntax is valid. - -This walkthrough parses a valid list, inspects an error, and retries with -corrected input. It calls the public `serde_json` API directly. - -The library documents [deserializing from a string](https://docs.rs/serde_json/1.0.151/serde_json/fn.from_str.html) -and [classifying errors](https://docs.rs/serde_json/1.0.151/serde_json/struct.Error.html). - - - -## Parse a valid list - - - -Start with this JSON array: - - - -```json -[2, 3, 5] -``` - - - -Parsing succeeds with 3 integers\. Their sum is 10\. The values are ordinary Rust integers after deserialization\. - - - -## Inspect a type mismatch - - - -Replace the second number with a string. This input is still valid JSON, -but it does not match `Vec`: - - - -```json -[2, "three", 5] -``` - - - -The failed parse returns an error\. Its display text identifies the incompatible value and the expected Rust type: - - - -```text -invalid type: string "three", expected u32 at line 1 column 11 -``` - - - -The error is classified as Data and is reported at line 1, column 11\. Use the category to distinguish a type mismatch from invalid JSON syntax\. - - - -## Correct the input and serialize the result - - - -Replace the string with the number 3 and parse the corrected source again\. The corrected input is the same valid array shown above: - - - -```json -[2, 3, 5] -``` - - - -The corrected parse returns \[2,3,5\]\. Pass that vector to serde\_json::to\_string\_pretty to obtain formatted JSON: - - - -```json -[ - 2, - 3, - 5 -] -``` - - - -The [formatted\-list](assets/parse-a-typed-list/formatted-list.json) can be parsed back into the same integers\. Formatting changes the presentation while preserving these values\. - diff --git a/crates/doc-example-config/examples/docs.rs b/crates/doc-example-config/examples/docs.rs deleted file mode 100644 index 9e89b4b..0000000 --- a/crates/doc-example-config/examples/docs.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! This example is both the authored guide and its executable evidence producer. -//! Build `config-demo` first so the guide can exercise the actual CLI. - -mod support; - -use lgtmd::runner::run_cli; -use std::path::Path; - -fn main() { - let baseline = Path::new(env!("CARGO_MANIFEST_DIR")).join("baseline/config"); - let result = run_cli(std::env::args().skip(1), &baseline, || { - let binary = support::locate_cli()?; - support::guide(&binary) - }); - if let Err(error) = result { - eprintln!("{error}"); - std::process::exit(1); - } -} diff --git a/crates/doc-example-config/examples/serde_json.rs b/crates/doc-example-config/examples/serde_json.rs deleted file mode 100644 index a6aacf4..0000000 --- a/crates/doc-example-config/examples/serde_json.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! An executable user explanation built against the existing serde_json crate. -//! This is our integration example, not documentation adopted by its maintainers. - -use lgtmd::runner::run_cli; -use lgtmd::{Artifact, Doc, Document, Result}; -use serde_json::error::Category; -use std::path::Path; - -fn main() { - let baseline = Path::new(env!("CARGO_MANIFEST_DIR")).join("baseline/serde-json"); - if let Err(error) = run_cli(std::env::args().skip(1), &baseline, guide) { - eprintln!("{error}"); - std::process::exit(1); - } -} - -fn guide() -> Result> { - let mut doc = Doc::new("parse-a-typed-list", "Parse a typed JSON list")?; - doc.markdown( - r#"Use `serde_json::from_str::>()` to read a JSON array of -unsigned integers into a Rust vector. The Rust type supplies a concrete -expectation: a string inside the array is a type mismatch, even if the JSON -syntax is valid. - -This walkthrough parses a valid list, inspects an error, and retries with -corrected input. It calls the public `serde_json` API directly. - -The library documents [deserializing from a string](https://docs.rs/serde_json/1.0.151/serde_json/fn.from_str.html) -and [classifying errors](https://docs.rs/serde_json/1.0.151/serde_json/struct.Error.html)."#, - )?; - - doc.heading(2, "Parse a valid list")?; - let source = "[2, 3, 5]"; - doc.paragraph("Start with this JSON array:")?; - let input = doc.resource_code("valid-input", input_json(source)?, "json")?; - let values: Vec = serde_json::from_str(source).map_err(|error| error.to_string())?; - let count = doc.expect_eq("parsed-length", values.len(), 3)?; - let total = doc.expect_eq("parsed-sum", values.iter().sum::(), 10)?; - doc.paragraph(( - "Parsing succeeds with ", - &count, - " integers. Their sum is ", - &total, - ". The values are ordinary Rust integers after deserialization.", - ))?; - - doc.heading(2, "Inspect a type mismatch")?; - doc.markdown( - r#"Replace the second number with a string. This input is still valid JSON, -but it does not match `Vec`:"#, - )?; - let invalid = r#"[2, "three", 5]"#; - doc.resource_code("mismatched-input", input_json(invalid)?, "json")?; - doc.require( - "mismatched-input-has-valid-json-syntax", - serde_json::from_str::(invalid).is_ok(), - )?; - let error = match serde_json::from_str::>(invalid) { - Ok(_) => return Err("Expected the string element to fail typed deserialization".into()), - Err(error) => error, - }; - doc.require( - "type-mismatch-is-data-error", - error.classify() == Category::Data, - )?; - let line = doc.expect_eq("error-line", error.line(), 1)?; - let column = doc.expect_eq("error-column", error.column(), 11)?; - doc.paragraph("The failed parse returns an error. Its display text identifies the incompatible value and the expected Rust type:")?; - let diagnostic = doc.resource_code( - "type-error", - captured(&error.to_string(), "text/plain", "txt")?, - "text", - )?; - doc.paragraph(( - "The error is classified as Data and is reported at line ", - &line, - ", column ", - &column, - ". Use the category to distinguish a type mismatch from invalid JSON syntax.", - ))?; - let details = doc.json( - "error-observation", - &serde_json::json!({ - "category": format!("{:?}", error.classify()), - "line": error.line(), - "column": error.column(), - "message": error.to_string(), - }), - )?; - doc.note_on( - &diagnostic, - ( - "The error text is captured from serde_json 1.0.151. A dependency update must review both the asserted category and the captured message. The ", - &details, - " records the observed diagnostic fields for contributors.", - ), - )?; - - doc.heading(2, "Correct the input and serialize the result")?; - doc.paragraph("Replace the string with the number 3 and parse the corrected source again. The corrected input is the same valid array shown above:")?; - doc.code(&input, "json")?; - let corrected = invalid.replace("\"three\"", "3"); - let recovered: Vec = - serde_json::from_str(&corrected).map_err(|error| error.to_string())?; - let recovered_values = doc.expect_eq("corrected-values", &recovered, &values)?; - doc.paragraph(( - "The corrected parse returns ", - &recovered_values, - ". Pass that vector to serde_json::to_string_pretty to obtain formatted JSON:", - ))?; - let pretty = serde_json::to_string_pretty(&recovered).map_err(|error| error.to_string())?; - let roundtrip: Vec = serde_json::from_str(&pretty).map_err(|error| error.to_string())?; - doc.require("pretty-json-preserves-values", roundtrip == recovered)?; - let formatted = doc.resource_code( - "formatted-list", - captured(&pretty, "application/json", "json")?, - "json", - )?; - doc.paragraph(( - "The ", - &formatted, - " can be parsed back into the same integers. Formatting changes the presentation while preserving these values.", - ))?; - doc.note("This integration is maintained here to test the documentation framework against an independently existing Rust library. It is not upstream serde_json documentation or evidence of adoption by its maintainers.")?; - Ok(vec![doc.finish()?]) -} - -fn captured(text: &str, mime: &str, extension: &str) -> Result { - Artifact::new( - text.as_bytes(), - mime, - extension, - "serde_json", - "serde-json-1.0.151", - ) -} - -fn input_json(text: &str) -> Result { - Artifact::new( - text.as_bytes(), - "application/json", - "json", - "serde-json-guide", - "json-fixture-v1", - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use lgtmd::{Audience, store}; - - #[test] - fn both_serde_json_views_match_the_retained_contract() { - let documents = guide().unwrap(); - let baseline = Path::new(env!("CARGO_MANIFEST_DIR")).join("baseline/serde-json"); - for (audience, directory) in [ - (Audience::Reader, "reader"), - (Audience::Contributor, "contributor"), - ] { - let files = Document::render_many(&documents, audience).unwrap(); - store::check(&baseline.join(directory), &files).unwrap(); - } - } - - #[test] - fn library_guide_repeats_exactly_without_exposing_contributor_diagnostics() { - let first = Document::render_many(&guide().unwrap(), Audience::Reader).unwrap(); - let second = Document::render_many(&guide().unwrap(), Audience::Reader).unwrap(); - assert_eq!(first, second); - assert!(!first.keys().any(|path| path.contains("error-observation"))); - let reader_text = first - .values() - .map(|bytes| String::from_utf8_lossy(bytes)) - .collect::>() - .join("\n"); - assert!(!reader_text.contains("observed diagnostic fields for contributors")); - } -} diff --git a/crates/doc-example-config/examples/support/mod.rs b/crates/doc-example-config/examples/support/mod.rs deleted file mode 100644 index 7b83b78..0000000 --- a/crates/doc-example-config/examples/support/mod.rs +++ /dev/null @@ -1,222 +0,0 @@ -use config_demo::Config; -use lgtmd::{Artifact, Doc, Document, Result}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; - -/// Resolve a built CLI without knowing the repository or target directory. -pub fn locate_cli() -> Result { - if let Some(binary) = std::env::var_os("CONFIG_DEMO_BIN") { - return fs::canonicalize(binary) - .map_err(|error| format!("CONFIG_DEMO_BIN could not be resolved: {error}")); - } - let example = std::env::current_exe().map_err(|error| error.to_string())?; - let directory = example - .parent() - .and_then(Path::parent) - .ok_or("could not locate the example's build directory")?; - let binary = directory.join(format!("config-demo{}", std::env::consts::EXE_SUFFIX)); - if !binary.is_file() { - return Err("Build the actual CLI with `cargo build -p doc-example-config --bin config-demo`, or set CONFIG_DEMO_BIN to its path.".into()); - } - Ok(binary) -} - -/// Author prose beside the operations and checks which support its promises. -pub fn guide(binary: &Path) -> Result> { - // Resolve before changing the child's directory. Some emulated hosts report - // a missing executable as child exit 127 instead of a process-spawn error. - let binary = fs::canonicalize(binary).map_err(|error| { - format!( - "could not run the config-demo CLI at {}: {error}", - binary.display() - ) - })?; - let directory = tempfile::tempdir().map_err(|error| error.to_string())?; - let output_path = directory.path().join("settings.json"); - let mut doc = Doc::new("configure-a-port", "Save a valid configuration")?; - - doc.markdown( - r#"Use **config-demo** to validate a JSON configuration before saving it. -The command reports a useful error when a value is invalid and keeps the last -valid output available while you correct the input. - -This walkthrough shows how to: - -- Save a valid configuration. -- Reject an invalid replacement without changing the saved file. -- Correct the input and retry successfully. - -Run the commands from a scratch directory so that `input.json` and -`settings.json` stay together."#, - )?; - let default_port = doc.expect_eq("default-port", Config::default().port, 8080)?; - doc.paragraph(( - "The configuration contains one setting: port. Choose an integer from 1 to 65535. The Rust API's default configuration uses port ", - &default_port, - ". The command line reads an explicit configuration file; it does not fill in a missing port.", - ))?; - doc.note("This guide exercises the public Config API and the built config-demo executable in an isolated temporary directory. No server is started; the example checks configuration behavior.")?; - - doc.heading(2, "Save a configuration")?; - let valid_input = b"{\n \"port\": 9090\n}\n"; - write_fixture(directory.path(), "input.json", valid_input)?; - let valid_source = doc.resource("valid-input", json_artifact(valid_input.to_vec())?)?; - doc.paragraph("Create input.json with this content:")?; - doc.code(&valid_source, "json")?; - - let successful = invoke(&binary, directory.path(), "input.json")?; - doc.require("successful-exit", successful.output.status.success())?; - doc.require( - "successful-stderr-empty", - successful.output.stderr.is_empty(), - )?; - let success_transcript = doc.resource( - "successful-command", - text_artifact(successful.transcript.into_bytes())?, - )?; - doc.paragraph("Pass the input file and an output path. A successful command saves the validated configuration:")?; - doc.code(&success_transcript, "text")?; - - let saved_bytes = fs::read(&output_path).map_err(|error| error.to_string())?; - let saved = Config::load(&output_path).map_err(|error| error.to_string())?; - let saved_port = doc.expect_eq("saved-port", saved.port, 9090)?; - let saved_artifact = - doc.resource("saved-configuration", json_artifact(saved_bytes.clone())?)?; - doc.paragraph(( - "The saved settings.json now contains port ", - &saved_port, - ". Its contents are shown below, and you can also open the ", - &saved_artifact, - " file directly.", - ))?; - doc.code(&saved_artifact, "json")?; - - doc.heading(2, "Reject an invalid replacement")?; - doc.paragraph("A rejected input leaves an existing output unchanged. To see this, replace input.json with an invalid port:")?; - let invalid_input = b"{\n \"port\": 0\n}\n"; - write_fixture(directory.path(), "input.json", invalid_input)?; - let invalid_source = doc.resource("invalid-input", json_artifact(invalid_input.to_vec())?)?; - doc.code(&invalid_source, "json")?; - - let rejected = invoke(&binary, directory.path(), "input.json")?; - let rejection_code = doc.expect_eq("rejected-exit", rejected.exit_code, 2)?; - doc.require("rejected-stdout-empty", rejected.output.stdout.is_empty())?; - doc.require( - "rejection-explains-port-range", - String::from_utf8_lossy(&rejected.output.stderr) - .contains("port must be between 1 and 65535 (received 0)"), - )?; - let after_rejection = fs::read(&output_path).map_err(|error| error.to_string())?; - let preservation = doc.require("rejected-output-unchanged", after_rejection == saved_bytes)?; - let rejection_transcript = doc.resource( - "rejected-command", - text_artifact(rejected.transcript.into_bytes())?, - )?; - doc.paragraph("Run the same command again. The error explains which value needs correction:")?; - doc.code(&rejection_transcript, "text")?; - doc.paragraph(( - "The command exits unsuccessfully and preserves every byte of settings.json. The ", - &saved_artifact, - " from the first command is still the current configuration.", - ))?; - doc.note_on( - &rejection_code, - "The CLI's rejection status is 2. The scenario also checks that rejected input produces no success output.", - )?; - doc.note_on( - &preservation, - "The preservation check compares the complete output bytes before and after the rejected subprocess invocation.", - )?; - - doc.heading(2, "Correct the input and retry")?; - doc.paragraph("Change the port in input.json to 8080 and rerun the command. The same output path can now be replaced with a valid configuration:")?; - let recovery_input = b"{\n \"port\": 8080\n}\n"; - write_fixture(directory.path(), "input.json", recovery_input)?; - let recovered = invoke(&binary, directory.path(), "input.json")?; - doc.require("recovery-exit", recovered.output.status.success())?; - doc.require("recovery-stderr-empty", recovered.output.stderr.is_empty())?; - let recovery_config = Config::load(&output_path).map_err(|error| error.to_string())?; - let recovered_port = doc.expect_eq("recovered-port", recovery_config.port, 8080)?; - let recovery_transcript = doc.resource( - "recovered-command", - text_artifact(recovered.transcript.into_bytes())?, - )?; - doc.code(&recovery_transcript, "text")?; - doc.paragraph(( - "The output now uses port ", - &recovered_port, - ". When validation fails, correct the input and retry; the last valid output remains available until the correction succeeds.", - ))?; - - let diagnostics = serde_json::json!({ - "driver": "actual config-demo subprocess", - "success_exit": successful.output.status.code(), - "rejected_exit": rejected.output.status.code(), - "recovery_exit": recovered.output.status.code(), - "rejected_output_unchanged": after_rejection == saved_bytes, - "saved_port": saved.port, - "recovered_port": recovery_config.port, - }); - let diagnostic_resource = doc.json("execution-checks", &diagnostics)?; - doc.note_on( - &rejection_transcript, - ( - "The transcript comes from the actual command's stdout, stderr, and exit status. Review the ", - &diagnostic_resource, - " for the recorded outcomes. This contributor evidence is attached to the same reader artifact.", - ), - )?; - - Ok(vec![doc.finish()?]) -} - -fn write_fixture(directory: &Path, name: &str, bytes: &[u8]) -> Result<()> { - fs::write(directory.join(name), bytes).map_err(|error| error.to_string()) -} - -struct Invocation { - output: Output, - exit_code: i32, - transcript: String, -} - -fn invoke(binary: &Path, directory: &Path, input: &str) -> Result { - let arguments = [input, "--output", "settings.json"]; - let output = Command::new(binary) - .current_dir(directory) - .args(arguments) - .output() - .map_err(|error| format!("could not run the config-demo CLI: {error}"))?; - let stdout = std::str::from_utf8(&output.stdout).map_err(|error| error.to_string())?; - let stderr = std::str::from_utf8(&output.stderr).map_err(|error| error.to_string())?; - let status = output - .status - .code() - .ok_or("config-demo terminated without an exit code")?; - // Only the executable's display name differs from the actual invocation. - // Arguments and working-directory-relative paths are preserved verbatim. - let transcript = format!( - "$ config-demo {}\n{stdout}{stderr}[exit {status}]\n", - arguments.join(" ") - ); - Ok(Invocation { - output, - exit_code: status, - transcript, - }) -} - -fn json_artifact(bytes: Vec) -> Result { - Artifact::new(bytes, "application/json", "json", "config-demo", "json-v1") -} - -fn text_artifact(bytes: Vec) -> Result { - Artifact::new( - bytes, - "text/plain", - "txt", - "config-demo-cli", - "transcript-v1", - ) -} diff --git a/crates/doc-example-config/src/lib.rs b/crates/doc-example-config/src/lib.rs deleted file mode 100644 index edacb25..0000000 --- a/crates/doc-example-config/src/lib.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! A small configuration API used to exercise executable user documentation. - -use serde::{Deserialize, Serialize}; -use std::fmt; -use std::fs::{self, OpenOptions}; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - -/// The application configuration. Values are validated before saving. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Config { - pub port: u32, -} - -impl Default for Config { - fn default() -> Self { - Self { port: 8080 } - } -} - -impl Config { - pub fn validate(&self) -> Result<(), ConfigError> { - if (1..=65535).contains(&self.port) { - Ok(()) - } else { - Err(ConfigError::InvalidPort(self.port)) - } - } - - pub fn from_json(source: &str) -> Result { - let config: Self = serde_json::from_str(source).map_err(ConfigError::Json)?; - config.validate()?; - Ok(config) - } - - pub fn load(path: &Path) -> Result { - let source = fs::read_to_string(path).map_err(|error| ConfigError::Io { - operation: "read", - path: path.to_owned(), - error, - })?; - Self::from_json(&source) - } - - /// Validate first, then replace the output with a complete adjacent file. - /// A rejected configuration never creates or changes the output file. - pub fn save(&self, output: &Path) -> Result<(), ConfigError> { - self.validate()?; - let mut bytes = serde_json::to_vec_pretty(self).map_err(ConfigError::Json)?; - bytes.push(b'\n'); - write_atomic(output, &bytes).map_err(|error| ConfigError::Io { - operation: "save", - path: output.to_owned(), - error, - }) - } -} - -#[derive(Debug)] -pub enum ConfigError { - InvalidPort(u32), - Json(serde_json::Error), - Io { - operation: &'static str, - path: PathBuf, - error: io::Error, - }, -} - -impl fmt::Display for ConfigError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidPort(port) => write!( - formatter, - "port must be between 1 and 65535 (received {port})." - ), - Self::Json(error) => write!(formatter, "invalid configuration JSON: {error}"), - Self::Io { - operation, - path, - error, - } => write!( - formatter, - "could not {operation} {}: {error}", - path.display() - ), - } - } -} - -impl std::error::Error for ConfigError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Json(error) => Some(error), - Self::Io { error, .. } => Some(error), - Self::InvalidPort(_) => None, - } - } -} - -static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(0); - -struct PendingFile(PathBuf); - -impl Drop for PendingFile { - fn drop(&mut self) { - let _ = fs::remove_file(&self.0); - } -} - -fn write_atomic(output: &Path, bytes: &[u8]) -> io::Result<()> { - let parent = output - .parent() - .filter(|path| !path.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - // Adjacent temporary files keep the final rename on one filesystem. Exclusive - // creation preserves unrelated files even if a previous process left one behind. - for _ in 0..128 { - let sequence = NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed); - let pending = parent.join(format!( - ".config-demo-{}-{sequence}.tmp", - std::process::id() - )); - let mut file = match OpenOptions::new() - .write(true) - .create_new(true) - .open(&pending) - { - Ok(file) => file, - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, - Err(error) => return Err(error), - }; - let pending = PendingFile(pending); - file.write_all(bytes)?; - file.sync_all()?; - drop(file); - fs::rename(&pending.0, output)?; - return Ok(()); - } - Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "could not allocate an adjacent temporary file", - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn accepts_the_documented_port_bounds() { - for port in [1, 8080, 65535] { - assert!(Config { port }.validate().is_ok()); - } - for port in [0, 65536, u32::MAX] { - assert!(matches!( - Config { port }.validate(), - Err(ConfigError::InvalidPort(actual)) if actual == port - )); - } - } - - #[test] - fn parsing_rejects_unknown_fields_and_non_integer_ports() { - assert!(Config::from_json(r#"{"port":8080,"prot":9090}"#).is_err()); - assert!(Config::from_json(r#"{"port":80.5}"#).is_err()); - assert!(Config::from_json(r#"{"port":-1}"#).is_err()); - assert!(Config::from_json("{}").is_err()); - } - - #[test] - fn rejected_save_preserves_original_bytes_and_leaves_no_temporary_file() { - let directory = tempfile::tempdir().unwrap(); - let output = directory.path().join("settings.json"); - let original = b"{\"port\":8080}\n"; - fs::write(&output, original).unwrap(); - - assert!(Config { port: 0 }.save(&output).is_err()); - assert_eq!(fs::read(&output).unwrap(), original); - assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1); - } - - #[test] - fn valid_save_replaces_the_complete_document() { - let directory = tempfile::tempdir().unwrap(); - let output = directory.path().join("settings.json"); - fs::write(&output, b"previous output").unwrap(); - - Config { port: 9090 }.save(&output).unwrap(); - assert_eq!(Config::load(&output).unwrap(), Config { port: 9090 }); - assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1); - } - - #[test] - fn failed_replacement_cleans_up_its_temporary_file() { - let directory = tempfile::tempdir().unwrap(); - let output = directory.path().join("settings.json"); - fs::create_dir(&output).unwrap(); - fs::write(output.join("keep.txt"), "existing data").unwrap(); - - assert!(Config { port: 9090 }.save(&output).is_err()); - assert_eq!( - fs::read_to_string(output.join("keep.txt")).unwrap(), - "existing data" - ); - assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1); - } -} diff --git a/crates/doc-example-config/src/main.rs b/crates/doc-example-config/src/main.rs deleted file mode 100644 index 75d21bf..0000000 --- a/crates/doc-example-config/src/main.rs +++ /dev/null @@ -1,40 +0,0 @@ -use config_demo::Config; -use std::ffi::OsString; -use std::path::PathBuf; -use std::process::ExitCode; - -const USAGE: &str = "Usage: config-demo INPUT.json --output OUTPUT.json"; - -fn main() -> ExitCode { - match run(std::env::args_os().skip(1).collect()) { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("{error}"); - ExitCode::from(2) - } - } -} - -fn run(arguments: Vec) -> Result<(), String> { - if arguments.len() == 1 && arguments[0] == "--help" { - println!("{USAGE}"); - println!("Validate a configuration and save it only when its port is valid."); - return Ok(()); - } - if arguments.len() != 3 || arguments[1] != "--output" { - return Err(USAGE.to_owned()); - } - let input = PathBuf::from(&arguments[0]); - let output = PathBuf::from(&arguments[2]); - let config = - Config::load(&input).map_err(|error| format!("Configuration rejected: {error}"))?; - config - .save(&output) - .map_err(|error| format!("Configuration not saved: {error}"))?; - println!( - "Saved validated configuration (port {}) to {}.", - config.port, - output.display() - ); - Ok(()) -} diff --git a/crates/doc-example-config/tests/cli.rs b/crates/doc-example-config/tests/cli.rs deleted file mode 100644 index 5915e74..0000000 --- a/crates/doc-example-config/tests/cli.rs +++ /dev/null @@ -1,50 +0,0 @@ -use config_demo::Config; -use std::fs; -use std::process::{Command, Output}; - -fn run(directory: &std::path::Path, input: &str) -> Output { - Command::new(env!("CARGO_BIN_EXE_config-demo")) - .current_dir(directory) - .args([input, "--output", "settings.json"]) - .output() - .unwrap() -} - -#[test] -fn actual_cli_rejects_without_replacing_and_recovers_after_correction() { - let directory = tempfile::tempdir().unwrap(); - let input = directory.path().join("input.json"); - let output = directory.path().join("settings.json"); - fs::write(&input, r#"{"port":9090}"#).unwrap(); - - let success = run(directory.path(), "input.json"); - assert!(success.status.success()); - assert_eq!(Config::load(&output).unwrap().port, 9090); - let original = fs::read(&output).unwrap(); - - fs::write(&input, r#"{"port":0}"#).unwrap(); - let rejected = run(directory.path(), "input.json"); - assert_eq!(rejected.status.code(), Some(2)); - assert!(rejected.stdout.is_empty()); - assert!( - String::from_utf8(rejected.stderr) - .unwrap() - .contains("port must be between 1 and 65535") - ); - assert_eq!(fs::read(&output).unwrap(), original); - - fs::write(&input, r#"{"port":8080}"#).unwrap(); - let recovered = run(directory.path(), "input.json"); - assert!(recovered.status.success()); - assert_eq!(Config::load(&output).unwrap().port, 8080); - assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 2); -} - -#[test] -fn malformed_input_does_not_create_output() { - let directory = tempfile::tempdir().unwrap(); - fs::write(directory.path().join("input.json"), "{unfinished").unwrap(); - let rejected = run(directory.path(), "input.json"); - assert_eq!(rejected.status.code(), Some(2)); - assert!(!directory.path().join("settings.json").exists()); -} diff --git a/crates/doc-example-config/tests/guide.rs b/crates/doc-example-config/tests/guide.rs deleted file mode 100644 index 0c6e297..0000000 --- a/crates/doc-example-config/tests/guide.rs +++ /dev/null @@ -1,62 +0,0 @@ -#[path = "../examples/support/mod.rs"] -#[allow(dead_code)] -mod support; - -use lgtmd::{Audience, Document, store}; -use std::path::Path; - -#[test] -fn guide_is_reproducible_and_keeps_contributor_evidence_out_of_reader_output() { - let binary = Path::new(env!("CARGO_BIN_EXE_config-demo")); - let first = support::guide(binary).unwrap(); - let second = support::guide(binary).unwrap(); - let reader = Document::render_many(&first, Audience::Reader).unwrap(); - let repeated = Document::render_many(&second, Audience::Reader).unwrap(); - assert_eq!(reader, repeated); - - let reader_text = reader - .iter() - .filter(|(path, _)| path.ends_with(".md")) - .map(|(_, bytes)| std::str::from_utf8(bytes).unwrap()) - .collect::>() - .join("\n"); - assert!(reader_text.contains("preserves every byte")); - assert!(reader_text.contains("[exit 2]")); - assert!(!reader_text.contains("contributor evidence")); - assert!(!reader.keys().any(|path| path.contains("execution-checks"))); - - let contributor = Document::render_many(&first, Audience::Contributor).unwrap(); - assert!( - contributor - .keys() - .any(|path| path.contains("execution-checks")) - ); - let contributor_text = contributor - .iter() - .filter(|(path, _)| path.ends_with(".md")) - .map(|(_, bytes)| std::str::from_utf8(bytes).unwrap()) - .collect::>() - .join("\n"); - assert!(contributor_text.contains("contributor evidence")); -} - -#[test] -fn guide_fails_if_the_actual_cli_cannot_run() { - let directory = tempfile::tempdir().unwrap(); - let error = support::guide(&directory.path().join("missing-config-demo")).unwrap_err(); - assert!(error.contains("could not run"), "Unexpected error: {error}"); -} - -#[test] -fn both_config_views_match_the_retained_contract() { - let binary = Path::new(env!("CARGO_BIN_EXE_config-demo")); - let documents = support::guide(binary).unwrap(); - let baseline = Path::new(env!("CARGO_MANIFEST_DIR")).join("baseline/config"); - for (audience, directory) in [ - (Audience::Reader, "reader"), - (Audience::Contributor, "contributor"), - ] { - let files = Document::render_many(&documents, audience).unwrap(); - store::check(&baseline.join(directory), &files).unwrap(); - } -} diff --git a/crates/lgtmd-node/Cargo.toml b/crates/lgtmd-node/Cargo.toml deleted file mode 100644 index f00eabf..0000000 --- a/crates/lgtmd-node/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "lgtmd-node" -version = "0.0.0" -edition = "2024" -rust-version = "1.95" -publish = false -license = "MIT" -description = "Coarse Node host bridge for the lgtmd compiler and artifact store" - -[[bin]] -name = "lgtmd-node" -path = "src/main.rs" - -[dependencies] -lgtmd = { path = "../lgtmd", version = "0.0.0" } -serde = { version = "=1.0.229", features = ["derive"] } -serde_json = "=1.0.151" diff --git a/crates/lgtmd-node/LICENSE b/crates/lgtmd-node/LICENSE deleted file mode 100644 index 943658a..0000000 --- a/crates/lgtmd-node/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Grida - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/crates/lgtmd-node/src/main.rs b/crates/lgtmd-node/src/main.rs deleted file mode 100644 index b95395f..0000000 --- a/crates/lgtmd-node/src/main.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! One request per process, never one request per authoring operation. The Node -//! facade owns execution; Rust owns compilation and the existing strict store. -use lgtmd::{ - Audience, Result, - runner::{self, RunMode, RunnerConfig}, - transport::{self, Input, Layout}, -}; -use serde::Deserialize; -use serde_json::{Value, json}; -use std::{ - io::{self, Read}, - path::PathBuf, -}; - -#[derive(Deserialize)] -#[serde(tag = "operation", rename_all = "lowercase", deny_unknown_fields)] -enum Request { - Compile { - input: Input, - #[serde(default)] - layout: Layout, - }, - Publish { - input: Input, - command: String, - reader: PathBuf, - contributor: PathBuf, - #[serde(default)] - layout: Layout, - }, -} - -fn execute(request: Request) -> Result { - match request { - Request::Compile { input, layout } => { - let exports = transport::compile(&input, &layout.export_layout()?)?; - serde_json::to_value(exports).map_err(|e| e.to_string()) - } - Request::Publish { - input, - command, - reader, - contributor, - layout, - } => { - let base = std::env::current_dir().map_err(|e| e.to_string())?; - let config = RunnerConfig::new(base)? - .output(Audience::Reader, reader, "reader")? - .output(Audience::Contributor, contributor, "contributor")? - .layout(layout.export_layout()?); - // Explicit per-audience paths apply to build as well. Runner's - // update preflight validates their full transaction namespaces; - // compilation prepares both trees before store publication below. - match command.as_str() { - "check" | "update" => { - let mode = if command == "check" { - RunMode::Check - } else { - RunMode::Update - }; - runner::run( - &config, - mode, - &[Audience::Reader, Audience::Contributor], - || input.thaw(), - )?; - } - "build" => { - runner::run( - &config, - RunMode::BuildConfigured, - &[Audience::Reader, Audience::Contributor], - || input.thaw(), - )?; - } - _ => return Err("Command must be build, check, or update".into()), - } - Ok(json!({"command":command})) - } - } -} - -fn main() { - let outcome = (|| { - let mut input = String::new(); - io::stdin() - .read_to_string(&mut input) - .map_err(|e| e.to_string())?; - let request = - serde_json::from_str(&input).map_err(|e| format!("Invalid bridge request: {e}"))?; - execute(request) - })(); - match outcome { - Ok(value) => println!("{}", json!({"ok":true,"result":value})), - Err(error) => { - println!("{}", json!({"ok":false,"error":error})); - std::process::exit(1); - } - } -} diff --git a/crates/lgtmd/.gitignore b/crates/lgtmd/.gitignore deleted file mode 100644 index bd30896..0000000 --- a/crates/lgtmd/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# Disposable standalone verification output and local review candidates. -/.cache/ -/target/ -__pycache__/ diff --git a/crates/lgtmd/Cargo.toml b/crates/lgtmd/Cargo.toml deleted file mode 100644 index 745e80d..0000000 --- a/crates/lgtmd/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "lgtmd" -version = "0.0.0" -edition = "2024" -rust-version = "1.95" -publish = false -license = "MIT" -description = "Rust-authored executable documents with checked observations and owned artifacts" -readme = "README.md" - -[dependencies] -serde = { version = "=1.0.229", features = ["derive"] } -serde_json = "=1.0.151" -sha2 = "=0.10.9" -same-file = "=1.0.6" -pulldown-cmark = { version = "=0.13.0", default-features = false, features = [ - "html", -] } - -[dev-dependencies] -tempfile = "=3.27.0" diff --git a/crates/lgtmd/LICENSE b/crates/lgtmd/LICENSE deleted file mode 100644 index 943658a..0000000 --- a/crates/lgtmd/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Grida - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/crates/lgtmd/README.md b/crates/lgtmd/README.md deleted file mode 100644 index 3889751..0000000 --- a/crates/lgtmd/README.md +++ /dev/null @@ -1,417 +0,0 @@ -# lgtmd - -Author a document while executing the behavior it explains. Ordinary Rust code -supplies the actions and checks; the document collects prose, frozen observations, -resources, and contributor notes. The same completed document can produce Reader -and Contributor exports without executing the application again. - -This package is maintained in the N3 repository, with local consumers. Its -API and manifest are experimental, and `publish = false` prevents registry -publication. It does not depend on N3, egui, wgpu, a renderer, an agent skill, or a -documentation website. - -## Start with an executable paragraph - -The example below is also a Rust documentation test. It needs only this crate: - -```rust -use lgtmd::{Audience, Doc, Document}; - -let mut doc = Doc::new("trim-text", "Trim surrounding whitespace")?; -doc.heading(2, "Normalize a string")?; - -let normalized = " hello\n".trim(); -let result = doc.expect_eq("trimmed-value", normalized, "hello")?; -doc.paragraph(("Trimming the input produces ", &result, "."))?; - -let output = doc.text_code("output", normalized, "text")?; -doc.note_on(&output, "The fixture includes spaces and a trailing newline.")?; - -let document = doc.finish()?; -let reader = Document::render_many(std::slice::from_ref(&document), Audience::Reader)?; -let contributor = Document::render_many(&[document], Audience::Contributor)?; -assert_eq!(reader["assets/trim-text/output.txt"], b"hello"); -assert!(!String::from_utf8_lossy(&reader["trim-text.md"]).contains("fixture")); -assert!(String::from_utf8_lossy(&contributor["trim-text.md"]).contains("fixture")); -# Ok::<(), String>(()) -``` - -Add this package as a development dependency through its local path. A standalone -copy of this directory retains explicit Cargo dependency versions, this README, -tests, and its license; no workspace-inherited package fields are required. - -## Authoring and evidence - -- `heading`, `paragraph`, and `markdown` append document blocks. Paragraph tuples - combine literal text with typed references without a separate template or - placeholder registry. Plain paragraph text is escaped, while `markdown` accepts - CommonMark with tables and strikethrough. -- `heading_with_id(id, level, text)` assigns a stable section anchor when that - section needs durable references. `Block::link(label)` gives those references - reader-facing labels; the returned block also accepts attached notes. Named - sections share the checked evidence namespace, so collisions fail explicitly. -- `markdown_parts` combines authored Markdown with typed references and preserves - the supplied whitespace, including the absence of a trailing newline. Use it - when an existing document needs exact composition. `binding` freezes a host's - observed text, code label, or key sequence; its typed handle emits escaped text, - ``, or `` markup. The host must validate control witnesses and - canonical key mappings. A binding is not a behavioral assertion. -- `expect_eq(id, actual, expected)` executes an equality check and freezes its - serialized observation. Its returned value handle can appear in prose. - `require(id, condition)` records a predicate check. Meaningful checks remain the - author's responsibility; the framework cannot prove arbitrary prose. -- `observe(id, value)` freezes a serialized value without recording a check. - Its `Observed` handle supports the same prose and note targets as checked - values. Explanation-only and observation-only documents are valid; successful - completion does not imply their prose was verified. -- `resource(id, artifact)` registers owned bytes. `code` or `embed` places an - existing resource. `resource_code` registers and places text while retaining its - custom MIME, producer and comparison profile. The same handle can be reused in - prose, placed again, or annotated without rerunning its producer. - `Resource::image(alt)` and `Resource::link(label)` provide inline views; - `Resource::code(language)` places text/JSON within `markdown_parts`, occupying - its own blank-line-separated block. Code views cannot nest in code syntax or notes. - image views require a declared image MIME type. `resource_at` preserves an - existing safe artifact path, while complete exports reject duplicate owners - across documents and generated pages. -- `text`, `json`, `text_code`, and `json_code` are conveniences for the built-in - UTF-8 and deterministic JSON encoders. Their producer labels identify those - encoders. Use `Artifact::new` or `Artifact::from_file` when application or external - producer attribution matters. File bytes are read immediately, not at export. -- `note` appends contributor commentary; `note_on` attaches it to a block, check, - checked value, or resource. Notes have their own block counter, so adding notes - cannot renumber reader blocks. Reader exports omit notes, their exclusive - resources, and their relations and producer profiles. - -Handles belong to one document run. Duplicate evidence IDs, foreign handles, -wrong text types, unreferenced resources, and failed checks fail explicitly. -Ignoring a failed builder operation does not make the document valid: it remains -tainted and `finish()` rejects it. Checks are optional, and recorded checks must -succeed. Public block IDs are currently positional; inserting public -content can change later block anchors. Check and resource IDs are author-owned. - -The built-in encoder IDs remain `executable-docs/text` and `executable-docs/json` -for compatibility with retained evidence receipts. These serialized producer IDs -are independent of the `lgtmd` package and Rust import name. - -`finish()` freezes and validates the authored document independently of its future -file layout. Export then validates resource use, page and artifact paths, local -links, and the complete selected audience. A finished document alone is not a -publishable bundle; every export performs those remaining checks before returning -bytes, and the runner prepares every requested export before publication begins. - -## Shared compiler and Node authoring - -[`transport`](src/transport.rs) exposes schema-v1 frozen document input and a -coarse `compile(input, layout)` operation returning both audience exports. -`Input::from_documents` and `Input::thaw` preserve native document semantics, -including literal Markdown, typed parts, observations, checks, bindings, and owned -resource bytes. Process-local handle owners are never serialized. This input is -an interoperability contract, separate from the inventory manifest and a future -viewer format. Unknown fields, unsupported versions, invalid identities and -references, failed checks, and invalid resource paths fail explicitly. - -Portable JSON accepts null, booleans, strings, finite numbers, arrays and objects; -integers must fit JavaScript's safe range. JavaScript rejects unsupported values -before freezing, including undefined, cycles, getters, nonplain objects, negative -zero, and nonfinite numbers. Native Rust authoring retains serde_json's existing -serialization behavior (including normalization of nonfinite floats to null); -the transport cannot recover information already normalized by a serializer. -The built-in JSON resource encoder is shared Rust code, including for frozen -Node JSON values; integer-like object keys and fractional numbers have the same -encoding from the same portable input. Custom producers own their byte encoding. - -The incubating [Node SDK](../../packages/lgtmd/README.md) offers TypeScript tagged -Markdown, optional native assertions, async preparation/cleanup, and an optional -Playwright adapter. Its thin native bridge uses this compiler and the existing -Rust runner/store. The [web example](../../examples/lgtmd-web/README.md) supplies -three complete documents without application-specific engine behavior. - -For readable inline sentences, the optional named form of `prose!` keeps the -text together and inserts typed handles. It returns a result because missing, -duplicate, unused, or malformed bindings fail rather than silently dropping text -or evidence. Use `{{` and `}}` for literal braces; format specifiers are not -supported. Each binding expression runs once, even when its name appears more -than once. This composition lives in the scenario, without a separate template. - -```rust -use lgtmd::{prose, Doc}; - -let mut doc = Doc::new("retry", "Retry an operation")?; -let section = doc.heading_with_id("retry-limit", 2, "Choose a retry limit")?; -let count = doc.expect_eq("attempts", 3, 3)?; -doc.paragraph(prose!("The operation takes {count} attempts.", count = &count)?)?; -doc.paragraph(("See ", section.link("the retry limit"), "."))?; -doc.note_on(§ion, "Keep the section ID when moving this explanation.")?; -let document = doc.finish()?; -# Ok::<(), String>(()) -``` - -The expression-list form of `prose!` accepts any number of ordinary Rust -expressions and works with `paragraph`, `markdown_parts`, and notes. Tuples remain -convenient for shorter sequences. Prefer ordinary blocks for new documents; -`markdown_parts` preserves exact Markdown bytes for an existing host format. - -```rust -use lgtmd::{prose, Doc}; - -let mut doc = Doc::new("retry-limit", "Retry limit")?; -let count = doc.expect_eq("attempts", 3, 3)?; -doc.markdown_parts(prose!["The operation takes **", &count, "** attempts.\n"])?; -let document = doc.finish()?; -# Ok::<(), String>(()) -``` - -Markdown references are parsed with `pulldown-cmark`, including reference-style -links, escaping, inline code and fenced code. Definitions resolve within each -`markdown` or `markdown_parts` block. Literal raw HTML and unresolved references -fail; typed code/key bindings generate only their escaped, predefined HTML. -The completed page is parsed again: generated tags must remain tags, authored -HTML must remain excluded, and code fences or other containers must not swallow -later document blocks. Place captured code with `code`/`resource_code`; do not -wrap typed bindings in Markdown code delimiters. Typed artifact destinations are -URL-encoded without changing their owned filesystem names. -Local links must resolve -within the actual audience's exported bundle; fragments refer to generated block -IDs, not downstream website heading slugs. HTTP(S) and `mailto:` links are allowed -without checking remote availability. An artifact's declared MIME type does not -validate its binary contents, and producer metadata is attribution rather than -cryptographic proof of origin. - -JSON resources and frozen equality observations recursively sort object keys, -including when a consumer enables `serde_json/preserve_order`. Array order is -preserved. Final manifests use the same canonical object ordering. - -## Exports, comparison and publication - -`Document::render_many` returns a sorted map of relative paths to bytes: Markdown -pages, resources under `assets//.`, and -`manifest.json`. It freezes no additional application state and performs no -filesystem publication. The package's small `runner` module accepts a generation -callback and exposes `check`, `build --out PATH`, and `update`, with Reader, -Contributor, or both audiences. The `store` module owns those filesystem effects. - -### Configure your project's layout - -The SDK does not discover a repository, read Cargo metadata, or prescribe where -document functions, fixtures, skills, or application binaries live. Your callback -supplies completed documents. Configure export paths separately from authoring: - -```rust,no_run -use lgtmd::{Audience, Doc, ExportLayout, runner::RunnerConfig}; -# let project_root = std::env::current_dir().unwrap(); - -let layout = ExportLayout::default() - .pages_under("reference")? - .resources_under("downloads")? - .page("setup", "manual/getting-started/index.md")?; - -let config = RunnerConfig::new(&project_root)? - .output(Audience::Reader, "website/generated", "public")? - .output(Audience::Contributor, "review/evidence", "internal")? - .layout(layout); - -lgtmd::runner::run_cli_with(std::env::args().skip(1), &config, || { - let mut doc = Doc::new("setup", "Set up the application")?; - let value = doc.expect_eq("trimmed", " ready ".trim(), "ready")?; - doc.paragraph(("The normalized value is ", &value, "."))?; - Ok(vec![doc.finish()?]) -})?; -# Ok::<(), String>(()) -``` - -`project_root` is a path chosen by your host. `RunnerConfig::new` captures that -base once; relative baseline paths and CLI `--out` paths resolve against it. -Absolute destinations may be outside the project. The base need not contain a -Cargo manifest or Git repository, and the runner never changes the working -directory. Fixture paths and process working directories remain the callback's -responsibility. - -Each `output` selects an audience, its retained baseline, and its relative -directory inside a fresh build destination. In this example, `update` writes -`website/generated` and `review/evidence` beneath the chosen base; -`build --out candidate` writes `candidate/public` and `candidate/internal`. -An empty build directory name exports a single selected audience directly at -the build root. Configured CLI runs select all configured audiences by default; -`default_audiences` or `--audience` can narrow that selection. Call -`runner::run(&config, RunMode::Check, &[Audience::Reader], generate)` to integrate -with a test, build tool, or your own CLI parser. The convenience `run_cli` keeps -its original `reader`/`contributor` directories and reader-only default. - -`pages_under` and `resources_under` accept an empty prefix for the bundle root. -Per-document `page` overrides are exact bundle-relative paths; they do not inherit -the page prefix. `Doc::resource_at` paths are also exact and do not inherit the -resource prefix. Page contents remain Markdown regardless of the chosen filename. -Typed resource links are calculated relative to the actual page location. -Authored Markdown links use that same page-relative convention: `..` may reach -another location within the bundle but cannot escape it. Missing targets, unknown -page overrides, duplicate paths, and file/directory collisions fail, including -collisions with contributor-only resources. - -For in-memory integration, use `Document::render_many_with(documents, audience, -&layout)` or `document.render_with(audience, &layout)`. No filesystem runner is -required. The configurable layout belongs to full exports; fragment adapters keep -their existing explicit resource paths and host-owned page assembly. - -The [configured example](examples/configured.rs) demonstrates host-owned fixtures, -external baselines, nested pages, renamed audience directories, and private -resources. Its positional arguments are `PROJECT_ROOT BASELINE_ROOT`, followed by -the standard SDK commands. The fixture convention in that example belongs to its -host, not the framework. - -Configuration does not weaken the evidence contract. The manifest filename -`manifest.json`, store metadata `.ownership.json`, audience rules, exact comparison, -and path-safety checks remain fixed. Retained audience trees must be separate, and -build output must not overlap any configured baseline. Moving an owned page is an -explicit retirement of its previous path, not silent deletion. -Each output also reserves adjacent `.NAME.lock`, `.NAME.staging`, and -`.NAME.backup` paths. The runner rejects configurations that place another output -inside those transaction paths before generation, including fresh build roots. -Artifact inventory keys always use `/`, independently of host path separators. - -Before creating audience trees, the runner also rejects paths that could alias on -a case-insensitive or Unicode-normalizing filesystem. Existing directories are -compared by filesystem identity. Where separation depends on missing directory -names, use distinct ASCII names such as `public` and `internal`; case alone is not -a distinction. Unicode roots and suffixes are supported, but if Unicode names are -the only distinction, create the separate directories first so their identities -can be checked. Missing names ending in dots or spaces, or containing `~`, also -cannot establish separation because some filesystems treat them as aliases. -This conservative preflight is read-only and does not guess the -filesystem's normalization policy. It builds on `lifecycle::ensure_separate_paths`, -which checks ancestry and existing directory identities. These checks assume a -cooperative filesystem; they do not reserve paths against another process changing -them after preflight. - -Checking compares the complete owned tree without writing. Building requires a -fresh destination separate from the retained baseline. Updating is explicit and -stages the complete candidate before replacing a compatible owned destination. -Ordinary publication failures preserve or restore a backup; this is not a -crash-atomic transaction. Multiple audiences publish independently. A successful -update records candidate bytes; it does not establish human review or approve a -release. Sites, navigation, styling, review policy, and application drivers belong -to each consumer. - -An existing host can use `Document::render_fragment` to retain its page title, -artifact paths and formatting. A fragment adds no title or block anchors, and -rejects typed block links that would point to omitted anchors. The host owns -whole-bundle link validation for that export. Handle ownership, image kinds, -resource accounting, and audience exclusions still apply. This adapter seam -does not weaken ordinary `render_many` bundle checking. -`Document::resource_files(audience)` returns only that audience's frozen resource -paths and bytes, without a page or manifest. Fragment adapters must apply the same -audience to both text and copied files so contributor-only resources stay private. - -The lower-level `lifecycle` module shares preparation, complete tree comparison, -path validation, staging, publication, and recovery with hosts that retain their -own manifest format. `Ownership::Dedicated` adds no metadata to a host-owned tree -and refuses unowned preexisting files; `Ownership::Recorded` retains explicit -ownership metadata for SDK exports. Host compatibility rules are explicit -callbacks. The structured `store` wrapper validates the SDK schema, identities, -references, complete artifact inventory, byte lengths and hashes, audience, and -producer/profile metadata before publication. Checks and recovery also validate -the retained tree. Updating permits an explicitly removed obsolete artifact only -when the complete candidate also omits that path; retained bytes must still match -their receipt. The generic lifecycle does not interpret application evidence or -claim that every byte is reproducible across different environments. - -Manifest schema version 1 records: - -| Field | Meaning | -| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `schema_version`, `audience` | Interpretation and visibility policy for this export. | -| `profiles` | Sorted producer/profile pairs for exported resources. | -| `documents` | Document IDs, titles, visible block IDs and reference relations, executed checks, and any visible typed bindings. | -| `artifacts` | Every exported page and resource except the manifest itself, including byte length and SHA-256; resources also identify owner, MIME, producer and profile. | - -Check records contain their ID, kind, and passed status. Contributor records also -contain observed and expected values. Reader records include those payloads only -when a public typed reference uses the value; check IDs and status are still -public. Treat evidence IDs as public metadata. Profiles describe the producer's -comparison conditions and must be chosen honestly by the adapter; they are not a -complete environment fingerprint. The manifest contains no run timestamp, -absolute source path, or process ID. Full tree comparison checks the manifest's -bytes as well as artifact bytes; a manifest cannot contain its own final hash. - -The initial schema is not a cross-language execution protocol or a promised -long-term compatibility standard. See the source and tests for its current -failure semantics. No crate release, registry publication, or repository split is -implied by this maintained local package. - -### Review exported documents - -The `preview` example renders a valid SDK export into a new disposable HTML tree. -It validates the source's complete receipt and ownership, then uses artifact -identities to convert pages, resolve page links, and preserve download bytes. -Custom page extensions work; a Markdown resource remains a download. Tables and -strikethrough use the same options as compilation. Page/resource output collisions -fail before writing. Reader previews contain only Reader-exported resources. - -After generating the [consumer example's candidate](../doc-example-config/README.md#review-intentional-documentation-changes), -run from this package directory: - -```sh -cargo run --example preview -- ../doc-example-config/.cache/config-review/reader .cache/config-reader-preview -cargo run --example preview -- ../doc-example-config/.cache/config-review/contributor .cache/config-contributor-preview -``` - -Open the resulting HTML pages in a browser and inspect both audiences and their -resources before explicitly accepting a baseline. The preview requires a new -destination and omits receipt metadata because its HTML differs from the exported -Markdown. It does not update the export or accept a baseline. - -## Develop and verify the portable packages - -From this package directory, run the package-owned GPU-free gate: - -```sh -python3 dev/verify.py -python3 dev/verify.py --standalone --toolchain 1.95.0 --offline -``` - -The first command uses the containing workspace's existing lockfile and host -toolchain. It checks Rust formatting for only the SDK and consumer packages, -warnings-denied Clippy, all targets including examples and exact Reader/Contributor -baselines, both packages' doctests, and warnings-denied API documentation. It also -runs the development harness's Python regressions. Python 3 and Rust with rustfmt -and Clippy are required; no N3 setup command or graphics adapter is involved. -N3's `just doc-framework-test` and its CI invoke this same gate. - -The standalone command copies both sibling packages, tests, examples, baselines, -and license notices into a disposable minimal workspace. It resolves a temporary -lockfile independently of N3's lockfile, patches, assets, and dependencies, then -runs the same gate on the explicitly selected installed toolchain. Rust 1.95.0 -is the declared and verified minimum. `just doc-framework-smoke --offline` runs -this check from N3. Omit `--offline` to allow dependency resolution when caches -are cold. Temporary resolution can select different compatible transitive -versions from N3's lockfile; its lockfile is deleted with the temporary workspace. -No second permanent workspace or lockfile is maintained here. - -Explicit `CARGO_HOME`, `CARGO_TARGET_DIR`, and offline environment settings are -preserved. Relative cache paths retain their meaning from the invocation directory -when checks change their working directory; absolute paths and empty overrides -are passed through. Without a target override, standalone builds reuse the ignored -package-local `.cache/standalone-target` directory. Omitting `--toolchain` uses -the installed Cargo toolchain selected in the temporary workspace; it does not -inherit N3's toolchain pin. - -Authored documentation consists of the two package READMEs and Rust documentation -comments. Rust formatting belongs to this gate. In N3, the authored READMEs use -`just fmt-docs`; the portable gate adds no Node.js requirement. Generated example -baselines, guide pages, manifests, and resources are excluded from prose -formatting and change only through explicit candidate review and baseline update. -Independent repository extraction will carry this development harness and both -packages together. - -## License and provenance - -The package originates in N3's executable documentation harness and its local -framework experiment. It uses the repository's existing MIT license, copied -verbatim into the packaged `LICENSE` file so a standalone copy preserves its notice. -The notice retains `Copyright (c) 2026 Grida`. Dependency licenses remain those of -their respective packages; no dependency code is vendored here. -The pinned direct dependency manifests declare MIT for `pulldown-cmark`, -`Unlicense/MIT` for `same-file`, and -MIT OR Apache-2.0 for `serde`, `serde_json`, `sha2`, and the `tempfile` development -dependency. This records the current source metadata rather than changing any -upstream terms. diff --git a/crates/lgtmd/dev/tests/test_verify.py b/crates/lgtmd/dev/tests/test_verify.py deleted file mode 100644 index 6e95291..0000000 --- a/crates/lgtmd/dev/tests/test_verify.py +++ /dev/null @@ -1,133 +0,0 @@ -import contextlib -import importlib.util -import io -import os -from pathlib import Path -import tempfile -import unittest -from unittest.mock import patch - - -SPEC = importlib.util.spec_from_file_location("portable_verify", Path(__file__).resolve().parents[1] / "verify.py") -verify = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(verify) - - -class PortableVerificationTests(unittest.TestCase): - def setUp(self): - temporary = tempfile.TemporaryDirectory() - self.addCleanup(temporary.cleanup) - self.root = Path(temporary.name) / "host with spaces $(not-a-command)" - self.sdk = self.root / "crates/lgtmd" - self.example = self.root / "crates/doc-example-config" - for package in (self.sdk, self.example): - (package / "src").mkdir(parents=True) - (package / "src/lib.rs").write_text("// Portable source.\n") - (package / "Cargo.toml").write_text('[package]\nlicense = "MIT"\n') - (package / "README.md").write_text("# Package\n") - (package / "LICENSE").write_text("MIT License\nCopyright notice\n") - - def test_copy_preserves_baselines_and_license_without_host_dependencies_or_output(self): - (self.root / "Cargo.toml").write_text("host patches and graphics dependencies\n") - (self.root / "Cargo.lock").write_text("host lockfile\n") - (self.root / "rust-toolchain.toml").write_text("host toolchain pin\n") - baseline = self.example / "baseline/reader" - baseline.mkdir(parents=True) - (baseline / "manifest.json").write_bytes(b'{"exact":"baseline"}\n') - (baseline / ".ownership.json").write_bytes(b'{"owned":"receipt"}\n') - download = baseline / "assets/target/Cargo.lock" - download.parent.mkdir(parents=True) - download.write_bytes(b"An owned download, not a build lockfile.\n") - private_download = baseline / "assets/.cache/__pycache__/receipt.txt" - private_download.parent.mkdir(parents=True) - private_download.write_bytes(b"Owned bytes also survive cache-like names.\n") - for excluded in (".cache", "target", "__pycache__"): - (self.sdk / excluded).mkdir() - (self.sdk / excluded / "disposable").write_text("ignored\n") - (self.sdk / "Cargo.lock").write_text("package lockfile is not portable input\n") - destination = self.root.parent / "isolated" - copied = verify.payload(self.sdk, destination) - self.assertEqual(copied, destination / "crates/lgtmd") - self.assertNotIn("host", (destination / "Cargo.toml").read_text()) - self.assertFalse((destination / "Cargo.lock").exists()) - self.assertFalse((destination / "rust-toolchain.toml").exists()) - for excluded in (".cache", "target", "__pycache__", "Cargo.lock"): - self.assertFalse((copied / excluded).exists()) - for package in ("lgtmd", "doc-example-config"): - self.assertEqual((destination / "crates" / package / "LICENSE").read_bytes(), b"MIT License\nCopyright notice\n") - copied_baseline = destination / "crates/doc-example-config/baseline/reader" - for name in ("manifest.json", ".ownership.json"): - self.assertEqual((copied_baseline / name).read_bytes(), (baseline / name).read_bytes()) - self.assertEqual((copied_baseline / "assets/target/Cargo.lock").read_bytes(), download.read_bytes()) - self.assertEqual((copied_baseline / "assets/.cache/__pycache__/receipt.txt").read_bytes(), private_download.read_bytes()) - - def test_copy_fails_if_provenance_is_missing(self): - (self.example / "LICENSE").unlink() - with self.assertRaisesRegex(OSError, "LICENSE"): - verify.payload(self.sdk, self.root.parent / "isolated") - - def test_standalone_resolves_a_temporary_lock_and_failure_stops_the_gate(self): - calls = [] - - def fail_resolution(command, *, cwd, environment): - calls.append((command, cwd, environment)) - self.assertTrue((cwd.parent.parent / "Cargo.toml").exists()) - self.assertFalse((cwd.parent.parent / "Cargo.lock").exists()) - return 17 - - with patch.object(verify, "run", side_effect=fail_resolution): - self.assertEqual(verify.main(["--standalone", "--toolchain", "1.95.0", "--offline"], sdk=self.sdk), 17) - self.assertEqual(calls[0][0], ["cargo", "+1.95.0", "generate-lockfile", "--offline"]) - self.assertEqual(len(calls), 1) - self.assertFalse(calls[0][1].exists()) - self.assertFalse((self.root / "Cargo.lock").exists()) - - def test_failing_consumer_checks_stop_before_doctests_and_docs(self): - calls = [] - - def fail_tests(command, *, cwd, environment): - calls.append(command) - return 19 if "--all-targets" in command and "test" in command else 0 - - with patch.object(verify, "run", side_effect=fail_tests): - self.assertEqual(verify.main([], sdk=self.sdk), 19) - self.assertEqual(len(calls), 4) - self.assertFalse(any("doc" in command or "--doc" in command for command in calls)) - - def test_environment_keeps_explicit_caches_and_doc_flags_without_mutating_caller(self): - environment = {"CARGO_TARGET_DIR": "/explicit target", "CARGO_HOME": "/explicit cargo", "RUSTDOCFLAGS": "--cfg custom"} - with patch.dict(os.environ, environment, clear=True): - prepared = verify.environment_for(self.sdk, True) - self.assertEqual(prepared["CARGO_TARGET_DIR"], "/explicit target") - self.assertEqual(prepared["CARGO_HOME"], "/explicit cargo") - self.assertNotIn("PYTHONDONTWRITEBYTECODE", os.environ) - with patch.object(verify, "run", return_value=0) as run: - self.assertEqual(verify.checks(self.sdk, ["cargo"], prepared, False), 0) - docs = run.call_args.kwargs["environment"] - self.assertEqual(docs["RUSTDOCFLAGS"], "--cfg custom -D warnings") - self.assertEqual(prepared["RUSTDOCFLAGS"], "--cfg custom") - - def test_relative_caches_keep_the_invocation_location_when_check_cwd_changes(self): - for standalone in (False, True): - with self.subTest(standalone=standalone), patch.dict(os.environ, { - "CARGO_HOME": "cache with spaces $(not-a-command)", - "CARGO_TARGET_DIR": "../shared target", - }, clear=True), patch.object(verify.Path, "cwd", return_value=self.root): - prepared = verify.environment_for(self.sdk, standalone) - self.assertEqual(prepared["CARGO_HOME"], str(self.root / "cache with spaces $(not-a-command)")) - self.assertEqual(prepared["CARGO_TARGET_DIR"], str(self.root / "../shared target")) - self.assertEqual(os.environ["CARGO_HOME"], "cache with spaces $(not-a-command)") - with patch.dict(os.environ, {"CARGO_HOME": "", "CARGO_TARGET_DIR": ""}, clear=True): - prepared = verify.environment_for(self.sdk, True) - self.assertEqual(prepared["CARGO_HOME"], "") - self.assertEqual(prepared["CARGO_TARGET_DIR"], "") - - def test_missing_cargo_fails_visibly(self): - output = io.StringIO() - with contextlib.redirect_stderr(output), patch.object(verify, "run", side_effect=FileNotFoundError("cargo unavailable")): - self.assertEqual(verify.main([], sdk=self.sdk), 1) - self.assertIn("cargo unavailable", output.getvalue()) - - -if __name__ == "__main__": - unittest.main() diff --git a/crates/lgtmd/dev/verify.py b/crates/lgtmd/dev/verify.py deleted file mode 100644 index 2ea85a9..0000000 --- a/crates/lgtmd/dev/verify.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Verify the portable Rust SDK and its real consumers without application replay.""" - -import argparse -import os -from pathlib import Path -import shutil -import subprocess -import sys -import tempfile - - -SDK = Path(__file__).resolve().parents[1] -PACKAGES = ("lgtmd", "doc-example-config") -WORKSPACE = """[workspace] -members = ["crates/lgtmd", "crates/doc-example-config"] -resolver = "3" -""" - - -def status(returncode): - return returncode if returncode >= 0 else 128 - returncode - - -def run(command, *, cwd, environment): - # Argument lists preserve paths and toolchain names as data, including spaces. - print("Portable check: " + " ".join(command), flush=True) - return status(subprocess.run(command, cwd=cwd, env=environment, check=False).returncode) - - -def payload(sdk, destination): - """Copy the maintained packages, without the host workspace or disposable output.""" - for source in (sdk, sdk.parent / "doc-example-config"): - for required in ("Cargo.toml", "README.md", "LICENSE", "src"): - if not (source / required).exists(): - raise OSError(f"Portable payload is missing {source / required}") - def disposable(directory, names): - directory = Path(directory) - excluded = set() - if directory == source: - excluded.update((".cache", "target", "Cargo.lock", "__pycache__")) - if directory == source / "dev" or source / "dev" in directory.parents: - excluded.add("__pycache__") - return excluded.intersection(names) - - shutil.copytree( - source, - destination / "crates" / source.name, - ignore=disposable, - ) - (destination / "Cargo.toml").write_text(WORKSPACE, encoding="utf-8") - return destination / "crates/lgtmd" - - -def environment_for(sdk, standalone): - environment = os.environ.copy() - environment["PYTHONDONTWRITEBYTECODE"] = "1" - invocation = Path.cwd() - for variable in ("CARGO_HOME", "CARGO_TARGET_DIR"): - value = environment.get(variable) - if value and not Path(value).is_absolute(): - # Cargo interprets these against its working directory. Capture the - # caller's location before native/standalone checks change that cwd. - environment[variable] = str(invocation / value) - if standalone: - # Keep rebuilds cheap while preserving explicit native/container overrides. - environment.setdefault("CARGO_TARGET_DIR", str(sdk / ".cache/standalone-target")) - return environment - - -def checks(sdk, cargo, environment, offline): - selection = [part for package in PACKAGES for part in ("-p", package)] - locked = ["--locked", *(["--offline"] if offline else [])] - commands = [ - [sys.executable, "-m", "unittest", "discover", "-s", str(sdk / "dev/tests"), "-p", "test_*.py"], - [*cargo, "fmt", *selection, "--check"], - [*cargo, "clippy", *locked, *selection, "--all-targets", "--", "-D", "warnings"], - [*cargo, "test", *locked, *selection, "--all-targets"], - [*cargo, "test", *locked, *selection, "--doc"], - [*cargo, "doc", *locked, *selection, "--no-deps"], - ] - for command in commands: - command_environment = environment - if command[:len(cargo) + 1] == [*cargo, "doc"]: - command_environment = environment.copy() - command_environment["RUSTDOCFLAGS"] = environment.get("RUSTDOCFLAGS", "") + " -D warnings" - result = run(command, cwd=sdk, environment=command_environment) - if result: - return result - return 0 - - -def main(arguments=None, *, sdk=SDK): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--standalone", action="store_true", help="copy both packages to a disposable minimal workspace") - parser.add_argument("--toolchain", help="select an installed rustup toolchain, e.g. 1.95.0") - parser.add_argument("--offline", action="store_true", help="resolve and verify using cached Cargo dependencies") - options = parser.parse_args(arguments) - cargo = ["cargo", *([f"+{options.toolchain}"] if options.toolchain else [])] - try: - sdk = Path(sdk).resolve() - environment = environment_for(sdk, options.standalone) - if not options.standalone: - return checks(sdk, cargo, environment, options.offline) - with tempfile.TemporaryDirectory(prefix="lgtmd-") as temporary: - isolated = payload(sdk, Path(temporary)) - # Resolve only portable dependencies into a temporary lockfile. Never - # copy or modify the application's lockfile, patches, or toolchain pin. - result = run( - [*cargo, "generate-lockfile", *(["--offline"] if options.offline else [])], - cwd=isolated, environment=environment, - ) - if result: - return result - return checks(isolated, cargo, environment, options.offline) - except OSError as error: - print(f"Portable verification failed: {error}", file=sys.stderr) - return 1 - except KeyboardInterrupt: - return 130 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/crates/lgtmd/examples/configured.rs b/crates/lgtmd/examples/configured.rs deleted file mode 100644 index 795248d..0000000 --- a/crates/lgtmd/examples/configured.rs +++ /dev/null @@ -1,144 +0,0 @@ -//! The host owns its project root, fixtures, audience destinations and export layout. -//! Run: configured PROJECT_ROOT BASELINE_ROOT check|update|build [SDK options] -//! PROJECT_ROOT contains fixtures/ports.txt with two nonzero ports, one per line. -use lgtmd::{ - Audience, Doc, Document, ExportLayout, Result, prose, - runner::{RunnerConfig, run_cli_with}, -}; -use std::path::Path; - -fn main() { - if let Err(error) = execute() { - eprintln!("{error}"); - std::process::exit(1); - } -} - -fn execute() -> Result<()> { - let mut args = std::env::args().skip(1); - let usage = "Usage: configured PROJECT_ROOT BASELINE_ROOT check|update|build [SDK options]"; - let project = args.next().ok_or(usage)?; - let baseline = args.next().ok_or(usage)?; - let project = Path::new(&project) - .canonicalize() - .map_err(|error| format!("Cannot resolve the project root: {error}"))?; - let config = configuration(&project, Path::new(&baseline))?; - run_cli_with(args, &config, || guide(&project)) -} - -fn configuration(project: &Path, baseline: &Path) -> Result { - let layout = ExportLayout::default() - .pages_under("handbook")? - .resources_under("downloads/evidence")? - .page("ports", "manual/network/index.md")?; - Ok(RunnerConfig::new(project)? - .output(Audience::Reader, baseline.join("published"), "website")? - .output( - Audience::Contributor, - baseline.join("review-notes"), - "maintainers", - )? - .layout(layout)) -} - -fn guide(project: &Path) -> Result> { - let text = std::fs::read_to_string(project.join("fixtures/ports.txt")) - .map_err(|error| format!("Cannot read the project's ports fixture: {error}"))?; - let ports: Vec = text - .lines() - .map(|line| line.parse::().map_err(|error| error.to_string())) - .collect::>()?; - let mut doc = Doc::new("ports", "Read configured ports")?; - let count = doc.expect_eq("fixture-port-count", ports.len(), 2)?; - doc.require("ports-are-nonzero", ports.iter().all(|port| *port != 0))?; - doc.paragraph(prose!( - "The fixture supplies {count} nonzero ports.", - count = &count - )?)?; - let input = doc.text_code("input", text, "text")?; - doc.paragraph(("Download ", &input, " to reuse the fixture."))?; - let observation = doc.json("private-observation", &ports)?; - doc.note_on( - &input, - ( - "The private parsed-port observation is ", - &observation, - ". It is included only in contributor exports.", - ), - )?; - - let mut overview = Doc::new("overview", "Fixture contract")?; - overview.require("fixture-was-parsed", !ports.is_empty())?; - overview.paragraph("This project reads its own fixture before composing the documents.")?; - Ok(vec![doc.finish()?, overview.finish()?]) -} - -#[cfg(test)] -mod tests { - use super::*; - use lgtmd::{lifecycle, runner::RunMode, runner::run}; - use std::sync::atomic::{AtomicUsize, Ordering}; - - #[test] - fn project_layout_and_audience_destinations_are_host_owned() { - let temporary = tempfile::tempdir().unwrap(); - let root = temporary.path().canonicalize().unwrap(); - let project = root.join("source-only-project"); - let baseline = root.join("external-publisher/retained"); - std::fs::create_dir_all(project.join("fixtures")).unwrap(); - std::fs::create_dir_all(&baseline).unwrap(); - std::fs::write(project.join("fixtures/ports.txt"), "8080\n9090\n").unwrap(); - std::fs::write(baseline.join("publisher-owned.txt"), "Keep this file.\n").unwrap(); - let config = configuration(&project, &baseline).unwrap(); - let audiences = [Audience::Reader, Audience::Contributor]; - let executions = AtomicUsize::new(0); - let generate = || { - executions.fetch_add(1, Ordering::SeqCst); - guide(&project) - }; - - run(&config, RunMode::Update, &audiences, generate).unwrap(); - assert_eq!(executions.load(Ordering::SeqCst), 1); - run(&config, RunMode::Check, &audiences, generate).unwrap(); - let candidate = root.join("separate-review-output"); - run( - &config, - RunMode::Build(candidate.clone()), - &audiences, - generate, - ) - .unwrap(); - assert_eq!(executions.load(Ordering::SeqCst), 3); - - let reader = lifecycle::read_tree(&candidate.join("website")).unwrap(); - let contributor = lifecycle::read_tree(&candidate.join("maintainers")).unwrap(); - assert!(reader.contains_key("manual/network/index.md")); - assert!(reader.contains_key("handbook/overview.md")); - assert!(reader.contains_key("downloads/evidence/ports/input.txt")); - assert!( - !reader - .keys() - .any(|path| path.contains("private-observation")) - ); - assert!( - contributor - .keys() - .any(|path| path.contains("private-observation")) - ); - for (name, files) in [("published", reader), ("review-notes", contributor)] { - assert_eq!(files, lifecycle::read_tree(&baseline.join(name)).unwrap()); - for bytes in files.values() { - let text = String::from_utf8_lossy(bytes); - assert!(!text.contains(root.to_str().unwrap())); - } - } - assert_eq!( - std::fs::read_to_string(baseline.join("publisher-owned.txt")).unwrap(), - "Keep this file.\n" - ); - assert!(!project.join("Cargo.toml").exists()); - assert!(!project.join(".git").exists()); - assert!(!candidate.join("reader").exists()); - assert!(!candidate.join("contributor").exists()); - } -} diff --git a/crates/lgtmd/examples/conformance.rs b/crates/lgtmd/examples/conformance.rs deleted file mode 100644 index d8f1a73..0000000 --- a/crates/lgtmd/examples/conformance.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Emit common frozen input and exports from actual native authoring. The Node -//! conformance test authors the same document independently and compares bytes. -use lgtmd::{ - Artifact, Doc, ExportLayout, Result, - transport::{self, Input}, -}; -use serde_json::json; - -fn main() -> Result<()> { - let mut doc = Doc::new("portable", "Portable document")?; - let count = doc.observe("count", 3)?; - let private = doc.observe("internal-only", "private observation")?; - doc.require("verified-count", true)?; - doc.heading_with_id("overview", 2, "Overview")?; - let passage = doc.markdown_parts(("Contains ", &count, " records.\n\n"))?; - doc.note_on(&passage, ("Internal: ", &private))?; - let data = doc.json("data", &json!({"z":2,"a":1}))?; - doc.markdown_parts(( - "Data:\n\n", - data.code("json"), - "\n\n", - data.link("Download"), - "\n\n", - ))?; - let image = doc.resource( - "image", - Artifact::new(vec![1, 2, 3], "image/png", "png", "fixture", "exact-v1")?, - )?; - doc.markdown_parts((image.image("Fixed fixture bytes"), "\n\n"))?; - let input = Input::from_documents(&[doc.finish()?])?; - let exports = transport::compile(&input, &ExportLayout::default())?; - println!( - "{}", - serde_json::to_string_pretty(&json!({"input":input,"exports":exports})) - .map_err(|e| e.to_string())? - ); - Ok(()) -} diff --git a/crates/lgtmd/examples/preview.rs b/crates/lgtmd/examples/preview.rs deleted file mode 100644 index 25c79a4..0000000 --- a/crates/lgtmd/examples/preview.rs +++ /dev/null @@ -1,488 +0,0 @@ -//! Disposable static rendering for human review, separate from guide generation. -use lgtmd::{ - Result, - lifecycle::{self, Files, Ownership}, -}; -use pulldown_cmark::{Event, Options, Parser, Tag, html}; -use std::{ - collections::{BTreeMap, BTreeSet}, - env, - path::Path, -}; - -fn main() -> std::result::Result<(), Box> { - let args: Vec<_> = env::args().skip(1).collect(); - if args.len() != 2 { - return Err("Usage: preview EXPORT_DIRECTORY NEW_PREVIEW_DIRECTORY".into()); - } - let destination = Path::new(&args[1]); - preview(Path::new(&args[0]), destination)?; - println!("Preview: {}", destination.display()); - Ok(()) -} - -fn preview(source: &Path, destination: &Path) -> Result<()> { - let source = source.canonicalize().map_err(|error| error.to_string())?; - let destination = lifecycle::absolute(destination)?; - lifecycle::ensure_separate_paths(&source, &destination)?; - if !source.is_dir() { - return Err("Preview source must be an exported directory".into()); - } - if destination.exists() { - return Err("Preview destination must be new; preserve earlier review evidence".into()); - } - let mut files = lifecycle::read_tree(&source)?; - files.remove(".ownership.json"); - // Validate the complete tree before rendering. In particular, a private or - // unrecorded file added to a Reader export must never become a preview asset. - lgtmd::store::check(&source, &files)?; - let output = preview_files(&files)?; - lifecycle::build(&destination, &output, Ownership::Dedicated) -} - -fn preview_link(destination: &str, page: &str, pages: &BTreeMap) -> Result { - if destination.contains(':') || destination.starts_with("//") { - return Ok(destination.to_owned()); - } - let (path, fragment) = destination - .split_once('#') - .map_or((destination, None), |(path, fragment)| { - (path, Some(fragment)) - }); - if path.is_empty() { - return Ok(destination.to_owned()); - } - let decoded = decode_path(path)?; - let mut target: Vec<_> = page.split('/').collect(); - target.pop(); - for part in decoded.split('/') { - match part { - "." => {} - ".." => { - target - .pop() - .ok_or_else(|| format!("Preview link escapes export: {destination}"))?; - } - _ => target.push(part), - } - } - let Some(output) = pages.get(&target.join("/")) else { - return Ok(destination.to_owned()); - }; - let mut directory: Vec<_> = pages[page].split('/').collect(); - directory.pop(); - let output: Vec<_> = output.split('/').collect(); - let common = directory - .iter() - .zip(&output) - .take_while(|(left, right)| left == right) - .count(); - let relative = std::iter::repeat_n("..", directory.len() - common) - .chain(output[common..].iter().copied()) - .collect::>() - .join("/"); - let mut link: String = relative - .bytes() - .map(|byte| { - if byte.is_ascii_alphanumeric() || b"/-._~".contains(&byte) { - (byte as char).to_string() - } else { - format!("%{byte:02X}") - } - }) - .collect(); - if let Some(fragment) = fragment { - link.push('#'); - link.push_str(fragment); - } - Ok(link) -} - -fn decode_path(path: &str) -> Result { - let mut bytes = Vec::new(); - let mut position = 0; - while position < path.len() { - if path.as_bytes()[position] == b'%' { - let escape = path - .get(position + 1..position + 3) - .ok_or_else(|| format!("Malformed preview link escape: {path}"))?; - bytes.push( - u8::from_str_radix(escape, 16) - .map_err(|_| format!("Malformed preview link escape: {path}"))?, - ); - position += 3; - } else { - bytes.push(path.as_bytes()[position]); - position += 1; - } - } - String::from_utf8(bytes).map_err(|_| format!("Preview link is not UTF-8: {path}")) -} - -fn preview_files(files: &Files) -> Result { - let manifest: serde_json::Value = - serde_json::from_slice(&files["manifest.json"]).map_err(|error| error.to_string())?; - let artifacts = manifest["artifacts"] - .as_array() - .ok_or("Manifest artifacts must be an array")?; - // The store already owns schema validation. This review tool only needs the - // inventory's page identities; extensions say nothing about artifact kind. - let pages: BTreeMap<_, _> = artifacts - .iter() - .filter(|artifact| artifact["kind"] == "page") - .map(|artifact| { - let path = artifact["path"] - .as_str() - .ok_or("Manifest artifact requires a path")?; - let output = Path::new(path).with_extension("html"); - Ok(( - path.to_owned(), - output.to_str().ok_or("Non UTF-8 preview path")?.to_owned(), - )) - }) - .collect::>()?; - let mut paths = BTreeSet::new(); - for path in files.keys().filter(|path| path.as_str() != "manifest.json") { - let output = pages.get(path).unwrap_or(path); - if paths.iter().any(|existing: &String| { - existing == output - || output.starts_with(&format!("{existing}/")) - || existing.starts_with(&format!("{output}/")) - }) { - return Err(format!("Preview output collision: {output}")); - } - paths.insert(output.to_owned()); - } - let mut output = Files::new(); - for (path, bytes) in files - .iter() - .filter(|(path, _)| path.as_str() != "manifest.json") - { - let Some(output_path) = pages.get(path) else { - output.insert(path.clone(), bytes.clone()); - continue; - }; - let source = - std::str::from_utf8(bytes).map_err(|error| format!("Preview page {path}: {error}"))?; - let parser = Parser::new_ext( - source, - Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH, - ) - .map(|event| match event { - Event::Start(Tag::Link { - link_type, - dest_url, - title, - id, - }) => Ok(Event::Start(Tag::Link { - link_type, - dest_url: preview_link(&dest_url, path, &pages)?.into(), - title, - id, - })), - other => Ok(other), - }) - .collect::>>()?; - let mut body = String::new(); - html::push_html(&mut body, parser.into_iter()); - output.insert(output_path.clone(), format!( - "Executable documentation review
{body}
" - ).into_bytes()); - } - Ok(output) -} - -const STYLE: &str = r#" -html { color-scheme: light; background: #f5f6f8; color: #17212b; } -body { margin: 0; font: 17px/1.65 -apple-system, BlinkMacSystemFont, sans-serif; } -main { max-width: 800px; margin: 42px auto; padding: 36px 48px; background: white; border: 1px solid #dde2e7; border-radius: 12px; } -h1, h2, h3 { line-height: 1.2; color: #0b243c; } h1 { font-size: 34px; } h2 { margin-top: 38px; } -a { color: #005d9f; } code { font: 0.88em/1.5 ui-monospace, monospace; background: #f1f3f6; padding: 2px 5px; border-radius: 3px; } -pre { overflow: auto; padding: 18px; border: 1px solid #dce3e9; border-radius: 6px; background: #f7f9fb; } pre code { padding: 0; background: transparent; } -blockquote { margin: 24px 0; border-left: 4px solid #ae7c19; background: #fff8e6; padding: 8px 20px; } -img { max-width: 100%; } table { border-collapse: collapse; width: 100%; } th, td { text-align: left; border: 1px solid #dce3e9; padding: 8px; } -@media(max-width: 700px) { main { margin: 0; padding: 22px; border: 0; } } -"#; - -#[cfg(test)] -mod tests { - use super::*; - use lgtmd::{Artifact, Audience, Doc, Document, ExportLayout}; - use std::fs; - - fn doc(id: &str) -> Doc { - let mut doc = Doc::new(id, id).unwrap(); - doc.require("executed", true).unwrap(); - doc - } - - fn text(bytes: &[u8], extension: &str) -> Artifact { - Artifact::new(bytes, "text/plain", extension, "preview-test", "v1").unwrap() - } - - fn export(source: &Path, document: Document) { - let files = document - .render_with(Audience::Reader, &ExportLayout::default()) - .unwrap(); - lgtmd::store::build(source, &files).unwrap(); - } - - #[test] - fn preview_rejects_overlapping_trees_before_creating_output() { - let temporary = tempfile::tempdir().unwrap(); - let root = temporary.path().canonicalize().unwrap(); - let source = root.join("export"); - fs::create_dir(&source).unwrap(); - fs::write(source.join("guide.md"), "Retain the source.").unwrap(); - for destination in [&source, &source.join("preview"), &root] { - assert!(preview(&source, destination).is_err()); - } - assert_eq!(fs::read_dir(&source).unwrap().count(), 1); - assert_eq!( - fs::read_to_string(source.join("guide.md")).unwrap(), - "Retain the source." - ); - } - - #[test] - fn preview_uses_inventory_for_pages_downloads_and_both_audiences() { - let temporary = tempfile::tempdir().unwrap(); - let root = temporary.path().canonicalize().unwrap(); - let mut first = doc("start"); - first.heading_with_id("intro", 2, "Start here").unwrap(); - first - .markdown( - "~~Old wording~~\n\n| Option | Value |\n| --- | --- |\n| Retries | 3 |\n\n\ - [Other](nested/other.source#target)\n[Self](#intro)\n\ - [Remote](https://example.com/other.md#section)\n\ - [Email](mailto:editor@example.md)", - ) - .unwrap(); - let markdown_download = - b"# Download remains Markdown\n[Other](../manual/nested/other.source#target)\n"; - let download = first - .resource_at( - "download", - text(markdown_download, "md"), - "downloads/raw.md", - ) - .unwrap(); - first.paragraph(download.link("Download source")).unwrap(); - let hidden = first - .resource_at( - "hidden", - text(b"A documented hidden file", "txt"), - ".fixture.txt", - ) - .unwrap(); - first.paragraph(hidden.link("Hidden filename")).unwrap(); - let private = first - .resource_at( - "private", - text(b"Private context", "txt"), - "internal/session.txt", - ) - .unwrap(); - first - .note(("Private rationale: ", private.link("Session"))) - .unwrap(); - let mut other = doc("other"); - other.heading_with_id("target", 2, "Other").unwrap(); - other - .markdown("[Back](../start.guide#intro)\n[Download](../../downloads/raw.md)") - .unwrap(); - let documents = [first.finish().unwrap(), other.finish().unwrap()]; - let layout = ExportLayout::default() - .page("start", "manual/start.guide") - .unwrap() - .page("other", "manual/nested/other.source") - .unwrap(); - for (audience, name) in [ - (Audience::Reader, "reader"), - (Audience::Contributor, "contributor"), - ] { - let source = root.join(format!("{name}-export")); - let destination = root.join(format!("{name}-preview")); - let files = lgtmd::render_with(&documents, audience, &layout).unwrap(); - lgtmd::store::build(&source, &files).unwrap(); - let retained = lifecycle::read_tree(&source).unwrap(); - preview(&source, &destination).unwrap(); - let html = fs::read_to_string(destination.join("manual/start.html")).unwrap(); - for target in [ - "nested/other.html#target", - "#intro", - "../downloads/raw.md", - "https://example.com/other.md#section", - "mailto:editor@example.md", - ] { - assert!(html.contains(&format!("href=\"{target}\"")), "{html}"); - } - assert!(html.contains("Old wording"), "{html}"); - assert!(html.contains(""), "{html}"); - assert!(html.contains(""), "{html}"); - let nested = fs::read_to_string(destination.join("manual/nested/other.html")).unwrap(); - assert!(nested.contains("href=\"../start.html#intro\""), "{nested}"); - assert!( - nested.contains("href=\"../../downloads/raw.md\""), - "{nested}" - ); - assert_eq!( - fs::read(destination.join("downloads/raw.md")).unwrap(), - markdown_download - ); - assert!(!destination.join("downloads/raw.html").exists()); - assert_eq!( - fs::read(destination.join(".fixture.txt")).unwrap(), - b"A documented hidden file" - ); - assert_eq!( - html.contains("Private rationale"), - audience == Audience::Contributor - ); - assert_eq!( - destination.join("internal/session.txt").exists(), - audience == Audience::Contributor - ); - assert!(!destination.join(".ownership.json").exists()); - assert!(!destination.join("manifest.json").exists()); - assert!(!destination.join("manual/start.guide").exists()); - assert_eq!(lifecycle::read_tree(&source).unwrap(), retained); - } - } - - #[test] - fn preview_preserves_external_urls_and_resolves_encoded_page_names() { - let pages = BTreeMap::from([ - ("manual/start.guide".into(), "manual/start.html".into()), - ( - "manual/入口/other.source".into(), - "manual/入口/other.html".into(), - ), - ]); - let page = "manual/start.guide"; - assert_eq!( - preview_link("%E5%85%A5%E5%8F%A3/other.source#target", page, &pages).unwrap(), - "%E5%85%A5%E5%8F%A3/other.html#target" - ); - for link in [ - "https://example.com/other.md#section", - "mailto:editor@example.md", - "//example.com/other.md", - ] { - assert_eq!(preview_link(link, page, &pages).unwrap(), link); - } - } - - #[test] - fn preview_rejects_page_resource_and_directory_collisions_before_publication() { - let temporary = tempfile::tempdir().unwrap(); - let root = temporary.path().canonicalize().unwrap(); - for (index, path) in ["guide.html", "guide.html/download.txt"] - .into_iter() - .enumerate() - { - let source = root.join(format!("export-{index}")); - let destination = root.join(format!("preview-{index}")); - let mut guide = doc("guide"); - let resource = guide - .resource_at("collision", text(b"Retained bytes", "txt"), path) - .unwrap(); - guide.paragraph(resource.link("Download")).unwrap(); - export(&source, guide.finish().unwrap()); - let error = preview(&source, &destination).unwrap_err(); - assert!(error.contains("Preview output collision"), "{error}"); - assert!(!destination.exists()); - assert_eq!(fs::read(source.join(path)).unwrap(), b"Retained bytes"); - } - } - - #[test] - fn preview_rejects_pages_that_map_to_the_same_html_path() { - let temporary = tempfile::tempdir().unwrap(); - let root = temporary.path().canonicalize().unwrap(); - let source = root.join("export"); - let destination = root.join("preview"); - let documents = [ - doc("first").finish().unwrap(), - doc("second").finish().unwrap(), - ]; - let layout = ExportLayout::default() - .page("first", "guide.md") - .unwrap() - .page("second", "guide.source") - .unwrap(); - let files = lgtmd::render_with(&documents, Audience::Reader, &layout).unwrap(); - lgtmd::store::build(&source, &files).unwrap(); - assert!( - preview(&source, &destination) - .unwrap_err() - .contains("Preview output collision") - ); - assert!(!destination.exists()); - } - - #[test] - fn preview_rejects_changed_or_unrecorded_source_files_and_preserves_existing_review() { - let temporary = tempfile::tempdir().unwrap(); - let root = temporary.path().canonicalize().unwrap(); - let source = root.join("export"); - let destination = root.join("preview"); - let mut guide = doc("guide"); - guide.paragraph("Retain the source.").unwrap(); - export(&source, guide.finish().unwrap()); - fs::write(source.join("private.txt"), "Must never leak").unwrap(); - assert!( - preview(&source, &destination) - .unwrap_err() - .contains("unrecorded") - ); - assert!(!destination.exists()); - fs::remove_file(source.join("private.txt")).unwrap(); - fs::write(source.join("guide.md"), "Changed after export").unwrap(); - assert!( - preview(&source, &destination) - .unwrap_err() - .contains("size/hash mismatch") - ); - assert!(!destination.exists()); - fs::create_dir(&destination).unwrap(); - fs::write(destination.join("review.txt"), "Earlier evidence").unwrap(); - assert!( - preview(&source, &destination) - .unwrap_err() - .contains("must be new") - ); - assert_eq!( - fs::read(destination.join("review.txt")).unwrap(), - b"Earlier evidence" - ); - } - - #[cfg(unix)] - #[test] - fn preview_rejects_symlinks_in_export_and_destination_components() { - use std::os::unix::fs::symlink; - let temporary = tempfile::tempdir().unwrap(); - let root = temporary.path().canonicalize().unwrap(); - let source = root.join("export"); - export(&source, doc("guide").finish().unwrap()); - symlink(source.join("guide.md"), source.join("linked.md")).unwrap(); - let destination = root.join("preview"); - assert!( - preview(&source, &destination) - .unwrap_err() - .contains("symlink") - ); - assert!(!destination.exists()); - fs::remove_file(source.join("linked.md")).unwrap(); - let alias = root.join("alias"); - symlink(&root, &alias).unwrap(); - assert!( - preview(&source, &alias.join("preview")) - .unwrap_err() - .to_lowercase() - .contains("symlink") - ); - assert!(!destination.exists()); - } -} diff --git a/crates/lgtmd/src/artifact.rs b/crates/lgtmd/src/artifact.rs deleted file mode 100644 index 72f5e08..0000000 --- a/crates/lgtmd/src/artifact.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Owned artifact payloads and the explicit file-reading boundary. - -use crate::Result; -use std::path::Path; - -/// Owned bytes and producer metadata. Files are read immediately, never at render time. -#[derive(Clone, Debug)] -pub struct Artifact { - pub(crate) bytes: Vec, - pub(crate) mime: String, - pub(crate) extension: String, - pub(crate) producer: String, - pub(crate) profile: String, -} - -impl Artifact { - /// The one canonical built-in JSON encoder, shared by native authoring and - /// portable input. Encoding happens only from an already owned snapshot. - pub(crate) fn json(value: &impl serde::Serialize) -> Result { - let mut value = serde_json::to_value(value).map_err(|e| e.to_string())?; - value.sort_all_objects(); - let mut bytes = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?; - bytes.push(b'\n'); - Self::new( - bytes, - "application/json", - "json", - "executable-docs/json", - "json-pretty-v1", - ) - } - - pub fn new( - bytes: impl Into>, - mime: &str, - extension: &str, - producer: &str, - profile: &str, - ) -> Result { - let (major, minor) = mime - .split_once('/') - .ok_or("MIME type requires type/subtype")?; - let token = |s: &str| { - !s.is_empty() - && s.bytes() - .all(|b| b.is_ascii_alphanumeric() || b"!#$&^_.+-".contains(&b)) - }; - if !token(major) || !token(minor) { - return Err("MIME type must be a concrete type/subtype without parameters".into()); - } - if extension.is_empty() - || !extension - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) - { - return Err( - "Artifact extension must contain lowercase letters/digits without a dot".into(), - ); - } - for (label, value) in [("producer", producer), ("profile", profile)] { - if value.is_empty() || value.chars().any(char::is_control) { - return Err(format!( - "Artifact {label} must be nonempty and contain no control characters" - )); - } - } - Ok(Self { - bytes: bytes.into(), - mime: mime.into(), - extension: extension.into(), - producer: producer.into(), - profile: profile.into(), - }) - } - - pub fn from_file( - path: impl AsRef, - mime: &str, - extension: &str, - producer: &str, - profile: &str, - ) -> Result { - let bytes = std::fs::read(path.as_ref()) - .map_err(|e| format!("Cannot freeze artifact {}: {e}", path.as_ref().display()))?; - Self::new(bytes, mime, extension, producer, profile) - } -} diff --git a/crates/lgtmd/src/authoring.rs b/crates/lgtmd/src/authoring.rs deleted file mode 100644 index b2a44eb..0000000 --- a/crates/lgtmd/src/authoring.rs +++ /dev/null @@ -1,491 +0,0 @@ -//! Rust authoring operations and evidence acquisition before document freezing. - -use crate::{ - Artifact, Result, - model::{ - Binding, BindingValue, Block, Check, Checked, Claim, Content, Document, Inline, IntoProse, - Kind, Node, Observed, Reference, Resource, Target, valid_id, - }, -}; -use serde::Serialize; -use serde_json::Value; -use std::{ - collections::BTreeMap, - marker::PhantomData, - sync::atomic::{AtomicU64, Ordering}, -}; - -static NEXT_OWNER: AtomicU64 = AtomicU64::new(1); - -pub struct Doc { - owner: u64, - ids: BTreeMap, - document: Document, - errors: Vec, -} - -impl Doc { - pub fn new(id: &str, title: &str) -> Result { - if !valid_id(id) { - return Err(format!("Invalid document ID: {id:?}")); - } - if title.trim().is_empty() || title.chars().any(char::is_control) { - return Err("Document title must be nonempty single-line text".into()); - } - Ok(Self { - owner: NEXT_OWNER.fetch_add(1, Ordering::Relaxed), - ids: BTreeMap::new(), - document: Document { - id: id.into(), - title: title.into(), - nodes: Vec::new(), - checks: BTreeMap::new(), - observations: BTreeMap::new(), - artifacts: BTreeMap::new(), - resource_paths: BTreeMap::new(), - bindings: BTreeMap::new(), - }, - errors: Vec::new(), - }) - } - - fn guarded(&mut self, result: Result) -> Result { - result.map_err(|error| { - let contextual = format!("Document {}: {error}", self.document.id); - self.errors.push(contextual.clone()); - contextual - }) - } - - fn reserve(&mut self, id: &str, kind: Kind) -> Result { - if !valid_id(id) { - return Err(format!("Invalid evidence ID: {id:?}")); - } - if self.ids.contains_key(id) { - return Err(format!("Duplicate evidence ID: {id}")); - } - self.ids.insert(id.into(), kind.clone()); - Ok(Reference { - owner: self.owner, - id: id.into(), - kind, - }) - } - - fn validate_reference(&self, reference: &Reference) -> Result<()> { - if reference.owner != self.owner { - return Err(format!("Foreign document/run handle: {}", reference.id)); - } - if self.ids.get(&reference.id) != Some(&reference.kind) { - return Err(format!("Missing or wrong-kind reference: {}", reference.id)); - } - Ok(()) - } - - fn append(&mut self, content: Content) -> Result { - self.append_with_id(None, content) - } - - pub(crate) fn append_with_id(&mut self, id: Option<&str>, content: Content) -> Result { - let result = (|| { - let note = matches!(content, Content::Note { .. }); - let count = self - .document - .nodes - .iter() - .filter(|node| node.is_note() == note) - .count(); - let prefix = if note { "note" } else { "block" }; - let node = Node { - id: id - .map(str::to_owned) - .unwrap_or_else(|| format!("{prefix}-{:04}", count + 1)), - content, - }; - for reference in node.references() { - self.validate_reference(reference)?; - } - match &node.content { - Content::Code(reference, language) => { - self.validate_code_resource(reference, language)?; - } - Content::Embed(reference, _) if reference.kind != Kind::Resource => { - return Err("Embedded content requires a resource reference".into()); - } - _ => {} - } - if let Content::Paragraph(prose) - | Content::MarkdownParts(prose) - | Content::Note { prose, .. } = &node.content - { - for part in &prose.0 { - if let Inline::Image(reference, _) = part - && (reference.kind != Kind::Resource - || !self.document.artifacts[&reference.id] - .mime - .starts_with("image/")) - { - return Err(format!( - "Resource {} is not declared as an image", - reference.id - )); - } - if let Inline::Code(reference, language) = part { - if !matches!(node.content, Content::MarkdownParts(_)) { - return Err( - "Code resource views require Markdown parts; use Doc::code for a standalone block" - .into(), - ); - } - self.validate_code_resource(reference, language)?; - } - } - validate_code_placement(prose)?; - } - let reference = self.reserve(&node.id, Kind::Block)?; - self.document.nodes.push(node); - Ok(Block(reference)) - })(); - self.guarded(result) - } - - pub fn heading(&mut self, level: u8, text: &str) -> Result { - self.append_heading(None, level, text) - } - - /// Give a section an explicit anchor that survives inserting other blocks. - /// IDs share the document's evidence namespace; duplicates fail. Other - /// headings retain their positional IDs unless explicitly named here. - pub fn heading_with_id(&mut self, id: &str, level: u8, text: &str) -> Result { - self.append_heading(Some(id), level, text) - } - - fn append_heading(&mut self, id: Option<&str>, level: u8, text: &str) -> Result { - if !(1..=6).contains(&level) || text.trim().is_empty() || text.chars().any(char::is_control) - { - return self.guarded(Err( - "Heading requires level 1..6 and nonempty single-line text".into(), - )); - } - self.append_with_id(id, Content::Heading(level, text.into())) - } - - pub fn paragraph(&mut self, prose: impl IntoProse) -> Result { - self.append(Content::Paragraph(prose.into_prose())) - } - - /// CommonMark plus tables and strikethrough. Reference links and code are parsed - /// by pulldown-cmark; unresolved references and raw HTML fail explicitly. - /// Local links target emitted files or explicit block anchors. External - /// http(s)/mailto links are not network-verified. - pub fn markdown(&mut self, text: &str) -> Result { - let parsed = crate::markdown::markdown_links(text); - if let Err(error) = parsed { - return self.guarded(Err(error)); - } - self.append(Content::Markdown(text.into())) - } - - /// Preserve literal Markdown bytes while inserting typed values, bindings and - /// resource views. No separator is appended. Literal HTML is still rejected; - /// only typed Code/Keys bindings can generate the corresponding safe HTML. - pub fn markdown_parts(&mut self, parts: impl IntoProse) -> Result { - let parts = parts.into_prose(); - let validation = crate::markdown::validate_markdown_parts(&parts); - self.guarded(validation)?; - self.append(Content::MarkdownParts(parts)) - } - - pub fn binding(&mut self, id: &str, value: BindingValue) -> Result { - let result = (|| { - match &value { - BindingValue::Text(text) | BindingValue::Code(text) => { - if text.is_empty() || text.chars().any(char::is_control) { - return Err("Binding labels must be nonempty single-line text".into()); - } - } - BindingValue::Keys(keys) => { - if keys.is_empty() - || keys - .iter() - .any(|text| text.is_empty() || text.chars().any(char::is_control)) - { - return Err("Key bindings require nonempty single-line key labels".into()); - } - } - } - let reference = self.reserve(id, Kind::Binding)?; - self.document.bindings.insert(id.into(), value); - Ok(Binding(reference)) - })(); - self.guarded(result) - } - - pub fn note(&mut self, prose: impl IntoProse) -> Result { - self.append(Content::Note { - target: None, - prose: prose.into_prose(), - }) - } - - pub fn note_on(&mut self, target: &impl Target, prose: impl IntoProse) -> Result { - self.append(Content::Note { - target: Some(target.reference()), - prose: prose.into_prose(), - }) - } - - pub fn expect_eq( - &mut self, - id: &str, - actual: T, - expected: T, - ) -> Result> { - let result = (|| { - let reference = self.reserve(id, Kind::Value)?; - let mut actual_json = serde_json::to_value(&actual) - .map_err(|e| format!("Cannot freeze observed value {id}: {e}"))?; - let mut expected_json = serde_json::to_value(&expected) - .map_err(|e| format!("Cannot freeze expected value {id}: {e}"))?; - // Cargo can unify serde_json's preserve_order feature through the - // host. Freeze all object keys in canonical order in either mode. - actual_json.sort_all_objects(); - expected_json.sort_all_objects(); - if actual != expected { - return Err(format!( - "Check {id} failed: observed {actual_json}, expected {expected_json}" - )); - } - self.document.checks.insert( - id.into(), - Check { - id: id.into(), - kind: "equality".into(), - actual: actual_json.clone(), - expected: expected_json, - }, - ); - Ok(Checked { - reference, - observed: actual_json, - marker: PhantomData, - }) - })(); - self.guarded(result) - } - - /// Freeze an observed value for prose and contributor notes. This records no - /// check and makes no verification claim. Later mutation of the source value - /// cannot change its serialized snapshot. - pub fn observe(&mut self, id: &str, value: T) -> Result> { - let result = (|| { - let reference = self.reserve(id, Kind::Observation)?; - let mut observed = serde_json::to_value(value) - .map_err(|e| format!("Cannot freeze observed value {id}: {e}"))?; - // A host may enable preserve_order; canonicalize nested objects too. - observed.sort_all_objects(); - self.document - .observations - .insert(id.into(), observed.clone()); - Ok(Observed { - reference, - observed, - marker: PhantomData, - }) - })(); - self.guarded(result) - } - - pub fn require(&mut self, id: &str, condition: bool) -> Result { - let result = (|| { - let reference = self.reserve(id, Kind::Claim)?; - if !condition { - return Err(format!("Claim {id} failed")); - } - self.document.checks.insert( - id.into(), - Check { - id: id.into(), - kind: "predicate".into(), - actual: Value::Bool(true), - expected: Value::Bool(true), - }, - ); - Ok(Claim(reference)) - })(); - self.guarded(result) - } - - pub fn resource(&mut self, id: &str, artifact: Artifact) -> Result { - let result = (|| { - let reference = self.reserve(id, Kind::Resource)?; - self.document.artifacts.insert(id.into(), artifact); - Ok(Resource(reference)) - })(); - self.guarded(result) - } - - /// Register a resource at a safe host-owned relative path. The complete - /// renderer also rejects collisions across documents and generated pages. - pub fn resource_at(&mut self, id: &str, artifact: Artifact, path: &str) -> Result { - let result = (|| { - crate::paths::validate_resource_path(path)?; - if self - .document - .resource_paths - .values() - .any(|existing| existing == path) - { - return Err(format!("Duplicate artifact path: {path}")); - } - let reference = self.reserve(id, Kind::Resource)?; - self.document.resource_paths.insert(id.into(), path.into()); - self.document.artifacts.insert(id.into(), artifact); - Ok(Resource(reference)) - })(); - self.guarded(result) - } - - /// Register and place a text resource without replacing its producer metadata. - /// The returned handle can be referenced or annotated again without executing - /// the producer or copying the artifact into the document a second time. - pub fn resource_code( - &mut self, - id: &str, - artifact: Artifact, - language: &str, - ) -> Result { - let resource = self.resource(id, artifact)?; - self.code(&resource, language)?; - Ok(resource) - } - - /// Freeze UTF-8 text with the built-in text producer. - pub fn text(&mut self, id: &str, text: impl Into) -> Result { - // Persisted encoder IDs remain stable across package renames, as for JSON below. - let artifact = Artifact::new( - text.into().into_bytes(), - "text/plain", - "txt", - "executable-docs/text", - "utf8-v1", - ); - let artifact = self.guarded(artifact)?; - self.resource(id, artifact) - } - - /// Freeze a JSON value with the built-in deterministic JSON producer. - pub fn json(&mut self, id: &str, value: &impl Serialize) -> Result { - let artifact = self.guarded(Artifact::json(value))?; - self.resource(id, artifact) - } - - pub fn text_code( - &mut self, - id: &str, - text: impl Into, - language: &str, - ) -> Result { - let resource = self.text(id, text)?; - self.code(&resource, language)?; - Ok(resource) - } - - pub fn json_code(&mut self, id: &str, value: &impl Serialize) -> Result { - let resource = self.json(id, value)?; - self.code(&resource, "json")?; - Ok(resource) - } - - pub fn embed(&mut self, resource: &Resource, caption: &str) -> Result { - self.append(Content::Embed(resource.reference(), caption.into())) - } - - pub fn code(&mut self, resource: &Resource, language: &str) -> Result { - self.append(Content::Code(resource.reference(), language.into())) - } - - fn validate_code_resource(&self, reference: &Reference, language: &str) -> Result<()> { - self.validate_reference(reference)?; - if reference.kind != Kind::Resource { - return Err("Code content requires a resource reference".into()); - } - if !language - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b"_+-".contains(&b)) - { - return Err("Code language must be an ASCII identifier".into()); - } - let artifact = &self.document.artifacts[&reference.id]; - if !(artifact.mime.starts_with("text/") || artifact.mime == "application/json") { - return Err(format!( - "Resource {} is not declared as text/JSON", - reference.id - )); - } - std::str::from_utf8(&artifact.bytes) - .map_err(|_| format!("Resource {} is not UTF-8", reference.id))?; - Ok(()) - } - - /// Freeze authored content and intrinsic references. Explanations and - /// observations do not require checks; successful completion never invents - /// verification evidence. Any failed operation still prevents finishing. - /// Export validates layout-dependent resource ownership and local links against its complete - /// bundle; finishing alone does not mean the document is publishable. - pub fn finish(self) -> Result { - if !self.errors.is_empty() { - return Err(format!( - "Document {} is tainted by failed operations: {}", - self.document.id, - self.errors.join("; ") - )); - } - // Publication paths and cross-document links depend on the selected export layout. - crate::validation::validate_document(&self.document)?; - Ok(self.document) - } -} - -fn validate_code_placement(prose: &crate::model::Prose) -> Result<()> { - use pulldown_cmark::{Event, Parser, Tag}; - if !prose.0.iter().any(|part| matches!(part, Inline::Code(..))) { - return Ok(()); - } - let mut template = String::new(); - let mut code_offsets = Vec::new(); - for part in &prose.0 { - if matches!(part, Inline::Code(..)) { - code_offsets.push(template.len()); - } - template.push_str(match part { - Inline::Text(text) => text, - _ => "LGTMD_REFERENCE", - }); - } - for &offset in &code_offsets { - let before = &template[..offset]; - let after = &template[offset + "LGTMD_REFERENCE".len()..]; - let separated = |text: &str| text.is_empty() || text.bytes().all(|byte| byte == b'\n'); - if !(separated(before) || before.ends_with("\n\n")) - || !(separated(after) || after.starts_with("\n\n")) - { - return Err( - "Code resource views must occupy their own blank-line-separated Markdown block" - .into(), - ); - } - } - for (event, range) in Parser::new(&template).into_offset_iter() { - if matches!( - event, - Event::Code(_) | Event::Start(Tag::CodeBlock(_) | Tag::HtmlBlock) - ) && code_offsets.iter().any(|offset| range.contains(offset)) - { - return Err( - "Code resource views cannot be placed inside existing code or HTML syntax".into(), - ); - } - } - Ok(()) -} diff --git a/crates/lgtmd/src/layout.rs b/crates/lgtmd/src/layout.rs deleted file mode 100644 index 2907c27..0000000 --- a/crates/lgtmd/src/layout.rs +++ /dev/null @@ -1,110 +0,0 @@ -use crate::model::{Document, valid_id}; -use crate::{Result, paths::validate_resource_path}; -use std::collections::BTreeMap; - -/// Bundle-relative Markdown paths, independent of a repository or build system. -/// -/// Defaults preserve the ordinary `.md` and -/// `assets//.` layout. Explicit -/// [`crate::Doc::resource_at`] paths remain relative to the bundle root and are -/// never prefixed or rewritten. `manifest.json` and `.ownership.json` (including -/// their subtrees) are reserved at the bundle root by the export protocol. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ExportLayout { - pages: String, - resources: String, - page_overrides: BTreeMap, -} - -impl Default for ExportLayout { - fn default() -> Self { - Self { - pages: String::new(), - resources: "assets".into(), - page_overrides: BTreeMap::new(), - } - } -} - -impl ExportLayout { - /// Place default document pages under this bundle-relative directory. - /// An empty string selects the bundle root. Overrides set by [`Self::page`] - /// remain exact paths, independent of this prefix. - pub fn pages_under(mut self, path: &str) -> Result { - validate_directory(path)?; - self.pages = path.into(); - Ok(self) - } - - /// Place automatically named resources under this bundle-relative directory. - /// An empty string selects the bundle root. Explicit resource paths are unchanged. - pub fn resources_under(mut self, path: &str) -> Result { - validate_directory(path)?; - self.resources = path.into(); - Ok(self) - } - - /// Set one document's exact bundle-relative page path. Duplicate assignments - /// are errors, including identical assignments. Rendering rejects overrides - /// for document IDs absent from the supplied bundle. - pub fn page(mut self, document_id: &str, path: &str) -> Result { - if !valid_id(document_id) { - return Err(format!( - "Invalid document ID in export layout: {document_id:?}" - )); - } - validate_resource_path(path)?; - if self.page_overrides.contains_key(document_id) { - return Err(format!( - "Duplicate page override for document: {document_id}" - )); - } - self.page_overrides.insert(document_id.into(), path.into()); - Ok(self) - } - - /// Resolve a document's page path without accessing a filesystem. - pub fn page_path(&self, document_id: &str) -> String { - self.page_overrides - .get(document_id) - .cloned() - .unwrap_or_else(|| under(&self.pages, &format!("{document_id}.md"))) - } - - pub(crate) fn resource_path(&self, document: &Document, id: &str) -> String { - document.resource_paths.get(id).cloned().unwrap_or_else(|| { - under( - &self.resources, - &format!( - "{}/{}.{}", - document.id, id, document.artifacts[id].extension - ), - ) - }) - } - - pub(crate) fn validate_documents(&self, documents: &[Document]) -> Result<()> { - for id in self.page_overrides.keys() { - if !documents.iter().any(|document| &document.id == id) { - return Err(format!("Page override names an unknown document: {id}")); - } - } - Ok(()) - } -} - -fn validate_directory(path: &str) -> Result<()> { - if path.is_empty() { - Ok(()) - } else { - validate_resource_path(path) - } -} - -fn under(directory: &str, path: &str) -> String { - if directory.is_empty() { - path.into() - } else { - format!("{directory}/{path}") - } -} diff --git a/crates/lgtmd/src/lib.rs b/crates/lgtmd/src/lib.rs deleted file mode 100644 index e5ab67a..0000000 --- a/crates/lgtmd/src/lib.rs +++ /dev/null @@ -1,46 +0,0 @@ -#![doc = include_str!("../README.md")] - -mod artifact; -mod authoring; -mod layout; -pub mod lifecycle; -mod manifest; -mod markdown; -mod model; -mod paths; -mod prose; -mod render; -pub mod runner; -pub mod store; -pub mod transport; -mod validation; - -pub use artifact::Artifact; -pub use authoring::Doc; -pub use layout::ExportLayout; -pub use model::{ - Audience, Binding, BindingValue, Block, Checked, Claim, Document, IntoProse, Observed, Prose, - Resource, Target, -}; -pub use render::{render, render_with}; - -pub type Result = std::result::Result; - -/// Combine text and typed handles without losing evidence references. -/// The expression-list form returns a sequence; named interpolation returns -/// `Result` and rejects missing, duplicate, or unused bindings. -/// Each supplied expression is evaluated once, in order, through [`IntoProse`]. -#[macro_export] -macro_rules! prose { - ($text:literal, $($name:ident = $value:expr),+ $(,)?) => { - $crate::Prose::interpolate( - $text, - [$( (::std::stringify!($name), $crate::IntoProse::into_prose($value)) ),+], - ) - }; - ($($part:expr),* $(,)?) => {{ - let parts: ::std::vec::Vec<$crate::Prose> = - ::std::vec![$($crate::IntoProse::into_prose($part)),*]; - parts - }}; -} diff --git a/crates/lgtmd/src/lifecycle.rs b/crates/lgtmd/src/lifecycle.rs deleted file mode 100644 index e43ce3d..0000000 --- a/crates/lgtmd/src/lifecycle.rs +++ /dev/null @@ -1,686 +0,0 @@ -//! Shared exact artifact collection and filesystem publication. -//! -//! Dedicated trees retain their existing file format. Recorded trees add an -//! ownership inventory; neither policy changes application-specific manifests. -//! -//! Each destination publishes independently. Renames and rollback protect against -//! ordinary errors, but this is not a crash-atomic filesystem transaction. An -//! interrupted transaction leaves named recovery material and fails closed. -use crate::Result; -use serde::{Deserialize, Serialize}; -use std::{ - collections::{BTreeMap, BTreeSet}, - fs::{self, OpenOptions}, - io::Write, - path::{Component, Path, PathBuf}, -}; - -pub type Files = BTreeMap>; -const OWNERSHIP: &str = ".ownership.json"; - -/// The adopter explicitly chooses whether its generated tree records ownership. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Ownership { - /// Every existing file belongs to the dedicated generated tree. Updates - /// reject files absent from the candidate and write no additional metadata. - Dedicated, - /// A `.ownership.json` inventory distinguishes generated and unowned files. - Recorded, -} - -/// Finish all generation and validation before calling a publication operation. -/// This catches Rust panics, not process termination or side effects in user code. -pub fn prepare(generate: impl FnOnce() -> Result) -> Result { - std::panic::catch_unwind(std::panic::AssertUnwindSafe(generate)).map_err(|payload| { - let detail = payload - .downcast_ref::() - .map(String::as_str) - .or_else(|| payload.downcast_ref::<&str>().copied()) - .unwrap_or("unknown panic payload"); - format!("Documentation generation panicked; no output was published: {detail}") - })? -} - -/// Insert once, validating the application-owned relative output path. -pub fn insert(files: &mut Files, path: String, bytes: Vec) -> Result<()> { - validate_path(&path)?; - match files.entry(path) { - std::collections::btree_map::Entry::Vacant(entry) => { - entry.insert(bytes); - Ok(()) - } - std::collections::btree_map::Entry::Occupied(entry) => { - Err(format!("Duplicate artifact owner: {}", entry.key())) - } - } -} - -/// Check a complete registration inventory independently of output rendering. -pub fn check_inventory(expected: &BTreeSet, current: &BTreeSet) -> Result<()> { - if expected == current { - return Ok(()); - } - Err(format!( - "Artifact inventory differs. Missing: {:?}; unregistered: {:?}", - expected.difference(current).collect::>(), - current.difference(expected).collect::>() - )) -} - -/// Read a dedicated tree verbatim. Absent roots produce an empty inventory. -/// Ownership metadata, when present, is an ordinary file in this raw view. -/// A sibling writer lock or interrupted transaction prevents a partial read. -pub fn read_tree(path: &Path) -> Result { - let transaction = Transaction::paths(path)?; - transaction.readable()?; - read_all(&transaction.target) -} - -/// Compare the complete expected inventory and every byte with a retained tree. -/// Application-specific profile and format checks remain the caller's policy. -pub fn compare(expected: &Files, current: &Files) -> Result<()> { - let keys: BTreeSet<_> = current.keys().chain(expected.keys()).collect(); - let changed: Vec<_> = keys - .into_iter() - .filter_map(|name| match (current.get(name), expected.get(name)) { - (None, Some(_)) => Some(format!("missing {name}")), - (Some(_), None) => Some(format!("extra {name}")), - (Some(a), Some(b)) if a != b => Some(format!("changed {name}")), - _ => None, - }) - .collect(); - if changed.is_empty() { - Ok(()) - } else { - Err(format!("Documentation drift: {}", changed.join(", "))) - } -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct OwnershipRecord { - schema_version: u32, - files: Vec, -} - -fn io_error(action: &str, path: &Path, error: std::io::Error) -> String { - format!("{action} {}: {error}", path.display()) -} - -/// Normalize an output path for adapter preflight without creating it. -/// Rejects parent traversal, filesystem roots, and existing symlink components. -/// Publication repeats these checks; this does not reserve the destination. -pub fn absolute(path: &Path) -> Result { - let clean = absolute_base(path)?; - if clean.file_name().is_none() { - return Err("An output directory must have a name; filesystem roots are forbidden".into()); - } - Ok(clean) -} - -// A path-resolution base may be the filesystem root; an output tree may not. -pub(crate) fn absolute_base(path: &Path) -> Result { - let full = if path.is_absolute() { - path.to_owned() - } else { - std::env::current_dir() - .map_err(|e| e.to_string())? - .join(path) - }; - if full.components().any(|p| matches!(p, Component::ParentDir)) { - return Err(format!( - "Parent traversal is not allowed: {}", - path.display() - )); - } - let clean: PathBuf = full - .components() - .filter(|p| !matches!(p, Component::CurDir)) - .collect(); - no_symlinks(&clean)?; - Ok(clean) -} - -/// Reject overlapping output trees before generation, without creating paths. -/// Existing targets are compared with the other path's existing ancestors by -/// filesystem identity, so case aliases cannot hide a retained directory. -/// Missing targets have no identity yet and receive lexical checks only. This -/// preflight assumes a cooperative filesystem; it neither reserves paths nor -/// protects against another process changing their identity after the check. -pub fn ensure_separate_paths(left: &Path, right: &Path) -> Result<()> { - let left = absolute(left)?; - let right = absolute(right)?; - let overlap = || { - format!( - "Output paths must not overlap: {} and {}", - left.display(), - right.display() - ) - }; - if left.starts_with(&right) || right.starts_with(&left) { - return Err(overlap()); - } - for (target, other) in [(&left, &right), (&right, &left)] { - if !exists(target)? { - continue; - } - for ancestor in other.ancestors() { - if exists(ancestor)? - && same_file::is_same_file(target, ancestor) - .map_err(|error| io_error("Compare path identity", ancestor, error))? - { - return Err(overlap()); - } - } - } - Ok(()) -} - -fn no_symlinks(path: &Path) -> Result<()> { - let mut part = PathBuf::new(); - for component in path.components() { - part.push(component.as_os_str()); - match fs::symlink_metadata(&part) { - Ok(meta) if meta.file_type().is_symlink() => { - return Err(format!( - "Symlinks are forbidden in output paths: {}", - part.display() - )); - } - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(io_error("Inspect", &part, error)), - } - } - Ok(()) -} - -fn exists(path: &Path) -> Result { - match fs::symlink_metadata(path) { - Ok(_) => Ok(true), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(io_error("Inspect", path, error)), - } -} - -pub fn validate_path(key: &str) -> Result<()> { - if key.is_empty() - || key.contains('\\') - || key - .split('/') - .any(|s| s.is_empty() || s == "." || s == "..") - || Path::new(key) - .components() - .any(|p| !matches!(p, Component::Normal(_))) - { - return Err(format!("Unsafe or reserved output path: {key:?}")); - } - Ok(()) -} - -fn validate(files: &Files, ownership: Ownership) -> Result<()> { - for key in files.keys() { - validate_path(key)?; - if ownership == Ownership::Recorded && key.split('/').next() == Some(OWNERSHIP) { - return Err(format!("Reserved ownership path: {key}")); - } - let mut parent = Path::new(key).parent(); - while let Some(path) = parent.filter(|p| !p.as_os_str().is_empty()) { - if files.contains_key(path.to_str().ok_or("Non UTF-8 output path")?) { - return Err(format!( - "Output file conflicts with a parent directory: {key}" - )); - } - parent = path.parent(); - } - } - Ok(()) -} - -fn owned_tree(path: &Path, ownership: Ownership) -> Result { - let (files, missing) = owned_tree_inventory(path, ownership)?; - require_complete_ownership(&missing)?; - Ok(files) -} - -fn require_complete_ownership(missing: &BTreeSet) -> Result<()> { - if let Some(name) = missing.first() { - return Err(format!("Ownership inventory: missing {name}")); - } - Ok(()) -} - -fn owned_tree_inventory(path: &Path, ownership: Ownership) -> Result<(Files, BTreeSet)> { - if ownership == Ownership::Dedicated { - return Ok((read_all(path)?, BTreeSet::new())); - } - no_symlinks(path)?; - let marker = path.join(OWNERSHIP); - no_symlinks(&marker)?; - let bytes = fs::read(&marker).map_err(|e| io_error("Read ownership marker", &marker, e))?; - let record: OwnershipRecord = serde_json::from_slice(&bytes) - .map_err(|e| format!("Invalid ownership marker {}: {e}", marker.display()))?; - if record.schema_version != 1 { - return Err("Unsupported ownership schema".into()); - } - let mut expected = BTreeSet::new(); - for name in record.files { - validate_path(&name)?; - if name.split('/').next() == Some(OWNERSHIP) { - return Err(format!("Reserved ownership path: {name}")); - } - if !expected.insert(name.clone()) { - return Err(format!("Duplicate owned path: {name}")); - } - } - let mut files = Files::new(); - visit(path, path, Some(&expected), &mut files)?; - let missing = expected - .into_iter() - .filter(|name| !files.contains_key(name)) - .collect(); - Ok((files, missing)) -} - -fn visit( - base: &Path, - dir: &Path, - expected: Option<&BTreeSet>, - files: &mut Files, -) -> Result<()> { - no_symlinks(dir)?; - for entry in fs::read_dir(dir).map_err(|e| io_error("Read directory", dir, e))? { - let entry = entry.map_err(|e| e.to_string())?; - let full = entry.path(); - let name = inventory_path(full.strip_prefix(base).map_err(|e| e.to_string())?)?; - let kind = entry - .file_type() - .map_err(|e| io_error("Inspect", &full, e))?; - if kind.is_symlink() { - return Err(format!("Output symlink is forbidden: {}", full.display())); - } - if kind.is_dir() { - if let Some(expected) = expected - && !expected.iter().any(|p| p.starts_with(&format!("{name}/"))) - { - return Err(format!("Unowned output directory: {name}")); - } - visit(base, &full, expected, files)?; - } else if kind.is_file() { - if let Some(expected) = expected { - if name == OWNERSHIP { - continue; - } - if !expected.contains(&name) { - return Err(format!("Unowned output file: {name}")); - } - } - insert( - files, - name, - fs::read(&full).map_err(|e| io_error("Read", &full, e))?, - )?; - } else { - return Err(format!("Unsupported output file type: {}", full.display())); - } - } - Ok(()) -} - -// Inventories use `/` on every host, while filesystem paths use the native -// separator. Convert components rather than replacing characters: a literal -// backslash in a Unix filename must still fail the output-path policy. -fn inventory_path(path: &Path) -> Result { - let name = path - .components() - .map(|component| match component { - Component::Normal(name) => name.to_str().ok_or("Non UTF-8 output path"), - _ => Err("Unexpected non-relative output path component"), - }) - .collect::, _>>()? - .join("/"); - validate_path(&name)?; - Ok(name) -} - -fn read_all(path: &Path) -> Result { - no_symlinks(path)?; - let mut files = Files::new(); - if exists(path)? { - visit(path, path, None, &mut files)?; - } - Ok(files) -} - -struct Transaction { - target: PathBuf, - lock: PathBuf, - stage: PathBuf, - backup: PathBuf, - locked: bool, -} - -// Each published tree reserves these sibling paths for its transaction. Runner -// preflight must keep other outputs out of all four locations, not just target. -pub(crate) fn transaction_paths(path: &Path) -> Result<[PathBuf; 4]> { - let transaction = Transaction::paths(path)?; - Ok([ - transaction.target.clone(), - transaction.lock.clone(), - transaction.stage.clone(), - transaction.backup.clone(), - ]) -} - -impl Transaction { - fn paths(path: &Path) -> Result { - let target = absolute(path)?; - let name = target - .file_name() - .and_then(|s| s.to_str()) - .ok_or("Non UTF-8 destination")?; - let parent = target.parent().ok_or("Destination has no parent")?; - Ok(Self { - lock: parent.join(format!(".{name}.lock")), - stage: parent.join(format!(".{name}.staging")), - backup: parent.join(format!(".{name}.backup")), - target, - locked: false, - }) - } - - fn acquire(path: &Path) -> Result { - let mut transaction = Self::paths(path)?; - let parent = transaction.target.parent().unwrap(); - fs::create_dir_all(parent).map_err(|e| io_error("Create output parent", parent, e))?; - no_symlinks(parent)?; - for path in [&transaction.lock, &transaction.stage, &transaction.backup] { - no_symlinks(path)?; - } - let mut lock = OpenOptions::new().write(true).create_new(true).open(&transaction.lock) - .map_err(|e| format!("Output lock unavailable at {}: {e}; inspect an interrupted run before removing a stale lock", transaction.lock.display()))?; - if let Err(error) = writeln!(lock, "pid={}", std::process::id()) { - let _ = fs::remove_file(&transaction.lock); - return Err(io_error("Write output lock", &transaction.lock, error)); - } - transaction.locked = true; - Ok(transaction) - } - - fn clean_state(&self) -> Result<()> { - for path in [&self.stage, &self.backup] { - no_symlinks(path)?; - if exists(path)? { - return Err(format!( - "Interrupted transaction material at {}; preserve and inspect it before retrying (recover_backup restores a valid backup only when the destination is absent)", - path.display() - )); - } - } - Ok(()) - } - - fn readable(&self) -> Result<()> { - no_symlinks(&self.lock)?; - if exists(&self.lock)? { - return Err(format!( - "Cannot check during an update or stale lock: {}", - self.lock.display() - )); - } - self.clean_state() - } -} - -impl Drop for Transaction { - fn drop(&mut self) { - if self.locked { - let _ = fs::remove_file(&self.lock); - } - } -} - -fn write_file(path: &Path, bytes: &[u8]) -> Result<()> { - let mut file = OpenOptions::new() - .write(true) - .create_new(true) - .open(path) - .map_err(|e| io_error("Create staged file", path, e))?; - file.write_all(bytes) - .map_err(|e| io_error("Write staged file", path, e))?; - file.sync_all() - .map_err(|e| io_error("Sync staged file", path, e)) -} - -fn stage(transaction: &Transaction, files: &Files, ownership: Ownership) -> Result<()> { - fs::create_dir(&transaction.stage) - .map_err(|e| io_error("Create staging directory", &transaction.stage, e))?; - let result = (|| { - for (name, bytes) in files { - let file = transaction.stage.join(name); - fs::create_dir_all(file.parent().unwrap()) - .map_err(|e| io_error("Create staged directory", &file, e))?; - write_file(&file, bytes)?; - } - if ownership == Ownership::Recorded { - let marker = serde_json::to_vec_pretty(&OwnershipRecord { - schema_version: 1, - files: files.keys().cloned().collect(), - }) - .map_err(|e| e.to_string())?; - write_file(&transaction.stage.join(OWNERSHIP), &marker)?; - } - if owned_tree(&transaction.stage, ownership)? != *files { - return Err("Staged output did not match the validated candidate".into()); - } - Ok(()) - })(); - if result.is_err() { - let _ = fs::remove_dir_all(&transaction.stage); - } - result -} - -/// Compare a complete retained output without modifying it or creating a lock. -/// The callback receives (retained, candidate) before exact byte comparison. -pub fn check( - path: &Path, - files: &Files, - ownership: Ownership, - validate_current: impl FnOnce(&Files, &Files) -> Result<()>, -) -> Result<()> { - validate(files, ownership)?; - let transaction = Transaction::paths(path)?; - transaction.readable()?; - let current = owned_tree(&transaction.target, ownership)?; - validate_current(¤t, files)?; - compare(files, ¤t) -} - -/// Write a complete candidate into a new directory; never overwrite an existing one. -pub fn build(path: &Path, files: &Files, ownership: Ownership) -> Result<()> { - validate(files, ownership)?; - let transaction = Transaction::acquire(path)?; - transaction.clean_state()?; - if exists(&transaction.target)? { - return Err(format!( - "Build destination already exists: {}", - transaction.target.display() - )); - } - stage(&transaction, files, ownership)?; - if let Err(error) = fs::rename(&transaction.stage, &transaction.target) { - let _ = fs::remove_dir_all(&transaction.stage); - return Err(io_error("Publish candidate", &transaction.target, error)); - } - Ok(()) -} - -/// Validate and stage all output before replacing a compatible owned baseline. -pub fn update( - path: &Path, - files: &Files, - ownership: Ownership, - validate_current: impl FnOnce(&Files, &Files) -> Result<()>, -) -> Result<()> { - update_inner( - path, - files, - ownership, - |current, candidate, missing| { - require_complete_ownership(missing)?; - validate_current(current, candidate) - }, - || Ok(()), - ) -} - -// The structured store can validate explicit retirement against its retained -// manifest. Generic lifecycle callers have no such evidence and remain strict. -pub(crate) fn update_with_retirement( - path: &Path, - files: &Files, - validate_current: impl FnOnce(&Files, &Files, &BTreeSet) -> Result<()>, -) -> Result<()> { - update_inner( - path, - files, - Ownership::Recorded, - validate_current, - || Ok(()), - ) -} - -fn update_inner( - path: &Path, - files: &Files, - ownership: Ownership, - validate_current: impl FnOnce(&Files, &Files, &BTreeSet) -> Result<()>, - before_publish: impl FnOnce() -> Result<()>, -) -> Result<()> { - validate(files, ownership)?; - let transaction = Transaction::acquire(path)?; - transaction.clean_state()?; - let had_baseline = exists(&transaction.target)?; - if had_baseline { - let (current, missing) = owned_tree_inventory(&transaction.target, ownership)?; - validate_current(¤t, files, &missing)?; - let obsolete: Vec<_> = current - .keys() - .filter(|name| !files.contains_key(*name)) - .collect(); - if !obsolete.is_empty() { - return Err(format!( - "Obsolete owned files require explicit removal before update: {obsolete:?}" - )); - } - } - stage(&transaction, files, ownership)?; - if had_baseline && let Err(error) = fs::rename(&transaction.target, &transaction.backup) { - let _ = fs::remove_dir_all(&transaction.stage); - return Err(io_error( - "Preserve baseline backup", - &transaction.backup, - error, - )); - } - let publication = before_publish().and_then(|()| { - fs::rename(&transaction.stage, &transaction.target) - .map_err(|e| io_error("Publish baseline", &transaction.target, e)) - }); - if let Err(error) = publication { - if had_baseline && let Err(rollback) = fs::rename(&transaction.backup, &transaction.target) - { - return Err(format!( - "{error}; rollback failed: {rollback}; preserved backup: {}; staged candidate: {}", - transaction.backup.display(), - transaction.stage.display() - )); - } - let _ = fs::remove_dir_all(&transaction.stage); - return Err(error); - } - if had_baseline { - fs::remove_dir_all(&transaction.backup).map_err(|e| { - io_error( - "Baseline published but backup cleanup failed; inspect preserved backup", - &transaction.backup, - e, - ) - })?; - } - Ok(()) -} - -/// Explicit recovery for the unambiguous interrupted state with no destination. -/// An existing destination is never replaced, and staged candidates are retained. -pub fn recover_backup( - path: &Path, - ownership: Ownership, - validate_backup: impl FnOnce(&Files) -> Result<()>, -) -> Result<()> { - let transaction = Transaction::acquire(path)?; - if exists(&transaction.target)? { - return Err( - "Recovery refused: destination exists; inspect it and the backup explicitly".into(), - ); - } - if !exists(&transaction.backup)? { - return Err("Recovery refused: no preserved backup exists".into()); - } - let backup = owned_tree(&transaction.backup, ownership)?; - validate(&backup, ownership)?; - validate_backup(&backup)?; - fs::rename(&transaction.backup, &transaction.target) - .map_err(|e| io_error("Restore preserved backup", &transaction.target, e)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn inventory_paths_join_native_components_with_portable_separators() { - let native = Path::new("assets").join("guide").join("evidence.txt"); - assert_eq!( - inventory_path(&native).unwrap(), - "assets/guide/evidence.txt" - ); - assert!(inventory_path(Path::new("../outside")).is_err()); - } - - #[cfg(unix)] - #[test] - fn inventory_paths_do_not_reinterpret_unix_backslash_filenames() { - assert!(inventory_path(Path::new(r"assets\evidence.txt")).is_err()); - } - - #[test] - fn failed_publication_restores_previous_baseline() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().canonicalize().unwrap().join("baseline"); - let mut before = Files::from([ - ( - "manifest.json".into(), - br#"{"schema_version":1,"audience":"reader","profiles":[],"artifacts":[]}"# - .to_vec(), - ), - ("page.md".into(), b"before".to_vec()), - ]); - update(&root, &before, Ownership::Dedicated, |_, _| Ok(())).unwrap(); - let old = before.clone(); - before.insert("page.md".into(), b"after".to_vec()); - let error = update_inner( - &root, - &before, - Ownership::Dedicated, - |_, _, _| Ok(()), - || Err("injected publish I/O failure".into()), - ) - .unwrap_err(); - assert!(error.contains("injected")); - check(&root, &old, Ownership::Dedicated, |_, _| Ok(())).unwrap(); - assert!(!root.parent().unwrap().join(".baseline.backup").exists()); - assert!(!root.parent().unwrap().join(".baseline.staging").exists()); - } -} diff --git a/crates/lgtmd/src/manifest.rs b/crates/lgtmd/src/manifest.rs deleted file mode 100644 index 4d7a193..0000000 --- a/crates/lgtmd/src/manifest.rs +++ /dev/null @@ -1,536 +0,0 @@ -//! The existing export receipt: typed emission and tolerant, strict validation. -//! Unknown JSON fields remain accepted; validation retains the original protocol -//! rules before typed metadata is used by publication compatibility checks. -use crate::{ - Artifact, Audience, Result, - lifecycle::{self, Files}, - model::{Content, Document, Node}, - validation::visible_nodes, -}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use std::{ - collections::{BTreeMap, BTreeSet}, - path::Path, -}; - -#[derive(Serialize, Deserialize)] -pub(crate) struct Manifest { - schema_version: u64, - pub(crate) audience: String, - profiles: Vec, - documents: Vec, - pub(crate) artifacts: Vec, -} -#[derive(Serialize, Deserialize)] -struct Profile { - producer: String, - profile: String, -} -#[derive(Serialize, Deserialize)] -struct DocumentRecord { - id: String, - title: String, - checks: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - observations: Vec, - blocks: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - bindings: Option>, -} -#[derive(Serialize, Deserialize)] -struct ObservationRecord { - id: String, - value: Value, -} -#[derive(Serialize, Deserialize)] -struct CheckRecord { - id: String, - kind: String, - passed: bool, - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "present_value" - )] - actual: Option, - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "present_value" - )] - expected: Option, -} -// JSON null is a present observation, distinct from an omitted private payload. -fn present_value<'de, D: serde::Deserializer<'de>>( - deserializer: D, -) -> std::result::Result, D::Error> { - Value::deserialize(deserializer).map(Some) -} -#[derive(Serialize, Deserialize)] -struct BlockRecord { - id: String, - kind: String, - references: Vec, -} -#[derive(Serialize, Deserialize)] -struct ReferenceRecord { - id: String, - kind: String, -} -#[derive(Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "lowercase")] -pub(crate) enum ArtifactRecord { - Page { - path: String, - document: String, - mime: String, - bytes: u64, - sha256: String, - }, - Resource { - path: String, - document: String, - id: String, - mime: String, - producer: String, - profile: String, - bytes: u64, - sha256: String, - }, -} -impl ArtifactRecord { - pub(crate) fn path(&self) -> &str { - match self { - Self::Page { path, .. } | Self::Resource { path, .. } => path, - } - } -} -fn sha256(bytes: &[u8]) -> String { - format!("{:x}", Sha256::digest(bytes)) -} -impl Manifest { - pub(crate) fn new(audience: Audience) -> Self { - Self { - schema_version: 1, - audience: match audience { - Audience::Reader => "reader", - Audience::Contributor => "contributor", - } - .into(), - profiles: Vec::new(), - documents: Vec::new(), - artifacts: Vec::new(), - } - } - pub(crate) fn add_page(&mut self, document: &str, path: &str, bytes: &[u8]) { - self.artifacts.push(ArtifactRecord::Page { - path: path.into(), - document: document.into(), - mime: "text/markdown".into(), - bytes: bytes.len() as u64, - sha256: sha256(bytes), - }); - } - pub(crate) fn add_resource( - &mut self, - document: &str, - id: &str, - path: &str, - artifact: &Artifact, - ) { - self.artifacts.push(ArtifactRecord::Resource { - path: path.into(), - document: document.into(), - id: id.into(), - mime: artifact.mime.clone(), - producer: artifact.producer.clone(), - profile: artifact.profile.clone(), - bytes: artifact.bytes.len() as u64, - sha256: sha256(&artifact.bytes), - }); - } - pub(crate) fn add_document(&mut self, document: &Document, audience: Audience) -> Result<()> { - let reader_refs: BTreeSet<_> = document - .nodes - .iter() - .filter(|node| !node.is_note()) - .flat_map(Node::references) - .map(|r| &r.id) - .collect(); - let checks = document - .checks - .values() - .map(|check| { - let reveal = audience == Audience::Contributor || reader_refs.contains(&check.id); - CheckRecord { - id: check.id.clone(), - kind: check.kind.clone(), - passed: true, - actual: reveal.then(|| check.actual.clone()), - expected: reveal.then(|| check.expected.clone()), - } - }) - .collect(); - let observations = document - .observations - .iter() - .filter(|(id, _)| audience == Audience::Contributor || reader_refs.contains(id)) - .map(|(id, value)| ObservationRecord { - id: id.clone(), - value: value.clone(), - }) - .collect(); - let blocks = visible_nodes(document, audience) - .map(|node| { - let kind = match &node.content { - Content::Heading(..) => "heading", - Content::Paragraph(..) => "paragraph", - Content::Markdown(..) => "markdown", - Content::MarkdownParts(..) => "markdown-parts", - Content::Embed(..) => "embed", - Content::Code(..) => "code", - Content::Note { .. } => "note", - }; - let references = node - .references() - .into_iter() - .map(|r| { - Ok(ReferenceRecord { - id: r.id.clone(), - kind: serde_json::to_value(&r.kind) - .map_err(|e| e.to_string())? - .as_str() - .ok_or("Invalid reference kind")? - .into(), - }) - }) - .collect::>>()?; - Ok(BlockRecord { - id: node.id.clone(), - kind: kind.into(), - references, - }) - }) - .collect::>>()?; - let visible_refs: BTreeSet<_> = visible_nodes(document, audience) - .flat_map(Node::references) - .map(|r| &r.id) - .collect(); - let bindings = document - .bindings - .iter() - .filter(|(id, _)| visible_refs.contains(id)) - .map(|(id, value)| { - Ok(( - id.clone(), - serde_json::to_value(value).map_err(|e| e.to_string())?, - )) - }) - .collect::>>()?; - self.documents.push(DocumentRecord { - id: document.id.clone(), - title: document.title.clone(), - checks, - observations, - blocks, - bindings: (!bindings.is_empty()).then_some(bindings), - }); - Ok(()) - } - pub(crate) fn encode(mut self) -> Result> { - self.artifacts.sort_by(|a, b| a.path().cmp(b.path())); - let profiles: BTreeSet<_> = self - .artifacts - .iter() - .filter_map(|artifact| match artifact { - ArtifactRecord::Resource { - producer, profile, .. - } => Some((producer.clone(), profile.clone())), - _ => None, - }) - .collect(); - self.profiles = profiles - .into_iter() - .map(|(producer, profile)| Profile { producer, profile }) - .collect(); - let mut value = serde_json::to_value(self).map_err(|e| e.to_string())?; - value.sort_all_objects(); - let mut bytes = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?; - bytes.push(b'\n'); - Ok(bytes) - } - pub(crate) fn read(files: &Files) -> Result { - serde_json::from_value(raw_manifest(files)?) - .map_err(|e| format!("Invalid manifest.json: {e}")) - } - pub(crate) fn resource_profiles(&self) -> Result> { - let mut profiles = BTreeMap::new(); - for artifact in &self.artifacts { - if let ArtifactRecord::Resource { - document, - id, - producer, - profile, - .. - } = artifact - { - let identity = (document.clone(), id.clone()); - if profiles - .insert(identity.clone(), (producer.clone(), profile.clone())) - .is_some() - { - return Err(format!( - "Duplicate resource manifest identity: {identity:?}" - )); - } - } - } - Ok(profiles) - } -} - -fn raw_manifest(files: &Files) -> Result { - let bytes = files.get("manifest.json").ok_or("Missing manifest.json")?; - let value: serde_json::Value = - serde_json::from_slice(bytes).map_err(|e| format!("Invalid manifest.json: {e}"))?; - if value["schema_version"] != 1 { - return Err(format!( - "Unsupported manifest schema: {}", - value["schema_version"] - )); - } - if !matches!(value["audience"].as_str(), Some("reader" | "contributor")) { - return Err("Manifest audience must be reader or contributor".into()); - } - if !value["profiles"].is_array() { - return Err("Manifest profiles must be an array".into()); - } - Ok(value) -} - -fn field<'a>(record: &'a Value, name: &str) -> Result<&'a str> { - record[name] - .as_str() - .filter(|text| !text.is_empty()) - .ok_or_else(|| format!("Manifest requires nonempty string field {name}")) -} - -fn array<'a>(record: &'a Value, name: &str) -> Result<&'a Vec> { - record[name] - .as_array() - .ok_or_else(|| format!("Manifest {name} must be an array")) -} - -fn identity(identities: &mut BTreeMap, id: &str, kind: &str) -> Result<()> { - if !crate::model::valid_id(id) { - return Err(format!("Invalid manifest evidence ID: {id}")); - } - if identities.insert(id.into(), kind.into()).is_some() { - return Err(format!("Duplicate manifest evidence identity: {id}")); - } - Ok(()) -} - -pub(crate) fn validate_tree(files: &Files, retired_from: Option<&Files>) -> Result<()> { - // Check map paths before reading metadata, so no malformed candidate can - // reach staging merely by omitting that path from its artifact inventory. - for path in files.keys() { - lifecycle::validate_path(path)?; - if path.split('/').next() == Some(".ownership.json") { - return Err(format!("Reserved ownership path: {path}")); - } - for parent in Path::new(path) - .ancestors() - .skip(1) - .filter(|p| !p.as_os_str().is_empty()) - { - if files.contains_key(parent.to_str().ok_or("Non UTF-8 output path")?) { - return Err(format!( - "Output file conflicts with a parent directory: {path}" - )); - } - } - } - let manifest = raw_manifest(files)?; - let documents = array(&manifest, "documents")?; - if documents.is_empty() { - return Err("Manifest must contain at least one document".into()); - } - let mut identities = BTreeMap::new(); - for document in documents { - let id = field(document, "id")?; - if !crate::model::valid_id(id) { - return Err(format!("Invalid manifest document ID: {id}")); - } - field(document, "title")?; - let mut local = BTreeMap::new(); - let checks = array(document, "checks")?; - for check in checks { - let kind = match field(check, "kind")? { - "equality" => "value", - "predicate" => "claim", - other => return Err(format!("Unknown manifest check kind: {other}")), - }; - identity(&mut local, field(check, "id")?, kind)?; - if check["passed"] != true { - return Err(format!( - "Manifest document {id} contains a failed or missing check result" - )); - } - if check.get("actual").is_some() != check.get("expected").is_some() { - return Err( - "Manifest check must include both actual and expected payloads, or neither" - .into(), - ); - } - } - if document.get("observations").is_some() { - for observation in array(document, "observations")? { - identity(&mut local, field(observation, "id")?, "observation")?; - if observation.get("value").is_none() { - return Err("Manifest observation requires a value; null is a value".into()); - } - } - } - for block in array(document, "blocks")? { - let kind = field(block, "kind")?; - if !matches!( - kind, - "heading" | "paragraph" | "markdown" | "markdown-parts" | "embed" | "code" | "note" - ) { - return Err(format!("Unknown manifest block kind: {kind}")); - } - if manifest["audience"] == "reader" && kind == "note" { - return Err("Reader manifest cannot contain contributor notes".into()); - } - identity(&mut local, field(block, "id")?, "block")?; - array(block, "references")?; - } - if let Some(bindings) = document.get("bindings") { - for (binding, value) in bindings - .as_object() - .ok_or("Manifest bindings must be an object")? - { - identity(&mut local, binding, "binding")?; - let valid_label = - |label: &str| !label.is_empty() && !label.chars().any(char::is_control); - let valid = match field(value, "kind")? { - "text" | "code" => value["value"].as_str().is_some_and(valid_label), - "keys" => value["value"].as_array().is_some_and(|keys| { - !keys.is_empty() - && keys.iter().all(|key| key.as_str().is_some_and(valid_label)) - }), - _ => false, - }; - if !valid { - return Err(format!("Invalid manifest binding: {binding}")); - } - } - } - if identities.insert(id.to_owned(), local).is_some() { - return Err(format!("Duplicate manifest document identity: {id}")); - } - } - let mut paths = BTreeSet::new(); - let mut pages = BTreeSet::new(); - let mut profiles = BTreeSet::new(); - for artifact in array(&manifest, "artifacts")? { - let path = field(artifact, "path")?; - crate::paths::validate_resource_path(path)?; - if path.split('/').next() == Some(".ownership.json") { - return Err(format!("Reserved ownership path in manifest: {path}")); - } - if !paths.insert(path.to_owned()) { - return Err(format!("Duplicate manifest artifact path: {path}")); - } - let document = field(artifact, "document")?; - let local = identities - .get_mut(document) - .ok_or_else(|| format!("Artifact {path} references unknown document {document}"))?; - let mime = field(artifact, "mime")?; - match field(artifact, "kind")? { - "page" => { - // The manifest binds each document to its exported page. Page - // placement belongs to the adopter, not to the document ID. - if mime != "text/markdown" { - return Err(format!("Invalid document page MIME: {path}")); - } - if !pages.insert(document.to_owned()) { - return Err(format!("Duplicate document page identity: {document}")); - } - } - "resource" => { - identity(local, field(artifact, "id")?, "resource")?; - let producer = field(artifact, "producer")?; - let profile = field(artifact, "profile")?; - crate::Artifact::new(Vec::new(), mime, "bin", producer, profile)?; - profiles.insert((producer.to_owned(), profile.to_owned())); - } - other => return Err(format!("Unknown manifest artifact kind: {other}")), - } - let size = artifact["bytes"] - .as_u64() - .ok_or_else(|| format!("Manifest artifact {path} requires a byte length"))?; - let digest = field(artifact, "sha256")?; - if digest.len() != 64 - || !digest - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(format!( - "Manifest artifact {path} requires a lowercase SHA-256" - )); - } - if let Some(bytes) = files.get(path) { - if size != bytes.len() as u64 || digest != format!("{:x}", Sha256::digest(bytes)) { - return Err(format!("Manifest artifact size/hash mismatch: {path}")); - } - } else if retired_from.is_none_or(|candidate| candidate.contains_key(path)) { - return Err(format!("Manifest artifact inventory: missing {path}")); - } - } - if pages != identities.keys().cloned().collect() { - return Err("Manifest must own exactly one page for every document".into()); - } - let extra: Vec<_> = files - .keys() - .filter(|path| path.as_str() != "manifest.json" && !paths.contains(*path)) - .collect(); - if !extra.is_empty() { - return Err(format!( - "Manifest artifact inventory: unrecorded paths {extra:?}" - )); - } - let mut declared_profiles = BTreeSet::new(); - for profile in array(&manifest, "profiles")? { - let pair = ( - field(profile, "producer")?.to_owned(), - field(profile, "profile")?.to_owned(), - ); - if !declared_profiles.insert(pair) { - return Err("Duplicate manifest producer/profile pair".into()); - } - } - if profiles != declared_profiles { - return Err( - "Manifest producer/profile inventory does not match its resource metadata".into(), - ); - } - for document in documents { - let id = field(document, "id")?; - let local = &identities[id]; - for block in array(document, "blocks")? { - for reference in array(block, "references")? { - let target = field(reference, "id")?; - if local.get(target).map(String::as_str) != Some(field(reference, "kind")?) { - return Err(format!( - "Manifest reference from {id} is missing or has the wrong kind: {target}" - )); - } - } - } - } - Ok(()) -} diff --git a/crates/lgtmd/src/markdown.rs b/crates/lgtmd/src/markdown.rs deleted file mode 100644 index af39104..0000000 --- a/crates/lgtmd/src/markdown.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! CommonMark validation and provenance for framework-generated HTML. -use crate::{ - Result, - model::{Inline, Prose}, -}; -use pulldown_cmark::{BrokenLink, Event, Options, Parser, Tag}; -use std::ops::Range; - -/// Keep provenance for the small amount of HTML emitted by typed bindings and -/// anchors. Authored Markdown never acquires that permission by sharing a page. -#[derive(Default)] -pub(crate) struct Rendered { - pub(crate) text: String, - html: Vec>, -} - -impl Rendered { - pub(crate) fn text(text: impl Into) -> Self { - Self { - text: text.into(), - html: Vec::new(), - } - } - - pub(crate) fn html(text: String) -> Self { - let len = text.len(); - Self { - text, - html: std::iter::once(0..len).collect(), - } - } - - pub(crate) fn push(&mut self, other: Self) { - let offset = self.text.len(); - self.text.push_str(&other.text); - self.html.extend( - other - .html - .into_iter() - .map(|range| range.start + offset..range.end + offset), - ); - } - - pub(crate) fn quote(self) -> Self { - let mut result = Self::default(); - let mut offset = 0; - for line in self.text.split_inclusive('\n') { - let start = result.text.len() + 2; - result.text.push_str("> "); - result.text.push_str(line); - for range in &self.html { - let left = range.start.max(offset); - let right = range.end.min(offset + line.len()); - if left < right { - result - .html - .push(start + left - offset..start + right - offset); - } - } - offset += line.len(); - } - // Match str::lines(), used by the original note renderer. - if result.text.ends_with('\n') { - result.text.pop(); - } - result - } -} - -pub(crate) fn markdown_links(text: &str) -> Result> { - parsed_links(&Rendered::text(text), &[]) -} - -/// Parse the bytes that will actually be exported. Every HTML token must come -/// from a generated span, every generated tag must survive Markdown parsing, -/// and code/container blocks cannot consume the next authored node. Plain -/// paragraphs may continue across MarkdownParts nodes, whose whitespace is literal. -pub(crate) fn parsed_links( - rendered: &Rendered, - boundaries: &[(usize, &str)], -) -> Result> { - let text = &rendered.text; - let mut unresolved = Vec::new(); - let mut callback = |link: BrokenLink<'_>| { - unresolved.push(link.reference.to_string()); - None - }; - let mut links = Vec::new(); - let mut html_ranges = Vec::new(); - for (event, range) in Parser::new_with_broken_link_callback( - text, - Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH, - Some(&mut callback), - ) - .into_offset_iter() - { - if matches!( - &event, - Event::Code(_) - | Event::Start( - Tag::CodeBlock(_) - | Tag::HtmlBlock - | Tag::BlockQuote(_) - | Tag::List(_) - | Tag::Item - | Tag::Table(_) - | Tag::TableHead - | Tag::TableRow - ) - ) && boundaries - .iter() - .any(|(boundary, _)| range.start < *boundary && *boundary < range.end) - { - return Err(format!( - "Markdown block crosses a document block boundary at {range:?} ({event:?}){}; close fences and separate blocks explicitly", - block_context(range.start, boundaries), - )); - } - match event { - Event::Start(Tag::Link { dest_url, .. } | Tag::Image { dest_url, .. }) => { - links.push(dest_url.to_string()) - } - Event::Html(_) | Event::InlineHtml(_) => { - if (range.clone()).any(|position| { - !text.as_bytes()[position].is_ascii_whitespace() - && !rendered - .html - .iter() - .any(|allowed| allowed.contains(&position)) - }) { - return Err(format!( - "Raw HTML is unsupported in authored Markdown{} at {range:?}; use typed document blocks", - block_context(range.start, boundaries), - )); - } - html_ranges.push(range); - } - _ => {} - } - } - if !unresolved.is_empty() { - return Err(format!( - "Unresolved Markdown references: {}", - unresolved.join(", ") - )); - } - if let Some(expected) = rendered.html.iter().find(|expected| { - (*expected).clone().any(|position| { - !text.as_bytes()[position].is_ascii_whitespace() - && !html_ranges.iter().any(|actual| actual.contains(&position)) - }) - }) { - return Err(format!( - "Markdown swallowed a generated binding or block anchor{} at {expected:?}; typed HTML cannot be placed inside code or HTML syntax", - block_context(expected.start, boundaries), - )); - } - Ok(links) -} - -fn block_context(position: usize, boundaries: &[(usize, &str)]) -> String { - boundaries - .iter() - .rev() - .find(|(start, _)| *start <= position) - .map(|(_, id)| format!(" in block {id}")) - .unwrap_or_default() -} - -pub(crate) fn validate_markdown_parts(parts: &Prose) -> Result<()> { - let template: String = parts - .0 - .iter() - .map(|part| match part { - Inline::Text(text) => text.as_str(), - _ => "EXECUTABLEDOCREFERENCE", - }) - .collect(); - markdown_links(&template).map(|_| ()) -} - -pub(crate) fn escape(text: &str) -> String { - let mut result = String::new(); - for c in text.chars() { - match c { - '&' => result.push_str("&"), - '<' => result.push_str("<"), - '>' => result.push_str(">"), - '\\' | '`' | '~' | '*' | '_' | '[' | ']' | '(' | ')' | '#' | '+' | '-' | '!' | '|' - | '{' | '}' | '.' => { - result.push('\\'); - result.push(c); - } - _ => result.push(c), - } - } - result -} diff --git a/crates/lgtmd/src/model.rs b/crates/lgtmd/src/model.rs deleted file mode 100644 index 78bedcc..0000000 --- a/crates/lgtmd/src/model.rs +++ /dev/null @@ -1,374 +0,0 @@ -//! Frozen document data, typed handles, and prose values. - -use crate::{Artifact, Result}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::{collections::BTreeMap, marker::PhantomData}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum Audience { - Reader, - Contributor, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub(crate) enum Kind { - Block, - Claim, - Value, - Observation, - Resource, - Binding, -} - -#[derive(Clone, Debug)] -pub struct Reference { - pub(crate) owner: u64, - pub(crate) id: String, - pub(crate) kind: Kind, -} - -/// A handle can only originate from this crate and the document that owns it. -pub trait Target: private::Sealed { - #[doc(hidden)] - fn reference(&self) -> Reference; -} - -mod private { - pub trait Sealed {} -} - -macro_rules! handle { - ($name:ident) => { - #[derive(Clone, Debug)] - pub struct $name(pub(crate) Reference); - impl private::Sealed for $name {} - impl Target for $name { - fn reference(&self) -> Reference { - self.0.clone() - } - } - impl $name { - pub fn id(&self) -> &str { - &self.0.id - } - } - impl IntoProse for &$name { - fn into_prose(self) -> Prose { - Prose(vec![Inline::Reference(self.reference())]) - } - } - impl IntoProse for $name { - fn into_prose(self) -> Prose { - (&self).into_prose() - } - } - }; -} - -handle!(Block); -handle!(Claim); -handle!(Resource); -handle!(Binding); - -impl Block { - /// Link to this block with a reader-facing label. Reader content cannot link - /// to contributor-only notes. - pub fn link(&self, label: &str) -> Prose { - Prose(vec![Inline::Link(self.reference(), label.into())]) - } -} - -/// An observed application label, not a behavioral assertion. The host adapter -/// must establish that the control or canonical shortcut was actually observed. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde( - tag = "kind", - content = "value", - rename_all = "lowercase", - deny_unknown_fields -)] -pub enum BindingValue { - Text(String), - Code(String), - Keys(Vec), -} - -impl Resource { - /// Place this resource as an image inline. The owning builder checks its MIME. - pub fn image(&self, alt: &str) -> Prose { - Prose(vec![Inline::Image(self.reference(), alt.into())]) - } - - /// Link to this resource using a reader-facing label. - pub fn link(&self, label: &str) -> Prose { - Prose(vec![Inline::Link(self.reference(), label.into())]) - } - - /// Present a UTF-8 text or JSON resource as a fenced code block in Markdown - /// parts. The view must occupy its own block, separated by blank lines. - /// The owning builder validates the language and resource kind. - pub fn code(&self, language: &str) -> Prose { - Prose(vec![Inline::Code(self.reference(), language.into())]) - } -} - -/// The serialized observation is frozen when the equality check succeeds. -#[derive(Clone, Debug)] -pub struct Checked { - pub(crate) reference: Reference, - pub(crate) observed: Value, - pub(crate) marker: PhantomData T>, -} - -impl Checked { - pub fn id(&self) -> &str { - &self.reference.id - } - pub fn observed(&self) -> &Value { - &self.observed - } -} -impl private::Sealed for Checked {} -impl Target for Checked { - fn reference(&self) -> Reference { - self.reference.clone() - } -} -impl IntoProse for &Checked { - fn into_prose(self) -> Prose { - Prose(vec![Inline::Reference(self.reference())]) - } -} -impl IntoProse for Checked { - fn into_prose(self) -> Prose { - (&self).into_prose() - } -} - -/// A serialized value frozen at registration, without a behavioral assertion. -/// Rendering this handle reports the observation; it never implies a check ran. -#[derive(Clone, Debug)] -pub struct Observed { - pub(crate) reference: Reference, - pub(crate) observed: Value, - pub(crate) marker: PhantomData T>, -} - -impl Observed { - pub fn id(&self) -> &str { - &self.reference.id - } - pub fn observed(&self) -> &Value { - &self.observed - } -} -impl private::Sealed for Observed {} -impl Target for Observed { - fn reference(&self) -> Reference { - self.reference.clone() - } -} -impl IntoProse for &Observed { - fn into_prose(self) -> Prose { - Prose(vec![Inline::Reference(self.reference())]) - } -} -impl IntoProse for Observed { - fn into_prose(self) -> Prose { - (&self).into_prose() - } -} - -#[derive(Clone, Debug)] -pub(crate) enum Inline { - Text(String), - Reference(Reference), - Image(Reference, String), - Link(Reference, String), - Code(Reference, String), -} - -#[derive(Clone, Debug)] -pub struct Prose(pub(crate) Vec); - -/// Text is escaped as literal prose; typed references are resolved by the owner. -/// Tuples permit mixed text and references without placeholder names. -pub trait IntoProse { - fn into_prose(self) -> Prose; -} -impl IntoProse for Prose { - fn into_prose(self) -> Prose { - self - } -} -impl IntoProse for &str { - fn into_prose(self) -> Prose { - Prose(vec![Inline::Text(self.into())]) - } -} -impl IntoProse for String { - fn into_prose(self) -> Prose { - Prose(vec![Inline::Text(self)]) - } -} -impl IntoProse for &String { - fn into_prose(self) -> Prose { - self.as_str().into_prose() - } -} -impl IntoProse for Vec { - fn into_prose(self) -> Prose { - Prose( - self.into_iter() - .flat_map(|part| part.into_prose().0) - .collect(), - ) - } -} -macro_rules! tuple_prose { - ($($ty:ident:$field:tt),+) => { - impl<$($ty: IntoProse),+> IntoProse for ($($ty,)+) { - fn into_prose(self) -> Prose { - let mut parts = Vec::new(); - $(parts.extend(self.$field.into_prose().0);)+ - Prose(parts) - } - } - }; -} -tuple_prose!(A:0, B:1); -tuple_prose!(A:0, B:1, C:2); -tuple_prose!(A:0, B:1, C:2, D:3); -tuple_prose!(A:0, B:1, C:2, D:3, E:4); -tuple_prose!(A:0, B:1, C:2, D:3, E:4, F:5); -tuple_prose!(A:0, B:1, C:2, D:3, E:4, F:5, G:6); -tuple_prose!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7); - -#[derive(Clone, Debug, Serialize)] -pub(crate) struct Check { - pub id: String, - pub kind: String, - pub actual: Value, - pub expected: Value, -} - -#[derive(Clone, Debug)] -pub(crate) enum Content { - Heading(u8, String), - Paragraph(Prose), - Markdown(String), - MarkdownParts(Prose), - Embed(Reference, String), - Code(Reference, String), - Note { - target: Option, - prose: Prose, - }, -} - -#[derive(Clone, Debug)] -pub(crate) struct Node { - pub id: String, - pub content: Content, -} - -impl Node { - pub(crate) fn is_note(&self) -> bool { - matches!(self.content, Content::Note { .. }) - } - pub(crate) fn references(&self) -> Vec<&Reference> { - let mut refs = Vec::new(); - match &self.content { - Content::Paragraph(prose) - | Content::MarkdownParts(prose) - | Content::Note { prose, .. } => { - refs.extend(prose.0.iter().filter_map(|part| match part { - Inline::Reference(r) - | Inline::Image(r, _) - | Inline::Link(r, _) - | Inline::Code(r, _) => Some(r), - _ => None, - })); - } - Content::Embed(r, _) | Content::Code(r, _) => refs.push(r), - _ => {} - } - if let Content::Note { - target: Some(target), - .. - } = &self.content - { - refs.push(target); - } - refs - } -} - -/// An immutable successfully completed scenario/document. -#[derive(Clone, Debug)] -pub struct Document { - pub(crate) id: String, - pub(crate) title: String, - pub(crate) nodes: Vec, - pub(crate) checks: BTreeMap, - pub(crate) observations: BTreeMap, - pub(crate) artifacts: BTreeMap, - pub(crate) resource_paths: BTreeMap, - pub(crate) bindings: BTreeMap, -} - -impl Document { - pub fn id(&self) -> &str { - &self.id - } - pub fn render_many( - documents: &[Self], - audience: Audience, - ) -> Result>> { - crate::render(documents, audience) - } - - /// Export a complete bundle using project-selected page and resource paths. - /// Checks ownership, audience visibility and links against the selected layout. - pub fn render_many_with( - documents: &[Self], - audience: Audience, - layout: &crate::ExportLayout, - ) -> Result>> { - crate::render_with(documents, audience, layout) - } - - /// Export this document as a complete bundle with a selected layout. - pub fn render_with( - &self, - audience: Audience, - layout: &crate::ExportLayout, - ) -> Result>> { - Self::render_many_with(std::slice::from_ref(self), audience, layout) - } - - /// Compose an existing host-owned Markdown page without adding a title or - /// block anchors. The host must validate links against its complete bundle. - /// Handles, resources, visibility and binding kinds remain strictly checked. - pub fn render_fragment(&self, audience: Audience) -> Result { - crate::render::render_fragment(self, audience) - } - - /// Copy the frozen resources referenced by this audience, keyed by export path. - /// Fragment adapters must use the same audience for page text and resources. - /// This adds no page or manifest and never reruns a resource producer. - pub fn resource_files(&self, audience: Audience) -> Result>> { - crate::render::resource_files(self, audience) - } -} - -pub(crate) fn valid_id(id: &str) -> bool { - !id.is_empty() - && id.len() <= 100 - && id - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_') -} diff --git a/crates/lgtmd/src/paths.rs b/crates/lgtmd/src/paths.rs deleted file mode 100644 index a09fcd1..0000000 --- a/crates/lgtmd/src/paths.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Bundle paths and local URL resolution, independent of Markdown export. -use crate::{ExportLayout, Result, model::Document}; - -pub(crate) fn validate_resource_path(path: &str) -> Result<()> { - if path.is_empty() - || matches!( - path.split('/').next(), - Some("manifest.json" | ".ownership.json") - ) - || path - .chars() - .any(|c| c.is_control() || c.is_whitespace() || "\\:%?#()[]<>\"".contains(c)) - || path - .split('/') - .any(|part| part.is_empty() || part == "." || part == "..") - { - return Err(format!("Unsafe or reserved artifact path: {path:?}")); - } - Ok(()) -} - -pub(crate) fn artifact_path(document: &Document, id: &str) -> String { - ExportLayout::default().resource_path(document, id) -} - -/// Decode URL escapes before checking local ownership, so encoded traversal and -/// encoded audience-hidden resource links receive the same checks as plain ones. -fn decode_path(text: &str) -> Result { - let bytes = text.as_bytes(); - let mut decoded = Vec::new(); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'%' { - if i + 2 >= bytes.len() { - return Err(format!("Malformed percent escape in link: {text}")); - } - let hex = - std::str::from_utf8(&bytes[i + 1..i + 3]).map_err(|_| "Invalid URL escape")?; - decoded.push( - u8::from_str_radix(hex, 16) - .map_err(|_| format!("Malformed percent escape in link: {text}"))?, - ); - i += 3; - } else { - decoded.push(bytes[i]); - i += 1; - } - } - String::from_utf8(decoded).map_err(|_| "Local link is not UTF-8".into()) -} - -/// Produce a page-relative URL path while keeping ownership paths bundle-relative. -pub(crate) fn relative_path(page: &str, target: &str) -> String { - let mut directory: Vec<_> = page.split('/').collect(); - directory.pop(); - let target: Vec<_> = target.split('/').collect(); - let common = directory - .iter() - .zip(&target) - .take_while(|(a, b)| a == b) - .count(); - std::iter::repeat_n("..", directory.len() - common) - .chain(target[common..].iter().copied()) - .collect::>() - .join("/") -} - -pub(crate) fn local_target(link: &str, page: &str) -> Result)>> { - if link.chars().any(char::is_control) { - return Err("Link contains control characters".into()); - } - for scheme in ["https://", "http://"] { - if let Some(rest) = link.strip_prefix(scheme) { - if rest.is_empty() || rest.starts_with('/') || rest.chars().any(char::is_whitespace) { - return Err(format!("Malformed external link: {link}")); - } - return Ok(None); - } - } - if let Some(rest) = link.strip_prefix("mailto:") { - if rest.is_empty() || rest.chars().any(char::is_whitespace) { - return Err(format!("Malformed mail link: {link}")); - } - return Ok(None); - } - let (path, fragment) = link - .split_once('#') - .map_or((link, None), |(path, fragment)| (path, Some(fragment))); - let path = decode_path(path)?; - if path.contains(['\\', ':', '?']) - || path.starts_with('/') - || (!path.is_empty() && path.split('/').any(str::is_empty)) - || path.chars().any(char::is_control) - { - return Err(format!("Unsafe or unsupported local link: {link}")); - } - let fragment = fragment.map(decode_path).transpose()?; - if path.is_empty() && fragment.as_deref().is_none_or(str::is_empty) { - return Err("An empty link does not identify evidence".into()); - } - let resolved = if path.is_empty() { - page.to_owned() - } else { - let mut components: Vec<_> = page.split('/').collect(); - components.pop(); - for component in path.split('/') { - match component { - "." => {} - ".." => { - if components.pop().is_none() { - return Err(format!("Local link escapes the export bundle: {link}")); - } - } - _ => components.push(component), - } - } - if components.is_empty() { - return Err(format!("Local link does not identify a file: {link}")); - } - components.join("/") - }; - Ok(Some((resolved, fragment))) -} diff --git a/crates/lgtmd/src/prose.rs b/crates/lgtmd/src/prose.rs deleted file mode 100644 index 08eb77e..0000000 --- a/crates/lgtmd/src/prose.rs +++ /dev/null @@ -1,55 +0,0 @@ -use crate::{Prose, Result, model::Inline}; -use std::collections::{BTreeMap, BTreeSet}; - -impl Prose { - /// Compose named typed parts without flattening their evidence references. - /// Supports `{name}` and escaped `{{`/`}}`; format specifiers are unsupported. - /// Prefer the named form of `prose!` at authoring call sites. - pub fn interpolate(text: &str, bindings: [(&str, Self); N]) -> Result { - let mut names = BTreeMap::new(); - for (name, value) in bindings { - if name.is_empty() || name.chars().any(|c| c.is_whitespace() || "{}:".contains(c)) { - return Err(format!("Invalid prose binding name: {name:?}")); - } - if names.insert(name, value).is_some() { - return Err(format!("Duplicate prose binding: {name}")); - } - } - let mut used = BTreeSet::new(); - let mut parts = Vec::new(); - let mut literal = String::new(); - let mut remaining = text; - while let Some(index) = remaining.find(['{', '}']) { - literal.push_str(&remaining[..index]); - remaining = &remaining[index..]; - if remaining.starts_with("{{") || remaining.starts_with("}}") { - literal.push(remaining.as_bytes()[0] as char); - remaining = &remaining[2..]; - continue; - } - if remaining.starts_with('}') { - return Err("Unmatched closing brace in prose; use }} for a literal brace".into()); - } - let end = remaining.find('}').ok_or("Unclosed prose binding")?; - let name = &remaining[1..end]; - let value = names - .get(name) - .ok_or_else(|| format!("Unknown prose binding: {name:?}"))?; - if !literal.is_empty() { - parts.push(Inline::Text(std::mem::take(&mut literal))); - } - parts.extend(value.0.iter().cloned()); - used.insert(name); - remaining = &remaining[end + 1..]; - } - literal.push_str(remaining); - if !literal.is_empty() { - parts.push(Inline::Text(literal)); - } - let unused: Vec<_> = names.keys().filter(|name| !used.contains(**name)).collect(); - if !unused.is_empty() { - return Err(format!("Unused prose bindings: {unused:?}")); - } - Ok(Self(parts)) - } -} diff --git a/crates/lgtmd/src/render.rs b/crates/lgtmd/src/render.rs deleted file mode 100644 index 960ece6..0000000 --- a/crates/lgtmd/src/render.rs +++ /dev/null @@ -1,417 +0,0 @@ -use crate::model::{BindingValue, Content, Document, Inline, Kind, Node, Prose, Reference}; -use crate::{ - Audience, ExportLayout, Result, - manifest::Manifest, - markdown::{Rendered, escape, markdown_links, parsed_links}, - paths::{artifact_path, local_target, relative_path, validate_resource_path}, - validation::{validate_document, visible_nodes}, -}; -use std::collections::{BTreeMap, BTreeSet}; - -fn node_links(document: &Document, node: &Node, layout: &ExportLayout) -> Result> { - let links = match &node.content { - Content::Markdown(text) => markdown_links(text), - Content::MarkdownParts(parts) => { - parsed_links(&render_prose(document, parts, true, layout), &[]) - } - _ => Ok(Vec::new()), - }; - links.map_err(|error| { - format!( - "Document {} ({}), block {}: {error}", - document.id, - layout.page_path(&document.id), - node.id - ) - }) -} - -fn visible_resources( - document: &Document, - audience: Audience, - layout: &ExportLayout, -) -> Result> { - let paths: BTreeMap<_, _> = document - .artifacts - .keys() - .map(|id| (layout.resource_path(document, id), id)) - .collect(); - let mut used = BTreeSet::new(); - for node in visible_nodes(document, audience) { - for reference in node.references() { - if reference.kind == Kind::Resource { - used.insert(reference.id.clone()); - } - } - for link in node_links(document, node, layout)? { - if let Some((path, _)) = local_target(&link, &layout.page_path(&document.id))? - && let Some(id) = paths.get(&path) - { - used.insert((*id).clone()); - } - } - } - Ok(used) -} - -fn insert_owned_path(paths: &mut BTreeSet, path: String) -> Result<()> { - validate_resource_path(&path)?; - if paths.contains(&path) { - return Err(format!("Duplicate artifact path: {path}")); - } - if paths.iter().any(|existing| { - path.starts_with(&format!("{existing}/")) || existing.starts_with(&format!("{path}/")) - }) { - return Err(format!("Artifact file/directory collision: {path}")); - } - paths.insert(path); - Ok(()) -} - -fn validate_document_with(document: &Document, layout: &ExportLayout) -> Result<()> { - validate_document(document)?; - let mut paths = BTreeSet::from(["manifest.json".to_owned()]); - insert_owned_path(&mut paths, layout.page_path(&document.id))?; - for id in document.artifacts.keys() { - insert_owned_path(&mut paths, layout.resource_path(document, id))?; - } - let used = visible_resources(document, Audience::Contributor, layout)?; - let orphaned: Vec<_> = document - .artifacts - .keys() - .filter(|id| !used.contains(*id)) - .collect(); - if !orphaned.is_empty() { - return Err(format!("Unreferenced resources: {orphaned:?}")); - } - Ok(()) -} - -fn resource_url(document: &Document, reference: &Reference, layout: &ExportLayout) -> String { - if reference.kind == Kind::Block { - return format!("#{}", reference.id); - } - // Encode bytes before Markdown parses entities or punctuation. The stored - // artifact path remains unchanged; ownership checks decode this destination. - relative_path( - &layout.page_path(&document.id), - &layout.resource_path(document, &reference.id), - ) - .bytes() - .map(|byte| { - if byte.is_ascii_alphanumeric() || b"/-._~".contains(&byte) { - (byte as char).to_string() - } else { - format!("%{byte:02X}") - } - }) - .collect() -} - -fn binding_html(tag: &str, text: &str) -> Rendered { - let mut rendered = Rendered::html(format!("<{tag}>")); - rendered.push(Rendered::text(escape_html(text))); - rendered.push(Rendered::html(format!(""))); - rendered -} - -fn inline(document: &Document, reference: &Reference, layout: &ExportLayout) -> Rendered { - Rendered::text(match reference.kind { - Kind::Resource | Kind::Block => format!( - "[{}]({})", - escape(&reference.id), - resource_url(document, reference, layout) - ), - Kind::Claim => format!("`{}`", reference.id), - Kind::Value | Kind::Observation => { - let actual = match reference.kind { - Kind::Value => &document.checks[&reference.id].actual, - _ => &document.observations[&reference.id], - }; - escape( - &actual - .as_str() - .map(str::to_owned) - .unwrap_or_else(|| actual.to_string()), - ) - } - Kind::Binding => match &document.bindings[&reference.id] { - BindingValue::Text(text) => escape(text), - BindingValue::Code(text) => return binding_html("code", text), - BindingValue::Keys(keys) => { - let mut rendered = Rendered::default(); - for (index, key) in keys.iter().enumerate() { - if index != 0 { - rendered.push(Rendered::text(" + ")); - } - rendered.push(binding_html("kbd", key)); - } - return rendered; - } - }, - }) -} - -fn escape_html(text: &str) -> String { - text.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") - .replace('|', "|") - .replace('`', "`") - .replace('*', "*") - .replace('_', "_") - .replace('~', "~") - .replace('\\', "\") - .replace('[', "[") - .replace(']', "]") - .replace('!', "!") -} - -fn render_prose( - document: &Document, - prose: &Prose, - markdown: bool, - layout: &ExportLayout, -) -> Rendered { - let mut rendered = Rendered::default(); - for part in &prose.0 { - rendered.push(match part { - Inline::Text(text) => { - Rendered::text(if markdown { text.clone() } else { escape(text) }) - } - Inline::Reference(reference) => inline(document, reference, layout), - Inline::Image(reference, alt) => Rendered::text(format!( - "![{}]({})", - escape(alt), - resource_url(document, reference, layout) - )), - Inline::Link(reference, label) => Rendered::text(format!( - "[{}]({})", - escape(label), - resource_url(document, reference, layout) - )), - Inline::Code(reference, language) => { - let text = std::str::from_utf8(&document.artifacts[&reference.id].bytes) - .expect("Authoring validates code resource UTF-8"); - Rendered::text(format!("{}\n\n", render_code(text, language))) - } - }); - } - rendered -} - -fn code_fence(text: &str) -> String { - let longest = text.split(|c| c != '`').map(str::len).max().unwrap_or(0); - "`".repeat(longest.max(2) + 1) -} - -fn render_code(text: &str, language: &str) -> String { - let fence = code_fence(text); - format!( - "{fence}{language}\n{text}{}{fence}", - if text.ends_with('\n') { "" } else { "\n" } - ) -} - -fn render_page( - document: &Document, - audience: Audience, - fragment: bool, - layout: &ExportLayout, -) -> Result<(String, Vec)> { - let mut page = if fragment { - Rendered::default() - } else { - Rendered::text(format!("# {}\n\n", escape(&document.title))) - }; - let mut boundaries = Vec::new(); - for node in visible_nodes(document, audience) { - boundaries.push((page.text.len(), node.id.as_str())); - if !fragment { - page.push(Rendered::html(format!("", node.id))); - page.push(Rendered::text("\n\n")); - } - let content = match &node.content { - Content::Heading(level, text) => { - Rendered::text(format!("{} {}", "#".repeat((*level).into()), escape(text))) - } - Content::Paragraph(text) => render_prose(document, text, false, layout), - Content::Markdown(text) => Rendered::text(text.clone()), - Content::MarkdownParts(parts) => render_prose(document, parts, true, layout), - Content::Embed(reference, caption) => { - let image = document.artifacts[&reference.id].mime.starts_with("image/"); - Rendered::text(format!( - "{}[{}]({})", - if image { "!" } else { "" }, - escape(caption), - resource_url(document, reference, layout) - )) - } - Content::Code(reference, language) => { - let text = std::str::from_utf8(&document.artifacts[&reference.id].bytes) - .map_err(|_| "Code resource must be UTF-8")?; - Rendered::text(render_code(text, language)) - } - Content::Note { target, prose } => { - let mut note = Rendered::text("**Contributor note"); - if let Some(reference) = target { - note.push(Rendered::text(" on ")); - note.push(match reference.kind { - Kind::Claim | Kind::Value | Kind::Observation => { - Rendered::text(format!("`{}`", reference.id)) - } - _ => inline(document, reference, layout), - }); - } - note.push(Rendered::text(":** ")); - note.push(render_prose(document, prose, false, layout)); - note.quote() - } - }; - page.push(content); - if !matches!(node.content, Content::MarkdownParts(_)) { - page.push(Rendered::text("\n\n")); - } - } - let links = parsed_links(&page, &boundaries).map_err(|error| { - format!( - "Document {} ({}): {error}", - document.id, - layout.page_path(&document.id) - ) - })?; - Ok((page.text, links)) -} - -pub(crate) fn render_fragment(document: &Document, audience: Audience) -> Result { - validate_document_with(document, &ExportLayout::default())?; - for node in visible_nodes(document, audience) { - if node - .references() - .iter() - .any(|reference| reference.kind == Kind::Block) - { - return Err( - "Fragment rendering omits block anchors; block references are unsupported".into(), - ); - } - } - render_page(document, audience, true, &ExportLayout::default()).map(|(text, _)| text) -} - -pub(crate) fn resource_files( - document: &Document, - audience: Audience, -) -> Result>> { - validate_document_with(document, &ExportLayout::default())?; - Ok( - visible_resources(document, audience, &ExportLayout::default())? - .into_iter() - .map(|id| { - ( - artifact_path(document, &id), - document.artifacts[&id].bytes.clone(), - ) - }) - .collect(), - ) -} - -/// Render ordinary Markdown, immutable resources and a deterministic JSON manifest. -/// Reader output contains neither contributor blocks nor their exclusive resources. -/// Local link fragments must refer to generated block IDs; raw heading slugs are -/// intentionally not assumed to be identical across downstream site renderers. -pub fn render(documents: &[Document], audience: Audience) -> Result>> { - render_with(documents, audience, &ExportLayout::default()) -} - -/// Render with project-selected bundle paths. Typed links are relative to their -/// containing page; authored links resolve from that same page directory. -/// The complete inventory remains strict across both audiences. -pub fn render_with( - documents: &[Document], - audience: Audience, - layout: &ExportLayout, -) -> Result>> { - layout.validate_documents(documents)?; - if documents.is_empty() { - return Err("At least one document is required".into()); - } - let mut sorted: Vec<_> = documents.iter().collect(); - sorted.sort_by(|a, b| a.id.cmp(&b.id)); - // Ownership is a property of the completed bundle, including contributor-only - // resources. Selecting Reader must not hide a duplicate declaration. - let mut owned_paths = BTreeSet::from(["manifest.json".to_owned()]); - for document in &sorted { - for path in std::iter::once(layout.page_path(&document.id)).chain( - document - .artifacts - .keys() - .map(|id| layout.resource_path(document, id)), - ) { - insert_owned_path(&mut owned_paths, path)?; - } - } - let mut files = BTreeMap::new(); - let mut ids = BTreeSet::new(); - let mut manifest = Manifest::new(audience); - let mut anchors = BTreeMap::new(); - let mut page_links = BTreeMap::new(); - for document in &sorted { - if !ids.insert(&document.id) { - return Err(format!("Duplicate document ID: {}", document.id)); - } - validate_document_with(document, layout)?; - let path = layout.page_path(&document.id); - let (page, links) = render_page(document, audience, false, layout)?; - let page = page.into_bytes(); - page_links.insert(&document.id, links); - manifest.add_page(&document.id, &path, &page); - if files.insert(path.clone(), page).is_some() { - return Err(format!("Duplicate artifact path: {path}")); - } - anchors.insert( - path, - visible_nodes(document, audience) - .map(|node| node.id.clone()) - .collect::>(), - ); - let resources = visible_resources(document, audience, layout)?; - for id in resources { - let artifact = &document.artifacts[&id]; - let path = layout.resource_path(document, &id); - manifest.add_resource(&document.id, &id, &path, artifact); - if files.insert(path.clone(), artifact.bytes.clone()).is_some() { - return Err(format!("Duplicate artifact path: {path}")); - } - } - manifest.add_document(document, audience)?; - } - // Validate against the actual filtered bundle, never against all collected resources. - for document in &sorted { - for link in &page_links[&document.id] { - if let Some((path, fragment)) = local_target(link, &layout.page_path(&document.id))? { - if !files.contains_key(&path) { - return Err(format!( - "Broken or excluded local link in {}: {link}", - document.id - )); - } - if let Some(fragment) = fragment - && !anchors - .get(&path) - .is_some_and(|ids| ids.contains(&fragment)) - { - return Err(format!( - "Unknown generated block anchor in {}: {link}", - document.id - )); - } - } - } - } - files.insert("manifest.json".into(), manifest.encode()?); - Ok(files) -} diff --git a/crates/lgtmd/src/runner.rs b/crates/lgtmd/src/runner.rs deleted file mode 100644 index edfb477..0000000 --- a/crates/lgtmd/src/runner.rs +++ /dev/null @@ -1,373 +0,0 @@ -//! Small project-owned runner: execute once, render before publishing, then verify. -use crate::{Audience, Document, ExportLayout, Result, lifecycle, render_with, store}; -use std::{ - ffi::OsString, - path::{Path, PathBuf}, -}; - -const USAGE: &str = "Usage: check|update|build --out PATH [--audience reader|contributor|both]"; - -/// A project may call `run` directly without adopting the supplied CLI syntax. -#[derive(Clone, Debug)] -pub enum RunMode { - Check, - Update, - Build(PathBuf), - /// Build fresh trees at the explicitly configured audience output paths. - /// Useful for hosts that choose each candidate path rather than a build root. - BuildConfigured, -} - -struct Options { - mode: RunMode, - audiences: Option>, -} - -#[derive(Clone, Debug)] -struct Output { - audience: Audience, - baseline: PathBuf, - candidate_dir: PathBuf, -} - -/// Caller-owned filesystem and export policy. No repository or Cargo discovery. -/// Relative baseline and build paths resolve against `base_dir`, captured once. -#[derive(Clone, Debug)] -pub struct RunnerConfig { - base_dir: PathBuf, - outputs: Vec, - layout: ExportLayout, - defaults: Option>, - protected_roots: Vec, -} - -impl RunnerConfig { - pub fn new(base_dir: impl AsRef) -> Result { - Ok(Self { - base_dir: lifecycle::absolute_base(base_dir.as_ref())?, - outputs: Vec::new(), - layout: ExportLayout::default(), - defaults: None, - protected_roots: Vec::new(), - }) - } - - /// Configure one retained tree and its relative directory under a build root. - /// An empty candidate directory puts a single selected audience at that root. - pub fn output( - mut self, - audience: Audience, - baseline: impl Into, - candidate_dir: impl Into, - ) -> Result { - if self - .outputs - .iter() - .any(|output| output.audience == audience) - { - return Err(format!( - "Duplicate configured audience: {}", - audience_name(audience) - )); - } - let candidate_dir = candidate_dir.into(); - if !candidate_dir.as_os_str().is_empty() { - lifecycle::validate_path( - candidate_dir - .to_str() - .ok_or("Candidate directory must be UTF-8")?, - )?; - } - self.outputs.push(Output { - audience, - baseline: baseline.into(), - candidate_dir, - }); - Ok(self) - } - - pub fn layout(mut self, layout: ExportLayout) -> Self { - self.layout = layout; - self - } - - /// CLI default selection. Without this override, all configured outputs run. - pub fn default_audiences(mut self, audiences: impl IntoIterator) -> Self { - self.defaults = Some(audiences.into_iter().collect()); - self - } - - pub fn base_dir(&self) -> &Path { - &self.base_dir - } - - fn resolve(&self, path: &Path) -> Result { - store::absolute(&self.base_dir.join(path)) - } -} - -fn parse(args: impl IntoIterator) -> Result { - let mut args = args.into_iter(); - let mode = args.next().ok_or(USAGE)?; - if !matches!(mode.as_str(), "check" | "update" | "build") { - return Err(USAGE.into()); - } - let mut out = None; - let mut selected = None; - while let Some(argument) = args.next() { - match argument.as_str() { - "--out" if out.is_none() => { - let value = args.next().ok_or("--out requires a directory")?; - if value.is_empty() || value.starts_with("--") { - return Err("--out requires a directory".into()); - } - out = Some(PathBuf::from(value)); - } - "--audience" if selected.is_none() => { - selected = Some(match args.next().as_deref() { - Some("reader") => vec![Audience::Reader], - Some("contributor") => vec![Audience::Contributor], - Some("both") => vec![Audience::Reader, Audience::Contributor], - _ => return Err("--audience requires reader, contributor, or both".into()), - }); - } - _ => { - return Err(format!( - "Unknown or duplicate argument {argument:?}. {USAGE}" - )); - } - } - } - let mode = match (mode.as_str(), out) { - ("check", None) => RunMode::Check, - ("update", None) => RunMode::Update, - ("build", Some(out)) => RunMode::Build(out), - ("build", None) => return Err("build requires --out PATH".into()), - _ => return Err("--out is only valid for build".into()), - }; - Ok(Options { - mode, - audiences: selected, - }) -} - -fn audience_name(audience: Audience) -> &'static str { - match audience { - Audience::Reader => "reader", - Audience::Contributor => "contributor", - } -} - -fn existing_ancestor(path: &Path) -> Result<(PathBuf, Vec)> { - let mut ancestor = path; - let mut missing = Vec::new(); - while !ancestor.try_exists().map_err(|error| error.to_string())? { - missing.push( - ancestor - .file_name() - .ok_or("Output path has no existing ancestor")? - .to_owned(), - ); - ancestor = ancestor.parent().ok_or("Output path has no parent")?; - } - missing.reverse(); - Ok((ancestor.to_owned(), missing)) -} - -// Missing destinations have no filesystem identity yet. A fresh Contributor -// path must not alias a Reader path after its first publication. Existing -// prefixes establish real separation; otherwise require one pair of missing -// components to be distinct ASCII names even on a case-insensitive filesystem. -// Do not guess Unicode normalization/case equivalence: an ASCII distinguishing -// component or preexisting distinct directories keeps those paths usable. Names -// with trailing dots/spaces or DOS-short-name syntax are ambiguous too. -fn ensure_distinct_destinations(left: &Path, right: &Path) -> Result<()> { - // A candidate at `.manual.backup` can disable checks of `manual`, and an - // audience inside another audience's staging tree can collide during a later - // update. Reserve the complete transaction namespace before any generation. - ensure_distinct_paths(left, right)?; - for reserved in lifecycle::transaction_paths(left)?.into_iter().skip(1) { - ensure_distinct_paths(&reserved, right)?; - } - for reserved in lifecycle::transaction_paths(right)?.into_iter().skip(1) { - ensure_distinct_paths(left, &reserved)?; - } - Ok(()) -} - -fn ensure_distinct_paths(left: &Path, right: &Path) -> Result<()> { - lifecycle::ensure_separate_paths(left, right)?; - let (left_parent, left_missing) = existing_ancestor(left)?; - let (right_parent, right_missing) = existing_ancestor(right)?; - if !same_file::is_same_file(left_parent, right_parent).map_err(|error| error.to_string())? { - return Ok(()); - } - if left_missing - .iter() - .zip(&right_missing) - .any(|(left, right)| match (left.to_str(), right.to_str()) { - (Some(left), Some(right)) - if [left, right].into_iter().all(|component| { - component.is_ascii() - && !component.ends_with(['.', ' ']) - && !component.contains('~') - }) => - { - !left.eq_ignore_ascii_case(right) - } - _ => false, - }) - { - return Ok(()); - } - Err(format!( - "Output paths may overlap through filesystem aliases: {} and {}; use a distinct portable ASCII directory component or precreate distinct directories", - left.display(), - right.display() - )) -} - -/// Convenience wrapper retaining the original reader/contributor directory layout -/// and reader-only default. Arguments exclude the executable name. -pub fn run_cli( - args: impl IntoIterator, - baseline: &Path, - generate: impl FnOnce() -> Result>, -) -> Result<()> { - let mut config = RunnerConfig::new(std::env::current_dir().map_err(|e| e.to_string())?)? - .output(Audience::Reader, baseline.join("reader"), "reader")? - .output( - Audience::Contributor, - baseline.join("contributor"), - "contributor", - )? - .default_audiences([Audience::Reader]); - // The convenience API historically reserves its entire baseline root. - // Explicit configurations reserve their individually configured trees. - config.protected_roots.push(baseline.to_owned()); - run_cli_with(args, &config, generate) -} - -/// Use caller-owned paths/layout with the optional standard CLI adapter. -pub fn run_cli_with( - args: impl IntoIterator, - config: &RunnerConfig, - generate: impl FnOnce() -> Result>, -) -> Result<()> { - let options = parse(args)?; - let audiences = options.audiences.unwrap_or_else(|| { - config.defaults.clone().unwrap_or_else(|| { - config - .outputs - .iter() - .map(|output| output.audience) - .collect() - }) - }); - run(config, options.mode, &audiences, generate) -} - -/// Execute once and prepare every selected export before publishing anything. -/// Destinations are validated before generation. Each audience has an independent -/// transaction; a multi-audience update is not atomic. -pub fn run( - config: &RunnerConfig, - mode: RunMode, - audiences: &[Audience], - generate: impl FnOnce() -> Result>, -) -> Result<()> { - if audiences.is_empty() { - return Err("Select at least one configured audience".into()); - } - let baselines = config - .outputs - .iter() - .map(|output| config.resolve(&output.baseline)) - .collect::>>()?; - for (index, path) in baselines.iter().enumerate() { - for other in &baselines[..index] { - ensure_distinct_destinations(path, other)?; - } - } - let build_root = if let RunMode::Build(path) = &mode { - let output = config.resolve(path)?; - for baseline in &baselines { - ensure_distinct_destinations(&output, baseline)?; - } - for protected in &config.protected_roots { - ensure_distinct_destinations(&output, &config.resolve(protected)?)?; - } - if output.try_exists().map_err(|e| e.to_string())? { - return Err(format!("Build root already exists: {}", output.display())); - } - Some(output) - } else { - None - }; - let mut destinations: Vec<(Audience, PathBuf)> = Vec::new(); - for &audience in audiences { - if destinations - .iter() - .any(|(selected, _)| *selected == audience) - { - return Err(format!( - "Duplicate selected audience: {}", - audience_name(audience) - )); - } - let index = config - .outputs - .iter() - .position(|output| output.audience == audience) - .ok_or_else(|| format!("Audience is not configured: {}", audience_name(audience)))?; - let path = match &build_root { - Some(root) => store::absolute(&root.join(&config.outputs[index].candidate_dir))?, - None => baselines[index].clone(), - }; - for (_, other) in &destinations { - ensure_distinct_destinations(&path, other)?; - } - if matches!(mode, RunMode::BuildConfigured) - && path.try_exists().map_err(|e| e.to_string())? - { - return Err(format!( - "Build destination already exists: {}", - path.display() - )); - } - destinations.push((audience, path)); - } - let prepared = lifecycle::prepare(|| { - let documents = generate()?; - let mut prepared = Vec::new(); - for (audience, destination) in destinations { - let name = audience_name(audience); - let files = render_with(&documents, audience, &config.layout) - .map_err(|error| format!("Render {name}: {error}"))?; - prepared.push((name, destination, files)); - } - Ok::<_, String>(prepared) - })?; - - let mut completed = Vec::new(); - for (name, destination, files) in prepared { - let result = match mode { - RunMode::Check => store::check(&destination, &files), - RunMode::Build(_) | RunMode::BuildConfigured => store::build(&destination, &files), - RunMode::Update => store::update(&destination, &files), - }; - if let Err(error) = result { - let previous = if completed.is_empty() { - String::new() - } else { - format!( - "; earlier audiences completed independently: {}", - completed.join(", ") - ) - }; - return Err(format!("{name}: {error}{previous}")); - } - completed.push(name); - } - Ok(()) -} diff --git a/crates/lgtmd/src/store.rs b/crates/lgtmd/src/store.rs deleted file mode 100644 index 0ec7272..0000000 --- a/crates/lgtmd/src/store.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! Structured SDK manifest policy over the shared artifact lifecycle. -use crate::{ - Result, - lifecycle::{self, Ownership}, - manifest::{Manifest, validate_tree}, -}; -pub use lifecycle::Files; -use std::{ - collections::BTreeSet, - path::{Path, PathBuf}, -}; - -pub(crate) fn absolute(path: &Path) -> Result { - lifecycle::absolute(path) -} - -fn compatible(baseline: &Files, candidate: &Files) -> Result<()> { - validate_tree(baseline, None)?; - compatible_profiles(baseline, candidate) -} - -fn compatible_update( - baseline: &Files, - candidate: &Files, - missing_owned: &BTreeSet, -) -> Result<()> { - // Explicit retirement removes an old file before update. Its old receipt is - // necessarily stale until this transaction publishes the complete new tree. - // Only a missing old artifact absent from the candidate qualifies; changed - // retained bytes, unrecorded files and missing retained files still fail. - validate_tree(baseline, Some(candidate))?; - // A removed file is intentional retirement only when both retained records - // identify it. Phantom ownership entries must never be silently rewritten; - // nor may retirement conceal omissions from the ownership marker. - let old = Manifest::read(baseline)?; - let mut retired = BTreeSet::new(); - for artifact in &old.artifacts { - let path = artifact.path(); - if !baseline.contains_key(path) { - retired.insert(path.to_owned()); - } - } - if &retired != missing_owned { - return Err(format!( - "Ownership inventory differs from retained manifest: missing owned {missing_owned:?}; retired artifacts {retired:?}" - )); - } - compatible_profiles(baseline, candidate) -} - -fn compatible_profiles(baseline: &Files, candidate: &Files) -> Result<()> { - let old = Manifest::read(baseline)?; - let new = Manifest::read(candidate)?; - if old.audience != new.audience { - return Err(format!( - "Incompatible audience: baseline={:?}, candidate={:?}; use a separately reviewed baseline", - old.audience, new.audience - )); - } - // Compatibility belongs to retained resource identities, not the aggregate - // profile set: new features may introduce a producer, and two retained - // resources can swap profiles without changing that aggregate set. - let previous = old.resource_profiles()?; - let next = new.resource_profiles()?; - for (identity, profile) in previous { - if let Some(candidate) = next.get(&identity) - && *candidate != profile - { - return Err(format!( - "Incompatible producer/comparison profiles for {}/{}: baseline={profile:?}, candidate={candidate:?}; use a separately reviewed baseline", - identity.0, identity.1, - )); - } - } - Ok(()) -} - -fn validate(files: &Files) -> Result<()> { - validate_tree(files, None) -} - -/// Read-only exact check with structured manifest compatibility. -pub fn check(path: &Path, files: &Files) -> Result<()> { - validate(files)?; - lifecycle::check(path, files, Ownership::Recorded, compatible) -} - -/// Generate a new, independently owned candidate directory. -pub fn build(path: &Path, files: &Files) -> Result<()> { - validate(files)?; - lifecycle::build(path, files, Ownership::Recorded) -} - -/// Replace a compatible owned baseline only after staging and validation. -pub fn update(path: &Path, files: &Files) -> Result<()> { - validate(files)?; - lifecycle::update_with_retirement(path, files, compatible_update) -} - -/// Explicitly restore a complete valid backup when its destination is absent. -pub fn recover_backup(path: &Path) -> Result<()> { - lifecycle::recover_backup(path, Ownership::Recorded, validate) -} diff --git a/crates/lgtmd/src/transport.rs b/crates/lgtmd/src/transport.rs deleted file mode 100644 index d7018a7..0000000 --- a/crates/lgtmd/src/transport.rs +++ /dev/null @@ -1,588 +0,0 @@ -//! Versioned, language-neutral frozen document input. No callbacks or host state -//! cross this boundary. Compilation uses the same validation and exporters as -//! native Rust authoring; this is not the export inventory or a viewer schema. - -use crate::{ - Artifact, Audience, BindingValue, Doc, Document, ExportLayout, Result, Target, - lifecycle::Files, - model::{Content, Inline, Kind, Prose, Reference}, -}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::BTreeMap; - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Input { - pub schema_version: u32, - pub documents: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct FrozenDocument { - pub id: String, - pub title: String, - pub nodes: Vec, - #[serde(default)] - pub observations: Vec, - #[serde(default)] - pub checks: Vec, - #[serde(default)] - pub resources: Vec, - #[serde(default)] - pub bindings: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Observation { - pub id: String, - pub value: Value, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Check { - pub id: String, - pub kind: String, - pub passed: bool, - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "present_value" - )] - pub actual: Option, - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "present_value" - )] - pub expected: Option, -} - -fn present_value<'de, D: serde::Deserializer<'de>>( - deserializer: D, -) -> std::result::Result, D::Error> { - Value::deserialize(deserializer).map(Some) -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Resource { - pub id: String, - pub mime: String, - pub extension: String, - pub producer: String, - pub profile: String, - #[serde(default)] - pub bytes: Vec, - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "present_value" - )] - pub json: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Binding { - pub id: String, - pub value: BindingValue, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Ref { - pub id: String, - pub kind: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)] -pub enum Part { - Text { text: String }, - Markdown { text: String }, - Reference { target: Ref }, - Image { target: Ref, label: String }, - Link { target: Ref, label: String }, - Code { target: Ref, language: String }, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)] -pub enum Node { - Heading { - id: String, - level: u8, - text: String, - }, - Paragraph { - id: String, - parts: Vec, - }, - Markdown { - id: String, - parts: Vec, - }, - #[serde(rename = "raw-markdown")] - RawMarkdown { - id: String, - text: String, - }, - Embed { - id: String, - target: Ref, - label: String, - }, - Code { - id: String, - target: Ref, - language: String, - }, - Note { - id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - target: Option, - parts: Vec, - }, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Layout { - #[serde(default)] - pub pages: String, - #[serde(default = "default_resources")] - pub resources: String, - #[serde(default)] - pub page_overrides: BTreeMap, -} - -fn default_resources() -> String { - "assets".into() -} - -impl Default for Layout { - fn default() -> Self { - Self { - pages: String::new(), - resources: default_resources(), - page_overrides: BTreeMap::new(), - } - } -} - -impl Layout { - pub fn export_layout(&self) -> Result { - let mut layout = ExportLayout::default() - .pages_under(&self.pages)? - .resources_under(&self.resources)?; - for (id, path) in &self.page_overrides { - layout = layout.page(id, path)?; - } - Ok(layout) - } -} - -/// Both audiences are prepared from one frozen input, before any publication. -#[derive(Serialize)] -pub struct Exports { - pub reader: Files, - pub contributor: Files, -} - -pub fn compile(input: &Input, layout: &ExportLayout) -> Result { - let documents = input.thaw()?; - Ok(Exports { - reader: crate::render_with(&documents, Audience::Reader, layout)?, - contributor: crate::render_with(&documents, Audience::Contributor, layout)?, - }) -} - -impl Input { - pub fn from_documents(documents: &[Document]) -> Result { - Ok(Self { - schema_version: 1, - documents: documents - .iter() - .map(FrozenDocument::from_document) - .collect::>()?, - }) - } - - pub fn thaw(&self) -> Result> { - if self.schema_version != 1 { - return Err(format!( - "Unsupported frozen document schema: {}", - self.schema_version - )); - } - self.documents - .iter() - .map(|document| { - document - .thaw() - .map_err(|error| format!("Document {}: {error}", document.id)) - }) - .collect() - } -} - -// The JS common-value domain is explicit. Rust-only APIs retain their existing -// serde value domain; crossing the portable boundary rejects lossy integers. -fn portable_value(value: &Value) -> Result<()> { - match value { - Value::Number(number) => { - const LIMIT: u64 = 9_007_199_254_740_991; - if number.as_u64().is_some_and(|n| n > LIMIT) - || number.as_i64().is_some_and(|n| n.unsigned_abs() > LIMIT) - || number - .as_f64() - .is_some_and(|n| n.fract() == 0.0 && n.abs() > LIMIT as f64) - { - return Err( - "Portable JSON integers must be within JavaScript's safe integer range".into(), - ); - } - } - Value::Array(values) => { - for value in values { - portable_value(value)?; - } - } - Value::Object(values) => { - for value in values.values() { - portable_value(value)?; - } - } - _ => {} - } - Ok(()) -} - -fn resolve(reference: &Ref, handles: &BTreeMap) -> Result { - let handle = handles - .get(&reference.id) - .ok_or_else(|| format!("Missing reference: {}", reference.id))?; - if serde_json::to_value(&handle.kind).map_err(|e| e.to_string())? != reference.kind { - return Err(format!( - "Wrong-kind reference: {} ({})", - reference.id, reference.kind - )); - } - Ok(handle.clone()) -} - -fn parts(parts: &[Part], handles: &BTreeMap, markdown: bool) -> Result { - Ok(Prose( - parts - .iter() - .map(|part| { - Ok(match part { - Part::Text { text } => Inline::Text(if markdown { - crate::markdown::escape(text) - } else { - text.clone() - }), - Part::Markdown { text } => { - if !markdown { - return Err("Literal Markdown parts require a Markdown block".into()); - } - Inline::Text(text.clone()) - } - Part::Reference { target } => Inline::Reference(resolve(target, handles)?), - Part::Image { target, label } => { - let reference = resolve(target, handles)?; - if reference.kind != Kind::Resource { - return Err("Image target must be a resource".into()); - } - Inline::Image(reference, label.clone()) - } - Part::Link { target, label } => { - let reference = resolve(target, handles)?; - if !matches!(reference.kind, Kind::Resource | Kind::Block) { - return Err("Link target must be a resource or block".into()); - } - Inline::Link(reference, label.clone()) - } - Part::Code { target, language } => { - let reference = resolve(target, handles)?; - if reference.kind != Kind::Resource { - return Err("Code target must be a resource".into()); - } - Inline::Code(reference, language.clone()) - } - }) - }) - .collect::>()?, - )) -} - -impl FrozenDocument { - fn thaw(&self) -> Result { - let mut doc = Doc::new(&self.id, &self.title)?; - let mut handles = BTreeMap::new(); - for value in &self.observations { - portable_value(&value.value)?; - handles.insert( - value.id.clone(), - doc.observe(&value.id, &value.value)?.reference(), - ); - } - for check in &self.checks { - if !check.passed { - return Err(format!("Failed check: {}", check.id)); - } - let handle = match check.kind.as_str() { - "predicate" => { - if check.actual.is_some() || check.expected.is_some() { - return Err("Predicate check must not include equality payloads".into()); - } - doc.require(&check.id, true)?.reference() - } - "equality" => { - let actual = check - .actual - .as_ref() - .ok_or("Equality check requires actual")?; - let expected = check - .expected - .as_ref() - .ok_or("Equality check requires expected")?; - portable_value(actual)?; - portable_value(expected)?; - doc.expect_eq(&check.id, actual, expected)?.reference() - } - other => return Err(format!("Unknown check kind: {other}")), - }; - handles.insert(check.id.clone(), handle); - } - for binding in &self.bindings { - handles.insert( - binding.id.clone(), - doc.binding(&binding.id, binding.value.clone())?.reference(), - ); - } - for resource in &self.resources { - let artifact = if let Some(value) = &resource.json { - if !resource.bytes.is_empty() - || resource.mime != "application/json" - || resource.extension != "json" - || resource.producer != "executable-docs/json" - || resource.profile != "json-pretty-v1" - { - return Err("JSON resources require the canonical JSON producer and no competing byte payload".into()); - } - portable_value(value)?; - Artifact::json(value)? - } else { - Artifact::new( - resource.bytes.clone(), - &resource.mime, - &resource.extension, - &resource.producer, - &resource.profile, - )? - }; - let handle = match &resource.path { - Some(path) => doc.resource_at(&resource.id, artifact, path)?, - None => doc.resource(&resource.id, artifact)?, - }; - handles.insert(resource.id.clone(), handle.reference()); - } - for node in &self.nodes { - let (id, content) = match node { - Node::Heading { id, level, text } => { - let handle = doc.heading_with_id(id, *level, text)?; - handles.insert(id.clone(), handle.reference()); - continue; - } - Node::Paragraph { id, parts: prose } => { - (id, Content::Paragraph(parts(prose, &handles, false)?)) - } - Node::Markdown { id, parts: prose } => { - let prose = parts(prose, &handles, true)?; - crate::markdown::validate_markdown_parts(&prose)?; - (id, Content::MarkdownParts(prose)) - } - Node::RawMarkdown { id, text } => { - crate::markdown::markdown_links(text)?; - (id, Content::Markdown(text.clone())) - } - Node::Embed { id, target, label } => { - let reference = resolve(target, &handles)?; - if reference.kind != Kind::Resource { - return Err("Embed target must be a resource".into()); - } - (id, Content::Embed(reference, label.clone())) - } - Node::Code { - id, - target, - language, - } => { - let reference = resolve(target, &handles)?; - if reference.kind != Kind::Resource { - return Err("Code target must be a resource".into()); - } - (id, Content::Code(reference, language.clone())) - } - Node::Note { - id, - target, - parts: prose, - } => ( - id, - Content::Note { - target: target - .as_ref() - .map(|target| resolve(target, &handles)) - .transpose()?, - prose: parts(prose, &handles, false)?, - }, - ), - }; - let handle = doc - .append_with_id(Some(id), content) - .map_err(|error| format!("Block {id}: {error}"))?; - handles.insert(id.clone(), handle.reference()); - } - doc.finish() - } - - fn from_document(document: &Document) -> Result { - fn reference(reference: &Reference) -> Result { - Ok(Ref { - id: reference.id.clone(), - kind: serde_json::to_value(&reference.kind) - .map_err(|e| e.to_string())? - .as_str() - .ok_or("Invalid kind")? - .into(), - }) - } - fn prose(prose: &Prose, markdown: bool) -> Result> { - prose - .0 - .iter() - .map(|part| { - Ok(match part { - Inline::Text(text) if markdown => Part::Markdown { text: text.clone() }, - Inline::Text(text) => Part::Text { text: text.clone() }, - Inline::Reference(target) => Part::Reference { - target: reference(target)?, - }, - Inline::Image(target, label) => Part::Image { - target: reference(target)?, - label: label.clone(), - }, - Inline::Link(target, label) => Part::Link { - target: reference(target)?, - label: label.clone(), - }, - Inline::Code(target, language) => Part::Code { - target: reference(target)?, - language: language.clone(), - }, - }) - }) - .collect() - } - let nodes = document - .nodes - .iter() - .map(|node| { - let id = node.id.clone(); - Ok(match &node.content { - Content::Heading(level, text) => Node::Heading { - id, - level: *level, - text: text.clone(), - }, - Content::Paragraph(parts) => Node::Paragraph { - id, - parts: prose(parts, false)?, - }, - Content::Markdown(text) => Node::RawMarkdown { - id, - text: text.clone(), - }, - Content::MarkdownParts(parts) => Node::Markdown { - id, - parts: prose(parts, true)?, - }, - Content::Embed(target, label) => Node::Embed { - id, - target: reference(target)?, - label: label.clone(), - }, - Content::Code(target, language) => Node::Code { - id, - target: reference(target)?, - language: language.clone(), - }, - Content::Note { - target, - prose: parts, - } => Node::Note { - id, - target: target.as_ref().map(reference).transpose()?, - parts: prose(parts, false)?, - }, - }) - }) - .collect::>()?; - let result = Self { - id: document.id.clone(), - title: document.title.clone(), - nodes, - observations: document - .observations - .iter() - .map(|(id, value)| Observation { - id: id.clone(), - value: value.clone(), - }) - .collect(), - checks: document - .checks - .values() - .map(|check| Check { - id: check.id.clone(), - kind: check.kind.clone(), - passed: true, - actual: (check.kind == "equality").then(|| check.actual.clone()), - expected: (check.kind == "equality").then(|| check.expected.clone()), - }) - .collect(), - resources: document - .artifacts - .iter() - .map(|(id, artifact)| Resource { - id: id.clone(), - bytes: artifact.bytes.clone(), - mime: artifact.mime.clone(), - extension: artifact.extension.clone(), - producer: artifact.producer.clone(), - profile: artifact.profile.clone(), - path: document.resource_paths.get(id).cloned(), - json: None, - }) - .collect(), - bindings: document - .bindings - .iter() - .map(|(id, value)| Binding { - id: id.clone(), - value: value.clone(), - }) - .collect(), - }; - result.thaw()?; - Ok(result) - } -} diff --git a/crates/lgtmd/src/validation.rs b/crates/lgtmd/src/validation.rs deleted file mode 100644 index c222479..0000000 --- a/crates/lgtmd/src/validation.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Intrinsic document visibility, independent of output formatting. -use crate::{ - Audience, Result, - model::{Document, Kind, Node}, -}; -use std::collections::BTreeSet; - -pub(crate) fn visible_nodes( - document: &Document, - audience: Audience, -) -> impl Iterator { - document - .nodes - .iter() - .filter(move |node| audience == Audience::Contributor || !node.is_note()) -} - -pub(crate) fn validate_document(document: &Document) -> Result<()> { - for audience in [Audience::Reader, Audience::Contributor] { - let ids: BTreeSet<_> = visible_nodes(document, audience) - .map(|node| node.id.as_str()) - .collect(); - for node in visible_nodes(document, audience) { - for reference in node.references() { - if reference.kind == Kind::Block && !ids.contains(reference.id.as_str()) { - return Err(format!( - "Visible block {} references excluded block {}", - node.id, reference.id - )); - } - } - } - } - Ok(()) -} diff --git a/crates/lgtmd/tests/acceptance.rs b/crates/lgtmd/tests/acceptance.rs deleted file mode 100644 index c0cec95..0000000 --- a/crates/lgtmd/tests/acceptance.rs +++ /dev/null @@ -1,186 +0,0 @@ -use lgtmd::{Artifact, Audience, Doc, Document}; -use std::collections::BTreeMap; -use std::sync::atomic::{AtomicUsize, Ordering}; - -fn resource(text: &str) -> Artifact { - Artifact::new( - text.as_bytes(), - "text/plain", - "txt", - "acceptance", - "utf8-v1", - ) - .unwrap() -} - -fn all_text(files: &BTreeMap>) -> String { - files - .iter() - .map(|(path, bytes)| format!("{path}\n{}", String::from_utf8_lossy(bytes))) - .collect() -} - -fn authored_document(notes: bool) -> Document { - let mut doc = Doc::new("configuration", "Configuration").unwrap(); - let port = doc.expect_eq("port-preserved", 8080, 8080).unwrap(); - doc.heading(2, "Choose a port").unwrap(); - if notes { - doc.note("INTERNAL NOTE: explain the fixed fixture.") - .unwrap(); - } - let paragraph = doc.paragraph(("The port is ", &port, ".")).unwrap(); - let output = doc.resource("settings", resource("port=8080")).unwrap(); - doc.code(&output, "text").unwrap(); - doc.paragraph(("Download ", &output, " to reuse it.")) - .unwrap(); - if notes { - let private = doc - .resource( - "private-observation", - Artifact::new( - b"PRIVATE RESOURCE BODY", - "application/x-private-review", - "bin", - "private-review-producer", - "private-review-profile", - ) - .unwrap(), - ) - .unwrap(); - doc.note_on(¶graph, ("Check this paragraph against ", &private, ".")) - .unwrap(); - doc.note_on(&output, "INTERNAL CAPTURE NOTE").unwrap(); - } - doc.finish().unwrap() -} - -#[test] -fn notes_do_not_change_any_reader_byte_or_resource_identity() { - let without = Document::render_many(&[authored_document(false)], Audience::Reader).unwrap(); - let with = Document::render_many(&[authored_document(true)], Audience::Reader).unwrap(); - assert_eq!( - without, with, - "Audience filtering must precede stable public identities" - ); - let reader = all_text(&with); - for private in [ - "INTERNAL", - "PRIVATE RESOURCE", - "private-observation", - "private-review", - "x-private-review", - ] { - assert!(!reader.contains(private), "Reader leaked {private}"); - } - assert_eq!(with.keys().filter(|path| path.ends_with(".txt")).count(), 1); - - let contributor = - Document::render_many(&[authored_document(true)], Audience::Contributor).unwrap(); - let contributor_text = all_text(&contributor); - assert!(contributor_text.contains("INTERNAL CAPTURE NOTE")); - assert!(contributor_text.contains("PRIVATE RESOURCE BODY")); -} - -#[test] -fn private_value_payload_is_not_exposed_through_reader_manifest() { - let mut doc = Doc::new("private-value", "Notes").unwrap(); - doc.require("public-condition", true).unwrap(); - doc.paragraph("The configuration is valid.").unwrap(); - let private = doc - .expect_eq("diagnostic", "INTERNAL-TOKEN", "INTERNAL-TOKEN") - .unwrap(); - doc.note(("Internal observation: ", &private)).unwrap(); - let document = doc.finish().unwrap(); - let reader = Document::render_many(std::slice::from_ref(&document), Audience::Reader).unwrap(); - assert!(!all_text(&reader).contains("INTERNAL-TOKEN")); - let contributor = Document::render_many(&[document], Audience::Contributor).unwrap(); - assert!(all_text(&contributor).contains("INTERNAL-TOKEN")); -} - -#[test] -fn annotating_an_existing_check_does_not_change_reader_manifest() { - let build = |with_note| { - let mut doc = Doc::new("checks", "Checks").unwrap(); - let claim = doc.require("preserves-output", true).unwrap(); - doc.paragraph("Invalid input preserves the original output.") - .unwrap(); - if with_note { - doc.note_on( - &claim, - "Compare complete bytes rather than only the parsed port.", - ) - .unwrap(); - } - Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap() - }; - assert_eq!(build(false), build(true)); -} - -#[test] -fn rendering_freezes_resources_and_never_reexecutes_application() { - let executions = AtomicUsize::new(0); - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("observation.txt"); - std::fs::write(&path, "first observation").unwrap(); - let document = { - executions.fetch_add(1, Ordering::SeqCst); - let mut doc = Doc::new("frozen", "Frozen evidence").unwrap(); - let mut value = String::from("first observation"); - let checked = doc.expect_eq("observed", &value, &value).unwrap(); - doc.paragraph(("Observed: ", &checked)).unwrap(); - let artifact = - Artifact::from_file(&path, "text/plain", "txt", "fixture", "utf8-v1").unwrap(); - let frozen = doc.resource("file", artifact).unwrap(); - doc.code(&frozen, "text").unwrap(); - value.clear(); - std::fs::write(&path, "changed after capture").unwrap(); - doc.finish().unwrap() - }; - for audience in [Audience::Reader, Audience::Contributor, Audience::Reader] { - let files = Document::render_many(std::slice::from_ref(&document), audience).unwrap(); - assert!(all_text(&files).contains("first observation")); - assert!(!all_text(&files).contains("changed after capture")); - } - assert_eq!(executions.load(Ordering::SeqCst), 1); -} - -#[test] -fn a_foreign_note_target_cannot_be_smuggled_into_reader_only_rendering() { - let mut first = Doc::new("first", "First").unwrap(); - let claim = first.require("valid", true).unwrap(); - first.paragraph("First document.").unwrap(); - first.finish().unwrap(); - - let mut second = Doc::new("second", "Second").unwrap(); - second.require("valid", true).unwrap(); - second.paragraph("Second document.").unwrap(); - assert!(second.note_on(&claim, "Foreign target").is_err()); - assert!( - second.finish().is_err(), - "Ignoring a note error cannot bless the candidate" - ); -} - -#[test] -fn duplicate_document_identity_fails_the_complete_bundle() { - let document = authored_document(false); - let result = Document::render_many(&[document.clone(), document], Audience::Reader); - assert!(result.is_err()); -} - -#[test] -fn code_fences_preserve_backticks_without_manufacturing_links() { - let mut doc = Doc::new("fences", "Literal output").unwrap(); - doc.require("command-produced-text", true).unwrap(); - let text = "```\n[not a link](missing.txt)\n```\n"; - let artifact = doc.resource("transcript", resource(text)).unwrap(); - doc.code(&artifact, "text").unwrap(); - let files = Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap(); - let markdown = String::from_utf8_lossy(&files["fences.md"]); - let parser = pulldown_cmark::Parser::new(&markdown); - assert!(!parser.into_iter().any(|event| matches!( - event, - pulldown_cmark::Event::Start(pulldown_cmark::Tag::Link { .. }) - ))); - assert!(markdown.contains(text)); -} diff --git a/crates/lgtmd/tests/code_views.rs b/crates/lgtmd/tests/code_views.rs deleted file mode 100644 index 2c598e9..0000000 --- a/crates/lgtmd/tests/code_views.rs +++ /dev/null @@ -1,108 +0,0 @@ -use lgtmd::{Artifact, Audience, Doc, Document}; -use pulldown_cmark::{Event, Parser, Tag}; -use serde_json::Value; - -#[test] -fn a_typed_code_view_shares_one_frozen_resource_with_its_download() { - let mut doc = Doc::new("configuration", "Configuration").unwrap(); - let text = "```\n[not a link](missing.md)\n`````\n"; - let output = doc.text("output", text).unwrap(); - let passage = doc - .markdown_parts(( - "The command produced:\n\n", - output.code("text"), - "\n\nDownload ", - output.link("the output"), - ".\n", - )) - .unwrap(); - doc.note_on(&passage, "The producer runs once.").unwrap(); - let files = Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap(); - assert_eq!(files["assets/configuration/output.txt"], text.as_bytes()); - assert_eq!(files.len(), 3); - let page = String::from_utf8_lossy(&files["configuration.md"]); - let mut code_blocks = 0; - let mut code_text = String::new(); - let mut in_code = false; - for event in Parser::new(&page) { - match event { - Event::Start(Tag::CodeBlock(_)) => { - code_blocks += 1; - in_code = true; - } - Event::End(pulldown_cmark::TagEnd::CodeBlock) => in_code = false, - Event::Text(text) if in_code => code_text.push_str(&text), - _ => {} - } - } - assert_eq!(code_blocks, 1); - assert_eq!(code_text, text); - let manifest: Value = serde_json::from_slice(&files["manifest.json"]).unwrap(); - assert_eq!( - manifest["documents"][0]["blocks"][0]["references"], - serde_json::json!([ - {"id": "output", "kind": "resource"}, - {"id": "output", "kind": "resource"} - ]) - ); -} - -#[test] -fn isolated_code_views_end_before_the_following_document_block() { - let mut doc = Doc::new("output", "Output").unwrap(); - let output = doc.text("result", "result").unwrap(); - doc.markdown_parts(output.code("text")).unwrap(); - doc.paragraph("The next explanation remains prose.") - .unwrap(); - let files = Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap(); - let page = String::from_utf8_lossy(&files["output.md"]); - assert!(page.contains("```text\nresult\n```\n\n")); -} - -#[test] -fn invalid_code_view_placement_or_kind_taints_completion() { - for (before, after) in [ - ("A value ", " follows."), - (" ", ""), - ("```text\n\n", "\n\n```"), - ("", "\nThe following paragraph"), - ] { - let mut doc = Doc::new("placement", "Placement").unwrap(); - let text = doc.text("result", "frozen output").unwrap(); - assert!( - doc.markdown_parts((before, text.code("text"), after)) - .is_err(), - "Accepted code view between {before:?} and {after:?}" - ); - assert!(doc.finish().is_err()); - } - let mut paragraph = Doc::new("paragraph", "Paragraph").unwrap(); - let text = paragraph.text("result", "frozen output").unwrap(); - assert!(paragraph.paragraph(text.code("text")).is_err()); - assert!(paragraph.finish().is_err()); - - let mut image = Doc::new("image", "Image").unwrap(); - let image_resource = image - .resource( - "result", - Artifact::new(b"image", "image/png", "png", "test", "v1").unwrap(), - ) - .unwrap(); - assert!(image.markdown_parts(image_resource.code("text")).is_err()); - assert!(image.finish().is_err()); - - let mut bytes = Doc::new("bytes", "Bytes").unwrap(); - let text = bytes - .resource( - "result", - Artifact::new([0xff], "text/plain", "txt", "test", "v1").unwrap(), - ) - .unwrap(); - assert!(bytes.markdown_parts(text.code("text")).is_err()); - assert!(bytes.finish().is_err()); - - let mut language = Doc::new("language", "Language").unwrap(); - let text = language.text("result", "output").unwrap(); - assert!(language.markdown_parts(text.code("text\n```\n")).is_err()); - assert!(language.finish().is_err()); -} diff --git a/crates/lgtmd/tests/fixtures/portable.json b/crates/lgtmd/tests/fixtures/portable.json deleted file mode 100644 index 38d0fe3..0000000 --- a/crates/lgtmd/tests/fixtures/portable.json +++ /dev/null @@ -1,529 +0,0 @@ -{ - "exports": { - "contributor": { - "assets/portable/data.json": [ - 123, 10, 32, 32, 34, 97, 34, 58, 32, 49, 44, 10, 32, 32, 34, 122, 34, - 58, 32, 50, 10, 125, 10 - ], - "assets/portable/image.png": [1, 2, 3], - "manifest.json": [ - 123, 10, 32, 32, 34, 97, 114, 116, 105, 102, 97, 99, 116, 115, 34, 58, - 32, 91, 10, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 34, 98, - 121, 116, 101, 115, 34, 58, 32, 50, 51, 44, 10, 32, 32, 32, 32, 32, 32, - 34, 100, 111, 99, 117, 109, 101, 110, 116, 34, 58, 32, 34, 112, 111, - 114, 116, 97, 98, 108, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 105, - 100, 34, 58, 32, 34, 100, 97, 116, 97, 34, 44, 10, 32, 32, 32, 32, 32, - 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 114, 101, 115, 111, 117, - 114, 99, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 109, 105, 109, - 101, 34, 58, 32, 34, 97, 112, 112, 108, 105, 99, 97, 116, 105, 111, 110, - 47, 106, 115, 111, 110, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 112, 97, - 116, 104, 34, 58, 32, 34, 97, 115, 115, 101, 116, 115, 47, 112, 111, - 114, 116, 97, 98, 108, 101, 47, 100, 97, 116, 97, 46, 106, 115, 111, - 110, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 112, 114, 111, 100, 117, - 99, 101, 114, 34, 58, 32, 34, 101, 120, 101, 99, 117, 116, 97, 98, 108, - 101, 45, 100, 111, 99, 115, 47, 106, 115, 111, 110, 34, 44, 10, 32, 32, - 32, 32, 32, 32, 34, 112, 114, 111, 102, 105, 108, 101, 34, 58, 32, 34, - 106, 115, 111, 110, 45, 112, 114, 101, 116, 116, 121, 45, 118, 49, 34, - 44, 10, 32, 32, 32, 32, 32, 32, 34, 115, 104, 97, 50, 53, 54, 34, 58, - 32, 34, 56, 51, 102, 97, 52, 102, 51, 98, 55, 97, 52, 50, 101, 100, 49, - 54, 57, 98, 55, 55, 100, 49, 54, 52, 101, 50, 54, 102, 51, 56, 53, 51, - 48, 54, 50, 50, 52, 97, 97, 52, 97, 54, 56, 52, 49, 51, 51, 97, 54, 98, - 52, 48, 102, 49, 56, 97, 56, 48, 56, 53, 102, 97, 55, 53, 34, 10, 32, - 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, - 32, 34, 98, 121, 116, 101, 115, 34, 58, 32, 51, 44, 10, 32, 32, 32, 32, - 32, 32, 34, 100, 111, 99, 117, 109, 101, 110, 116, 34, 58, 32, 34, 112, - 111, 114, 116, 97, 98, 108, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, - 105, 100, 34, 58, 32, 34, 105, 109, 97, 103, 101, 34, 44, 10, 32, 32, - 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 114, 101, 115, - 111, 117, 114, 99, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 109, - 105, 109, 101, 34, 58, 32, 34, 105, 109, 97, 103, 101, 47, 112, 110, - 103, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 112, 97, 116, 104, 34, 58, - 32, 34, 97, 115, 115, 101, 116, 115, 47, 112, 111, 114, 116, 97, 98, - 108, 101, 47, 105, 109, 97, 103, 101, 46, 112, 110, 103, 34, 44, 10, 32, - 32, 32, 32, 32, 32, 34, 112, 114, 111, 100, 117, 99, 101, 114, 34, 58, - 32, 34, 102, 105, 120, 116, 117, 114, 101, 34, 44, 10, 32, 32, 32, 32, - 32, 32, 34, 112, 114, 111, 102, 105, 108, 101, 34, 58, 32, 34, 101, 120, - 97, 99, 116, 45, 118, 49, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 115, - 104, 97, 50, 53, 54, 34, 58, 32, 34, 48, 51, 57, 48, 53, 56, 99, 54, - 102, 50, 99, 48, 99, 98, 52, 57, 50, 99, 53, 51, 51, 98, 48, 97, 52, - 100, 49, 52, 101, 102, 55, 55, 99, 99, 48, 102, 55, 56, 97, 98, 99, 99, - 99, 101, 100, 53, 50, 56, 55, 100, 56, 52, 97, 49, 97, 50, 48, 49, 49, - 99, 102, 98, 56, 49, 34, 10, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, - 32, 123, 10, 32, 32, 32, 32, 32, 32, 34, 98, 121, 116, 101, 115, 34, 58, - 32, 51, 57, 55, 44, 10, 32, 32, 32, 32, 32, 32, 34, 100, 111, 99, 117, - 109, 101, 110, 116, 34, 58, 32, 34, 112, 111, 114, 116, 97, 98, 108, - 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, - 32, 34, 112, 97, 103, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 109, - 105, 109, 101, 34, 58, 32, 34, 116, 101, 120, 116, 47, 109, 97, 114, - 107, 100, 111, 119, 110, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 112, - 97, 116, 104, 34, 58, 32, 34, 112, 111, 114, 116, 97, 98, 108, 101, 46, - 109, 100, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 115, 104, 97, 50, 53, - 54, 34, 58, 32, 34, 56, 55, 52, 54, 101, 101, 54, 99, 99, 55, 57, 57, - 48, 57, 99, 57, 98, 98, 99, 99, 51, 101, 56, 52, 48, 52, 100, 57, 98, - 102, 99, 54, 98, 97, 97, 49, 97, 99, 100, 57, 101, 51, 54, 52, 54, 102, - 48, 48, 55, 99, 98, 50, 99, 98, 52, 55, 100, 51, 50, 101, 102, 55, 56, - 57, 34, 10, 32, 32, 32, 32, 125, 10, 32, 32, 93, 44, 10, 32, 32, 34, 97, - 117, 100, 105, 101, 110, 99, 101, 34, 58, 32, 34, 99, 111, 110, 116, - 114, 105, 98, 117, 116, 111, 114, 34, 44, 10, 32, 32, 34, 100, 111, 99, - 117, 109, 101, 110, 116, 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, 123, - 10, 32, 32, 32, 32, 32, 32, 34, 98, 108, 111, 99, 107, 115, 34, 58, 32, - 91, 10, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, - 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 111, 118, 101, 114, 118, - 105, 101, 119, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, - 107, 105, 110, 100, 34, 58, 32, 34, 104, 101, 97, 100, 105, 110, 103, - 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 114, 101, 102, - 101, 114, 101, 110, 99, 101, 115, 34, 58, 32, 91, 93, 10, 32, 32, 32, - 32, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 123, - 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, - 34, 98, 108, 111, 99, 107, 45, 48, 48, 48, 50, 34, 44, 10, 32, 32, 32, - 32, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 109, - 97, 114, 107, 100, 111, 119, 110, 45, 112, 97, 114, 116, 115, 34, 44, - 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 114, 101, 102, 101, 114, - 101, 110, 99, 101, 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 99, 111, 117, 110, 116, 34, - 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, - 105, 110, 100, 34, 58, 32, 34, 111, 98, 115, 101, 114, 118, 97, 116, - 105, 111, 110, 34, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, - 125, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 93, 10, 32, 32, 32, 32, - 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, - 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, - 110, 111, 116, 101, 45, 48, 48, 48, 49, 34, 44, 10, 32, 32, 32, 32, 32, - 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 110, 111, - 116, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 114, - 101, 102, 101, 114, 101, 110, 99, 101, 115, 34, 58, 32, 91, 10, 32, 32, - 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, - 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 105, 110, - 116, 101, 114, 110, 97, 108, 45, 111, 110, 108, 121, 34, 44, 10, 32, 32, - 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, - 34, 58, 32, 34, 111, 98, 115, 101, 114, 118, 97, 116, 105, 111, 110, 34, - 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 125, 44, 10, 32, 32, - 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, - 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 98, 108, - 111, 99, 107, 45, 48, 48, 48, 50, 34, 44, 10, 32, 32, 32, 32, 32, 32, - 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, - 98, 108, 111, 99, 107, 34, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 125, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 93, 10, 32, 32, - 32, 32, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, - 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, - 32, 34, 98, 108, 111, 99, 107, 45, 48, 48, 48, 51, 34, 44, 10, 32, 32, - 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, - 109, 97, 114, 107, 100, 111, 119, 110, 45, 112, 97, 114, 116, 115, 34, - 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 114, 101, 102, 101, - 114, 101, 110, 99, 101, 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, 32, 32, - 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 100, 97, 116, 97, 34, 44, - 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, - 105, 110, 100, 34, 58, 32, 34, 114, 101, 115, 111, 117, 114, 99, 101, - 34, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 125, 44, 10, 32, - 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, - 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 100, - 97, 116, 97, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 114, 101, 115, 111, 117, - 114, 99, 101, 34, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, - 125, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 93, 10, 32, 32, 32, 32, - 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, - 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, - 98, 108, 111, 99, 107, 45, 48, 48, 48, 52, 34, 44, 10, 32, 32, 32, 32, - 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 109, 97, - 114, 107, 100, 111, 119, 110, 45, 112, 97, 114, 116, 115, 34, 44, 10, - 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 114, 101, 102, 101, 114, - 101, 110, 99, 101, 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 105, 109, 97, 103, 101, 34, - 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, - 105, 110, 100, 34, 58, 32, 34, 114, 101, 115, 111, 117, 114, 99, 101, - 34, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 125, 10, 32, 32, - 32, 32, 32, 32, 32, 32, 32, 32, 93, 10, 32, 32, 32, 32, 32, 32, 32, 32, - 125, 10, 32, 32, 32, 32, 32, 32, 93, 44, 10, 32, 32, 32, 32, 32, 32, 34, - 99, 104, 101, 99, 107, 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, 32, 32, - 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 97, 99, - 116, 117, 97, 108, 34, 58, 32, 116, 114, 117, 101, 44, 10, 32, 32, 32, - 32, 32, 32, 32, 32, 32, 32, 34, 101, 120, 112, 101, 99, 116, 101, 100, - 34, 58, 32, 116, 114, 117, 101, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 34, 105, 100, 34, 58, 32, 34, 118, 101, 114, 105, 102, 105, 101, - 100, 45, 99, 111, 117, 110, 116, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 112, 114, 101, 100, - 105, 99, 97, 116, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, - 32, 34, 112, 97, 115, 115, 101, 100, 34, 58, 32, 116, 114, 117, 101, 10, - 32, 32, 32, 32, 32, 32, 32, 32, 125, 10, 32, 32, 32, 32, 32, 32, 93, 44, - 10, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 112, 111, 114, - 116, 97, 98, 108, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 111, 98, - 115, 101, 114, 118, 97, 116, 105, 111, 110, 115, 34, 58, 32, 91, 10, 32, - 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, - 32, 34, 105, 100, 34, 58, 32, 34, 99, 111, 117, 110, 116, 34, 44, 10, - 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 118, 97, 108, 117, 101, 34, - 58, 32, 51, 10, 32, 32, 32, 32, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, - 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, - 105, 100, 34, 58, 32, 34, 105, 110, 116, 101, 114, 110, 97, 108, 45, - 111, 110, 108, 121, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, - 34, 118, 97, 108, 117, 101, 34, 58, 32, 34, 112, 114, 105, 118, 97, 116, - 101, 32, 111, 98, 115, 101, 114, 118, 97, 116, 105, 111, 110, 34, 10, - 32, 32, 32, 32, 32, 32, 32, 32, 125, 10, 32, 32, 32, 32, 32, 32, 93, 44, - 10, 32, 32, 32, 32, 32, 32, 34, 116, 105, 116, 108, 101, 34, 58, 32, 34, - 80, 111, 114, 116, 97, 98, 108, 101, 32, 100, 111, 99, 117, 109, 101, - 110, 116, 34, 10, 32, 32, 32, 32, 125, 10, 32, 32, 93, 44, 10, 32, 32, - 34, 112, 114, 111, 102, 105, 108, 101, 115, 34, 58, 32, 91, 10, 32, 32, - 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 34, 112, 114, 111, 100, 117, - 99, 101, 114, 34, 58, 32, 34, 101, 120, 101, 99, 117, 116, 97, 98, 108, - 101, 45, 100, 111, 99, 115, 47, 106, 115, 111, 110, 34, 44, 10, 32, 32, - 32, 32, 32, 32, 34, 112, 114, 111, 102, 105, 108, 101, 34, 58, 32, 34, - 106, 115, 111, 110, 45, 112, 114, 101, 116, 116, 121, 45, 118, 49, 34, - 10, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 123, 10, 32, 32, 32, - 32, 32, 32, 34, 112, 114, 111, 100, 117, 99, 101, 114, 34, 58, 32, 34, - 102, 105, 120, 116, 117, 114, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, - 34, 112, 114, 111, 102, 105, 108, 101, 34, 58, 32, 34, 101, 120, 97, 99, - 116, 45, 118, 49, 34, 10, 32, 32, 32, 32, 125, 10, 32, 32, 93, 44, 10, - 32, 32, 34, 115, 99, 104, 101, 109, 97, 95, 118, 101, 114, 115, 105, - 111, 110, 34, 58, 32, 49, 10, 125, 10 - ], - "portable.md": [ - 35, 32, 80, 111, 114, 116, 97, 98, 108, 101, 32, 100, 111, 99, 117, 109, - 101, 110, 116, 10, 10, 60, 97, 32, 105, 100, 61, 34, 111, 118, 101, 114, - 118, 105, 101, 119, 34, 62, 60, 47, 97, 62, 10, 10, 35, 35, 32, 79, 118, - 101, 114, 118, 105, 101, 119, 10, 10, 60, 97, 32, 105, 100, 61, 34, 98, - 108, 111, 99, 107, 45, 48, 48, 48, 50, 34, 62, 60, 47, 97, 62, 10, 10, - 67, 111, 110, 116, 97, 105, 110, 115, 32, 51, 32, 114, 101, 99, 111, - 114, 100, 115, 46, 10, 10, 60, 97, 32, 105, 100, 61, 34, 110, 111, 116, - 101, 45, 48, 48, 48, 49, 34, 62, 60, 47, 97, 62, 10, 10, 62, 32, 42, 42, - 67, 111, 110, 116, 114, 105, 98, 117, 116, 111, 114, 32, 110, 111, 116, - 101, 32, 111, 110, 32, 91, 98, 108, 111, 99, 107, 92, 45, 48, 48, 48, - 50, 93, 40, 35, 98, 108, 111, 99, 107, 45, 48, 48, 48, 50, 41, 58, 42, - 42, 32, 73, 110, 116, 101, 114, 110, 97, 108, 58, 32, 112, 114, 105, - 118, 97, 116, 101, 32, 111, 98, 115, 101, 114, 118, 97, 116, 105, 111, - 110, 10, 10, 60, 97, 32, 105, 100, 61, 34, 98, 108, 111, 99, 107, 45, - 48, 48, 48, 51, 34, 62, 60, 47, 97, 62, 10, 10, 68, 97, 116, 97, 58, 10, - 10, 96, 96, 96, 106, 115, 111, 110, 10, 123, 10, 32, 32, 34, 97, 34, 58, - 32, 49, 44, 10, 32, 32, 34, 122, 34, 58, 32, 50, 10, 125, 10, 96, 96, - 96, 10, 10, 10, 10, 91, 68, 111, 119, 110, 108, 111, 97, 100, 93, 40, - 97, 115, 115, 101, 116, 115, 47, 112, 111, 114, 116, 97, 98, 108, 101, - 47, 100, 97, 116, 97, 46, 106, 115, 111, 110, 41, 10, 10, 60, 97, 32, - 105, 100, 61, 34, 98, 108, 111, 99, 107, 45, 48, 48, 48, 52, 34, 62, 60, - 47, 97, 62, 10, 10, 33, 91, 70, 105, 120, 101, 100, 32, 102, 105, 120, - 116, 117, 114, 101, 32, 98, 121, 116, 101, 115, 93, 40, 97, 115, 115, - 101, 116, 115, 47, 112, 111, 114, 116, 97, 98, 108, 101, 47, 105, 109, - 97, 103, 101, 46, 112, 110, 103, 41, 10, 10 - ] - }, - "reader": { - "assets/portable/data.json": [ - 123, 10, 32, 32, 34, 97, 34, 58, 32, 49, 44, 10, 32, 32, 34, 122, 34, - 58, 32, 50, 10, 125, 10 - ], - "assets/portable/image.png": [1, 2, 3], - "manifest.json": [ - 123, 10, 32, 32, 34, 97, 114, 116, 105, 102, 97, 99, 116, 115, 34, 58, - 32, 91, 10, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 34, 98, - 121, 116, 101, 115, 34, 58, 32, 50, 51, 44, 10, 32, 32, 32, 32, 32, 32, - 34, 100, 111, 99, 117, 109, 101, 110, 116, 34, 58, 32, 34, 112, 111, - 114, 116, 97, 98, 108, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 105, - 100, 34, 58, 32, 34, 100, 97, 116, 97, 34, 44, 10, 32, 32, 32, 32, 32, - 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 114, 101, 115, 111, 117, - 114, 99, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 109, 105, 109, - 101, 34, 58, 32, 34, 97, 112, 112, 108, 105, 99, 97, 116, 105, 111, 110, - 47, 106, 115, 111, 110, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 112, 97, - 116, 104, 34, 58, 32, 34, 97, 115, 115, 101, 116, 115, 47, 112, 111, - 114, 116, 97, 98, 108, 101, 47, 100, 97, 116, 97, 46, 106, 115, 111, - 110, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 112, 114, 111, 100, 117, - 99, 101, 114, 34, 58, 32, 34, 101, 120, 101, 99, 117, 116, 97, 98, 108, - 101, 45, 100, 111, 99, 115, 47, 106, 115, 111, 110, 34, 44, 10, 32, 32, - 32, 32, 32, 32, 34, 112, 114, 111, 102, 105, 108, 101, 34, 58, 32, 34, - 106, 115, 111, 110, 45, 112, 114, 101, 116, 116, 121, 45, 118, 49, 34, - 44, 10, 32, 32, 32, 32, 32, 32, 34, 115, 104, 97, 50, 53, 54, 34, 58, - 32, 34, 56, 51, 102, 97, 52, 102, 51, 98, 55, 97, 52, 50, 101, 100, 49, - 54, 57, 98, 55, 55, 100, 49, 54, 52, 101, 50, 54, 102, 51, 56, 53, 51, - 48, 54, 50, 50, 52, 97, 97, 52, 97, 54, 56, 52, 49, 51, 51, 97, 54, 98, - 52, 48, 102, 49, 56, 97, 56, 48, 56, 53, 102, 97, 55, 53, 34, 10, 32, - 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, - 32, 34, 98, 121, 116, 101, 115, 34, 58, 32, 51, 44, 10, 32, 32, 32, 32, - 32, 32, 34, 100, 111, 99, 117, 109, 101, 110, 116, 34, 58, 32, 34, 112, - 111, 114, 116, 97, 98, 108, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, - 105, 100, 34, 58, 32, 34, 105, 109, 97, 103, 101, 34, 44, 10, 32, 32, - 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 114, 101, 115, - 111, 117, 114, 99, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 109, - 105, 109, 101, 34, 58, 32, 34, 105, 109, 97, 103, 101, 47, 112, 110, - 103, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 112, 97, 116, 104, 34, 58, - 32, 34, 97, 115, 115, 101, 116, 115, 47, 112, 111, 114, 116, 97, 98, - 108, 101, 47, 105, 109, 97, 103, 101, 46, 112, 110, 103, 34, 44, 10, 32, - 32, 32, 32, 32, 32, 34, 112, 114, 111, 100, 117, 99, 101, 114, 34, 58, - 32, 34, 102, 105, 120, 116, 117, 114, 101, 34, 44, 10, 32, 32, 32, 32, - 32, 32, 34, 112, 114, 111, 102, 105, 108, 101, 34, 58, 32, 34, 101, 120, - 97, 99, 116, 45, 118, 49, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 115, - 104, 97, 50, 53, 54, 34, 58, 32, 34, 48, 51, 57, 48, 53, 56, 99, 54, - 102, 50, 99, 48, 99, 98, 52, 57, 50, 99, 53, 51, 51, 98, 48, 97, 52, - 100, 49, 52, 101, 102, 55, 55, 99, 99, 48, 102, 55, 56, 97, 98, 99, 99, - 99, 101, 100, 53, 50, 56, 55, 100, 56, 52, 97, 49, 97, 50, 48, 49, 49, - 99, 102, 98, 56, 49, 34, 10, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, - 32, 123, 10, 32, 32, 32, 32, 32, 32, 34, 98, 121, 116, 101, 115, 34, 58, - 32, 50, 56, 56, 44, 10, 32, 32, 32, 32, 32, 32, 34, 100, 111, 99, 117, - 109, 101, 110, 116, 34, 58, 32, 34, 112, 111, 114, 116, 97, 98, 108, - 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, - 32, 34, 112, 97, 103, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 109, - 105, 109, 101, 34, 58, 32, 34, 116, 101, 120, 116, 47, 109, 97, 114, - 107, 100, 111, 119, 110, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 112, - 97, 116, 104, 34, 58, 32, 34, 112, 111, 114, 116, 97, 98, 108, 101, 46, - 109, 100, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 115, 104, 97, 50, 53, - 54, 34, 58, 32, 34, 100, 51, 98, 54, 102, 56, 57, 98, 100, 98, 57, 48, - 101, 101, 56, 54, 99, 99, 54, 102, 54, 49, 51, 48, 99, 102, 52, 49, 54, - 99, 57, 54, 101, 97, 51, 53, 50, 50, 99, 99, 99, 56, 51, 48, 101, 52, - 102, 101, 57, 51, 53, 51, 49, 52, 100, 97, 49, 50, 48, 99, 50, 99, 57, - 49, 34, 10, 32, 32, 32, 32, 125, 10, 32, 32, 93, 44, 10, 32, 32, 34, 97, - 117, 100, 105, 101, 110, 99, 101, 34, 58, 32, 34, 114, 101, 97, 100, - 101, 114, 34, 44, 10, 32, 32, 34, 100, 111, 99, 117, 109, 101, 110, 116, - 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, - 32, 34, 98, 108, 111, 99, 107, 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, - 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, - 105, 100, 34, 58, 32, 34, 111, 118, 101, 114, 118, 105, 101, 119, 34, - 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, - 34, 58, 32, 34, 104, 101, 97, 100, 105, 110, 103, 34, 44, 10, 32, 32, - 32, 32, 32, 32, 32, 32, 32, 32, 34, 114, 101, 102, 101, 114, 101, 110, - 99, 101, 115, 34, 58, 32, 91, 93, 10, 32, 32, 32, 32, 32, 32, 32, 32, - 125, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, - 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 98, 108, 111, 99, - 107, 45, 48, 48, 48, 50, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, - 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 109, 97, 114, 107, 100, 111, - 119, 110, 45, 112, 97, 114, 116, 115, 34, 44, 10, 32, 32, 32, 32, 32, - 32, 32, 32, 32, 32, 34, 114, 101, 102, 101, 114, 101, 110, 99, 101, 115, - 34, 58, 32, 91, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 123, - 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, - 100, 34, 58, 32, 34, 99, 111, 117, 110, 116, 34, 44, 10, 32, 32, 32, 32, - 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, - 32, 34, 111, 98, 115, 101, 114, 118, 97, 116, 105, 111, 110, 34, 10, 32, - 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 125, 10, 32, 32, 32, 32, 32, - 32, 32, 32, 32, 32, 93, 10, 32, 32, 32, 32, 32, 32, 32, 32, 125, 44, 10, - 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 34, 105, 100, 34, 58, 32, 34, 98, 108, 111, 99, 107, 45, 48, 48, - 48, 51, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, - 105, 110, 100, 34, 58, 32, 34, 109, 97, 114, 107, 100, 111, 119, 110, - 45, 112, 97, 114, 116, 115, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 34, 114, 101, 102, 101, 114, 101, 110, 99, 101, 115, 34, 58, 32, - 91, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, - 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, - 32, 34, 100, 97, 116, 97, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 114, - 101, 115, 111, 117, 114, 99, 101, 34, 10, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, - 34, 105, 100, 34, 58, 32, 34, 100, 97, 116, 97, 34, 44, 10, 32, 32, 32, - 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, - 58, 32, 34, 114, 101, 115, 111, 117, 114, 99, 101, 34, 10, 32, 32, 32, - 32, 32, 32, 32, 32, 32, 32, 32, 32, 125, 10, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 32, 93, 10, 32, 32, 32, 32, 32, 32, 32, 32, 125, 44, 10, 32, 32, - 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, - 34, 105, 100, 34, 58, 32, 34, 98, 108, 111, 99, 107, 45, 48, 48, 48, 52, - 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 107, 105, 110, - 100, 34, 58, 32, 34, 109, 97, 114, 107, 100, 111, 119, 110, 45, 112, 97, - 114, 116, 115, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, - 114, 101, 102, 101, 114, 101, 110, 99, 101, 115, 34, 58, 32, 91, 10, 32, - 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, - 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 105, - 109, 97, 103, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 114, 101, 115, - 111, 117, 114, 99, 101, 34, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 125, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 93, 10, 32, 32, - 32, 32, 32, 32, 32, 32, 125, 10, 32, 32, 32, 32, 32, 32, 93, 44, 10, 32, - 32, 32, 32, 32, 32, 34, 99, 104, 101, 99, 107, 115, 34, 58, 32, 91, 10, - 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 34, 105, 100, 34, 58, 32, 34, 118, 101, 114, 105, 102, 105, 101, - 100, 45, 99, 111, 117, 110, 116, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, - 32, 32, 32, 34, 107, 105, 110, 100, 34, 58, 32, 34, 112, 114, 101, 100, - 105, 99, 97, 116, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, - 32, 34, 112, 97, 115, 115, 101, 100, 34, 58, 32, 116, 114, 117, 101, 10, - 32, 32, 32, 32, 32, 32, 32, 32, 125, 10, 32, 32, 32, 32, 32, 32, 93, 44, - 10, 32, 32, 32, 32, 32, 32, 34, 105, 100, 34, 58, 32, 34, 112, 111, 114, - 116, 97, 98, 108, 101, 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 111, 98, - 115, 101, 114, 118, 97, 116, 105, 111, 110, 115, 34, 58, 32, 91, 10, 32, - 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, - 32, 34, 105, 100, 34, 58, 32, 34, 99, 111, 117, 110, 116, 34, 44, 10, - 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 118, 97, 108, 117, 101, 34, - 58, 32, 51, 10, 32, 32, 32, 32, 32, 32, 32, 32, 125, 10, 32, 32, 32, 32, - 32, 32, 93, 44, 10, 32, 32, 32, 32, 32, 32, 34, 116, 105, 116, 108, 101, - 34, 58, 32, 34, 80, 111, 114, 116, 97, 98, 108, 101, 32, 100, 111, 99, - 117, 109, 101, 110, 116, 34, 10, 32, 32, 32, 32, 125, 10, 32, 32, 93, - 44, 10, 32, 32, 34, 112, 114, 111, 102, 105, 108, 101, 115, 34, 58, 32, - 91, 10, 32, 32, 32, 32, 123, 10, 32, 32, 32, 32, 32, 32, 34, 112, 114, - 111, 100, 117, 99, 101, 114, 34, 58, 32, 34, 101, 120, 101, 99, 117, - 116, 97, 98, 108, 101, 45, 100, 111, 99, 115, 47, 106, 115, 111, 110, - 34, 44, 10, 32, 32, 32, 32, 32, 32, 34, 112, 114, 111, 102, 105, 108, - 101, 34, 58, 32, 34, 106, 115, 111, 110, 45, 112, 114, 101, 116, 116, - 121, 45, 118, 49, 34, 10, 32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, - 123, 10, 32, 32, 32, 32, 32, 32, 34, 112, 114, 111, 100, 117, 99, 101, - 114, 34, 58, 32, 34, 102, 105, 120, 116, 117, 114, 101, 34, 44, 10, 32, - 32, 32, 32, 32, 32, 34, 112, 114, 111, 102, 105, 108, 101, 34, 58, 32, - 34, 101, 120, 97, 99, 116, 45, 118, 49, 34, 10, 32, 32, 32, 32, 125, 10, - 32, 32, 93, 44, 10, 32, 32, 34, 115, 99, 104, 101, 109, 97, 95, 118, - 101, 114, 115, 105, 111, 110, 34, 58, 32, 49, 10, 125, 10 - ], - "portable.md": [ - 35, 32, 80, 111, 114, 116, 97, 98, 108, 101, 32, 100, 111, 99, 117, 109, - 101, 110, 116, 10, 10, 60, 97, 32, 105, 100, 61, 34, 111, 118, 101, 114, - 118, 105, 101, 119, 34, 62, 60, 47, 97, 62, 10, 10, 35, 35, 32, 79, 118, - 101, 114, 118, 105, 101, 119, 10, 10, 60, 97, 32, 105, 100, 61, 34, 98, - 108, 111, 99, 107, 45, 48, 48, 48, 50, 34, 62, 60, 47, 97, 62, 10, 10, - 67, 111, 110, 116, 97, 105, 110, 115, 32, 51, 32, 114, 101, 99, 111, - 114, 100, 115, 46, 10, 10, 60, 97, 32, 105, 100, 61, 34, 98, 108, 111, - 99, 107, 45, 48, 48, 48, 51, 34, 62, 60, 47, 97, 62, 10, 10, 68, 97, - 116, 97, 58, 10, 10, 96, 96, 96, 106, 115, 111, 110, 10, 123, 10, 32, - 32, 34, 97, 34, 58, 32, 49, 44, 10, 32, 32, 34, 122, 34, 58, 32, 50, 10, - 125, 10, 96, 96, 96, 10, 10, 10, 10, 91, 68, 111, 119, 110, 108, 111, - 97, 100, 93, 40, 97, 115, 115, 101, 116, 115, 47, 112, 111, 114, 116, - 97, 98, 108, 101, 47, 100, 97, 116, 97, 46, 106, 115, 111, 110, 41, 10, - 10, 60, 97, 32, 105, 100, 61, 34, 98, 108, 111, 99, 107, 45, 48, 48, 48, - 52, 34, 62, 60, 47, 97, 62, 10, 10, 33, 91, 70, 105, 120, 101, 100, 32, - 102, 105, 120, 116, 117, 114, 101, 32, 98, 121, 116, 101, 115, 93, 40, - 97, 115, 115, 101, 116, 115, 47, 112, 111, 114, 116, 97, 98, 108, 101, - 47, 105, 109, 97, 103, 101, 46, 112, 110, 103, 41, 10, 10 - ] - } - }, - "input": { - "documents": [ - { - "bindings": [], - "checks": [ - { - "id": "verified-count", - "kind": "predicate", - "passed": true - } - ], - "id": "portable", - "nodes": [ - { - "id": "overview", - "kind": "heading", - "level": 2, - "text": "Overview" - }, - { - "id": "block-0002", - "kind": "markdown", - "parts": [ - { - "kind": "markdown", - "text": "Contains " - }, - { - "kind": "reference", - "target": { - "id": "count", - "kind": "observation" - } - }, - { - "kind": "markdown", - "text": " records.\n\n" - } - ] - }, - { - "id": "note-0001", - "kind": "note", - "parts": [ - { - "kind": "text", - "text": "Internal: " - }, - { - "kind": "reference", - "target": { - "id": "internal-only", - "kind": "observation" - } - } - ], - "target": { - "id": "block-0002", - "kind": "block" - } - }, - { - "id": "block-0003", - "kind": "markdown", - "parts": [ - { - "kind": "markdown", - "text": "Data:\n\n" - }, - { - "kind": "code", - "language": "json", - "target": { - "id": "data", - "kind": "resource" - } - }, - { - "kind": "markdown", - "text": "\n\n" - }, - { - "kind": "link", - "label": "Download", - "target": { - "id": "data", - "kind": "resource" - } - }, - { - "kind": "markdown", - "text": "\n\n" - } - ] - }, - { - "id": "block-0004", - "kind": "markdown", - "parts": [ - { - "kind": "image", - "label": "Fixed fixture bytes", - "target": { - "id": "image", - "kind": "resource" - } - }, - { - "kind": "markdown", - "text": "\n\n" - } - ] - } - ], - "observations": [ - { - "id": "count", - "value": 3 - }, - { - "id": "internal-only", - "value": "private observation" - } - ], - "resources": [ - { - "bytes": [ - 123, 10, 32, 32, 34, 97, 34, 58, 32, 49, 44, 10, 32, 32, 34, 122, - 34, 58, 32, 50, 10, 125, 10 - ], - "extension": "json", - "id": "data", - "mime": "application/json", - "producer": "executable-docs/json", - "profile": "json-pretty-v1" - }, - { - "bytes": [1, 2, 3], - "extension": "png", - "id": "image", - "mime": "image/png", - "producer": "fixture", - "profile": "exact-v1" - } - ], - "title": "Portable document" - } - ], - "schema_version": 1 - } -} diff --git a/crates/lgtmd/tests/json_review.rs b/crates/lgtmd/tests/json_review.rs deleted file mode 100644 index 3df1ef3..0000000 --- a/crates/lgtmd/tests/json_review.rs +++ /dev/null @@ -1,65 +0,0 @@ -use lgtmd::{Audience, Doc, Document}; -use serde_json::{Map, Value, json}; -use std::collections::{BTreeMap, HashMap}; - -fn ordered_object(reverse: bool) -> Value { - let mut inner = Map::new(); - for key in if reverse { ["z", "a"] } else { ["a", "z"] } { - inner.insert(key.into(), json!(key)); - } - let mut outer = Map::new(); - for key in if reverse { ["z", "a"] } else { ["a", "z"] } { - outer.insert(key.into(), json!([inner.clone(), 2, 1])); - } - Value::Object(outer) -} - -fn export(value: Value) -> BTreeMap> { - let mut doc = Doc::new("json", "JSON").unwrap(); - let checked = doc - .expect_eq("value", value.clone(), ordered_object(false)) - .unwrap(); - assert_eq!( - checked.observed().to_string(), - r#"{"a":[{"a":"a","z":"z"},2,1],"z":[{"a":"a","z":"z"},2,1]}"#, - ); - doc.paragraph(("Observed: ", &checked)).unwrap(); - doc.json_code("result", &value).unwrap(); - Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap() -} - -#[test] -fn nested_values_are_canonical_in_resources_prose_and_manifest() { - // Also run this test in the independent consumer with preserve_order enabled. - // Arrays deliberately contain descending elements: their order must survive. - let files = export(ordered_object(true)); - assert_eq!(files, export(ordered_object(false))); - let bytes = &files["assets/json/result.json"]; - assert_eq!( - std::str::from_utf8(bytes).unwrap(), - "{\n \"a\": [\n {\n \"a\": \"a\",\n \"z\": \"z\"\n },\n 2,\n 1\n ],\n \"z\": [\n {\n \"a\": \"a\",\n \"z\": \"z\"\n },\n 2,\n 1\n ]\n}\n", - ); - let manifest: Value = serde_json::from_slice(&files["manifest.json"]).unwrap(); - assert_eq!( - manifest["documents"][0]["checks"][0]["actual"], - ordered_object(false) - ); - assert_eq!( - manifest["documents"][0]["checks"][0]["expected"], - ordered_object(false) - ); -} - -#[test] -fn hash_maps_are_sorted_recursively_without_reordering_arrays() { - let nested = HashMap::from([("z", 3), ("a", 1), ("m", 2)]); - let input = HashMap::from([("z", vec![nested.clone()]), ("a", vec![nested])]); - let mut doc = Doc::new("hash-map", "Hash map").unwrap(); - doc.require("executed", true).unwrap(); - doc.json_code("result", &input).unwrap(); - let files = Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap(); - assert_eq!( - std::str::from_utf8(&files["assets/hash-map/result.json"]).unwrap(), - "{\n \"a\": [\n {\n \"a\": 1,\n \"m\": 2,\n \"z\": 3\n }\n ],\n \"z\": [\n {\n \"a\": 1,\n \"m\": 2,\n \"z\": 3\n }\n ]\n}\n", - ); -} diff --git a/crates/lgtmd/tests/layout.rs b/crates/lgtmd/tests/layout.rs deleted file mode 100644 index 59e1c1f..0000000 --- a/crates/lgtmd/tests/layout.rs +++ /dev/null @@ -1,375 +0,0 @@ -use lgtmd::{Artifact, Audience, Doc, Document, ExportLayout}; -use pulldown_cmark::{Event, Parser, Tag}; -use serde_json::Value; - -fn doc(id: &str) -> Doc { - let mut doc = Doc::new(id, id).unwrap(); - doc.require("executed", true).unwrap(); - doc -} - -fn text() -> Artifact { - Artifact::new(b"frozen evidence", "text/plain", "txt", "host", "v1").unwrap() -} - -#[test] -fn default_layout_preserves_all_export_bytes_and_freezes_no_repository_paths() { - let mut doc = doc("guide"); - let resource = doc.text("result", "actual result").unwrap(); - doc.paragraph(resource.link("Result")).unwrap(); - doc.note("Private rationale").unwrap(); - let document = doc.finish().unwrap(); - for audience in [Audience::Reader, Audience::Contributor] { - let ordinary = Document::render_many(std::slice::from_ref(&document), audience).unwrap(); - assert_eq!( - ordinary, - document - .render_with(audience, &ExportLayout::default()) - .unwrap() - ); - assert_eq!( - ordinary, - lgtmd::render_with( - std::slice::from_ref(&document), - audience, - &ExportLayout::default() - ) - .unwrap() - ); - assert_eq!(ordinary["assets/guide/result.txt"], b"actual result"); - } - let root_resources = ExportLayout::default().resources_under("").unwrap(); - let files = document - .render_with(Audience::Reader, &root_resources) - .unwrap(); - assert_eq!(files["guide/result.txt"], b"actual result"); - assert!(!files.contains_key("assets/guide/result.txt")); -} - -#[test] -fn nested_pages_use_relative_encoded_urls_for_typed_resources_and_keep_explicit_paths() { - let mut doc = doc("start"); - let automatic = doc.text("result", "actual result").unwrap(); - let explicit = doc - .resource_at("explicit", text(), "downloads/a©-é.txt") - .unwrap(); - doc.paragraph(( - automatic.link("Automatic"), - " and ", - explicit.link("Explicit"), - )) - .unwrap(); - doc.embed(&explicit, "Download").unwrap(); - let document = doc.finish().unwrap(); - let layout = ExportLayout::default() - .pages_under("manual/chapters") - .unwrap() - .resources_under("evidence/frozen") - .unwrap() - .page("start", "manual/入口/index.md") - .unwrap(); - let files = document.render_with(Audience::Reader, &layout).unwrap(); - assert!(files.contains_key("manual/入口/index.md")); - assert_eq!(files["downloads/a©-é.txt"], b"frozen evidence"); - assert_eq!(files["evidence/frozen/start/result.txt"], b"actual result"); - let page = std::str::from_utf8(&files["manual/入口/index.md"]).unwrap(); - let links: Vec<_> = Parser::new(page) - .filter_map(|event| match event { - Event::Start(Tag::Link { dest_url, .. }) => Some(dest_url.into_string()), - _ => None, - }) - .collect(); - assert_eq!( - links, - [ - "../../evidence/frozen/start/result.txt", - "../../downloads/a%26copy%3B-%C3%A9.txt", - "../../downloads/a%26copy%3B-%C3%A9.txt" - ] - ); - let manifest: Value = serde_json::from_slice(&files["manifest.json"]).unwrap(); - let page_record = manifest["artifacts"] - .as_array() - .unwrap() - .iter() - .find(|record| record["kind"] == "page") - .unwrap(); - assert_eq!(page_record["path"], "manual/入口/index.md"); -} - -#[test] -fn authored_relative_links_resolve_from_selected_page_and_validate_generated_anchors() { - let mut first = doc("first"); - first - .markdown("[Second](../reference/second.md#details)") - .unwrap(); - let mut second = doc("second"); - second.heading_with_id("details", 2, "Details").unwrap(); - second - .resource_at("result", text(), "downloads/data.txt") - .unwrap(); - second.markdown("[Data](../../downloads/data.txt)").unwrap(); - let documents = [first.finish().unwrap(), second.finish().unwrap()]; - let layout = ExportLayout::default() - .page("first", "manual/start/index.md") - .unwrap() - .page("second", "manual/reference/second.md") - .unwrap(); - let files = Document::render_many_with(&documents, Audience::Reader, &layout).unwrap(); - assert_eq!(files["downloads/data.txt"], b"frozen evidence"); - assert!(Document::render_many(&documents, Audience::Reader).is_err()); - let changed_layout = ExportLayout::default() - .page("first", "manual/start/index.md") - .unwrap() - .page("second", "manual/reference/moved.md") - .unwrap(); - assert!( - Document::render_many_with(&documents, Audience::Reader, &changed_layout) - .unwrap_err() - .contains("Broken") - ); -} - -#[test] -fn selected_layout_rejects_parent_traversal_outside_bundle_including_encoded_paths() { - let layout = ExportLayout::default().pages_under("manual").unwrap(); - for link in [ - "../../secret.txt", - "%2e%2e/%2e%2e/secret.txt", - "../%2e%2e/secret.txt", - "/secret.txt", - "https:///missing", - ] { - let mut doc = doc("guide"); - doc.markdown(&format!("[Bad]({link})")).unwrap(); - let document = doc.finish().unwrap(); - assert!( - document.render_with(Audience::Reader, &layout).is_err(), - "Accepted {link}" - ); - } -} - -#[test] -fn layout_validation_rejects_unsafe_and_unknown_configuration() { - for path in [ - "/absolute", - "../outside", - "a/../outside", - "a//b", - "a/./b", - "a\\b", - "manifest.json", - "manifest.json/guide.md", - ".ownership.json", - ".ownership.json/file.txt", - "a?b", - "a#b", - "a%2Fb", - ] { - assert!( - ExportLayout::default().pages_under(path).is_err(), - "pages {path}" - ); - assert!( - ExportLayout::default().resources_under(path).is_err(), - "resources {path}" - ); - assert!( - ExportLayout::default().page("guide", path).is_err(), - "page {path}" - ); - } - assert!(ExportLayout::default().page("guide", "").is_err()); - assert!(ExportLayout::default().page("bad/id", "guide.md").is_err()); - assert!( - ExportLayout::default() - .page("guide", "one.md") - .unwrap() - .page("guide", "two.md") - .is_err() - ); - let layout = ExportLayout::default().page("missing", "one.md").unwrap(); - let document = doc("guide").finish().unwrap(); - assert!( - document - .render_with(Audience::Reader, &layout) - .unwrap_err() - .contains("unknown document") - ); -} - -#[test] -fn layout_collisions_include_hidden_resources_and_file_directory_conflicts() { - let mut first = doc("first"); - let private = first - .resource_at("private", text(), "manual/second.md") - .unwrap(); - first.note((&private, ": private evidence")).unwrap(); - let second = doc("second").finish().unwrap(); - let layout = ExportLayout::default().pages_under("manual").unwrap(); - assert!( - Document::render_many_with( - &[first.finish().unwrap(), second], - Audience::Reader, - &layout - ) - .unwrap_err() - .contains("Duplicate artifact path") - ); - - let mut guide = doc("guide"); - let resource = guide.resource_at("file", text(), "manual").unwrap(); - guide.paragraph(&resource).unwrap(); - assert!( - guide - .finish() - .unwrap() - .render_with(Audience::Reader, &layout) - .unwrap_err() - .contains("file/directory collision") - ); -} - -#[test] -fn nested_layout_keeps_private_resources_and_metadata_out_of_reader_bundle() { - let mut doc = doc("guide"); - doc.paragraph("Public text").unwrap(); - let resource = doc - .resource_at("secret", text(), "private/secret.txt") - .unwrap(); - doc.note(("Private attachment: ", &resource)).unwrap(); - let document = doc.finish().unwrap(); - let layout = ExportLayout::default() - .pages_under("manual/chapters") - .unwrap() - .resources_under("evidence") - .unwrap(); - let reader = document.render_with(Audience::Reader, &layout).unwrap(); - let contributor = document - .render_with(Audience::Contributor, &layout) - .unwrap(); - assert!(!reader.contains_key("private/secret.txt")); - assert!(contributor.contains_key("private/secret.txt")); - assert!(!String::from_utf8_lossy(&reader["manifest.json"]).contains("secret")); - assert!( - String::from_utf8_lossy(&contributor["manual/chapters/guide.md"]) - .contains("../../private/secret.txt") - ); -} - -#[test] -fn layout_dependent_orphans_fail_before_any_export_returns() { - let mut doc = doc("guide"); - doc.text("unused", "unused bytes").unwrap(); - let document = doc.finish().unwrap(); - assert!( - document - .render_fragment(Audience::Reader) - .unwrap_err() - .contains("Unreferenced") - ); - assert!( - document - .resource_files(Audience::Reader) - .unwrap_err() - .contains("Unreferenced") - ); - assert!( - document - .render_with( - Audience::Reader, - &ExportLayout::default().pages_under("manual").unwrap() - ) - .unwrap_err() - .contains("Unreferenced") - ); -} - -#[test] -fn explicit_resource_paths_do_not_conflict_with_unselected_default_paths() { - let mut doc = doc("guide"); - let automatic = doc.text("first", "automatic").unwrap(); - let explicit = doc - .resource_at("second", text(), "assets/guide/first.txt") - .unwrap(); - doc.paragraph((&automatic, " and ", &explicit)).unwrap(); - let document = doc.finish().unwrap(); - assert!( - document - .render_with(Audience::Reader, &ExportLayout::default()) - .unwrap_err() - .contains("Duplicate artifact path") - ); - let layout = ExportLayout::default().resources_under("evidence").unwrap(); - let files = document.render_with(Audience::Reader, &layout).unwrap(); - assert_eq!(files["evidence/guide/first.txt"], b"automatic"); - assert_eq!(files["assets/guide/first.txt"], b"frozen evidence"); -} - -#[test] -fn nested_cross_document_links_cannot_reference_excluded_notes_or_resources() { - for use_note in [true, false] { - let mut second = doc("second"); - let secret = second - .resource_at("secret", text(), "private/secret.txt") - .unwrap(); - let note = second.note((&secret, ": private evidence")).unwrap(); - let target = if use_note { - format!("second.md#{}", note.id()) - } else { - "../../private/secret.txt".into() - }; - let mut first = doc("first"); - first.markdown(&format!("[Excluded]({target})")).unwrap(); - let layout = ExportLayout::default() - .pages_under("manual/chapters") - .unwrap(); - let documents = [first.finish().unwrap(), second.finish().unwrap()]; - assert!(Document::render_many_with(&documents, Audience::Reader, &layout).is_err()); - assert!(Document::render_many_with(&documents, Audience::Contributor, &layout).is_ok()); - } -} - -#[test] -fn fragments_and_resource_exports_reject_impossible_file_directory_inventories() { - let mut doc = doc("guide"); - let parent = doc.resource_at("parent", text(), "downloads").unwrap(); - let child = doc - .resource_at("child", text(), "downloads/file.txt") - .unwrap(); - doc.paragraph((&parent, " and ", &child)).unwrap(); - let document = doc.finish().unwrap(); - assert!( - document - .render_fragment(Audience::Reader) - .unwrap_err() - .contains("file/directory collision") - ); - assert!( - document - .resource_files(Audience::Reader) - .unwrap_err() - .contains("file/directory collision") - ); -} - -#[test] -fn reserved_ownership_metadata_cannot_be_registered_as_contributor_only_evidence() { - for path in [ - ".ownership.json", - ".ownership.json/private.txt", - "manifest.json/private.txt", - ] { - let mut document = doc("guide"); - document.paragraph("Reader narrative").unwrap(); - let result = document - .resource_at("private", text(), path) - .and_then(|resource| document.note(("Contributor evidence: ", &resource))); - assert!(result.unwrap_err().contains("reserved"), "Accepted {path}"); - assert!( - document.finish().is_err(), - "Swallowing private registration failure must taint the document" - ); - } -} diff --git a/crates/lgtmd/tests/lifecycle.rs b/crates/lgtmd/tests/lifecycle.rs deleted file mode 100644 index eb6592d..0000000 --- a/crates/lgtmd/tests/lifecycle.rs +++ /dev/null @@ -1,296 +0,0 @@ -use lgtmd::{ - Result, - lifecycle::{self, Files, Ownership}, -}; -use std::{collections::BTreeSet, fs, path::PathBuf}; - -fn directory() -> (tempfile::TempDir, PathBuf) { - let temporary = tempfile::tempdir().unwrap(); - let root = temporary.path().canonicalize().unwrap(); - (temporary, root) -} - -fn legacy() -> Files { - Files::from([ - ("README.md".into(), b"\n# Existing guide\n\n![Example](assets/example.webp)\n".to_vec()), - ("manifest.txt".into(), b"n3 executable documentation v6\nrenderer: macos-metal\nassert: original assertion text\n".to_vec()), - ("assets/example.webp".into(), vec![82, 73, 70, 70, 0, 1, 2, 3]), - ]) -} - -fn accepted(_: &Files, _: &Files) -> Result<()> { - Ok(()) -} - -#[test] -fn dedicated_tree_preserves_every_legacy_byte_and_adds_no_metadata() { - let (_temporary, parent) = directory(); - let root = parent.join("guide"); - let original = legacy(); - assert!(lifecycle::read_tree(&root).unwrap().is_empty()); - assert!(!root.exists()); - lifecycle::update(&root, &original, Ownership::Dedicated, accepted).unwrap(); - assert_eq!(lifecycle::read_tree(&root).unwrap(), original); - assert!(!root.join(".ownership.json").exists()); - assert!(!root.join("manifest.json").exists()); - - lifecycle::check(&root, &original, Ownership::Dedicated, accepted).unwrap(); - assert_eq!(lifecycle::read_tree(&root).unwrap(), original); - let mut next = original.clone(); - next.get_mut("assets/example.webp").unwrap().push(42); - assert!( - lifecycle::check(&root, &next, Ownership::Dedicated, accepted) - .unwrap_err() - .contains("assets/example.webp") - ); - assert_eq!(lifecycle::read_tree(&root).unwrap(), original); - lifecycle::update(&root, &next, Ownership::Dedicated, accepted).unwrap(); - assert_eq!(lifecycle::read_tree(&root).unwrap(), next); - assert_eq!( - fs::read(root.join("manifest.txt")).unwrap(), - original["manifest.txt"] - ); - assert_eq!( - fs::read_dir(&parent).unwrap().count(), - 1, - "No transaction files remain" - ); -} - -#[test] -fn dedicated_tree_refuses_unknown_and_retired_files_without_deleting_them() { - let (_temporary, parent) = directory(); - let root = parent.join("guide"); - let original = legacy(); - lifecycle::update(&root, &original, Ownership::Dedicated, accepted).unwrap(); - fs::write(root.join("unowned.txt"), "keep this file").unwrap(); - let with_unknown = lifecycle::read_tree(&root).unwrap(); - assert!( - lifecycle::update(&root, &original, Ownership::Dedicated, accepted) - .unwrap_err() - .contains("unowned.txt") - ); - assert_eq!(lifecycle::read_tree(&root).unwrap(), with_unknown); - fs::remove_file(root.join("unowned.txt")).unwrap(); - - let mut retired = original.clone(); - retired.remove("assets/example.webp"); - assert!( - lifecycle::update(&root, &retired, Ownership::Dedicated, accepted) - .unwrap_err() - .contains("explicit removal") - ); - assert_eq!(lifecycle::read_tree(&root).unwrap(), original); - fs::remove_file(root.join("assets/example.webp")).unwrap(); - lifecycle::update(&root, &retired, Ownership::Dedicated, accepted).unwrap(); - assert_eq!(lifecycle::read_tree(&root).unwrap(), retired); -} - -#[test] -fn failed_or_panicking_preparation_never_reaches_legacy_publication() { - let (_temporary, parent) = directory(); - let root = parent.join("guide"); - let original = legacy(); - lifecycle::update(&root, &original, Ownership::Dedicated, accepted).unwrap(); - let failure: Result = lifecycle::prepare(|| Err("behavior assertion failed".into())); - let result = - failure.and_then(|files| lifecycle::update(&root, &files, Ownership::Dedicated, accepted)); - assert!(result.unwrap_err().contains("behavior assertion failed")); - assert_eq!(lifecycle::read_tree(&root).unwrap(), original); - - let failure: Result = lifecycle::prepare(|| panic!("capture failed")); - let result = - failure.and_then(|files| lifecycle::update(&root, &files, Ownership::Dedicated, accepted)); - assert!(result.unwrap_err().contains("panicked")); - assert_eq!(lifecycle::read_tree(&root).unwrap(), original); - assert_eq!(fs::read_dir(&parent).unwrap().count(), 1); -} - -#[test] -fn adapter_profile_policy_runs_before_check_or_publication() { - let (_temporary, parent) = directory(); - let root = parent.join("guide"); - let original = legacy(); - lifecycle::update(&root, &original, Ownership::Dedicated, accepted).unwrap(); - let mut other = original.clone(); - other.insert("manifest.txt".into(), b"renderer: other-profile\n".to_vec()); - let reject = |current: &Files, candidate: &Files| -> Result<()> { - assert_eq!(current, &original); - assert_eq!(candidate, &other); - Err("renderer mismatch from adapter".into()) - }; - assert_eq!( - lifecycle::check(&root, &other, Ownership::Dedicated, reject).unwrap_err(), - "renderer mismatch from adapter" - ); - assert_eq!( - lifecycle::update(&root, &other, Ownership::Dedicated, reject).unwrap_err(), - "renderer mismatch from adapter" - ); - assert_eq!(lifecycle::read_tree(&root).unwrap(), original); - assert_eq!(fs::read_dir(&parent).unwrap().count(), 1); -} - -#[test] -fn generic_inventory_and_duplicate_checks_are_strict() { - let mut files = Files::new(); - lifecycle::insert(&mut files, "page.md".into(), b"first".to_vec()).unwrap(); - assert!( - lifecycle::insert(&mut files, "page.md".into(), b"second".to_vec()) - .unwrap_err() - .contains("Duplicate") - ); - assert_eq!(files["page.md"], b"first"); - for path in ["../escape", "/absolute", "a//b", "a/./b", "a\\b"] { - assert!(lifecycle::insert(&mut files, path.into(), Vec::new()).is_err()); - } - let expected = BTreeSet::from(["registered".into()]); - lifecycle::check_inventory(&expected, &expected).unwrap(); - let error = - lifecycle::check_inventory(&expected, &BTreeSet::from(["unexpected".into()])).unwrap_err(); - assert!(error.contains("Missing") && error.contains("unregistered")); -} - -#[test] -fn recorded_inventory_is_complete_before_validation_or_publication() { - let (_temporary, parent) = directory(); - let root = parent.join("guide"); - let original = legacy(); - lifecycle::build(&root, &original, Ownership::Recorded).unwrap(); - fs::remove_file(root.join("assets/example.webp")).unwrap(); - let damaged = lifecycle::read_tree(&root).unwrap(); - let mut replacement = original.clone(); - replacement.remove("assets/example.webp"); - for candidate in [&original, &replacement] { - let called = std::cell::Cell::new(false); - let accept = |_: &Files, _: &Files| { - called.set(true); - Ok(()) - }; - for result in [ - lifecycle::check(&root, candidate, Ownership::Recorded, accept), - lifecycle::update(&root, candidate, Ownership::Recorded, accept), - ] { - assert!(result.unwrap_err().contains("missing assets/example.webp")); - assert!(!called.get()); - assert_eq!(lifecycle::read_tree(&root).unwrap(), damaged); - assert_eq!(fs::read_dir(&parent).unwrap().count(), 1); - } - } -} - -#[test] -fn path_separation_rejects_lexical_overlap_without_creating_output() { - let (_temporary, parent) = directory(); - let baseline = parent.join("guide"); - for candidate in [&baseline, &baseline.join("candidate"), &parent] { - assert!( - lifecycle::ensure_separate_paths(&baseline, candidate) - .unwrap_err() - .contains("overlap") - ); - } - lifecycle::ensure_separate_paths(&baseline, &parent.join("guide-sibling")).unwrap(); - assert_eq!(fs::read_dir(&parent).unwrap().count(), 0); -} - -#[test] -fn path_separation_uses_existing_identity_without_folding_distinct_names() { - let (_temporary, parent) = directory(); - let baseline = parent.join("Guide"); - let alias = parent.join("guide"); - fs::create_dir(&baseline).unwrap(); - fs::write(baseline.join("keep.txt"), "retained bytes").unwrap(); - let candidate = alias.join("candidate"); - - if alias.exists() { - assert!(same_file::is_same_file(&baseline, &alias).unwrap()); - for (left, right) in [(&baseline, &candidate), (&candidate, &baseline)] { - assert!( - lifecycle::ensure_separate_paths(left, right) - .unwrap_err() - .contains("overlap") - ); - } - } else { - // A case-sensitive host must keep genuinely distinct directories usable. - fs::create_dir(&alias).unwrap(); - assert!(!same_file::is_same_file(&baseline, &alias).unwrap()); - lifecycle::ensure_separate_paths(&baseline, &candidate).unwrap(); - lifecycle::ensure_separate_paths(&candidate, &baseline).unwrap(); - } - assert!(!candidate.exists()); - assert_eq!( - fs::read(baseline.join("keep.txt")).unwrap(), - b"retained bytes" - ); - assert_eq!(fs::read_dir(&baseline).unwrap().count(), 1); -} - -#[test] -fn dedicated_recovery_requires_the_adapters_backup_validation() { - let (_temporary, parent) = directory(); - let root = parent.join("guide"); - let backup = parent.join(".guide.backup"); - let original = legacy(); - lifecycle::update(&root, &original, Ownership::Dedicated, accepted).unwrap(); - fs::rename(&root, &backup).unwrap(); - assert!( - lifecycle::read_tree(&root) - .unwrap_err() - .contains("Interrupted transaction") - ); - fs::remove_file(backup.join("assets/example.webp")).unwrap(); - let error = lifecycle::recover_backup(&root, Ownership::Dedicated, |files| { - lifecycle::compare(&original, files) - }) - .unwrap_err(); - assert!(error.contains("missing assets/example.webp")); - assert!(!root.exists()); - fs::write( - backup.join("assets/example.webp"), - &original["assets/example.webp"], - ) - .unwrap(); - lifecycle::recover_backup(&root, Ownership::Dedicated, |files| { - lifecycle::compare(&original, files) - }) - .unwrap(); - assert_eq!(lifecycle::read_tree(&root).unwrap(), original); -} - -#[cfg(unix)] -#[test] -fn dedicated_paths_reject_symlinks_without_touching_targets() { - use std::os::unix::fs::symlink; - let (_temporary, parent) = directory(); - let protected = parent.join("protected"); - fs::create_dir(&protected).unwrap(); - fs::write(protected.join("keep"), "untouched").unwrap(); - let link = parent.join("linked"); - symlink(&protected, &link).unwrap(); - assert!( - lifecycle::read_tree(&link) - .unwrap_err() - .to_lowercase() - .contains("symlink") - ); - assert!( - lifecycle::update( - &link.join("guide"), - &legacy(), - Ownership::Dedicated, - accepted - ) - .is_err() - ); - let root = parent.join("guide"); - lifecycle::update(&root, &legacy(), Ownership::Dedicated, accepted).unwrap(); - fs::remove_file(root.join("assets/example.webp")).unwrap(); - symlink(protected.join("keep"), root.join("assets/example.webp")).unwrap(); - assert!(lifecycle::update(&root, &legacy(), Ownership::Dedicated, accepted).is_err()); - assert_eq!( - fs::read_to_string(protected.join("keep")).unwrap(), - "untouched" - ); -} diff --git a/crates/lgtmd/tests/model.rs b/crates/lgtmd/tests/model.rs deleted file mode 100644 index 85d4a44..0000000 --- a/crates/lgtmd/tests/model.rs +++ /dev/null @@ -1,597 +0,0 @@ -use lgtmd::{Artifact, Audience, BindingValue, Doc, Document, IntoProse}; -use serde_json::{Value, json}; - -fn doc() -> Doc { - let mut doc = Doc::new("example", "Example").unwrap(); - doc.require("initial-condition", true).unwrap(); - doc -} - -fn render(doc: Doc, audience: Audience) -> std::collections::BTreeMap> { - Document::render_many(&[doc.finish().unwrap()], audience).unwrap() -} - -#[test] -fn prose_macro_combines_many_mixed_parts_and_evaluates_each_once_in_order() { - let mut doc = doc(); - let count = doc.expect_eq("count", 3, 3).unwrap(); - let control = doc - .binding("save", BindingValue::Code("Save".into())) - .unwrap(); - let text = doc.text("result", "saved").unwrap(); - let mut evaluated = Vec::new(); - doc.markdown_parts(lgtmd::prose![ - { - evaluated.push(1); - "First: " - }, - &count, - ". Choose ", - &control, - "; read ", - text.link("the result"), - ". Then ", - &count, - " again. ", - { - evaluated.push(2); - String::from("Last: ") - }, - &control, - ".\n", - ]) - .unwrap(); - assert_eq!(evaluated, [1, 2]); - let document = doc.finish().unwrap(); - assert_eq!( - document.render_fragment(Audience::Reader).unwrap(), - "First: 3. Choose Save; read [the result](assets/example/result.txt). Then 3 again. Last: Save.\n" - ); - let files = Document::render_many(&[document], Audience::Reader).unwrap(); - let manifest: Value = serde_json::from_slice(&files["manifest.json"]).unwrap(); - let references: Vec<_> = manifest["documents"][0]["blocks"][0]["references"] - .as_array() - .unwrap() - .iter() - .map(|reference| reference["id"].as_str().unwrap()) - .collect(); - assert_eq!(references, ["count", "save", "result", "count", "save"]); - let empty = lgtmd::prose![]; - assert!(empty.is_empty()); -} - -#[test] -fn swallowed_failure_duplicate_and_foreign_handles_taint_finish() { - let mut failed = doc(); - let _ = failed.expect_eq("result", 2, 3); - failed.paragraph("This must never publish.").unwrap(); - assert!(failed.finish().unwrap_err().contains("tainted")); - - let mut duplicate = doc(); - let _ = duplicate.require("initial-condition", true); - assert!(duplicate.finish().unwrap_err().contains("Duplicate")); - - let mut first = doc(); - let checked = first.expect_eq("observed", 42, 42).unwrap(); - let mut second = doc(); - second.expect_eq("observed", 42, 42).unwrap(); - let _ = second.paragraph(("Foreign value ", &checked)); - assert!( - second - .finish() - .unwrap_err() - .contains("Foreign document/run") - ); -} - -#[test] -fn value_and_file_bytes_are_frozen_at_observation() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("result.txt"); - std::fs::write(&path, "before").unwrap(); - let mut doc = doc(); - let checked = doc.expect_eq("count", 42_u32, 42_u32).unwrap(); - doc.paragraph(("Count: ", &checked)).unwrap(); - let resource = doc - .resource( - "result", - Artifact::from_file(&path, "text/plain", "txt", "test", "v1").unwrap(), - ) - .unwrap(); - std::fs::write(&path, "after").unwrap(); - doc.code(&resource, "text").unwrap(); - let files = render(doc, Audience::Reader); - assert_eq!(files["assets/example/result.txt"], b"before"); - let manifest: Value = serde_json::from_slice(&files["manifest.json"]).unwrap(); - assert!( - manifest["documents"][0]["checks"] - .as_array() - .unwrap() - .iter() - .any(|check| check["id"] == "count" - && check["actual"] == 42 - && check["expected"] == 42) - ); - assert!(String::from_utf8_lossy(&files["example.md"]).contains("Count: 42")); -} - -#[test] -fn notes_and_their_values_resources_profiles_and_edges_are_absent_from_reader() { - let mut doc = doc(); - let public = doc.text_code("public", "public bytes", "text").unwrap(); - let private = doc - .resource( - "private-secret", - Artifact::new( - b"private-body".to_vec(), - "text/x-secret", - "secret", - "private-producer", - "private-profile", - ) - .unwrap(), - ) - .unwrap(); - let value = doc - .expect_eq( - "private-value", - "private-observation", - "private-observation", - ) - .unwrap(); - doc.note_on(&public, ("private-note ", &private, " observed ", &value)) - .unwrap(); - doc.note_on(&value, "Explain the checked observation.") - .unwrap(); - let document = doc.finish().unwrap(); - let reader = Document::render_many(std::slice::from_ref(&document), Audience::Reader).unwrap(); - let contributor = Document::render_many(&[document], Audience::Contributor).unwrap(); - for (path, bytes) in &reader { - let text = String::from_utf8_lossy(bytes); - assert!(!path.contains("private")); - for secret in [ - "private-secret", - "private-note", - "private-body", - "private-observation", - "private-producer", - "private-profile", - ] { - assert!( - !text.contains(secret), - "Private evidence leaked into {path}: {text}" - ); - } - } - assert!(contributor.contains_key("assets/example/private-secret.secret")); - assert!( - String::from_utf8_lossy(&contributor["example.md"]) - .contains("Contributor note on `private-value`") - ); - assert!(String::from_utf8_lossy(&contributor["manifest.json"]).contains("private-observation")); -} - -#[test] -fn inserting_notes_never_changes_reader_block_ids_or_bytes() { - fn build(notes: bool) -> Doc { - let mut doc = doc(); - if notes { - doc.note("Before").unwrap(); - } - let block = doc.heading(2, "Section").unwrap(); - if notes { - doc.note_on(&block, "After").unwrap(); - } - doc.paragraph(("See ", &block)).unwrap(); - doc - } - assert_eq!( - render(build(false), Audience::Reader), - render(build(true), Audience::Reader) - ); -} - -#[test] -fn dangling_resources_and_public_references_to_private_blocks_fail() { - let mut unused = doc(); - unused.text("unused", "not placed").unwrap(); - assert!( - Document::render_many(&[unused.finish().unwrap()], Audience::Reader) - .unwrap_err() - .contains("Unreferenced resources") - ); - let mut leaked = doc(); - let note = leaked.note("Private").unwrap(); - leaked.paragraph(("See ", ¬e)).unwrap(); - assert!(leaked.finish().unwrap_err().contains("excluded block")); -} - -#[test] -fn wrong_text_kind_fails_even_when_error_is_swallowed() { - let mut doc = doc(); - let image = doc - .resource( - "image", - Artifact::new(vec![0, 1, 2], "image/png", "png", "test", "v1").unwrap(), - ) - .unwrap(); - let _ = doc.code(&image, "text"); - doc.embed(&image, "image").unwrap(); - assert!(doc.finish().unwrap_err().contains("not declared as text")); -} - -#[test] -fn resource_reuse_registers_bytes_once_and_code_fences_cannot_be_closed_by_content() { - let mut doc = doc(); - let resource = doc - .text_code( - "transcript", - "```\n[not a link](missing.md)\n`````\n", - "text", - ) - .unwrap(); - doc.paragraph(("Download ", &resource)).unwrap(); - doc.note_on(&resource, "One run supplies both uses.") - .unwrap(); - let files = render(doc, Audience::Contributor); - assert_eq!( - files - .keys() - .filter(|path| path.starts_with("assets/")) - .count(), - 1 - ); - assert!(String::from_utf8_lossy(&files["example.md"]).contains("``````text\n")); -} - -#[test] -fn markdown_references_use_the_real_parser_and_ignore_code_examples() { - let mut doc = doc(); - let resource = doc.json("data", &json!({"b": 2, "a": 1})).unwrap(); - doc.markdown("[saved data][result]\n\n[result]: assets/example/data.json \"JSON\"\n\n`[code](missing.md)`\n\n```text\n[also code](missing.md)\n```").unwrap(); - doc.note_on(&resource, "Source resource").unwrap(); - let files = render(doc, Audience::Reader); - assert!(files.contains_key("assets/example/data.json")); - let mut unresolved = doc_for_reference(); - let _ = unresolved.markdown("[missing][definition]"); - assert!( - unresolved - .finish() - .unwrap_err() - .contains("Unresolved Markdown") - ); -} - -fn doc_for_reference() -> Doc { - doc() -} - -#[test] -fn raw_cross_document_links_wait_for_complete_bundle_but_unknown_and_hidden_links_fail() { - let mut first = Doc::new("first", "First").unwrap(); - first.require("checked", true).unwrap(); - first.markdown("[second](second.md)").unwrap(); - let first = first.finish().unwrap(); - assert!(Document::render_many(std::slice::from_ref(&first), Audience::Reader).is_err()); - let mut second = Doc::new("second", "Second").unwrap(); - second.require("checked", true).unwrap(); - second.paragraph("Second document").unwrap(); - assert!(Document::render_many(&[first, second.finish().unwrap()], Audience::Reader).is_ok()); -} - -#[test] -fn raw_html_unsafe_schemes_and_encoded_traversal_fail() { - for markdown in [ - "", - "[bad](javascript:alert)", - "[bad](%2e%2e/secret.txt)", - ] { - let mut doc = doc(); - let result = doc.markdown(markdown); - assert!( - result.is_err() - || doc - .finish() - .and_then(|document| Document::render_many(&[document], Audience::Reader)) - .is_err(), - "Accepted {markdown}" - ); - } -} - -#[test] -fn repeated_runs_are_identical_and_registration_order_is_canonical() { - fn build(reverse: bool) -> Document { - let mut doc = doc(); - let mut names = ["first", "second"]; - if reverse { - names.reverse(); - } - let mut resources = std::collections::BTreeMap::new(); - for name in names { - resources.insert(name, doc.text(name, name).unwrap()); - } - for resource in resources.values() { - doc.code(resource, "text").unwrap(); - } - doc.finish().unwrap() - } - let first = Document::render_many(&[build(false)], Audience::Reader).unwrap(); - let second = Document::render_many(&[build(true)], Audience::Reader).unwrap(); - assert_eq!(first, second); -} - -#[test] -fn resource_code_preserves_custom_provenance_and_failure_taints_the_document() { - let mut complete = doc(); - let resource = complete - .resource_code( - "command", - Artifact::new( - b"actual stdout", - "text/plain", - "txt", - "application-cli", - "stdout-v1", - ) - .unwrap(), - "text", - ) - .unwrap(); - complete - .note_on(&resource, "Produced by the real command.") - .unwrap(); - let files = render(complete, Audience::Reader); - assert_eq!(files["assets/example/command.txt"], b"actual stdout"); - let manifest: Value = serde_json::from_slice(&files["manifest.json"]).unwrap(); - let record = manifest["artifacts"] - .as_array() - .unwrap() - .iter() - .find(|record| record["id"] == "command") - .unwrap(); - assert_eq!(record["producer"], "application-cli"); - assert_eq!(record["profile"], "stdout-v1"); - - let mut failed = doc(); - let _ = failed.resource_code( - "binary", - Artifact::new( - vec![0xff], - "text/plain", - "txt", - "application-cli", - "stdout-v1", - ) - .unwrap(), - "text", - ); - assert!(failed.finish().unwrap_err().contains("not UTF-8")); -} - -#[test] -fn legacy_fragment_preserves_markdown_whitespace_and_typed_control_media_markup() { - let mut doc = doc(); - let control = doc - .binding("control", BindingValue::Code("File & \"'|".into())) - .unwrap(); - let shortcut = doc - .binding( - "shortcut", - BindingValue::Keys(vec!["Command".into(), "O".into()]), - ) - .unwrap(); - let image = doc - .resource_at( - "preview", - Artifact::new( - b"frozen image", - "image/webp", - "webp", - "host-renderer", - "metal-v1", - ) - .unwrap(), - "assets/legacy-preview.webp", - ) - .unwrap(); - doc.markdown_parts(vec![ - "# Existing guide\n\nChoose ".into_prose(), - (&control).into_prose(), - " or press ".into_prose(), - (&shortcut).into_prose(), - ".\n\n".into_prose(), - image.image("Legacy preview"), - "\n\n[Related](other.md#existing-heading)\n".into_prose(), - ]) - .unwrap(); - doc.note("Contributor explanation.").unwrap(); - let document = doc.finish().unwrap(); - assert_eq!( - document.render_fragment(Audience::Reader).unwrap(), - "# Existing guide\n\nChoose File & <View> "'| or press Command + O.\n\n![Legacy preview](assets/legacy-preview.webp)\n\n[Related](other.md#existing-heading)\n" - ); - assert!( - Document::render_many(&[document], Audience::Reader).is_err(), - "The ordinary bundle renderer still owns strict whole-bundle link checking" - ); -} - -#[test] -fn typed_bindings_and_image_views_preserve_audience_isolation() { - fn build(notes: bool) -> Document { - let mut doc = doc(); - let public = doc - .binding("public-label", BindingValue::Text("Visible".into())) - .unwrap(); - doc.markdown_parts(("A **", &public, "** label.\n")) - .unwrap(); - if notes { - let private = doc - .binding("secret-label", BindingValue::Keys(vec!["SECRET".into()])) - .unwrap(); - let image = doc - .resource_at( - "secret-image", - Artifact::new( - b"SECRET BYTES", - "image/png", - "png", - "secret-producer", - "secret-profile", - ) - .unwrap(), - "assets/secret.png", - ) - .unwrap(); - doc.note((&private, ": ", image.image("secret image"))) - .unwrap(); - } - doc.finish().unwrap() - } - let ordinary = Document::render_many(&[build(false)], Audience::Reader).unwrap(); - let annotated = Document::render_many(&[build(true)], Audience::Reader).unwrap(); - assert_eq!(ordinary, annotated); - let contributor = Document::render_many(&[build(true)], Audience::Contributor).unwrap(); - assert!(contributor.contains_key("assets/secret.png")); - assert!(String::from_utf8_lossy(&contributor["manifest.json"]).contains("secret-label")); -} - -#[test] -fn fragment_resource_files_exclude_note_only_bytes_from_reader() { - let mut doc = doc(); - let diagnostic = doc.text("diagnostic", "private frozen bytes").unwrap(); - doc.markdown_parts("Public explanation.\n").unwrap(); - doc.note(("Contributor diagnosis: ", &diagnostic)).unwrap(); - let document = doc.finish().unwrap(); - assert_eq!( - document.render_fragment(Audience::Reader).unwrap(), - "Public explanation.\n" - ); - assert!( - document - .resource_files(Audience::Reader) - .unwrap() - .is_empty() - ); - let files = document.resource_files(Audience::Contributor).unwrap(); - assert_eq!(files.len(), 1); - assert_eq!( - files["assets/example/diagnostic.txt"], - b"private frozen bytes" - ); - assert_eq!( - files, - document.resource_files(Audience::Contributor).unwrap() - ); -} - -#[test] -fn binding_and_media_composition_rejects_foreign_handles_wrong_mime_and_literal_html() { - let mut first = doc(); - let foreign = first - .binding("label", BindingValue::Text("Label".into())) - .unwrap(); - let mut second = doc(); - assert!(second.markdown_parts(("Foreign ", &foreign)).is_err()); - assert!(second.finish().is_err()); - - let mut wrong_kind = doc(); - let text = wrong_kind.text("plain", "bytes").unwrap(); - assert!( - wrong_kind - .markdown_parts(text.image("Not an image")) - .is_err() - ); - assert!(wrong_kind.finish().is_err()); - - let mut html = doc(); - let tag = html - .binding("tag", BindingValue::Text("script".into())) - .unwrap(); - assert!(html.markdown_parts(("<", &tag, ">bad")).is_err()); - assert!(html.finish().is_err()); -} - -#[test] -fn custom_resource_paths_cannot_overwrite_another_document_or_resource() { - fn document(id: &str, path: &str) -> Document { - let mut doc = Doc::new(id, id).unwrap(); - doc.require("executed", true).unwrap(); - let resource = doc - .resource_at( - "shared", - Artifact::new(b"bytes", "text/plain", "txt", "host", "v1").unwrap(), - path, - ) - .unwrap(); - doc.paragraph(resource.link("Download")).unwrap(); - doc.finish().unwrap() - } - let error = Document::render_many( - &[ - document("first", "assets/shared.txt"), - document("second", "assets/shared.txt"), - ], - Audience::Reader, - ) - .unwrap_err(); - assert!(error.contains("Duplicate artifact path")); - let error = Document::render_many( - &[ - document("first", "second.md"), - document("second", "assets/second.txt"), - ], - Audience::Reader, - ) - .unwrap_err(); - assert!(error.contains("Duplicate artifact path")); - let mut unsafe_path = doc(); - assert!( - unsafe_path - .resource_at( - "bad", - Artifact::new(b"bytes", "text/plain", "txt", "host", "v1").unwrap(), - "../bad.txt" - ) - .is_err() - ); - assert!(unsafe_path.finish().is_err()); - - let private_document = |id: &str| { - let mut doc = Doc::new(id, id).unwrap(); - doc.require("checked", true).unwrap(); - let resource = doc - .resource_at( - "private", - Artifact::new(b"private", "text/plain", "txt", "host", "v1").unwrap(), - "assets/private.txt", - ) - .unwrap(); - doc.note(("Internal observation: ", &resource)).unwrap(); - doc.finish().unwrap() - }; - assert!( - Document::render_many( - &[private_document("first"), private_document("second")], - Audience::Reader - ) - .is_err(), - "Reader selection cannot conceal duplicate contributor ownership" - ); -} - -#[test] -fn fragment_rejects_block_links_without_generated_anchors() { - let mut doc = doc(); - let heading = doc.heading(2, "Section").unwrap(); - doc.paragraph(("See ", &heading)).unwrap(); - let document = doc.finish().unwrap(); - assert!( - document - .render_fragment(Audience::Reader) - .unwrap_err() - .contains("omits block anchors") - ); - assert!(Document::render_many(&[document], Audience::Reader).is_ok()); -} diff --git a/crates/lgtmd/tests/observations.rs b/crates/lgtmd/tests/observations.rs deleted file mode 100644 index edcab7a..0000000 --- a/crates/lgtmd/tests/observations.rs +++ /dev/null @@ -1,266 +0,0 @@ -use lgtmd::{Audience, Doc, Document, store}; -use serde::{Serialize, Serializer, ser::Error}; -use serde_json::{Value, json}; -use std::collections::BTreeMap; - -fn render(doc: Doc, audience: Audience) -> BTreeMap> { - Document::render_many(&[doc.finish().unwrap()], audience).unwrap() -} - -fn manifest(files: &BTreeMap>) -> Value { - serde_json::from_slice(&files["manifest.json"]).unwrap() -} - -#[test] -fn explanations_publish_without_manufacturing_checks_or_observations() { - let mut doc = Doc::new("saved-view", "Save a useful view").unwrap(); - let section = doc.heading(2, "Name your view").unwrap(); - doc.paragraph("Give the view a name you will recognize later.") - .unwrap(); - doc.note_on( - §ion, - "This page makes no behavioral verification claim.", - ) - .unwrap(); - let files = render(doc, Audience::Reader); - let receipt = manifest(&files); - assert_eq!(receipt["documents"][0]["checks"], json!([])); - assert!(receipt["documents"][0].get("observations").is_none()); - let directory = tempfile::tempdir().unwrap(); - store::build( - &directory.path().canonicalize().unwrap().join("reader"), - &files, - ) - .unwrap(); - assert!(String::from_utf8_lossy(&files["saved-view.md"]).contains("recognize later")); - assert!(!String::from_utf8_lossy(&files["saved-view.md"]).contains("verification claim")); -} - -#[test] -fn observations_freeze_at_registration_and_are_distinct_from_executed_checks() { - let mut doc = Doc::new("values", "Values").unwrap(); - let mut source = json!({"nested": [{"z": "last", "a": "first"}], "count": 2}); - let observed = doc.observe("response", &source).unwrap(); - assert_eq!(observed.id(), "response"); - assert_eq!(observed.observed(), &source); - doc.paragraph(("The response was ", &observed, ".")) - .unwrap(); - let count = doc.expect_eq("expected-count", 2, 2).unwrap(); - doc.paragraph(("The checked count is ", &count, ".")) - .unwrap(); - source["count"] = 99.into(); - source["nested"][0]["a"] = "changed".into(); - let files = render(doc, Audience::Reader); - let receipt = manifest(&files); - assert_eq!( - receipt["documents"][0]["observations"], - json!([{"id": "response", "value": { - "nested": [{"z": "last", "a": "first"}], "count": 2 - }}]) - ); - assert_eq!( - receipt["documents"][0]["checks"].as_array().unwrap().len(), - 1 - ); - assert_eq!(receipt["documents"][0]["checks"][0]["id"], "expected-count"); - assert_eq!( - receipt["documents"][0]["blocks"][0]["references"][0]["kind"], - "observation" - ); - let page = String::from_utf8_lossy(&files["values.md"]); - assert!(page.contains("first")); - assert!(!page.contains("changed")); - assert!(!page.contains("99")); - let directory = tempfile::tempdir().unwrap(); - store::build( - &directory.path().canonicalize().unwrap().join("reader"), - &files, - ) - .unwrap(); -} - -#[test] -fn observing_does_not_require_an_assertion_and_json_null_remains_present() { - let mut doc = Doc::new("optional", "An optional response").unwrap(); - let response = doc.observe("response", Option::::None).unwrap(); - doc.paragraph(("The response was ", response, ".")).unwrap(); - let files = render(doc, Audience::Reader); - let receipt = manifest(&files); - assert_eq!(receipt["documents"][0]["checks"], json!([])); - assert_eq!( - receipt["documents"][0]["observations"][0].get("value"), - Some(&Value::Null) - ); - assert!(String::from_utf8_lossy(&files["optional.md"]).contains("response was null")); - let directory = tempfile::tempdir().unwrap(); - store::build( - &directory.path().canonicalize().unwrap().join("reader"), - &files, - ) - .unwrap(); -} - -#[test] -fn adding_private_observations_and_notes_preserves_every_reader_byte() { - fn document(private: bool) -> Document { - let mut doc = Doc::new("records", "Records").unwrap(); - let heading = doc.heading(2, "Review records").unwrap(); - doc.paragraph("Use the name to identify a record.").unwrap(); - if private { - let token = doc.observe("private-token", "PRIVATE SNAPSHOT").unwrap(); - let details = doc.text("private-resource", "PRIVATE BYTES").unwrap(); - doc.note_on(&heading, ("Internal: ", &token, " ", &details)) - .unwrap(); - doc.note_on(&token, "Keep this observation for contributor review.") - .unwrap(); - } - doc.finish().unwrap() - } - let ordinary = Document::render_many(&[document(false)], Audience::Reader).unwrap(); - let annotated = Document::render_many(&[document(true)], Audience::Reader).unwrap(); - assert_eq!(ordinary, annotated); - let contributor = Document::render_many(&[document(true)], Audience::Contributor).unwrap(); - let receipt = manifest(&contributor); - assert_eq!( - receipt["documents"][0]["observations"], - json!([{"id": "private-token", "value": "PRIVATE SNAPSHOT"}]) - ); - assert!( - String::from_utf8_lossy(&contributor["records.md"]) - .contains("Contributor note on `private-token`") - ); - assert_eq!( - contributor["assets/records/private-resource.txt"], - b"PRIVATE BYTES" - ); -} - -#[test] -fn observation_registration_order_and_nested_json_keys_are_canonical() { - struct OutOfOrder; - impl Serialize for OutOfOrder { - fn serialize(&self, serializer: S) -> Result { - use serde::ser::SerializeMap; - let mut map = serializer.serialize_map(Some(2))?; - map.serialize_entry("z", &json!([{"z": 2, "a": 1}]))?; - map.serialize_entry("a", &"first")?; - map.end() - } - } - fn document(reverse: bool) -> Document { - let mut doc = Doc::new("canonical", "Canonical observations").unwrap(); - let mut ids = ["alpha", "zeta"]; - if reverse { - ids.reverse(); - } - let mut observations = BTreeMap::new(); - for id in ids { - let observed = doc.observe(id, OutOfOrder).unwrap(); - assert_eq!( - observed.observed().to_string(), - r#"{"a":"first","z":[{"a":1,"z":2}]}"# - ); - observations.insert(id, observed); - } - for observed in observations.values() { - doc.paragraph(observed).unwrap(); - } - doc.finish().unwrap() - } - assert_eq!( - Document::render_many(&[document(false)], Audience::Reader).unwrap(), - Document::render_many(&[document(true)], Audience::Reader).unwrap() - ); -} - -#[test] -fn swallowed_observation_failures_and_failed_checks_still_prevent_completion() { - struct CannotSerialize; - impl Serialize for CannotSerialize { - fn serialize(&self, _: S) -> Result { - Err(S::Error::custom("snapshot failed")) - } - } - let mut failed = Doc::new("failed", "Failed").unwrap(); - let _ = failed.observe("snapshot", CannotSerialize); - failed - .paragraph("An explanation cannot hide a failed operation.") - .unwrap(); - assert!(failed.finish().unwrap_err().contains("snapshot failed")); - - let mut duplicate = Doc::new("duplicate", "Duplicate").unwrap(); - duplicate.observe("shared", 1).unwrap(); - let _ = duplicate.require("shared", true); - assert!(duplicate.finish().unwrap_err().contains("Duplicate")); - - let mut first = Doc::new("first", "First").unwrap(); - let observed = first.observe("value", 1).unwrap(); - let mut second = Doc::new("second", "Second").unwrap(); - second.observe("value", 1).unwrap(); - let _ = second.paragraph(&observed); - assert!( - second - .finish() - .unwrap_err() - .contains("Foreign document/run") - ); - - let mut check = Doc::new("check", "Failed check").unwrap(); - check.observe("value", 1).unwrap(); - let _ = check.expect_eq("incorrect", 1, 2); - assert!( - check - .finish() - .unwrap_err() - .contains("Check incorrect failed") - ); -} - -#[test] -fn malformed_observation_receipts_fail_before_writing_output() { - let mut doc = Doc::new("values", "Values").unwrap(); - let observed = doc.observe("response", 2).unwrap(); - doc.paragraph(&observed).unwrap(); - let original = render(doc, Audience::Reader); - let directory = tempfile::tempdir().unwrap(); - let parent = directory.path().canonicalize().unwrap(); - store::build(&parent.join("valid"), &original).unwrap(); - for (index, edit) in [ - (|receipt: &mut Value| { - receipt["documents"][0]["observations"] = Value::Null; - }) as fn(&mut Value), - |receipt| { - receipt["documents"][0]["observations"][0] - .as_object_mut() - .unwrap() - .remove("value"); - }, - |receipt| { - let duplicate = receipt["documents"][0]["observations"][0].clone(); - receipt["documents"][0]["observations"] - .as_array_mut() - .unwrap() - .push(duplicate); - }, - |receipt| { - receipt["documents"][0]["observations"][0]["id"] = "block-0001".into(); - }, - |receipt| { - receipt["documents"][0]["blocks"][0]["references"][0]["kind"] = "value".into(); - }, - ] - .into_iter() - .enumerate() - { - let mut candidate = original.clone(); - let mut receipt = manifest(&candidate); - edit(&mut receipt); - candidate.insert( - "manifest.json".into(), - serde_json::to_vec(&receipt).unwrap(), - ); - let output = parent.join(index.to_string()); - assert!(store::build(&output, &candidate).is_err()); - assert!(!output.exists()); - } -} diff --git a/crates/lgtmd/tests/prose.rs b/crates/lgtmd/tests/prose.rs deleted file mode 100644 index f859e35..0000000 --- a/crates/lgtmd/tests/prose.rs +++ /dev/null @@ -1,107 +0,0 @@ -use lgtmd::{Audience, BindingValue, Doc, Document, IntoProse, Prose, prose}; -use std::cell::Cell; - -#[test] -fn named_prose_preserves_typed_evidence_and_exact_text() { - fn document(named: bool) -> Document { - let mut doc = Doc::new("prose", "Prose").unwrap(); - let count = doc.expect_eq("count", 3, 3).unwrap(); - let key = doc - .binding("key", BindingValue::Keys(vec!["Space".into()])) - .unwrap(); - let parts = if named { - prose!( - "Press {key} for **{count}** items. Again: {count}. {{literal}}\n", - key = &key, - count = &count - ) - .unwrap() - } else { - prose![ - "Press ", - &key, - " for **", - &count, - "** items. Again: ", - &count, - ". {literal}\n" - ] - .into_prose() - }; - doc.markdown_parts(parts).unwrap(); - doc.finish().unwrap() - } - for audience in [Audience::Reader, Audience::Contributor] { - assert_eq!( - Document::render_many(&[document(true)], audience).unwrap(), - Document::render_many(&[document(false)], audience).unwrap(), - ); - } -} - -#[test] -fn named_expressions_run_once_even_when_referenced_twice() { - let evaluations = Cell::new(0); - let parts = prose!( - "{value} and {value}", - value = { - evaluations.set(evaluations.get() + 1); - "one" - } - ) - .unwrap(); - assert_eq!(evaluations.get(), 1); - let mut doc = Doc::new("once", "Once").unwrap(); - doc.require("evaluated-once", evaluations.get() == 1) - .unwrap(); - doc.paragraph(parts).unwrap(); - let text = doc - .finish() - .unwrap() - .render_fragment(Audience::Reader) - .unwrap(); - assert_eq!(text, "one and one\n\n"); -} - -#[test] -fn invalid_interpolation_fails_instead_of_dropping_evidence() { - for text in [ - "{missing}", - "{value", - "value}", - "{value:?}", - "no placeholders", - ] { - assert!(Prose::interpolate(text, [("value", "x".into_prose())]).is_err()); - } -} - -#[test] -fn duplicate_names_and_foreign_handles_remain_errors() { - assert!(Prose::interpolate("{x}", [("x", "a".into_prose()), ("x", "b".into_prose())]).is_err()); - let mut first = Doc::new("first", "First").unwrap(); - let value = first.expect_eq("value", 3, 3).unwrap(); - let mut second = Doc::new("second", "Second").unwrap(); - second.require("check", true).unwrap(); - assert!( - second - .paragraph(prose!("Observed {value}", value = &value).unwrap()) - .is_err() - ); - assert!(second.finish().is_err()); -} - -#[test] -fn private_interpolated_resources_stay_private() { - let mut doc = Doc::new("notes", "Notes").unwrap(); - doc.require("check", true).unwrap(); - doc.paragraph("Public explanation.").unwrap(); - let private = doc.text("private", "private bytes").unwrap(); - doc.note(prose!("Internal evidence: {private}", private = &private).unwrap()) - .unwrap(); - let document = doc.finish().unwrap(); - let reader = Document::render_many(std::slice::from_ref(&document), Audience::Reader).unwrap(); - assert!(!reader.contains_key("assets/notes/private.txt")); - let contributor = Document::render_many(&[document], Audience::Contributor).unwrap(); - assert_eq!(contributor["assets/notes/private.txt"], b"private bytes"); -} diff --git a/crates/lgtmd/tests/render_review.rs b/crates/lgtmd/tests/render_review.rs deleted file mode 100644 index 1831ffd..0000000 --- a/crates/lgtmd/tests/render_review.rs +++ /dev/null @@ -1,162 +0,0 @@ -use lgtmd::{Artifact, Audience, BindingValue, Doc, Document, ExportLayout}; -use pulldown_cmark::{Event, Parser, Tag, html}; - -fn doc() -> Doc { - let mut doc = Doc::new("review", "Renderer review").unwrap(); - doc.require("exercised", true).unwrap(); - doc -} - -fn render(doc: Doc) -> Result>, String> { - Document::render_many(&[doc.finish()?], Audience::Reader) -} - -#[test] -fn interpolated_delimiter_cannot_expose_authored_html() { - let mut doc = doc(); - let delimiter = doc.expect_eq("delimiter", "`", "`").unwrap(); - doc.markdown_parts(("`", &delimiter, "`")) - .unwrap(); - assert!(render(doc).unwrap_err().contains("Raw HTML")); -} - -#[test] -fn generated_html_has_no_permission_to_cover_adjacent_authored_tags() { - let mut doc = doc(); - let delimiter = doc.expect_eq("delimiter", "`", "`").unwrap(); - let code = doc - .binding("label", BindingValue::Code("safe".into())) - .unwrap(); - doc.markdown_parts(("`", &delimiter, "` ", &code)) - .unwrap(); - assert!(render(doc).unwrap_err().contains("Raw HTML")); -} - -#[test] -fn typed_html_cannot_silently_become_code_text() { - let mut doc = doc(); - let code = doc - .binding("label", BindingValue::Code("safe".into())) - .unwrap(); - doc.markdown_parts(("`", &code, "`\n")).unwrap(); - assert!(render(doc).unwrap_err().contains("swallowed")); -} - -#[test] -fn code_and_key_bindings_remain_literal_in_markdown_and_contributor_notes() { - let mut doc = doc(); - let label = doc - .binding( - "label", - BindingValue::Code("*literal* `tick` [x] & ".into()), - ) - .unwrap(); - let keys = doc - .binding( - "keys", - BindingValue::Keys(vec!["Command".into(), "`".into()]), - ) - .unwrap(); - doc.markdown_parts(("Choose ", &label, " with ", &keys, ".\n\n")) - .unwrap(); - doc.note(("Again ", &label, " with ", &keys)).unwrap(); - let files = Document::render_many(&[doc.finish().unwrap()], Audience::Contributor).unwrap(); - let page = std::str::from_utf8(&files["review.md"]).unwrap(); - let mut html = String::new(); - html::push_html(&mut html, Parser::new(page)); - assert!(html.contains("*literal* `tick` [x] & <y>")); - assert!(html.contains("Command + `")); - assert!(html.contains("Contributor note")); - assert!(!html.contains("literal")); -} - -#[test] -fn resource_destinations_encode_entities_and_unicode_without_renaming_files() { - let mut doc = doc(); - let path = "assets/a©-é;'!.txt"; - let resource = doc - .resource_at( - "result", - Artifact::new(b"data", "text/plain", "txt", "test", "v1").unwrap(), - path, - ) - .unwrap(); - doc.paragraph(resource.link("Download")).unwrap(); - doc.embed(&resource, "Embedded download").unwrap(); - let files = render(doc).unwrap(); - assert_eq!(files[path], b"data"); - let page = std::str::from_utf8(&files["review.md"]).unwrap(); - let destinations: Vec<_> = Parser::new(page) - .filter_map(|event| match event { - Event::Start(Tag::Link { dest_url, .. }) => Some(dest_url.into_string()), - _ => None, - }) - .collect(); - assert_eq!(destinations, ["assets/a%26copy%3B-%C3%A9%3B%27%21.txt"; 2]); -} - -#[test] -fn unclosed_fence_cannot_swallow_following_blocks_in_pages_or_fragments() { - for fence in ["```rust\nlet value = 3;", "~~~text\nunclosed"] { - let mut doc = doc(); - doc.markdown(fence).unwrap(); - doc.heading(2, "Must remain a heading").unwrap(); - let document = doc.finish().unwrap(); - let error = - Document::render_many(std::slice::from_ref(&document), Audience::Reader).unwrap_err(); - assert!(error.contains("boundary"), "{error}"); - assert!( - document - .render_fragment(Audience::Reader) - .unwrap_err() - .contains("boundary") - ); - } -} - -#[test] -fn closed_code_containing_html_and_backticks_keeps_following_anchors_active() { - let mut doc = doc(); - doc.markdown("````text\n\n```\n````") - .unwrap(); - let heading = doc.heading(2, "Still visible").unwrap(); - doc.paragraph(("Continue at ", &heading)).unwrap(); - let files = render(doc).unwrap(); - let mut html = String::new(); - html::push_html( - &mut html, - Parser::new(std::str::from_utf8(&files["review.md"]).unwrap()), - ); - assert!(html.contains("

Still visible

")); - assert!(html.contains("")); - assert!(html.contains("<img src=x onerror=alert(1)>")); - assert!(!html.contains(" (tempfile::TempDir, PathBuf) { - let temporary = tempfile::tempdir().unwrap(); - let path = temporary.path().canonicalize().unwrap(); - (temporary, path) -} - -fn guide() -> Vec { - let mut doc = Doc::new("guide", "Guide").unwrap(); - doc.require("completed", true).unwrap(); - doc.paragraph("Public explanation").unwrap(); - let secret = doc.text("secret", "private evidence").unwrap(); - doc.note(("Contributor attachment: ", &secret)).unwrap(); - vec![doc.finish().unwrap()] -} - -#[test] -fn missing_audience_aliases_fail_before_generation_in_either_order() { - for (reader, contributor) in [ - ("Manual", "manual"), - ("Manual", "manual/internal"), - ("Reader", "Reader."), - ("Reader", "Reader "), - ("LongDirectory", "LONGDI~1"), - ("caf\u{e9}", "cafe\u{301}"), - ("\u{c5}", "\u{e5}"), - ] { - for audiences in [ - [Audience::Reader, Audience::Contributor], - [Audience::Contributor, Audience::Reader], - ] { - let (_temporary, root) = root(); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, reader, "public") - .unwrap() - .output(Audience::Contributor, contributor, "internal") - .unwrap(); - let invoked = Cell::new(false); - let error = run(&config, RunMode::Update, &audiences, || { - invoked.set(true); - Ok(guide()) - }) - .unwrap_err(); - assert!(error.contains("overlap"), "{error}"); - assert!(!invoked.get()); - assert_eq!(fs::read_dir(&root).unwrap().count(), 0); - } - } -} - -#[test] -fn candidate_aliases_and_unselected_baseline_aliases_fail_without_writes() { - let (_temporary, root) = root(); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "Manual", "Site") - .unwrap() - .output(Audience::Contributor, "Private", "site") - .unwrap(); - for (destination, audiences) in [ - ("candidate", vec![Audience::Contributor, Audience::Reader]), - ("manual/candidate", vec![Audience::Reader]), - ("private/candidate", vec![Audience::Reader]), - ] { - let invoked = Cell::new(false); - let error = run( - &config, - RunMode::Build(destination.into()), - &audiences, - || { - invoked.set(true); - Ok(guide()) - }, - ) - .unwrap_err(); - assert!(error.contains("overlap"), "{error}"); - assert!(!invoked.get()); - assert_eq!(fs::read_dir(&root).unwrap().count(), 0); - } -} - -#[test] -fn existing_unicode_parents_and_ascii_discriminators_support_unicode_paths() { - let (_temporary, root) = root(); - let project = root.join("\u{51fa}\u{7248}\u{793e}"); - fs::create_dir(&project).unwrap(); - let config = RunnerConfig::new(&project) - .unwrap() - .output( - Audience::Reader, - "public/\u{516c}\u{958b}", - "public/\u{516c}\u{958b}", - ) - .unwrap() - .output( - Audience::Contributor, - "private/\u{5185}\u{90e8}", - "private/\u{5185}\u{90e8}", - ) - .unwrap(); - run( - &config, - RunMode::Update, - &[Audience::Contributor, Audience::Reader], - || Ok(guide()), - ) - .unwrap(); - assert!(project.join("public/\u{516c}\u{958b}/guide.md").is_file()); - assert!( - !project - .join("public/\u{516c}\u{958b}/assets/guide/secret.txt") - .exists() - ); - assert!( - project - .join("private/\u{5185}\u{90e8}/assets/guide/secret.txt") - .is_file() - ); -} - -#[test] -fn precreated_distinct_directories_prove_separation_without_ascii_names() { - let (_temporary, root) = root(); - let reader = root.join("\u{516c}\u{958b}"); - let contributor = root.join("\u{5185}\u{90e8}"); - fs::create_dir(&reader).unwrap(); - fs::create_dir(&contributor).unwrap(); - assert!(!same_file::is_same_file(&reader, &contributor).unwrap()); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, reader.join("output"), "public") - .unwrap() - .output(Audience::Contributor, contributor.join("output"), "private") - .unwrap(); - run( - &config, - RunMode::Update, - &[Audience::Contributor, Audience::Reader], - || Ok(guide()), - ) - .unwrap(); - assert!(reader.join("output/guide.md").is_file()); - assert!(!reader.join("output/assets/guide/secret.txt").exists()); - assert!(contributor.join("output/assets/guide/secret.txt").is_file()); -} - -#[test] -fn distinct_existing_ancestor_and_descendant_can_own_separate_missing_branches() { - let (_temporary, root) = root(); - fs::create_dir(root.join("existing")).unwrap(); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "public", "site") - .unwrap() - .output(Audience::Contributor, "existing/private", "review") - .unwrap(); - run( - &config, - RunMode::Update, - &[Audience::Reader, Audience::Contributor], - || Ok(guide()), - ) - .unwrap(); - assert!(root.join("public/guide.md").is_file()); - assert!(root.join("existing/private/guide.md").is_file()); -} - -#[test] -fn existing_case_distinctions_follow_filesystem_identity() { - let (_temporary, root) = root(); - let upper = root.join("Existing"); - let lower = root.join("existing"); - fs::create_dir(&upper).unwrap(); - let aliases = lower.exists(); - if !aliases { - fs::create_dir(&lower).unwrap(); - } - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, upper.join("Manual"), "site") - .unwrap() - .output(Audience::Contributor, lower.join("manual"), "review") - .unwrap(); - let invoked = Cell::new(false); - let result = run( - &config, - RunMode::Update, - &[Audience::Contributor, Audience::Reader], - || { - invoked.set(true); - Ok(guide()) - }, - ); - if aliases { - assert!(result.unwrap_err().contains("overlap")); - assert!(!invoked.get()); - assert!(!upper.join("Manual").exists()); - } else { - result.unwrap(); - assert!(invoked.get()); - assert!(upper.join("Manual/guide.md").is_file()); - assert!(!upper.join("Manual/assets/guide/secret.txt").exists()); - assert!(lower.join("manual/assets/guide/secret.txt").is_file()); - } -} diff --git a/crates/lgtmd/tests/runner_config.rs b/crates/lgtmd/tests/runner_config.rs deleted file mode 100644 index 312251d..0000000 --- a/crates/lgtmd/tests/runner_config.rs +++ /dev/null @@ -1,343 +0,0 @@ -use lgtmd::{ - Audience, Doc, Document, ExportLayout, - runner::{RunMode, RunnerConfig, run, run_cli_with}, -}; -use std::{cell::Cell, fs, path::Path}; - -fn guide() -> Vec { - let mut doc = Doc::new("setup", "Setup").unwrap(); - let value = doc - .expect_eq("trimmed", " ready ".trim(), "ready") - .unwrap(); - doc.paragraph(("Status: ", &value)).unwrap(); - let private = doc.text("internal", "not for readers").unwrap(); - doc.note(("Review ", &private)).unwrap(); - vec![doc.finish().unwrap()] -} - -fn root() -> (tempfile::TempDir, std::path::PathBuf) { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().canonicalize().unwrap(); - (temp, path) -} - -#[test] -fn configured_roots_and_nested_exports_work_without_project_discovery() { - let (_temp, root) = root(); - let project = root.join("application"); - fs::create_dir(&project).unwrap(); - fs::write(project.join("owned-by-adopter"), "preserve").unwrap(); - let layout = ExportLayout::default() - .page("setup", "handbook/start/index.md") - .unwrap() - .resources_under("evidence") - .unwrap(); - let config = RunnerConfig::new(&project) - .unwrap() - .output(Audience::Reader, "published-manual", "site") - .unwrap() - .output( - Audience::Contributor, - root.join("private-review"), - "review/internal", - ) - .unwrap() - .layout(layout); - let calls = Cell::new(0); - let generate = || { - calls.set(calls.get() + 1); - Ok(guide()) - }; - run_cli_with(["update"].map(str::to_owned), &config, generate).unwrap(); - assert_eq!( - calls.get(), - 1, - "both configured defaults reuse one execution" - ); - run( - &config, - RunMode::Check, - &[Audience::Reader, Audience::Contributor], - generate, - ) - .unwrap(); - run_cli_with( - ["build", "--out", "review-candidate"].map(str::to_owned), - &config, - generate, - ) - .unwrap(); - assert_eq!(calls.get(), 3); - for directory in [ - project.join("published-manual"), - project.join("review-candidate/site"), - ] { - assert!(directory.join("handbook/start/index.md").is_file()); - assert!(!directory.join("evidence/setup/internal.txt").exists()); - assert!( - !fs::read_to_string(directory.join("manifest.json")) - .unwrap() - .contains("internal") - ); - } - assert!( - root.join("private-review/evidence/setup/internal.txt") - .is_file() - ); - assert!( - project - .join("review-candidate/review/internal/evidence/setup/internal.txt") - .is_file() - ); - assert_eq!( - fs::read_to_string(project.join("owned-by-adopter")).unwrap(), - "preserve" - ); - assert!(!project.join("reader").exists()); - assert!(!project.join("contributor").exists()); -} - -#[test] -fn single_audience_can_publish_directly_at_a_build_root() { - let (_temp, root) = root(); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Contributor, "retained", "") - .unwrap(); - run_cli_with( - ["build", "--out", "candidate"].map(str::to_owned), - &config, - || Ok(guide()), - ) - .unwrap(); - assert!(root.join("candidate/setup.md").is_file()); - assert!(!root.join("candidate/contributor").exists()); -} - -#[test] -fn filesystem_root_is_a_valid_base_but_never_an_output() { - let (_temp, root) = root(); - let filesystem_root = root.ancestors().last().unwrap(); - let config = RunnerConfig::new(filesystem_root) - .unwrap() - .output(Audience::Reader, root.join("retained"), "") - .unwrap(); - assert_eq!(config.base_dir(), filesystem_root); - run( - &config, - RunMode::Build(root.join("candidate")), - &[Audience::Reader], - || Ok(guide()), - ) - .unwrap(); - let invoked = Cell::new(false); - assert!( - run( - &config, - RunMode::Build(filesystem_root.to_owned()), - &[Audience::Reader], - || { - invoked.set(true); - Ok(guide()) - } - ) - .is_err() - ); - assert!(!invoked.get()); -} - -#[test] -fn invalid_destination_selection_fails_before_user_generation() { - let (_temp, root) = root(); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "retained", "") - .unwrap() - .output(Audience::Contributor, "private", "review") - .unwrap(); - let invoked = Cell::new(false); - let generate = || { - invoked.set(true); - Ok(guide()) - }; - for (mode, audiences) in [ - ( - RunMode::Build("candidate".into()), - vec![Audience::Reader, Audience::Contributor], - ), - (RunMode::Check, vec![Audience::Reader, Audience::Reader]), - (RunMode::Check, vec![]), - ( - RunMode::Build("private/candidate".into()), - vec![Audience::Reader], - ), - (RunMode::Build("../outside".into()), vec![Audience::Reader]), - ] { - assert!(run(&config, mode, &audiences, generate).is_err()); - assert!(!invoked.get()); - } - assert!(!root.join("candidate").exists()); - let overlapping = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "retained", "site") - .unwrap() - .output(Audience::Contributor, "retained/private", "review") - .unwrap(); - assert!(run(&overlapping, RunMode::Update, &[Audience::Reader], generate).is_err()); - assert!(!invoked.get()); - assert!(!root.join("retained").exists()); -} - -#[test] -fn invalid_candidate_directories_and_duplicate_configurations_reject() { - let (_temp, root) = root(); - for path in ["../outside", "/absolute", "a/../b", "./current"] { - assert!( - RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "retained", Path::new(path)) - .is_err() - ); - } - assert!( - RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "one", "one") - .unwrap() - .output(Audience::Reader, "two", "two") - .is_err() - ); -} - -#[test] -fn explicit_default_selection_does_not_publish_other_views() { - let (_temp, root) = root(); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "retained", "site") - .unwrap() - .output(Audience::Contributor, "private", "review") - .unwrap() - .default_audiences([Audience::Reader]); - run_cli_with(["update"].map(str::to_owned), &config, || Ok(guide())).unwrap(); - assert!(root.join("retained/setup.md").exists()); - assert!(!root.join("private").exists()); - let missing = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "retained", "site") - .unwrap(); - let invoked = Cell::new(false); - assert!( - run_cli_with( - ["check", "--audience", "contributor"].map(str::to_owned), - &missing, - || { - invoked.set(true); - Ok(guide()) - } - ) - .is_err() - ); - assert!(!invoked.get()); -} - -#[test] -fn builds_cannot_occupy_retained_transaction_paths() { - let (_temporary, root) = root(); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "manual", "") - .unwrap(); - run( - &config, - RunMode::Update, - &[Audience::Reader], - || Ok(guide()), - ) - .unwrap(); - for destination in [ - ".manual.lock", - ".manual.staging", - ".manual.backup", - ".manual.staging/review", - ".MANUAL.backup", - ".MANUAL.backup/review", - ] { - let called = Cell::new(false); - let error = run( - &config, - RunMode::Build(destination.into()), - &[Audience::Reader], - || { - called.set(true); - Ok(guide()) - }, - ) - .unwrap_err(); - assert!(error.contains("overlap"), "{destination}: {error}"); - assert!(!called.get(), "{destination}"); - assert!(!root.join(destination).exists(), "{destination}"); - run(&config, RunMode::Check, &[Audience::Reader], || Ok(guide())).unwrap(); - } -} - -#[test] -fn configured_audiences_cannot_occupy_each_others_transaction_paths() { - for reserved in [ - ".manual.lock", - ".manual.staging", - ".manual.backup", - ".manual.backup/review", - ] { - for (reader, contributor) in [("manual", reserved), (reserved, "manual")] { - let (_temporary, root) = root(); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, reader, "public") - .unwrap() - .output(Audience::Contributor, contributor, "internal") - .unwrap(); - let called = Cell::new(false); - let error = run(&config, RunMode::Update, &[Audience::Reader], || { - called.set(true); - Ok(guide()) - }) - .unwrap_err(); - assert!(error.contains("overlap"), "{error}"); - assert!(!called.get()); - assert_eq!(fs::read_dir(&root).unwrap().count(), 0); - } - } -} - -#[test] -fn candidate_audience_paths_reserve_their_own_transaction_paths() { - for reserved in [ - ".public.lock", - ".public.staging", - ".public.backup", - ".public.backup/review", - ] { - let (_temporary, root) = root(); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "manual", "public") - .unwrap() - .output(Audience::Contributor, "private", reserved) - .unwrap(); - let called = Cell::new(false); - let error = run( - &config, - RunMode::Build("candidate".into()), - &[Audience::Reader, Audience::Contributor], - || { - called.set(true); - Ok(guide()) - }, - ) - .unwrap_err(); - assert!(error.contains("overlap"), "{error}"); - assert!(!called.get()); - assert_eq!(fs::read_dir(&root).unwrap().count(), 0); - } -} diff --git a/crates/lgtmd/tests/section_review.rs b/crates/lgtmd/tests/section_review.rs deleted file mode 100644 index d2a83df..0000000 --- a/crates/lgtmd/tests/section_review.rs +++ /dev/null @@ -1,86 +0,0 @@ -use lgtmd::{Audience, Doc, Document}; -use serde_json::Value; - -fn doc() -> Doc { - let mut doc = Doc::new("sections", "Sections").unwrap(); - doc.require("executed", true).unwrap(); - doc -} - -#[test] -fn named_section_links_and_notes_keep_their_target_after_insertions() { - for prefix in [false, true] { - let mut doc = doc(); - if prefix { - doc.paragraph("An unrelated paragraph inserted later.") - .unwrap(); - } - let section = doc.heading_with_id("workflow", 2, "Workflow").unwrap(); - assert_eq!(section.id(), "workflow"); - doc.paragraph(section.link("Read the workflow")).unwrap(); - doc.note_on(§ion, "A contributor decision attached to this section.") - .unwrap(); - let document = doc.finish().unwrap(); - for audience in [Audience::Reader, Audience::Contributor] { - let files = Document::render_many(std::slice::from_ref(&document), audience).unwrap(); - let page = std::str::from_utf8(&files["sections.md"]).unwrap(); - assert!(page.contains("\n\n## Workflow")); - assert!(page.contains("[Read the workflow](#workflow)")); - assert_eq!( - page.contains("Contributor note on [workflow](#workflow)"), - audience == Audience::Contributor - ); - let manifest: Value = serde_json::from_slice(&files["manifest.json"]).unwrap(); - let blocks = manifest["documents"][0]["blocks"].as_array().unwrap(); - assert!(blocks.iter().any(|block| block["id"] == "workflow")); - assert!(blocks.iter().any(|block| { - block["references"] - .as_array() - .unwrap() - .iter() - .any(|reference| reference["id"] == "workflow" && reference["kind"] == "block") - })); - } - } -} - -#[test] -fn explicit_section_ids_share_evidence_namespace_and_fail_closed() { - for id in ["executed", "Invalid ID"] { - let mut doc = doc(); - assert!(doc.heading_with_id(id, 2, "Invalid").is_err()); - assert!(doc.finish().unwrap_err().contains("tainted")); - } - let mut duplicate = doc(); - duplicate.heading_with_id("workflow", 2, "First").unwrap(); - assert!(duplicate.heading_with_id("workflow", 2, "Second").is_err()); - assert!(duplicate.finish().unwrap_err().contains("tainted")); - - let mut automatic_collision = doc(); - automatic_collision - .heading_with_id("block-0002", 2, "Explicit") - .unwrap(); - assert!(automatic_collision.heading(2, "Automatic").is_err()); - assert!( - automatic_collision - .finish() - .unwrap_err() - .contains("tainted") - ); -} - -#[test] -fn labeled_links_cannot_leak_notes_or_cross_document_ownership() { - let mut private = doc(); - let note = private.note("Private detail").unwrap(); - private - .paragraph(note.link("Read the hidden note")) - .unwrap(); - assert!(private.finish().unwrap_err().contains("excluded block")); - - let mut source = doc(); - let section = source.heading_with_id("workflow", 2, "Workflow").unwrap(); - let mut foreign = doc(); - assert!(foreign.paragraph(section.link("Wrong owner")).is_err()); - assert!(foreign.finish().unwrap_err().contains("tainted")); -} diff --git a/crates/lgtmd/tests/store.rs b/crates/lgtmd/tests/store.rs deleted file mode 100644 index e80c817..0000000 --- a/crates/lgtmd/tests/store.rs +++ /dev/null @@ -1,903 +0,0 @@ -use lgtmd::{Artifact, Audience, Doc, Document, runner::run_cli, store}; -use std::{ - collections::BTreeMap, - fs, - path::{Path, PathBuf}, -}; - -fn directory() -> (tempfile::TempDir, PathBuf) { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().canonicalize().unwrap(); - (temp, root) -} - -fn files(audience: &str, profile: &str, text: &str) -> store::Files { - let mut doc = Doc::new("example", "Example").unwrap(); - doc.require("executed", true).unwrap(); - let resource = doc - .resource_at( - "content", - Artifact::new(text.as_bytes(), "text/plain", "md", "text", profile).unwrap(), - "pages/example.md", - ) - .unwrap(); - doc.code(&resource, "text").unwrap(); - Document::render_many( - &[doc.finish().unwrap()], - if audience == "reader" { - Audience::Reader - } else { - Audience::Contributor - }, - ) - .unwrap() -} - -fn without_content() -> store::Files { - let mut doc = Doc::new("example", "Example").unwrap(); - doc.require("executed", true).unwrap(); - doc.paragraph("Content has been retired.").unwrap(); - Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap() -} - -#[test] -fn retained_receipts_accept_unknown_fields_and_null_or_omitted_check_payloads() { - let (_temporary, parent) = directory(); - let root = parent.join("reader"); - let mut doc = Doc::new("compatibility", "Receipt compatibility").unwrap(); - let observation = doc.expect_eq("nothing", Option::::None, None).unwrap(); - doc.require("private-check", true).unwrap(); - let label = doc - .binding("label", lgtmd::BindingValue::Code("result".into())) - .unwrap(); - doc.paragraph(("The ", label, " is ", observation)).unwrap(); - let resource = doc - .resource( - "download", - Artifact::new(b"result", "text/plain", "txt", "text", "v1").unwrap(), - ) - .unwrap(); - doc.embed(&resource, "Download the result").unwrap(); - let candidate = Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap(); - let mut retained = candidate.clone(); - let mut receipt: serde_json::Value = - serde_json::from_slice(&retained["manifest.json"]).unwrap(); - let checks = receipt["documents"][0]["checks"].as_array().unwrap(); - let nothing = checks - .iter() - .find(|check| check["id"] == "nothing") - .unwrap(); - assert_eq!(nothing.get("actual"), Some(&serde_json::Value::Null)); - assert_eq!(nothing.get("expected"), Some(&serde_json::Value::Null)); - let private = checks - .iter() - .find(|check| check["id"] == "private-check") - .unwrap(); - assert!(private.get("actual").is_none() && private.get("expected").is_none()); - // Additional record fields have always been tolerated. A typed internal - // reader must not make an otherwise valid retained baseline incompatible. - receipt["extension"] = serde_json::json!({"owner": "another consumer"}); - for document in receipt["documents"].as_array_mut().unwrap() { - document["extension"] = serde_json::json!(1); - for check in document["checks"].as_array_mut().unwrap() { - check["extension"] = serde_json::json!(null); - } - for block in document["blocks"].as_array_mut().unwrap() { - block["extension"] = serde_json::json!(false); - for reference in block["references"].as_array_mut().unwrap() { - reference["extension"] = serde_json::json!([]); - } - } - for binding in document["bindings"].as_object_mut().unwrap().values_mut() { - binding["extension"] = serde_json::json!({}); - } - } - for key in ["profiles", "artifacts"] { - for record in receipt[key].as_array_mut().unwrap() { - record["extension"] = serde_json::json!("retained"); - } - } - retained.insert( - "manifest.json".into(), - serde_json::to_vec(&receipt).unwrap(), - ); - store::build(&root, &retained).unwrap(); - store::check(&root, &retained).unwrap(); - store::update(&root, &candidate).unwrap(); - store::check(&root, &candidate).unwrap(); -} - -fn snapshot(root: &Path) -> BTreeMap> { - fn walk(base: &Path, dir: &Path, files: &mut BTreeMap>) { - for entry in fs::read_dir(dir).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - walk(base, &path, files); - } else { - files.insert( - path.strip_prefix(base).unwrap().to_str().unwrap().into(), - fs::read(path).unwrap(), - ); - } - } - } - let mut files = BTreeMap::new(); - walk(root, root, &mut files); - files -} - -#[test] -fn check_is_read_only_and_reports_changed_missing_and_extra_files() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let expected = files("reader", "v1", "original"); - store::update(&root, &expected).unwrap(); - let saved = snapshot(&root); - store::check(&root, &expected).unwrap(); - assert_eq!(snapshot(&root), saved); - let changed = files("reader", "v1", "changed"); - assert!( - store::check(&root, &changed) - .unwrap_err() - .contains("changed pages/example.md") - ); - let fewer = without_content(); - assert!( - store::check(&root, &fewer) - .unwrap_err() - .contains("extra pages/example.md") - ); - fs::remove_file(root.join("pages/example.md")).unwrap(); - assert!( - store::check(&root, &expected) - .unwrap_err() - .contains("missing pages/example.md") - ); - assert!(!parent.join(".reader.lock").exists()); -} - -#[test] -fn intended_updates_work_but_retired_files_require_explicit_removal() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let original = files("reader", "v1", "old"); - store::update(&root, &original).unwrap(); - let mut replacement = files("reader", "v1", "new"); - store::update(&root, &replacement).unwrap(); - store::check(&root, &replacement).unwrap(); - replacement = without_content(); - let saved = snapshot(&root); - assert!( - store::update(&root, &replacement) - .unwrap_err() - .contains("explicit removal") - ); - assert_eq!(snapshot(&root), saved); - fs::remove_file(root.join("pages/example.md")).unwrap(); - store::update(&root, &replacement).unwrap(); - store::check(&root, &replacement).unwrap(); -} - -#[test] -fn incompatible_profiles_audiences_and_schemas_cannot_be_blessed() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let original = files("reader", "v1", "old"); - store::update(&root, &original).unwrap(); - let saved = snapshot(&root); - for (candidate, message) in [ - (files("reader", "v2", "old"), "profiles"), - (files("contributor", "v1", "old"), "audience"), - ] { - assert!( - store::update(&root, &candidate) - .unwrap_err() - .contains(message) - ); - assert!( - store::check(&root, &candidate) - .unwrap_err() - .contains(message) - ); - assert_eq!(snapshot(&root), saved); - } - let mut candidate = original.clone(); - candidate.insert( - "manifest.json".into(), - br#"{"schema_version":99,"audience":"reader","profiles":[],"artifacts":[]}"#.to_vec(), - ); - assert!( - store::update(&root, &candidate) - .unwrap_err() - .contains("schema") - ); - assert_eq!(snapshot(&root), saved); -} - -fn resource_files(resources: &[(&str, &str, &str)]) -> store::Files { - let mut doc = Doc::new("example", "Example").unwrap(); - doc.require("executed", true).unwrap(); - for (id, producer, profile) in resources { - let resource = doc - .resource_at( - id, - Artifact::new( - id.as_bytes(), - "application/octet-stream", - "bin", - producer, - profile, - ) - .unwrap(), - &format!("assets/{id}.bin"), - ) - .unwrap(); - doc.paragraph(resource.link(id)).unwrap(); - } - Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap() -} - -#[test] -fn adding_and_explicitly_retiring_producers_does_not_change_retained_compatibility() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let first = resource_files(&[("config", "json", "v1")]); - store::update(&root, &first).unwrap(); - let expanded = resource_files(&[("config", "json", "v1"), ("preview", "renderer", "metal")]); - store::update(&root, &expanded).unwrap(); - store::check(&root, &expanded).unwrap(); - let retired = resource_files(&[("preview", "renderer", "metal")]); - assert!( - store::update(&root, &retired) - .unwrap_err() - .contains("explicit removal") - ); - fs::remove_file(root.join("assets/config.bin")).unwrap(); - store::update(&root, &retired).unwrap(); - store::check(&root, &retired).unwrap(); -} - -#[test] -fn retained_resources_cannot_swap_profiles_or_producers() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let first = resource_files(&[("a", "renderer", "metal"), ("b", "renderer", "vulkan")]); - store::update(&root, &first).unwrap(); - let saved = snapshot(&root); - // The aggregate profile set is unchanged, but each retained resource differs. - let swapped = resource_files(&[("a", "renderer", "vulkan"), ("b", "renderer", "metal")]); - assert!( - store::update(&root, &swapped) - .unwrap_err() - .contains("profiles for example/a") - ); - assert_eq!(snapshot(&root), saved); - let producer_change = resource_files(&[ - ("a", "different-renderer", "metal"), - ("b", "renderer", "vulkan"), - ]); - assert!( - store::update(&root, &producer_change) - .unwrap_err() - .contains("profiles for example/a") - ); - assert_eq!(snapshot(&root), saved); -} - -#[test] -fn unsafe_or_conflicting_paths_fail_before_creating_output() { - let (_temp, parent) = directory(); - for unsafe_path in [ - "../escape", - "/absolute", - "a//b", - "a/./b", - "a\\b", - ".ownership.json", - ] { - let root = parent.join("reader"); - let mut candidate = files("reader", "v1", "content"); - candidate.insert(unsafe_path.into(), b"unsafe".to_vec()); - assert!( - store::update(&root, &candidate) - .unwrap_err() - .contains("path") - ); - assert!(!root.exists()); - } - let mut candidate = files("reader", "v1", "content"); - candidate.insert("pages".into(), b"conflict".to_vec()); - assert!( - store::update(&parent.join("reader"), &candidate) - .unwrap_err() - .contains("conflicts") - ); -} - -#[test] -fn unowned_files_and_directories_are_preserved() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let expected = files("reader", "v1", "content"); - fs::create_dir(&root).unwrap(); - fs::write(root.join("personal.txt"), "keep me").unwrap(); - assert!( - store::update(&root, &expected) - .unwrap_err() - .contains("ownership") - ); - assert_eq!( - fs::read_to_string(root.join("personal.txt")).unwrap(), - "keep me" - ); - fs::remove_file(root.join("personal.txt")).unwrap(); - fs::remove_dir(&root).unwrap(); - store::update(&root, &expected).unwrap(); - fs::write(root.join("personal.txt"), "keep me").unwrap(); - assert!( - store::update(&root, &expected) - .unwrap_err() - .contains("Unowned") - ); - fs::remove_file(root.join("personal.txt")).unwrap(); - fs::create_dir(root.join("personal")).unwrap(); - assert!( - store::update(&root, &expected) - .unwrap_err() - .contains("Unowned output directory") - ); - assert!(root.join("personal").is_dir()); -} - -#[test] -fn duplicate_ownership_entries_are_rejected() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let expected = files("reader", "v1", "content"); - store::update(&root, &expected).unwrap(); - fs::write( - root.join(".ownership.json"), - br#"{"schema_version":1,"files":["manifest.json","manifest.json"]}"#, - ) - .unwrap(); - assert!( - store::check(&root, &expected) - .unwrap_err() - .contains("Duplicate owned") - ); -} - -#[test] -fn phantom_ownership_entries_cannot_be_checked_updated_or_recovered() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let expected = files("reader", "v1", "content"); - store::build(&root, &expected).unwrap(); - let marker = root.join(".ownership.json"); - let mut ownership: serde_json::Value = - serde_json::from_slice(&fs::read(&marker).unwrap()).unwrap(); - ownership["files"] - .as_array_mut() - .unwrap() - .push("never-generated.txt".into()); - fs::write(&marker, serde_json::to_vec_pretty(&ownership).unwrap()).unwrap(); - let damaged = snapshot(&root); - - for result in [ - store::check(&root, &expected), - store::update(&root, &expected), - ] { - let error = result.unwrap_err(); - assert!(error.contains("Ownership inventory"), "{error}"); - assert!(error.contains("never-generated.txt"), "{error}"); - assert_eq!(snapshot(&root), damaged); - assert_eq!(fs::read_dir(&parent).unwrap().count(), 1); - } - - let backup = parent.join(".reader.backup"); - fs::rename(&root, &backup).unwrap(); - assert!( - store::recover_backup(&root) - .unwrap_err() - .contains("missing never-generated.txt") - ); - assert_eq!(snapshot(&backup), damaged); - assert!(!root.exists()); -} - -#[test] -fn retirement_requires_agreement_between_manifest_and_ownership_inventory() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let expected = files("reader", "v1", "content"); - store::build(&root, &expected).unwrap(); - fs::remove_file(root.join("pages/example.md")).unwrap(); - - // Deleting a still-required resource cannot be accepted as retirement. - let damaged = snapshot(&root); - assert!( - store::update(&root, &expected) - .unwrap_err() - .contains("missing pages/example.md") - ); - assert_eq!(snapshot(&root), damaged); - - // Even genuine retirement must not conceal a contradictory ownership record. - let marker = root.join(".ownership.json"); - let mut ownership: serde_json::Value = - serde_json::from_slice(&fs::read(&marker).unwrap()).unwrap(); - ownership["files"] - .as_array_mut() - .unwrap() - .retain(|name| name != "pages/example.md"); - fs::write(&marker, serde_json::to_vec_pretty(&ownership).unwrap()).unwrap(); - fs::remove_dir(root.join("pages")).unwrap(); - let damaged = snapshot(&root); - assert!( - store::update(&root, &without_content()) - .unwrap_err() - .contains("Ownership inventory differs") - ); - assert_eq!(snapshot(&root), damaged); - assert_eq!(fs::read_dir(&parent).unwrap().count(), 1); -} - -#[test] -fn lock_conflicts_never_remove_someone_elses_lock() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let expected = files("reader", "v1", "content"); - store::update(&root, &expected).unwrap(); - let lock = parent.join(".reader.lock"); - fs::write(&lock, "other writer").unwrap(); - assert!( - store::update(&root, &expected) - .unwrap_err() - .contains("lock") - ); - assert!(store::check(&root, &expected).unwrap_err().contains("lock")); - assert_eq!(fs::read_to_string(&lock).unwrap(), "other writer"); -} - -#[test] -fn interrupted_transaction_material_is_not_discarded() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let expected = files("reader", "v1", "content"); - store::update(&root, &expected).unwrap(); - for suffix in ["staging", "backup"] { - let retained = parent.join(format!(".reader.{suffix}")); - fs::create_dir(&retained).unwrap(); - fs::write(retained.join("evidence.txt"), "inspect first").unwrap(); - assert!( - store::update(&root, &expected) - .unwrap_err() - .contains("Interrupted transaction") - ); - assert!( - store::check(&root, &expected) - .unwrap_err() - .contains("Interrupted transaction") - ); - assert_eq!( - fs::read_to_string(retained.join("evidence.txt")).unwrap(), - "inspect first" - ); - fs::remove_dir_all(retained).unwrap(); - } -} - -#[test] -fn explicit_recovery_restores_only_a_complete_backup_to_an_absent_destination() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let expected = files("reader", "v1", "content"); - store::update(&root, &expected).unwrap(); - assert!( - store::recover_backup(&root) - .unwrap_err() - .contains("destination exists") - ); - let backup = parent.join(".reader.backup"); - fs::rename(&root, &backup).unwrap(); - store::recover_backup(&root).unwrap(); - store::check(&root, &expected).unwrap(); - fs::rename(&root, &backup).unwrap(); - fs::remove_file(backup.join("pages/example.md")).unwrap(); - assert!( - store::recover_backup(&root) - .unwrap_err() - .contains("missing pages/example.md") - ); - assert!(backup.exists()); - assert!(!root.exists()); -} - -#[test] -fn builds_require_new_destination_and_preserve_existing_candidates() { - let (_temp, parent) = directory(); - let root = parent.join("candidate"); - let expected = files("reader", "v1", "content"); - store::build(&root, &expected).unwrap(); - let saved = snapshot(&root); - assert!( - store::build(&root, &expected) - .unwrap_err() - .contains("already exists") - ); - assert_eq!(snapshot(&root), saved); -} - -#[cfg(unix)] -#[test] -fn symlink_destinations_ancestors_resources_and_transaction_paths_are_rejected() { - use std::os::unix::fs::symlink; - let (_temp, parent) = directory(); - let expected = files("reader", "v1", "content"); - let protected = parent.join("protected"); - fs::create_dir(&protected).unwrap(); - fs::write(protected.join("keep"), "untouched").unwrap(); - let alias = parent.join("alias"); - symlink(&protected, &alias).unwrap(); - assert!( - store::update(&alias.join("reader"), &expected) - .unwrap_err() - .contains("Symlink") - ); - assert!( - store::update(&alias, &expected) - .unwrap_err() - .contains("Symlink") - ); - for suffix in ["lock", "staging", "backup"] { - let link = parent.join(format!(".reader.{suffix}")); - symlink(&protected, &link).unwrap(); - assert!( - store::update(&parent.join("reader"), &expected) - .unwrap_err() - .contains("Symlink") - ); - assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); - fs::remove_file(link).unwrap(); - } - let root = parent.join("reader"); - store::update(&root, &expected).unwrap(); - fs::remove_file(root.join("pages/example.md")).unwrap(); - symlink(protected.join("keep"), root.join("pages/example.md")).unwrap(); - assert!( - store::update(&root, &expected) - .unwrap_err() - .contains("symlink") - ); - assert_eq!( - fs::read_to_string(protected.join("keep")).unwrap(), - "untouched" - ); -} - -#[test] -fn generation_errors_and_panics_never_publish_any_requested_audience() { - let (_temp, parent) = directory(); - let baseline = parent.join("baseline"); - let expected = files("reader", "v1", "original"); - store::update(&baseline.join("reader"), &expected).unwrap(); - let saved = snapshot(&baseline); - let args = || ["update", "--audience", "both"].map(str::to_owned); - assert!( - run_cli(args(), &baseline, || Err("failed assertion".into())) - .unwrap_err() - .contains("failed assertion") - ); - assert_eq!(snapshot(&baseline), saved); - assert!( - run_cli(args(), &baseline, || panic!("broken generator")) - .unwrap_err() - .contains("panicked") - ); - assert_eq!(snapshot(&baseline), saved); - assert!(!baseline.join("contributor").exists()); -} - -#[test] -fn invalid_cli_or_overlapping_build_paths_fail_before_execution() { - let (_temp, parent) = directory(); - let baseline = parent.join("baseline"); - for args in [ - vec!["unknown"], - vec!["check", "--out", "somewhere"], - vec!["build"], - vec!["update", "--audience", "unknown"], - ] { - let invoked = std::cell::Cell::new(false); - assert!( - run_cli(args.into_iter().map(str::to_owned), &baseline, || { - invoked.set(true); - Err("must not execute".into()) - }) - .is_err() - ); - assert!(!invoked.get()); - } - for output in [&baseline, &baseline.join("candidate"), &parent] { - let invoked = std::cell::Cell::new(false); - let error = run_cli( - vec![ - "build".into(), - "--out".into(), - output.to_str().unwrap().into(), - ], - &baseline, - || { - invoked.set(true); - Err("must not execute".into()) - }, - ) - .unwrap_err(); - assert!(error.contains("overlap")); - assert!(!invoked.get()); - } -} - -#[test] -fn a_case_alias_of_existing_baseline_fails_before_generation() { - let (_temp, parent) = directory(); - let baseline = parent.join("Baseline"); - let alias = parent.join("baseline"); - let expected = files("reader", "v1", "retained bytes"); - store::update(&baseline.join("reader"), &expected).unwrap(); - if !alias.exists() { - // The distinct-directory behavior on case-sensitive hosts is covered by - // the lifecycle test; there is no case alias to exercise in this runner. - return; - } - assert!(same_file::is_same_file(&baseline, &alias).unwrap()); - let saved = snapshot(&baseline); - let output = alias.join("candidate"); - let invoked = std::cell::Cell::new(false); - let error = run_cli( - [ - "build".into(), - "--out".into(), - output.to_str().unwrap().into(), - ], - &baseline, - || { - invoked.set(true); - Ok(vec![document()]) - }, - ) - .unwrap_err(); - assert!(error.contains("overlap")); - assert!(!invoked.get(), "preflight must precede generation"); - assert!(!output.exists()); - assert_eq!(snapshot(&baseline), saved); -} - -fn document() -> lgtmd::Document { - let mut doc = lgtmd::Doc::new("example", "Example").unwrap(); - let value = doc.expect_eq("value", 8080, 8080).unwrap(); - doc.paragraph(("Port ", &value, ".")).unwrap(); - doc.note("Contributor-only explanation").unwrap(); - doc.finish().unwrap() -} - -#[test] -fn both_views_execute_once_and_publish_separate_inventories() { - let (_temp, parent) = directory(); - let baseline = parent.join("baseline"); - let candidate = parent.join("candidate"); - let invoked = std::cell::Cell::new(0); - run_cli( - vec![ - "build".into(), - "--out".into(), - candidate.to_str().unwrap().into(), - "--audience".into(), - "both".into(), - ], - &baseline, - || { - invoked.set(invoked.get() + 1); - Ok(vec![document()]) - }, - ) - .unwrap(); - assert_eq!(invoked.get(), 1); - assert!(!baseline.exists()); - for audience in ["reader", "contributor"] { - let manifest: serde_json::Value = serde_json::from_slice( - &fs::read(candidate.join(audience).join("manifest.json")).unwrap(), - ) - .unwrap(); - assert_eq!(manifest["audience"], audience); - assert!(candidate.join(audience).join(".ownership.json").exists()); - } -} - -#[test] -fn render_failure_prevents_all_publication() { - let (_temp, parent) = directory(); - let baseline = parent.join("baseline"); - let doc = document(); - let error = run_cli( - ["update", "--audience", "both"].map(str::to_owned), - &baseline, - || Ok(vec![doc.clone(), doc]), - ) - .unwrap_err(); - assert!(error.to_lowercase().contains("duplicate")); - assert!(!baseline.exists()); -} - -#[test] -fn partial_audience_publication_is_explicit_and_preserves_other_writer() { - let (_temp, parent) = directory(); - let baseline = parent.join("baseline"); - fs::create_dir(&baseline).unwrap(); - let lock = baseline.join(".contributor.lock"); - fs::write(&lock, "another writer").unwrap(); - let error = run_cli( - ["update", "--audience", "both"].map(str::to_owned), - &baseline, - || Ok(vec![document()]), - ) - .unwrap_err(); - assert!(error.contains("earlier audiences completed independently: reader")); - assert!(baseline.join("reader/manifest.json").exists()); - assert!(!baseline.join("contributor").exists()); - assert_eq!(fs::read_to_string(lock).unwrap(), "another writer"); -} - -fn edit_manifest(files: &mut store::Files, edit: impl FnOnce(&mut serde_json::Value)) { - let mut manifest: serde_json::Value = serde_json::from_slice(&files["manifest.json"]).unwrap(); - edit(&mut manifest); - files.insert( - "manifest.json".into(), - serde_json::to_vec_pretty(&manifest).unwrap(), - ); -} - -#[test] -fn inconsistent_artifact_manifests_never_build_update_or_pass_check() { - let (_temp, parent) = directory(); - let baseline = parent.join("baseline"); - let original = files("reader", "v1", "frozen bytes"); - store::update(&baseline, &original).unwrap(); - let saved = snapshot(&baseline); - let mut candidates = Vec::new(); - let mut changed_bytes = original.clone(); - changed_bytes.insert( - "pages/example.md".into(), - b"different actual bytes".to_vec(), - ); - candidates.push(("stale hash", changed_bytes)); - let mut missing = original.clone(); - missing.remove("pages/example.md"); - candidates.push(("missing file", missing)); - let mut unrecorded = original.clone(); - unrecorded.insert("unrecorded.txt".into(), b"extra".to_vec()); - candidates.push(("unrecorded file", unrecorded)); - - for (name, edit) in [ - ( - "wrong length", - (|manifest: &mut serde_json::Value| { - manifest["artifacts"][0]["bytes"] = 999.into(); - }) as fn(&mut serde_json::Value), - ), - ("wrong hash", |manifest| { - manifest["artifacts"][0]["sha256"] = "0".repeat(64).into(); - }), - ("missing artifact", |manifest| { - manifest["artifacts"].as_array_mut().unwrap().pop(); - }), - ("duplicate path", |manifest| { - let repeated = manifest["artifacts"][0].clone(); - manifest["artifacts"].as_array_mut().unwrap().push(repeated); - }), - ("duplicate document", |manifest| { - let repeated = manifest["documents"][0].clone(); - manifest["documents"].as_array_mut().unwrap().push(repeated); - }), - ("unknown document", |manifest| { - manifest["artifacts"][0]["document"] = "unknown".into(); - }), - ("unsafe declared path", |manifest| { - manifest["artifacts"][0]["path"] = "../outside".into(); - }), - ("unknown artifact kind", |manifest| { - manifest["artifacts"][0]["kind"] = "unknown".into(); - }), - ("profile mismatch", |manifest| { - manifest["profiles"][0]["profile"] = "invented".into(); - }), - ("duplicate profile", |manifest| { - let repeated = manifest["profiles"][0].clone(); - manifest["profiles"].as_array_mut().unwrap().push(repeated); - }), - ("failed check", |manifest| { - manifest["documents"][0]["checks"][0]["passed"] = false.into(); - }), - ("wrong-kind reference", |manifest| { - manifest["documents"][0]["blocks"][0]["references"][0]["kind"] = "value".into(); - }), - ("reader note", |manifest| { - manifest["documents"][0]["blocks"][0]["kind"] = "note".into(); - }), - ] { - let mut candidate = original.clone(); - edit_manifest(&mut candidate, edit); - candidates.push((name, candidate)); - } - for (index, (name, candidate)) in candidates.into_iter().enumerate() { - let fresh = parent.join(format!("candidate-{index}")); - assert!( - store::build(&fresh, &candidate).is_err(), - "build accepted {name}" - ); - assert!(!fresh.exists(), "build wrote a rejected candidate: {name}"); - assert!( - store::update(&baseline, &candidate).is_err(), - "update accepted {name}" - ); - assert!( - store::check(&baseline, &candidate).is_err(), - "check accepted {name}" - ); - assert_eq!( - snapshot(&baseline), - saved, - "rejected {name} changed the baseline" - ); - } -} - -#[test] -fn distinct_paths_cannot_reuse_a_resource_identity() { - let (_temp, parent) = directory(); - let mut candidate = - resource_files(&[("first", "producer", "v1"), ("second", "producer", "v1")]); - edit_manifest(&mut candidate, |manifest| { - let resources: Vec<_> = manifest["artifacts"] - .as_array_mut() - .unwrap() - .iter_mut() - .filter(|record| record["kind"] == "resource") - .collect(); - for resource in resources { - resource["id"] = "same".into(); - } - }); - let error = store::build(&parent.join("candidate"), &candidate).unwrap_err(); - assert!(error.contains("Duplicate manifest evidence identity")); -} - -#[test] -fn corruption_of_retained_bytes_or_backup_cannot_be_approved_as_an_update() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let expected = files("reader", "v1", "original"); - store::update(&root, &expected).unwrap(); - fs::write(root.join("pages/example.md"), "corrupted").unwrap(); - let damaged = snapshot(&root); - assert!( - store::check(&root, &expected) - .unwrap_err() - .contains("size/hash mismatch") - ); - assert!( - store::update(&root, &expected) - .unwrap_err() - .contains("size/hash mismatch") - ); - assert_eq!(snapshot(&root), damaged); - let backup = parent.join(".reader.backup"); - fs::rename(&root, &backup).unwrap(); - assert!( - store::recover_backup(&root) - .unwrap_err() - .contains("size/hash mismatch") - ); - assert!(backup.exists()); - assert!(!root.exists()); -} diff --git a/crates/lgtmd/tests/store_layout.rs b/crates/lgtmd/tests/store_layout.rs deleted file mode 100644 index 55a1b83..0000000 --- a/crates/lgtmd/tests/store_layout.rs +++ /dev/null @@ -1,171 +0,0 @@ -use lgtmd::{Audience, Doc, Document, ExportLayout, lifecycle, store}; -use serde_json::Value; -use std::{fs, path::PathBuf}; - -fn directory() -> (tempfile::TempDir, PathBuf) { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().canonicalize().unwrap(); - (temp, root) -} - -fn files(page: &str, text: &str) -> store::Files { - let mut document = Doc::new("example", "An independently placed page").unwrap(); - document.require("completed", true).unwrap(); - let output = document.text("output", text).unwrap(); - document - .paragraph(("The observed output is in ", &output, ".")) - .unwrap(); - let layout = ExportLayout::default() - .resources_under("evidence/observations") - .unwrap() - .page("example", page) - .unwrap(); - Document::render_many_with(&[document.finish().unwrap()], Audience::Reader, &layout).unwrap() -} - -fn edit_manifest(files: &mut store::Files, edit: impl FnOnce(&mut Value)) { - let mut manifest: Value = serde_json::from_slice(&files["manifest.json"]).unwrap(); - edit(&mut manifest); - files.insert( - "manifest.json".into(), - serde_json::to_vec_pretty(&manifest).unwrap(), - ); -} - -#[test] -fn custom_nested_exports_build_check_update_and_recover() { - let (_temp, parent) = directory(); - let root = parent.join("independent-publication"); - let original = files("manual/getting-started/README.md", "original observation"); - assert!(!original.contains_key("example.md")); - assert!(original.contains_key("evidence/observations/example/output.txt")); - store::build(&root, &original).unwrap(); - store::check(&root, &original).unwrap(); - - let changed = files("manual/getting-started/README.md", "new observation"); - assert!( - store::check(&root, &changed) - .unwrap_err() - .contains("changed") - ); - store::update(&root, &changed).unwrap(); - store::check(&root, &changed).unwrap(); - - // Recovery must validate the same manifest-defined page layout as publication. - fs::rename(&root, parent.join(".independent-publication.backup")).unwrap(); - store::recover_backup(&root).unwrap(); - store::check(&root, &changed).unwrap(); - assert_eq!( - fs::read_to_string(root.join("evidence/observations/example/output.txt")).unwrap(), - "new observation" - ); -} - -#[test] -fn moving_a_page_requires_explicit_retirement_of_its_old_owned_path() { - let (_temp, parent) = directory(); - let root = parent.join("publication"); - let old_page = "manual/start/README.md"; - let original = files(old_page, "same evidence"); - store::build(&root, &original).unwrap(); - let retained = lifecycle::read_tree(&root).unwrap(); - let moved = files("handbook/entry.md", "same evidence"); - - assert!( - store::update(&root, &moved) - .unwrap_err() - .contains("explicit removal") - ); - assert_eq!(lifecycle::read_tree(&root).unwrap(), retained); - fs::remove_file(root.join(old_page)).unwrap(); - store::update(&root, &moved).unwrap(); - store::check(&root, &moved).unwrap(); - assert!(!root.join(old_page).exists()); - assert!(root.join("handbook/entry.md").is_file()); -} - -#[test] -fn configurable_paths_do_not_weaken_page_identity_or_inventory_validation() { - let (_temp, parent) = directory(); - let baseline = parent.join("retained"); - let page = "manual/getting-started/README.md"; - let original = files(page, "frozen evidence"); - store::build(&baseline, &original).unwrap(); - let retained = lifecycle::read_tree(&baseline).unwrap(); - let mut candidates = Vec::new(); - - let mut missing = original.clone(); - missing.remove(page); - edit_manifest(&mut missing, |manifest| { - manifest["artifacts"] - .as_array_mut() - .unwrap() - .retain(|record| record["kind"] != "page"); - }); - candidates.push(("missing page", missing)); - - let mut duplicate = original.clone(); - duplicate.insert("manual/extra.md".into(), original[page].clone()); - edit_manifest(&mut duplicate, |manifest| { - let mut record = manifest["artifacts"] - .as_array() - .unwrap() - .iter() - .find(|record| record["kind"] == "page") - .unwrap() - .clone(); - record["path"] = "manual/extra.md".into(); - manifest["artifacts"].as_array_mut().unwrap().push(record); - }); - candidates.push(("duplicate page identity", duplicate)); - - for (name, field, value) in [ - ("unknown document", "document", "other-document"), - ("wrong MIME", "mime", "text/plain"), - ("unsafe path", "path", "../outside.md"), - ("reserved path", "path", ".ownership.json/page.md"), - ] { - let mut candidate = original.clone(); - edit_manifest(&mut candidate, |manifest| { - let record = manifest["artifacts"] - .as_array_mut() - .unwrap() - .iter_mut() - .find(|record| record["kind"] == "page") - .unwrap(); - record[field] = value.into(); - }); - candidates.push((name, candidate)); - } - let mut collision = original.clone(); - collision.insert( - "manual".into(), - b"cannot be a file and a directory".to_vec(), - ); - candidates.push(("file and directory collision", collision)); - let mut corrupt = original.clone(); - corrupt.insert(page.into(), b"changed without a matching receipt".to_vec()); - candidates.push(("stale page hash", corrupt)); - - for (index, (name, candidate)) in candidates.into_iter().enumerate() { - let fresh = parent.join(format!("rejected-{index}")); - assert!( - store::build(&fresh, &candidate).is_err(), - "build accepted {name}" - ); - assert!(!fresh.exists(), "build wrote invalid {name}"); - assert!( - store::check(&baseline, &candidate).is_err(), - "check accepted {name}" - ); - assert!( - store::update(&baseline, &candidate).is_err(), - "update accepted {name}" - ); - assert_eq!( - lifecycle::read_tree(&baseline).unwrap(), - retained, - "{name} changed retained evidence" - ); - } -} diff --git a/crates/lgtmd/tests/transport.rs b/crates/lgtmd/tests/transport.rs deleted file mode 100644 index 3f4a5ae..0000000 --- a/crates/lgtmd/tests/transport.rs +++ /dev/null @@ -1,103 +0,0 @@ -use lgtmd::{ - Artifact, Audience, Doc, ExportLayout, Result, render, - transport::{self, Input}, -}; -use serde_json::{Value, json}; - -fn documents() -> Result> { - let mut doc = Doc::new("portable", "Portable document")?; - let value = doc.observe("count", 3)?; - let private = doc.observe("internal-only", "private observation")?; - let checked = doc.expect_eq("null-value", Value::Null, Value::Null)?; - doc.require("verified-count", true)?; - doc.heading_with_id("overview", 2, "Overview")?; - let passage = doc.markdown_parts(("Contains ", &value, " records and ", &checked, ".\n\n"))?; - doc.note_on(&passage, ("Internal: ", &private))?; - let json = doc.json("data", &json!({"z":2,"a":1}))?; - doc.markdown_parts(( - "Data:\n\n", - json.code("json"), - "\n\n", - json.link("Download"), - "\n\n", - ))?; - let image = doc.resource( - "image", - Artifact::new(vec![1, 2, 3], "image/png", "png", "fixture", "exact-v1")?, - )?; - doc.paragraph(image.image("Fixed fixture bytes"))?; - doc.code(&json, "json")?; - doc.markdown("A **literal** Markdown block.")?; - Ok(vec![doc.finish()?]) -} - -#[test] -fn frozen_transport_preserves_both_native_exports_and_null_checks() { - let docs = documents().unwrap(); - let input = Input::from_documents(&docs).unwrap(); - let serialized = serde_json::to_vec(&input).unwrap(); - let input: Input = serde_json::from_slice(&serialized).unwrap(); - let exports = transport::compile(&input, &ExportLayout::default()).unwrap(); - assert_eq!(exports.reader, render(&docs, Audience::Reader).unwrap()); - assert_eq!( - exports.contributor, - render(&docs, Audience::Contributor).unwrap() - ); - assert!( - !String::from_utf8(exports.reader["manifest.json"].clone()) - .unwrap() - .contains("internal-only") - ); -} - -#[test] -fn transport_rejects_invalid_schema_references_outcomes_and_numbers() { - let input = - serde_json::to_value(Input::from_documents(&documents().unwrap()).unwrap()).unwrap(); - for mutate in [ - |v: &mut Value| v["schema_version"] = json!(2), - |v: &mut Value| v["documents"][0]["checks"][0]["passed"] = json!(false), - |v: &mut Value| { - v["documents"][0]["observations"][0]["value"] = json!(9_007_199_254_740_992_u64) - }, - |v: &mut Value| { - v["documents"][0]["nodes"][1]["parts"][1]["target"]["id"] = json!("missing") - }, - |v: &mut Value| { - v["documents"][0]["nodes"][1]["parts"][1]["target"]["kind"] = json!("resource") - }, - |v: &mut Value| v["documents"][0]["resources"][0]["path"] = json!("../escape.json"), - |v: &mut Value| v["documents"][0]["nodes"][0]["id"] = json!("count"), - ] { - let mut candidate = input.clone(); - mutate(&mut candidate); - let candidate: Input = serde_json::from_value(candidate).unwrap(); - assert!(transport::compile(&candidate, &ExportLayout::default()).is_err()); - } - let mut unknown = input.clone(); - unknown["documents"][0]["host_state"] = json!(true); - assert!(serde_json::from_value::(unknown).is_err()); - let mut unknown_binding = input.clone(); - unknown_binding["documents"][0]["bindings"] = json!([{ - "id":"label", "value":{"kind":"text", "value":"Visible", "unknown":true} - }]); - assert!(serde_json::from_value::(unknown_binding).is_err()); - let mut invalid_bytes = input; - invalid_bytes["documents"][0]["resources"][0]["bytes"] = json!([256]); - assert!(serde_json::from_value::(invalid_bytes).is_err()); -} - -#[test] -fn native_values_outside_portable_numeric_domain_fail_at_boundary() { - let mut doc = Doc::new("large-number", "Large number").unwrap(); - doc.observe("large", u64::MAX).unwrap(); - assert!(Input::from_documents(&[doc.finish().unwrap()]).is_err()); -} - -#[test] -fn shared_fixture_exports_remain_exact() { - let fixture: Value = serde_json::from_str(include_str!("fixtures/portable.json")).unwrap(); - let input: Input = serde_json::from_value(fixture["input"].clone()).unwrap(); - let exports = transport::compile(&input, &ExportLayout::default()).unwrap(); - assert_eq!(serde_json::to_value(exports).unwrap(), fixture["exports"]); -} diff --git a/docs/architecture/executable-documents.md b/docs/architecture/executable-documents.md index f9256ee..2f57b16 100644 --- a/docs/architecture/executable-documents.md +++ b/docs/architecture/executable-documents.md @@ -1,16 +1,17 @@ # Executable documents and the N3 adapter -The maintained `lgtmd` SDK separates document composition and artifact -publication from the application being documented. N3 remains one application -crate. The SDK lives in [crates/lgtmd](../../crates/lgtmd/README.md), -and [crates/doc-example-config](../../crates/doc-example-config/README.md) provides -a real CLI consumer and an integration with the existing serde_json library. -Neither SDK use nor those examples requires N3's renderer, UI stack, website, -or agent skill. - -This is a local, experimental API with `publish = false`. The implementation is -maintained here so the same source can support N3 and other consumers before a -future repository move. No registry publication or release is implied. +The [lgtmd SDK](https://github.com/softmarshmallow/lgtmd) separates document +composition and artifact publication from the application being documented. +N3 remains one application crate and consumes lgtmd from an exact Git revision. +The SDK, its Node bridge, and portable Rust/React examples are maintained in the +lgtmd repository. N3 owns its application adapter, replay, renderer, scenarios, +and guide artifacts here. Neither SDK use nor its examples requires N3's renderer, +UI stack, website, or agent skill. + +The SDK remains experimental and unpublished. Moving its source establishes +independent repository ownership; it does not establish a public release or a +stable API. A future registry dependency can replace the Git pin after the +package passes its release gates. ## The shared contract @@ -30,7 +31,7 @@ correct application behavior from an artifact's existence or a declared MIME type. Meaningful assertions, producer metadata, fixtures, runtime drivers, review, and documentation organization remain the consumer's responsibility. -The [SDK README](../../crates/lgtmd/README.md) is the API and manifest +The [SDK README](https://github.com/softmarshmallow/lgtmd/blob/main/crates/lgtmd/README.md) is the API and manifest reference. N3's [documentation pipeline](documentation-pipeline.md) owns product replay, timing, captures, and renderer-specific evidence. This document records the boundary between them. @@ -88,7 +89,7 @@ paths need distinct ASCII components beyond any shared ancestors. Case-only or Unicode-only distinctions require preexisting separate directories rather than guessing a filesystem's alias rules. Unicode parent directories and later path components remain supported. -The [configured SDK example](../../crates/lgtmd/examples/configured.rs) +The [configured SDK example](https://github.com/softmarshmallow/lgtmd/blob/main/crates/lgtmd/examples/configured.rs) exercises a source-only host, external baselines, nested pages, renamed audience directories, private resources, and preservation of unrelated files. This boundary does not add a configuration file format, plugin loader, or repository abstraction. @@ -193,14 +194,17 @@ review. It does not approve product behavior, publication, or a release. ## Verification and remaining validation -`just doc-framework-test` delegates to the SDK-owned -[verification entry point](../../crates/lgtmd/dev/verify.py): scoped -formatting, Clippy, tooling regressions, SDK and consumer tests, doctests, and API -docs without N3's GPU harness. Each consumer checks retained Reader and -Contributor outputs. `just doc-framework-smoke --offline` copies those packages -into a disposable minimal workspace, resolves its own temporary lockfile, and -verifies the declared Rust 1.95 minimum independently of N3's dependencies and -patches. No permanent second workspace is introduced. +N3's `just verify` checks its formatting, Clippy, tooling, full Rust suite, +measurement contracts, and exact guide replay on the native renderer. An SDK +revision update goes through this same gate; changes to its API must remain +explicit in the N3 adapter and scenarios. + +The SDK owns its independent +[verification workflow](https://github.com/softmarshmallow/lgtmd/blob/main/AGENTS.md), +including portable sources, Rust/Node tests, its own examples, and installed +Cargo/npm consumers. Those gates run in the lgtmd repository rather than through +N3's application commands or CI. They supplement consumer verification and do +not establish N3 behavior or renderer compatibility. The SDK's static preview validates the complete export receipt before displaying it. Page identity comes from that inventory rather than file extensions; @@ -219,10 +223,10 @@ future work. The framework detects encoded behavioral and artifact drift; prose clarity, meaningful coverage, visual legibility, and physical interaction still need human review. -## Node and web incubation +## Node and web integration -[packages/lgtmd](../../packages/lgtmd/README.md) authors documents in Node 24 ESM; -[crates/lgtmd-node](../../crates/lgtmd-node/Cargo.toml) is its coarse native bridge. +[packages/lgtmd](https://github.com/softmarshmallow/lgtmd/blob/main/packages/lgtmd/README.md) authors documents in Node 24 ESM; +[crates/lgtmd-node](https://github.com/softmarshmallow/lgtmd/blob/main/crates/lgtmd-node/Cargo.toml) is its coarse native bridge. One serialized request per compile/publication carries schema-v1 frozen input to the same Rust validation, Markdown exporters, runner and owned store. The wire contains complete ordered content and resource snapshots, never process-local @@ -230,7 +234,7 @@ handle owners or application callbacks. Rust transport round trips preserve existing native exports, including literal Markdown versus typed parts. Built-in JSON resources use one Rust encoder, including those registered from Node. -The [three React/Vite examples](../../examples/lgtmd-web/README.md) exercise prose-only +The [three React/Vite examples](https://github.com/softmarshmallow/lgtmd/blob/main/examples/lgtmd-web/README.md) exercise prose-only authoring, application-owned async preparation/cleanup, supplied context, observations, native checks, private notes/resources, JSON and CSV downloads, and Playwright screenshot evidence. The React app uses ordinary Vite development and @@ -252,9 +256,9 @@ binaries in npm tarballs; installation performs no Rust build. Node 24 ESM on macOS arm64 is the executed local scope. Linux x64 has CI wiring, not a completed runtime claim. A full viewer and browser-hosted SDK are deferred. -`just doc-web-test --offline` checks the native bridge, Node authoring, failure -semantics, real Chromium replay, exact repeated exports, and clean Cargo/npm -consumers. `just doc-web-smoke --offline` repeats that gate in an independent -workspace on Rust 1.95. The npm consumer installs with Rust absent from PATH and -imports the core before installing the optional Playwright peer. Browser setup -is explicit through `just doc-web-setup`; web gates supplement N3's native verifier. +The SDK repository owns explicit browser setup and its Node/Chromium verification +workflow. It checks the native bridge, authoring and failure semantics, real +browser replay, exact repeated exports, and clean Cargo/npm consumers independently +of N3. The npm consumer installs with Rust absent from PATH and imports the core +before installing the optional Playwright peer. N3's existing browser editor and +its native documentation adapter retain their own host policies and checks. diff --git a/docs/development.md b/docs/development.md index b85b831..459e0f6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,9 +1,9 @@ # Developing N3 The application is a single Rust package named `n3`, with private implementation -modules. The workspace also maintains the portable `lgtmd` SDK and its -consumer examples under `crates/`; the root package remains the default Cargo -target. Read [AGENTS.md](../AGENTS.md) for development principles and iteration +modules. Its documentation adapter consumes the separately maintained +[lgtmd SDK](https://github.com/softmarshmallow/lgtmd) at an exact Git revision. +SDK sources and portable examples live in that repository. Read [AGENTS.md](../AGENTS.md) for development principles and iteration guidance. [Testing](../TESTING.md) indexes verification workflows, reference suites, and benchmark resources. Start at [the architecture map](architecture/architecture.md) and [the milestone review](architecture/milestone-review.md). The old viewer-only @@ -422,7 +422,7 @@ navigation adapters, and scene renderer. Assertions verify the intended outcome. The portable SDK owns document composition, frozen evidence, and shared artifact publication; it does not own N3's input or renderer. See [executable documents](architecture/executable-documents.md) for the maintained -boundary and [the SDK README](../crates/lgtmd/README.md) for its API. +boundary and [the SDK README](https://github.com/softmarshmallow/lgtmd/blob/main/crates/lgtmd/README.md) for its API. ```sh just docs @@ -501,30 +501,20 @@ Finder delivery, macOS dialogs, or artistic approval. contract using Python's standard library. It also runs as part of `just verify`; it does not replace the Rust replay and exact artifact checks. -For focused framework work, run `just doc-framework-test`. The SDK-owned workflow -checks scoped Rust formatting, Clippy, tooling, SDK and consumer tests, doctests, -and API documentation without starting N3's renderer. This gate also runs inside -`just verify`. Run `just doc-framework-smoke --offline` to verify a disposable -standalone copy on the declared Rust 1.95 minimum, once that toolchain and the -portable dependencies are cached. Its fresh temporary lockfile excludes N3's -dependencies, patches, and workspace configuration. See the -[SDK development workflow](../crates/lgtmd/README.md#develop-and-verify-the-portable-packages) -for direct commands and cache handling. -The [consumer README](../crates/doc-example-config/README.md) documents direct Cargo -commands, package-local baselines, and candidate review. These examples exercise -two integration shapes; the serde_json example is our use of an existing library, -not adoption by its maintainers. N3 adapter or scenario changes still require -the native replay and `just verify`. - -For the incubating Node SDK and independent web example, run -`just doc-web-setup` with Node 24/npm. This explicitly installs npm dependencies -and pinned Chromium, builds the Rust host binding, and compiles TypeScript. -`just doc-web-test --offline` verifies authoring, the optional Playwright adapter, -real browser replay, and clean Cargo/npm consumers. `just doc-web-smoke --offline` -copies only portable sources to an independent workspace and repeats the gate on -Rust 1.95. These checks require the installed browser, run natively, and never -silently accept a baseline. They supplement `just verify`; the N3 app's existing -`web/` host is separate from [lgtmd's web example](../examples/lgtmd-web/README.md). +SDK authoring, compiler/store work, and its portable Rust/React examples are +maintained and verified in the +[lgtmd repository](https://github.com/softmarshmallow/lgtmd). Follow its contributor +instructions for SDK setup, independent package checks, and Node/Chromium gates. +N3 does not install or run the SDK's web example during application verification. +The N3 app's `web/` host remains a separate consumer boundary. + +N3 pins the SDK's full Git revision in `Cargo.toml` and retains its resolved +source in `Cargo.lock`. Updating that revision requires `just verify`, including +native replay and exact guide checks; portable SDK checks alone cannot establish +N3 compatibility. Cargo must fetch the pinned source and dependencies before an +offline run. Keep the dependency reproducible without sibling checkout discovery +or a developer-specific path. A registry dependency is deferred until an +explicitly authorized SDK release is ready. ## User settings diff --git a/examples/lgtmd-web/.gitignore b/examples/lgtmd-web/.gitignore deleted file mode 100644 index 2400018..0000000 --- a/examples/lgtmd-web/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -/.cache/ -/node_modules/ -/dist/ diff --git a/examples/lgtmd-web/README.md b/examples/lgtmd-web/README.md deleted file mode 100644 index 4c5e181..0000000 --- a/examples/lgtmd-web/README.md +++ /dev/null @@ -1,188 +0,0 @@ -# lgtmd web example - -Build a small records application with React, TypeScript, and Vite, then develop -its filtering feature through executable documentation. The application uses an -ordinary HTTP API and Playwright fixtures. lgtmd authors and publishes its guide; -the application and fixture own their state, preparation, and cleanup. - -Three complete documents use the same SDK and Rust compiler: - -| Source | What it demonstrates | -| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Record names](guides/01-record-names.ts) | Explanation-only prose, a reused ordinary function, and a note attached to a passage. It makes no executed verification claim. | -| [Record resources](guides/02-resources.ts) | Async preparation and cleanup, a frozen observation, a named native assertion, JSON as code and a download, CSV bytes, and a private text resource. | -| [Browser filtering](browser/filter-records.spec.ts) | Normal Playwright queries and assertions, filtering and clearing, a screenshot, and a targeted contributor note. | - -[Supplied context](guides/supplied-context.ts) calls the same resource authoring -function when another application runner already owns preparation. It is an -alternative execution path for the same document, not a fourth page. The SDK -does not acquire or dispose of that supplied context. - -## Run the application - -Use Node.js 24 ESM and the pinned workspace dependencies. From the repository root, -install dependencies, build the local Rust binding and TypeScript SDK, and install -Chromium explicitly: - -```sh -just doc-web-setup -``` - -Start the API in one terminal and the Vite development server in another: - -```sh -npm run dev:api --workspace @lgtmd/example-web -``` - -```sh -npm run dev --workspace @lgtmd/example-web -``` - -Open . The API listens on port 4174; Vite proxies its -requests. The root page loads the sample records immediately, so viewing the -application requires no test fixture or seed command. Playwright creates isolated -workspaces through the same API for its tests. - -Documentation runs build the app, then start production preview and a test API -on ports 4183 and 4184. -The development servers can stay open; the test configuration owns its services -and uses the same React application. Vite excludes the generated `.cache/` tree -from its dev watcher, so writing Playwright HTML traces does not reset an open -development session. - -These are conventional application commands: - -```sh -npm run build --workspace @lgtmd/example-web -npm run preview --workspace @lgtmd/example-web -``` - -`build` checks TypeScript and writes Vite's production bundle to ignored `dist/`. -`preview` serves that bundle at and still requires the API. React's browser bundle -does not import lgtmd; the SDK is a development dependency used by the authoring -and test processes. - -## Develop a feature with its guide - -The filtering example keeps the reader's instructions beside the actions and -assertions that support them. Its development loop is: - -1. Describe the promise in [the browser document](browser/filter-records.spec.ts): - entering part of a name narrows the list, and clearing the field restores it. - Write ordinary Playwright assertions for both outcomes alongside the prose. -2. Implement the interaction in [the React application](src/App.tsx). Use the - development server to inspect and interact with the normal application. -3. Run the document to produce a fresh review candidate: - - ```sh - npm run docs --workspace @lgtmd/example-web -- build "$PWD/.cache/lgtmd-web-review" - ``` - -4. Read the `reader/` and `contributor/` Markdown exports and inspect the filtered - list image. Contributor notes explain fixture conditions and verification - limits without entering the reader's guide. Bring feedback into the same - React implementation and browser document, then build another candidate at a - fresh output path. `build` rejects an existing destination. -5. After reviewing an intentional change, accept it into a project-owned baseline - directory explicitly: - - ```sh - npm run docs --workspace @lgtmd/example-web -- update "$PWD/.cache/lgtmd-web-baseline" - npm run docs --workspace @lgtmd/example-web -- check "$PWD/.cache/lgtmd-web-baseline" - ``` - -`check` runs the same documents and compares the retained trees exactly. It does -not accept drift. A project can choose its own baseline path and commit the -reviewed tree. Fresh candidates, repeatable runs, and accepted baselines serve -different purposes. - -The browser document is the narrative source: there is no separate prose -template to keep synchronized. Assertions remain normal Playwright assertions; -they do not need a lgtmd wrapper. The guide gives product feedback a concrete -place to land alongside the implementation and executable promises. - -## Run the documents - -The commands above run all three authoring sources and use one completed browser -run for both audiences. The browser dependency is pinned to Playwright 1.64.0. - -For the two documents that do not require a browser, use a separate output root: - -```sh -npm run docs --workspace @lgtmd/example-web -- build "$PWD/.cache/lgtmd-node-review" --without-browser -``` - -That subset is useful for authoring or binding checks. It does not establish the -browser example's behavior or screenshot baseline. - -`just doc-web-test --offline` runs the complete local web and package checks. -`just doc-web-smoke --offline` repeats the portable gate in an independent copy, -including the Rust 1.95 baseline. Setup is separate: checks do not fetch a browser -or require Docker. The current native package and browser capture have been -verified on macOS arm64; other platforms need their own binding and capture gate. - -## Application and runner ownership - -[The application fixture](browser/application-test.ts) creates an isolated -workspace through the example's HTTP API and removes it during ordinary -Playwright teardown. [The API server](server/index.mjs) stores the supplied records -in memory; [the React application](src/App.tsx) renders the accessible search -page. A real project can replace this fixture with its existing mocks, seeds, -services, or already-prepared state. - -React with Vite is one complete integration shape. A Next.js project can keep its -own application and Playwright configuration and use the same SDK boundary; -this example does not exercise or establish support for a particular Next.js -configuration. - -`withDocs(applicationTest)` adds a `doc` fixture while preserving the application's -fixtures and assertions. Calling `doc.assets.image()` registers the bytes returned -by a normal Playwright screenshot; it does not control browser input or alter the -application. Record names and the CSV buffer are intentionally mutated after -registration in the resources example to exercise snapshot ownership. The exports -must retain their original contents. - -The browser document uses unwrapped Playwright assertions. Their success belongs -to the host test result; the SDK does not manufacture named checks for them. The -resources document separately shows the optional `doc.check()` wrapper around a -native assertion and an observation that is not an assertion. - -## Completion and supported scope - -Documentation captures use the production Vite bundle. Keeping hot reload and -development dependency optimization out of that process gives captures a stable -application build while ordinary development still uses Vite's dev server. -The Playwright configuration owns these commands; lgtmd does not invoke or know -about Vite itself. - - -[The configuration](playwright.config.ts) selects one Chromium project, no retries, -and a fixed viewport, locale, timezone, color scheme, and reduced-motion preference. -The image receipt includes the pinned Playwright version and host platform. Exact -screenshots still depend on their capture environment; this example makes no -cross-platform pixel-identity claim. - -Use `docsReporter()` as the sole configured reporter and run through -`runPlaywright()`, as [the entry point](run.ts) does. The SDK stages document -attachments during the test and publishes only after the complete Playwright -process exits successfully and a unique completed receipt validates. Test or -worker teardown failures, global teardown failures, timeouts, interruption, caught -named-check failures, and document ID collisions block publication. - -Playwright swallows exceptions thrown by custom reporter methods. To preserve -their failures, pass publicly imported reporter instances to the guard: - -```ts -reporter: docsReporter({ reporters: [new ProjectReporter()] }) -``` - -The guard awaits returned promises through `onExit`. Adding other reporters -outside it is rejected. Built-in reporter name tuples are not delegated by this -initial adapter; it provides its own concise terminal output. Running the -Playwright CLI directly can produce test diagnostics and document attachments, -but never publishes documentation. - -The initial adapter explicitly rejects retries, repeated runs, multiple projects, -dependency/teardown projects, and shards. It requires every discovered test to -pass cleanly. These limits keep execution attempts unambiguous while the shared -document contract is incubated. diff --git a/examples/lgtmd-web/browser/application-test.ts b/examples/lgtmd-web/browser/application-test.ts deleted file mode 100644 index 3c70a7c..0000000 --- a/examples/lgtmd-web/browser/application-test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { test as base, expect } from "@playwright/test"; -import { exampleRecords } from "../src/records.ts"; - -interface Workspace { - url: string; - conditions: string; -} - -/** A normal project fixture: lgtmd knows nothing about this API or its state. */ -export const test = base.extend<{ workspace: Workspace }>({ - workspace: async ({ request, baseURL }, use) => { - const created = await request.post("/api/workspaces", { - data: exampleRecords, - }); - expect(created.status()).toBe(201); - const { id, path } = await created.json(); - try { - await use({ - url: new URL(path, baseURL).href, - conditions: - "An isolated project fixture supplies exactly three sample records.", - }); - } finally { - const deleted = await request.delete(`/api/workspaces/${id}`); - expect(deleted.status()).toBe(204); - } - }, -}); - -export { expect }; diff --git a/examples/lgtmd-web/browser/filter-records.spec.ts b/examples/lgtmd-web/browser/filter-records.spec.ts deleted file mode 100644 index ef99ff4..0000000 --- a/examples/lgtmd-web/browser/filter-records.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { withDocs } from "lgtmd/playwright"; -import { test as applicationTest, expect } from "./application-test.ts"; - -const test = withDocs(applicationTest); - -test("Find records by name", async ({ page, workspace, doc }) => { - doc.page({ id: "filter-records", title: "Find a record" }); - doc.md` - Search when you know part of a record's name. This example starts with - three sample records. - `; - - await page.goto(workspace.url); - const search = page.getByRole("searchbox", { name: "Search records" }); - const results = page.getByRole("list", { name: "Records" }); - await expect(results.getByRole("listitem")).toHaveText([ - "Alpha contract", - "Alpha renewal", - "Beta proposal", - ]); - - await search.fill("Alpha"); - await expect(results.getByRole("listitem")).toHaveText([ - "Alpha contract", - "Alpha renewal", - ]); - - const screenshot = doc.assets.image( - "matching-records", - await results.screenshot(), - { - producer: "playwright/chromium", - profile: `playwright-1.64.0-${process.platform}-${process.arch}-800x600`, - }, - ); - const explanation = doc.md` - Enter **Alpha** in **Search records**. The list now contains only the two - records whose names include that text. - - ${screenshot.embed("Alpha contract and Alpha renewal in the filtered list")} - - Clear the search field to see the other records again. - `; - - await search.fill(""); - await expect(results.getByRole("listitem")).toHaveText([ - "Alpha contract", - "Alpha renewal", - "Beta proposal", - ]); - - doc.noteOn(explanation)` - ${workspace.conditions} - The existing project fixture owns preparation and cleanup. These native - Playwright assertions verify filtering and clearing the displayed results; - they do not verify a production backend. They remain host test outcomes, - without manufactured named SDK checks. - `; -}); diff --git a/examples/lgtmd-web/guides/01-record-names.ts b/examples/lgtmd-web/guides/01-record-names.ts deleted file mode 100644 index a08b092..0000000 --- a/examples/lgtmd-web/guides/01-record-names.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { guide } from "lgtmd"; -import { explainRecordNames } from "./shared.ts"; - -export default guide( - { id: "record-names", title: "Choose useful record names" }, - (doc) => { - const introduction = doc.md` - A record's name helps you recognize it in the list. Include words that - describe the work, such as the project and the kind of document. - - Names like **Alpha contract** and **Alpha renewal** share a project name - while distinguishing the work. Each record also has an identifier for - referring to it in an export. - `; - - explainRecordNames(doc); - - doc.noteOn(introduction)` - This page explains naming. It does not run the application or record an - executed check. The browser document separately exercises name filtering. - `; - }, -); diff --git a/examples/lgtmd-web/guides/02-resources.ts b/examples/lgtmd-web/guides/02-resources.ts deleted file mode 100644 index 48f40ed..0000000 --- a/examples/lgtmd-web/guides/02-resources.ts +++ /dev/null @@ -1,105 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { guide, type Doc } from "lgtmd"; -import { exampleRecords } from "../src/records.ts"; -import type { RecordsContext } from "./shared.ts"; - -/** Preparation belongs to the application; the SDK receives only the result. */ -export async function prepareRecords(): Promise<{ - context: RecordsContext; - cleanup: () => Promise; -}> { - const directory = await mkdtemp(join(tmpdir(), "lgtmd-records-")); - try { - const filename = join(directory, "records.json"); - await writeFile(filename, JSON.stringify(exampleRecords)); - const records = JSON.parse(await readFile(filename, "utf8")); - return { - context: { - records, - preparationNote: - "Records came from an isolated, application-owned file.", - }, - cleanup: () => rm(directory, { recursive: true }), - }; - } catch (error) { - // Until acquisition returns, the project owns partial setup cleanup. - try { - await rm(directory, { recursive: true }); - } catch (cleanupError) { - throw new AggregateError( - [error, cleanupError], - "Record setup and cleanup failed", - ); - } - throw error; - } -} - -/** Also usable with a context that an existing runner has already prepared. */ -export async function writeResources( - doc: Doc, - context: RecordsContext, -): Promise { - const recordCount = doc.observe("record-count", context.records.length); - const countCheck = await doc.check("three-records", () => { - assert.equal(context.records.length, 3); - }); - - doc.md` - This export contains ${recordCount} sample records. Keep a JSON copy when - you need the record identifiers as well as their names. - `; - - doc.section("download-records", "Read or download an export"); - const exportData = { - records: context.records.map((record) => ({ ...record })), - }; - const json = doc.assets.json("records", exportData); - const csvBytes = new TextEncoder().encode( - "id,name\n" + - context.records.map((record) => `${record.id},${record.name}\n`).join(""), - ); - const csv = doc.assets.file("records-table", { - bytes: csvBytes, - mediaType: "text/csv", - extension: "csv", - }); - const preparation = doc.assets.text("preparation", context.preparationNote); - - // Values and bytes have already been captured. Later source mutation must - // never rewrite the document or a previously registered download. - exportData.records[0]!.name = "Changed after registration"; - csvBytes.fill(0); - - const passage = doc.md` - The JSON includes the same three records: - - ${json.code()} - - ${json.link("Download the JSON export")} or - ${csv.link("download a CSV table")} for use in another tool. - `; - - doc.noteOn(passage)` - ${preparation.link("Preparation details")} - This resource belongs to the contributor explanation and is excluded from - the Reader export. The source values and buffer are deliberately changed - after registration to exercise snapshot ownership. - `; - doc.noteOn(countCheck)` - The named check verifies the supplied sample count. It does not exercise - a production export endpoint. The observed count alone is not an assertion. - `; -} - -export default guide( - { - id: "record-resources", - title: "Read and download record data", - prepare: prepareRecords, - }, - writeResources, -); diff --git a/examples/lgtmd-web/guides/shared.ts b/examples/lgtmd-web/guides/shared.ts deleted file mode 100644 index fb0caec..0000000 --- a/examples/lgtmd-web/guides/shared.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Doc } from "lgtmd"; -import type { RecordEntry } from "../src/records.ts"; - -/** Ordinary functions are enough to reuse prose between authored documents. */ -export function explainRecordNames(doc: Doc): void { - doc.section("recognizable-names", "Use words you will recognize"); - doc.md` - **Alpha contract** is easier to recognize than **Record 3**. - Use a project name and the purpose of the record when both are useful. - `; -} - -export interface RecordsContext { - records: RecordEntry[]; - preparationNote: string; -} diff --git a/examples/lgtmd-web/guides/supplied-context.ts b/examples/lgtmd-web/guides/supplied-context.ts deleted file mode 100644 index 4268ce9..0000000 --- a/examples/lgtmd-web/guides/supplied-context.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { runGuideWithContext } from "lgtmd"; -import { writeResources } from "./02-resources.ts"; -import type { RecordsContext } from "./shared.ts"; - -/** An existing application runner retains ownership of its context and cleanup. */ -export function documentPreparedRecords(context: RecordsContext) { - return runGuideWithContext( - { id: "record-resources", title: "Read and download record data" }, - context, - writeResources, - ); -} diff --git a/examples/lgtmd-web/index.html b/examples/lgtmd-web/index.html deleted file mode 100644 index 6630b99..0000000 --- a/examples/lgtmd-web/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Example records - - -
- - - diff --git a/examples/lgtmd-web/package.json b/examples/lgtmd-web/package.json deleted file mode 100644 index c9e3a31..0000000 --- a/examples/lgtmd-web/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@lgtmd/example-web", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "typecheck": "tsc --noEmit -p tsconfig.json", - "docs": "node run.ts", - "dev": "vite", - "dev:api": "node server/index.mjs", - "build": "npm run typecheck && vite build", - "preview": "vite preview" - }, - "dependencies": { - "react": "19.3.0", - "react-dom": "19.3.0" - }, - "devDependencies": { - "vite": "8.3.4", - "@vitejs/plugin-react": "6.1.2", - "@types/react": "19.3.0", - "@types/react-dom": "19.3.0", - "lgtmd": "0.0.0" - } -} diff --git a/examples/lgtmd-web/playwright.config.ts b/examples/lgtmd-web/playwright.config.ts deleted file mode 100644 index bf0d409..0000000 --- a/examples/lgtmd-web/playwright.config.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { defineConfig } from "@playwright/test"; -import { fileURLToPath } from "node:url"; -import { docsReporter } from "lgtmd/playwright"; - -export default defineConfig({ - testDir: "./browser", - outputDir: "./.cache/playwright", - fullyParallel: false, - workers: 1, - retries: 0, - timeout: 15_000, - reporter: docsReporter(), - use: { - baseURL: "http://127.0.0.1:4183", - browserName: "chromium", - viewport: { width: 800, height: 600 }, - locale: "en-US", - timezoneId: "UTC", - colorScheme: "light", - reducedMotion: "reduce", - trace: "retain-on-failure", - }, - projects: [{ name: "chromium" }], - webServer: [ - { - command: "npm run dev:api -- --port 4184", - cwd: fileURLToPath(new URL(".", import.meta.url)), - url: "http://127.0.0.1:4184/api/health", - reuseExistingServer: false, - timeout: 10_000, - }, - { - command: "npm run build && npm run preview -- --port 4183", - env: { RECORDS_API_URL: "http://127.0.0.1:4184" }, - cwd: fileURLToPath(new URL(".", import.meta.url)), - url: "http://127.0.0.1:4183", - reuseExistingServer: false, - timeout: 10_000, - }, - ], -}); diff --git a/examples/lgtmd-web/run.ts b/examples/lgtmd-web/run.ts deleted file mode 100644 index e768b2e..0000000 --- a/examples/lgtmd-web/run.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { runGuide, publish } from "lgtmd"; -import { runPlaywright } from "lgtmd/playwright"; -import { fileURLToPath } from "node:url"; -import { resolve } from "node:path"; -import recordNames from "./guides/01-record-names.ts"; -import resources from "./guides/02-resources.ts"; - -const [command, output] = process.argv.slice(2); -if (!(command === "build" || command === "check" || command === "update") || !output) { - throw new Error("Usage: node run.ts build|check|update OUTPUT [--without-browser]"); -} -const root = fileURLToPath(new URL(".", import.meta.url)); -const destination = resolve(output); -const options = { - command, - reader: `${destination}/reader`, - contributor: `${destination}/contributor`, -} as const; -const documents = await Promise.all([runGuide(recordNames), runGuide(resources)]); -if (process.argv.includes("--without-browser")) { - await publish(documents, options); -} else { - await runPlaywright({ - config: `${root}playwright.config.ts`, - cwd: root, - ...options, - documents, - }); -} -console.log(`lgtmd ${command} completed: ${output}`); diff --git a/examples/lgtmd-web/server/index.mjs b/examples/lgtmd-web/server/index.mjs deleted file mode 100644 index a149541..0000000 --- a/examples/lgtmd-web/server/index.mjs +++ /dev/null @@ -1,79 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { exampleRecords } from "../src/records.ts"; -import { createServer } from "node:http"; - -const portIndex = process.argv.indexOf("--port"); -const port = Number(portIndex === -1 ? 4174 : process.argv[portIndex + 1]); -if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error("Provide --port with an integer between 1 and 65535"); -} - -const workspaces = new Map(); - -function send(response, status, value) { - response.writeHead(status, { - "Content-Type": "application/json", - "Cache-Control": "no-store", - }); - response.end(JSON.stringify(value)); -} - -const server = createServer(async (request, response) => { - const path = new URL(request.url, `http://127.0.0.1:${port}`).pathname; - if (request.method === "GET" && path === "/api/health") { - send(response, 200, { ready: true }); - return; - } - if (request.method === "GET" && path === "/api/records") { - send(response, 200, exampleRecords); - return; - } - if (request.method === "POST" && path === "/api/workspaces") { - try { - let source = ""; - for await (const chunk of request) { - source += chunk; - if (source.length > 65536) - throw new Error("Workspace payload is too large"); - } - const records = JSON.parse(source); - if ( - !Array.isArray(records) || - !records.every( - (record) => - record && - typeof record.id === "string" && - typeof record.name === "string", - ) - ) - throw new Error("Expected records with string id and name fields"); - const id = randomUUID(); - workspaces.set(id, records); - send(response, 201, { id, path: `/?workspace=${id}` }); - } catch (error) { - send(response, 400, { error: error.message }); - } - return; - } - const match = /^\/api\/workspaces\/([^/]+)$/.exec(path); - if (match) { - const id = match[1]; - if (request.method === "GET" && workspaces.has(id)) { - send(response, 200, workspaces.get(id)); - return; - } - if (request.method === "DELETE" && workspaces.delete(id)) { - response.writeHead(204); - response.end(); - return; - } - } - send(response, 404, { error: "Not found" }); -}); - -server.listen(port, "127.0.0.1", () => { - process.stdout.write(`Records API listening at http://127.0.0.1:${port}\n`); -}); -for (const signal of ["SIGINT", "SIGTERM"]) { - process.on(signal, () => server.close(() => process.exit(0))); -} diff --git a/examples/lgtmd-web/src/App.tsx b/examples/lgtmd-web/src/App.tsx deleted file mode 100644 index c38aa0d..0000000 --- a/examples/lgtmd-web/src/App.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useEffect, useState } from "react"; -import type { RecordEntry } from "./records.ts"; - -function RecordList({ records }: { records: readonly RecordEntry[] }) { - return ( -
    - {records.map((record) => ( -
  • {record.name}
  • - ))} -
- ); -} - -export function App() { - const [search, setSearch] = useState(""); - const [records, setRecords] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(); - const workspace = new URLSearchParams(window.location.search).get( - "workspace", - ); - - useEffect(() => { - const controller = new AbortController(); - const endpoint = - workspace === null - ? "/api/records" - : `/api/workspaces/${encodeURIComponent(workspace)}`; - - async function loadRecords() { - setLoading(true); - setError(undefined); - try { - const response = await fetch(endpoint, { signal: controller.signal }); - if (!response.ok) throw new Error("The records could not be loaded."); - const loaded: RecordEntry[] = await response.json(); - if (!controller.signal.aborted) setRecords(loaded); - } catch (failure) { - if (!controller.signal.aborted) { - setError( - failure instanceof Error - ? failure.message - : "The records could not be loaded.", - ); - } - } finally { - if (!controller.signal.aborted) setLoading(false); - } - } - - void loadRecords(); - return () => controller.abort(); - }, [workspace]); - - const query = search.toLocaleLowerCase("en-US"); - const matches = records.filter((record) => - record.name.toLocaleLowerCase("en-US").includes(query), - ); - const count = loading - ? "Loading records…" - : error - ? "Records unavailable" - : `${matches.length} of ${records.length} records`; - - return ( -
-

Records

-

Find a record by part of its name.

- - setSearch(event.target.value)} - /> -

- {count} -

- - -
- ); -} diff --git a/examples/lgtmd-web/src/main.tsx b/examples/lgtmd-web/src/main.tsx deleted file mode 100644 index 9c3e228..0000000 --- a/examples/lgtmd-web/src/main.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import { App } from "./App.tsx"; -import "./styles.css"; - -const root = document.getElementById("root"); -if (!root) throw new Error("The application root is missing."); - -createRoot(root).render( - - - , -); diff --git a/examples/lgtmd-web/src/records.ts b/examples/lgtmd-web/src/records.ts deleted file mode 100644 index e623fe6..0000000 --- a/examples/lgtmd-web/src/records.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface RecordEntry { - id: string; - name: string; -} - -/** Application-owned input reused by standalone preparation and browser fixtures. */ -export const exampleRecords: RecordEntry[] = [ - { id: "alpha-contract", name: "Alpha contract" }, - { id: "alpha-renewal", name: "Alpha renewal" }, - { id: "beta-proposal", name: "Beta proposal" }, -]; diff --git a/examples/lgtmd-web/src/styles.css b/examples/lgtmd-web/src/styles.css deleted file mode 100644 index b78df7c..0000000 --- a/examples/lgtmd-web/src/styles.css +++ /dev/null @@ -1,64 +0,0 @@ -* { - box-sizing: border-box; -} -body { - margin: 0; - background: #f6f7f9; - color: #172033; - font: - 16px/1.5 Arial, - sans-serif; -} -main { - width: 640px; - max-width: calc(100% - 48px); - margin: 48px auto; -} -h1 { - margin: 0 0 8px; - font-size: 30px; -} -p { - margin: 0 0 24px; - color: #46546b; -} -label { - display: block; - margin-bottom: 8px; - font-weight: bold; -} -input { - width: 100%; - padding: 10px 12px; - border: 1px solid #aab4c2; - border-radius: 6px; - background: white; - color: inherit; - font: inherit; -} -input:focus-visible { - outline: 3px solid #bfd3ff; - border-color: #325fc6; -} -#count { - margin: 16px 0 8px; - font-size: 14px; -} -ul { - margin: 0; - padding: 0; - list-style: none; - border: 1px solid #ccd3de; - border-radius: 8px; - overflow: hidden; - background: white; -} -li { - padding: 16px 20px; -} -li + li { - border-top: 1px solid #e0e5ed; -} -#error { - color: #a12020; -} diff --git a/examples/lgtmd-web/tsconfig.json b/examples/lgtmd-web/tsconfig.json deleted file mode 100644 index 073a8d3..0000000 --- a/examples/lgtmd-web/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2023", - "module": "ESNext", - "moduleResolution": "Bundler", - "jsx": "react-jsx", - "strict": true, - "noEmit": true, - "allowImportingTsExtensions": true, - "types": ["vite/client", "node"], - "skipLibCheck": true - }, - "include": ["src/**/*.ts", "src/**/*.tsx", "guides/**/*.ts", "browser/**/*.ts", "*.ts"] -} diff --git a/examples/lgtmd-web/vite.config.ts b/examples/lgtmd-web/vite.config.ts deleted file mode 100644 index ad7c864..0000000 --- a/examples/lgtmd-web/vite.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; - -const apiProxy = { - "/api": process.env.RECORDS_API_URL ?? "http://127.0.0.1:4174", -}; - -export default defineConfig({ - plugins: [react()], - server: { - host: "127.0.0.1", - port: 4173, - strictPort: true, - // Playwright traces contain HTML snapshots. Writing review artifacts must - // not reload the developer's open application. - watch: { ignored: ["**/.cache/**"] }, - proxy: apiProxy, - }, - preview: { - host: "127.0.0.1", - port: 4175, - strictPort: true, - proxy: apiProxy, - }, -}); diff --git a/justfile b/justfile index 098e559..ee71417 100644 --- a/justfile +++ b/justfile @@ -180,28 +180,6 @@ lint: test *args: cargo test --locked "$@" -# Verify the maintained documentation SDK, consumers, examples, and exact baselines. -# Default application build/run/test routing remains on N3. -doc-framework-test: - python3 crates/lgtmd/dev/verify.py - -# Verify a disposable portable workspace, including the declared Rust minimum. -doc-framework-smoke *args: - python3 crates/lgtmd/dev/verify.py --standalone --toolchain 1.95.0 "$@" - -# Explicit setup: Node dependencies, native compiler, TS build, pinned Chromium. -doc-web-setup: - python3 packages/lgtmd/dev/verify.py --prepare - npm exec -- playwright install chromium - -# Portable web contracts, real browser replay, and clean Cargo/npm consumers. -doc-web-test *args: - python3 packages/lgtmd/dev/verify.py "$@" - -# Prove the web SDK without the N3 source tree, renderer, or workspace patches. -doc-web-smoke *args: - python3 packages/lgtmd/dev/verify.py --standalone --toolchain 1.95.0 "$@" - # Open the local guide preview, check it, or intentionally regenerate it. docs mode="serve" *args: #!/usr/bin/env bash @@ -261,4 +239,4 @@ test-metal: cargo test --locked documentation::capture::tests:: # Native development and pre-push checks. CI infrastructure is opt-in. -verify: fmt-check lint tools-test doc-framework-test test benchmark-rust-test +verify: fmt-check lint tools-test test benchmark-rust-test diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 31d426d..0000000 --- a/package-lock.json +++ /dev/null @@ -1,1364 +0,0 @@ -{ - "name": "n3-lgtmd-incubation", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "n3-lgtmd-incubation", - "workspaces": [ - "packages/lgtmd", - "examples/lgtmd-web" - ], - "devDependencies": { - "@playwright/test": "1.64.0", - "@types/node": "24.19.2", - "typescript": "7.0.2" - }, - "engines": { - "node": ">=24 <25" - } - }, - "examples/lgtmd-web": { - "name": "@lgtmd/example-web", - "version": "0.0.0", - "dependencies": { - "react": "19.3.0", - "react-dom": "19.3.0" - }, - "devDependencies": { - "@types/react": "19.3.0", - "@types/react-dom": "19.3.0", - "@vitejs/plugin-react": "6.1.2", - "lgtmd": "0.0.0", - "vite": "8.3.4" - } - }, - "node_modules/@lgtmd/example-web": { - "resolved": "examples/lgtmd-web", - "link": true - }, - "node_modules/@oxc-project/types": { - "version": "0.153.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", - "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/oxc-project" - } - }, - "node_modules/@playwright/test": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", - "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.64.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", - "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", - "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", - "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", - "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", - "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", - "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", - "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", - "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", - "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", - "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", - "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", - "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", - "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", - "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", - "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.19.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", - "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" - } - }, - "node_modules/@types/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", - "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", - "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.3.0" - } - }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", - "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "oxc-transform-react": "^0.152.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "oxc-transform-react": { - "optional": true - } - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/lgtmd": { - "resolved": "packages/lgtmd", - "link": true - }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/nanoid": { - "version": "3.3.20", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", - "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/playwright": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", - "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.64.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/playwright-core": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", - "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/postcss": { - "version": "8.5.30", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", - "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.20", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", - "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", - "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.28.0" - }, - "peerDependencies": { - "react": "^19.3.0" - } - }, - "node_modules/rolldown": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", - "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.153.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.13", - "@rolldown/binding-android-arm64": "1.2.13", - "@rolldown/binding-darwin-arm64": "1.2.13", - "@rolldown/binding-darwin-x64": "1.2.13", - "@rolldown/binding-freebsd-x64": "1.2.13", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", - "@rolldown/binding-linux-arm64-gnu": "1.2.13", - "@rolldown/binding-linux-arm64-musl": "1.2.13", - "@rolldown/binding-linux-ppc64-gnu": "1.2.13", - "@rolldown/binding-linux-s390x-gnu": "1.2.13", - "@rolldown/binding-linux-x64-gnu": "1.2.13", - "@rolldown/binding-linux-x64-musl": "1.2.13", - "@rolldown/binding-openharmony-arm64": "1.2.13", - "@rolldown/binding-win32-arm64-msvc": "1.2.13", - "@rolldown/binding-win32-x64-msvc": "1.2.13" - } - }, - "node_modules/scheduler": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", - "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", - "license": "MIT" - }, - "node_modules/source-map-js": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", - "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc" - }, - "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" - } - }, - "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", - "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.33.0", - "picomatch": "^4.0.7", - "postcss": "^8.5.29", - "rolldown": "~1.2.12", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "packages/lgtmd": { - "version": "0.0.0", - "license": "MIT", - "engines": { - "node": ">=24 <25" - }, - "peerDependencies": { - "@playwright/test": "1.64.0" - }, - "peerDependenciesMeta": { - "@playwright/test": { - "optional": true - } - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 9537f37..0000000 --- a/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "n3-lgtmd-incubation", - "private": true, - "workspaces": [ - "packages/lgtmd", - "examples/lgtmd-web" - ], - "type": "module", - "scripts": { - "build": "npm run build --workspace lgtmd", - "test": "npm run test --workspace lgtmd", - "typecheck": "npm run typecheck --workspace @lgtmd/example-web" - }, - "devDependencies": { - "@playwright/test": "1.64.0", - "@types/node": "24.19.2", - "typescript": "7.0.2" - }, - "engines": { - "node": ">=24 <25" - } -} diff --git a/packages/lgtmd/.gitignore b/packages/lgtmd/.gitignore deleted file mode 100644 index c4180d1..0000000 --- a/packages/lgtmd/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -/dist/ -/native/ -__pycache__/ diff --git a/packages/lgtmd/LICENSE b/packages/lgtmd/LICENSE deleted file mode 100644 index 943658a..0000000 --- a/packages/lgtmd/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Grida - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/lgtmd/README.md b/packages/lgtmd/README.md deleted file mode 100644 index 4e2af76..0000000 --- a/packages/lgtmd/README.md +++ /dev/null @@ -1,138 +0,0 @@ -# lgtmd for Node - -Write documentation beside ordinary application code. Combine prose, frozen -values, downloads, images, and contributor notes; use native assertions when -a claim should be verified. The shared Rust compiler exports Reader and -Contributor Markdown/resources from one completed run. - -This SDK is incubating locally. The npm package is private and unpublishable; -these APIs and schema-v1 input are not a stable release promise. - -```ts -import assert from "node:assert/strict"; -import { guide, runGuide, publish } from "lgtmd"; - -const example = guide({ id: "records", title: "Read an export" }, async (doc) => { - const count = doc.observe("record-count", 3); - await doc.check("three-records", () => assert.equal(count.value, 3)); - const data = doc.assets.json("records", { count: count.value }); - const passage = doc.md` - The export contains ${count} records. - - ${data.code()} - - ${data.link("Download JSON")} - `; - doc.noteOn(passage)`This example uses supplied data.`; -}); - -await publish([await runGuide(example)], { - command: "build", - reader: "/absolute/new-review/reader", - contributor: "/absolute/new-review/contributor", -}); -``` - -`guide` declares content without executing it. `runGuide` executes authoring and -returns frozen input only after acquired cleanup succeeds. An optional `prepare` -returns `{context, cleanup?}`; the project owns partial preparation failure. -`runGuideWithContext(page, context, write)` accepts an existing runner's context -without taking over cleanup. Primary and cleanup failures retain both errors. - -`doc.md` dedents its literal Markdown and ends each block automatically. Scalar -substitutions and observation values are escaped. Typed image/link/code views -retain references; code views occupy their own blank-line-separated block. -Use ordinary functions for reuse and `section(id, title)` for a stable anchor. -Paragraph IDs are automatic. Resource registration appends no prose. - -`observe` records a snapshot, and `check` executes a native assertion returning -void. Checks are optional. Caught check failures still taint the document; pending -checks prevent finishing. An ordinary assertion outside `check` remains the -host's outcome and does not manufacture a named SDK check. Values and bytes copy -at registration. Unsupported JSON fails rather than silently disappearing. -The [Rust SDK](../../crates/lgtmd/README.md#shared-compiler-and-node-authoring) -describes the portable value domain and host serialization limits. - -Notes are plain contributor prose. Reader exports omit notes, exclusively private -resources/observations, and their metadata. Check IDs and status remain public -metadata; their values appear only when referenced publicly. Notes use a separate -counter, so adding them does not change Reader anchors or bytes. - -`compile(documents, layout?)` returns both audiences without writing files. -`publish` uses Rust's owned artifact store: `build` requires fresh destinations, -`check` compares exactly without acceptance, and `update` explicitly accepts a -compatible baseline. `layout` selects page/resource prefixes and exact page -overrides. Destinations must be separate, including transaction siblings; symlink -paths are rejected. Audiences publish independently, not as one atomic operation. - -## Playwright - -```ts -import { test as applicationTest, expect } from "@playwright/test"; -import { withDocs } from "lgtmd/playwright"; - -const test = withDocs(applicationTest); -test("Find a record", async ({ page, doc }) => { - doc.page({ id: "find-record", title: "Find a record" }); - // Navigate and prepare using the project's existing fixtures. - await page.getByRole("searchbox", { name: "Search records" }).fill("Alpha"); - const results = page.getByRole("list", { name: "Records" }); - await expect(results.getByRole("listitem")).toHaveCount(2); - const image = doc.assets.image("matches", await results.screenshot()); - doc.md`The two matching records appear below. - -${image.embed("Two matching records")}`; -}); -``` - -Configure `reporter: docsReporter()` and run through `runPlaywright(options)`. -Existing fixtures, queries, assertions, app state, and backend preparation stay -project-owned. A fixture only attaches candidate input. The parent publishes -after the runner process exits successfully and its unique completed receipt -confirms all tests, teardown and reporters succeeded. Timeout, interruption, -worker/global teardown failure, swallowed named check failure, duplicate document -IDs, and missing receipts block publication. - -Playwright swallows reporter exceptions, so `docsReporter({reporters: [...]})` -guards imported custom reporter instances through their final callbacks. It must -be the sole configured reporter; unguarded extra reporters are rejected. Built-in -reporter-name delegation is not supported. Direct `playwright test` can collect -attachments but never publishes docs. Initial support requires one project, -retries zero, repeatEach one, no shards or dependency/teardown projects, and clean -passed outcomes for every discovered test. Unsupported modes reject acceptance. - -See the [complete example](../../examples/lgtmd-web/README.md), including its -server, preparation fixture, config, and finalizer. DOM queries remain subject to -application changes; documentation does not make them unbreakable. - -## Development and support - -From the incubating repository root: - -```sh -just doc-web-setup -just doc-web-test --offline -just doc-web-smoke --offline -``` - -Setup explicitly installs npm dependencies and Chromium, builds the native bridge, -and compiles TypeScript. Checks do not silently download browsers or require -Docker. The standalone gate copies only portable sources, tests them in their own -workspace, packages Cargo/npm, and installs the npm tarball with Rust absent from -PATH. Playwright is an optional peer; importing the core does not require it. - -Initial runtime: **Node 24, ESM**. Native `darwin-arm64` is the locally verified -target; `linux-x64` has a configured CI lane and remains unverified until that lane -runs. Other targets, CommonJS, and browser-hosted SDK execution have no support -claim. Installing a prepared npm tarball requires no Rust compiler; contributors -build the platform binding before packing. There is no install-time build or -fallback download. Unsupported platforms fail clearly. Distributed binaries, -signing, release versions, registry installation, and broader platform testing -remain release work. - -The compiler and strict artifact store are Rust; authoring and host lifecycle are -native Node APIs. WASM compilation was exercised against the same input, but its -filesystem publication is unsupported. The initial coarse native bridge reuses -the existing store without implementing a second filesystem contract. A full -canonical viewer, cursor/key overlays, AI-assisted execution, and repository -extraction remain later work. diff --git a/packages/lgtmd/dev/tests/test_verify.py b/packages/lgtmd/dev/tests/test_verify.py deleted file mode 100644 index 6c8fed1..0000000 --- a/packages/lgtmd/dev/tests/test_verify.py +++ /dev/null @@ -1,55 +0,0 @@ -import importlib.util -import json -import os -from pathlib import Path -import tempfile -import unittest -from unittest import mock - - -SPEC = importlib.util.spec_from_file_location("lgtmd_web_verify", Path(__file__).resolve().parents[1] / "verify.py") -VERIFY = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(VERIFY) - - -class PortablePayloadTests(unittest.TestCase): - def test_copy_has_only_portable_sources_and_preserves_fixture_paths(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) / "source" - destination = Path(temporary) / "copy" - root.mkdir() - (root / "src").mkdir() - (root / "src/private-n3.rs").write_text("not portable", encoding="utf-8") - (root / "Cargo.lock").write_text("host lock", encoding="utf-8") - (root / "package.json").write_text(json.dumps({"workspaces": ["packages/lgtmd", "examples/lgtmd-web"]}), encoding="utf-8") - (root / "package-lock.json").write_text("{}", encoding="utf-8") - for relative in VERIFY.SOURCES: - package = root / relative - package.mkdir(parents=True) - (package / "LICENSE").write_text("license", encoding="utf-8") - for disposable in (".cache", "target", "node_modules", "dist", "native"): - (package / disposable).mkdir() - (package / disposable / "stale").write_text("do not copy", encoding="utf-8") - fixture = root / "crates/lgtmd/tests/fixtures/target/.ownership.json" - fixture.parent.mkdir(parents=True) - fixture.write_text("fixture bytes", encoding="utf-8") - VERIFY.payload(root, destination) - self.assertEqual((destination / "Cargo.toml").read_text(encoding="utf-8"), VERIFY.WORKSPACE) - self.assertFalse((destination / "Cargo.lock").exists()) - self.assertFalse((destination / "src").exists()) - self.assertEqual((destination / "crates/lgtmd/tests/fixtures/target/.ownership.json").read_text(encoding="utf-8"), "fixture bytes") - for relative in VERIFY.SOURCES: - self.assertTrue((destination / relative / "LICENSE").exists()) - for disposable in (".cache", "target", "node_modules", "dist", "native"): - self.assertFalse((destination / relative / disposable).exists()) - - def test_relative_explicit_cargo_overrides_keep_the_invocation_base(self): - with tempfile.TemporaryDirectory() as temporary, mock.patch.dict(os.environ, {"CARGO_HOME": "cache/cargo", "CARGO_TARGET_DIR": "cache/target", "npm_config_cache": "cache/npm"}): - environment = VERIFY.environment_for(Path(temporary)) - self.assertEqual(environment["CARGO_HOME"], str(Path.cwd() / "cache/cargo")) - self.assertEqual(environment["CARGO_TARGET_DIR"], str(Path.cwd() / "cache/target")) - self.assertEqual(environment["npm_config_cache"], str(Path.cwd() / "cache/npm")) - - -if __name__ == "__main__": - unittest.main() diff --git a/packages/lgtmd/dev/verify.py b/packages/lgtmd/dev/verify.py deleted file mode 100644 index da65b7e..0000000 --- a/packages/lgtmd/dev/verify.py +++ /dev/null @@ -1,342 +0,0 @@ -"""Verify lgtmd's web SDK, real browser example, and clean package consumers.""" - -import argparse -import json -import os -from pathlib import Path, PurePosixPath -import shutil -import subprocess -import sys -import tarfile -import tempfile - - -SDK = Path(__file__).resolve().parents[1] -SOURCES = ( - "crates/lgtmd", - "crates/lgtmd-node", - "packages/lgtmd", - "examples/lgtmd-web", -) -WORKSPACE = """[workspace] -members = ["crates/lgtmd", "crates/lgtmd-node"] -resolver = "3" -""" - - -class VerificationError(Exception): - pass - - -def run(command, *, cwd, environment, capture=False): - command = [str(part) for part in command] - print("Web check: " + " ".join(command), flush=True) - result = subprocess.run( - command, cwd=cwd, env=environment, check=False, - stdout=subprocess.PIPE if capture else None, - text=True, - ) - if result.returncode: - if capture and result.stdout: - print(result.stdout, file=sys.stderr) - raise VerificationError(f"Command failed with exit {result.returncode}: {command[0]}") - return result.stdout if capture else None - - -def payload(root, destination): - """Copy portable maintained sources; never copy N3 or prebuilt binaries.""" - for relative in SOURCES: - source = root / relative - if not source.is_dir(): - raise OSError(f"Portable web payload is missing {source}") - - def disposable(directory, names): - directory = Path(directory) - excluded = set() - if directory == source: - excluded.update((".cache", "target", "Cargo.lock", "node_modules", "dist", "native")) - if directory == source / "dev" or source / "dev" in directory.parents: - excluded.add("__pycache__") - return excluded.intersection(names) - - shutil.copytree(source, destination / relative, ignore=disposable) - for filename in ("package.json", "package-lock.json"): - shutil.copyfile(root / filename, destination / filename) - npm = json.loads((destination / "package.json").read_text(encoding="utf-8")) - if npm.get("workspaces") != ["packages/lgtmd", "examples/lgtmd-web"]: - raise VerificationError("The portable npm manifest must contain only the SDK and web example") - (destination / "Cargo.toml").write_text(WORKSPACE, encoding="utf-8") - return destination - - -def environment_for(root): - environment = os.environ.copy() - environment["PYTHONDONTWRITEBYTECODE"] = "1" - invocation = Path.cwd() - for name, default in ( - ("CARGO_HOME", root / ".cache/cargo"), - ("CARGO_TARGET_DIR", root / ".cache/lgtmd-web/target"), - ("npm_config_cache", root / ".cache/npm"), - ): - inherited = environment.get(name) - if name == "npm_config_cache": - inherited = inherited or environment.get("NPM_CONFIG_CACHE") - value = Path(inherited or str(default)) - environment[name] = str(value if value.is_absolute() else invocation / value) - if name == "npm_config_cache" and "NPM_CONFIG_CACHE" in environment: - environment["NPM_CONFIG_CACHE"] = environment[name] - return environment - - -def extract_package(archive, destination, package_name): - """Extract ordinary Cargo files without accepting links or traversal paths.""" - with tarfile.open(archive, "r:gz") as contents: - for member in contents: - path = PurePosixPath(member.name) - if path.is_absolute() or ".." in path.parts or path.parts[0] != package_name: - raise VerificationError(f"Unexpected Cargo archive path: {member.name}") - output = destination.joinpath(*path.parts) - if member.isdir(): - output.mkdir(parents=True, exist_ok=True) - elif member.isfile(): - output.parent.mkdir(parents=True, exist_ok=True) - with contents.extractfile(member) as source, output.open("wb") as target: - shutil.copyfileobj(source, target) - else: - raise VerificationError(f"Cargo archive contains a non-file entry: {member.name}") - return destination / package_name - - -def cargo_consumer(root, cargo, environment, offline): - flags = ["--locked", *(["--offline"] if offline else [])] - # Cargo's default package verification builds the actual normalized archive. - run([*cargo, "package", "-p", "lgtmd", "--allow-dirty", *flags], cwd=root, environment=environment) - metadata = json.loads(run( - [*cargo, "metadata", "--no-deps", "--format-version", "1", *flags], - cwd=root, environment=environment, capture=True, - )) - package = next(package for package in metadata["packages"] if package["name"] == "lgtmd") - name = f"lgtmd-{package['version']}" - archive = Path(metadata["target_directory"]) / "package" / f"{name}.crate" - with tempfile.TemporaryDirectory(prefix="lgtmd-cargo-consumer-") as temporary: - directory = Path(temporary).resolve() - extracted = extract_package(archive, directory / "extracted", name) - consumer = directory / "consumer" - (consumer / "src").mkdir(parents=True) - dependency_path = json.dumps(str(extracted), ensure_ascii=False) - (consumer / "Cargo.toml").write_text( - "[package]\nname = \"lgtmd-clean-consumer\"\nversion = \"0.0.0\"\nedition = \"2024\"\n" - f"[dependencies]\nlgtmd = {{ path = {dependency_path} }}\n", - encoding="utf-8", - ) - (consumer / "src/main.rs").write_text( - "use lgtmd::{Audience, Doc, Document};\n" - "fn main() {\n" - " let mut doc = Doc::new(\"packaged\", \"Packaged SDK\").unwrap();\n" - " let value = doc.observe(\"count\", 3).unwrap();\n" - " doc.paragraph((\"There are \", &value, \" records.\")).unwrap();\n" - " let files = Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap();\n" - " assert!(String::from_utf8_lossy(&files[\"packaged.md\"]).contains(\"3 records\"));\n" - "}\n", - encoding="utf-8", - ) - run([*cargo, "generate-lockfile", *(["--offline"] if offline else [])], cwd=consumer, environment=environment) - run([*cargo, "run", *flags], cwd=consumer, environment=environment) - - -CORE_SMOKE = """import assert from 'node:assert/strict'; -import { createRequire } from 'node:module'; -import { resolve } from 'node:path'; -import { Doc, compile, publish } from 'lgtmd'; -const require = createRequire(import.meta.url); -assert.throws(() => require.resolve('@playwright/test'), { code: 'MODULE_NOT_FOUND' }); -const doc = new Doc('packaged', 'Packaged SDK'); -const count = doc.observe('count', 3); -const data = doc.assets.json('data', { count: 3 }); -const passage = doc.md`There are ${count} records. - -${data.code()} - -${data.link('Download JSON')}`; -doc.noteOn(passage)`This package runs without Rust on PATH.`; -const document = doc.finish(); -const exports = await compile([document]); -assert.match(new TextDecoder().decode(exports.reader['packaged.md']), /3 records/); -assert.ok(exports.reader['assets/packaged/data.json']); -const options = { reader: resolve('output/reader'), contributor: resolve('output/contributor') }; -await publish([document], { ...options, command: 'build' }); -await publish([document], { ...options, command: 'check' }); -console.log('Packaged core compiled and published with Rust absent from PATH and Playwright not installed.'); -""" - -PLAYWRIGHT_SMOKE = """import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { test } from '@playwright/test'; -import { withDocs, docsReporter, runPlaywright } from 'lgtmd/playwright'; -const extended = withDocs(test); -assert.equal(typeof extended.extend, 'function'); -assert.equal(typeof runPlaywright, 'function'); -assert.equal(docsReporter().length, 1); -const options = { - config: new URL('./playwright.config.mjs', import.meta.url), - reader: resolve('playwright-output/reader'), - contributor: resolve('playwright-output/contributor'), -}; -await runPlaywright({ ...options, command: 'build' }); -await runPlaywright({ ...options, command: 'check' }); -assert.match(await readFile('playwright-output/reader/packaged-runner.md', 'utf8'), /The installed runner executed/); -console.log('Packaged optional Playwright fixture, reporter, finalizer, and publication passed.'); -""" - -PLAYWRIGHT_TEST = """import assert from 'node:assert/strict'; -import { test as base } from '@playwright/test'; -import { withDocs } from 'lgtmd/playwright'; -const test = withDocs(base); -test('document an ordinary Node result', async ({ doc }) => { - doc.page({ id: 'packaged-runner', title: 'Packaged runner' }); - await doc.check('sum', () => { assert.equal(1 + 1, 2); }); - doc.md`The installed runner executed the documented assertion.`; -}); -""" - -PLAYWRIGHT_CONFIG = """import { docsReporter } from 'lgtmd/playwright'; -export default { - testDir: '.', testMatch: 'packaged.spec.mjs', - outputDir: '.cache/playwright', - workers: 1, retries: 0, timeout: 5000, - reporter: docsReporter(), -}; -""" - - -def npm_consumer(root, node, npm, environment, offline): - with tempfile.TemporaryDirectory(prefix="lgtmd-npm-consumer-") as temporary: - directory = Path(temporary).resolve() - archives = directory / "archives" - archives.mkdir() - packed = json.loads(run( - [npm, "pack", "--json", "--pack-destination", archives], - cwd=root / "packages/lgtmd", environment=environment, capture=True, - )) - archive = archives / packed[0]["filename"] - consumer = directory / "consumer" - consumer.mkdir() - (consumer / "package.json").write_text(json.dumps({ - "name": "lgtmd-clean-consumer", "version": "0.0.0", "private": True, - "type": "module", "dependencies": {"lgtmd": f"file:{archive}"}, - }), encoding="utf-8") - only_node = directory / "node-only-bin" - only_node.mkdir() - (only_node / "node").symlink_to(Path(node).resolve()) - isolated = environment.copy() - isolated["PATH"] = str(only_node) - isolated.pop("LGTMD_CONFORMANCE_FIXTURE", None) - isolated.pop("NODE_PATH", None) - isolated.pop("NODE_OPTIONS", None) - if shutil.which("cargo", path=isolated["PATH"]) or shutil.which("rustc", path=isolated["PATH"]): - raise VerificationError("The npm consumer must have no Rust toolchain on PATH") - install = [npm, "install", "--global=false", "--prefix", consumer, "--no-audit", "--no-fund", *(["--offline"] if offline else [])] - # Installation itself has only Node on PATH; no ignored install scripts - # or local source paths can conceal a build-time Rust requirement. - run(install, cwd=consumer, environment=isolated) - (consumer / "core.mjs").write_text(CORE_SMOKE, encoding="utf-8") - run([node, "core.mjs"], cwd=consumer, environment=isolated) - version = json.loads((root / "package.json").read_text(encoding="utf-8"))["devDependencies"]["@playwright/test"] - run([*install, f"@playwright/test@{version}"], cwd=consumer, environment=isolated) - (consumer / "playwright.mjs").write_text(PLAYWRIGHT_SMOKE, encoding="utf-8") - (consumer / "playwright.config.mjs").write_text(PLAYWRIGHT_CONFIG, encoding="utf-8") - (consumer / "packaged.spec.mjs").write_text(PLAYWRIGHT_TEST, encoding="utf-8") - run([node, "playwright.mjs"], cwd=consumer, environment=isolated) - - -def node_tools(root, environment): - node = shutil.which("node", path=environment.get("PATH")) - npm = shutil.which("npm", path=environment.get("PATH")) - if not node or not npm: - raise VerificationError("Web verification requires Node.js 24 and npm") - version = run([node, "-p", "process.versions.node"], cwd=root, environment=environment, capture=True).strip() - if version.split(".")[0] != "24": - raise VerificationError(f"Web verification requires Node.js 24; found {version}") - tag = run([node, "-p", "process.platform + '-' + process.arch"], cwd=root, environment=environment, capture=True).strip() - return node, npm, tag - - -def prepare(root, cargo, environment, offline, npm, tag): - flags = ["--locked", *(["--offline"] if offline else [])] - run([*cargo, "build", "-p", "lgtmd-node", "--release", *flags], cwd=root, environment=environment) - executable = "lgtmd-node.exe" if tag.startswith("win32-") else "lgtmd-node" - binary = Path(environment["CARGO_TARGET_DIR"]) / "release" / executable - native = root / "packages/lgtmd/native" / tag / executable - native.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(binary, native) - run([npm, "ci", "--no-audit", "--no-fund", *(["--offline"] if offline else [])], cwd=root, environment=environment) - run([npm, "run", "build"], cwd=root, environment=environment) - - -def checks(root, cargo, environment, offline, install_browser): - node, npm, tag = node_tools(root, environment) - environment = environment.copy() - environment["LGTMD_CONFORMANCE_FIXTURE"] = str(root / "crates/lgtmd/tests/fixtures/portable.json") - selection = ["-p", "lgtmd", "-p", "lgtmd-node"] - flags = ["--locked", *(["--offline"] if offline else [])] - run([sys.executable, "-m", "unittest", "discover", "-s", root / "packages/lgtmd/dev/tests"], cwd=root, environment=environment) - run([*cargo, "fmt", *selection, "--check"], cwd=root, environment=environment) - run([*cargo, "clippy", *selection, *flags, "--all-targets", "--", "-D", "warnings"], cwd=root, environment=environment) - run([*cargo, "test", *selection, *flags, "--all-targets"], cwd=root, environment=environment) - prepare(root, cargo, environment, offline, npm, tag) - if install_browser: - run([npm, "exec", "--", "playwright", "install", "chromium"], cwd=root, environment=environment) - run([npm, "test"], cwd=root, environment=environment) - run([npm, "run", "typecheck"], cwd=root, environment=environment) - with tempfile.TemporaryDirectory(prefix="lgtmd-web-review-") as temporary: - candidate = Path(temporary).resolve() / "candidate" - command = [node, "examples/lgtmd-web/run.ts"] - run([*command, "build", candidate], cwd=root, environment=environment) - run([*command, "check", candidate], cwd=root, environment=environment) - cargo_consumer(root, cargo, environment, offline) - npm_consumer(root, node, npm, environment, offline) - - -def main(arguments=None, *, sdk=SDK): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--standalone", action="store_true", help="copy only portable sources to a minimal temporary workspace") - parser.add_argument("--prepare", action="store_true", help="install npm dependencies and build the local binding without verification or browser installation") - parser.add_argument("--toolchain", help="select an installed Rust toolchain, e.g. 1.95.0") - parser.add_argument("--offline", action="store_true", help="use warm Cargo and npm caches; Chromium must already be installed") - parser.add_argument("--install-browser", action="store_true", help="explicitly install the pinned Chromium browser before real browser checks") - options = parser.parse_args(arguments) - if options.offline and options.install_browser: - parser.error("--offline cannot install a browser; run the explicit browser setup separately") - if options.prepare and options.install_browser: - parser.error("--prepare does not install a browser; run the explicit browser setup separately") - cargo = ["cargo", *([f"+{options.toolchain}"] if options.toolchain else [])] - try: - root = Path(sdk).resolve().parents[1] - environment = environment_for(root) - if options.standalone: - with tempfile.TemporaryDirectory(prefix="lgtmd-web-standalone-") as temporary: - isolated = payload(root, Path(temporary).resolve()) - run([*cargo, "generate-lockfile", *(["--offline"] if options.offline else [])], cwd=isolated, environment=environment) - if options.prepare: - _, npm, tag = node_tools(isolated, environment) - prepare(isolated, cargo, environment, options.offline, npm, tag) - else: - checks(isolated, cargo, environment, options.offline, options.install_browser) - else: - if options.prepare: - _, npm, tag = node_tools(root, environment) - prepare(root, cargo, environment, options.offline, npm, tag) - else: - checks(root, cargo, environment, options.offline, options.install_browser) - print("Web SDK prepared." if options.prepare else "Portable web verification passed.", flush=True) - return 0 - except (OSError, ValueError, KeyError, VerificationError) as error: - print(f"Portable web verification failed: {error}", file=sys.stderr) - return 1 - except KeyboardInterrupt: - return 130 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/packages/lgtmd/package.json b/packages/lgtmd/package.json deleted file mode 100644 index 220ac1b..0000000 --- a/packages/lgtmd/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "lgtmd", - "version": "0.0.0", - "private": true, - "description": "Code-authored documents compiled by lgtmd's shared Rust engine", - "license": "MIT", - "files": [ - "dist/src", - "native", - "LICENSE", - "README.md" - ], - "type": "module", - "exports": { - ".": { - "types": "./dist/src/index.d.ts", - "import": "./dist/src/index.js" - }, - "./playwright": { - "types": "./dist/src/playwright/index.d.ts", - "import": "./dist/src/playwright/index.js" - }, - "./playwright/reporter": "./dist/src/playwright/reporter.js" - }, - "scripts": { - "build": "tsc -p tsconfig.json", - "test": "node --test dist/tests/*.test.js" - }, - "peerDependencies": { - "@playwright/test": "1.64.0" - }, - "peerDependenciesMeta": { - "@playwright/test": { - "optional": true - } - }, - "engines": { - "node": ">=24 <25" - } -} diff --git a/packages/lgtmd/src/authoring.ts b/packages/lgtmd/src/authoring.ts deleted file mode 100644 index 0396457..0000000 --- a/packages/lgtmd/src/authoring.ts +++ /dev/null @@ -1,481 +0,0 @@ -import { deepFreeze, freezeJson, singleLine, validId } from "./model.js"; -import type { - FrozenDocument, - FrozenResource, - Json, - Node, - Page, - Part, - Reference, - Scalar, -} from "./model.js"; - -const identity = Symbol("lgtmd target"); -const presentation = Symbol("lgtmd presentation"); -export interface Target { - readonly [identity]: Reference["kind"]; -} -export interface Block extends Target { - readonly [identity]: "block"; -} -type FrozenValue = T extends object - ? { readonly [Key in keyof T]: FrozenValue } - : T; -export interface Observation extends Target { - readonly [identity]: "observation"; - readonly value: FrozenValue; -} -export interface Check extends Target { - readonly [identity]: "claim"; -} -export interface Presentation { - readonly [presentation]: true; -} -export interface Asset extends Target { - readonly [identity]: "resource"; - link(label: string): Presentation; -} -export interface ImageAsset extends Asset { - embed(alt: string): Presentation; -} -export interface CodeAsset extends Asset { - code(): Presentation; -} -export type Content = Scalar | Observation | Presentation; -export type Markdown = ( - strings: TemplateStringsArray, - ...values: Content[] -) => Block; -export type Note = ( - strings: TemplateStringsArray, - ...values: Content[] -) => void; - -export interface ResourceOptions { - readonly producer?: string; - readonly profile?: string; -} -export interface ImageOptions extends ResourceOptions { - readonly mediaType?: - | "image/png" - | "image/jpeg" - | "image/webp" - | "image/gif" - | "image/svg+xml"; -} -export interface FileOptions extends ResourceOptions { - readonly bytes: Uint8Array; - readonly mediaType: string; - readonly extension: string; -} - -const targets = new WeakMap(); -const presentations = new WeakMap(); - -/** Dedent template literals without inspecting or changing interpolated values. */ -function dedent(strings: readonly string[]): string[] { - let index = 0; - let marker = `\uE000${index}\uE001`; - while (strings.some((text) => text.includes(marker))) { - index += 1; - marker = `\uE000${index}\uE001`; - } - const lines = strings.join(marker).replace(/\r\n?/g, "\n").split("\n"); - if (lines[0]?.trim() === "") lines.shift(); - if (lines.at(-1)?.trim() === "") lines.pop(); - const nonempty = lines.filter((line) => line.trim() !== ""); - const indent = - nonempty.length === 0 - ? 0 - : Math.min(...nonempty.map((line) => /^[ \t]*/.exec(line)![0].length)); - return lines - .map((line) => - line.slice(Math.min(indent, /^[ \t]*/.exec(line)![0].length)), - ) - .join("\n") - .split(marker); -} - -export class Doc { - #page?: Page; - #finished = false; - #ids = new Map(); - #nodes: Node[] = []; - #observations: { id: string; value: Json }[] = []; - #checks: { id: string; kind: "predicate"; passed: true }[] = []; - #resources: FrozenResource[] = []; - #errors: unknown[] = []; - #pending = 0; - #blocks = 0; - #notes = 0; - - constructor(id?: string, title?: string) { - if (id !== undefined || title !== undefined) - this.page({ id: id!, title: title! }); - Object.freeze(this); - } - - #open(): void { - if (this.#finished) throw new Error("Document is already finished"); - } - - #guard(operation: () => T): T { - this.#open(); - try { - return operation(); - } catch (error) { - this.#errors.push(error); - throw error; - } - } - - page(page: Page): void { - this.#guard(() => { - if (this.#page) throw new Error("Document identity can only be set once"); - if (!validId(page.id)) - throw new TypeError(`Invalid document ID: ${String(page.id)}`); - singleLine(page.title, "Document title"); - this.#page = Object.freeze({ id: page.id, title: page.title }); - }); - } - - #reserve(id: string, kind: Reference["kind"]): Reference { - if (!validId(id)) throw new TypeError(`Invalid evidence ID: ${String(id)}`); - if (this.#ids.has(id)) throw new Error(`Duplicate evidence ID: ${id}`); - this.#ids.set(id, kind); - return Object.freeze({ id, kind }); - } - - #target(reference: Reference, properties: object = {}): T { - const target = Object.freeze({ [identity]: reference.kind, ...properties }); - targets.set(target, { owner: this, reference }); - return target as T; - } - - #reference(target: Target): Reference { - const found = - typeof target === "object" && target !== null - ? targets.get(target) - : undefined; - if (!found || found.owner !== this) - throw new Error("Foreign or forged document handle"); - return found.reference; - } - - #presentation(part: Part): Presentation { - const value = Object.freeze({ [presentation]: true as const }); - presentations.set(value, { owner: this, part }); - return value; - } - - #parts( - strings: TemplateStringsArray, - values: readonly Content[], - literalKind: "markdown" | "text" = "markdown", - ): Part[] { - if ( - !Array.isArray(strings) || - strings.length !== values.length + 1 || - !strings.every((value) => typeof value === "string") - ) { - throw new TypeError( - "Markdown and notes must be called as tagged templates", - ); - } - const literals = dedent(strings); - const parts: Part[] = []; - for (let index = 0; index < literals.length; index += 1) { - const literal = literals[index]!; - if (literal !== "") parts.push({ kind: literalKind, text: literal }); - if (index >= values.length) continue; - const value = values[index]!; - if ( - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" - ) { - freezeJson(value); - parts.push({ kind: "text", text: String(value) }); - } else if (typeof value === "object" && value !== null) { - const view = presentations.get(value); - if (view) { - if (view.owner !== this) - throw new Error("Foreign document presentation"); - if (literalKind === "text" && view.part.kind === "code") - throw new TypeError( - "Code resource presentations belong in Markdown blocks, not contributor notes", - ); - parts.push(view.part); - } else { - const reference = this.#reference(value as Observation); - if (reference.kind !== "observation") - throw new TypeError( - "Interpolate an observation or an explicit resource presentation", - ); - parts.push({ kind: "reference", target: reference }); - } - } else { - throw new TypeError( - "Unsupported Markdown interpolation; observe JSON values or use a typed resource presentation", - ); - } - } - return parts; - } - - readonly md: Markdown = (strings, ...values) => - this.#guard(() => { - const parts = this.#parts(strings, values); - // A template is one authored block. The native Markdown-parts API also - // supports fragments, so terminate this block before compilation. - parts.push({ kind: "markdown", text: "\n\n" }); - const reference = this.#reserve( - `block-${String(++this.#blocks).padStart(4, "0")}`, - "block", - ); - this.#nodes.push({ id: reference.id, kind: "markdown", parts }); - return this.#target(reference); - }); - - section(id: string, title: string): Block { - return this.#guard(() => { - singleLine(title, "Section title"); - const reference = this.#reserve(id, "block"); - this.#blocks += 1; - this.#nodes.push({ id, kind: "heading", level: 2, text: title }); - return this.#target(reference); - }); - } - - observe(id: string, value: T): Observation { - return this.#guard(() => { - const frozen = freezeJson(value); - const reference = this.#reserve(id, "observation"); - this.#observations.push({ id, value: frozen }); - return this.#target>(reference, { value: frozen }); - }); - } - - async check( - id: string, - assertion: () => void | Promise, - ): Promise { - const reference = this.#guard(() => { - if (typeof assertion !== "function") - throw new TypeError("Check requires an assertion callback"); - return this.#reserve(id, "claim"); - }); - this.#pending += 1; - try { - const result: unknown = await assertion(); - if (result !== undefined) - throw new TypeError( - "Check callbacks must return void; assert behavior inside the callback", - ); - this.#open(); - this.#checks.push({ id, kind: "predicate", passed: true }); - return this.#target(reference); - } catch (error) { - this.#errors.push(error); - throw error; - } finally { - this.#pending -= 1; - } - } - - readonly note: Note = (strings, ...values) => { - this.#appendNote(undefined, strings, values); - }; - - noteOn(target: Target): Note { - const reference = this.#guard(() => this.#reference(target)); - return (strings, ...values) => { - this.#appendNote(reference, strings, values); - }; - } - - #appendNote( - target: Reference | undefined, - strings: TemplateStringsArray, - values: readonly Content[], - ): void { - this.#guard(() => { - const parts = this.#parts(strings, values, "text"); - const reference = this.#reserve( - `note-${String(++this.#notes).padStart(4, "0")}`, - "block", - ); - this.#nodes.push( - target - ? { id: reference.id, kind: "note", target, parts } - : { id: reference.id, kind: "note", parts }, - ); - }); - } - - #resource( - id: string, - bytes: Uint8Array, - mime: string, - extension: string, - producer: string, - profile: string, - json?: Json, - ): Reference { - if (!(bytes instanceof Uint8Array)) - throw new TypeError("Resource bytes must be a Uint8Array"); - if ( - typeof mime !== "string" || - !/^[a-zA-Z0-9!#$&^_.+-]+\/[a-zA-Z0-9!#$&^_.+-]+$/.test(mime) - ) - throw new TypeError("Resource requires a MIME type without parameters"); - if (typeof extension !== "string" || !/^[a-z0-9]{1,16}$/.test(extension)) - throw new TypeError( - "Resource extension must be 1..16 lowercase letters or digits", - ); - singleLine(producer, "Resource producer"); - singleLine(profile, "Resource profile"); - const reference = this.#reserve(id, "resource"); - this.#resources.push({ - id, - mime, - extension, - producer, - profile, - bytes: Array.from(bytes), - ...(json !== undefined ? { json } : {}), - }); - return reference; - } - - #asset(reference: Reference, extras: object = {}): Asset { - return this.#target(reference, { - link: (label: string) => - this.#guard(() => { - singleLine(label, "Resource link label"); - return this.#presentation({ kind: "link", target: reference, label }); - }), - ...extras, - }); - } - - #codeAsset(reference: Reference, language: string): CodeAsset { - if (typeof language !== "string" || !/^[a-zA-Z0-9_+-]*$/.test(language)) - throw new TypeError("Code language must be a plain fence label"); - return this.#asset(reference, { - code: () => - this.#guard(() => - this.#presentation({ kind: "code", target: reference, language }), - ), - }) as CodeAsset; - } - - readonly assets = Object.freeze({ - image: ( - id: string, - bytes: Uint8Array, - options: ImageOptions = {}, - ): ImageAsset => - this.#guard(() => { - const mime = options.mediaType ?? "image/png"; - const extensions = { - "image/png": "png", - "image/jpeg": "jpg", - "image/webp": "webp", - "image/gif": "gif", - "image/svg+xml": "svg", - }; - const extension = extensions[mime]; - if (!extension) - throw new TypeError(`Unsupported image MIME type: ${mime}`); - const reference = this.#resource( - id, - bytes, - mime, - extension, - options.producer ?? "lgtmd/image", - options.profile ?? "exact-v1", - ); - return this.#asset(reference, { - embed: (alt: string) => - this.#guard(() => { - singleLine(alt, "Image alternative text"); - return this.#presentation({ - kind: "image", - target: reference, - label: alt, - }); - }), - }) as ImageAsset; - }), - json: (id: string, value: Json): CodeAsset => - this.#guard(() => { - const frozen = freezeJson(value); - return this.#codeAsset( - this.#resource( - id, - new Uint8Array(), - "application/json", - "json", - "executable-docs/json", - "json-pretty-v1", - frozen, - ), - "json", - ); - }), - text: (id: string, value: string, language = "text"): CodeAsset => - this.#guard(() => { - if (typeof value !== "string") - throw new TypeError("Text resource requires a string"); - freezeJson(value); - return this.#codeAsset( - this.#resource( - id, - new TextEncoder().encode(value), - "text/plain", - "txt", - "executable-docs/text", - "utf8-v1", - ), - language, - ); - }), - file: (id: string, file: FileOptions): Asset => - this.#guard(() => - this.#asset( - this.#resource( - id, - file.bytes, - file.mediaType, - file.extension, - file.producer ?? "lgtmd/file", - file.profile ?? "exact-v1", - ), - ), - ), - }); - - /** Freeze once. Application/runner success and publication remain host responsibilities. */ - finish(): FrozenDocument { - this.#open(); - this.#finished = true; - if (this.#errors.length > 0) - throw new AggregateError( - this.#errors, - "Document authoring failed; caught failures cannot produce accepted evidence", - ); - if (this.#pending > 0) - throw new Error("Document has pending checks; await every named check"); - if (!this.#page) - throw new Error("Document identity is missing; call doc.page once"); - return deepFreeze({ - ...this.#page, - nodes: this.#nodes, - observations: this.#observations, - checks: this.#checks, - resources: this.#resources, - bindings: [] as never[], - }); - } -} diff --git a/packages/lgtmd/src/compiler.ts b/packages/lgtmd/src/compiler.ts deleted file mode 100644 index a7284ed..0000000 --- a/packages/lgtmd/src/compiler.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { spawn } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import type { FrozenDocument } from "./model.js"; -import { freezeJson } from "./model.js"; - -export interface ExportLayout { - pages?: string; - resources?: string; - pageOverrides?: Readonly>; -} - -export interface PublishOptions { - command: "build" | "check" | "update"; - reader: string; - contributor: string; - layout?: ExportLayout; -} - -export interface Exports { - reader: Readonly>; - contributor: Readonly>; -} - -function layout(value?: ExportLayout): Record { - return { - pages: value?.pages ?? "", - resources: value?.resources ?? "assets", - page_overrides: value?.pageOverrides ?? {}, - }; -} - -async function request(value: unknown): Promise { - const executable = fileURLToPath( - new URL( - `../../native/${process.platform}-${process.arch}/lgtmd-node${process.platform === "win32" ? ".exe" : ""}`, - import.meta.url, - ), - ); - // Freeze the complete request before starting a process. Host execution has - // already ended; the bridge never calls application or authoring callbacks. - const input = JSON.stringify(freezeJson(value)); - return new Promise((resolve, reject) => { - const child = spawn(executable, [], { stdio: ["pipe", "pipe", "pipe"] }); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8").on("data", (chunk: string) => { - stdout += chunk; - }); - child.stderr.setEncoding("utf8").on("data", (chunk: string) => { - stderr += chunk; - }); - child.on("error", (error) => - reject( - new Error( - `lgtmd native bridge unavailable for ${process.platform}-${process.arch}. Build the local binding or install a package containing that platform.`, - { cause: error }, - ), - ), - ); - child.stdin.on("error", (error) => { - if (error.message !== "write EPIPE") reject(error); - }); - child.on("close", (code, signal) => { - try { - const response = JSON.parse(stdout) as { - ok?: boolean; - result?: unknown; - error?: string; - }; - if (code !== 0 || signal || response.ok !== true) { - reject( - new Error( - response.error ?? - `lgtmd bridge failed (${signal ?? code}): ${stderr}`, - ), - ); - } else resolve(response.result); - } catch (cause) { - reject( - new Error( - `Invalid lgtmd bridge response (${signal ?? code}): ${stderr}`, - { cause }, - ), - ); - } - }); - child.stdin.end(input); - }); -} - -/** Compile both audiences from immutable document input. This writes no files. */ -export async function compile( - documents: readonly FrozenDocument[], - options?: ExportLayout, -): Promise { - const result = (await request({ - operation: "compile", - input: { schema_version: 1, documents }, - layout: layout(options), - })) as { - reader: Record; - contributor: Record; - }; - const bytes = (files: Record): Record => - Object.fromEntries( - Object.entries(files).map(([path, content]) => [ - path, - Uint8Array.from(content), - ]), - ); - return { - reader: bytes(result.reader), - contributor: bytes(result.contributor), - }; -} - -/** Build fresh review trees, check exactly, or explicitly accept a baseline. - * Both audiences compile before publication. Their filesystem transactions are - * independent; a later audience failure can leave the earlier one published. - */ -export async function publish( - documents: readonly FrozenDocument[], - options: PublishOptions, -): Promise { - await request({ - operation: "publish", - input: { schema_version: 1, documents }, - command: options.command, - reader: options.reader, - contributor: options.contributor, - layout: layout(options.layout), - }); -} diff --git a/packages/lgtmd/src/index.ts b/packages/lgtmd/src/index.ts deleted file mode 100644 index 25858e2..0000000 --- a/packages/lgtmd/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./authoring.js"; -export type { FrozenDocument, Json, Page } from "./model.js"; -export * from "./lifecycle.js"; -export { compile, publish } from "./compiler.js"; -export type { Exports, ExportLayout, PublishOptions } from "./compiler.js"; diff --git a/packages/lgtmd/src/lifecycle.ts b/packages/lgtmd/src/lifecycle.ts deleted file mode 100644 index f761eea..0000000 --- a/packages/lgtmd/src/lifecycle.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Doc } from "./authoring.js"; -import { singleLine, validId } from "./model.js"; -import type { FrozenDocument, Page } from "./model.js"; - -export interface Prepared { - readonly context: T; - /** Omit cleanup when preparation acquires no resources. */ - readonly cleanup?: () => void | Promise; -} - -const definition = Symbol("lgtmd guide definition"); -export interface GuideDefinition { - readonly [definition]: true; -} -type Write = (doc: Doc, context: T) => unknown | Promise; -interface Definition { - readonly page: Page; - readonly prepare?: () => Prepared | Promise>; - readonly write: Write; -} -const definitions = new WeakMap(); - -function copyPage(page: Page): Page { - if (!validId(page.id)) - throw new TypeError(`Invalid document ID: ${String(page.id)}`); - singleLine(page.title, "Document title"); - return Object.freeze({ id: page.id, title: page.title }); -} - -/** Declare a document. Importing does not execute application code. - * Authoring callback return values are ignored; only explicit checks are evidence. - */ -export function guide( - page: Page & { readonly prepare: () => Prepared | Promise> }, - write: Write, -): GuideDefinition; -export function guide( - page: Page & { readonly prepare?: never }, - write: (doc: Doc) => unknown | Promise, -): GuideDefinition; -export function guide( - page: Page & { - readonly prepare?: () => Prepared | Promise>; - }, - write: Write, -): GuideDefinition { - const identity = copyPage(page); - if (typeof write !== "function") - throw new TypeError("Guide requires an authoring callback"); - if (page.prepare !== undefined && typeof page.prepare !== "function") - throw new TypeError("Guide preparation must be a callback"); - const result = Object.freeze({ [definition]: true as const }); - definitions.set(result, { page: identity, prepare: page.prepare, write }); - return result; -} - -/** Both failures stay available in their original order and identity. */ -export class GuideRunError extends AggregateError { - readonly primary: unknown; - readonly cleanup: unknown; - - constructor(primary: unknown, cleanup: unknown) { - super([primary, cleanup], "Guide authoring and cleanup both failed", { - cause: primary, - }); - this.name = "GuideRunError"; - this.primary = primary; - this.cleanup = cleanup; - } -} - -/** Execute native authoring, then cleanup, then freeze an eligible document. */ -export async function runGuide( - guideDefinition: GuideDefinition, -): Promise { - const registered = - typeof guideDefinition === "object" && guideDefinition !== null - ? definitions.get(guideDefinition) - : undefined; - if (!registered) - throw new TypeError("Expected a guide declared by this SDK instance"); - const doc = new Doc(registered.page.id, registered.page.title); - // Preparation owns its own partial failure. No cleanup has been acquired yet. - const prepared = registered.prepare ? await registered.prepare() : undefined; - if ( - prepared !== undefined && - (prepared === null || - typeof prepared !== "object" || - !Object.hasOwn(prepared, "context") || - (prepared.cleanup !== undefined && - typeof prepared.cleanup !== "function")) - ) { - throw new TypeError("Preparation must return { context, cleanup? }"); - } - if (registered.prepare && prepared === undefined) - throw new TypeError("Preparation must return { context, cleanup? }"); - // Acquire cleanup once. Author code may still hold and mutate the project's - // preparation result, but cannot replace this acquired capability. - const context = prepared?.context; - const cleanup = prepared?.cleanup; - - let primary: unknown; - let failed = false; - let document: FrozenDocument | undefined; - try { - await registered.write(doc, context); - document = doc.finish(); - } catch (error) { - failed = true; - primary = error; - } - try { - await cleanup?.call(prepared); - } catch (cleanupError) { - if (failed) throw new GuideRunError(primary, cleanupError); - throw cleanupError; - } - if (failed) throw primary; - return document!; -} - -/** The supplying host owns this context's setup and teardown. */ -export async function runGuideWithContext( - page: Page, - context: T, - write: Write, -): Promise { - const definition = guide( - { ...copyPage(page), prepare: () => ({ context }) }, - write, - ); - return runGuide(definition); -} diff --git a/packages/lgtmd/src/model.ts b/packages/lgtmd/src/model.ts deleted file mode 100644 index ecc5cce..0000000 --- a/packages/lgtmd/src/model.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** JSON values are copied at registration; unsupported JavaScript values fail. */ -export type Scalar = string | number | boolean; -export type Json = - | null - | Scalar - | readonly Json[] - | { readonly [key: string]: Json }; - -export interface Page { - readonly id: string; - readonly title: string; -} - -/** Internal compiler input. Schema v1 is the incubation interoperability boundary. */ -export interface Reference { - readonly kind: - | "observation" - | "resource" - | "block" - | "claim" - | "value" - | "binding"; - readonly id: string; -} - -export type Part = - | { readonly kind: "text" | "markdown"; readonly text: string } - | { readonly kind: "reference"; readonly target: Reference } - | { - readonly kind: "image" | "link"; - readonly target: Reference; - readonly label: string; - } - | { - readonly kind: "code"; - readonly target: Reference; - readonly language: string; - }; - -export type Node = - | { - readonly id: string; - readonly kind: "markdown"; - readonly parts: readonly Part[]; - } - | { - readonly id: string; - readonly kind: "heading"; - readonly level: number; - readonly text: string; - } - | { - readonly id: string; - readonly kind: "note"; - readonly target?: Reference; - readonly parts: readonly Part[]; - }; - -export interface FrozenResource { - readonly id: string; - readonly mime: string; - readonly extension: string; - readonly producer: string; - readonly profile: string; - readonly bytes: readonly number[]; - /** JSON assets use the shared Rust encoder instead of a host serializer. */ - readonly json?: Json; -} - -export interface FrozenDocument extends Page { - readonly nodes: readonly Node[]; - readonly observations: readonly { - readonly id: string; - readonly value: Json; - }[]; - readonly checks: readonly { - readonly id: string; - readonly kind: "predicate"; - readonly passed: true; - }[]; - readonly resources: readonly FrozenResource[]; - readonly bindings: readonly never[]; -} - -export interface CompilerInput { - readonly schema_version: 1; - readonly documents: readonly FrozenDocument[]; -} - -export function validId(id: unknown): id is string { - return typeof id === "string" && /^[a-z0-9_-]{1,100}$/.test(id); -} - -export function singleLine( - value: unknown, - name: string, -): asserts value is string { - if ( - typeof value !== "string" || - value.trim() === "" || - /[\u0000-\u001f\u007f]/u.test(value) - ) { - throw new TypeError(`${name} must be nonempty single-line text`); - } -} - -/** Avoid JSON.stringify's silent omission, coercion, getters, and toJSON hooks. */ -export function freezeJson(value: unknown): Json { - const ancestors = new Set(); - const copy = (input: unknown, path: string): Json => { - if (input === null || typeof input === "boolean") return input; - if (typeof input === "string") { - // Under Unicode regexp semantics a valid surrogate pair is one scalar, - // outside this range. TextEncoder would silently replace an unpaired one. - if (/[\uD800-\uDFFF]/u.test(input)) { - throw new TypeError(`Cannot freeze ${path}: unpaired UTF-16 surrogate`); - } - return input; - } - if (typeof input === "number") { - if ( - !Number.isFinite(input) || - (Number.isInteger(input) && !Number.isSafeInteger(input)) || - Object.is(input, -0) - ) { - throw new TypeError( - `Cannot freeze ${path}: number must be finite, safely represented, and not negative zero`, - ); - } - return input; - } - if (typeof input !== "object") - throw new TypeError(`Cannot freeze ${path}: expected a JSON value`); - if (ancestors.has(input)) - throw new TypeError(`Cannot freeze ${path}: cyclic value`); - if (Object.getOwnPropertySymbols(input).length !== 0) - throw new TypeError(`Cannot freeze ${path}: symbol keys are unsupported`); - ancestors.add(input); - try { - const descriptors = Object.getOwnPropertyDescriptors(input); - if (Array.isArray(input)) { - if ( - Object.keys(descriptors).some( - (key) => - key !== "length" && - (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= input.length), - ) - ) { - throw new TypeError( - `Cannot freeze ${path}: array has extra properties`, - ); - } - const result: Json[] = []; - for (let index = 0; index < input.length; index += 1) { - const descriptor = descriptors[String(index)]; - if ( - !descriptor || - !("value" in descriptor) || - !descriptor.enumerable - ) { - throw new TypeError( - `Cannot freeze ${path}[${index}]: sparse arrays and accessors are unsupported`, - ); - } - result.push(copy(descriptor.value, `${path}[${index}]`)); - } - return Object.freeze(result); - } - const prototype = Object.getPrototypeOf(input); - if (prototype !== Object.prototype && prototype !== null) - throw new TypeError(`Cannot freeze ${path}: expected a plain object`); - const result: Record = {}; - for (const key of Object.keys(descriptors).sort()) { - copy(key, `${path} property name`); - const descriptor = descriptors[key]!; - if (!("value" in descriptor) || !descriptor.enumerable) { - throw new TypeError( - `Cannot freeze ${path}.${key}: nonenumerable properties and accessors are unsupported`, - ); - } - Object.defineProperty(result, key, { - value: copy(descriptor.value, `${path}.${key}`), - enumerable: true, - }); - } - return Object.freeze(result); - } finally { - ancestors.delete(input); - } - }; - return copy(value, "value"); -} - -export function deepFreeze(value: T): T { - if (value !== null && typeof value === "object" && !Object.isFrozen(value)) { - for (const child of Object.values(value)) deepFreeze(child); - Object.freeze(value); - } - return value; -} diff --git a/packages/lgtmd/src/playwright/guard.ts b/packages/lgtmd/src/playwright/guard.ts deleted file mode 100644 index c252b85..0000000 --- a/packages/lgtmd/src/playwright/guard.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { Reporter } from "@playwright/test/reporter"; - -function describe(error: unknown): string { - return error instanceof Error - ? `${error.name}: ${error.message}` - : String(error); -} - -/** Playwright swallows reporter exceptions, so delegate errors become receipt failures. */ -export class ReporterGuard { - readonly errors: string[] = []; - #pending = new Set>(); - - constructor(readonly reporters: readonly Reporter[] = []) {} - - #result(method: keyof Reporter, value: unknown): void { - if ( - method === "onEnd" && - value !== null && - typeof value === "object" && - "status" in value && - value.status !== undefined && - value.status !== "passed" - ) { - this.errors.push( - `onEnd: reporter requested ${String(value.status)} status`, - ); - } - } - - invoke(method: keyof Reporter, ...args: unknown[]): void { - for (const reporter of this.reporters) { - try { - const callback = reporter[method]; - if (typeof callback !== "function") continue; - const result = Reflect.apply(callback, reporter, args) as unknown; - if ( - result && - typeof (result as PromiseLike).then === "function" - ) { - let pending: Promise; - pending = Promise.resolve(result) - .then((value: unknown) => { - this.#result(method, value); - }) - // Inspecting a returned status can itself throw (for example, a - // custom reporter getter). Retain it just like a callback failure. - .catch((error: unknown) => { - this.errors.push(`${method}: ${describe(error)}`); - }) - .finally(() => { - this.#pending.delete(pending); - }); - this.#pending.add(pending); - } else { - this.#result(method, result); - } - } catch (error) { - this.errors.push(`${method}: ${describe(error)}`); - } - } - } - - async settle(): Promise { - while (this.#pending.size > 0) await Promise.all([...this.#pending]); - } -} diff --git a/packages/lgtmd/src/playwright/index.ts b/packages/lgtmd/src/playwright/index.ts deleted file mode 100644 index 1d19ef7..0000000 --- a/packages/lgtmd/src/playwright/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { - Fixtures, - ReporterDescription, - TestInfo, - TestType, -} from "@playwright/test"; -import { Doc } from "../authoring.js"; -import { - ATTACHMENT_NAME, - ATTACHMENT_TYPE, - REPORTER_PATH, - type ReporterOptions, -} from "./reporter.js"; - -export { runPlaywright } from "./runner.js"; -export type { RunPlaywrightOptions } from "./runner.js"; - -/** Preserve an application's existing fixtures, assertions, and cleanup. */ -export function withDocs( - test: TestType, -): TestType { - return test.extend<{ doc: Doc }>({ - doc: async ({}, use: (value: Doc) => Promise, testInfo: TestInfo) => { - const document = new Doc(); - await use(document); - if ( - testInfo.status === "passed" && - testInfo.expectedStatus === "passed" - ) { - await testInfo.attach(ATTACHMENT_NAME, { - body: Buffer.from(JSON.stringify(document.finish()), "utf8"), - contentType: ATTACHMENT_TYPE, - }); - } - }, - // Playwright's mapped fixture type cannot resolve a new key for generic T/W. - // The adapter contributes only this documented `doc` fixture. - } as unknown as Fixtures<{ doc: Doc }, {}, T, W>); -} - -/** Guard every optional custom reporter, including its final onExit callback. */ -export function docsReporter( - options: ReporterOptions = {}, -): ReporterDescription[] { - return [[REPORTER_PATH, options]]; -} diff --git a/packages/lgtmd/src/playwright/reporter.ts b/packages/lgtmd/src/playwright/reporter.ts deleted file mode 100644 index e569b71..0000000 --- a/packages/lgtmd/src/playwright/reporter.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { writeFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import type { - FullConfig, - FullResult, - Reporter, - Suite, - TestCase, - TestError, - TestResult, - TestStep, -} from "@playwright/test/reporter"; -import type { FrozenDocument } from "../model.js"; -import { ReporterGuard } from "./guard.js"; - -export const ATTACHMENT_NAME = "lgtmd-document-v1"; -export const ATTACHMENT_TYPE = "application/vnd.lgtmd.document+json"; -export const RECEIPT_ENV = "LGTMD_PLAYWRIGHT_RECEIPT"; -export const TOKEN_ENV = "LGTMD_PLAYWRIGHT_TOKEN"; -export const REPORTER_PATH = fileURLToPath( - new URL("./reporter.js", import.meta.url), -); - -export interface ReporterOptions { - readonly reporters?: readonly Reporter[]; -} - -export interface RunReceipt { - readonly version: 1; - readonly token: string; - readonly status: FullResult["status"]; - readonly errors: readonly string[]; - readonly testCount: number; - readonly attempts: readonly { - readonly testId: string; - readonly project: string; - readonly retry: number; - readonly status: TestResult["status"]; - }[]; - readonly documents: readonly FrozenDocument[]; -} - -/** The sole configured reporter; every optional project reporter is delegated here. */ -export default class DocsReporter implements Reporter { - #guard: ReporterGuard; - #errors: string[] = []; - #suite?: Suite; - #result?: FullResult; - #documents = new Map(); - #attempts: RunReceipt["attempts"][number][] = []; - - constructor(options: ReporterOptions = {}) { - this.#guard = new ReporterGuard(options.reporters ?? []); - } - - printsToStdio(): boolean { - // This reporter prints summaries, preventing Playwright from silently adding - // a terminal reporter outside the guard. - return true; - } - - async preprocess( - params: Parameters>[0], - ): Promise { - this.#guard.invoke("preprocess", params); - await this.#guard.settle(); - } - - onBegin(config: FullConfig, suite: Suite): void { - this.#suite = suite; - const reporters = config.reporter; - if (reporters.length !== 1 || reporters[0]?.[0] !== REPORTER_PATH) { - this.#errors.push( - "Use docsReporter() as the sole reporter; unguarded reporters cannot prove success", - ); - } - if (config.projects.length !== 1) - this.#errors.push( - "lgtmd currently requires exactly one Playwright project", - ); - if (config.shard !== null) - this.#errors.push("lgtmd does not support Playwright sharding yet"); - for (const project of config.projects) { - if (project.retries !== 0) - this.#errors.push("lgtmd currently requires retries: 0"); - if (project.repeatEach !== 1) - this.#errors.push("lgtmd currently requires repeatEach: 1"); - if (project.dependencies.length !== 0 || project.teardown !== undefined) { - this.#errors.push( - "lgtmd does not support dependency or teardown projects yet", - ); - } - } - if (suite.allTests().length === 0) - this.#errors.push("No Playwright tests were discovered"); - this.#guard.invoke("onBegin", config, suite); - } - - onTestBegin(test: TestCase, result: TestResult): void { - this.#guard.invoke("onTestBegin", test, result); - } - - onStepBegin(test: TestCase, result: TestResult, step: TestStep): void { - this.#guard.invoke("onStepBegin", test, result, step); - } - - onStepEnd(test: TestCase, result: TestResult, step: TestStep): void { - this.#guard.invoke("onStepEnd", test, result, step); - } - - onStdOut(chunk: string | Buffer, test?: TestCase, result?: TestResult): void { - process.stdout.write(chunk); - this.#guard.invoke("onStdOut", chunk, test, result); - } - - onStdErr(chunk: string | Buffer, test?: TestCase, result?: TestResult): void { - process.stderr.write(chunk); - this.#guard.invoke("onStdErr", chunk, test, result); - } - - onError(error: TestError, workerInfo?: unknown): void { - this.#errors.push( - error.message ?? - error.value ?? - "Playwright reported a global or worker error", - ); - this.#guard.invoke("onError", error, workerInfo); - } - - onTestEnd(test: TestCase, result: TestResult): void { - const project = test.parent.project()?.name ?? ""; - this.#attempts.push({ - testId: test.id, - project, - retry: result.retry, - status: result.status, - }); - process.stdout.write(`${result.status}: ${test.title}\n`); - if ( - test.expectedStatus !== "passed" || - result.status !== "passed" || - result.errors.length > 0 || - result.retry !== 0 - ) { - this.#errors.push( - `Test did not pass cleanly: ${test.title} (${result.status}, retry ${result.retry})`, - ); - } else { - const attachments = result.attachments.filter( - (attachment) => attachment.name === ATTACHMENT_NAME, - ); - if (attachments.length > 1) - this.#errors.push( - `Multiple documentation attachments for test: ${test.title}`, - ); - for (const attachment of attachments) { - try { - if (attachment.contentType !== ATTACHMENT_TYPE || !attachment.body) { - throw new Error( - "Documentation attachment must contain an in-memory frozen document", - ); - } - const document = JSON.parse( - attachment.body.toString("utf8"), - ) as FrozenDocument; - if (!document || typeof document.id !== "string") - throw new Error( - "Documentation attachment has no document identity", - ); - if (this.#documents.has(document.id)) - throw new Error(`Duplicate document identity: ${document.id}`); - this.#documents.set(document.id, document); - } catch (error) { - this.#errors.push( - error instanceof Error ? error.message : String(error), - ); - } - } - } - this.#guard.invoke("onTestEnd", test, result); - } - - async onEnd(result: FullResult): Promise<{ status: FullResult["status"] }> { - this.#result = result; - for (const test of this.#suite?.allTests() ?? []) { - if ( - test.results.length !== 1 || - test.results[0]?.status !== "passed" || - test.expectedStatus !== "passed" - ) { - this.#errors.push( - `Incomplete or unsupported test outcome: ${test.title}`, - ); - } - } - this.#guard.invoke("onEnd", result); - await this.#guard.settle(); - return { - status: - this.#errors.length === 0 && this.#guard.errors.length === 0 - ? result.status - : "failed", - }; - } - - async onExit(): Promise { - this.#guard.invoke("onExit"); - await this.#guard.settle(); - const filename = process.env[RECEIPT_ENV]; - const token = process.env[TOKEN_ENV]; - // Direct Playwright execution can collect diagnostic attachments; only the - // SDK parent process provides a receipt destination and can publish. - if (!filename || !token) return; - const errors = [...this.#errors, ...this.#guard.errors]; - if (!this.#suite || !this.#result) - errors.push("Playwright lifecycle did not complete"); - if (this.#documents.size === 0) - errors.push("No authored documents were collected"); - for (const error of errors) process.stderr.write(`lgtmd: ${error}\n`); - const receipt: RunReceipt = { - version: 1, - token, - status: this.#result?.status ?? "failed", - errors, - testCount: this.#suite?.allTests().length ?? 0, - attempts: this.#attempts, - documents: [...this.#documents.values()].sort((left, right) => - left.id.localeCompare(right.id, "en"), - ), - }; - // A swallowed write error leaves no valid receipt, which the parent rejects. - writeFileSync(filename, JSON.stringify(receipt), { - flag: "wx", - mode: 0o600, - }); - } -} diff --git a/packages/lgtmd/src/playwright/runner.ts b/packages/lgtmd/src/playwright/runner.ts deleted file mode 100644 index ceabb7e..0000000 --- a/packages/lgtmd/src/playwright/runner.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { spawn } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { createRequire } from "node:module"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { publish, type ExportLayout } from "../compiler.js"; -import type { FrozenDocument } from "../model.js"; -import { RECEIPT_ENV, TOKEN_ENV, type RunReceipt } from "./reporter.js"; - -export interface RunPlaywrightOptions { - readonly config: string | URL; - readonly cwd?: string; - readonly command: "build" | "check" | "update"; - readonly reader: string; - readonly contributor: string; - readonly layout?: ExportLayout; - /** Documents from other host runs whose preparation and cleanup completed. */ - readonly documents?: readonly FrozenDocument[]; -} - -function readReceipt(source: string, token: string): RunReceipt { - const value = JSON.parse(source) as RunReceipt; - if ( - !value || - value.version !== 1 || - value.token !== token || - value.status !== "passed" || - !Array.isArray(value.errors) || - value.errors.length !== 0 || - !Number.isInteger(value.testCount) || - value.testCount < 1 || - !Array.isArray(value.attempts) || - value.attempts.length !== value.testCount || - !value.attempts.every( - (attempt) => attempt.status === "passed" && attempt.retry === 0, - ) || - new Set(value.attempts.map((attempt) => attempt.testId)).size !== - value.testCount || - !Array.isArray(value.documents) || - value.documents.length === 0 - ) { - const errors = Array.isArray(value?.errors) - ? value.errors.join("\n") - : "Invalid or missing success receipt"; - throw new Error( - `Playwright output is not eligible for publication: ${errors}`, - ); - } - return value; -} - -/** Publish only after the actual runner process and all guarded reporters finish. */ -export async function runPlaywright(options: RunPlaywrightOptions) { - const config = - options.config instanceof URL - ? fileURLToPath(options.config) - : resolve(options.cwd ?? process.cwd(), options.config); - const cwd = resolve(options.cwd ?? dirname(config)); - const require = createRequire(config); - const cli = require.resolve("@playwright/test/cli"); - const directory = await mkdtemp(join(tmpdir(), "lgtmd-playwright-")); - const receiptPath = join(directory, "receipt.json"); - const token = randomUUID(); - try { - const child = spawn( - process.execPath, - [cli, "test", "--config", config, "--forbid-only"], - { - cwd, - env: { ...process.env, [RECEIPT_ENV]: receiptPath, [TOKEN_ENV]: token }, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - let interrupted = false; - const interrupt = () => { - interrupted = true; - child.kill("SIGINT"); - }; - const terminate = () => { - interrupted = true; - child.kill("SIGTERM"); - }; - process.on("SIGINT", interrupt); - process.on("SIGTERM", terminate); - child.stdout.on("data", (chunk: Buffer) => process.stdout.write(chunk)); - child.stderr.on("data", (chunk: Buffer) => process.stderr.write(chunk)); - let exit: { code: number | null; signal: NodeJS.Signals | null }; - try { - exit = await new Promise((fulfill, reject) => { - child.once("error", reject); - child.once("close", (code, signal) => fulfill({ code, signal })); - }); - } finally { - process.off("SIGINT", interrupt); - process.off("SIGTERM", terminate); - } - if (interrupted || exit.code !== 0 || exit.signal !== null) { - throw new Error( - `Playwright did not finish successfully (exit ${String(exit.code)}, signal ${String(exit.signal)})`, - ); - } - let receipt: RunReceipt; - try { - receipt = readReceipt(await readFile(receiptPath, "utf8"), token); - } catch (error) { - throw new Error( - `Playwright produced no valid completed success receipt: ${error instanceof Error ? error.message : String(error)}`, - { cause: error }, - ); - } - const documents = [...(options.documents ?? []), ...receipt.documents]; - if ( - new Set(documents.map((document) => document.id)).size !== - documents.length - ) { - throw new Error("Duplicate document identity across completed host runs"); - } - return await publish(documents, { - command: options.command, - reader: resolve(cwd, options.reader), - contributor: resolve(cwd, options.contributor), - ...(options.layout === undefined ? {} : { layout: options.layout }), - }); - } finally { - await rm(directory, { recursive: true, force: true }); - } -} diff --git a/packages/lgtmd/tests/authoring.test.ts b/packages/lgtmd/tests/authoring.test.ts deleted file mode 100644 index f4644a6..0000000 --- a/packages/lgtmd/tests/authoring.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { Doc } from "../src/authoring.js"; -import type { Content, Target } from "../src/authoring.js"; -import { freezeJson } from "../src/model.js"; - -test("explanation and observations do not manufacture executed checks", () => { - const doc = new Doc("saved-view", "Save a view"); - const count = doc.observe("record-count", 3); - doc.md`The view contains ${count} records.`; - const frozen = doc.finish(); - assert.deepEqual(frozen.checks, []); - assert.deepEqual(frozen.observations, [{ id: "record-count", value: 3 }]); - assert.deepEqual(frozen.nodes, [ - { - id: "block-0001", - kind: "markdown", - parts: [ - { kind: "markdown", text: "The view contains " }, - { - kind: "reference", - target: { id: "record-count", kind: "observation" }, - }, - { kind: "markdown", text: " records." }, - { kind: "markdown", text: "\n\n" }, - ], - }, - ]); -}); - -test("template indentation and literal Markdown stay separate from escaped substitutions", () => { - const doc = new Doc("markdown", "Markdown"); - doc.md` - **A heading** - - A literal ${"[untrusted](wrong.md)"} and ${2}. - `; - assert.deepEqual(doc.finish().nodes, [ - { - id: "block-0001", - kind: "markdown", - parts: [ - { kind: "markdown", text: "**A heading**\n\nA literal " }, - { kind: "text", text: "[untrusted](wrong.md)" }, - { kind: "markdown", text: " and " }, - { kind: "text", text: "2" }, - { kind: "markdown", text: "." }, - { kind: "markdown", text: "\n\n" }, - ], - }, - ]); -}); - -test("Markdown templates terminate blocks before a following heading or paragraph", () => { - const doc = new Doc("boundaries", "Block boundaries"); - doc.md`Opening paragraph.`; - doc.section("next", "Next section"); - doc.md`Final paragraph.`; - const nodes = doc.finish().nodes; - for (const index of [0, 2]) { - const node = nodes[index]!; - assert.equal(node.kind, "markdown"); - if (node.kind === "markdown") { - assert.deepEqual(node.parts.at(-1), { kind: "markdown", text: "\n\n" }); - } - } - assert.equal(nodes[1]!.kind, "heading"); -}); - -test("template marker characters retain their literal position around substitutions", () => { - const doc = new Doc("markers", "Template markers"); - doc.md`\uE000${"value"}\uE001\uE0000\uE001`; - assert.deepEqual(doc.finish().nodes, [ - { - id: "block-0001", - kind: "markdown", - parts: [ - { kind: "markdown", text: "\uE000" }, - { kind: "text", text: "value" }, - { kind: "markdown", text: "\uE001\uE0000\uE001" }, - { kind: "markdown", text: "\n\n" }, - ], - }, - ]); -}); - -test("adding notes never changes reader block identities", () => { - const make = (notes: boolean) => { - const doc = new Doc("notes", "Notes"); - const first = doc.md`First.`; - if (notes) doc.noteOn(first)`Private **literal** explanation.`; - doc.section("next", "Next"); - if (notes) doc.note`A second note.`; - doc.md`Last.`; - return doc.finish(); - }; - const withNotes = make(true); - assert.deepEqual( - withNotes.nodes.filter((node) => node.kind !== "note"), - make(false).nodes, - ); - assert.deepEqual(withNotes.nodes[1], { - id: "note-0001", - kind: "note", - target: { kind: "block", id: "block-0001" }, - parts: [{ kind: "text", text: "Private **literal** explanation." }], - }); -}); - -test("observations, JSON, and bytes belong to their registration snapshot", () => { - const doc = new Doc("snapshot", "Snapshots"); - const mutable = { z: ["original"], a: 3 }; - const observed = doc.observe("state", mutable); - const json = doc.assets.json("json", mutable); - const bytes = new Uint8Array([7, 8, 9]); - const image = doc.assets.image("image", bytes); - mutable.z[0] = "changed"; - bytes.fill(0); - doc.md`${json.code()} - -${json.link("Download JSON")} - -${image.embed("Frozen bytes")}`; - assert.deepEqual(observed.value, { a: 3, z: ["original"] }); - if (false) { - // @ts-expect-error Snapshot arrays are recursively readonly. - observed.value.z.push("forbidden"); - // @ts-expect-error Snapshot properties are recursively readonly. - observed.value.a = 5; - } - assert.throws(() => { - (observed.value.z as string[])[0] = "forbidden"; - }, TypeError); - const frozen = doc.finish(); - assert.deepEqual(frozen.resources[1]!.bytes, [7, 8, 9]); - assert.deepEqual(frozen.resources[0]!.bytes, []); - assert.deepEqual(frozen.resources[0]!.json, { a: 3, z: ["original"] }); - assert.equal(frozen.resources[0]!.producer, "executable-docs/json"); - assert.equal(frozen.resources[0]!.profile, "json-pretty-v1"); - assert.throws(() => { - (frozen.resources[1]!.bytes as number[])[0] = 0; - }, TypeError); - assert.throws(() => doc.md`After finish.`, /already finished/); - assert.throws(() => doc.finish(), /already finished/); - assert.throws(() => { - (doc as unknown as { md: unknown }).md = () => undefined; - }, TypeError); -}); - -test("foreign handles and presentations taint even caught authoring failures", () => { - const first = new Doc("first", "First"); - const value = first.observe("value", 4); - const asset = first.assets.text("text", "Private"); - const second = new Doc("second", "Second"); - assert.throws(() => second.md`${value}`, /Foreign/); - assert.throws(() => second.md`${asset.link("Foreign")}`, /Foreign/); - assert.throws(() => second.noteOn({} as Target), /forged/); - assert.throws(() => second.finish(), /caught failures/); -}); - -test("resource registration appends no content and named ids share one namespace", () => { - const doc = new Doc("ids", "IDs"); - doc.assets.text("same", "Registered once"); - assert.throws(() => doc.observe("same", 1), /Duplicate evidence/); - assert.throws(() => doc.finish(), /authoring failed/); - const empty = new Doc("resources", "Resources"); - empty.assets.text("private", "No placement"); - assert.deepEqual(empty.finish().nodes, []); -}); - -test("native assertion wrappers execute once and retain caught failure", async () => { - const doc = new Doc("checks", "Checks"); - let calls = 0; - const error = new Error("Native assertion failed"); - await assert.rejects( - doc.check("failure", async () => { - calls += 1; - throw error; - }), - (candidate) => candidate === error, - ); - assert.equal(calls, 1); - assert.throws( - () => doc.finish(), - (candidate) => - candidate instanceof AggregateError && candidate.errors[0] === error, - ); -}); - -test("boolean check results cannot be mistaken for native assertions", async () => { - for (const result of [true, false]) { - const doc = new Doc("boolean", "Boolean"); - await assert.rejects( - doc.check("wrong", (() => result) as unknown as () => void), - /must return void/, - ); - assert.throws(() => doc.finish(), /authoring failed/); - } -}); - -test("pending checks prevent publication eligibility", async () => { - const doc = new Doc("pending", "Pending"); - let settle: () => void = () => { - throw new Error("Missing resolver"); - }; - const check = doc.check( - "wait", - () => - new Promise((resolve) => { - settle = resolve; - }), - ); - assert.throws(() => doc.finish(), /pending checks/); - settle(); - await assert.rejects(check, /already finished/); -}); - -test("successful checks can receive targeted contributor notes", async () => { - const doc = new Doc("success", "Success"); - const checked = await doc.check("count", () => assert.equal(3, 3)); - doc.noteOn(checked)`This check covers the sample count.`; - const frozen = doc.finish(); - assert.deepEqual(frozen.checks, [ - { id: "count", kind: "predicate", passed: true }, - ]); - assert.deepEqual(frozen.nodes[0], { - id: "note-0001", - kind: "note", - target: { kind: "claim", id: "count" }, - parts: [{ kind: "text", text: "This check covers the sample count." }], - }); -}); - -test("unsupported JSON never silently omits, coerces, invokes getters, or loses precision", () => { - const cycle: Record = {}; - cycle.self = cycle; - let getterCalls = 0; - const getter = Object.defineProperty({}, "value", { - enumerable: true, - get: () => { - getterCalls += 1; - return 1; - }, - }); - const cases = [ - undefined, - NaN, - Infinity, - -0, - Number.MAX_SAFE_INTEGER + 1, - 1n, - new Date(), - new Map(), - { missing: undefined }, - [undefined], - new Array(1), - cycle, - getter, - Object.defineProperty({}, "hidden", { value: 1 }), - { [Symbol("hidden")]: 1 }, - () => 1, - ]; - for (const value of cases) assert.throws(() => freezeJson(value), TypeError); - assert.equal(getterCalls, 0); - const shared = { value: "same" }; - assert.deepEqual(freezeJson([shared, shared]), [ - { value: "same" }, - { value: "same" }, - ]); - const special = JSON.parse( - '{"__proto__":{"safe":true},"constructor":"value"}', - ); - assert.deepEqual(freezeJson(special), special); - assert.equal(Object.getPrototypeOf(freezeJson(special)), Object.prototype); -}); - -test("numeric-looking properties outside an array's index range are never omitted", () => { - const array: unknown[] = []; - Object.defineProperty(array, "4294967295", { - value: "not an array index", - enumerable: true, - }); - assert.equal(array.length, 0); - assert.throws(() => freezeJson(array), /array has extra properties/); - assert.deepEqual(freezeJson(["ordinary", "indices"]), [ - "ordinary", - "indices", - ]); -}); - -test("ill-formed Unicode is rejected before JSON or text bytes can change it", () => { - for (const value of [ - "\uD800", - "\uDFFF", - "before\uD800after", - "\uD800\uD800", - ]) { - assert.throws(() => freezeJson(value), /unpaired UTF-16 surrogate/); - assert.throws(() => freezeJson({ value }), /unpaired UTF-16 surrogate/); - assert.throws( - () => freezeJson({ [value]: "property" }), - /unpaired UTF-16 surrogate/, - ); - const doc = new Doc("invalid-unicode", "Invalid Unicode"); - assert.throws( - () => doc.assets.text("text", value), - /unpaired UTF-16 surrogate/, - ); - assert.throws(() => doc.finish(), /authoring failed/); - } - const valid = "A record 📄 with a valid pair \uD83D\uDE00"; - assert.equal(freezeJson(valid), valid); - const doc = new Doc("unicode", "Unicode"); - doc.assets.text("text", valid); - const bytes = doc.finish().resources[0]!.bytes; - assert.equal( - new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(bytes)), - valid, - ); -}); - -test("raw objects and code in notes fail rather than flattening typed structure", () => { - const doc = new Doc("invalid", "Invalid"); - assert.throws(() => doc.md`${{ value: 1 } as unknown as Content}`, /forged/); - const code = doc.assets.text("code", "text"); - assert.throws(() => doc.note`${code.code()}`, /not contributor notes/); - assert.throws(() => doc.finish(), /authoring failed/); -}); - -test("runner identity is set once and validated", () => { - const doc = new Doc(); - doc.page({ id: "browser", title: "Browser" }); - doc.md`Ready.`; - assert.throws( - () => doc.page({ id: "second", title: "Second" }), - /only be set once/, - ); - assert.throws(() => doc.finish(), /authoring failed/); - assert.throws(() => new Doc("Invalid ID", "Title"), /Invalid document ID/); -}); diff --git a/packages/lgtmd/tests/compiler.test.ts b/packages/lgtmd/tests/compiler.test.ts deleted file mode 100644 index 958a656..0000000 --- a/packages/lgtmd/tests/compiler.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { Doc } from "../src/authoring.js"; -import { compile, publish } from "../src/compiler.js"; -import type { FrozenDocument } from "../src/model.js"; -import { test } from "node:test"; - -test("JSON resources use Rust's canonical key and number encoding", async () => { - const doc = new Doc("canonical-json", "Canonical JSON"); - const resource = doc.assets.json("data", { "2": 2, "10": 1, tiny: 0.000001 }); - doc.md`${resource.code()}`; - const exports = await compile([doc.finish()]); - assert.equal( - new TextDecoder().decode(exports.reader["assets/canonical-json/data.json"]), - '{\n "10": 1,\n "2": 2,\n "tiny": 1e-6\n}\n', - ); -}); - -async function portableDocument(): Promise { - const doc = new Doc("portable", "Portable document"); - const count = doc.observe("count", 3); - const privateValue = doc.observe("internal-only", "private observation"); - await doc.check("verified-count", () => assert.equal(count.value, 3)); - doc.section("overview", "Overview"); - const passage = doc.md`Contains ${count} records.`; - doc.noteOn(passage)`Internal: ${privateValue}`; - const data = doc.assets.json("data", { z: 2, a: 1 }); - doc.md`Data: - -${data.code()} - -${data.link("Download")}`; - const image = doc.assets.image("image", Uint8Array.of(1, 2, 3), { - producer: "fixture", - profile: "exact-v1", - }); - doc.md`${image.embed("Fixed fixture bytes")}`; - return doc.finish(); -} - -test("Rust and TypeScript authoring produce identical common exports", async () => { - const fixture = - process.env.LGTMD_CONFORMANCE_FIXTURE ?? - fileURLToPath( - new URL( - "../../../../crates/lgtmd/tests/fixtures/portable.json", - import.meta.url, - ), - ); - const expected = JSON.parse(await readFile(fixture, "utf8")) as { - exports: { - reader: Record; - contributor: Record; - }; - }; - const actual = await compile([await portableDocument()]); - for (const audience of ["reader", "contributor"] as const) { - assert.deepEqual( - Object.fromEntries( - Object.entries(actual[audience]).map(([path, bytes]) => [ - path, - Array.from(bytes), - ]), - ), - expected.exports[audience], - ); - } - assert.ok( - !new TextDecoder() - .decode(actual.reader["manifest.json"]) - .includes("internal-only"), - ); -}); - -test("the native store checks exactly, preserves ownership, and requires explicit updates", async () => { - const directory = await mkdtemp(join(tmpdir(), "lgtmd-store-")); - // macOS /var is a symlink; use real paths for the store's explicit policy. - const { realpath } = await import("node:fs/promises"); - const root = await realpath(directory); - const options = { - reader: join(root, "public"), - contributor: join(root, "private"), - }; - try { - const document = await portableDocument(); - await publish([document], { ...options, command: "build" }); - await publish([document], { ...options, command: "check" }); - await assert.rejects( - publish([document], { ...options, command: "build" }), - /already exists/, - ); - const changed = new Doc("portable", "Portable document"); - changed.md`Changed prose.`; - await assert.rejects( - publish([changed.finish()], { ...options, command: "check" }), - /differ|Missing|Orphan|Changed|mismatch/i, - ); - await writeFile(join(options.reader, "unowned.txt"), "keep me"); - await assert.rejects( - publish([document], { ...options, command: "update" }), - /Unowned/, - ); - await rm(join(options.reader, "unowned.txt")); - await publish([document], { ...options, command: "check" }); - const alternate = await portableDocument(); - const unsupported = { - ...alternate, - resources: alternate.resources.map((resource) => - resource.id === "image" - ? { ...resource, profile: "different-host" } - : resource, - ), - }; - await assert.rejects( - publish([unsupported], { ...options, command: "update" }), - /Incompatible producer/, - ); - await publish([document], { ...options, command: "check" }); - await assert.rejects( - publish([document], { - ...options, - contributor: options.reader, - command: "update", - }), - /overlap|distinct|separate/i, - ); - } finally { - await rm(root, { recursive: true }); - } -}); - -test("compiler rejects malformed frozen data instead of trusting host handles", async () => { - const doc = await portableDocument(); - const missing = structuredClone(doc) as unknown as { - nodes: { parts?: { target?: { id: string } }[] }[]; - }; - missing.nodes[1]!.parts![1]!.target!.id = "missing"; - await assert.rejects( - compile([missing as unknown as FrozenDocument]), - /Missing reference/, - ); - const orphan = { - ...doc, - resources: [...doc.resources, { ...doc.resources[0]!, id: "orphan" }], - }; - await assert.rejects(compile([orphan]), /Unreferenced resources/); - await assert.rejects( - compile([doc], { pageOverrides: { portable: "../outside.md" } }), - /path|Path/, - ); -}); - -test("untyped compiler callers cannot silently coerce unsupported JSON", async () => { - const doc = await portableDocument(); - const invalidNumber = { - ...doc, - observations: [{ id: "count", value: Number.NaN }], - }; - await assert.rejects(compile([invalidNumber]), /finite/); - const omitted = { ...doc, observations: [{ id: "count", value: undefined }] }; - await assert.rejects( - compile([omitted as unknown as FrozenDocument]), - /JSON value/, - ); -}); diff --git a/packages/lgtmd/tests/lifecycle.test.ts b/packages/lgtmd/tests/lifecycle.test.ts deleted file mode 100644 index cc8ccbd..0000000 --- a/packages/lgtmd/tests/lifecycle.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { - guide, - GuideRunError, - runGuide, - runGuideWithContext, -} from "../src/lifecycle.js"; - -test("declaring a guide does not execute it and execution cleans up before returning", async () => { - const order: string[] = []; - const definition = guide( - { - id: "prepared", - title: "Prepared", - prepare: async () => { - order.push("prepare"); - return { - context: { count: 3 }, - cleanup: async () => { - order.push("cleanup"); - }, - }; - }, - }, - (doc, context) => { - order.push("write"); - doc.md`There are ${context.count} records.`; - }, - ); - assert.equal(order.length, 0); - const document = await runGuide(definition); - order.push("returned"); - assert.deepEqual(order, ["prepare", "write", "cleanup", "returned"]); - assert.equal(document.id, "prepared"); - assert.deepEqual(document.checks, []); -}); - -test("partial preparation failure remains project-owned", async () => { - const error = new Error("Preparation failed before context acquisition"); - let wrote = false; - const definition = guide( - { - id: "partial", - title: "Partial", - prepare: async () => { - throw error; - }, - }, - () => { - wrote = true; - }, - ); - await assert.rejects( - runGuide(definition), - (candidate) => candidate === error, - ); - assert.equal(wrote, false); -}); - -test("successful acquisition runs cleanup exactly once after an authoring failure", async () => { - const error = new Error("Write failed"); - let cleanups = 0; - const definition = guide( - { - id: "write-failure", - title: "Write failure", - prepare: () => ({ - context: undefined, - cleanup: () => { - cleanups += 1; - }, - }), - }, - () => { - throw error; - }, - ); - await assert.rejects( - runGuide(definition), - (candidate) => candidate === error, - ); - assert.equal(cleanups, 1); -}); - -test("primary and cleanup failures retain both original errors", async () => { - const primary = new Error("Authoring failed"); - const cleanup = new Error("Cleanup failed"); - const definition = guide( - { - id: "both", - title: "Both failures", - prepare: () => ({ - context: null, - cleanup: () => { - throw cleanup; - }, - }), - }, - () => { - throw primary; - }, - ); - await assert.rejects(runGuide(definition), (candidate) => { - assert.ok(candidate instanceof GuideRunError); - assert.deepEqual(candidate.errors, [primary, cleanup]); - assert.equal(candidate.primary, primary); - assert.equal(candidate.cleanup, cleanup); - return true; - }); -}); - -test("cleanup failure prevents otherwise successful output", async () => { - const cleanup = new Error("Cleanup failed"); - const definition = guide( - { - id: "cleanup", - title: "Cleanup", - prepare: () => ({ - context: 3, - cleanup: () => { - throw cleanup; - }, - }), - }, - (doc) => { - doc.md`Successful authoring.`; - }, - ); - await assert.rejects( - runGuide(definition), - (candidate) => candidate === cleanup, - ); -}); - -test("swallowed native assertion failure and cleanup failure are both preserved", async () => { - const primary = new Error("Native assertion failed"); - const cleanup = new Error("Cleanup failed"); - const definition = guide( - { - id: "taint", - title: "Tainted authoring", - prepare: () => ({ - context: 3, - cleanup: () => { - throw cleanup; - }, - }), - }, - async (doc) => { - try { - await doc.check("failed", () => { - throw primary; - }); - } catch { - /* Deliberately test swallowed errors. */ - } - doc.md`This prose cannot conceal a failed assertion.`; - }, - ); - await assert.rejects(runGuide(definition), (candidate) => { - assert.ok(candidate instanceof GuideRunError); - assert.ok(candidate.primary instanceof AggregateError); - assert.equal(candidate.primary.errors[0], primary); - assert.equal(candidate.cleanup, cleanup); - return true; - }); -}); - -test("supplied context has no framework cleanup ownership", async () => { - let cleanups = 0; - const context = { - value: 7, - cleanup: () => { - cleanups += 1; - }, - }; - const document = await runGuideWithContext( - { id: "supplied", title: "Supplied" }, - context, - (doc, supplied) => { - assert.equal(supplied, context); - doc.md`The supplied value is ${supplied.value}.`; - }, - ); - assert.equal(document.id, "supplied"); - assert.equal(cleanups, 0); -}); - -test("declarations copy identity while preparation may omit cleanup", async () => { - const page = { - id: "original", - title: "Original", - prepare: () => ({ context: 5 }), - }; - const definition = guide(page, (doc, context) => { - doc.md`Value: ${context}.`; - }); - page.id = "changed"; - page.title = "Changed"; - assert.equal((await runGuide(definition)).id, "original"); -}); - -test("cleanup is acquired once even when authoring mutates the preparation result", async () => { - let original = 0; - let replacement = 0; - const prepared = { - context: 7, - cleanup: () => { - original += 1; - }, - }; - const definition = guide( - { id: "acquired", title: "Acquired cleanup", prepare: () => prepared }, - (doc) => { - prepared.cleanup = () => { - replacement += 1; - }; - doc.md`Cleanup ownership is already established.`; - }, - ); - await runGuide(definition); - assert.equal(original, 1); - assert.equal(replacement, 0); -}); - -test("concise expression callbacks author documents without return boilerplate", async () => { - const definition = guide( - { id: "concise", title: "Concise authoring" }, - (doc) => doc.md`A useful explanation.`, - ); - const document = await runGuide(definition); - assert.equal(document.nodes.length, 1); - assert.deepEqual(document.checks, []); -}); - -test("authoring callback return values do not establish verification claims", async () => { - for (const value of [true, false]) { - const definition = guide( - { id: "returned", title: "Returned value" }, - async () => value, - ); - const document = await runGuide(definition); - assert.deepEqual(document.nodes, []); - assert.deepEqual(document.checks, []); - } -}); diff --git a/packages/lgtmd/tests/playwright.test.ts b/packages/lgtmd/tests/playwright.test.ts deleted file mode 100644 index 84a914f..0000000 --- a/packages/lgtmd/tests/playwright.test.ts +++ /dev/null @@ -1,413 +0,0 @@ -import assert from "node:assert/strict"; -import { - mkdtemp, - mkdir, - readFile, - realpath, - rm, - stat, - symlink, - writeFile, -} from "node:fs/promises"; -import { createRequire } from "node:module"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; -import { runPlaywright } from "../src/playwright/index.js"; -import { ReporterGuard } from "../src/playwright/guard.js"; - -const require = createRequire(import.meta.url); -const playwrightDirectory = dirname( - require.resolve("@playwright/test/package.json"), -); -const packageDirectory = fileURLToPath(new URL("../../", import.meta.url)); - -async function project( - options: { - source?: string; - config?: string; - beforeConfig?: string; - reporter?: string; - files?: Record; - } = {}, -) { - const directory = await realpath( - await mkdtemp(join(tmpdir(), "lgtmd-runner-test-")), - ); - await mkdir(join(directory, "node_modules", "@playwright"), { - recursive: true, - }); - await symlink( - playwrightDirectory, - join(directory, "node_modules", "@playwright", "test"), - "dir", - ); - await symlink( - packageDirectory, - join(directory, "node_modules", "lgtmd"), - "dir", - ); - await writeFile( - join(directory, "package.json"), - JSON.stringify({ type: "module" }), - ); - await writeFile( - join(directory, "playwright.config.mjs"), - ` - import { defineConfig } from "@playwright/test"; - import { docsReporter } from "lgtmd/playwright"; - ${options.beforeConfig ?? ""} - export default defineConfig({ - testDir: ".", testMatch: "case.spec.mjs", workers: 1, - retries: 0, timeout: 3000, projects: [{name: "one"}], - reporter: ${options.reporter ?? "docsReporter()"}, - ${options.config ?? ""} - }); - `, - ); - await writeFile( - join(directory, "case.spec.mjs"), - ` - import {test as base, expect} from "@playwright/test"; - import {withDocs} from "lgtmd/playwright"; - ${ - options.source ?? - ` - const test = withDocs(base); - test("a documented check", async ({doc}) => { - doc.page({id: "sample", title: "Sample"}); - doc.md\`The sample is ready.\`; - expect(2 + 2).toBe(4); - }); - ` - } - `, - ); - for (const [filename, source] of Object.entries(options.files ?? {})) { - await writeFile(join(directory, filename), source); - } - return { - directory, - reader: join(directory, "reader"), - contributor: join(directory, "contributor"), - run() { - return runPlaywright({ - config: join(directory, "playwright.config.mjs"), - command: "build", - reader: join(directory, "reader"), - contributor: join(directory, "contributor"), - }); - }, - cleanup: () => rm(directory, { recursive: true, force: true }), - }; -} - -async function assertUnpublished(run: Awaited>) { - await assert.rejects(stat(run.reader), { code: "ENOENT" }); - await assert.rejects(stat(run.contributor), { code: "ENOENT" }); -} - -test("reporter guard retains synchronous, asynchronous, malformed, and explicit failing outcomes", async () => { - const guard = new ReporterGuard([ - { - onTestBegin() { - throw new Error("sync failure"); - }, - }, - { - async onExit() { - throw new Error("async failure"); - }, - }, - { - async onEnd() { - return Object.defineProperty({}, "status", { - get() { - throw new Error("reporter result getter failed"); - }, - }) as { status?: "passed" }; - }, - }, - { - async onEnd() { - return { status: "failed" as const }; - }, - }, - ]); - guard.invoke("onTestBegin"); - guard.invoke("onExit"); - guard.invoke("onEnd"); - await guard.settle(); - assert.deepEqual(guard.errors.sort(), [ - "onEnd: Error: reporter result getter failed", - "onEnd: reporter requested failed status", - "onExit: Error: async failure", - "onTestBegin: Error: sync failure", - ]); -}); - -test("real Playwright fixture composition publishes only after test and worker cleanup", async () => { - const run = await project({ - source: ` - import {appendFile} from "node:fs/promises"; - const applicationTest = base.extend({ - workspace: async ({}, use) => { - await use({name: "Prepared workspace"}); - await appendFile("cleanup.txt", "test cleanup\\n"); - }, - service: [async ({}, use) => { - await use("running"); - await appendFile("cleanup.txt", "worker cleanup\\n"); - }, {scope: "worker"}], - }); - const test = withDocs(applicationTest); - test("native fixtures stay available", async ({workspace, service, doc}) => { - expect(service).toBe("running"); - doc.page({id: "sample", title: "Sample"}); - doc.md\`Use \${workspace.name}.\`; - expect(workspace.name).toBe("Prepared workspace"); - }); - `, - }); - try { - await run.run(); - assert.equal( - await readFile(join(run.directory, "cleanup.txt"), "utf8"), - "test cleanup\nworker cleanup\n", - ); - assert.match( - await readFile(join(run.reader, "sample.md"), "utf8"), - /Prepared workspace/, - ); - } finally { - await run.cleanup(); - } -}); - -test("caught named check failure cannot become accepted browser evidence", async () => { - const run = await project({ - source: ` - const test = withDocs(base); - test("caught assertion", async ({doc}) => { - doc.page({id: "sample", title: "Sample"}); - doc.md\`A claim.\`; - try { await doc.check("false-claim", () => expect(1).toBe(2)); } catch {} - }); - `, - }); - try { - await assert.rejects(run.run(), /did not finish successfully/); - await assertUnpublished(run); - } finally { - await run.cleanup(); - } -}); - -for (const [name, source, config, files] of [ - [ - "test fixture teardown", - ` - const test = withDocs(base.extend({ - context: async ({}, use) => { await use({}); throw new Error("test teardown failed"); }, - })); - test("test teardown", async ({doc, context}) => { - doc.page({id: "sample", title: "Sample"}); doc.md\`Candidate.\`; expect(context).toEqual({}); - }); - `, - "", - {}, - ], - [ - "worker fixture teardown", - ` - const test = withDocs(base.extend({ - service: [async ({}, use) => { await use("ready"); throw new Error("worker teardown failed"); }, {scope: "worker"}], - })); - test("worker teardown", async ({doc, service}) => { - doc.page({id: "sample", title: "Sample"}); doc.md\`Candidate.\`; expect(service).toBe("ready"); - }); - `, - "", - {}, - ], - [ - "global teardown", - undefined, - 'globalTeardown: "./teardown.mjs",', - { - "teardown.mjs": - 'export default async () => { throw new Error("global teardown failed"); };', - }, - ], - [ - "test timeout", - ` - const test = withDocs(base); - test("timeout", async ({doc}) => { - doc.page({id: "sample", title: "Sample"}); doc.md\`Candidate.\`; - await new Promise(resolve => setTimeout(resolve, 500)); - }); - `, - "timeout: 100,", - {}, - ], - [ - "global timeout", - ` - const test = withDocs(base); - test("global timeout", async ({doc}) => { - doc.page({id: "sample", title: "Sample"}); doc.md\`Candidate.\`; - await new Promise(resolve => setTimeout(resolve, 3000)); - }); - `, - "timeout: 5000, globalTimeout: 1000,", - {}, - ], - [ - "SIGINT interruption", - ` - const test = withDocs(base); - test("interrupt the owned runner", async ({doc}) => { - doc.page({id: "sample", title: "Sample"}); doc.md\`Candidate.\`; - // Playwright forks this test worker from the CLI owned by runPlaywright. - setTimeout(() => process.kill(process.ppid, "SIGINT"), 50); - await new Promise(resolve => setTimeout(resolve, 1000)); - }); - `, - "", - {}, - ], -] as const) { - test(`real Playwright ${name} blocks publication`, async () => { - const run = await project({ source, config, files }); - try { - await assert.rejects(run.run(), /did not finish successfully/); - await assertUnpublished(run); - } finally { - await run.cleanup(); - } - }); -} - -test("async reporter result inspection failure blocks publication", async () => { - const run = await project({ - beforeConfig: - 'class BrokenReporter { async onEnd() { return Object.defineProperty({}, "status", {get() {throw new Error("reporter result getter failed");}}); } }', - reporter: "docsReporter({reporters: [new BrokenReporter()]})", - }); - try { - await assert.rejects(run.run(), /did not finish successfully/); - await assertUnpublished(run); - } finally { - await run.cleanup(); - } -}); - -test("late guarded onExit error blocks publication even when Playwright exits zero", async () => { - const run = await project({ - beforeConfig: - 'class LateReporter { async onExit() { await Promise.resolve(); throw new Error("late reporter failure"); } }', - reporter: "docsReporter({reporters: [new LateReporter()]})", - }); - try { - await assert.rejects( - run.run(), - /completed success receipt:.*[\s\S]*late reporter failure/, - ); - await assertUnpublished(run); - } finally { - await run.cleanup(); - } -}); - -test("late reporter failure preserves an existing explicitly accepted baseline", async () => { - const run = await project(); - try { - await runPlaywright({ - config: join(run.directory, "playwright.config.mjs"), - command: "update", - reader: run.reader, - contributor: run.contributor, - }); - const readerBefore = await readFile(join(run.reader, "sample.md")); - const contributorBefore = await readFile( - join(run.contributor, "sample.md"), - ); - const configPath = join(run.directory, "playwright.config.mjs"); - const config = await readFile(configPath, "utf8"); - await writeFile( - configPath, - config.replace( - "reporter: docsReporter(),", - 'reporter: docsReporter({reporters: [{async onExit() {throw new Error("late update failure");}}]}),', - ), - ); - await assert.rejects( - runPlaywright({ - config: configPath, - command: "update", - reader: run.reader, - contributor: run.contributor, - }), - /late update failure/, - ); - assert.deepEqual( - await readFile(join(run.reader, "sample.md")), - readerBefore, - ); - assert.deepEqual( - await readFile(join(run.contributor, "sample.md")), - contributorBefore, - ); - } finally { - await run.cleanup(); - } -}); - -test("unguarded custom reporter configuration is rejected", async () => { - const run = await project({ - reporter: '[...docsReporter(), ["./unguarded.mjs"]]', - files: { "unguarded.mjs": "export default class OtherReporter {}" }, - }); - try { - await assert.rejects(run.run(), /did not finish successfully/); - await assertUnpublished(run); - } finally { - await run.cleanup(); - } -}); - -for (const [name, config] of [ - ["retries", "retries: 1,"], - ["repeated runs", "repeatEach: 2,"], - ["multiple projects", 'projects: [{name: "one"}, {name: "two"}],'], - ["shards", "shard: {current: 1, total: 2},"], -] as const) { - test(`unsupported ${name} fails explicitly`, async () => { - const run = await project({ config }); - try { - await assert.rejects(run.run(), /did not finish successfully/); - await assertUnpublished(run); - } finally { - await run.cleanup(); - } - }); -} - -test("duplicate page identities are rejected rather than choosing by arrival", async () => { - const run = await project({ - source: ` - const test = withDocs(base); - for (const title of ["first", "second"]) test(title, async ({doc}) => { - doc.page({id: "same", title}); doc.md\`A document.\`; - }); - `, - }); - try { - await assert.rejects(run.run(), /did not finish successfully/); - await assertUnpublished(run); - } finally { - await run.cleanup(); - } -}); diff --git a/packages/lgtmd/tsconfig.json b/packages/lgtmd/tsconfig.json deleted file mode 100644 index 614c997..0000000 --- a/packages/lgtmd/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "declaration": true, - "outDir": "dist", - "rootDir": ".", - "types": ["node"], - "skipLibCheck": true, - "noUncheckedIndexedAccess": true - }, - "include": ["src/**/*.ts", "tests/**/*.ts"] -} diff --git a/rfcs/0003-executable-documentation-framework.md b/rfcs/0003-executable-documentation-framework.md index 82c9bb6..b2a1a1c 100644 --- a/rfcs/0003-executable-documentation-framework.md +++ b/rfcs/0003-executable-documentation-framework.md @@ -1,16 +1,17 @@ # RFC 0003: A reusable executable documentation framework -Status: **Implemented locally — experimental SDK and N3 bridge; independent adoption and release remain open** +Status: **Implemented and extracted — experimental SDK and N3 bridge; independent adoption and release remain open** Date: 2026-10-04 -Incubate this work inside N3, with an eventual move to a dedicated repository. -The user authorized local implementation after reviewing the initial plan. The -maintained SDK and representative integrations now live in this repository; -[the architecture contract](../docs/architecture/executable-documents.md) records -the implemented boundary. Broader design sketches below remain proposals where -they exceed that boundary. The maintained SDK is now named `lgtmd` and remains -unpublished; historical sections retain the prototype's names. +The framework was incubated inside N3 and is now maintained in the dedicated +[lgtmd repository](https://github.com/softmarshmallow/lgtmd). N3 consumes an exact +Git revision and retains its application adapter, replay, renderer, and guide +artifacts. [The architecture contract](../docs/architecture/executable-documents.md) +records the current boundary. The findings and development commands below are a +historical record of incubation; broader sketches remain proposals where they +exceed the implemented contract. lgtmd remains unpublished, and its current +repository owns SDK development and verification. Authoring requirements: concise, honest authoring and scenarios that write the document's text, resources, and explicit contributor notes themselves. The primary @@ -680,7 +681,7 @@ infrastructure, publication, and the repository move remain deferred. The local implementation now includes: -- [An unpublished SDK](../crates/lgtmd/README.md) with Rust-authored prose, +- [An unpublished SDK](https://github.com/softmarshmallow/lgtmd/blob/main/crates/lgtmd/README.md) with Rust-authored prose, checked values, observed bindings, frozen resources, contributor notes, strict audience filtering, a structured manifest, and a project-owned runner. - One filesystem lifecycle used by the SDK and N3. Dedicated trees preserve N3's @@ -696,7 +697,7 @@ The local implementation now includes: format supports private prose notes and public captured media; private captured resources fail before publication. Full SDK exports support contributor-only resources and exclude their files and metadata from the reader bundle. -- [Two non-GUI integrations](../crates/doc-example-config/README.md): a real +- [Two non-GUI integrations](https://github.com/softmarshmallow/lgtmd/blob/main/crates/doc-example-config/README.md): a real configuration CLI and the existing serde_json library's public parsing/error/ serialization APIs. The latter is our integration, not independent maintainer adoption or upstream documentation. diff --git a/rfcs/0004-lgtmd-web-incubation.md b/rfcs/0004-lgtmd-web-incubation.md index 027194b..3621820 100644 --- a/rfcs/0004-lgtmd-web-incubation.md +++ b/rfcs/0004-lgtmd-web-incubation.md @@ -1,20 +1,20 @@ # RFC 0004: Incubate lgtmd's Rust and web contracts together -Status: **Implemented locally; extraction and release deferred** +Status: **Implemented and extracted; release deferred** Date: 2026-10-13 -Incubate the portable Rust SDK and a small JavaScript/TypeScript integration -directly in N3, without a submodule. Keep N3 working as a real consumer while -the language and lifecycle boundaries develop. Then move the independently -verified SDK, examples, and development harness to the lgtmd repository. +The portable Rust SDK and TypeScript integration were incubated directly in N3, +without a submodule, while N3 remained a real consumer. The independently verified +SDK, Node bridge, Rust/React examples, and development harness now live in the +[lgtmd repository](https://github.com/softmarshmallow/lgtmd). N3 consumes an exact +Git revision and retains its own adapter, replay, renderer, and guide artifacts. -The preferred eventual N3 integration is a crates.io dependency once the package -passes its release gates. A submodule is optional contingency, not a required -stage. This proposal authorizes no repository move, publication, or release. - -The incubation below is implemented locally. The package/path/import rename is -recorded separately from the shared Rust/Node implementation and web examples. +The preferred eventual N3 integration remains a crates.io dependency once the +package passes its release gates. Extraction and dependency pinning do not +publish a package or authorize a release. The incubation findings and original +sequence below preserve the reasoning and historical verification evidence; +SDK-owned development commands now run in the lgtmd repository. ## Implementation record @@ -36,7 +36,7 @@ recorded separately from the shared Rust/Node implementation and web examples. receipt, and successful runner-process exit before publishing. Imported custom reporter instances are supported; built-in reporter-name delegation, retries, repeatEach greater than one, shards, and multiple/dependency projects are not. -- [Three complete React/Vite examples](../examples/lgtmd-web/README.md) include all project +- [Three complete React/Vite examples](https://github.com/softmarshmallow/lgtmd/blob/main/examples/lgtmd-web/README.md) include all project setup, cleanup, application code, prose, observations, checks, downloads, screenshots and contributor notes. React components and ordinary Vite scripts model a real developer application; lgtmd is a development dependency. Browser @@ -59,17 +59,17 @@ visual regression baseline or cross-platform pixel identity. The separately reviewed local Reader/Contributor trees and screenshots are disposable evidence; projects choose and explicitly accept their own baselines using `update`. -See the [implemented architecture](../docs/architecture/executable-documents.md#node-and-web-incubation) -and [Node SDK contract](../packages/lgtmd/README.md). The original sequence below +See the [implemented architecture](../docs/architecture/executable-documents.md#node-and-web-integration) +and [Node SDK contract](https://github.com/softmarshmallow/lgtmd/blob/main/packages/lgtmd/README.md). The original sequence below records the reasoning and later extraction/release gates. ## Outcome and rationale -The goal is to discover major authoring, compiler, and host integration changes -while both Rust and JavaScript consumers can exercise them together. Extraction -should primarily change manifests, dependency location, and development wiring. -N3 scenarios should keep their authoring API and retained guide output when the -dependency becomes external. +Incubation exercised major authoring, compiler, and host integration changes +through both Rust and JavaScript consumers. Extraction changes manifests, +dependency location, and development wiring while retaining N3's authoring API +and guide output. Future SDK development proceeds in its own repository, with +N3 compatibility checked explicitly when updating the pinned dependency. Readiness applies to a declared initial support scope. Incubation cannot promise that no future breaking change will ever be needed. Package contents, clean @@ -78,7 +78,7 @@ exit gates; moving files alone does not establish production readiness. [RFC 0003](0003-executable-documentation-framework.md) records the existing Rust framework. The [implemented architecture](../docs/architecture/executable-documents.md) -and [SDK README](../crates/lgtmd/README.md) remain authoritative for current behavior. +and [SDK README](https://github.com/softmarshmallow/lgtmd/blob/main/crates/lgtmd/README.md) remain authoritative for current behavior. ## Initial support scope diff --git a/rfcs/README.md b/rfcs/README.md index 2d4cdc7..f8db9f2 100644 --- a/rfcs/README.md +++ b/rfcs/README.md @@ -34,9 +34,9 @@ that contain no useful reasoning do not need an RFC. ## Index -| RFC | Status | Topic | -| -------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------- | -| [0001](0001-paired-selection-in-2d.md) | Deferred | Pair corresponding front/back vertices during selection in an aligned 2D view. | -| [0002](0002-authored-scenes.md) | Draft | N3-owned scenes, editor/history boundaries, migration, and interchange feasibility. | -| [0003](0003-executable-documentation-framework.md) | Implemented locally | A reusable Rust-first documentation SDK, evidence contract, and staged extraction from N3. | -| [0004](0004-lgtmd-web-incubation.md) | Implemented locally | Incubate Rust and web consumers together before extracting lgtmd and consuming its published SDK. | +| RFC | Status | Topic | +| -------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------- | +| [0001](0001-paired-selection-in-2d.md) | Deferred | Pair corresponding front/back vertices during selection in an aligned 2D view. | +| [0002](0002-authored-scenes.md) | Draft | N3-owned scenes, editor/history boundaries, migration, and interchange feasibility. | +| [0003](0003-executable-documentation-framework.md) | Implemented and extracted | A reusable Rust-first documentation SDK and N3 adapter, maintained in separate repositories. | +| [0004](0004-lgtmd-web-incubation.md) | Implemented and extracted | Rust/web incubation completed; N3 consumes an exact lgtmd Git revision until registry release. | diff --git a/tools/ci_runner.py b/tools/ci_runner.py index 64ace8a..441274a 100644 --- a/tools/ci_runner.py +++ b/tools/ci_runner.py @@ -54,12 +54,9 @@ cargo test --locked --features viewport-measure --lib measurement:: cargo test --locked --features viewport-measure --lib scene_only_presentation_matches_egui_pixels_and_restores_texture_after_resize """ -DOC_FRAMEWORK_CHECKS = """\ -python3 crates/lgtmd/dev/verify.py -""" -CI_SCRIPT = CI_CHECKS_PREFIX + DOC_FRAMEWORK_CHECKS + BENCHMARK_RUST_CHECKS + "exec cargo test --locked\n" +CI_SCRIPT = CI_CHECKS_PREFIX + BENCHMARK_RUST_CHECKS + "exec cargo test --locked\n" CI_PARTITION_SCRIPTS = { - "checks": CI_CHECKS_PREFIX + "python3 tools/ci_test_inventory.py\n" + DOC_FRAMEWORK_CHECKS + BENCHMARK_RUST_CHECKS + "checks": CI_CHECKS_PREFIX + "python3 tools/ci_test_inventory.py\n" + BENCHMARK_RUST_CHECKS + "exec cargo test --locked -- --skip documentation::tests::generated_documentation_is_current --exact\n", "guide": """\ python3 tools/ci_test_inventory.py diff --git a/tools/tests/test_ci_runner.py b/tools/tests/test_ci_runner.py index acd0819..0865ea4 100644 --- a/tools/tests/test_ci_runner.py +++ b/tools/tests/test_ci_runner.py @@ -107,7 +107,6 @@ def test_ci_runs_full_checks_without_recursing_through_just_or_runner(self): "node --test tools/tests/web_wrapper.test.mjs", "python3 -m unittest discover -s tools/benchmark/tests -t . -p 'test_*.py'", "node --test tools/benchmark/tests/*.test.mjs", - "python3 crates/lgtmd/dev/verify.py", "cargo test --locked --features viewport-measure --lib measurement::", "cargo test --locked --features viewport-measure --lib scene_only_presentation_matches_egui_pixels_and_restores_texture_after_resize", "exec cargo test --locked", @@ -134,7 +133,6 @@ def test_ci_partition_modes_keep_checks_and_execute_the_validated_exact_partitio self.assertIn("exec " + " ".join(selected) + "\n", script) self.assertLess(script.index("python3 tools/ci_test_inventory.py"), script.index("exec cargo test")) self.assertEqual(runner.CI_CHECKS_PREFIX in script, mode == "checks") - self.assertEqual(runner.DOC_FRAMEWORK_CHECKS in script, mode == "checks") self.assertIn("LP_NATIVE_VECTOR_WIDTH=256", arguments) self.assertIn("MESA_SHADER_CACHE_DISABLE=true", arguments) @@ -160,37 +158,6 @@ def test_partition_inventory_failure_prevents_test_execution_in_both_shell_scrip calls = record.read_text().splitlines() if record.exists() else [] self.assertEqual(calls, ["fmt --all --check", "clippy --locked --workspace --all-targets -- -D warnings"] if mode == "checks" else []) - def test_documentation_framework_failure_stops_the_remaining_rust_gates(self): - commands = self.root / "commands" - commands.mkdir() - cargo = commands / "cargo" - cargo.write_text("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$N3_TEST_RECORD\"\n") - cargo.chmod(0o755) - python = commands / "python3" - python.write_text( - "#!/bin/sh\n" - "if [ \"$1\" = crates/lgtmd/dev/verify.py ]; then\n" - " printf '%s\\n' 'portable framework gate' >> \"$N3_TEST_RECORD\"\n" - " exit 19\n" - "fi\n" - ) - python.chmod(0o755) - node = commands / "node" - node.write_text("#!/bin/sh\nexit 0\n") - node.chmod(0o755) - record = self.root / "cargo-calls.txt" - environment = {**os.environ, "PATH": f"{commands}:{os.environ['PATH']}", "N3_TEST_RECORD": str(record)} - for label, script in (("ci", runner.CI_SCRIPT), ("checks", runner.CI_PARTITION_SCRIPTS["checks"])): - with self.subTest(mode=label): - record.unlink(missing_ok=True) - result = subprocess.run(["/bin/sh", "-eu", "-c", script], cwd=self.root, env=environment, check=False) - self.assertEqual(result.returncode, 19) - self.assertEqual(record.read_text().splitlines(), [ - "fmt --all --check", - "clippy --locked --workspace --all-targets -- -D warnings", - "portable framework gate", - ]) - def test_environment_receipt_reports_only_architecture_libc_and_cpu_features(self): cpuinfo = """processor : 0 model name : private host name From deb47f39c14f0efdc9ae2e461bf70dd632a0e6e5 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 13 Oct 2026 21:24:24 +0900 Subject: [PATCH 4/4] Point lgtmd integration to its organization repository Follow the transfer to lgtmd/lgtmd in Cargo's manifest and lockfile and in maintained contributor links. Preserve the exact SDK revision and all existing dependency versions. --- .agents/skills/docs-driven-development/SKILL.md | 2 +- AGENTS.md | 4 ++-- Cargo.lock | 2 +- Cargo.toml | 2 +- TODO.md | 2 +- docs/architecture/executable-documents.md | 14 +++++++------- docs/development.md | 6 +++--- rfcs/0003-executable-documentation-framework.md | 6 +++--- rfcs/0004-lgtmd-web-incubation.md | 8 ++++---- 9 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.agents/skills/docs-driven-development/SKILL.md b/.agents/skills/docs-driven-development/SKILL.md index 29e6927..5f80268 100644 --- a/.agents/skills/docs-driven-development/SKILL.md +++ b/.agents/skills/docs-driven-development/SKILL.md @@ -154,7 +154,7 @@ for the full native suite and `just test-metal` for focused capture checks. For skill or hand-authored contributor-doc changes alone, validate instructions and links; unchanged app/media output does not require regeneration. Portable framework changes belong in the -[lgtmd repository](https://github.com/softmarshmallow/lgtmd) and use its SDK and +[lgtmd repository](https://github.com/lgtmd/lgtmd) and use its SDK and consumer gates. N3 adapter or dependency revision changes require native replay and `just verify` here. diff --git a/AGENTS.md b/AGENTS.md index fecee10..e974640 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ boundaries. The current Editor still depends on egui and is not a public kernel ## Code organization Keep N3 as one application crate. Its documentation adapter consumes the portable -[lgtmd SDK](https://github.com/softmarshmallow/lgtmd), maintained in its own repository. +[lgtmd SDK](https://github.com/lgtmd/lgtmd), maintained in its own repository. N3 pins an exact Git revision until a registry release is ready. The tested boundary separates application replay from document composition and publication; it does not make the editor a public kernel. Keep N3's replay, renderer, scenarios, @@ -207,7 +207,7 @@ README, check links and factual claims; unchanged application/media output does not require rebuilding the renderer or regenerating the guide. Verify SDK or web adapter changes in the -[lgtmd repository](https://github.com/softmarshmallow/lgtmd). An N3 dependency +[lgtmd repository](https://github.com/lgtmd/lgtmd). An N3 dependency revision update still requires native `just verify`, including real replay and exact guide checks. Keep dependency updates explicit; do not add local path fallbacks or discover a sibling SDK checkout automatically. diff --git a/Cargo.lock b/Cargo.lock index a2b9c55..d295f8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1356,7 +1356,7 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "lgtmd" version = "0.0.0" -source = "git+https://github.com/softmarshmallow/lgtmd?rev=8828c41eaf7fbb002044aea9f8bfd4a06ba76168#8828c41eaf7fbb002044aea9f8bfd4a06ba76168" +source = "git+https://github.com/lgtmd/lgtmd?rev=8828c41eaf7fbb002044aea9f8bfd4a06ba76168#8828c41eaf7fbb002044aea9f8bfd4a06ba76168" dependencies = [ "pulldown-cmark", "same-file", diff --git a/Cargo.toml b/Cargo.toml index c9ba632..093f62e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ wgpu = "30.0.1" winit = "=0.30.13" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -lgtmd = { git = "https://github.com/softmarshmallow/lgtmd", rev = "8828c41eaf7fbb002044aea9f8bfd4a06ba76168" } +lgtmd = { git = "https://github.com/lgtmd/lgtmd", rev = "8828c41eaf7fbb002044aea9f8bfd4a06ba76168" } alacritty_terminal = "=0.26.0" egui-winit = { version = "=0.36.2", default-features = false, features = [ "clipboard", diff --git a/TODO.md b/TODO.md index c52a71a..7bf4c25 100644 --- a/TODO.md +++ b/TODO.md @@ -18,7 +18,7 @@ and document the implemented behavior in the executable user guide. | Paired selection in 2D | **Deferred:** consider verified front/back vertex pairing in aligned views; exact geometry does not prove selection intent. See [RFC 0001](rfcs/0001-paired-selection-in-2d.md). | | Grouping and hierarchy entities | Add an explicit document entity for organizing objects; evaluate groups, collections, or folders, including nesting, selection, and shared transform semantics. | | Multiple authored scenes | **Draft; implementation deferred.** Independent N3 scenes in one document/editor. Revisit [RFC 0002](rfcs/0002-authored-scenes.md), starting with a small native-scene proof of switching, history/recovery, and persistence before broader import work. | -| Executable guide authoring | lgtmd has moved to its [own repository](https://github.com/softmarshmallow/lgtmd); N3 consumes an exact Git revision. Migrate the remaining 26 N3 templates through the existing adapter when scheduled, and switch to crates.io after an explicitly authorized SDK release. See [the boundary](docs/architecture/executable-documents.md) and historical [RFC 0004](rfcs/0004-lgtmd-web-incubation.md). | +| Executable guide authoring | lgtmd has moved to its [own repository](https://github.com/lgtmd/lgtmd); N3 consumes an exact Git revision. Migrate the remaining 26 N3 templates through the existing adapter when scheduled, and switch to crates.io after an explicitly authorized SDK release. See [the boundary](docs/architecture/executable-documents.md) and historical [RFC 0004](rfcs/0004-lgtmd-web-incubation.md). | | 3D format I/O | Build on read-only glTF/GLB viewing: scene export/authoring, OBJ export, compression and advanced extensions; evaluate OpenUSD composition/schema support. See the [viewer milestone](docs/milestones/modern-scene-viewer.md). | | Materials and PBR | Extend imported PBR viewing into editable material assignment; HDR environment import, shadows, advanced materials and color management. See the [viewer milestone](docs/milestones/modern-scene-viewer.md). | | Node Canvas UI Kit | Establish reusable UI primitives for node blocks, ports, connections, selection, dragging, pan, and zoom. Validate interactions in isolated workbench examples so future editors can adopt them confidently when a concrete consumer exists. Scope this as UI foundation work; shader/modifier semantics and evaluation remain separate. | diff --git a/docs/architecture/executable-documents.md b/docs/architecture/executable-documents.md index 2f57b16..c4e1d99 100644 --- a/docs/architecture/executable-documents.md +++ b/docs/architecture/executable-documents.md @@ -1,6 +1,6 @@ # Executable documents and the N3 adapter -The [lgtmd SDK](https://github.com/softmarshmallow/lgtmd) separates document +The [lgtmd SDK](https://github.com/lgtmd/lgtmd) separates document composition and artifact publication from the application being documented. N3 remains one application crate and consumes lgtmd from an exact Git revision. The SDK, its Node bridge, and portable Rust/React examples are maintained in the @@ -31,7 +31,7 @@ correct application behavior from an artifact's existence or a declared MIME type. Meaningful assertions, producer metadata, fixtures, runtime drivers, review, and documentation organization remain the consumer's responsibility. -The [SDK README](https://github.com/softmarshmallow/lgtmd/blob/main/crates/lgtmd/README.md) is the API and manifest +The [SDK README](https://github.com/lgtmd/lgtmd/blob/main/crates/lgtmd/README.md) is the API and manifest reference. N3's [documentation pipeline](documentation-pipeline.md) owns product replay, timing, captures, and renderer-specific evidence. This document records the boundary between them. @@ -89,7 +89,7 @@ paths need distinct ASCII components beyond any shared ancestors. Case-only or Unicode-only distinctions require preexisting separate directories rather than guessing a filesystem's alias rules. Unicode parent directories and later path components remain supported. -The [configured SDK example](https://github.com/softmarshmallow/lgtmd/blob/main/crates/lgtmd/examples/configured.rs) +The [configured SDK example](https://github.com/lgtmd/lgtmd/blob/main/crates/lgtmd/examples/configured.rs) exercises a source-only host, external baselines, nested pages, renamed audience directories, private resources, and preservation of unrelated files. This boundary does not add a configuration file format, plugin loader, or repository abstraction. @@ -200,7 +200,7 @@ revision update goes through this same gate; changes to its API must remain explicit in the N3 adapter and scenarios. The SDK owns its independent -[verification workflow](https://github.com/softmarshmallow/lgtmd/blob/main/AGENTS.md), +[verification workflow](https://github.com/lgtmd/lgtmd/blob/main/AGENTS.md), including portable sources, Rust/Node tests, its own examples, and installed Cargo/npm consumers. Those gates run in the lgtmd repository rather than through N3's application commands or CI. They supplement consumer verification and do @@ -225,8 +225,8 @@ still need human review. ## Node and web integration -[packages/lgtmd](https://github.com/softmarshmallow/lgtmd/blob/main/packages/lgtmd/README.md) authors documents in Node 24 ESM; -[crates/lgtmd-node](https://github.com/softmarshmallow/lgtmd/blob/main/crates/lgtmd-node/Cargo.toml) is its coarse native bridge. +[packages/lgtmd](https://github.com/lgtmd/lgtmd/blob/main/packages/lgtmd/README.md) authors documents in Node 24 ESM; +[crates/lgtmd-node](https://github.com/lgtmd/lgtmd/blob/main/crates/lgtmd-node/Cargo.toml) is its coarse native bridge. One serialized request per compile/publication carries schema-v1 frozen input to the same Rust validation, Markdown exporters, runner and owned store. The wire contains complete ordered content and resource snapshots, never process-local @@ -234,7 +234,7 @@ handle owners or application callbacks. Rust transport round trips preserve existing native exports, including literal Markdown versus typed parts. Built-in JSON resources use one Rust encoder, including those registered from Node. -The [three React/Vite examples](https://github.com/softmarshmallow/lgtmd/blob/main/examples/lgtmd-web/README.md) exercise prose-only +The [three React/Vite examples](https://github.com/lgtmd/lgtmd/blob/main/examples/lgtmd-web/README.md) exercise prose-only authoring, application-owned async preparation/cleanup, supplied context, observations, native checks, private notes/resources, JSON and CSV downloads, and Playwright screenshot evidence. The React app uses ordinary Vite development and diff --git a/docs/development.md b/docs/development.md index 459e0f6..f240a50 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,7 +2,7 @@ The application is a single Rust package named `n3`, with private implementation modules. Its documentation adapter consumes the separately maintained -[lgtmd SDK](https://github.com/softmarshmallow/lgtmd) at an exact Git revision. +[lgtmd SDK](https://github.com/lgtmd/lgtmd) at an exact Git revision. SDK sources and portable examples live in that repository. Read [AGENTS.md](../AGENTS.md) for development principles and iteration guidance. [Testing](../TESTING.md) indexes verification workflows, reference suites, and benchmark resources. Start at [the architecture map](architecture/architecture.md) and @@ -422,7 +422,7 @@ navigation adapters, and scene renderer. Assertions verify the intended outcome. The portable SDK owns document composition, frozen evidence, and shared artifact publication; it does not own N3's input or renderer. See [executable documents](architecture/executable-documents.md) for the maintained -boundary and [the SDK README](https://github.com/softmarshmallow/lgtmd/blob/main/crates/lgtmd/README.md) for its API. +boundary and [the SDK README](https://github.com/lgtmd/lgtmd/blob/main/crates/lgtmd/README.md) for its API. ```sh just docs @@ -503,7 +503,7 @@ it does not replace the Rust replay and exact artifact checks. SDK authoring, compiler/store work, and its portable Rust/React examples are maintained and verified in the -[lgtmd repository](https://github.com/softmarshmallow/lgtmd). Follow its contributor +[lgtmd repository](https://github.com/lgtmd/lgtmd). Follow its contributor instructions for SDK setup, independent package checks, and Node/Chromium gates. N3 does not install or run the SDK's web example during application verification. The N3 app's `web/` host remains a separate consumer boundary. diff --git a/rfcs/0003-executable-documentation-framework.md b/rfcs/0003-executable-documentation-framework.md index b2a1a1c..defab87 100644 --- a/rfcs/0003-executable-documentation-framework.md +++ b/rfcs/0003-executable-documentation-framework.md @@ -5,7 +5,7 @@ Status: **Implemented and extracted — experimental SDK and N3 bridge; independ Date: 2026-10-04 The framework was incubated inside N3 and is now maintained in the dedicated -[lgtmd repository](https://github.com/softmarshmallow/lgtmd). N3 consumes an exact +[lgtmd repository](https://github.com/lgtmd/lgtmd). N3 consumes an exact Git revision and retains its application adapter, replay, renderer, and guide artifacts. [The architecture contract](../docs/architecture/executable-documents.md) records the current boundary. The findings and development commands below are a @@ -681,7 +681,7 @@ infrastructure, publication, and the repository move remain deferred. The local implementation now includes: -- [An unpublished SDK](https://github.com/softmarshmallow/lgtmd/blob/main/crates/lgtmd/README.md) with Rust-authored prose, +- [An unpublished SDK](https://github.com/lgtmd/lgtmd/blob/main/crates/lgtmd/README.md) with Rust-authored prose, checked values, observed bindings, frozen resources, contributor notes, strict audience filtering, a structured manifest, and a project-owned runner. - One filesystem lifecycle used by the SDK and N3. Dedicated trees preserve N3's @@ -697,7 +697,7 @@ The local implementation now includes: format supports private prose notes and public captured media; private captured resources fail before publication. Full SDK exports support contributor-only resources and exclude their files and metadata from the reader bundle. -- [Two non-GUI integrations](https://github.com/softmarshmallow/lgtmd/blob/main/crates/doc-example-config/README.md): a real +- [Two non-GUI integrations](https://github.com/lgtmd/lgtmd/blob/main/crates/doc-example-config/README.md): a real configuration CLI and the existing serde_json library's public parsing/error/ serialization APIs. The latter is our integration, not independent maintainer adoption or upstream documentation. diff --git a/rfcs/0004-lgtmd-web-incubation.md b/rfcs/0004-lgtmd-web-incubation.md index 3621820..42322b5 100644 --- a/rfcs/0004-lgtmd-web-incubation.md +++ b/rfcs/0004-lgtmd-web-incubation.md @@ -7,7 +7,7 @@ Date: 2026-10-13 The portable Rust SDK and TypeScript integration were incubated directly in N3, without a submodule, while N3 remained a real consumer. The independently verified SDK, Node bridge, Rust/React examples, and development harness now live in the -[lgtmd repository](https://github.com/softmarshmallow/lgtmd). N3 consumes an exact +[lgtmd repository](https://github.com/lgtmd/lgtmd). N3 consumes an exact Git revision and retains its own adapter, replay, renderer, and guide artifacts. The preferred eventual N3 integration remains a crates.io dependency once the @@ -36,7 +36,7 @@ SDK-owned development commands now run in the lgtmd repository. receipt, and successful runner-process exit before publishing. Imported custom reporter instances are supported; built-in reporter-name delegation, retries, repeatEach greater than one, shards, and multiple/dependency projects are not. -- [Three complete React/Vite examples](https://github.com/softmarshmallow/lgtmd/blob/main/examples/lgtmd-web/README.md) include all project +- [Three complete React/Vite examples](https://github.com/lgtmd/lgtmd/blob/main/examples/lgtmd-web/README.md) include all project setup, cleanup, application code, prose, observations, checks, downloads, screenshots and contributor notes. React components and ordinary Vite scripts model a real developer application; lgtmd is a development dependency. Browser @@ -60,7 +60,7 @@ reviewed local Reader/Contributor trees and screenshots are disposable evidence; projects choose and explicitly accept their own baselines using `update`. See the [implemented architecture](../docs/architecture/executable-documents.md#node-and-web-integration) -and [Node SDK contract](https://github.com/softmarshmallow/lgtmd/blob/main/packages/lgtmd/README.md). The original sequence below +and [Node SDK contract](https://github.com/lgtmd/lgtmd/blob/main/packages/lgtmd/README.md). The original sequence below records the reasoning and later extraction/release gates. ## Outcome and rationale @@ -78,7 +78,7 @@ exit gates; moving files alone does not establish production readiness. [RFC 0003](0003-executable-documentation-framework.md) records the existing Rust framework. The [implemented architecture](../docs/architecture/executable-documents.md) -and [SDK README](https://github.com/softmarshmallow/lgtmd/blob/main/crates/lgtmd/README.md) remain authoritative for current behavior. +and [SDK README](https://github.com/lgtmd/lgtmd/blob/main/crates/lgtmd/README.md) remain authoritative for current behavior. ## Initial support scope