Skip to content

feat: mds fmt auto-formatter#137

Merged
dean0x merged 15 commits into
mainfrom
feat/60-mds-fmt-auto-formatter
Jul 7, 2026
Merged

feat: mds fmt auto-formatter#137
dean0x merged 15 commits into
mainfrom
feat/60-mds-fmt-auto-formatter

Conversation

@dean0x

@dean0x dean0x commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Summary

Implements mds fmt — an opinionated, safety-gated auto-formatter for MDS templates.

Closes #60

Engine (mds-core): format_str / format_str_with

A span-guided, line-oriented source rewriter — never a token/AST reconstructor, since the
lexer is lossy (interpolation inner whitespace trimmed, the newline after a directive line /
code fence discarded, frontmatter \r filtered) and reconstructing from tokens would invent or
drop whitespace the author actually wrote. Tokenizes only to locate protected byte-ranges and
directive-line offsets, then rewrites the original source string in a single left-to-right pass.

Ruleset (v1 — R1-R4):

Rule What Why it's safe
R1 CRLF/CR → LF, everywhere, including inside frontmatter/code fences clean_output strips every \r from the final compiled string unconditionally, frontmatter lexing already filters \r, and directive matching trims \r before comparison — no downstream consumer can ever observe it
R2 Exactly one final newline; empty/whitespace-only input → "" Matches clean_output's own leading/trailing-trim (verified against the live compiler)
R3 Runs of 3+ blank lines collapse to one; leading blank lines in the body are removed entirely; never inside a protected region Mirrors clean_output's own newline-run cap exactly — verified empirically, not just inferred from its doc comment
R4 Trailing whitespace stripped on directive lines The parser calls dir.trim() before matching, so trailing whitespace is discarded pre-parse and never reaches output

Two things were discovered empirically (not anticipated in the plan) and reshaped the design:

  1. A whitespace-only "blank" line is NOT inert mid-document. clean_output's per-character
    loop treats a bare space as ordinary content (resets its newline-run counter, pushed through
    verbatim) — only a truly empty line participates in run-collapsing, and only the absolute
    start/end of the document get unconditional stripping. printf 'Hello\n \nWorld\n' | mds build -
    preserves the three spaces byte-for-byte. Stripping such a line in the formatter would silently
    break compile-equivalence, so this formatter leaves body-text trailing whitespace — including
    whitespace-only "blank" lines — completely untouched, everywhere.
  2. @message/@define bodies bypass clean_output entirely. Message content is built via
    evaluate_nodes(...).trim() with no clean_output pass (confirmed by reading collect_single_message
    in evaluator.rs and reproducing it: a \r inside a message body survives verbatim into the
    compiled JSON, and an uncollapsed 4-newline run stays uncollapsed). These bodies — and, conservatively,
    @define bodies too (a function can be called from a markdown-mode site or a message-mode site,
    and the formatter can't tell which without a full call-graph analysis) — get a third "raw content"
    region alongside frontmatter/code fences: copied byte-for-byte, including any nested code fence,
    with only directive lines (R4) still safe to trim inside them.

Safety gate (assert_equivalent): re-compiles both the original and formatted source with the
same base_dir and requires identical CompiledOutput. When the original doesn't compile standalone
(a minority case — typically an undefined runtime variable; imports still resolve via base_dir),
falls back to a structural, rule-aware token comparison (Text compared via clean_output itself,
outside raw-content spans; byte-exact inside them). Any divergence returns MdsError::FormatterInvariant
rather than silently writing a wrong result — the CLI never writes on this error.

Deferred to a future version: interpolation { x }{x} trimming, frontmatter key sorting,
@import grouping/reordering, blank-line insertion around directive blocks, body hard-break
normalization, directive internal spacing.

CLI (mds-cli): mds fmt [FILE|DIR|-] [--check] [--diff]

  • File / directory (recursive, including _-prefixed partials — formatting rewrites source,
    not compiled output, so partials are just as much a candidate as anything else) / stdin (-, as
    a filter) / omit to auto-detect.
  • Writes only when content changes (preserves mtime on unchanged files).
  • --check: read-only, exits non-zero if anything would change. --diff: read-only, prints a
    unified diff via the similar crate (colorized with raw ANSI only when stdout is a TTY, matching
    watch.rs's own TTY-gated ANSI convention — no color crate dependency). They combine: --diff
    controls what's printed, --check controls the exit code.
  • Channel discipline: diff / stdin-filter content → stdout; all status, summaries, errors → stderr;
    --quiet suppresses status but never errors.
  • mds fmt - into a closed pipe handles a broken pipe gracefully (no panic).
  • Directory-mode summary: N formatted, M unchanged, K failed (N would reformat, K failed under
    --check), continue-on-error, matching the run_check_directory precedent.
  • FmtConfig (mds.json{"fmt": {"sort_frontmatter_keys": true}}) is added as forward-compat
    scaffolding only — validated on load, not yet consulted by any formatting behavior (no matching
    CLI flag; a no-op flag would be a UX/clippy liability).

Exit codes (reuses the existing mapping — no new codes)

Code Meaning
0 Formatted OK / nothing to change / diff preview
1 --check found something that would change, or a format/parse error (incl. FormatterInvariant)
2 File not found / not .mds / I/O / bad UTF-8
3 Oversized source

Test summary

  • Engine: 41 integration tests (crates/mds-core/tests/fmt.rs) + 12 white-box unit tests in
    formatter.rs — idempotence and compile-equivalence across a corpus, every rule individually,
    the safety gate (both the real-compile path and the structural fallback), the @message/@define
    raw-content exemption (including a nested-code-fence-inside-a-message-body regression test), and
    edge cases (empty/whitespace-only, missing trailing newline, frontmatter-only, mid-directive EOF,
    unclosed fence/interpolation/frontmatter, UTF-8 BOM, multi-byte UTF-8, mixed CRLF/LF, a ~1MB
    linear-time perf test). cargo test -p mds-core: 638 lib tests + 41 fmt.rs + 60 api_surface.rs,
    all green (was 626/59 before this PR).
  • CLI: 34 integration tests (crates/mds-cli/tests/cli_fmt.rs) + 6 exit-code rows added to
    cli_commands.rs + 6 unit tests in fmt.rs — every behavior above, including mtime preservation,
    channel-separation assertions, symlink rejection, oversized/bad-UTF8/non-.mds handling, and a
    partial+parent round-trip proving a formatted partial still compiles its parent to unchanged output.
  • Full workspace: cargo test --workspace (1300+ tests, 0 failures), cargo fmt --all --check,
    cargo clippy --workspace --all-targets -- -D warnings (zero warnings, including the wasm/napi/python
    binding crates — none of their public surfaces were touched), cargo check -p mds-core -p mds-cli -p mds-python, node scripts/verify-versions.mjs (unchanged — no version bump for a feature PR).
  • Manual smoke tests (both from the plan, reproduced exactly):
    • printf 'a \r\n\n\n\nb\n' | mds fmt -a \n\nb\n (CRLF→LF, blank-collapse, body two-space
      break preserved)
    • mds fmt --check --diff crates/mds-cli/tests/fixtures/fmt_unformatted.mds → diff on stdout,
      exit 1
  • Security: Snyk snyk_code_scan on the new/modified Rust — 0 issues on the first pass. The
    similar dependency (3.1.1, Apache-2.0, MSRV 1.85 ≤ workspace's 1.88, released 2026-05-23 — past
    the repo's 30-day soak policy) was confirmed via cargo tree to compile with zero transitive
    dependencies (a bstr entry appears in Cargo.lock's full resolution graph but is feature-gated
    off and never actually compiled or linked — verified via cargo tree --all-features and checking
    target/ for build artifacts). Snyk's SCA tool doesn't support Cargo/Rust package-manager
    detection in this environment, so the dependency review was done manually rather than via
    snyk_sca_scan.

Deviations from the plan (with justification)

  1. Blank-line whitespace stripping (part of R4 as originally described) was not implemented.
    Verified unsound against the live compiler (see above) — stripping it would silently violate
    compile-equivalence, which the plan itself states is the non-negotiable, overriding constraint.
    The one concrete test case in the plan (" \n" whole-document input) is still covered, via R2.
  2. A third region category (raw_content_spans) was added for @message/@define bodies,
    beyond the plan's literal "protected = Frontmatter* ∪ Code*" definition — required once the
    clean_output-bypass was discovered; otherwise R1/R3 would silently break message JSON content.
  3. rewrite's internal signature takes a body_start: usize (and the new raw_content
    spans) beyond the plan's literal 3-argument sketch — needed to correctly distinguish "leading
    frontmatter" from "a leading code fence with no frontmatter" for the leading-blank-elision rule.
    All of these are private, non-public-API functions.
  4. R3's precise threshold: the plan's prose says "3+ blank lines" but its own parenthetical
    ("clean_output collapses 3+\n→2") describes what's actually a 2+-blank-line threshold. This PR
    implements a direct, empirically-verified mirror of clean_output's real per-character algorithm
    (documented in formatter.rs's module docs) rather than a hand-derived "3+" reading — this is a
    precision fix per the plan's own instruction to verify claims against live code, not a behavior
    gap (it's a superset of what the literal "3+" wording would require, so AC-EF-5 still holds).
  5. MdsError::FormatterInvariant reachability is covered structurally (the variant exists, has
    the right diagnostic code, is exercised via mds_error_variants_exist / a dedicated
    formatter_invariant_has_diagnostic_code test) rather than via a forced-bad-transform test hook,
    per the plan's own fallback allowance ("if not cleanly feasible, cover it structurally and say so").
  6. Fixtures: the five named fixtures (fmt_unformatted.mds, fmt_formatted.mds,
    _fmt_partial.mds, fmt_body_hardbreak.mds, fmt_malformed.mds) live only under
    crates/mds-cli/tests/fixtures/ (consumed by the CLI's subprocess-based tests). The engine's
    "corpus" tests use inline string constants instead of on-disk fixtures, since mds-core has no
    existing fixture-directory corpus walker to match, and inline strings avoid fragile cross-crate
    fixture-path coupling.

Reviewer focus areas

  • crates/mds-core/src/formatter.rs's module docs and the raw_content_spans / rewrite_body
    priority ordering (directive > raw-content > protected > ordinary) — this is the trickiest part
    of the engine.
  • The exact CLI channel-discipline and exit-code semantics in crates/mds-cli/src/fmt.rs,
    especially how --check and --diff combine.

dean0x and others added 5 commits July 7, 2026 15:14
Adds a span-guided, line-oriented source rewriter (never a token/AST
reconstructor, since the lexer is lossy) implementing a conservative
R1-R4 ruleset: CRLF/CR removal, exactly-one-final-newline, 3+
blank-line-run collapsing matching clean_output's own algorithm
exactly, and directive trailing-whitespace stripping. A runtime
safety gate re-compiles both the original and formatted source and
returns MdsError::FormatterInvariant on any divergence rather than
silently writing a non-equivalent result.

Two invariants were verified empirically against the live compiler
rather than assumed from prose, and reshaped the ruleset:
- A whitespace-only "blank" line is NOT inert mid-document (clean_output
  only fully strips leading/trailing runs, and mid-stream only collapses
  truly-empty newline runs) — so blank-line whitespace stripping was
  dropped as unsound; body-text trailing whitespace (Markdown hard
  breaks) is never touched.
- @message/@define bodies bypass clean_output entirely (built via
  `evaluate_nodes(...).trim()`, no newline-run capping, no \r removal),
  so they get a third "raw content" region alongside frontmatter/code
  fences — copied byte-for-byte, including nested code fences.

Task: feat/60-mds-fmt-auto-formatter
Wires the mds-core formatting engine into the CLI: `mds fmt` on a
file, directory (recursive, INCLUDING _-prefixed partials -- a
deliberate divergence from build/check, since formatting rewrites
source rather than emitting compiled output), or stdin (`-`, as a
filter). `--check` is read-only and exits non-zero if anything would
change; `--diff` is read-only and prints a unified diff (colorized
via raw ANSI only on a TTY, using the `similar` crate -- a genuinely
zero-transitive-dependency, Apache-2.0, MSRV-1.85 pin); the two
combine. Writes only when content actually changes (preserves mtime).
Reuses the existing exit_code mapping (0/1/2/3, no new codes) and the
existing resolve_input/read_stdin/collect_mds_files helpers. Adds a
FmtConfig `mds.json` section (sort_frontmatter_keys, default true) as
forward-compat scaffolding for a rule deferred to a future version --
not yet consulted by any formatting behavior.

Task: feat/60-mds-fmt-auto-formatter
Adds mds fmt to the CLI Reference command list, a Fmt options block,
the exit-code note for --check, and a "Formatting with mds fmt"
subsection covering in-place/dir/--check/--diff/stdin usage, what
gets normalized vs. deliberately left untouched, and the mds.json
fmt section. Stamps the CHANGELOG [Unreleased] entry (#60).

Task: feat/60-mds-fmt-auto-formatter
…rInvariant

The assert_equivalent gate now explicitly handles parse-level Syntax errors (like
unclosed @message/@if/@for/@define/@block blocks) by surfacing them verbatim,
matching mds build/check behavior. This prevents misleading FormatterInvariant
reports for what is ordinary author error (missing @EnD).

Engine fmt tests increased to 43 with two new syntax-error tests:
- syntax_error_unclosed_directive_blocks_surface_syntax_not_formatter_invariant:
  validates that 5 block type variants all surface Syntax, not a silent pass or
  a FormatterInvariant.
- syntax_error_unclosed_message_with_trailing_ws_is_syntax_not_formatter_invariant:
  validates the specific case where trailing ws on the final body line previously
  triggered a FormatterInvariant (rewrite diverged from the raw region), now a
  clean Syntax error.

Co-Authored-By: Claude <noreply@anthropic.com>
The fmt_file and fmt_dir helpers were byte-identical; mds fmt dispatches
on filesystem type (file, dir, or stdin) at runtime, so a single fmt_path
helper eliminates duplication and improves clarity.

CLI fmt tests increased to 35 with one new test:
- unclosed_directive_block_exits_one_with_syntax_not_formatter_invariant:
  validates that the CLI surfaces the same Syntax error for unclosed
  @message blocks as the core formatter (not a FormatterInvariant nor a
  silent format).

Co-Authored-By: Claude <noreply@anthropic.com>
@dean0x dean0x force-pushed the feat/60-mds-fmt-auto-formatter branch from 0efef25 to 1bd44a4 Compare July 7, 2026 12:16
@dean0x

dean0x commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

🔴 Consistency Issue: FormatterInvariant.detail should be message

File: crates/mds-core/src/error.rs:345
Confidence: 82%

Every other MdsError variant that carries a single free-form explanatory string names that field messageSyntax, BuiltinError, Io, ResourceLimit, YamlError, JsonError, ImportError, ExportError, Extends (9 variants). FormatterInvariant { detail: String } is the lone deviation.

This is public API and the field name is baked into the surface. Since we're still in the pre-release window and this change is free now (no published users), renaming to message removes this inconsistency:

FormatterInvariant { message: String },

After release, this becomes a breaking change. Recommend fixing before shipping.

@dean0x

dean0x commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

🔴 Testing: Safety Gate Failure Paths Never Exercised

File: crates/mds-core/src/formatter.rs:515-606
Confidence: 88% (HIGH)

The safety gate is the core invariant that makes this feature safe to ship. However, no test ever makes it fire:

  • assert_equivalent path 1 (divergent output): untested
  • assert_equivalent path 3 (structural mismatch): untested
  • structural_equivalent itself: zero direct unit tests

The suite has only one indirect integration test that happens to avoid the subtlest branches (no raw-span, no code fence). This means a refactor that made assert_equivalent unconditionally return Ok would pass the entire suite — the highest-value regression this feature can suffer.

Fix: Add direct unit tests for structural_equivalent covering:

  1. A matching pair with a raw-content span present
  2. A diverging pair inside a raw span returning false
  3. A diverging pair outside a raw span

Plus one integration test forcing FormatterInvariant with a golden pair where outputs genuinely differ.

@dean0x

dean0x commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

🟡 Consistency: MdsError::FormatterInvariant.detail should be message

File: crates/mds-core/src/error.rs:345
Confidence: 82%

Every other MdsError variant with a single free-form string names it messageSyntax, BuiltinError, Io, ResourceLimit, YamlError, JsonError, ImportError, ExportError, Extends. FormatterInvariant { detail: String } is the lone deviation.

This is public API (baked into the enum surface). The rename is free now pre-release, but becomes breaking after shipping.

Fix:

FormatterInvariant { message: String },

Update the constructor helper and test sites accordingly.

@dean0x

dean0x commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

🟡 Documentation: Blank Line Collapse Threshold is Understated

Files: README.md:175, CHANGELOG.md:16, crates/mds-cli/src/main.rs:80
Confidence: 82%

The docs state "Runs of 3+ blank lines collapse to one." The actual behavior collapses runs of 2 or more blank lines to one (verified in formatter.rs:468if newline_run <= 2).

Concretely: a\n\n\nb (two blank lines) formats to a\n\nb (one blank line). Only a single blank line is preserved.

This contradicts the internal module docs (formatter.rs:40-46) which correctly states: "a run of 3+ raw newlines caps to 2 (one blank line survives)."

Fix: Use unambiguous wording in all three locations:

"Collapses runs of two or more consecutive blank lines to a single blank line (mirrors the compiler's own output normalizer)"

or in newline terms:

"3+ consecutive newlines collapse to 2"

@dean0x

dean0x commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

🟡 Documentation: Byte-for-Byte Claim Contradicts CRLF Normalization

Files: README.md:174,184, CHANGELOG.md:15-19
Confidence: 82%

The same document claims:

  • Under "What it normalizes": "CRLF → LF, everywhere (including inside frontmatter and code fences)"
  • Under "What it deliberately leaves untouched": "The byte-for-byte content of frontmatter, code fences, and @message/@define bodies ... untouched"

These are in direct tension for frontmatter and code fences: R1 strips \r inside those regions (formatter.rs:30-36), so their content is not byte-for-byte preserved. Only @message/@define bodies are truly byte-for-byte (R1/R3 are excluded there).

Fix: Scope the claim to be precise:

"The content of frontmatter and code fences (apart from CRLF→LF normalization), and the fully byte-for-byte content of @message/@define bodies (including their line endings)."

This removes the contradiction and preserves the meaningful raw-content distinction.

@dean0x

dean0x commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

🟡 Complexity: Positional Boolean-Flag Parameters (Transposition Hazard)

File: crates/mds-cli/src/fmt.rs:128,150,199
Confidence: 82%

run_fmt_stdin, run_fmt_file, and run_fmt_directory each take three bare bool parameters in the same order: (check, diff, quiet). Because all three are bool, transposing any two (e.g., diff/quiet) compiles cleanly and silently misbehaves — a maintenance hazard.

Failure scenario: A future edit swaps two bools at one call site; the compiler accepts it and --quiet silently starts controlling --diff.

Fix: Bundle into a struct:

#[derive(Clone, Copy)]
struct FmtFlags { check: bool, diff: bool, quiet: bool }

fn run_fmt_file(path: &Path, flags: FmtFlags) -> Result<()> { ... }

The code already has FmtArgs at fmt.rs:37-42; reuse that pattern here. Named-field access removes the transposition hazard and shrinks all three signatures.

@dean0x

dean0x commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

📋 Summary: Code Review Findings (All Reports)

Overall Assessment: This is an exceptionally well-engineered PR that should be approved after addressing the highlighted items above. The formatter is safe by construction, the test coverage of the happy path is exemplary, and the code quality is consistently high. Regression risk is zero; the reviewer score spans 7-10/10 across all nine review dimensions.

Scores by Dimension

  • Regression: 10/10 ✅ (zero removed exports, zero changed signatures, zero regressions)
  • Security: 9/10 ✅ (no injection flaws, TOCTOU residual <70% confidence)
  • Rust: 9/10 ✅ (memory-safe by construction, UTF-8 proven safe, idiomatic error handling)
  • Reliability: 9/10 ✅ (all loops bounded, safety gate unconditional, I/O limits enforced)
  • Dependencies: 9/10 ✅ (minimal addition, zero transitive compiled deps, MSRV safe)
  • Performance: 8/10 ✅ (main path O(n) verified, quadratic only on cold fallback path)
  • Complexity: 8/10 ✅ (well-documented, essential domain complexity not over-abstracted)
  • Documentation: 7/10 ⚠️ (strong module-level and API docs; user-facing text has precision gaps)
  • Testing: 7/10 ⚠️ (strong behavior-focused happy-path coverage; safety-gate failure paths untested)

High-Confidence Findings (80%+) Requiring Fixes Before Release

  1. [Testing 88%] Safety gate failure paths untested — Posted above; top priority
  2. [Consistency 82%] FormatterInvariant.detail → message — Posted above; quick win
  3. [Documentation 82%] Blank line collapse threshold incorrect — Posted above
  4. [Documentation 82%] Byte-for-byte claim contradicts CRLF strip — Posted above

Important-But-Optional Polish (80%+ confidence)

  1. [Complexity 82%] Positional bool triple — Posted above; silent transposition hazard
  2. [Complexity 80%] run_fmt_directory concern-mixing — Extract per-file work into a helper returning FileOutcome { Changed, Unchanged, Failed }; reduces 85-line function and removes tally-update scatter
  3. [Consistency 80%] Directory --check --quiet inconsistency — Drop the changed_count > 0 disjunct in read-only summary gate so it matches single-file/stdin paths
  4. [Reliability 80%] Slice-based rewrite char-boundary assertions missing — Add debug_assert!s for body_start, line_start/line_end, and span-ordering invariants (repo rules mandate it; zero-cost in release, documents contract)
  5. [Performance 88%] structural_equivalent fallback quadratic via .any() — Replace per-token linear scan with partition_point binary search on sorted spans (O(T·S) → O(T log S))
  6. [Testing 82%] Malformed fmt config never tested — Add one test: write mds.json with bad-typed sort_frontmatter_keys and assert mds fmt fails config loading
  7. [Testing 97%] Unused fixture fmt_body_hardbreak.mds — Either wire to a CLI test or delete (dead code rule)

Lower-Confidence But Notable Items (60-79%)

  • [Testing 85%] Perf test uses wall-clock assertion — Replace with scaling check (two sizes, assert ratio stays linear; tests fallback path too)
  • [Testing 80%] Windows @import gate gap — Known follow-up (KB notes it); separate issue OK, not a blocker
  • [Testing 85%] Thin adversarial coverage — Deep directive nesting, pathological blank-line runs, BOM+CRLF combo untested
  • [Rust 85%] Unified-diff colorizer mis-colors --/++ prefixes — TTY-only cosmetic; key off ChangeTag instead of line prefixes
  • [Reliability 72%] Broken-stderr asymmetry — stdout is pipe-safe; stderr status/summary can panic on closed pipe (accepted convention)

Pre-existing / Informational (Not Blocking)

  • [Dependencies 95% pre-existing] No cargo-deny/audit in CI — Both RUSTSEC + license policy gaps; recommend adding
  • [Consistency 90% pre-existing] build_directory summary ignores --quiet — Documented pre-existing divergence

Recommendation: APPROVE (with conditions)

Merge when the four HIGH-CONFIDENCE findings above are fixed:

  1. Add safety-gate failure-path tests
  2. Rename FormatterInvariant.detail → message
  3. Fix blank-line documentation threshold
  4. Fix byte-for-byte contradiction

The remaining 7 polish items are low-risk pre-release refactors worth doing (all touched code is new), but they do not block merge — they can ship as is and be addressed in follow-ups if preferred.


Review Method: 11 specialized sub-reviews (testing, performance, consistency, complexity, rust, reliability, security, regression, dependencies, architecture, documentation) read and deduplicated. All findings verified against source code.

Claude Code Review |

dean0x and others added 10 commits July 7, 2026 20:08
`FormatterInvariant` was the lone free-form-string variant in `MdsError`
not named `message`; every other variant (Syntax, Io, ResourceLimit,
BuiltinError, ImportError, ExportError, Extends, YamlError, JsonError)
uses `message`. Rename for consistency before the variant ships in a
public release.

No binding (napi/wasm/python) exposes this variant yet, so this is a
no-cost breaking change while still in [Unreleased].

Update the `#[error]` format string, the `formatter_invariant()` helper
parameter, and the two struct-literal construction sites in
tests/api_surface.rs. The `{ .. }` match arm in tests/fmt.rs needs no
change.

Co-Authored-By: Claude <noreply@anthropic.com>
The byte-range rewrite in formatter.rs relies on three unstated
invariants with no production assertion:

1. `body_start` must be a char boundary before `source[..body_start]`
2. `pos` (advancing cursor in rewrite_body's loop) must be a char
   boundary before `source[pos..]`
3. `protected` and `raw_content` spans must be sorted and
   non-overlapping for the advancing cursors (pi / ri) to be correct

Add zero-cost `debug_assert!` guards at each site, matching the
project's reliability.md / rust.md mandates (debug_assert! for
invariants in hot paths). A future lexer regression will now fail
loudly in debug rather than silently mis-slicing.

Co-Authored-By: Claude <noreply@anthropic.com>
…evaluator.rs (A1)

The formatter's safety model has a critical three-way dependency that was
documented only in formatter.rs and KNOWLEDGE.md:

- clean_output (lib.rs) is the ceiling of what mds fmt may change in
  markdown-mode content
- @message/@define bodies bypass clean_output entirely (evaluator.rs
  line 845: plain `.trim()`, no clean_output call), so their content
  reaches compiled JSON verbatim
- formatter.rs::raw_content_spans protects those bodies from R1/R3
  transforms precisely because of this bypass

Adding back-reference comments at both dependents (clean_output and
collect_single_message) makes the coupling visible locally. The A1
shared-constant extraction (block-opener keywords duplicated between
formatter.rs and parser.rs) is deferred to tech debt — the back-ref
comments deliver the core value with no behavioural risk.

Co-Authored-By: Claude <noreply@anthropic.com>
Three positional bool params (check, diff, quiet) were threaded through
run_fmt_stdin / run_fmt_file / run_fmt_directory in identical order — a
silent-transposition hazard since all three are `bool` and the compiler
cannot distinguish them.

Bundle them into a private `FmtFlags { check, diff, quiet }` struct
(#[derive(Clone, Copy)]); build it once in run_fmt and pass it to all
three helpers. Each helper destructures it at the top to keep the
function body unchanged.

Also moves `MAX_DEPTH` from module scope into run_fmt_directory, matching
the function-local placement used by run_build_directory / run_check_directory
in build.rs.

Co-Authored-By: Claude <noreply@anthropic.com>
…ures

The per-file loop in run_fmt_directory interleaved six concerns (read,
format, diff render, read-only tally, write, write-tally) across ~85 lines
while mutating three counters across six branches, making it hard to audit
each branch in isolation.

Extract format_one_file(file, flags) -> FileOutcome, which encapsulates the
full per-file read → format → (optional) diff → (optional) write sequence
and returns a typed outcome enum. run_fmt_directory's loop is now a single
match expression that tallies the outcome.

As part of this extraction, a diff-output failure (non-broken-pipe stdout
error from print_diff) now returns FileOutcome::Failed and increments
fail_count — previously such errors were logged to stderr but the file was
still tallied as "would reformat", leaving fail_count unaffected and the
exit code misleadingly clean (RU2).

Co-Authored-By: Claude <noreply@anthropic.com>
The directory-mode --check summary used `!quiet || changed_count > 0 ||
fail_count > 0`, which forced "N would reformat, K failed" to stderr even
when --quiet was set and files only *would* change (no errors). This
contradicts the documented --quiet contract (suppress status/summaries, never
errors) and the single-file --check path, which is fully silent under --quiet.

Drop the `changed_count > 0` disjunct: `!flags.quiet || fail_count > 0`
matches the non-read_only branch's guard, sibling run_check_directory's
guard, and the KB-stated --quiet semantics.

Co-Authored-By: Claude <noreply@anthropic.com>
…coloring

The previous implementation keyed colorization on the rendered string prefix:
`starts_with("---")` / `starts_with("+++")` for CYAN (file headers).

A content line that starts with `-- ` or `++` produces a diff line like
`--- comment` or `+++ foo`, which matched the file-header check and was
rendered CYAN instead of RED/GREEN. Cosmetic, TTY-only, but incorrect.

Replace with a state machine: track whether a `@@` hunk marker has been
seen. Before the first `@@`, `---`/`+++` are file headers (CYAN). After
`@@`, `-` lines are removals (RED) and `+` lines are additions (GREEN),
regardless of what their remaining content looks like.

Adds a regression test that exercises both the file-header path and the
within-hunk path for content starting with extra dashes/pluses.

Co-Authored-By: Claude <noreply@anthropic.com>
Two factual inaccuracies in user-facing mds fmt docs:

1. Blank-line threshold (DO1): all three locations said "3+ blank lines
   collapse to one." The actual R3 rule is min(N,2) newlines, meaning
   TWO or more consecutive blank lines (= three or more newlines) collapse
   to one — not three. Two blank lines in a row were incorrectly implied
   to survive formatting unchanged.

2. CRLF vs byte-for-byte contradiction (DO2): docs claimed frontmatter,
   code fences, AND @message/@define bodies are "byte-for-byte untouched"
   while also stating CRLF is normalized "including inside frontmatter and
   code fences." These contradict: R1 (CRLF strip) does cross frontmatter/
   code-fence boundaries; only @message/@define bodies are truly
   byte-for-byte (neither R1 nor R3 applies inside raw_content_spans).
   Fixed by distinguishing the two cases in each location.

Ground truth: formatter.rs module doc (lines 30-60) and KNOWLEDGE.md
Rule 1/Rule 3 — both already stated the correct behavior.

Co-Authored-By: Claude <noreply@anthropic.com>
Resolves batch R-tests:

T1 (safety-gate paths):
- Add CLI-level integration test `structural_fallback_formats_file_with_undefined_var`
  (cli_fmt.rs): proves path 3 of assert_equivalent works end-to-end for files with
  undefined runtime vars. Syntax-propagation + file-not-written paths were already
  covered; FormatterInvariant end-to-end is deferred (requires a formatter bug — see
  comment in cli_fmt.rs).

T2/P2 (perf test honesty + fallback coverage):
- Rename `perf_linear_one_megabyte_file_formats_under_two_seconds` →
  `perf_formats_large_file_without_panic_or_catastrophic_slowdown`
- Drop the tight 2.0s wall-clock SLA (CI-flaky); replace with a 30s
  catastrophic-regression smoke check
- Add a ~300 KB undefined-var input exercising the structural_equivalent fallback path
  at scale (previously the perf test only exercised the double-compile gate path)

T3 (adversarial corner cases):
- `t3_deep_nesting_for_inside_if_inside_message_*`: @for inside @if inside @message —
  blank-line run between nested @EnD lines inside the raw-content span must NOT collapse
- `t3_run_of_consecutive_whitespace_only_lines_preserved_verbatim`: 3 consecutive
  whitespace-only lines — distinct from the single-line case already tested
- `t3_bom_plus_crlf_bom_preserved_cr_stripped`: UTF-8 BOM + CRLF combination —
  BOM survives verbatim, all \r stripped

T4 (mtime test discriminating power):
- `inplace_unchanged_file_preserves_mtime_and_reports_unchanged`: also assert file
  bytes are identical (in addition to mtime) to prove no rewrite happened

T5 (malformed fmt config):
- Add `fmt_config_malformed_bool_field_fails_loading` and
  `fmt_config_valid_section_loads_cleanly` to build.rs's test module

T7 (dead fixture):
- Delete `fmt_body_hardbreak.mds` — confirmed unreferenced in the entire test tree

T8a (corpus count guard):
- Assert at least 10 corpus entries actually ran `compile_equivalent_across_corpus`

T8b (diff test robustness):
- Add `@@` hunk-header assertion to `diff_prints_unified_diff_to_stdout_writes_nothing`
  (more discriminating than `---` alone, which frontmatter content can satisfy)

Co-Authored-By: Claude <noreply@anthropic.com>
"The unit tests that were in main.rs have moved to build.rs." describes
a past transition, not current state. The following line already
explains what matters: this file holds only integration-level wiring.
@dean0x dean0x deleted the feat/60-mds-fmt-auto-formatter branch July 7, 2026 20:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: mds fmt — auto-formatter

1 participant