From d8c7e489bb6ad3c4898041aad77d161ddae3c6fe Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 9 Aug 2026 08:29:07 +0200 Subject: [PATCH 1/7] feat(mds-core): mark LintDiagnostic #[non_exhaustive]; add constructor and builders [#259] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `#[non_exhaustive]` to the public `LintDiagnostic` struct so new fields can be added in minor releases without a breaking change for external crates. External crates can no longer construct `LintDiagnostic` via struct literal. Replace all struct literal construction sites (9 total: 1 production, 2 test fixtures in mds-cli, 6 integration tests in mds-core) with: - `LintDiagnostic::new(rule, severity, message)` — creates a diagnostic with all optional fields defaulting to `None`. - `with_help`, `with_span`, `with_file`, `with_fix_removals`, `with_fix_edits` — builder methods that set optional fields via method chaining. Move sanitized-clone logic from the CLI's `render_diag_human` into `LintDiagnostic::sanitized_for_render(&self) -> Self` in mds-core. This is the architecturally correct home for render-boundary sanitization per PF-014: the CLI assembles the sanitized copy by calling the method rather than building the struct literal itself. Behavior is byte-identical: HUMAN-mode escape on message/help, fix_removals/fix_edits set to None to avoid unnecessary allocations. Also export `TextEdit` from `mds-core`'s `lib.rs` public API so callers of `with_fix_edits` have access to the type. Co-Authored-By: Claude --- CHANGELOG.md | 12 ++- crates/mds-cli/src/lint.rs | 56 ++++--------- crates/mds-cli/src/output.rs | 5 +- crates/mds-core/src/lib.rs | 1 + crates/mds-core/src/lint/diagnostic.rs | 107 +++++++++++++++++++++++ crates/mds-core/tests/api_surface.rs | 112 +++++++++++-------------- 6 files changed, 184 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b46e3cd..67151f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,14 +40,22 @@ mds lint --fix && mds fmt Dynamic message roles now use double braces: `@message {{role}}:` instead of `@message {role}:`. Bare-word roles (`@message system:`) are unchanged. +#### `LintDiagnostic` is now `#[non_exhaustive]`; use the constructor, not struct literals + +`LintDiagnostic` is marked `#[non_exhaustive]` so future minor releases can add +fields without a breaking change. External Rust crates can no longer construct it +via a struct literal. Use `LintDiagnostic::new(rule, severity, message)` to create +a diagnostic with required fields and all optional fields defaulting to `None`, then +chain the builder methods `with_help`, `with_span`, `with_file`, `with_fix_removals`, +and `with_fix_edits` to set optional fields. + #### New `fix_edits` field on `LintDiagnostic` `LintDiagnostic` gains an additive `fix_edits` field (null when not fixable; an array of `{start, end, new_text}` byte-span edit objects when fixable). This field is present across all binding surfaces: CLI JSON output, napi (`LintDiagnostic.fix_edits?: …`), WASM, and Python -(`LintDiagnostic.fix_edits: list[dict] | None`). Code that constructs -`LintDiagnostic` objects directly must add `fix_edits: null` or the typed field. +(`LintDiagnostic.fix_edits: list[dict] | None`). ### Security diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index 2ad8113..e3610af 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -294,22 +294,10 @@ fn render_diag_human(diag: &mds::LintDiagnostic, quiet: bool, named_source: (&st if quiet && matches!(diag.severity, Severity::Info | Severity::Warn) { return; } - // Sanitize message and help at the input boundary. - // fix_removals/fix_edits are not read by miette's Diagnostic impl — set to None - // to avoid unnecessary allocations (architecture-3 / rust-1). - let sanitized = mds::LintDiagnostic { - rule: diag.rule.clone(), - severity: diag.severity, - message: mds::sanitize_control_chars(&diag.message).into_owned(), - help: diag - .help - .as_deref() - .map(|h| mds::sanitize_control_chars(h).into_owned()), - span: diag.span.clone(), - file: diag.file.clone(), - fix_removals: None, - fix_edits: None, - }; + // Sanitize message and help at the input boundary via the core method. + // fix_removals/fix_edits are set to None inside sanitized_for_render to avoid + // unnecessary allocations (architecture-3 / rust-1). + let sanitized = diag.sanitized_for_render(); let (filename, src) = named_source; let report = miette::Report::from(sanitized) .with_source_code(mds::named_source_for_render(filename, src)); @@ -1489,36 +1477,22 @@ mod tests { fn preview_fixes_surfaces_rejected_on_overlap() { let source = "line0\nline1\nline2\n"; // Edit A covers bytes [0, 12): line0 start through line1 end (inclusive). - let diag_a = LintDiagnostic { - rule: "duplicate-import".to_string(), - severity: Severity::Error, - message: "a".to_string(), - help: None, - span: None, - file: None, - fix_removals: Some(vec![FixLineSpan { + let diag_a = LintDiagnostic::new("duplicate-import", Severity::Error, "a") + .with_fix_removals(vec![FixLineSpan { from: 0, // inside line0 to: 6, // inside line1; extend_to_line_end(6) = 12 to_inclusive: true, - }]), - fix_edits: None, - }; + }]); // Edit B covers bytes [6, 18): line1 start through line2 end (inclusive). // Partially overlaps A at [6, 12). - let diag_b = LintDiagnostic { - rule: "empty-block".to_string(), - severity: Severity::Warn, - message: "b".to_string(), - help: None, - span: None, - file: None, - fix_removals: Some(vec![FixLineSpan { - from: 6, // inside line1 - to: 12, // inside line2; extend_to_line_end(12) = 18 - to_inclusive: true, - }]), - fix_edits: None, - }; + let diag_b = + LintDiagnostic::new("empty-block", Severity::Warn, "b").with_fix_removals(vec![ + FixLineSpan { + from: 6, // inside line1 + to: 12, // inside line2; extend_to_line_end(12) = 18 + to_inclusive: true, + }, + ]); let result = LintResult { diagnostics: vec![diag_a, diag_b], truncated: false, diff --git a/crates/mds-cli/src/output.rs b/crates/mds-cli/src/output.rs index f825150..6bcd46d 100644 --- a/crates/mds-cli/src/output.rs +++ b/crates/mds-cli/src/output.rs @@ -462,7 +462,10 @@ pub(crate) fn atomic_write_file(path: &Path, content: &str) -> Result<()> { match std::fs::metadata(path) { Ok(m) => Some(m.permissions().mode()), Err(e) => { - eprint_error(miette::miette!("cannot get metadata for {}: {e}", path.display())); + eprint_error(miette::miette!( + "cannot get metadata for {}: {e}", + path.display() + )); None } } diff --git a/crates/mds-core/src/lib.rs b/crates/mds-core/src/lib.rs index fda9f46..979ff37 100644 --- a/crates/mds-core/src/lib.rs +++ b/crates/mds-core/src/lib.rs @@ -63,6 +63,7 @@ pub use fs::{effective_parent, FileSystem, NativeFs, VirtualFs}; pub use lint::{ fix, named_source_for_render, neutralize_source_for_render, sanitize_control_chars, sanitize_control_chars_wire, FixLineSpan, LintConfig, LintDiagnostic, LintResult, Severity, + TextEdit, }; pub use options::{ format_unknown_keys_error, json_type_name, parse_json_vars, reject_unknown_json_keys, VarsError, diff --git a/crates/mds-core/src/lint/diagnostic.rs b/crates/mds-core/src/lint/diagnostic.rs index 0d938ad..8af12fd 100644 --- a/crates/mds-core/src/lint/diagnostic.rs +++ b/crates/mds-core/src/lint/diagnostic.rs @@ -286,6 +286,11 @@ impl FixLineSpan { /// `Error` → Error; `Off` diagnostics are never constructed (the lint engine filters /// them before collecting). /// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Obtain values from `mds::lint_str`, `mds::lint`, and similar lint API functions, or +/// construct via [`LintDiagnostic::new`] and the `with_*` builder methods; do not +/// construct via struct literal. +/// /// **CLI render**: always use `mds_cli::output::eprint_error` to render diagnostics on /// a TTY — never call `eprintln!("{report:?}")` on a raw `miette::Report`. Writing the /// rendered frame directly bypasses input-level sanitization and can inject C0/C1 @@ -297,6 +302,7 @@ impl FixLineSpan { /// **Sanitization**: see the module-level "Sanitization discipline" note — `message` /// and `help` are sanitized at every output boundary; constructors keep raw bytes so /// span offsets and `fix_edits` stay byte-accurate. +#[non_exhaustive] pub struct LintDiagnostic { /// Short rule identifier, e.g. `"unused-variable"`. Becomes the miette code /// `mds::lint::`. @@ -329,6 +335,107 @@ pub struct LintDiagnostic { pub fix_edits: Option>, } +impl LintDiagnostic { + /// Construct a `LintDiagnostic` with required fields; all optional fields default + /// to `None`. + /// + /// This is the supported construction path for external crates — struct literals + /// are not available because this type is `#[non_exhaustive]`. + /// + /// # Examples + /// + /// ``` + /// use mds::{LintDiagnostic, Severity}; + /// let diag = LintDiagnostic::new("unused-variable", Severity::Warn, "Variable 'x' is unused"); + /// assert_eq!(diag.rule, "unused-variable"); + /// assert_eq!(diag.severity, Severity::Warn); + /// assert!(diag.help.is_none()); + /// ``` + pub fn new(rule: impl Into, severity: Severity, message: impl Into) -> Self { + LintDiagnostic { + rule: rule.into(), + severity, + message: message.into(), + help: None, + span: None, + file: None, + fix_removals: None, + fix_edits: None, + } + } + + /// Set the help text for this diagnostic. + /// + /// # Examples + /// + /// ``` + /// use mds::{LintDiagnostic, Severity}; + /// let diag = LintDiagnostic::new("unused-variable", Severity::Warn, "Variable 'x' is unused") + /// .with_help("Remove the frontmatter key or reference it in the template body."); + /// assert!(diag.help.is_some()); + /// ``` + pub fn with_help(mut self, help: impl Into) -> Self { + self.help = Some(help.into()); + self + } + + /// Set the source span for this diagnostic. + pub fn with_span(mut self, span: crate::error::SerializedSpan) -> Self { + self.span = Some(span); + self + } + + /// Set the source file path for this diagnostic. + pub fn with_file(mut self, file: impl Into) -> Self { + self.file = Some(file.into()); + self + } + + /// Set line-removal fix spans for this diagnostic. + pub fn with_fix_removals(mut self, fix_removals: Vec) -> Self { + self.fix_removals = Some(fix_removals); + self + } + + /// Set in-place replacement edits for this diagnostic. + pub fn with_fix_edits(mut self, fix_edits: Vec) -> Self { + self.fix_edits = Some(fix_edits); + self + } + + /// Return a sanitized clone of this diagnostic for use at the miette render boundary. + /// + /// Sanitizes `message` and `help` in HUMAN mode (preserves `\n`; escapes C0/DEL/C1 + /// controls, bidi controls, and BOM — see module-level "Sanitization discipline"). + /// Preserves `rule`, `severity`, `span`, and `file` unchanged. Intentionally sets + /// `fix_removals` and `fix_edits` to `None` — miette's `Diagnostic` impl does not + /// read those fields, so cloning them would waste allocations (architecture-3 / + /// rust-1). + /// + /// This method is the architecturally correct home for render-boundary sanitization + /// (PF-014): the CLI should not assemble sanitized copies itself. It owns the escape + /// logic because it lives alongside the sanitizers and the struct definition. + /// + /// **Behavior is byte-identical to the original CLI render path** — this is + /// security-critical code (#176 / CWE-150). Do not alter the escape mode or field + /// selection without a corresponding audit of all render boundaries. + pub fn sanitized_for_render(&self) -> Self { + LintDiagnostic { + rule: self.rule.clone(), + severity: self.severity, + message: sanitize_control_chars(&self.message).into_owned(), + help: self + .help + .as_deref() + .map(|h| sanitize_control_chars(h).into_owned()), + span: self.span.clone(), + file: self.file.clone(), + fix_removals: None, + fix_edits: None, + } + } +} + impl fmt::Debug for LintDiagnostic { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("LintDiagnostic") diff --git a/crates/mds-core/tests/api_surface.rs b/crates/mds-core/tests/api_surface.rs index 6105461..88f4c16 100644 --- a/crates/mds-core/tests/api_surface.rs +++ b/crates/mds-core/tests/api_surface.rs @@ -1024,16 +1024,13 @@ fn lint_types_exist() { assert_eq!(config.rules.get("unused-variable"), Some(&Severity::Off)); // LintDiagnostic has the expected fields. - let diag = LintDiagnostic { - rule: "unused-variable".to_string(), - severity: Severity::Warn, - message: "Variable 'name' is never used".to_string(), - help: Some("Remove the frontmatter key or reference it in the body".to_string()), - span: None, - file: Some("test.mds".to_string()), - fix_removals: None, - fix_edits: None, - }; + let diag = LintDiagnostic::new( + "unused-variable", + Severity::Warn, + "Variable 'name' is never used", + ) + .with_help("Remove the frontmatter key or reference it in the body") + .with_file("test.mds"); assert_eq!(diag.rule, "unused-variable"); assert_eq!(diag.severity, Severity::Warn); @@ -1091,21 +1088,19 @@ fn lint_canonical_json_schema() { use mds::SerializedSpan; let result = LintResult { - diagnostics: vec![LintDiagnostic { - rule: "unused-variable".to_string(), - severity: Severity::Warn, - message: "Variable 'name' is never used".to_string(), - help: Some("Remove the frontmatter key or reference it in the body".to_string()), - span: Some(SerializedSpan { - offset: 4, - length: 4, - line: Some(2), - column: Some(1), - }), - file: Some("test.mds".to_string()), - fix_removals: None, - fix_edits: None, - }], + diagnostics: vec![LintDiagnostic::new( + "unused-variable", + Severity::Warn, + "Variable 'name' is never used", + ) + .with_help("Remove the frontmatter key or reference it in the body") + .with_span(SerializedSpan { + offset: 4, + length: 4, + line: Some(2), + column: Some(1), + }) + .with_file("test.mds")], truncated: false, is_standalone: false, }; @@ -1152,16 +1147,13 @@ fn lint_canonical_json_fixable_semantics() { // Tier A rule (duplicate-import) with fix_removals → fixable regardless of is_standalone. let tier_a = LintResult { - diagnostics: vec![LintDiagnostic { - rule: "duplicate-import".to_string(), - severity: Severity::Error, - message: "Duplicate import".to_string(), - help: None, - span: None, - file: Some("a.mds".to_string()), - fix_removals: Some(vec![FixLineSpan::single(0)]), - fix_edits: None, - }], + diagnostics: vec![LintDiagnostic::new( + "duplicate-import", + Severity::Error, + "Duplicate import", + ) + .with_file("a.mds") + .with_fix_removals(vec![FixLineSpan::single(0)])], truncated: false, is_standalone: false, // even non-standalone Tier A is fixable }; @@ -1171,16 +1163,13 @@ fn lint_canonical_json_fixable_semantics() { // Tier B rule (unused-function) — fixable only for standalone files. // fix_removals: Some(...) + non-standalone → fixable: false let tier_b_non_standalone = LintResult { - diagnostics: vec![LintDiagnostic { - rule: "unused-function".to_string(), - severity: Severity::Warn, - message: "Unused function".to_string(), - help: None, - span: None, - file: Some("b.mds".to_string()), - fix_removals: Some(vec![FixLineSpan::single(0)]), - fix_edits: None, - }], + diagnostics: vec![LintDiagnostic::new( + "unused-function", + Severity::Warn, + "Unused function", + ) + .with_file("b.mds") + .with_fix_removals(vec![FixLineSpan::single(0)])], truncated: false, is_standalone: false, }; @@ -1189,16 +1178,13 @@ fn lint_canonical_json_fixable_semantics() { // fix_removals: Some(...) + standalone → fixable: true let tier_b_standalone = LintResult { - diagnostics: vec![LintDiagnostic { - rule: "unused-function".to_string(), - severity: Severity::Warn, - message: "Unused function".to_string(), - help: None, - span: None, - file: Some("c.mds".to_string()), - fix_removals: Some(vec![FixLineSpan::single(0)]), - fix_edits: None, - }], + diagnostics: vec![LintDiagnostic::new( + "unused-function", + Severity::Warn, + "Unused function", + ) + .with_file("c.mds") + .with_fix_removals(vec![FixLineSpan::single(0)])], truncated: false, is_standalone: true, }; @@ -1207,16 +1193,12 @@ fn lint_canonical_json_fixable_semantics() { // Tier C rule (unused-variable) → never fixable (fix_removals: None also → false). let tier_c = LintResult { - diagnostics: vec![LintDiagnostic { - rule: "unused-variable".to_string(), - severity: Severity::Warn, - message: "Unused variable".to_string(), - help: None, - span: None, - file: Some("d.mds".to_string()), - fix_removals: None, - fix_edits: None, - }], + diagnostics: vec![LintDiagnostic::new( + "unused-variable", + Severity::Warn, + "Unused variable", + ) + .with_file("d.mds")], truncated: false, is_standalone: true, // even standalone Tier C is not fixable }; From 1762c655a8a75a11a9d2a2e68199d410bd2fadfc Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 9 Aug 2026 09:48:14 +0200 Subject: [PATCH 2/7] feat(api): mark nine public types #[non_exhaustive], add constructors [#259] Mark LintResult, SerializedError, SerializedSpan, TextEdit, FixLineSpan, ByteEdit, RejectedEdit, FixPlan, and LintConfig as #[non_exhaustive] so future minor releases can add fields without a breaking change. Construction paths added: - LintResult::new(diagnostics, truncated, is_standalone) - SerializedSpan::new(offset, length) + .with_line() / .with_column() - TextEdit::new(start, end, new_text) - FixLineSpan::range(from, to, to_inclusive) [single() already existed] - ByteEdit::new(start, end, rule, replacement) - RejectedEdit::new(edit, reason) - LintConfig::with_rules(rules) [default() suffices for empty config] - FixPlan::default() [no struct literal; just #[non_exhaustive] added] - SerializedError: no external constructor (obtained via MdsError::serialize()) All external struct literals in api_surface.rs, build.rs, lint.rs, mds-napi, mds-wasm, and mds-python migrated to constructors. CHANGELOG [Unreleased] BREAKING section updated. Part of review findings on d8c7e48: - Add #[must_use] to six LintDiagnostic builders and sanitized_for_render - Fix with_span parameter type (crate::error::SerializedSpan -> crate::SerializedSpan) - Add unit tests T-SFR-1/T-SFR-2 for sanitized_for_render - Expand sanitized_for_render rustdoc (one-way escaping, render-only, fix-strip) - Add file/help/span assertions to lint_types_exist in api_surface.rs --- CHANGELOG.md | 16 ++ crates/mds-cli/src/build.rs | 2 +- crates/mds-cli/src/lint.rs | 26 ++- crates/mds-core/src/error.rs | 66 ++++++++ crates/mds-core/src/lint/config.rs | 26 +++ crates/mds-core/src/lint/diagnostic.rs | 215 ++++++++++++++++++++++++- crates/mds-core/src/lint/fix.rs | 50 ++++++ crates/mds-core/tests/api_surface.rs | 149 ++++++++--------- crates/mds-napi/src/lib.rs | 2 +- crates/mds-python/src/lib.rs | 2 +- crates/mds-wasm/src/lib.rs | 2 +- 11 files changed, 450 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67151f8..91bb323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,22 @@ a diagnostic with required fields and all optional fields defaulting to `None`, chain the builder methods `with_help`, `with_span`, `with_file`, `with_fix_removals`, and `with_fix_edits` to set optional fields. +#### Nine additional public types are now `#[non_exhaustive]`; migrate struct literals to constructors + +The following types are marked `#[non_exhaustive]` so future minor releases can add +fields without a breaking change. External Rust crates can no longer construct them +via struct literals. Use the named constructor or builder listed for each: + +- **`LintResult`** — use `LintResult::new(diagnostics, truncated, is_standalone)`. +- **`SerializedError`** — not externally constructable by design; obtain via `MdsError::serialize()`. +- **`SerializedSpan`** — use `SerializedSpan::new(offset, length)`, then chain `.with_line(n)` and/or `.with_column(n)`. +- **`TextEdit`** — use `TextEdit::new(start, end, new_text)`. +- **`FixLineSpan`** — use `FixLineSpan::single(offset)` for single-line removals or the new `FixLineSpan::range(from, to, to_inclusive)` for multi-line spans. +- **`ByteEdit`** — use `ByteEdit::new(start, end, rule, replacement)`. +- **`RejectedEdit`** — use `RejectedEdit::new(edit, reason)`. +- **`FixPlan`** — use `FixPlan::default()` for an empty plan; all mutation methods remain available. +- **`LintConfig`** — use `LintConfig::with_rules(rules)` or `LintConfig::default()` for no overrides. + #### New `fix_edits` field on `LintDiagnostic` `LintDiagnostic` gains an additive `fix_edits` field (null when not fixable; diff --git a/crates/mds-cli/src/build.rs b/crates/mds-cli/src/build.rs index f9c7133..f1bda62 100644 --- a/crates/mds-cli/src/build.rs +++ b/crates/mds-cli/src/build.rs @@ -54,7 +54,7 @@ pub(crate) struct LintCliConfig { impl LintCliConfig { /// Convert to the core `LintConfig` consumed by `mds::lint_*` functions. pub(crate) fn into_core_config(self) -> mds::LintConfig { - mds::LintConfig { rules: self.rules } + mds::LintConfig::with_rules(self.rules) } } diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index e3610af..70f28fa 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -1478,26 +1478,22 @@ mod tests { let source = "line0\nline1\nline2\n"; // Edit A covers bytes [0, 12): line0 start through line1 end (inclusive). let diag_a = LintDiagnostic::new("duplicate-import", Severity::Error, "a") - .with_fix_removals(vec![FixLineSpan { - from: 0, // inside line0 - to: 6, // inside line1; extend_to_line_end(6) = 12 - to_inclusive: true, - }]); + .with_fix_removals(vec![FixLineSpan::range( + 0, // inside line0 + 6, // inside line1; extend_to_line_end(6) = 12 + true, + )]); // Edit B covers bytes [6, 18): line1 start through line2 end (inclusive). // Partially overlaps A at [6, 12). let diag_b = LintDiagnostic::new("empty-block", Severity::Warn, "b").with_fix_removals(vec![ - FixLineSpan { - from: 6, // inside line1 - to: 12, // inside line2; extend_to_line_end(12) = 18 - to_inclusive: true, - }, + FixLineSpan::range( + 6, // inside line1 + 12, // inside line2; extend_to_line_end(12) = 18 + true, + ), ]); - let result = LintResult { - diagnostics: vec![diag_a, diag_b], - truncated: false, - is_standalone: false, - }; + let result = LintResult::new(vec![diag_a, diag_b], false, false); let outcome = preview_fixes( &result, diff --git a/crates/mds-core/src/error.rs b/crates/mds-core/src/error.rs index 029f1bd..f92bb7c 100644 --- a/crates/mds-core/src/error.rs +++ b/crates/mds-core/src/error.rs @@ -13,6 +13,11 @@ use crate::lint::{named_source_for_render, sanitize_control_chars, sanitize_cont /// matching `miette::SourceSpan`. `line` is 1-indexed; `column` is the /// 1-indexed character position (Unicode scalar values) from the start of the /// line — NOT a byte offset and NOT UTF-16 code units. +/// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Construct via [`SerializedSpan::new`] and the optional `with_line` / +/// `with_column` builders; do not construct via struct literal. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, serde::Serialize)] pub struct SerializedSpan { pub offset: usize, @@ -21,9 +26,70 @@ pub struct SerializedSpan { pub column: Option, } +impl SerializedSpan { + /// Construct a `SerializedSpan` with byte `offset` and `length`; line and + /// column default to `None`. + /// + /// This is the supported construction path for external crates — struct literals + /// are not available because this type is `#[non_exhaustive]`. + /// + /// # Examples + /// + /// ``` + /// use mds::SerializedSpan; + /// let span = SerializedSpan::new(10, 5); + /// assert_eq!(span.offset, 10); + /// assert_eq!(span.length, 5); + /// assert!(span.line.is_none()); + /// assert!(span.column.is_none()); + /// ``` + pub fn new(offset: usize, length: usize) -> Self { + SerializedSpan { + offset, + length, + line: None, + column: None, + } + } + + /// Set the 1-indexed line number. + /// + /// # Examples + /// + /// ``` + /// use mds::SerializedSpan; + /// let span = SerializedSpan::new(10, 5).with_line(3); + /// assert_eq!(span.line, Some(3)); + /// ``` + #[must_use] + pub fn with_line(mut self, line: usize) -> Self { + self.line = Some(line); + self + } + + /// Set the 1-indexed character column. + /// + /// # Examples + /// + /// ``` + /// use mds::SerializedSpan; + /// let span = SerializedSpan::new(10, 5).with_column(7); + /// assert_eq!(span.column, Some(7)); + /// ``` + #[must_use] + pub fn with_column(mut self, column: usize) -> Self { + self.column = Some(column); + self + } +} + /// A serializable, `serde`-friendly representation of an [`MdsError`]. /// /// Suitable for embedding in JSON API responses or structured log output. +/// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Obtain values via [`MdsError::serialize`]; do not construct via struct literal. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, serde::Serialize)] pub struct SerializedError { pub code: String, diff --git a/crates/mds-core/src/lint/config.rs b/crates/mds-core/src/lint/config.rs index ddc747e..dc4bed3 100644 --- a/crates/mds-core/src/lint/config.rs +++ b/crates/mds-core/src/lint/config.rs @@ -24,6 +24,12 @@ use super::diagnostic::Severity; /// /// Unknown severity *values* (e.g. `"verbose"`) cause a hard parse error (`exit 2`) /// because the closed enum has no sensible fallback. +/// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Use `LintConfig::default()` for a config with all rules at engine defaults, or +/// [`LintConfig::with_rules`] to supply per-rule overrides; do not construct via +/// struct literal. +#[non_exhaustive] #[derive(Debug, Default, Clone)] pub struct LintConfig { /// Per-rule severity overrides. Key = rule name (e.g. `"unused-variable"`), @@ -32,6 +38,26 @@ pub struct LintConfig { } impl LintConfig { + /// Construct a `LintConfig` with the given per-rule severity overrides. + /// + /// This is the supported construction path for external crates — struct literals + /// are not available because this type is `#[non_exhaustive]`. + /// + /// # Examples + /// + /// ``` + /// use std::collections::HashMap; + /// use mds::{LintConfig, Severity}; + /// let config = LintConfig::with_rules(HashMap::from([ + /// ("unused-variable".to_string(), Severity::Off), + /// ])); + /// assert_eq!(config.severity_for("unused-variable"), Some(&Severity::Off)); + /// ``` + #[must_use] + pub fn with_rules(rules: HashMap) -> Self { + LintConfig { rules } + } + /// Look up the configured severity for a rule name. /// /// Returns `None` when the rule has no explicit override — callers should fall diff --git a/crates/mds-core/src/lint/diagnostic.rs b/crates/mds-core/src/lint/diagnostic.rs index 8af12fd..f17b061 100644 --- a/crates/mds-core/src/lint/diagnostic.rs +++ b/crates/mds-core/src/lint/diagnostic.rs @@ -225,6 +225,10 @@ impl fmt::Display for Severity { /// /// Used by the fix planner for in-place replacement edits (e.g. `{x}` → `{{x}}`). /// An empty `new_text` is equivalent to a pure deletion. +/// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Construct via [`TextEdit::new`]; do not use a struct literal. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq)] pub struct TextEdit { /// Inclusive start byte offset of the range to replace. @@ -235,6 +239,30 @@ pub struct TextEdit { pub new_text: String, } +impl TextEdit { + /// Construct a `TextEdit` with all fields. + /// + /// This is the supported construction path for external crates — struct literals + /// are not available because this type is `#[non_exhaustive]`. + /// + /// # Examples + /// + /// ``` + /// use mds::TextEdit; + /// let edit = TextEdit::new(6, 12, "{{name}}"); + /// assert_eq!(edit.start, 6); + /// assert_eq!(edit.end, 12); + /// assert_eq!(edit.new_text, "{{name}}"); + /// ``` + pub fn new(start: usize, end: usize, new_text: impl Into) -> Self { + TextEdit { + start, + end, + new_text: new_text.into(), + } + } +} + // ── FixLineSpan ─────────────────────────────────────────────────────────────── /// A line-range descriptor for a single lint auto-fix removal. @@ -252,6 +280,11 @@ pub struct TextEdit { /// /// **Single-line helper**: use `FixLineSpan::single(offset)` to remove exactly /// the one line that contains `offset`. +/// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Construct via [`FixLineSpan::single`] (single-line) or [`FixLineSpan::range`] +/// (multi-line); external crates must not use a struct literal. +#[non_exhaustive] #[derive(Debug, Clone)] pub struct FixLineSpan { /// Byte offset of any character in the first line to remove. @@ -267,7 +300,7 @@ pub struct FixLineSpan { impl FixLineSpan { /// Remove exactly the one line that contains `offset`. /// - /// Equivalent to `FixLineSpan { from: offset, to: offset, to_inclusive: true }`. + /// Equivalent to `FixLineSpan::range(offset, offset, true)`. pub fn single(offset: usize) -> Self { FixLineSpan { from: offset, @@ -275,6 +308,19 @@ impl FixLineSpan { to_inclusive: true, } } + + /// Remove a range of lines spanning byte offsets `from` through `to`. + /// + /// - `to_inclusive: true` → the line containing `to` is removed. + /// - `to_inclusive: false` → removal stops at the start of the line containing + /// `to` (that line is kept). + pub fn range(from: usize, to: usize, to_inclusive: bool) -> Self { + FixLineSpan { + from, + to, + to_inclusive, + } + } } // ── LintDiagnostic ──────────────────────────────────────────────────────────── @@ -374,30 +420,35 @@ impl LintDiagnostic { /// .with_help("Remove the frontmatter key or reference it in the template body."); /// assert!(diag.help.is_some()); /// ``` + #[must_use] pub fn with_help(mut self, help: impl Into) -> Self { self.help = Some(help.into()); self } /// Set the source span for this diagnostic. - pub fn with_span(mut self, span: crate::error::SerializedSpan) -> Self { + #[must_use] + pub fn with_span(mut self, span: crate::SerializedSpan) -> Self { self.span = Some(span); self } /// Set the source file path for this diagnostic. + #[must_use] pub fn with_file(mut self, file: impl Into) -> Self { self.file = Some(file.into()); self } /// Set line-removal fix spans for this diagnostic. + #[must_use] pub fn with_fix_removals(mut self, fix_removals: Vec) -> Self { self.fix_removals = Some(fix_removals); self } /// Set in-place replacement edits for this diagnostic. + #[must_use] pub fn with_fix_edits(mut self, fix_edits: Vec) -> Self { self.fix_edits = Some(fix_edits); self @@ -419,6 +470,32 @@ impl LintDiagnostic { /// **Behavior is byte-identical to the original CLI render path** — this is /// security-critical code (#176 / CWE-150). Do not alter the escape mode or field /// selection without a corresponding audit of all render boundaries. + /// + /// # Escaping is one-way + /// + /// The HUMAN-mode escaping applied to `message` and `help` is **lossy and + /// non-injective** — see the `# Escaping is one-way` section on + /// [`sanitize_control_chars`]. Callers **MUST NOT** un-escape `\uXXXX` sequences + /// back into bytes. + /// + /// # Result is render-only — do not serialize to a WIRE surface + /// + /// The returned diagnostic has `message` and `help` sanitized in **HUMAN mode**, + /// which preserves `\n`. Passing this value to `LintResult::to_canonical_json` or + /// any JSON / binding serializer would double-escape already-escaped text and, more + /// importantly, would emit raw newlines inside JSON string values — violating the + /// WIRE contract (log forging, YAML key injection). For JSON/binding output, call + /// `to_canonical_json()` on the *original* `LintResult` instead; it applies WIRE + /// mode at the correct boundary. + /// + /// # Fix data is intentionally stripped + /// + /// `fix_removals` and `fix_edits` are set to `None` in the returned value. A caller + /// that reuses the sanitized clone for the fix pipeline would silently lose every + /// `fixable` flag and every edit plan — the `fixable` field in the JSON wire format + /// is derived from `fix_removals`/`fix_edits`, so it would always serialize as + /// `false`. + #[must_use] pub fn sanitized_for_render(&self) -> Self { LintDiagnostic { rule: self.rule.clone(), @@ -509,6 +586,11 @@ impl miette::Diagnostic for LintDiagnostic { /// directives — used to determine whether Tier B fixes (unused-import, unused-function) /// are safe to apply (they change compiled output for non-standalone files because the /// importer's compiled output depends on what the imported module exports). +/// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Obtain values from `mds::lint_str`, `mds::lint`, and similar lint API functions, +/// or construct via [`LintResult::new`]; do not construct via struct literal. +#[non_exhaustive] #[derive(Debug)] pub struct LintResult { /// Collected lint findings. Never contains `Severity::Off` diagnostics. @@ -520,6 +602,28 @@ pub struct LintResult { } impl LintResult { + /// Construct a `LintResult` with all fields. + /// + /// This is the supported construction path for external crates — struct literals + /// are not available because this type is `#[non_exhaustive]`. + /// + /// # Examples + /// + /// ``` + /// use mds::LintResult; + /// let result = LintResult::new(vec![], false, true); + /// assert!(result.diagnostics.is_empty()); + /// assert!(!result.truncated); + /// assert!(result.is_standalone); + /// ``` + pub fn new(diagnostics: Vec, truncated: bool, is_standalone: bool) -> Self { + LintResult { + diagnostics, + truncated, + is_standalone, + } + } + /// Produce the canonical, LSP-stable JSON wire format. /// /// Schema: @@ -1767,4 +1871,111 @@ mod tests { }; assert_eq!(err.severity(), Some(miette::Severity::Error)); } + + // ── sanitized_for_render ────────────────────────────────────────────────── + // + // Security-critical: this method is the CLI's render-boundary sanitizer. + // It lives in mds-core and must pass these invariants even when no CLI test + // exercises it, so `cargo test -p mds-core` stays green independently. + + /// T-SFR-1: ESC byte in `message` is escaped; `\n` is preserved (HUMAN mode). + /// + /// Pins the HUMAN-mode choice: `\n` in a diagnostic body is legitimate prose + /// punctuation (multi-line miette frame) and must survive the round-trip. + /// ESC (U+001B) is a hostile control and must be replaced by ``. + #[test] + fn sanitized_for_render_escapes_esc_preserves_newline() { + let diag = LintDiagnostic { + rule: "unused-variable".to_string(), + severity: Severity::Warn, + message: "line one\x1Bline two\nline three".to_string(), + help: Some("help\x1Btext\nwith newline".to_string()), + span: None, + file: None, + fix_removals: None, + fix_edits: None, + }; + + let rendered = diag.sanitized_for_render(); + + // ESC is replaced by the \uXXXX literal. + assert!( + !rendered.message.contains('\x1B'), + "raw ESC must not appear in sanitized message; got: {:?}", + rendered.message + ); + assert!( + rendered.message.contains("\\u001B"), + "\\u001B literal must appear in sanitized message; got: {:?}", + rendered.message + ); + + // \n is preserved (HUMAN mode). + assert!( + rendered.message.contains('\n'), + "newline must be preserved in HUMAN-mode message; got: {:?}", + rendered.message + ); + + // help is also sanitized. + let help = rendered.help.as_deref().expect("help must be Some"); + assert!(!help.contains('\x1B'), "raw ESC must not appear in help"); + assert!( + help.contains("\\u001B"), + "\\u001B must appear in sanitized help" + ); + assert!(help.contains('\n'), "newline must be preserved in help"); + } + + /// T-SFR-2: `span` and `file` pass through byte-identical; fix data is stripped. + /// + /// Pins three behaviours together: + /// - `span` offset/length are raw byte values, not altered by sanitization. + /// - `file` is copied verbatim (WIRE escaping of the filename is the caller's + /// responsibility via `named_source_for_render`). + /// - `fix_removals` and `fix_edits` are set to `None` so the sanitized clone + /// cannot be mistaken for a fix-bearing diagnostic. + #[test] + fn sanitized_for_render_span_file_unchanged_fix_nulled() { + let span = crate::error::SerializedSpan { + offset: 42, + length: 7, + line: Some(3), + column: Some(1), + }; + let diag = LintDiagnostic { + rule: "duplicate-import".to_string(), + severity: Severity::Error, + message: "msg".to_string(), + help: None, + span: Some(span.clone()), + file: Some("a/b.mds".to_string()), + fix_removals: Some(vec![FixLineSpan::single(42)]), + fix_edits: Some(vec![TextEdit::new(0, 3, "x")]), + }; + + let rendered = diag.sanitized_for_render(); + + // span is byte-identical. + let rendered_span = rendered.span.as_ref().expect("span must survive"); + assert_eq!(rendered_span.offset, 42, "span.offset must be unchanged"); + assert_eq!(rendered_span.length, 7, "span.length must be unchanged"); + + // file is copied verbatim. + assert_eq!( + rendered.file.as_deref(), + Some("a/b.mds"), + "file must be byte-identical" + ); + + // fix data is intentionally stripped. + assert!( + rendered.fix_removals.is_none(), + "fix_removals must be None in the sanitized clone" + ); + assert!( + rendered.fix_edits.is_none(), + "fix_edits must be None in the sanitized clone" + ); + } } diff --git a/crates/mds-core/src/lint/fix.rs b/crates/mds-core/src/lint/fix.rs index 0af1677..be56922 100644 --- a/crates/mds-core/src/lint/fix.rs +++ b/crates/mds-core/src/lint/fix.rs @@ -107,6 +107,10 @@ pub use super::tier::{is_fixable, is_output_neutral, rule_tier, FixTier}; /// /// **CRLF note**: `end` must be chosen to include the complete line terminator /// (call [`extend_to_line_end`] to adjust if needed) for line-removal edits. +/// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Construct via [`ByteEdit::new`]; do not use a struct literal. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct ByteEdit { /// Inclusive start byte offset of the range to replace. @@ -119,7 +123,31 @@ pub struct ByteEdit { pub replacement: String, } +impl ByteEdit { + /// Construct a `ByteEdit` with all fields. + /// + /// This is the supported construction path for external crates — struct literals + /// are not available because this type is `#[non_exhaustive]`. + pub fn new( + start: usize, + end: usize, + rule: impl Into, + replacement: impl Into, + ) -> Self { + ByteEdit { + start, + end, + rule: rule.into(), + replacement: replacement.into(), + } + } +} + /// A fix edit that was rejected by the per-edit reverify gate in [`apply_fixes_incremental`]. +/// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Construct via [`RejectedEdit::new`]; do not use a struct literal. +#[non_exhaustive] #[derive(Debug, Clone)] pub struct RejectedEdit { /// The edit that was rejected. @@ -129,6 +157,19 @@ pub struct RejectedEdit { pub reason: String, } +impl RejectedEdit { + /// Construct a `RejectedEdit` with an edit and a rejection reason. + /// + /// This is the supported construction path for external crates — struct literals + /// are not available because this type is `#[non_exhaustive]`. + pub fn new(edit: ByteEdit, reason: impl Into) -> Self { + RejectedEdit { + edit, + reason: reason.into(), + } + } +} + /// Render a reverify failure into a single-line, display-safe rejection reason. /// /// The single construction site for every rejection reason that embeds an @@ -158,6 +199,15 @@ fn reverify_failure_reason(err: &MdsError) -> String { } /// A plan of fix edits for a single file's source. +/// +/// Obtain via [`plan_fixes`] or [`plan_fixes_with_options`], then pass to +/// [`apply_fixes`] or [`apply_fixes_incremental`]. External crates that need an +/// empty plan can use `FixPlan::default()`. +/// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Construct via the planning functions or `FixPlan::default()`; do not use a +/// struct literal in external crates. +#[non_exhaustive] #[derive(Debug, Default)] pub struct FixPlan { /// Sorted (start ASC, end DESC), deduplicated, non-overlapping byte edits diff --git a/crates/mds-core/tests/api_surface.rs b/crates/mds-core/tests/api_surface.rs index 88f4c16..6a61ccb 100644 --- a/crates/mds-core/tests/api_surface.rs +++ b/crates/mds-core/tests/api_surface.rs @@ -1018,9 +1018,10 @@ fn lint_types_exist() { let _err = Severity::Error; // LintConfig has a `rules` field (HashMap). - let config = LintConfig { - rules: HashMap::from([("unused-variable".to_string(), Severity::Off)]), - }; + let config = LintConfig::with_rules(HashMap::from([( + "unused-variable".to_string(), + Severity::Off, + )])); assert_eq!(config.rules.get("unused-variable"), Some(&Severity::Off)); // LintDiagnostic has the expected fields. @@ -1030,16 +1031,25 @@ fn lint_types_exist() { "Variable 'name' is never used", ) .with_help("Remove the frontmatter key or reference it in the body") - .with_file("test.mds"); + .with_file("test.mds") + .with_span(mds::SerializedSpan::new(0, 4)); assert_eq!(diag.rule, "unused-variable"); assert_eq!(diag.severity, Severity::Warn); + // Verify that the builder methods actually set their fields. + assert_eq!( + diag.help.as_deref(), + Some("Remove the frontmatter key or reference it in the body"), + "with_help must set the help field" + ); + assert_eq!( + diag.file.as_deref(), + Some("test.mds"), + "with_file must set the file field" + ); + assert!(diag.span.is_some(), "with_span must set the span field"); // LintResult has diagnostics, truncated, and is_standalone fields. - let result = LintResult { - diagnostics: vec![diag], - truncated: false, - is_standalone: false, - }; + let result = LintResult::new(vec![diag], false, false); assert_eq!(result.diagnostics.len(), 1); assert!(!result.truncated); } @@ -1087,23 +1097,18 @@ fn max_diagnostics_pinned() { fn lint_canonical_json_schema() { use mds::SerializedSpan; - let result = LintResult { - diagnostics: vec![LintDiagnostic::new( + let result = LintResult::new( + vec![LintDiagnostic::new( "unused-variable", Severity::Warn, "Variable 'name' is never used", ) .with_help("Remove the frontmatter key or reference it in the body") - .with_span(SerializedSpan { - offset: 4, - length: 4, - line: Some(2), - column: Some(1), - }) + .with_span(SerializedSpan::new(4, 4).with_line(2).with_column(1)) .with_file("test.mds")], - truncated: false, - is_standalone: false, - }; + false, + false, + ); let json = result.to_canonical_json(); @@ -1146,62 +1151,54 @@ fn lint_canonical_json_fixable_semantics() { use mds::LintDiagnostic; // Tier A rule (duplicate-import) with fix_removals → fixable regardless of is_standalone. - let tier_a = LintResult { - diagnostics: vec![LintDiagnostic::new( - "duplicate-import", - Severity::Error, - "Duplicate import", - ) - .with_file("a.mds") - .with_fix_removals(vec![FixLineSpan::single(0)])], - truncated: false, - is_standalone: false, // even non-standalone Tier A is fixable - }; + let tier_a = LintResult::new( + vec![ + LintDiagnostic::new("duplicate-import", Severity::Error, "Duplicate import") + .with_file("a.mds") + .with_fix_removals(vec![FixLineSpan::single(0)]), + ], + false, + false, // even non-standalone Tier A is fixable + ); let json = tier_a.to_canonical_json(); assert_eq!(json["files"][0]["diagnostics"][0]["fixable"], true); // Tier B rule (unused-function) — fixable only for standalone files. // fix_removals: Some(...) + non-standalone → fixable: false - let tier_b_non_standalone = LintResult { - diagnostics: vec![LintDiagnostic::new( - "unused-function", - Severity::Warn, - "Unused function", - ) - .with_file("b.mds") - .with_fix_removals(vec![FixLineSpan::single(0)])], - truncated: false, - is_standalone: false, - }; + let tier_b_non_standalone = LintResult::new( + vec![ + LintDiagnostic::new("unused-function", Severity::Warn, "Unused function") + .with_file("b.mds") + .with_fix_removals(vec![FixLineSpan::single(0)]), + ], + false, + false, + ); let json = tier_b_non_standalone.to_canonical_json(); assert_eq!(json["files"][0]["diagnostics"][0]["fixable"], false); // fix_removals: Some(...) + standalone → fixable: true - let tier_b_standalone = LintResult { - diagnostics: vec![LintDiagnostic::new( - "unused-function", - Severity::Warn, - "Unused function", - ) - .with_file("c.mds") - .with_fix_removals(vec![FixLineSpan::single(0)])], - truncated: false, - is_standalone: true, - }; + let tier_b_standalone = LintResult::new( + vec![ + LintDiagnostic::new("unused-function", Severity::Warn, "Unused function") + .with_file("c.mds") + .with_fix_removals(vec![FixLineSpan::single(0)]), + ], + false, + true, + ); let json = tier_b_standalone.to_canonical_json(); assert_eq!(json["files"][0]["diagnostics"][0]["fixable"], true); // Tier C rule (unused-variable) → never fixable (fix_removals: None also → false). - let tier_c = LintResult { - diagnostics: vec![LintDiagnostic::new( - "unused-variable", - Severity::Warn, - "Unused variable", - ) - .with_file("d.mds")], - truncated: false, - is_standalone: true, // even standalone Tier C is not fixable - }; + let tier_c = LintResult::new( + vec![ + LintDiagnostic::new("unused-variable", Severity::Warn, "Unused variable") + .with_file("d.mds"), + ], + false, + true, // even standalone Tier C is not fixable + ); let json = tier_c.to_canonical_json(); assert_eq!(json["files"][0]["diagnostics"][0]["fixable"], false); } @@ -1420,38 +1417,20 @@ fn fix_api_incremental_exists() { } // RejectedEdit struct has `edit` and `reason` fields. - let edit = ByteEdit { - start: 0, - end: 5, - rule: "duplicate-import".to_string(), - replacement: String::new(), - }; - let rejected = RejectedEdit { - edit, - reason: "simulated reverify failure".to_string(), - }; + let edit = ByteEdit::new(0, 5, "duplicate-import", ""); + let rejected = RejectedEdit::new(edit, "simulated reverify failure"); assert_eq!(rejected.reason, "simulated reverify failure"); assert_eq!(rejected.edit.rule, "duplicate-import"); // apply_fixes_incremental is callable with F: Fn — compile-time and runtime check. let source = "Hello!\n"; - let original = LintResult { - diagnostics: vec![], - truncated: false, - is_standalone: false, - }; + let original = LintResult::new(vec![], false, false); let plan = plan_fixes(&original, source); let outcome = apply_fixes_incremental( source, plan, &original, - |_s| -> Result { - Ok(LintResult { - diagnostics: vec![], - truncated: false, - is_standalone: false, - }) - }, + |_s| -> Result { Ok(LintResult::new(vec![], false, false)) }, ); // Empty source with no diagnostics → NothingToFix (no reverify called). assert!( diff --git a/crates/mds-napi/src/lib.rs b/crates/mds-napi/src/lib.rs index 73a1fbb..762baf6 100644 --- a/crates/mds-napi/src/lib.rs +++ b/crates/mds-napi/src/lib.rs @@ -832,7 +832,7 @@ fn extract_rules_direct(env: &Env, obj: &Object) -> napi::Result Err(throw_options_error( env, diff --git a/crates/mds-python/src/lib.rs b/crates/mds-python/src/lib.rs index 56c3dce..9fbbb75 100644 --- a/crates/mds-python/src/lib.rs +++ b/crates/mds-python/src/lib.rs @@ -1260,7 +1260,7 @@ fn extract_rules(py: Python<'_>, rules: Option<&Bound<'_, PyAny>>) -> PyResult Result { })?; rules.insert(key, severity); } - Ok(mds::LintConfig { rules }) + Ok(mds::LintConfig::with_rules(rules)) } /// Parse the JS options for `lint` and `lint_virtual`. From 9dd499f900ef2f6f8219dfba7da3582e5feb30fc Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 9 Aug 2026 10:31:15 +0200 Subject: [PATCH 3/7] feat(api): mark CompileOptions/InvalidOptionsError/VarsError/FixTier #[non_exhaustive]; add builders and migrate call sites [#259] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CompileOptions: add #[non_exhaustive] + with_source_map / with_include_sources_content / with_source_map_base builders - InvalidOptionsError: add #[non_exhaustive] - VarsError: add #[non_exhaustive] - FixTier: add #[non_exhaustive] - LintResult::new: drop truncated/is_standalone args; add .truncated() and .standalone() builders - FixLineSpan::range: replace with range_inclusive and range_exclusive (debug_assert from > to) - ByteEdit::new: replace with deletion and replacement named constructors - LintConfig::with_rules → from_rules (C-CTOR naming guideline) - #[must_use] on LintDiagnostic::new, TextEdit::new (+ debug_assert), SerializedSpan::new, FixLineSpan::single, LintResult::new, ByteEdit::deletion/replacement, RejectedEdit::new - FixPlan doc: clarify fields are pub and directly readable/writable --- crates/mds-core/src/error.rs | 1 + crates/mds-core/src/lib.rs | 6 +- crates/mds-core/src/lint/config.rs | 10 +- crates/mds-core/src/lint/diagnostic.rs | 141 ++++++++++++++++++------- crates/mds-core/src/lint/fix.rs | 52 +++++++-- crates/mds-core/src/lint/tier.rs | 1 + crates/mds-core/src/options.rs | 1 + crates/mds-core/src/sourcemap.rs | 56 ++++++++++ 8 files changed, 215 insertions(+), 53 deletions(-) diff --git a/crates/mds-core/src/error.rs b/crates/mds-core/src/error.rs index f92bb7c..f537b48 100644 --- a/crates/mds-core/src/error.rs +++ b/crates/mds-core/src/error.rs @@ -43,6 +43,7 @@ impl SerializedSpan { /// assert!(span.line.is_none()); /// assert!(span.column.is_none()); /// ``` + #[must_use] pub fn new(offset: usize, length: usize) -> Self { SerializedSpan { offset, diff --git a/crates/mds-core/src/lib.rs b/crates/mds-core/src/lib.rs index 979ff37..39304a6 100644 --- a/crates/mds-core/src/lib.rs +++ b/crates/mds-core/src/lib.rs @@ -972,7 +972,7 @@ pub fn compile_virtual_with_deps( /// /// ```rust,no_run /// use std::path::Path; -/// let result = mds::compile_with_deps_opts(Path::new("t.mds"), None, mds::CompileOptions { source_map: true, include_sources_content: false, ..Default::default() })?; +/// let result = mds::compile_with_deps_opts(Path::new("t.mds"), None, mds::CompileOptions::default().with_source_map(true))?; /// if let Some(sm) = result.source_map { println!("{}", sm.to_json()); } /// # Ok::<(), Box>(()) /// ``` @@ -1016,7 +1016,7 @@ pub fn compile_with_deps_opts( /// "Hello!\n", /// None, /// None, -/// mds::CompileOptions { source_map: true, include_sources_content: false, ..Default::default() }, +/// mds::CompileOptions::default().with_source_map(true), /// )?; /// assert!(result.source_map.is_some()); /// # Ok::<(), Box>(()) @@ -1057,7 +1057,7 @@ pub fn compile_str_with_deps_opts( /// modules, /// "main.mds", /// None, -/// mds::CompileOptions { source_map: true, include_sources_content: false, ..Default::default() }, +/// mds::CompileOptions::default().with_source_map(true), /// )?; /// assert!(result.source_map.is_some()); /// # Ok::<(), Box>(()) diff --git a/crates/mds-core/src/lint/config.rs b/crates/mds-core/src/lint/config.rs index dc4bed3..4fb79ba 100644 --- a/crates/mds-core/src/lint/config.rs +++ b/crates/mds-core/src/lint/config.rs @@ -27,7 +27,7 @@ use super::diagnostic::Severity; /// /// This type is `#[non_exhaustive]`: new fields may be added in minor releases. /// Use `LintConfig::default()` for a config with all rules at engine defaults, or -/// [`LintConfig::with_rules`] to supply per-rule overrides; do not construct via +/// [`LintConfig::from_rules`] to supply per-rule overrides; do not construct via /// struct literal. #[non_exhaustive] #[derive(Debug, Default, Clone)] @@ -43,18 +43,22 @@ impl LintConfig { /// This is the supported construction path for external crates — struct literals /// are not available because this type is `#[non_exhaustive]`. /// + /// Per Rust API guidelines (C-CTOR): constructors are named `new`, `from_*`, + /// or `with_*` only when taking `self`. This function does not take `self`, + /// so it is named `from_rules`. + /// /// # Examples /// /// ``` /// use std::collections::HashMap; /// use mds::{LintConfig, Severity}; - /// let config = LintConfig::with_rules(HashMap::from([ + /// let config = LintConfig::from_rules(HashMap::from([ /// ("unused-variable".to_string(), Severity::Off), /// ])); /// assert_eq!(config.severity_for("unused-variable"), Some(&Severity::Off)); /// ``` #[must_use] - pub fn with_rules(rules: HashMap) -> Self { + pub fn from_rules(rules: HashMap) -> Self { LintConfig { rules } } diff --git a/crates/mds-core/src/lint/diagnostic.rs b/crates/mds-core/src/lint/diagnostic.rs index f17b061..59e698b 100644 --- a/crates/mds-core/src/lint/diagnostic.rs +++ b/crates/mds-core/src/lint/diagnostic.rs @@ -254,7 +254,9 @@ impl TextEdit { /// assert_eq!(edit.end, 12); /// assert_eq!(edit.new_text, "{{name}}"); /// ``` + #[must_use] pub fn new(start: usize, end: usize, new_text: impl Into) -> Self { + debug_assert!(start <= end, "TextEdit::new: start ({start}) > end ({end})"); TextEdit { start, end, @@ -282,8 +284,10 @@ impl TextEdit { /// the one line that contains `offset`. /// /// This type is `#[non_exhaustive]`: new fields may be added in minor releases. -/// Construct via [`FixLineSpan::single`] (single-line) or [`FixLineSpan::range`] -/// (multi-line); external crates must not use a struct literal. +/// Construct via [`FixLineSpan::single`] (single-line), +/// [`FixLineSpan::range_inclusive`] (multi-line, inclusive), or +/// [`FixLineSpan::range_exclusive`] (multi-line, exclusive); +/// external crates must not use a struct literal. #[non_exhaustive] #[derive(Debug, Clone)] pub struct FixLineSpan { @@ -300,7 +304,8 @@ pub struct FixLineSpan { impl FixLineSpan { /// Remove exactly the one line that contains `offset`. /// - /// Equivalent to `FixLineSpan::range(offset, offset, true)`. + /// Equivalent to `FixLineSpan::range_inclusive(offset, offset)`. + #[must_use] pub fn single(offset: usize) -> Self { FixLineSpan { from: offset, @@ -309,16 +314,45 @@ impl FixLineSpan { } } - /// Remove a range of lines spanning byte offsets `from` through `to`. + /// Remove a range of lines **including** the line containing `to`. + /// + /// Both `from` and `to` are byte offsets within their respective lines. + /// The planner translates them to exact line boundaries. + /// + /// # Panics (debug) + /// + /// Panics in debug builds when `from > to`. + #[must_use] + pub fn range_inclusive(from: usize, to: usize) -> Self { + debug_assert!( + from <= to, + "FixLineSpan::range_inclusive: from ({from}) > to ({to})" + ); + FixLineSpan { + from, + to, + to_inclusive: true, + } + } + + /// Remove a range of lines **excluding** the line containing `to`. /// - /// - `to_inclusive: true` → the line containing `to` is removed. - /// - `to_inclusive: false` → removal stops at the start of the line containing - /// `to` (that line is kept). - pub fn range(from: usize, to: usize, to_inclusive: bool) -> Self { + /// The line containing `to` is kept; removal stops at the start of that line. + /// Use this when a closing token (e.g. `@end`) must remain in the source. + /// + /// # Panics (debug) + /// + /// Panics in debug builds when `from > to`. + #[must_use] + pub fn range_exclusive(from: usize, to: usize) -> Self { + debug_assert!( + from <= to, + "FixLineSpan::range_exclusive: from ({from}) > to ({to})" + ); FixLineSpan { from, to, - to_inclusive, + to_inclusive: false, } } } @@ -397,6 +431,7 @@ impl LintDiagnostic { /// assert_eq!(diag.severity, Severity::Warn); /// assert!(diag.help.is_none()); /// ``` + #[must_use] pub fn new(rule: impl Into, severity: Severity, message: impl Into) -> Self { LintDiagnostic { rule: rule.into(), @@ -589,7 +624,8 @@ impl miette::Diagnostic for LintDiagnostic { /// /// This type is `#[non_exhaustive]`: new fields may be added in minor releases. /// Obtain values from `mds::lint_str`, `mds::lint`, and similar lint API functions, -/// or construct via [`LintResult::new`]; do not construct via struct literal. +/// or construct via [`LintResult::new`] (and chain [`.truncated()`] / [`.standalone()`]); +/// do not construct via struct literal. #[non_exhaustive] #[derive(Debug)] pub struct LintResult { @@ -602,7 +638,10 @@ pub struct LintResult { } impl LintResult { - /// Construct a `LintResult` with all fields. + /// Construct a `LintResult` from a diagnostic list. + /// + /// Defaults: `truncated = false`, `is_standalone = false`. + /// Chain [`.truncated()`] or [`.standalone()`] to override. /// /// This is the supported construction path for external crates — struct literals /// are not available because this type is `#[non_exhaustive]`. @@ -611,19 +650,36 @@ impl LintResult { /// /// ``` /// use mds::LintResult; - /// let result = LintResult::new(vec![], false, true); + /// let result = LintResult::new(vec![]).standalone(); /// assert!(result.diagnostics.is_empty()); /// assert!(!result.truncated); /// assert!(result.is_standalone); /// ``` - pub fn new(diagnostics: Vec, truncated: bool, is_standalone: bool) -> Self { + #[must_use] + pub fn new(diagnostics: Vec) -> Self { LintResult { diagnostics, - truncated, - is_standalone, + truncated: false, + is_standalone: false, } } + /// Mark this result as truncated (the diagnostic cap was reached). + #[must_use] + pub fn truncated(mut self) -> Self { + self.truncated = true; + self + } + + /// Mark this result as standalone (the file has no `@import` or `@extends`). + /// + /// Standalone status gates Tier B `--fix` eligibility. + #[must_use] + pub fn standalone(mut self) -> Self { + self.is_standalone = true; + self + } + /// Produce the canonical, LSP-stable JSON wire format. /// /// Schema: @@ -1930,42 +1986,51 @@ mod tests { /// T-SFR-2: `span` and `file` pass through byte-identical; fix data is stripped. /// /// Pins three behaviours together: - /// - `span` offset/length are raw byte values, not altered by sanitization. - /// - `file` is copied verbatim (WIRE escaping of the filename is the caller's - /// responsibility via `named_source_for_render`). + /// - `span` offset/length/line/column are raw values, not altered by sanitization. + /// - `file` is copied verbatim — including hostile control bytes (WIRE escaping of + /// the filename is the caller's responsibility via `named_source_for_render`). /// - `fix_removals` and `fix_edits` are set to `None` so the sanitized clone /// cannot be mistaken for a fix-bearing diagnostic. + /// + /// The hostile filename `"a\u{1B}/b\u{202E}.mds"` exercises the no-sanitize + /// guarantee: `sanitized_for_render` must copy `file` byte-for-byte regardless of + /// what control bytes it contains. This is intentional — WIRE-escaping happens in + /// `named_source_for_render`, not here (PF-014). #[test] fn sanitized_for_render_span_file_unchanged_fix_nulled() { - let span = crate::error::SerializedSpan { - offset: 42, - length: 7, - line: Some(3), - column: Some(1), - }; - let diag = LintDiagnostic { - rule: "duplicate-import".to_string(), - severity: Severity::Error, - message: "msg".to_string(), - help: None, - span: Some(span.clone()), - file: Some("a/b.mds".to_string()), - fix_removals: Some(vec![FixLineSpan::single(42)]), - fix_edits: Some(vec![TextEdit::new(0, 3, "x")]), - }; + let span = crate::error::SerializedSpan::new(42, 7) + .with_line(3) + .with_column(1); + let hostile_file = "a\u{1B}/b\u{202E}.mds".to_string(); + let diag = LintDiagnostic::new("duplicate-import", Severity::Error, "msg") + .with_span(span.clone()) + .with_file(hostile_file.clone()) + .with_fix_removals(vec![FixLineSpan::single(42)]) + .with_fix_edits(vec![TextEdit::new(0, 3, "x")]); let rendered = diag.sanitized_for_render(); - // span is byte-identical. + // span is byte-identical, including line and column. let rendered_span = rendered.span.as_ref().expect("span must survive"); assert_eq!(rendered_span.offset, 42, "span.offset must be unchanged"); assert_eq!(rendered_span.length, 7, "span.length must be unchanged"); + assert_eq!( + rendered_span.line, + Some(3), + "span.line must survive sanitization" + ); + assert_eq!( + rendered_span.column, + Some(1), + "span.column must survive sanitization" + ); - // file is copied verbatim. + // file is copied verbatim — hostile bytes are NOT sanitized by sanitized_for_render + // (the caller's named_source_for_render is responsible for WIRE-escaping the filename). assert_eq!( rendered.file.as_deref(), - Some("a/b.mds"), - "file must be byte-identical" + Some(hostile_file.as_str()), + "file must be byte-identical, including hostile control bytes" ); // fix data is intentionally stripped. diff --git a/crates/mds-core/src/lint/fix.rs b/crates/mds-core/src/lint/fix.rs index be56922..545bc78 100644 --- a/crates/mds-core/src/lint/fix.rs +++ b/crates/mds-core/src/lint/fix.rs @@ -109,7 +109,7 @@ pub use super::tier::{is_fixable, is_output_neutral, rule_tier, FixTier}; /// (call [`extend_to_line_end`] to adjust if needed) for line-removal edits. /// /// This type is `#[non_exhaustive]`: new fields may be added in minor releases. -/// Construct via [`ByteEdit::new`]; do not use a struct literal. +/// Construct via [`ByteEdit::deletion`] or [`ByteEdit::replacement`]; do not use a struct literal. #[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct ByteEdit { @@ -124,21 +124,54 @@ pub struct ByteEdit { } impl ByteEdit { - /// Construct a `ByteEdit` with all fields. + /// Construct a `ByteEdit` that **deletes** the byte range `[start, end)`. + /// + /// Equivalent to a replacement with an empty string. + /// + /// This is the supported construction path for external crates — struct literals + /// are not available because this type is `#[non_exhaustive]`. + /// + /// # Examples + /// + /// ``` + /// use mds::fix::ByteEdit; + /// let edit = ByteEdit::deletion(0, 6, "duplicate-import"); + /// assert_eq!(edit.replacement, ""); + /// ``` + #[must_use] + pub fn deletion(start: usize, end: usize, rule: impl Into) -> Self { + ByteEdit { + start, + end, + rule: rule.into(), + replacement: String::new(), + } + } + + /// Construct a `ByteEdit` that **replaces** the byte range `[start, end)` with `text`. /// /// This is the supported construction path for external crates — struct literals /// are not available because this type is `#[non_exhaustive]`. - pub fn new( + /// + /// # Examples + /// + /// ``` + /// use mds::fix::ByteEdit; + /// let edit = ByteEdit::replacement(6, 12, "legacy-interpolation", "{{name}}"); + /// assert_eq!(edit.replacement, "{{name}}"); + /// ``` + #[must_use] + pub fn replacement( start: usize, end: usize, rule: impl Into, - replacement: impl Into, + text: impl Into, ) -> Self { ByteEdit { start, end, rule: rule.into(), - replacement: replacement.into(), + replacement: text.into(), } } } @@ -162,6 +195,7 @@ impl RejectedEdit { /// /// This is the supported construction path for external crates — struct literals /// are not available because this type is `#[non_exhaustive]`. + #[must_use] pub fn new(edit: ByteEdit, reason: impl Into) -> Self { RejectedEdit { edit, @@ -202,11 +236,11 @@ fn reverify_failure_reason(err: &MdsError) -> String { /// /// Obtain via [`plan_fixes`] or [`plan_fixes_with_options`], then pass to /// [`apply_fixes`] or [`apply_fixes_incremental`]. External crates that need an -/// empty plan can use `FixPlan::default()`. +/// empty plan can use `FixPlan::default()`; its fields are `pub`, so they remain +/// directly readable and writable. /// -/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. -/// Construct via the planning functions or `FixPlan::default()`; do not use a -/// struct literal in external crates. +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases; +/// do not use a struct literal in external crates. #[non_exhaustive] #[derive(Debug, Default)] pub struct FixPlan { diff --git a/crates/mds-core/src/lint/tier.rs b/crates/mds-core/src/lint/tier.rs index 7e1e4c5..304563a 100644 --- a/crates/mds-core/src/lint/tier.rs +++ b/crates/mds-core/src/lint/tier.rs @@ -22,6 +22,7 @@ //! unused import or function must produce byte-identical compiled output. /// Fix tier for a lint rule. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq)] pub enum FixTier { /// Auto-fixable with a reverify gate. Diagnostic `fixable` = `true`. diff --git a/crates/mds-core/src/options.rs b/crates/mds-core/src/options.rs index a850fff..b1b9ba0 100644 --- a/crates/mds-core/src/options.rs +++ b/crates/mds-core/src/options.rs @@ -45,6 +45,7 @@ pub fn json_type_name(v: &serde_json::Value) -> &'static str { // ── VarsError ───────────────────────────────────────────────────────────────── /// Errors that can occur when parsing the `vars` option. +#[non_exhaustive] #[derive(Debug)] pub enum VarsError { /// The `vars` value was not a JSON object (e.g. it was an array or string). diff --git a/crates/mds-core/src/sourcemap.rs b/crates/mds-core/src/sourcemap.rs index 2e595e6..aa59979 100644 --- a/crates/mds-core/src/sourcemap.rs +++ b/crates/mds-core/src/sourcemap.rs @@ -440,6 +440,7 @@ pub(crate) fn encode_mappings(mut points: Vec<(u32, u32, u32, u32, u32)>) -> Str /// (`compile_with_deps_opts`, `compile_str_with_deps_opts`, /// `compile_virtual_with_deps_opts`) and threaded through the resolver and /// evaluator. +#[non_exhaustive] #[derive(Debug, Clone, Default)] pub struct CompileOptions { /// Generate a [`SourceMap`] and attach it to [`crate::CompileResult::source_map`]. @@ -469,6 +470,7 @@ pub struct CompileOptions { /// /// Each binding maps this to its own error type and message; the unit struct intentionally /// carries no context — the per-binding wording is always determined at the call site. +#[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct InvalidOptionsError; @@ -489,6 +491,60 @@ impl CompileOptions { Ok(()) } } + + /// Enable or disable source map generation. + /// + /// When `true`, [`crate::CompileResult::source_map`] will be populated. + /// + /// # Examples + /// + /// ``` + /// let opts = mds::CompileOptions::default().with_source_map(true); + /// assert!(opts.source_map); + /// ``` + #[must_use] + pub fn with_source_map(mut self, source_map: bool) -> Self { + self.source_map = source_map; + self + } + + /// Enable or disable embedding source file contents in the map. + /// + /// When `true`, `sourcesContent` is included in the generated source map. + /// + /// # Examples + /// + /// ``` + /// let opts = mds::CompileOptions::default() + /// .with_source_map(true) + /// .with_include_sources_content(true); + /// assert!(opts.include_sources_content); + /// ``` + #[must_use] + pub fn with_include_sources_content(mut self, include: bool) -> Self { + self.include_sources_content = include; + self + } + + /// Set the directory the source map file will be written to. + /// + /// When `Some`, `sources[]` paths are emitted relative to this directory; + /// when `None` (the default), paths are root-relative. + /// + /// # Examples + /// + /// ``` + /// use std::path::PathBuf; + /// let opts = mds::CompileOptions::default() + /// .with_source_map(true) + /// .with_source_map_base(Some(PathBuf::from("out"))); + /// assert!(opts.source_map_base.is_some()); + /// ``` + #[must_use] + pub fn with_source_map_base(mut self, base: Option) -> Self { + self.source_map_base = base; + self + } } // --------------------------------------------------------------------------- From 6989ac118e20e94c6db90a26d696fdbb7cb3e2b1 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 9 Aug 2026 10:31:35 +0200 Subject: [PATCH 4/7] fix(api): builder-style LintResult::new, split FixLineSpan/ByteEdit, rename LintConfig::from_rules, add #[must_use] [#259] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate all binding and CLI call sites to the new APIs introduced in the companion core commit: - mds-cli/src/build.rs: 3 × CompileOptions literal → builder chain; LintConfig::with_rules → from_rules - mds-cli/src/lint.rs: FixLineSpan::range → range_inclusive; LintResult::new 3-arg → 1-arg; doc comment updated to reference new constructor names - mds-napi/src/lib.rs: CompileOptions literal → builder; from_rules; VarsError match + wildcard arm (non_exhaustive) - mds-python/src/lib.rs: CompileOptions literal → builder; from_rules; VarsError match + wildcard arm (non_exhaustive) - mds-wasm/src/lib.rs: 2 × CompileOptions literal → builder; from_rules; VarsError match + wildcard arm (non_exhaustive) --- crates/mds-cli/src/build.rs | 29 +++++++++++++---------------- crates/mds-cli/src/lint.rs | 12 +++++------- crates/mds-napi/src/lib.rs | 12 ++++++------ crates/mds-python/src/lib.rs | 12 ++++++------ crates/mds-wasm/src/lib.rs | 20 +++++++++----------- 5 files changed, 39 insertions(+), 46 deletions(-) diff --git a/crates/mds-cli/src/build.rs b/crates/mds-cli/src/build.rs index f1bda62..318c6f4 100644 --- a/crates/mds-cli/src/build.rs +++ b/crates/mds-cli/src/build.rs @@ -54,7 +54,7 @@ pub(crate) struct LintCliConfig { impl LintCliConfig { /// Convert to the core `LintConfig` consumed by `mds::lint_*` functions. pub(crate) fn into_core_config(self) -> mds::LintConfig { - mds::LintConfig::with_rules(self.rules) + mds::LintConfig::from_rules(self.rules) } } @@ -1153,11 +1153,10 @@ pub(crate) fn run_build(args: BuildArgs) -> Result<()> { } let source_map_base = compute_source_map_base(Path::new("-"), &output, &out_dir, &None); - let opts = mds::CompileOptions { - source_map: use_source_map, - include_sources_content: use_embed_sources, - source_map_base, - }; + let opts = mds::CompileOptions::default() + .with_source_map(use_source_map) + .with_include_sources_content(use_embed_sources) + .with_source_map_base(source_map_base); let (source, cwd) = read_stdin()?; let result = mds::compile_str_with_deps_opts(&source, Some(&cwd), runtime_vars, opts) @@ -1272,11 +1271,10 @@ pub(crate) fn run_build(args: BuildArgs) -> Result<()> { } let source_map_base = compute_source_map_base(&input, &output, &out_dir, &config); - let opts = mds::CompileOptions { - source_map: use_source_map, - include_sources_content: use_embed_sources, - source_map_base, - }; + let opts = mds::CompileOptions::default() + .with_source_map(use_source_map) + .with_include_sources_content(use_embed_sources) + .with_source_map_base(source_map_base); let compiled = compile_to_content(&input, runtime_vars, quiet, opts)?; let output_path = resolve_output_path_for_kind( @@ -1482,11 +1480,10 @@ fn run_build_directory( .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| PathBuf::from(".")); - let opts = mds::CompileOptions { - source_map, - include_sources_content: embed_sources, - source_map_base: Some(source_map_base), - }; + let opts = mds::CompileOptions::default() + .with_source_map(source_map) + .with_include_sources_content(embed_sources) + .with_source_map_base(Some(source_map_base)); // Compile (all reads go through mds-core which enforces MAX_FILE_SIZE — PF-004). match compile_to_content(file, runtime_vars.clone(), quiet, opts) { diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index 70f28fa..158da37 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -1465,8 +1465,8 @@ mod tests { /// /// Math (identical to ISS-02): /// source = "line0\nline1\nline2\n" - /// Edit A: FixLineSpan { from: 0, to: 6, to_inclusive: true } → ByteEdit [0, 12) - /// Edit B: FixLineSpan { from: 6, to: 12, to_inclusive: true } → ByteEdit [6, 18) + /// Edit A: FixLineSpan::range_inclusive(0, 6) → ByteEdit [0, 12) + /// Edit B: FixLineSpan::range_inclusive(6, 12) → ByteEdit [6, 18) /// A.end=12 > B.start=6, B.end=18 > A.end=12 → partial overlap → overlap_rejected. /// /// `preview_fixes` must return `PreviewOutcome::Rejected` — the rejection must be @@ -1478,22 +1478,20 @@ mod tests { let source = "line0\nline1\nline2\n"; // Edit A covers bytes [0, 12): line0 start through line1 end (inclusive). let diag_a = LintDiagnostic::new("duplicate-import", Severity::Error, "a") - .with_fix_removals(vec![FixLineSpan::range( + .with_fix_removals(vec![FixLineSpan::range_inclusive( 0, // inside line0 6, // inside line1; extend_to_line_end(6) = 12 - true, )]); // Edit B covers bytes [6, 18): line1 start through line2 end (inclusive). // Partially overlaps A at [6, 12). let diag_b = LintDiagnostic::new("empty-block", Severity::Warn, "b").with_fix_removals(vec![ - FixLineSpan::range( + FixLineSpan::range_inclusive( 6, // inside line1 12, // inside line2; extend_to_line_end(12) = 18 - true, ), ]); - let result = LintResult::new(vec![diag_a, diag_b], false, false); + let result = LintResult::new(vec![diag_a, diag_b]); let outcome = preview_fixes( &result, diff --git a/crates/mds-napi/src/lib.rs b/crates/mds-napi/src/lib.rs index 762baf6..e7af9ac 100644 --- a/crates/mds-napi/src/lib.rs +++ b/crates/mds-napi/src/lib.rs @@ -464,6 +464,8 @@ fn extract_vars_direct(env: &Env, obj: &Object) -> napi::Result throw_options_error(env, &msg), VarsError::Conversion(mds_err) => throw_mds_error(env, mds_err), + // VarsError is #[non_exhaustive]; handle future variants gracefully. + _ => throw_options_error(env, &format!("vars error: {e}")), }) } other => Err(throw_options_error( @@ -516,11 +518,9 @@ fn extract_bool_direct( fn extract_compile_options_direct(env: &Env, obj: &Object) -> napi::Result { let source_map = extract_bool_direct(env, obj, "sourceMap", false)?; let include_sources_content = extract_bool_direct(env, obj, "sourcesContent", false)?; - let opts = mds::CompileOptions { - source_map, - include_sources_content, - ..Default::default() - }; + let opts = mds::CompileOptions::default() + .with_source_map(source_map) + .with_include_sources_content(include_sources_content); opts.validate().map_err(|_| { throw_options_error( env, @@ -832,7 +832,7 @@ fn extract_rules_direct(env: &Env, obj: &Object) -> napi::Result Err(throw_options_error( env, diff --git a/crates/mds-python/src/lib.rs b/crates/mds-python/src/lib.rs index 9fbbb75..3d8dedc 100644 --- a/crates/mds-python/src/lib.rs +++ b/crates/mds-python/src/lib.rs @@ -1126,6 +1126,8 @@ fn extract_vars( parse_json_vars(json).map(Some).map_err(|e| match e { VarsError::InvalidType(msg) => options_error(py, &msg), VarsError::Conversion(mds_err) => mds_err_to_py(py, &mds_err), + // VarsError is #[non_exhaustive]; handle future variants as conversion errors. + _ => options_error(py, &format!("vars error: {e}")), }) } @@ -1260,7 +1262,7 @@ fn extract_rules(py: Python<'_>, rules: Option<&Bound<'_, PyAny>>) -> PyResult PyResult { - let opts = mds::CompileOptions { - source_map, - include_sources_content: sources_content, - ..Default::default() - }; + let opts = mds::CompileOptions::default() + .with_source_map(source_map) + .with_include_sources_content(sources_content); opts.validate().map_err(|_| { options_error( py, diff --git a/crates/mds-wasm/src/lib.rs b/crates/mds-wasm/src/lib.rs index 4e11200..1cf97d8 100644 --- a/crates/mds-wasm/src/lib.rs +++ b/crates/mds-wasm/src/lib.rs @@ -364,6 +364,8 @@ fn extract_vars(obj: &js_sys::Object) -> Result>, parse_json_vars(vars_json).map(Some).map_err(|e| match e { VarsError::InvalidType(msg) => options_error(&msg), VarsError::Conversion(mds_err) => mds_error_to_js(mds_err), + // VarsError is #[non_exhaustive]; handle future variants gracefully. + _ => options_error(&format!("vars error: {e}")), }) } @@ -391,11 +393,9 @@ fn extract_bool_wasm(obj: &js_sys::Object, key: &str, default_val: bool) -> Resu fn extract_compile_options_wasm(obj: &js_sys::Object) -> Result { let source_map = extract_bool_wasm(obj, "sourceMap", false)?; let include_sources_content = extract_bool_wasm(obj, "sourcesContent", false)?; - let opts = mds::CompileOptions { - source_map, - include_sources_content, - ..Default::default() - }; + let opts = mds::CompileOptions::default() + .with_source_map(source_map) + .with_include_sources_content(include_sources_content); opts.validate().map_err(|_| { options_error("option \"sourcesContent\" requires \"sourceMap\" to be true") })?; @@ -493,7 +493,7 @@ fn extract_rules(obj: &js_sys::Object) -> Result { })?; rules.insert(key, severity); } - Ok(mds::LintConfig::with_rules(rules)) + Ok(mds::LintConfig::from_rules(rules)) } /// Parse the JS options for `lint` and `lint_virtual`. @@ -719,11 +719,9 @@ pub fn compile(source: &str, options: JsValue) -> Result { catch_panic(AssertUnwindSafe(move || { let opts = parse_options(options)?; - let compile_opts = mds::CompileOptions { - source_map: opts.source_map, - include_sources_content: opts.include_sources_content, - ..Default::default() - }; + let compile_opts = mds::CompileOptions::default() + .with_source_map(opts.source_map) + .with_include_sources_content(opts.include_sources_content); let modules = build_modules(source, &opts.filename, opts.extra_modules)?; let result = mds::compile_virtual_with_deps_opts(modules, &opts.filename, opts.vars, compile_opts) From 3a771dffc265fec1f1a2cd96b17f2c6ebc6245f8 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 9 Aug 2026 10:31:53 +0200 Subject: [PATCH 5/7] test(api): pin TextEdit/fix_edits public API surface; harden T-SFR-2 with hostile file input [#259] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api_surface.rs: migrate all CompileOptions literal → builder, LintResult::new 3-arg → 1-arg (+.standalone()), LintConfig::with_rules → from_rules, ByteEdit::new → ByteEdit::deletion; add F-API-2 test pinning mds::TextEdit public nameability and fix_edits JSON emission - T-SFR-2 (diagnostic.rs): replace plain filename with hostile "a\u{1B}/b\u{202E}.mds"; use LintDiagnostic::new builder; assert span.line/column survive sanitization; assert file is byte-identical including hostile control bytes - source_map_vfs.rs: all 10 CompileOptions literal → builder - virtual_fs.rs: 2 × CompileOptions literal → builder - producer_discipline.rs: CompileOptions literal → builder; remove unused import --- crates/mds-cli/tests/producer_discipline.rs | 7 +- crates/mds-core/tests/api_surface.rs | 171 +++++++++++--------- crates/mds-core/tests/source_map_vfs.rs | 64 ++------ crates/mds-core/tests/virtual_fs.rs | 12 +- 4 files changed, 115 insertions(+), 139 deletions(-) diff --git a/crates/mds-cli/tests/producer_discipline.rs b/crates/mds-cli/tests/producer_discipline.rs index d805839..1bc94ce 100644 --- a/crates/mds-cli/tests/producer_discipline.rs +++ b/crates/mds-cli/tests/producer_discipline.rs @@ -58,7 +58,7 @@ const ESCAPED_RLO: &str = "\\u202E"; /// `resolver.rs` branch that names the module in its warning. #[test] fn hostile_module_name_reaches_the_warning() { - use mds::{CompileOptions, Value}; + use mds::Value; let items: Vec = (0..100_000) .map(|_| Value::String("x".to_string())) @@ -83,10 +83,7 @@ fn hostile_module_name_reaches_the_warning() { modules, "entry.mds", Some(vars), - CompileOptions { - source_map: true, - ..Default::default() - }, + mds::CompileOptions::default().with_source_map(true), ) .expect("compilation must succeed even when the segment cap is hit"); diff --git a/crates/mds-core/tests/api_surface.rs b/crates/mds-core/tests/api_surface.rs index 6a61ccb..9edfe75 100644 --- a/crates/mds-core/tests/api_surface.rs +++ b/crates/mds-core/tests/api_surface.rs @@ -1018,7 +1018,7 @@ fn lint_types_exist() { let _err = Severity::Error; // LintConfig has a `rules` field (HashMap). - let config = LintConfig::with_rules(HashMap::from([( + let config = LintConfig::from_rules(HashMap::from([( "unused-variable".to_string(), Severity::Off, )])); @@ -1049,7 +1049,7 @@ fn lint_types_exist() { assert!(diag.span.is_some(), "with_span must set the span field"); // LintResult has diagnostics, truncated, and is_standalone fields. - let result = LintResult::new(vec![diag], false, false); + let result = LintResult::new(vec![diag]); assert_eq!(result.diagnostics.len(), 1); assert!(!result.truncated); } @@ -1097,18 +1097,14 @@ fn max_diagnostics_pinned() { fn lint_canonical_json_schema() { use mds::SerializedSpan; - let result = LintResult::new( - vec![LintDiagnostic::new( - "unused-variable", - Severity::Warn, - "Variable 'name' is never used", - ) - .with_help("Remove the frontmatter key or reference it in the body") - .with_span(SerializedSpan::new(4, 4).with_line(2).with_column(1)) - .with_file("test.mds")], - false, - false, - ); + let result = LintResult::new(vec![LintDiagnostic::new( + "unused-variable", + Severity::Warn, + "Variable 'name' is never used", + ) + .with_help("Remove the frontmatter key or reference it in the body") + .with_span(SerializedSpan::new(4, 4).with_line(2).with_column(1)) + .with_file("test.mds")]); let json = result.to_canonical_json(); @@ -1151,54 +1147,48 @@ fn lint_canonical_json_fixable_semantics() { use mds::LintDiagnostic; // Tier A rule (duplicate-import) with fix_removals → fixable regardless of is_standalone. - let tier_a = LintResult::new( - vec![ - LintDiagnostic::new("duplicate-import", Severity::Error, "Duplicate import") - .with_file("a.mds") - .with_fix_removals(vec![FixLineSpan::single(0)]), - ], - false, - false, // even non-standalone Tier A is fixable - ); + let tier_a = LintResult::new(vec![LintDiagnostic::new( + "duplicate-import", + Severity::Error, + "Duplicate import", + ) + .with_file("a.mds") + .with_fix_removals(vec![FixLineSpan::single(0)])]); // even non-standalone Tier A is fixable let json = tier_a.to_canonical_json(); assert_eq!(json["files"][0]["diagnostics"][0]["fixable"], true); // Tier B rule (unused-function) — fixable only for standalone files. // fix_removals: Some(...) + non-standalone → fixable: false - let tier_b_non_standalone = LintResult::new( - vec![ - LintDiagnostic::new("unused-function", Severity::Warn, "Unused function") - .with_file("b.mds") - .with_fix_removals(vec![FixLineSpan::single(0)]), - ], - false, - false, - ); + let tier_b_non_standalone = LintResult::new(vec![LintDiagnostic::new( + "unused-function", + Severity::Warn, + "Unused function", + ) + .with_file("b.mds") + .with_fix_removals(vec![FixLineSpan::single(0)])]); let json = tier_b_non_standalone.to_canonical_json(); assert_eq!(json["files"][0]["diagnostics"][0]["fixable"], false); // fix_removals: Some(...) + standalone → fixable: true - let tier_b_standalone = LintResult::new( - vec![ - LintDiagnostic::new("unused-function", Severity::Warn, "Unused function") - .with_file("c.mds") - .with_fix_removals(vec![FixLineSpan::single(0)]), - ], - false, - true, - ); + let tier_b_standalone = LintResult::new(vec![LintDiagnostic::new( + "unused-function", + Severity::Warn, + "Unused function", + ) + .with_file("c.mds") + .with_fix_removals(vec![FixLineSpan::single(0)])]) + .standalone(); let json = tier_b_standalone.to_canonical_json(); assert_eq!(json["files"][0]["diagnostics"][0]["fixable"], true); // Tier C rule (unused-variable) → never fixable (fix_removals: None also → false). - let tier_c = LintResult::new( - vec![ - LintDiagnostic::new("unused-variable", Severity::Warn, "Unused variable") - .with_file("d.mds"), - ], - false, - true, // even standalone Tier C is not fixable - ); + let tier_c = LintResult::new(vec![LintDiagnostic::new( + "unused-variable", + Severity::Warn, + "Unused variable", + ) + .with_file("d.mds")]) + .standalone(); // even standalone Tier C is not fixable let json = tier_c.to_canonical_json(); assert_eq!(json["files"][0]["diagnostics"][0]["fixable"], false); } @@ -1316,19 +1306,13 @@ fn native_fs_check_symlink_is_public() { #[test] fn compile_options_has_source_map_and_include_sources_content() { // T1: both fields must exist and be independently settable. - let off = mds::CompileOptions { - source_map: false, - include_sources_content: false, - ..Default::default() - }; + let off = mds::CompileOptions::default(); assert!(!off.source_map); assert!(!off.include_sources_content); - let on = mds::CompileOptions { - source_map: true, - include_sources_content: true, - ..Default::default() - }; + let on = mds::CompileOptions::default() + .with_source_map(true) + .with_include_sources_content(true); assert!(on.source_map); assert!(on.include_sources_content); @@ -1350,11 +1334,7 @@ fn include_sources_content_false_omits_sources_content() { modules, "main.mds", None, - mds::CompileOptions { - source_map: true, - include_sources_content: false, - ..Default::default() - }, + mds::CompileOptions::default().with_source_map(true), ) .expect("should compile"); @@ -1376,11 +1356,9 @@ fn include_sources_content_true_includes_sources_content() { modules, "main.mds", None, - mds::CompileOptions { - source_map: true, - include_sources_content: true, - ..Default::default() - }, + mds::CompileOptions::default() + .with_source_map(true) + .with_include_sources_content(true), ) .expect("should compile"); @@ -1417,20 +1395,20 @@ fn fix_api_incremental_exists() { } // RejectedEdit struct has `edit` and `reason` fields. - let edit = ByteEdit::new(0, 5, "duplicate-import", ""); + let edit = ByteEdit::deletion(0, 5, "duplicate-import"); let rejected = RejectedEdit::new(edit, "simulated reverify failure"); assert_eq!(rejected.reason, "simulated reverify failure"); assert_eq!(rejected.edit.rule, "duplicate-import"); // apply_fixes_incremental is callable with F: Fn — compile-time and runtime check. let source = "Hello!\n"; - let original = LintResult::new(vec![], false, false); + let original = LintResult::new(vec![]); let plan = plan_fixes(&original, source); let outcome = apply_fixes_incremental( source, plan, &original, - |_s| -> Result { Ok(LintResult::new(vec![], false, false)) }, + |_s| -> Result { Ok(LintResult::new(vec![])) }, ); // Empty source with no diagnostics → NothingToFix (no reverify called). assert!( @@ -1454,3 +1432,52 @@ fn string_source_map_label_is_in_public_api() { updating every surface that uses it" ); } + +/// F-API-2: TextEdit is publicly nameable and LintDiagnostic::with_fix_edits wires through. +/// +/// Pins: +/// - `mds::TextEdit` is a public type (was previously unnameable: pub inside pub(crate) mod). +/// - `LintDiagnostic::with_fix_edits` stores and exposes the edits. +/// - `LintResult::to_canonical_json()` emits the `fix_edits` array for a diagnostic that has them. +#[test] +fn text_edit_and_fix_edits_public_api() { + use mds::TextEdit; + + // TextEdit is publicly constructable. + let edit = TextEdit::new(6, 12, "{{name}}"); + assert_eq!(edit.start, 6); + assert_eq!(edit.end, 12); + assert_eq!(edit.new_text, "{{name}}"); + + // with_fix_edits stores the edits on LintDiagnostic. + let diag = LintDiagnostic::new( + "legacy-interpolation", + Severity::Warn, + "legacy brace syntax", + ) + .with_file("t.mds") + .with_fix_edits(vec![edit]); + assert!( + diag.fix_edits.is_some(), + "with_fix_edits must populate fix_edits" + ); + let edits = diag.fix_edits.as_ref().unwrap(); + assert_eq!(edits.len(), 1); + assert_eq!(edits[0].start, 6); + assert_eq!(edits[0].new_text, "{{name}}"); + + // to_canonical_json emits fix_edits as an array (not null) for this diagnostic. + let result = LintResult::new(vec![diag]); + let json = result.to_canonical_json(); + let diags = &json["files"][0]["diagnostics"]; + let fix_edits = &diags[0]["fix_edits"]; + assert!( + fix_edits.is_array(), + "fix_edits must be a JSON array when present; got: {fix_edits:?}" + ); + let arr = fix_edits.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["start"], 6); + assert_eq!(arr[0]["end"], 12); + assert_eq!(arr[0]["new_text"], "{{name}}"); +} diff --git a/crates/mds-core/tests/source_map_vfs.rs b/crates/mds-core/tests/source_map_vfs.rs index 7b1f7a8..626400b 100644 --- a/crates/mds-core/tests/source_map_vfs.rs +++ b/crates/mds-core/tests/source_map_vfs.rs @@ -47,23 +47,14 @@ fn vfs_with_map(modules: HashMap, entry: &str) -> CompileResult vfs_opts( modules, entry, - CompileOptions { - source_map: true, - include_sources_content: true, - ..Default::default() - }, + CompileOptions::default() + .with_source_map(true) + .with_include_sources_content(true), ) } fn vfs_no_map(modules: HashMap, entry: &str) -> CompileResult { - vfs_opts( - modules, - entry, - CompileOptions { - source_map: false, - ..Default::default() - }, - ) + vfs_opts(modules, entry, CompileOptions::default()) } // ── Tests ───────────────────────────────────────────────────────────────────── @@ -872,10 +863,7 @@ fn source_map_messages_mode_degrades_to_none() { let result = vfs_opts( modules, "chat.mds", - CompileOptions { - source_map: true, - ..Default::default() - }, + CompileOptions::default().with_source_map(true), ); // source_map must be None (messages-mode degrades gracefully). @@ -941,10 +929,7 @@ fn source_map_segment_cap_degrades_to_none() { modules, "big.mds", Some(vars), - CompileOptions { - source_map: true, - ..Default::default() - }, + CompileOptions::default().with_source_map(true), ) .expect("compilation must succeed even when cap is hit"); @@ -1061,19 +1046,9 @@ fn extends_output_byte_identical_with_and_without_source_map() { let with_map = vfs_opts( modules.clone(), "child.mds", - CompileOptions { - source_map: true, - ..Default::default() - }, - ); - let without_map = vfs_opts( - modules, - "child.mds", - CompileOptions { - source_map: false, - ..Default::default() - }, + CompileOptions::default().with_source_map(true), ); + let without_map = vfs_opts(modules, "child.mds", CompileOptions::default()); // ADR-002: byte-identical output regardless of source-map mode. assert_eq!( @@ -1136,10 +1111,7 @@ fn for_max_total_iterations_across_extends_regions_source_map() { modules, "child.mds", Some(vars), - CompileOptions { - source_map: true, - ..Default::default() - }, + CompileOptions::default().with_source_map(true), ) .expect_err( "REL-1: cumulative iteration budget across @extends regions must trip MAX_TOTAL_ITERATIONS", @@ -1167,11 +1139,7 @@ fn d1_string_source_sources_label_is_input_mds() { "Hello World!\n", None, None, - CompileOptions { - source_map: true, - include_sources_content: false, - ..Default::default() - }, + CompileOptions::default().with_source_map(true), ) .expect("should compile"); let sm = result.source_map.expect("source_map must be present"); @@ -1206,11 +1174,7 @@ fn d1_s8_locally_defined_function_no_source_sentinel() { "@define greet():\nHello!\n@end\n{{greet()}}\n", None, None, - CompileOptions { - source_map: true, - include_sources_content: false, - ..Default::default() - }, + CompileOptions::default().with_source_map(true), ) .expect("should compile"); let sm = result.source_map.expect("source_map must be present"); @@ -1245,11 +1209,7 @@ fn d1_extends_from_string_no_source_sentinel() { child, Some(dir.path()), None, - CompileOptions { - source_map: true, - include_sources_content: false, - ..Default::default() - }, + CompileOptions::default().with_source_map(true), ) .expect("should compile"); let sm = result.source_map.expect("source_map must be present"); diff --git a/crates/mds-core/tests/virtual_fs.rs b/crates/mds-core/tests/virtual_fs.rs index da35247..4a8058a 100644 --- a/crates/mds-core/tests/virtual_fs.rs +++ b/crates/mds-core/tests/virtual_fs.rs @@ -1529,11 +1529,7 @@ fn source_map_extends_type_mismatch_span_not_misattributed_to_child() { modules, "child.mds", None, - mds::CompileOptions { - source_map: true, - include_sources_content: false, - ..Default::default() - }, + mds::CompileOptions::default().with_source_map(true), ) .expect_err("cross-type mismatch in an inherited @if must error"); let serialized = err.serialize(); @@ -1569,11 +1565,7 @@ fn source_map_standalone_type_mismatch_carries_span() { src, None, None, - mds::CompileOptions { - source_map: true, - include_sources_content: false, - ..Default::default() - }, + mds::CompileOptions::default().with_source_map(true), ) .expect_err("cross-type == in @if must fail"); let serialized = err.serialize(); From 42a5d7ff4997fd12d933ed6a3ba880626a0c9c74 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 9 Aug 2026 10:32:09 +0200 Subject: [PATCH 6/7] docs(changelog): add sanitized_for_render, fix TextEdit re-export note, fix FixPlan mutation claim [#259] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the [Unreleased] section to reflect the new APIs: - LintResult: new(diagnostics) + .truncated()/.standalone() builder chain - TextEdit: document that it was previously unnameable from external crates (was pub inside a pub(crate) module); this PR re-exports at crate root - FixLineSpan: range_inclusive / range_exclusive instead of range(from, to, bool) - ByteEdit: deletion / replacement instead of new(start, end, rule, replacement) - FixPlan: clarify fields are pub (not just mutation methods) - LintConfig: from_rules instead of with_rules - Add sanitized_for_render() entry (PF-014 redesign — render-boundary escape logic now lives co-located with the struct definition) --- CHANGELOG.md | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91bb323..577dc20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,15 +55,28 @@ The following types are marked `#[non_exhaustive]` so future minor releases can fields without a breaking change. External Rust crates can no longer construct them via struct literals. Use the named constructor or builder listed for each: -- **`LintResult`** — use `LintResult::new(diagnostics, truncated, is_standalone)`. +- **`LintResult`** — use `LintResult::new(diagnostics)` (defaults: `truncated=false`, `is_standalone=false`), + then chain `.truncated()` or `.standalone()` to override. - **`SerializedError`** — not externally constructable by design; obtain via `MdsError::serialize()`. - **`SerializedSpan`** — use `SerializedSpan::new(offset, length)`, then chain `.with_line(n)` and/or `.with_column(n)`. -- **`TextEdit`** — use `TextEdit::new(start, end, new_text)`. -- **`FixLineSpan`** — use `FixLineSpan::single(offset)` for single-line removals or the new `FixLineSpan::range(from, to, to_inclusive)` for multi-line spans. -- **`ByteEdit`** — use `ByteEdit::new(start, end, rule, replacement)`. +- **`TextEdit`** — use `TextEdit::new(start, end, new_text)`. Previously this type was `pub` + inside a `pub(crate)` module and was thus unnameable from external crates; this PR re-exports + it at the crate root, making `mds::TextEdit` accessible for the first time. The `fix_edits` + field on `LintDiagnostic` (and the corresponding JSON field) was effectively unusable from + Rust until this change. +- **`FixLineSpan`** — use `FixLineSpan::single(offset)` for single-line removals, + `FixLineSpan::range_inclusive(from, to)` to remove through the line containing `to`, + or `FixLineSpan::range_exclusive(from, to)` to keep the line containing `to`. +- **`ByteEdit`** — use `ByteEdit::deletion(start, end, rule)` for pure deletions or + `ByteEdit::replacement(start, end, rule, text)` for in-place replacements. - **`RejectedEdit`** — use `RejectedEdit::new(edit, reason)`. -- **`FixPlan`** — use `FixPlan::default()` for an empty plan; all mutation methods remain available. -- **`LintConfig`** — use `LintConfig::with_rules(rules)` or `LintConfig::default()` for no overrides. +- **`FixPlan`** — use `FixPlan::default()` for an empty plan; its fields are `pub`, so they + remain directly readable and writable from external crates. +- **`LintConfig`** — use `LintConfig::from_rules(rules)` or `LintConfig::default()` for no overrides. +- **`LintDiagnostic::sanitized_for_render()`** — a new method that returns a sanitized clone + suitable for miette render boundaries. `mds-cli`'s diagnostic render path now delegates to + this method instead of assembling sanitized copies itself, keeping the escape logic co-located + with the struct definition (PF-014). #### New `fix_edits` field on `LintDiagnostic` From b94ef8088e9a7bdc497e2973e6bcb4cd0488a312 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 9 Aug 2026 10:39:27 +0200 Subject: [PATCH 7/7] fix(docs): remove literal ESC byte from T-SFR-1 doc comment [#259] --- crates/mds-core/src/lint/diagnostic.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mds-core/src/lint/diagnostic.rs b/crates/mds-core/src/lint/diagnostic.rs index 59e698b..64940c5 100644 --- a/crates/mds-core/src/lint/diagnostic.rs +++ b/crates/mds-core/src/lint/diagnostic.rs @@ -1938,7 +1938,7 @@ mod tests { /// /// Pins the HUMAN-mode choice: `\n` in a diagnostic body is legitimate prose /// punctuation (multi-line miette frame) and must survive the round-trip. - /// ESC (U+001B) is a hostile control and must be replaced by ``. + /// ESC (U+001B) is a hostile control and must be replaced by `\\u001B`. #[test] fn sanitized_for_render_escapes_esc_preserves_newline() { let diag = LintDiagnostic {