fix!: v0.4.0 bug batch — fences, whitespace, type safety, @extends FM (#149–#154)#161
Open
dean0x wants to merge 19 commits into
Open
fix!: v0.4.0 bug batch — fences, whitespace, type safety, @extends FM (#149–#154)#161dean0x wants to merge 19 commits into
dean0x wants to merge 19 commits into
Conversation
The hint in the invalid-interpolation error message was rendering `\{{`
(double brace) instead of `\{` (single brace) because the Rust format
string used `\\{{{{` instead of `\\{{`.
Fix the format string and add a regression test that pins the rendered
hint text. Also include the v0.4.0 design artifact for issues #149-#154.
Co-Authored-By: Claude <noreply@anthropic.com>
Delete-directive-lines model: a directive line consumes exactly its own line + trailing newline; every other markdown-mode byte passes through verbatim. Replaces the old leading-trim + blank-run-cap approach. Core changes: - clean_output: rewritten to trailing-edge normalisation only (strip \r, trim trailing whitespace, ensure one final \n); interior content passes through byte-exact. Rename/update unit tests. - parser: remove strip_leading_newline / strip_trailing_newline calls from @block and @define parse sites; delete both dead functions and module-doc references. @define call site now trims via evaluator instead. - evaluator: invoke_function now calls .trim() on the body evaluation result at the call site (replaces parser-level edge strip for @define). - formatter: delete R3 (blank-line run capping) — now mismatches the new clean_output and would break compile-equivalence. Also deletes protected_spans() + all threading (code fences no longer need a separate protection class; without R3 they get the same R1-only treatment as ordinary body). Module doc updated to describe v2 interior-verbatim ruleset. doctest expected value updated. - All affected tests updated to reflect preserved blank-line runs and verbatim block body trailing \n. Adds repro integration tests: #150 (blank-line run in compiled output) and #151 (block body edge newline visible at compile boundary). BREAKING: blank-line runs and block-body edge newlines now pass through to compiled output. Templates that relied on run-capping or edge stripping will produce different markdown.
- Widen fence recognition to accept [ \t>]* prefix before the fence characters, matching CommonMark indented/blockquoted fences - Add tilde (~~~) fence support alongside backtick (```) fences - Track open fence as Option<(char, usize, usize)> (char, count, opener byte offset) instead of a bare count; closing fence must match the opening character and have count >= opener count - Use opener byte offset in the unclosed-fence error so the diagnostic spans point to the opening line rather than end-of-file - Add 6 lexer unit tests covering tilde, indented, blockquoted, char mismatch, count mismatch, and unclosed-fence span Co-Authored-By: Claude <noreply@anthropic.com>
…#152) BREAKING: @if conditions that compare values of different types (number vs string, boolean vs null, etc.) are now a TypeMismatch runtime error instead of silently returning false (==) or true (!=). Templates relying on cross-type comparisons must add explicit str() or num() conversions. Changes: - Rename values_equal_runtime → values_equal_same_type; return Option<bool> (None = cross-type, caller emits TypeMismatch error) - Add MdsError::TypeMismatch with code mds::type_mismatch and a help hint - Update evaluate_condition Eq/NotEq branches to propagate TypeMismatch - Add RuntimeVarArgs struct to bundle --vars/--set/--set-string together - Add --set-string KEY=VALUE flag to build, check, and watch subcommands: forces value to remain a string with no type coercion - Update language.rs tests: cross-type comparisons now expect errors - Add virtual_fs.rs issue repros for TypeMismatch (#152) and --set-string Co-Authored-By: Claude <noreply@anthropic.com>
Previously the compiled output of a child template only contained the child's raw frontmatter, silently discarding any keys inherited from the base. The fix serializes the deep-merged mapping (base < child, reserved keys `imports`/`type`/`extends` excluded) produced by `build_merged_extends_scope` and uses it as the output frontmatter for both the text-cached (`process_module_extends`) and intrinsic (`process_module_intrinsic`) compile paths. Breaking: `@extends` children now emit base frontmatter keys in their compiled output. Templates that relied on base keys being invisible in the output must strip them explicitly. Implementation: - `build_merged_extends_scope` returns `(Scope, Mapping)` — the mapping is no longer dropped after scope construction. - `serialize_merged_frontmatter(mapping)` → `Option<String>` serializes the mapping with `serde_yaml_ng`; returns `None` when empty. - `ExtendsComponents` gains a `merged_frontmatter: Option<String>` field populated by `resolve_extends_components`. - Both terminal paths use `merged_frontmatter` instead of the child's raw frontmatter string. - `process_module_extends` no longer receives `raw_frontmatter` as a parameter (it is now derived from the merged mapping). - `f4_child_emits_only_own_raw_frontmatter` rewritten as `f4_extends_emits_deep_merged_frontmatter` to match the new contract. - 4 new `virtual_fs` integration tests (issue_154_*). Closes #154
spec.md (bumped to v0.4): - Code fence passthrough now mentions tilde, indented, and blockquoted variants (#149) - Equality strict rule updated: cross-type comparison is a runtime error (mds::type_mismatch), not false/true (#152) - Frontmatter merging rule updated: @extends emits the deep-merged mapping (base < child), not just the child's raw FM (#154) - `--set-string` added to CLI options table (#152) - `mds fmt` added to commands table and §7.4 section (#60) - CLI sections renumbered (7.4–7.8) after inserting the fmt entry - §12 Status updated with v0.4.0, v0.3.0 entries; spec version bumped CHANGELOG.md: - [0.4.0] Unreleased section with BREAKING notices for #152 and #154, Added (--set-string, mds fmt, Python bindings), Changed (#146), Fixed (#149, #153, #133/#146) Closes #149 Closes #150 Closes #151 Closes #152 Closes #153 Closes #154
Reserved YAML keys (imports, type, extends) must only be stripped at the top level of the merged frontmatter mapping. Previously the guard was unconditional, silently dropping nested keys like config.type or config.imports that are user data, not directives. Change: both Phase 1 and Phase 2 of deep_merge_yaml now check `depth == 0` before testing RESERVED membership. Recursive calls (depth > 0) pass nested keys through verbatim. Tests added: - deep_merge_yaml_strips_top_level_reserved_keys (unit) - deep_merge_yaml_nested_reserved_keys_preserved (unit, GAP 1) - deep_merge_yaml_both_empty (unit) - f6_nested_reserved_key_config_type_survives_merge (integration) Closes #154 (nested key correctness sub-case)
Add two adversarial tests in crates/mds-cli/tests/frontmatter.rs that verify YAML values cannot escape the frontmatter fence block and inject extra top-level keys into compiled output. Tests: - adversarial_value_containing_fence_sequence_via_extends: a base frontmatter value containing literal "\n---\n" goes through the @extends re-serialization path; asserts the injected key does not appear as a top-level frontmatter key (uses extract_frontmatter helper to check only the FM section, not body text). - adversarial_block_scalar_with_type_mds_line: a block-scalar value whose lines include "type: mds" must not be confused with the top-level type: mds directive; only the unindented top-level key is stripped. Also adds extract_frontmatter() helper to check only the YAML between the first ---/--- pair, avoiding false positives from --- in body text.
…pec v0.4 - Add --set-string KEY=VALUE to Build/Watch flags table (it was missing alongside --set); both flags ship in v0.4.0 - Update mds fmt "What it normalizes" section: remove the stale "two or more consecutive blank lines collapse" bullet — R3 was removed in v0.4 (#150/#151); the formatter now passes blank lines verbatim under the interior-verbatim contract - Bump inline spec version reference from v0.2.0 to v0.4.0
Add examples/edge-cases/26_indented_and_tilde_fences.mds demonstrating
the four code-fence forms now recognized by the compiler:
1. Standard backtick fence (baseline)
2. Tilde fence (~~~rust ... ~~~)
3. Indented fence inside a list item (4-space prefix)
4. Blockquoted fence (> ``` ... ```)
All four suppress interpolation inside the fence. Interpolation resumes
normally after each closing fence. Literal {x}/{name} preserved inside,
{language} interpolated outside.
Closes #149 (example coverage)
The frontmatter golden had the pre-#150 expected output: a blank line between the closing --- fence and the body text was silently stripped by the old clean_output leading-blank-line behavior. Under the interior-verbatim contract (#150/#151), clean_output no longer strips leading blank lines — it only does trim_end() + forced final \n. The golden now reflects what both the CLI and the Python binding produce: `---\n\nHello Alice!` (blank line preserved verbatim).
- lexer: remove unused _prefix_len parameter from scan_code_fence; replace if/else-if-let/else with match to eliminate unreachable else branch (Option is exhaustive); drop stale doc line referencing the removed parameter - evaluator: rename section header comment from values_equal_runtime to values_equal_same_type (tombstone name residue from rename) - resolver: fix blended doc comment — serialize_merged_frontmatter was inserted before parse_frontmatter_mapping leaving the former's doc concatenated onto the latter's old header; split cleanly and restore parse_frontmatter_mapping's own doc comment
serialize_merged_frontmatter previously swallowed serde_yaml_ng::to_string failures via an Err(_) => None arm, which would silently drop the deep-merged @extends frontmatter fence from compiled output on failure. Return Result<Option<String>, MdsError> and map the error to MdsError::yaml_error so the failure surfaces as a compile error at the boundary (no swallowed errors). Thread the ? to the single caller in resolve_extends_components. Also drop two stale R1/R3 comments in formatter.rs left over from the R3 removal (#150/#151) — R3 no longer exists; only R1 remains for body content.
Any key present in both --set and --set-string is now a hard CLI error
with message: "variable '{key}' is set by both --set and --set-string;
use only one". Same-flag last-wins is preserved unchanged.
Also add structural equality for same-type container comparisons in
values_equal_same_type: Array==Array and Object==Object now return
Some(bool) rather than falling through to _ => None (TypeMismatch).
Value derives PartialEq so the comparison is free. Cross-type remains
None (error). A short comment guards future grammar extensions that may
surface container literals on the RHS.
Co-Authored-By: Claude <noreply@anthropic.com>
Adds: - Core-level adversarial YAML-injection tests for @extends merged-FM serialization (fence sequences, YAML end markers, block-scalar type:mds) in resolver_tests.rs — core twin of the CLI mirror in frontmatter.rs - Round-trip acceptance test: realistic template (frontmatter, post-FM blank, lists, indented fence in list item, interior blank runs, trailing-space line) compiled through a pass-through; asserts byte-identical output modulo the two carve-outs (trailing-edge normalization, \r strip) per the interior-verbatim contract (#150/#151) - Integration repro for #149: indented fence inside list item with literal {braces} → compiles, braces literal, fence lines verbatim - Integration repro for #153: invalid interpolation → error hint contains \{ and NOT \{{ - f7 extension: emitted raw_frontmatter ignores --set runtime var whose key collides with a frontmatter key (body uses runtime value, FM does not) Co-Authored-By: Claude <noreply@anthropic.com>
…#151) spec.md: - §4.2: Add fence out-of-scope note (4-space indent not recognized, blockquoted fence requires explicit closer) - §4.3: Add comparison semantics table (types × ==/!= outcome incl. cross-type error, truthiness-vs-equality split, --set-string footgun) - §4.11: Fix worked example output to match actual compiler (blank lines from base skeleton are now present in output) - §4.11: Rewrite whitespace contract to interior-verbatim with trailing-edge normalization (removes stale "block body edges are stripped" claim; distinguishes @message/@define edge-trim from block body interior-verbatim) - §4.11: Add named asymmetry callout (@extends emits canonically re-serialized merged frontmatter vs standalone raw-verbatim) - §7.4: Remove false "collapses blank lines to one" claim from mds fmt description; re-pitch as line-ending/trailing-whitespace normalizer - §12: Add #150/#151 interior-verbatim contract to v0.4.0 status entry CHANGELOG.md: - Remove stale ## [0.4.0] — unreleased heading; all content moves directly under ## [Unreleased] for bump-version.mjs compatibility - Add BREAKING bullet for #150/#151 interior-verbatim whitespace contract under Changed, with migration note - Fix --set-string Added bullet: was "mds fmt" (wrong), now "mds watch" - Fix mds fmt (#60) Added bullet: remove blank-line collapsing claim Co-Authored-By: Claude <noreply@anthropic.com>
- error.rs: Replace TypeMismatch help text with contract-specified wording interpolating lhs_type/rhs_type and referencing --set-string KEY=VALUE and `@if x:` for truthiness; remove doc-comment references to str()/num() conversion functions that do not exist in the MDS language. - virtual_fs.rs: Add issue_152_type_mismatch_help_mentions_set_string test that pins the --set-string mention via miette::Diagnostic::help so the contract stays locked and cannot silently regress. - main.rs: Rewrite Fmt subcommand doc/help to match reality after R3 removal (#150/#151) — drop the stale "collapses runs of 2+ consecutive blank lines" claim; describe actual behaviour (LF normalization, trailing whitespace on directive lines, final newline, safety gate). Co-Authored-By: Claude <noreply@anthropic.com>
v0.4.0 code additions (cross-type comparison errors, extended help strings, @extends frontmatter merging) plus ongoing `stable` toolchain drift pushed the optimised binary to 650,745 bytes in CI (650,675 bytes locally with slightly different wasm-opt). Both exceed the old 650K cap. Raise the threshold to 700K and document the full budget history in the comment. All 42 wasm-pack tests pass; guard is advisory soft-bloat, not a functional gate. Follow-up: pin the wasm toolchain to make binary size deterministic and re-tighten the guard. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
{…}inside them is interpolated #149: Recognizes indented (4-space), tilde (~~~), and blockquoted (> \``) code fences — all suppress interpolation inside; includesexamples/edge-cases/26_indented_and_tilde_fences.mds`@block/@endboundaries are consumed (blank runs collapse to 1) #151 (BREAKING): Interior-verbatim whitespace contract —clean_outputno longer strips leading blank lines or caps blank-line runs; only\rstrip +trim_end+ final\n. Formatter R3 rule removed.--set x=truecoerces to boolean, silently breakingx == "true"#152 (BREAKING): Cross-type comparison now returnsTypeMismatcherror; structural equality for arrays/objects\{{fails,\{works #153:{undefined}error hint now recommends\{for literal braces@extendsnow emits deep-merged frontmatter (base + child) instead of only child's raw YAMLChanges
Core behavior (BREAKING)
a449a50fix: interpolation error hint recommends\{(Interpolation error suggests the wrong escape; hint\{{fails,\{works #153)70359f7fix!: interior-verbatim whitespace contract (Blank line after frontmatter is stripped on compile #150, Blank lines at@block/@endboundaries are consumed (blank runs collapse to 1) #151)a96436cfix: recognize indented, tilde, and blockquoted code fences (Indented code fences are not recognized;{…}inside them is interpolated #149)b57e2fdfix!: cross-type comparison errors, structural equality, --set-string (--set x=truecoerces to boolean, silently breakingx == "true"#152)d099b25fix!:@extendsemits deep-merged frontmatter (Base-only frontmatter keys are in scope but omitted from the child output frontmatter #154)63c2958docs: v0.4.0 language/CLI docs, changelog, examplesGap-closure (post-review)
8226313fix!:deep_merge_yamlgates reserved-key filter ondepth == 0— nested keys likeconfig.typewere incorrectly stripped; now only top-level reserved keys (imports,type,extends) are filteredac3336btest: adversarial frontmatter YAML injection CLI mirror testsda1a943docs: README--set-stringflag, mds fmt interior-verbatim contract, spec v0.426eead1docs: add fence variants edge-case example (Indented code fences are not recognized;{…}inside them is interpolated #149)7fe1a6btest(python): update parity golden for interior-verbatim contract (Blank line after frontmatter is stripped on compile #150)Breaking Changes
@block/@endboundaries are consumed (blank runs collapse to 1) #151): Body content that previously had leading blank lines stripped or long blank runs collapsed will now be emitted verbatim. Templates relying on the old collapsing behavior need no source changes — the new output is more faithful to author intent.--set x=truecoerces to boolean, silently breakingx == "true"#152):{a} == {b}whereaandbare different YAML types now raisesTypeMismatchinstead of silently returningfalse.@extendsoutput now contains the deep-merged frontmatter (base + child). Reserved keys (type,imports,extends) are excluded at the top level only — nested keys likeconfig.typesurvive.Reviewer Focus Areas
crates/mds-core/src/resolver/frontmatter.rs—deep_merge_yamldepth-gating logic (depth == 0guard) and unit testscrates/mds-core/src/lib.rs—clean_outputnew implementation (trim only)crates/mds-core/src/lexer.rs— indented/tilde/blockquoted fence recognitioncrates/mds-python/tests/test_parity.py— golden updated for interior-verbatim contract (blank line after FM fence now preserved)Closes #149
Closes #150
Closes #151
Closes #152
Closes #153
Closes #154