feat(core): template inheritance via @extends / @block (#58)#95
feat(core): template inheritance via @extends / @block (#58)#95dean0x wants to merge 23 commits into
Conversation
…#58) Adds foundational AST types and parser support for template inheritance, keeping the workspace green (0 regressions across all 626+ tests). AST (ast.rs): - Module.extends: Option<ExtendsDirective> — child-vs-standalone discriminator - Node::Block(BlockNode { name, body, offset }) — named template block placeholder Parser (parser.rs): - parse_extends_if_present: consumes leading @extends, tolerates blank-line Text nodes - Stray @extends elsewhere → mds::syntax (TODO(phase5): map to mds::extends) - parse_block: @block name: ... @EnD, top-level-only via inside_block flag + depth guard - BlockGuard RAII pattern modeled on MessageGuard - @block and @extends added to valid-directive list Evaluator (evaluator.rs): - Node::Block arm in evaluate_nodes → renders body inline (transparent in text mode) - Node::Block arm in collect_messages → descends for messages mode Validator (validator.rs): - Node::Block arm → validate_block_node Resolver (resolver.rs): - collect_block helper: enforces MAX_BLOCKS_PER_MODULE (256), detects duplicate names, detects @block-vs-@define collisions via shared block_names + functions sets - Node::Block arm in has_message_block Limits (limits.rs): - MAX_BLOCKS_PER_MODULE = 256 scan_imports (lib.rs): - Prepends extends path before frontmatter/body imports (base is first dependency) Tests: 37 new Phase 1 unit tests in parser_tests.rs, evaluator.rs, resolver.rs Applies ADR-010, ADR-016. Avoids PF-004 (no std::fs calls added). Co-Authored-By: Claude <noreply@anthropic.com>
…ds (#58) Implements the skeleton-resolution engine and text-mode rendering for @extends template inheritance. 0 regressions (1019 tests pass). error.rs: - MdsError::Extends { message, span, src } — new variant with code(mds::extends) - extends_error_at() constructor (mirrors import_error_at pattern) - Extends arm added to serialize() span-extraction match - #[non_exhaustive] already present — no re-add needed in Phase 5 resolver.rs — ResolvedModule new fields: - effective_skeleton: Arc<[Node]> — root-ancestor body, Arc-shared (P1: no deep-clone) - effective_blocks: IndexMap<String, Arc<BlockNode>> — clone-before-override fold, most-derived wins, diamond-inheritance safe (F5) - frontmatter_values: Option<serde_yaml_ng::Mapping> — reserved-key split deferred Phase 3 - extends_path: Option<String> resolver.rs — new entry points: - resolve_by_key_skeleton: reads via FileSystem trait (PF-004 compliant), cycle detection and MAX_IMPORT_DEPTH via same resolving stack (decision #1, E5/E6) - process_module_skeleton: tokenize→parse→collect only, NO validate/evaluate (decision #2) - process_module_extends: full extends branch — child-only-blocks check (E3), unknown-override check (E4), clone(base.effective_blocks)+apply overrides, merged scope with minimal frontmatter merge (TODO(phase3) marker for deep_merge_yaml), splice_skeleton O(S+B), validate+evaluate on final_body (E12) Helpers: - parse_frontmatter_mapping: parses YAML once for storage - node_offset: extracts byte offset from any Node variant for error spans - splice_skeleton: linear O(S+B) pass, between-block spacing preserved verbatim (decision #9, F11) Cache-poisoning invariant (A1): skeleton base and standalone compile share the same normalized cache key (first-resolution wins). Both paths populate all fields. Multi-level chains (A←B←C) fold in O(1): each level does Arc::clone of grandparent skeleton and clone(grandparent.effective_blocks)+own overrides. Minimal frontmatter merge: child wins on collision; base vars injected if not set. TODO(phase3) marker placed for deep_merge_yaml / reserved-key exclusion. Tests (17 new, all green): F1 (worked example byte-exact), F2 (standalone base + cache non-poisoning), F3 (multi-level most-derived-wins), F5 (diamond, exact output for B and C), F12 (base @define in child scope), E3 (stray content → mds::extends + A5), E4 (unknown override → mds::extends + A5), E5 (circular A→B→A and self-extension), E6 (65-deep chain → depth error), E10 (missing base), E11 (parse error in base), E12 (undefined var in base default → caught at leaf), A2 (dependency ordering), P1 (Arc::ptr_eq confirms skeleton sharing), MdsError::Extends serialize wired. Applies ADR-014 (frontmatter imports before body), ADR-016 (re-validate at runtime). Avoids PF-004 (all base file reads via FileSystem trait / resolve_by_key_skeleton). Phase 5 note: retarget the two Phase-1 TODO(phase5) mds::syntax markers to mds::extends; do NOT re-add the Extends variant. Co-Authored-By: Claude <noreply@anthropic.com>
#58) Implements the deep recursive frontmatter merge for template inheritance, replacing the minimal Phase 2 merge with the full decision #3 / decision #7 semantics. limits.rs - MAX_FRONTMATTER_MERGE_DEPTH = 64: bounds deep_merge_yaml recursion (P4). resolver.rs - deep_merge_yaml(base, child, depth) -> Result<Mapping, MdsError>: - Recurses when BOTH values are Mapping; else child wins. - Arrays replace wholesale (decision #7). - Key order: base-then-child (A6 determinism). - Excludes reserved keys (imports, type, extends) from output. - Depth > MAX_FRONTMATTER_MERGE_DEPTH -> mds::resource_limit (P4). - build_scope_from_merged_mapping(mapping, runtime_vars) -> Result<Scope>: Pre-merged Mapping to scope with runtime vars applied LAST (F7, decision #3: base < child < runtime). - process_module_extends: replaced the TODO(phase3) minimal-merge block with full deep merge + per-file FM import resolution (ADR-014 order) + duplicate alias collision check + mds::name_collision on duplicate alias. - process_module_skeleton: transitive FM merge for multi-level chains (A<-B<-C). B now stores accumulated deep-merge of A+B in frontmatter_values, so C gets the full transitive chain correctly. Tests added (21 new, 0 regressions): deep_merge_yaml unit tests (nested merge, array wholesale replace, reserved key exclusion, key order, depth cap), plus integration tests for F6/F7/F8/A6/P4/F3/F4 acceptance criteria and regression. Applies ADR-014, ADR-016. Avoids PF-004. Co-Authored-By: Claude <noreply@anthropic.com>
Mirrors the @extends branch in process_module_messages so --format messages has full template inheritance support, identical to text mode. Key changes in resolver.rs: - ExtendsComponents struct: carries (final_body, scope, functions, effective_skeleton, effective_blocks, has_explicit_exports, explicit_exports) as the shared output of the extends pipeline. - resolve_extends_components helper: factors steps 3a-3e (validate+ resolve base via resolve_by_key_skeleton, child-only-blocks check, effective_blocks construction, deep-merge scope, splice final_body) so text and messages modes share one copy. Guarantees PF-004 (avoids PF-004): the oversized-base file-size guard fires in BOTH modes via the shared resolve_by_key_skeleton path. - process_module_extends: refactored to call resolve_extends_components then run validate+evaluate on final_body (step 3f, text mode). - process_module_messages: detects module.extends and calls resolve_extends_components; runs has_message_block guard on final_body (NOT module.body — decision #8, ADR-016: base @message blocks inside @block defaults are correctly detected after splice); then calls evaluate_messages on final_body. New tests (8 Phase-4 tests, total 672 mds-core / 1048 workspace): - F9: messages-mode inheritance compiles base+child @block override to message array; asserts role/content exactly. - F9: @message inside un-overridden base default block surfaces. - F9 multilevel: 3-level chain (A<-B<-C) in messages mode; most-derived wins. - E13: base with no @message in messages mode clears mds::syntax error. - F10 (messages half): empty block renders empty; surrounding messages intact. - P5: 32-level chain compiles in TEXT+MESSAGES in < 2 s (no OOM). - P6: 10 MiB+ base rejected (mds::resource_limit) in TEXT mode. - P6: same guard fires in MESSAGES mode — no path leak in error text. Co-Authored-By: Claude <noreply@anthropic.com>
…rror-code finalization: - E1/E2 (stray/double @extends): retarget mds::syntax to mds::extends - E9 (@block nesting): remove misleading TODO, correctly stays mds::syntax - A3: add parser + resolver consolidation tests for E1-E9 error code table spec.md: - Add section 4.11 Template Inheritance (@extends / @block) - Section 11 grammar: add extends and block productions - Section 10: remove now-implemented "Template inheritance" from deferred list Test count: 674 mds-core (was 672), 0 regressions. Co-Authored-By: Claude <noreply@anthropic.com>
…tests (#58) WASM (crates/mds-wasm/tests/web.rs): - compile_extends_text_mode_skeleton_and_override: F1 text mode round-trip - compile_extends_dependencies_contains_base: A4 output-shape stability - compile_extends_messages_mode: F9 messages mode via VirtualFs - compile_extends_error_code_is_mds_extends: E1 error code assertion NAPI (crates/mds-napi/__test__/index.spec.mjs): - INH-1: compileFile round-trip (base in deps, skeleton+override, A4 shape) - INH-2: stray @extends produces mds::extends error code CLI (crates/mds-cli/tests/inheritance.rs + fixtures/): - F1: byte-exact output for inh_base + inh_analyst worked example - F2: standalone base compiles with defaults - F6: base-only frontmatter key visible in child scope - F7: --set runtime var wins over merged frontmatter - F8: @if + {interp} in block body resolved with merged scope - F9: messages mode JSON array via --format messages - E5: circular (A->B->A) and self-extension exit nonzero with chain - E13: --format messages without @message blocks exits nonzero - F11: whitespace contract between blocks - F13: _base.mds partial not emitted; child has base in dependencies - A2: compile_with_deps includes base + scan_imports extends path first - P2: 200-block wide base compiles < 200ms Note: WASM tests authored for CI (wasm-pack + Binaryen required locally). Co-Authored-By: Claude <noreply@anthropic.com>
…ed first as an @extends skeleton (prompt_body=None, no standalone validate/evaluate) was cached and then served as-is to a later standalone compile of that same file in the same ModuleCache, yielding EMPTY output for the base. Add an explicit is_skeleton discriminator on ResolvedModule and have resolve_by_key upgrade a skeleton cache hit to a full compile, reusing the skeleton's effective_skeleton/effective_blocks Arcs so descendants keep Arc-sharing. Not reachable via the current public compile* APIs (each creates a fresh cache and resolves the entry point first), but ModuleCache is a public, reusable type and the prior ResolvedModule doc comment incorrectly claimed this scenario was handled. Add regression tests: - f2_cache_nonpoisoning_skeleton_then_standalone_reverse_order - scan_imports_extends_before_fm_and_body (closes A2 ordering coverage gap: @extends path precedes frontmatter AND body imports) Co-Authored-By: Claude <noreply@anthropic.com>
…ps (#58) GAP 1 - F4 intermediate (f4_intermediate_new_block_rejected): Adds an A<-B<-C chain where intermediate B declares a brand-new @block name absent from root A. Pins that compiling B directly, compiling leaf C, and check_virtual on B all produce mds::extends with "only the root template may declare @block placeholders" and a non-None span. Previously the E4 test only covered a leaf declaring an unknown block; this closes the intermediate case. GAP 2 - F11 whitespace contract (f11_whitespace_contract_4_combination_matrix): Four byte-exact combinations pinning decision #9 (block-body edge-newline stripping + between-block text verbatim preservation): 1. base default (no override): between-block blank line preserved, block body single edge newline stripped. 2. override without blank lines: clean output. 3. override WITH surrounding blank lines: only ONE edge newline stripped per side, so residual interior newline survives, producing extra blank line. 4. override with indented content: leading spaces preserved verbatim. Note: combination 3 is intentionally NOT identical to combination 2. The task description"s "same output" claim was incorrect per strip_leading/trailing_newline semantics (single-newline strip only). Test encodes actual correct behaviour. Co-Authored-By: Claude <noreply@anthropic.com>
…ests (#58) The three new inheritance tests (F1 text, A4 dependencies, F9 messages) were passing both child_src as the source argument AND "child.mds" as a key in opts.modules. The WASM binding build_modules() rejects this as a filename collision (mds::filename_collision), since registering the entry file twice would shadow the source. Fix: inheritance_modules_opts() now puts only "base.mds" in modules and sets filename: "child.mds" so the binding maps source to child.mds internally. compile_extends_messages_mode uses the shared helper instead of an inline opts object with the same bug. The E1 error-code test (compile_extends_error_code_is_mds_extends) was correct and untouched. The A4 dependencies assertion is preserved -- base.mds remains in modules and is the extends target, so scan_imports still lists it as a dependency. No production code changed. No watch test touched. Co-Authored-By: Claude <noreply@anthropic.com>
CRITICAL: Panic/DoS on untrusted templates — byte offset mismatchFile: The IssueThis line validates validator::validate(&final_body, &mut scope, ctx.file_str, ctx.source)?;The problem: Reproduced Case
This is a DoS vector on the public APIs Required Fix (choose one)
Tests to AddAn Recommendation: Fix before merge. This is a confirmed panic on untrusted input. |
HIGH: Spurious `#[allow(dead_code)]` attributes on production-used itemsFiles & Confidence:
The IssueBoth items carry ExtendsDirective.offset: Marked dead but read at 6 production sites in resolver.rs (lines 637, 642, 649, 899, 902, 907 — all extends_error: Marked dead but called from production code in The codebase convention is that FixRemove both attributes. Both items compile clean without the suppressions. Impact: Minor — code quality / consistency only. |
MEDIUM: Duplicated `effective_blocks` seeding logic — DRY + PF-004 divergence riskFile: The IssueBoth paths build the root-base This is a textbook PF-004 violation: parallel code paths prone to silent divergence. When one path is later changed (e.g., new dedup logic, ordering changes), the other may drift without notice. FixExtract a single helper function: fn seed_effective_blocks(
body: &[Node],
block_names: &HashSet<String>,
) -> IndexMap<String, Arc<BlockNode>> {
body.iter()
.filter_map(|n| match n {
Node::Block(b) if block_names.contains(&b.name) =>
Some((b.name.clone(), Arc::new(b.clone()))),
_ => None,
})
.collect()
}Call from both sites. Guarantees the two paths stay identical by construction. Impact: Non-breaking refactor. Reduces maintenance burden and eliminates one copy of non-trivial logic. |
HIGH: O(N²) frontmatter accumulation across inheritance chain — test gapFile: The IssueEach intermediate base accumulates all ancestor frontmatter before merging with its own: deep_merge_yaml(gp_fm, own_fm, 0) // At level k, gp_fm contains O(k) keysCost: O(1 + 2 + ... + N) = O(N²) key-merges across an N-level chain, plus full clones of YAML values at each level. The cited P5 performance test ( A realistic deep chain (each level with a few FM keys, or one level with a nested mapping) could be substantially slower than the guard implies. Fix
Impact: Bounded by |
MEDIUM: Messages-mode inheritance path skips validation — parity + safety gapFile: The IssueThe In contrast, text-mode Impact: Correctness/parity gap. Validation is the layer that enforces "evaluator trusts all references exist" (KNOWLEDGE.md anti-pattern: "Calling evaluate before validate"). Skipping it on the messages path means base-default blocks bypass static reference/arity checks that text mode enforces. Defensively, this gap also means messages mode does not hit the CRITICAL panic (never reaches FixAdd Blocker for: The CRITICAL panic fix. Land that first; then add this validation safely. |
MEDIUM: E1–E4 error code tests don't assert non-None spanFiles:
The IssueThe error tests assert only the error code ( The compiler populates error spans (see Only two places assert spans: FixIn both error-code tables ( let s = err.serialize();
assert_eq!(s.code, "mds::extends");
assert!(s.span.is_some(), "E3 must carry a source span");This converts an untested assumption (spans are populated) into a guarded contract. Impact: Testing only — no behavior change. Improves regression detection. |
Summary: Review Findings & Merge ReadinessBranch: feature/58-template-inheritance → main Merge Status: CHANGES_REQUESTEDBlocking Issue: CRITICAL panic/DoS (see comment above). Must be fixed before merge. Inline Comments Above
Additional Should-Fix Items (no line anchors — repo-level)1. CHANGELOG missing inheritance feature (92%)Template inheritance ( Fix: Add under - `@extends "./base.mds"` + `@block name: … @end` template inheritance. A child
template extends a base, overriding named `@block` placeholders; only the root
base declares block names. Frontmatter deep-merges base < child < runtime.
New error code `mds::extends`; new limits MAX_BLOCKS_PER_MODULE (256) and
MAX_FRONTMATTER_MERGE_DEPTH (64). See spec §4.11.2. Stale "Phase N" doc comments (88%)Several doc comments describe work as "Phase 2/3/5 deferred" when that work is implemented in this PR:
Fix: Rewrite to present-tense behavior. Examples:
3. spec.md mislabeled output mode (88%)§4.11 line 626 labels an example "Text mode example (base defines @message blocks):" but the output (lines 649–655) is Messages JSON array, not text. Reader following §4.10 convention will be misled. Fix: Relabel line 626 to "Messages mode example (base defines @message blocks):" (matches the JSON output). 4. spec.md omits @message from @block nesting rules (85%)§4.11 line 597 lists where Fix: Add Test CoverageStrong, behavior-focused suite overall (9/10 score). All major acceptance criteria are covered; gaps above (error span assertions, perf test with FM) are polish items. Consolidated Findings by Severity
Reviewers:
All findings generated by multi-agent code review. See linked review reports for detailed analysis. |
…rective (#58) - Remove spurious #[allow(dead_code)] on ExtendsDirective.offset: field is read at 6 sites in resolver.rs via attach_import_span - Rewrite stale Phase-N doc comment on ExtendsDirective.offset to present-tense: "the resolver uses this to attach precise error spans" - Fix parser: stray/duplicate @extends uses extends_error_at (with offset + len) instead of extends_error so the error carries a span - Add span assertions to a3_parser_error_code_table for E1 and E2 cases, pinning the invariant that stray/double @extends errors carry spans Co-Authored-By: Claude <noreply@anthropic.com>
…@extends (#58) compute_line_column guarded only offset > source.len() before slicing. When a base-template validation span offset was paired with a child ctx.source containing multibyte UTF-8 chars, the offset could land mid-codepoint and panic with 'byte index N is not a char boundary'. Fix: add !source.is_char_boundary(offset) guard so a foreign or stale offset returns None instead of panicking. Callers already degrade gracefully when compute_line_column returns None. Applies ADR-016 (validate-at-leaf is correct; the bug is cross-source offset reuse, not the design). Regression tests reproduce the @extends pair where a base-default block references an undefined var and the child source contains multibyte chars. Both compile_virtual and check_virtual paths assert a graceful mds:: error. Co-Authored-By: Claude <noreply@anthropic.com>
…4 parity (#58) The @extends branch of process_module_messages called resolve_extends_components then jumped straight to evaluate_messages with NO validator::validate call. Text-mode process_module_extends and the standalone messages path both call validator::validate before evaluate — this parallel-path omission is exactly the PF-004 pattern (a check present on the primary path, absent on the parallel one). Add validator::validate(&final_body, &mut scope, ctx.file_str, ctx.source)? before the has_message_block guard, mirroring text mode. Safe because Task 1 (commit before this one) made compute_line_column boundary-safe, eliminating the cross-source offset panic risk that made adding this call risky. Avoids PF-004. Parity test: task2_messages_mode_extends_validates_final_body_parity confirms messages mode now returns the same mds::undefined_var error as text mode when a base-default block references an undefined variable. Co-Authored-By: Claude <noreply@anthropic.com>
…phase-N docs, add error-span asserts (#58) 3a: Remove the dead extends_error() constructor (no-span variant) from error.rs. extends_error_at() is the only called variant (parser.rs:348, resolver.rs:1867+1895). Removing the allow(dead_code) and the dead body keeps the zero-warnings gate clean. 3b: Remove the dead extends_path field from ResolvedModule and its three initializers (standalone path, process_module_extends, process_module_skeleton). Confirmed with grep: the field is set but never read anywhere in the workspace. Keep is_skeleton which IS read at ~213/261 for the A1 cache-poisoning guard. 3c: Rewrite stale phase-N doc comments to present tense: - ResolvedModule: 'Phase 2' heading -> 'Template Inheritance Fields'; stale 'deferred to Phase 3' sentence on frontmatter_values replaced with accurate description of transitive-accumulation behavior that ships today. - error.rs Extends variant: remove 'Phase 2'/'Phase 5 will...' forward-looking text; replace with accurate description of current usage. 3d: Add span assertions to a3_resolver_error_code_table for E3 and E4 (the two resolver-level mds::extends errors): after err.serialize(), assert s.span.is_some() to pin that both errors carry source-location context. Co-Authored-By: Claude <noreply@anthropic.com>
…p5_deep_chain (existing) builds 32 levels with empty frontmatter on every level, so the O(N^2) per-level FM accumulation path (deep_merge_yaml called at each process_module_skeleton level) has zero coverage. Add p5b_deep_chain_32_levels_with_frontmatter_under_2s: 32 levels where every intermediate level adds one unique FM key and overrides a shared key. This exercises deep_merge_yaml on every skeleton resolution in the chain and asserts the same < 2 s wall-clock bound as P5. Converts an untested assumption (FM-carrying chains are fast) into a pinned guard. The merge algorithm is NOT changed (O(N^2) fix is deferred tech debt); this test documents current behaviour and will catch regressions. Co-Authored-By: Claude <noreply@anthropic.com>
…t seed_effective_blocks(body, block_names) — the duplicated effective_blocks seeding loop that appeared in both process_module (standalone path, ~586-598) and process_module_skeleton (root-base arm, ~937-949). Both call sites now use the helper. The cleaner filter_map form (iterate body, filter by block_names) is used as the canonical implementation, preserving body-declaration order in the IndexMap. Skipped extractions: 5b: build_merged_extends_scope — the step 3d block in resolve_extends_components calls resolve_frontmatter_imports (&mut self method) twice. Extracting into a free function would require threading &mut ModuleCache as a parameter alongside multiple lifetime-tied borrows; not mechanically clean without subtle borrow checker changes. Deferred to tech debt. 5c: resolve_intermediate_base — the intermediate-base arm of process_module_skeleton calls self.fs.normalize and self.resolve_by_key_skeleton; extraction as a method would require multiple borrowed parameters with interacting lifetimes. Not purely mechanical. Deferred to tech debt. Co-Authored-By: Claude <noreply@anthropic.com>
…ernal "Task 1" task-reference clause from production comment in process_module_messages (implementation detail slop) - Rename task1_*/task2_* test functions to domain-descriptive names (utf8_boundary_*, pf004_*) consistent with the existing f*/e*/a*/p* convention in resolver tests - Extract utf8_boundary_extends_fixture() + assert_graceful_mds_error() helpers to eliminate duplicated setup between the two UTF-8 boundary regression tests
Dream-Task: decisions Dream-Session: 307c0da9-d5c1-4f83-80f6-e9214f525517 Co-Authored-By: Devflow Dream <dream@devflow.local>
Dream-Task: knowledge Dream-Session: 307c0da9-d5c1-4f83-80f6-e9214f525517 Co-Authored-By: Devflow Dream <dream@devflow.local>
|
|
||
| ```mds | ||
| # base.mds | ||
| @message system: |
There was a problem hiding this comment.
🔴 CRITICAL (97% confidence) — Messages-mode example inverts @block/@message nesting
The example places @block context: inside a @message system: block, but the parser explicitly rejects this as mds::syntax (parser.rs:560-564). This contradicts:
- The spec's own rule (line 597): "@block is top-level only"
- The table row (line 624): "@message blocks inside @block bodies"
- Implementation tests (f9_messages_mode_* in resolver.rs)
Correct layout: @block should be top-level with @message inside its body:
@block context:
@message system:
You are a {role} assistant.
No additional context.
@end
@end
Review cycle 2 • Claude Code
There was a problem hiding this comment.
Resolved in 600cdaa — Rewrote the messages-mode example to use @block top-level with @message inside the block body, matching the f9_* test pattern and the spec's own rules. JSON output verified against resolver.rs.
cc Claude Code
| @@ -446,12 +579,362 @@ impl ModuleCache { | |||
| let prompt_body = evaluate(&module.body, &mut scope, warnings)?; | |||
| let prompt_body = (!prompt_body.trim().is_empty()).then_some(prompt_body); | |||
|
|
|||
| // Build effective_skeleton from this module's own body (Arc-shared, no deep-clone, P1). | |||
| let effective_skeleton: Arc<[Node]> = Arc::from(module.body.as_slice()); | |||
There was a problem hiding this comment.
Arc::from(slice) deep-clones the entire body AST
Arc::<[Node]>::from(&[Node]) allocates and element-wise clones every Node in the body (O(n) deep clone). The module.body is dead after evaluate + seed_effective_blocks (lines 579, 586), so this clone is pure waste on the standalone path.
Impact: Every standalone module resolution (including non-inheritance .mds files with zero @block declarations) pays an unnecessary deep clone that wasn't incurred pre-inheritance.
Fix: Move the owned Vec<Node> into Arc<[T]> instead of cloning:
let effective_blocks = seed_effective_blocks(&module.body, &block_names);
let effective_skeleton: Arc<[Node]> = module.body.into(); // moves, zero clonesThis reuses/transfers the allocation with zero element clones. Same pattern applies at line 922 (root-base arm).
Review cycle 2 • Claude Code
There was a problem hiding this comment.
Resolved in c7f84e3 — Moved the owned Vec into Arc<[Node]> instead of cloning, eliminating the unnecessary deep-clone on the standalone path. seed_effective_blocks is called before the move to preserve body-declaration order.
cc Claude Code
| module: crate::ast::Module, | ||
| ext: crate::ast::ExtendsDirective, | ||
| ctx: &ModuleCtx<'_>, | ||
| _is_md: bool, |
There was a problem hiding this comment.
_is_md parameter adds noise
The _is_md: bool parameter is never branched on inside this function body. The leading underscore confirms it is unused. The only call site (line 548) threads its own is_md through, making this a dead parameter that adds clutter to an already 8-argument signature (line 753: #[allow(clippy::too_many_arguments)]).
Fix (mechanical, no borrow-checker impact):
- Remove
_is_md: boolfrom the function signature here - Remove the
is_mdargument at the call site (line 548)
This drops the parameter count from 8 to 7 and makes it clear the function doesn't discriminate on file type.
Review cycle 2 • Claude Code
There was a problem hiding this comment.
Resolved in c7f84e3 — Removed _is_md from the function signature and the call site at line 548. Parameter count dropped from 8 to 7; also removed the #[allow(clippy::too_many_arguments)] lint silence.
cc Claude Code
| /// as a base and later as a standalone target yields correct output. (Messages mode | ||
| /// never caches its entry module — `resolve_key_messages` always re-computes — so the | ||
| /// poisoning window only exists on the text/`resolve_by_key` path.) | ||
| pub(crate) is_skeleton: bool, |
There was a problem hiding this comment.
ResolvedModule encodes skeleton-vs-full lifecycle as implicit invariant
ResolvedModule mixes is_skeleton: bool (line 85) with prompt_body: Option<String> (line 64), correlated by an implicit invariant: a skeleton entry always has prompt_body == None, while a full entry may have Some or None.
Problem: The type does not structurally enforce this invariant. The A1 cache-poisoning guard (lines 207, 254–258) exists precisely because the flag and the body can disagree at the cache boundary. Every future field added must reason about both states.
Fix (mechanical, follows Rust reliability principle):
Add a debug_assert! in the ResolvedModule producers (the five sites that construct ResolvedModule) to machine-check the correlation in debug builds:
debug_assert!(!is_skeleton || prompt_body.is_none());This pins the invariant without requiring a full enum refactor (which is deferred tech debt). Consistent with the repo's "assert invariants in production code, not just tests" reliability rule.
Review cycle 2 • Claude Code
There was a problem hiding this comment.
Resolved in c7f84e3 — Added debug_assert!(is_skeleton ⇒ prompt_body.is_none()) in the skeleton producer to machine-check the invariant in debug builds, following the repo's reliability principle.
cc Claude Code
| /// `@block name:` ... `@end` — a named placeholder block for template inheritance. | ||
| /// | ||
| /// In a standalone (non-extending) module the body is rendered inline as the default | ||
| /// content. In a child module (Phase 2) these are override nodes that replace the |
There was a problem hiding this comment.
ℹ️ LOW (82% confidence) — Stale "(Phase 2)" internal-phase labels in public rustdoc [DEDUP: documentation + consistency]
Two doc comments reference the internal implementation-plan phase number:
- Line 133: "In a child module (Phase 2) these are override nodes…"
- Line 141: "In inheritance mode (Phase 2) the resolver splices overrides…"
These are residue from cycle-1's present-tense sweep (commit 1c2a374), which updated resolver.rs and error.rs but missed ast.rs. The surrounding prose is accurate; "(Phase 2)" is just noise in public API docs (meaningless to a future reader).
Fix: Drop the parentheticals — the sentences read correctly without them:
- "In a child module these are override nodes that replace the base template's corresponding block."
- "In inheritance mode the resolver splices overrides from the child into the base skeleton…"
Review cycle 2 • Claude Code
There was a problem hiding this comment.
Resolved in 1c84830 — Removed the '(Phase 2)' parentheticals from the ast.rs doc comments; the sentences read correctly without them and are now consistent with the cycle-1 sweep.
cc Claude Code
Review Cycle 2: SummaryCross-cycle status: Cycle 1 fixed 12 findings with 0 false positives. All cycle-1 fixes (UTF-8 boundary validation, messages-mode validate parity, E1-E4 span assertions, phase-N present-tense sweep) are confirmed present in this diff. Inline Findings (≥80% confidence)
Lower-Confidence Suggestions (60-79%, summary only)Architecture (65-70%):
Testing (65-78%):
Reliability (60%):
Acknowledged Tech Debt (cycle 1, not re-raised)Five items evaluated and remain correctly deferred: (1) resolver.rs god-module split, (2) O(N²) frontmatter accumulation, (3) build_merged_extends_scope extraction, (4) resolve_intermediate_base extraction, (5) per-node source identity. Next Steps
Claude Code review agent • Cycle 2 |
…comments (#58) Cycle-1 commit 1c2a374 removed this class of internal-project-phase residue from resolver.rs and error.rs. Four missed locations in ast.rs and evaluator.rs carried the same stale Phase-2 parentheticals in doc comments on BlockNode and the evaluate_nodes / collect_messages match arms. Remove the labels; keep the surrounding description accurate and present-tense. ast.rs:133 - Node::Block doc comment ast.rs:141 - BlockNode struct doc comment evaluator.rs:102 - evaluate_nodes Node::Block arm inline comment evaluator.rs:783 - collect_messages Node::Block arm inline comment
…#58) Debug-build CI runners have no opt-level override (Cargo.toml [profile.test] is absent), so a 200ms absolute wall-clock assertion is flaky on shared runners. Relax to 1s: the test guards against O(N²) blowup across ~200 blocks, so 1s still catches orders-of-magnitude regressions while tolerating debug codegen. Rename test fn and update header comment to match. Co-Authored-By: Claude <noreply@anthropic.com>
…de example and add §11 grammar note (#58) The base.mds example nested @block context: inside @message system:, which the parser rejects with mds::syntax (parser.rs:560-564). Inverted to the correct layout: @block top-level, @message inside the block body, as pinned by f9_* tests (resolver.rs:3944, 3979). Updated child.mds override to also wrap the replacement content in @message system:. The JSON output is unchanged — it matches the corrected child override with role: research. Added a one-line context-free-vs-semantic clarifying note on the block production in §11: the grammar is context-free but @block is additionally constrained to top-level only by the parser. applies ADR-016
I3: Remove dead `_is_md` parameter from `process_module_extends`.
The param was underscore-prefixed and never branched on; single call
site in `process_module` updated. Removes the `#[allow(clippy::too_many_arguments)]`
attr that was only needed because of the extra param.
I2: Move Vec<Node> into Arc<[Node]> instead of cloning-via-slice.
On both the standalone path (`process_module`) and the root-base arm of
`process_module_skeleton`, swap `Arc::from(body.as_slice())` for
`Arc::from(body)` (consuming move). Requires reordering
`seed_effective_blocks` before the Arc construction so the borrow ends
first. Reuses the Vec allocation — no per-element Node clones.
I5: Add debug_assert for A1 skeleton invariant in `process_module_skeleton`.
Pin `is_skeleton=true ⇒ prompt_body=None` in debug builds so a future
refactor that accidentally adds an evaluate call on this path is caught
immediately. (Full SkeletonEntry/StandaloneEntry enum split is tech debt.)
I9: Replace silent fallback arm in `splice_skeleton` with debug_assert.
The else arm ("unknown block name after validation") was silently emitting
the skeleton default. Added a debug_assert! with a "compiler bug" message;
release build keeps the safe fallback to avoid silent empty output.
I10: Add UTF-8 char-boundary debug_asserts before source[offset..] slices.
`attach_import_span` and `check_child_only_blocks` both index into source
with AST offsets (same-source, safe today). Guards mirror the boundary
safety added to compute_line_column (b95a0ed).
I11: Pin e6_depth_limit_exceeded to single deterministic code mds::import.
The linear 66-file chain has no cycles and no frontmatter; check_import_depth
deterministically returns mds::import. Drop the three-way OR assertion.
I12: Remove dead scaffolding from e5_circular_inheritance_a_to_b_to_a.
The `a`, `b_content`, and `files2` variables were set up for a scenario
that the test comment itself noted "won't compile". Replaced with the
actual two-file mutual cycle test directly; no coverage lost.
I7: Add SYNC POINT cross-reference comments between deep_merge_yaml::RESERVED
(resolver.rs) and strip_reserved_keys (lib.rs). The two lists have different
purposes and are intentionally not identical; the comments document which key
(extends) appears in one but not the other and why.
I8: DEFERRED — ExtendsInput bundle for process_module_extends 7-param signature.
Borrow-checker interactions (same as cycle-1 deferred extractions 5b/5c) make
this non-mechanical. Conservative default per issue guidance.
Co-Authored-By: Claude <noreply@anthropic.com>
✅ Review Cycle 2: Resolution CompleteStatus: 12 of 13 findings fixed, 0 false positives, 1 deferred to tech debt. Resolved Findings
Deferred to Tech DebtI8 — ExtendsInput param bundle (same borrow-checker family as cycle-1's deferred resolver extractions)
Gate Status
Branch Status
Resolution posted by Claude Code |
Summary
Closes #58
Implements full template inheritance for MDS: a base template defines named
@blockplaceholder regions; a child declares@extends "./base.mds"and overrides those regions selectively. The base compiles standalone; the child splices its overrides into the base skeleton and evaluates the merged result as a single unit.This is a 5-phase implementation landed across 6 commits on this branch:
ExtendsDirective,BlockNode), parser, evaluator/validator/resolver arms, limits,scan_importssplice_skeleton,effective_blocks,MdsError::Extends, circular detectiondeep_merge_yaml), reserved-key exclusion, per-file FM imports, transitive chain,MAX_FRONTMATTER_MERGE_DEPTH=64resolve_extends_componentshelper;has_message_blockguard onfinal_bodyRendering model
@block name:bodies are its defaults.final_body(post-splice), not on the child source directly.base_fm < child_fm < runtime_vars; all frontmatter, functions, and imports share one namespace.Decisions honored (Phase 5)
std::fsin binding/test pathsError-code mapping (A3)
mds::extends@extendsnot first), E2 (two@extends), E3 (stray child content), E4 (unknown block override)mds::circular_import→chain)mds::name_collision@blockvs@define), E8 (duplicate@block)mds::syntax@blocknesting)mds::file_not_foundPublic API / binding shape: unchanged (A1 / A4)
All existing signatures for
compile,compile_str,compile_str_with,compile_with_deps,compile_virtual*,compile_messages_*,check*,scan_importsare unchanged.CompileOutputandCompileMessagesOutputfield sets are unchanged — no new top-level fields added.Acceptance-criteria coverage
mds-core::resolver::tests::f1_worked_example_byte_exact,mds-cli::inheritance::f1_analyst_compile_byte_exactf2_standalone_base_compiles_with_defaults(resolver + CLI)f3_multilevel_deep_merge_transitive(resolver)f6_deep_frontmatter_merge_*(resolver),f6_deep_merge_base_only_key_visible_in_child(CLI)f7_runtime_override_precedence(resolver),f7_set_runtime_var_overrides_merged_frontmatter(CLI)f8_*(resolver),f8_block_body_with_control_flow_and_interp(CLI)f9_messages_mode_*(resolver),f9_messages_mode_child_compiles_to_json_array(CLI), WASM + NAPIf11_whitespace_contract_base_blank_lines_preserved(CLI)f13_underscore_base_not_emitted_and_child_depends_on_it(CLI)a3_parser_error_code_table(parser),a3_resolver_error_code_table(resolver)e5_circular_inheritance_exits_nonzero_with_chain,e5_self_extension_exits_nonzeroe13_messages_mode_base_no_message_exits_nonzeroa2_dependencies_contains_base,a2_scan_imports_extends_path_first(CLI)compile_extends_dependencies_contains_base; NAPIINH-1p2_wide_base_200_blocks_under_200ms(CLI, 200ms wall-clock bound)p5_deep_chain_32_levels_text_and_messages_under_2s(resolver)p6_pf004_oversized_base_rejected_in_text_mode,..._messages_mode(resolver)WASM / NAPI status
WASM tests (
crates/mds-wasm/tests/web.rs) were authored but not run locally — local WASM builds require Binaryen v129+ and wasm-pack, which are not installed in this environment. CI exercises them via the.github/actions/setup-wasm/composite action.NAPI tests (
crates/mds-napi/__test__/index.spec.mjs) were run locally with the rebuilt.nodeaddon and pass (61/61 tests green).Spec changes
spec.md§4.11 "Template Inheritance" added (after §4.10 Messages)extendsandblockproductions added;blockadded todirectivealternationVerification gates
cargo test --workspacecargo fmt --all --checkcargo clippy --workspace --all-targets -- -D warningswasm-pack test --nodenode --test __test__/index.spec.mjs