Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ crates/mds-python/dist/
# Local Cargo config (e.g. sccache workarounds, nextest job overrides) — never commit
.cargo/

# devflow local state — local-only, not shared via git (reverses ADR-019; see ADR-023)
# devflow local state — local-only, not shared via git. The v0.3.0 wave (88ddbcc, 2026-06-27) stopped tracking .devflow/ wholesale; the curated re-includes below are the later, narrow exception.

# Devflow runtime data — local by default (memory, learning, docs, locks).
# Exception: feature knowledge bases under .devflow/features/ are shared via git —
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`mds::load_vars_file`/`load_vars_str` never sees it). At most 1 000 distinct
duplicate key paths are listed; beyond that a single tail line reports how many
more were omitted: `warning: {n} more duplicate keys in vars file <file> are not
listed`. `mds watch` reloads the vars file from disk on every rebuild (ADR-016),
listed`. `mds watch` reloads the vars file from disk on every rebuild,
so its duplicate keys are re-reported on every rebuild that writes output too —
including a duplicate introduced mid-session by editing the vars file — while
`--set`/`--set-string` duplicate warnings keep their existing once-at-startup
Expand Down
69 changes: 38 additions & 31 deletions crates/mds-cli/src/watch.rs

Large diffs are not rendered by default.

27 changes: 14 additions & 13 deletions crates/mds-cli/tests/cli_watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2326,7 +2326,7 @@ fn watch_file_mode_entry_deleted_settles_then_recovers() {
// Delete the entry file (parent intact).
std::fs::remove_file(&src).unwrap();

// Scale-invariant error bound (guards PF-006): run two equal idle windows and assert
// Scale-invariant error bound (guards against once-per-tick re-firing — the watcher self-trigger pitfall): run two equal idle windows and assert
// the error count does NOT grow in the second window. A per-tick implementation would
// accumulate one error per tick across BOTH windows; the fix settles quickly after the
// initial native-event errors and is then silent.
Expand Down Expand Up @@ -2788,7 +2788,8 @@ fn watch_dir_mode_persistent_error_bounded_count() {
"a.md should compile despite bad.mds error"
);

// Scale-invariant error bound (applies ADR-021, guards PF-006): run two equal idle
// Scale-invariant error bound (applies the reconcile rule — see the `src/watch.rs`
// module doc — and guards against once-per-tick re-firing): run two equal idle
// windows and assert the "undefined variable" count does NOT grow in the second window.
// A per-tick implementation would fire continuously; error-settle means it fires once at
// startup and then goes silent.
Expand Down Expand Up @@ -2823,7 +2824,7 @@ fn watch_dir_mode_persistent_error_bounded_count() {
assert_eq!(
count_w1, count_w2,
"error count must not grow in a second idle window (not once-per-tick); \
w1={count_w1}, w2={count_w2} (applies ADR-021, guards PF-006); \
w1={count_w1}, w2={count_w2} (reconcile rule; no once-per-tick re-firing); \
stderr:\n{stderr_str}"
);
}
Expand Down Expand Up @@ -3092,7 +3093,7 @@ fn watch_dir_mode_soak_50_edits_bounded_and_clean_exit() {

// ── QA Fix: File-mode parent dir deleted — bounded errors then recovers ───────

/// Regression test for the edge-triggered recovery fix (ADR-021).
/// Regression test for the edge-triggered recovery fix (reconcile rule).
///
/// When the watched entry's PARENT DIRECTORY is deleted entirely, the per-tick
/// `watcher.watch()` re-arm fails every idle tick (the parent is missing). Before the
Expand Down Expand Up @@ -3144,7 +3145,7 @@ fn watch_file_mode_parent_dir_deleted_bounded_errors_then_recovers() {
// Delete the ENTIRE parent directory (not just the file — this is the bug scenario).
std::fs::remove_dir_all(&src_dir).unwrap();

// Scale-invariant error bound (guards PF-006, applies ADR-021): run two equal idle
// Scale-invariant error bound (reconcile rule; guards against once-per-tick re-firing): run two equal idle
// windows and assert the error count does NOT grow in the second window. A per-tick
// implementation would produce ≥1 error per tick continuously; the fix settles after
// the initial native-event error(s) and then goes silent.
Expand All @@ -3170,7 +3171,7 @@ fn watch_file_mode_parent_dir_deleted_bounded_errors_then_recovers() {
count_w1, count_w2,
"error count must not grow in a second idle window (not once-per-tick); \
w1={count_w1}, w2={count_w2} — the fix must settle after initial native-event errors \
(applies ADR-021, guards PF-006)"
(reconcile rule; no once-per-tick re-firing)"
);

// Recreate the parent directory and write the file with new content.
Expand Down Expand Up @@ -3211,16 +3212,16 @@ fn watch_file_mode_parent_dir_deleted_bounded_errors_then_recovers() {
/// compiles to complete, then idle for ≥10 poll-interval ticks and assert ZERO
/// "Recompiled" lines in the idle window.
///
/// This is the regression guard for the ADR-021 invariant: "idle cost stays O(1)
/// This is the regression guard for the reconcile-rule invariant: "idle cost stays O(1)
/// regardless of tree size." A per-tick full-tree walk (the anti-pattern) would
/// manifest as spurious "Recompiled" events under CI load; the edge-triggered
/// liveness probe (ADR-021) must emit none.
/// liveness probe (reconcile rule) must emit none.
///
/// An additional positive observable — the sentinel output file's mtime must not
/// advance during the idle window — makes the failure mode deterministic rather
/// than relying on timing luck alone.
///
/// applies ADR-021
/// applies the reconcile rule
#[test]
fn watch_dir_mode_idle_500_files_no_recompile() {
const FILE_COUNT: usize = 500;
Expand Down Expand Up @@ -3277,7 +3278,7 @@ fn watch_dir_mode_idle_500_files_no_recompile() {

// Idle for ≥10 ticks at 50ms poll-interval (500ms total, bounded). A per-tick
// full-tree walk would trigger O(FILE_COUNT) work per tick; edge-triggered probes
// (ADR-021) must emit zero "Recompiled" lines during this window.
// (reconcile rule) must emit zero "Recompiled" lines during this window.
std::thread::sleep(Duration::from_millis(600));

let stderr_str = stderr_tap.finish_text(&mut child);
Expand All @@ -3286,7 +3287,7 @@ fn watch_dir_mode_idle_500_files_no_recompile() {
assert_eq!(
recompiled_count, 0,
"AC-P5: idle dir-mode watcher over {FILE_COUNT} files must emit 0 Recompiled \
across ≥10 ticks (ADR-021: idle cost is O(1) regardless of tree size); \
across ≥10 ticks (reconcile rule: idle cost is O(1) regardless of tree size); \
got {recompiled_count}; stderr:\n{stderr_str}"
);

Expand Down Expand Up @@ -4524,7 +4525,7 @@ fn i9_dir_watch_duplicate_set_warns_exactly_once_at_startup() {
// ── I16-I18: duplicate --vars file key warnings under `mds watch` (#326) ─────
//
// Unlike I8/I9 (--set/--set-string warn once per SESSION, at startup), a
// duplicate in the --vars FILE warns at startup AND on every rebuild: ADR-016
// duplicate in the --vars FILE warns at startup AND on every rebuild: the freshness rule
// reloads the vars file on every rebuild, so a duplicate present in it is
// re-reported each time (D9).

Expand Down Expand Up @@ -4570,7 +4571,7 @@ fn i16_file_watch_vars_file_duplicate_warns_at_startup_and_on_every_rebuild() {
"I16: expected exactly 1 warning at startup; stderr:\n{stderr_after_start}"
);

// Edit 1: trigger a rebuild — ADR-016 reloads the vars file, re-reporting the
// Edit 1: trigger a rebuild — the freshness rule reloads the vars file, re-reporting the
// duplicate.
write_atomic(&src, "version 2");
assert!(
Expand Down
3 changes: 2 additions & 1 deletion crates/mds-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -965,7 +965,8 @@ impl MdsError {
/// evaluator runs without source context — see the arity span-divergence
/// note in `evaluator.rs`), `MixedContent` is a *structural* error about the
/// template's shape: the offending node's byte offset is known statically
/// from the AST, so the diagnostic underlines the orphan content (ADR-022).
/// from the AST, so the diagnostic underlines the orphan content (the origin rides
/// along the node, so no path→source lookup is needed).
///
/// `offset`/`len` index into `source`; the shared [`at`] guard drops `src`
/// (keeping raw offset/length for `serialize()`) if they fall out of bounds,
Expand Down
3 changes: 2 additions & 1 deletion crates/mds-core/src/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1262,7 +1262,8 @@ pub struct EvalMessage {
/// `file`/`source` provide the diagnostic context for the [`MdsError::MixedContent`]
/// span: when orphan content is found, the offending node's byte offset (already
/// captured by the parser on every `TextNode`/`Interpolation`) is paired with
/// `source` so the error underlines the prose (ADR-022). For the `@extends` path the
/// `source` so the error underlines the prose (origin rides along the data, not a
/// path→source lookup). For the `@extends` path the
/// offsets may originate in a base template rather than `source`; the shared `at()`
/// guard drops the source in that out-of-bounds case so no miette `OutOfBounds`
/// render can occur (the raw offset/length are still preserved in `serialize()`).
Expand Down
28 changes: 18 additions & 10 deletions crates/mds-core/src/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1069,7 +1069,9 @@ impl ModuleCache {
let (mut scope, fm_imports) =
build_scope_from_frontmatter(module.frontmatter.as_ref(), is_md, ctx.runtime_vars)?;

// Resolve frontmatter imports BEFORE body imports (per spec, ADR-014).
// Resolve frontmatter imports BEFORE body imports (per spec: frontmatter imports
// resolve before body imports; a duplicate alias is a compile error — legacy
// decision 014 in 88ddbcc~1:.devflow/decisions/decisions.md).
self.resolve_frontmatter_imports(&fm_imports, &mut scope, ctx, warnings)?;

// Walk the AST: collect @define functions (with closure capture), process imports/exports
Expand Down Expand Up @@ -1180,7 +1182,7 @@ impl ModuleCache {
/// - Parse FM imports from base and child frontmatter (3d-i).
/// - Deep-merge base and child FM mappings (3d-ii).
/// - Build scope from the merged mapping with runtime vars (3d-iii).
/// - Resolve base FM imports against the base file (3d-iv, ADR-014 ordering).
/// - Resolve base FM imports against the base file (3d-iv, frontmatter-first ordering).
/// - Resolve child FM imports against the child file (3d-v).
/// - Merge base functions into scope (3d-vi).
///
Expand All @@ -1190,7 +1192,7 @@ impl ModuleCache {
/// independent of `&mut self`. The caller passes `&*arc` to deref from `Arc`.
///
/// # Invariants preserved
/// - Base FM imports resolved BEFORE child FM imports (ADR-014).
/// - Base FM imports resolved BEFORE child FM imports (frontmatter-first ordering).
/// - `deep_merge_yaml` applies `MAX_FRONTMATTER_MERGE_DEPTH` cap.
/// - `resolve_frontmatter_imports` → `resolve_import_from` → `resolve_by_key_skeleton`
/// preserves PF-004 safety (cycle detection, `check_import_depth`, file-size cap).
Expand All @@ -1207,7 +1209,8 @@ impl ModuleCache {
// Base imports resolve relative to the BASE file's directory (base_base_dir, derived
// from base_key via FileSystem::parent_dir). Child imports resolve relative to the
// CHILD file's directory (ctx.base_dir). Both sets are resolved; a duplicate alias
// across base+child → mds::name_collision (ADR-014).
// across base+child → mds::name_collision (a duplicate alias across frontmatter
// and body is a compile error).
let base_fm_imports: Vec<FrontmatterImport> = base
.frontmatter_values
.as_ref()
Expand Down Expand Up @@ -1236,7 +1239,7 @@ impl ModuleCache {
// (base < child < runtime, F7, decision #3).
let mut scope = build_scope_from_merged_mapping(&merged_mapping, ctx.runtime_vars)?;

// 3d-iv: Resolve base frontmatter imports against base_key (ADR-014 ordering,
// 3d-iv: Resolve base frontmatter imports against base_key (frontmatter-first ordering,
// PF-004 safe via resolve_frontmatter_imports → resolve_import_from).
// Use a ctx pointing to the base file with its REAL source bytes so that any
// span-carrying error here attributes correctly AND the at() debug_assert can't
Expand Down Expand Up @@ -1284,7 +1287,8 @@ impl ModuleCache {
///
/// Factoring here enforces that BOTH modes go through the same PF-004-safe
/// `resolve_by_key_skeleton` path for the base, and share one copy of the
/// scope-construction pipeline (ADR-016: re-validate at the leaf; decision #3/7).
/// scope-construction pipeline (re-validated at the leaf even though every part
/// passed its parse-time check; decision #3/7).
fn resolve_extends_components(
&mut self,
module: &crate::ast::Module,
Expand Down Expand Up @@ -1381,7 +1385,7 @@ impl ModuleCache {
/// and `process_module_intrinsic` (@extends branch) — enforcing PF-004 parity: the two
/// parallel paths can never drift because they share one implementation.
///
/// ADR-016: re-validate at the leaf (on `final_body` regions), not at intermediate bases.
/// Re-validate at the leaf (on `final_body` regions), not at intermediate bases.
fn validate_extends_components(
components: &ExtendsComponents,
scope: &mut Scope,
Expand Down Expand Up @@ -1418,7 +1422,8 @@ impl ModuleCache {
// Validate per-region so each region's offsets are checked against the correct
// source (fixes the cross-source OutOfBounds diagnostic bug). This is what makes
// E12 work: a base default block referencing an undefined var is caught HERE
// against the merged leaf scope. (ADR-016: re-validate dynamically-assembled content.)
// against the merged leaf scope. (dynamically-assembled content is re-validated
// even though each part was checked at parse time.)
{
let mut scope = components.scope.clone();
Self::validate_extends_components(&components, &mut scope)?;
Expand Down Expand Up @@ -1534,7 +1539,9 @@ impl ModuleCache {
/// - `check_import_depth` + cycle detection (`self.resolving`) apply via
/// `resolve_by_key_skeleton`.
/// - `deep_merge_yaml` depth cap (`MAX_FRONTMATTER_MERGE_DEPTH`) applies transitively.
/// - `skeleton_origin` Arc is cloned from the grandparent (ADR-022 ride-along).
/// - `skeleton_origin` Arc is cloned from the grandparent (origin rides along the
/// data — never a path→source lookup; legacy decision 022 in the same
/// git-history file).
#[allow(clippy::type_complexity)]
fn resolve_intermediate_base(
&mut self,
Expand Down Expand Up @@ -1569,7 +1576,8 @@ impl ModuleCache {

// skeleton_origin Arc::clone'd from grandparent — the root base's source bytes
// ride down the chain so the leaf's validate_extends_components can attribute
// non-block skeleton node diagnostics to the root file (Risk #2, ADR-022).
// non-block skeleton node diagnostics to the root file (Risk #2; origin rides
// along via Arc, never reconstructed from a path cache).
let skel_origin = grandparent.skeleton_origin.clone();

// Phase 3: transitive FM merge: grandparent.frontmatter_values < own_fm_values.
Expand Down
2 changes: 1 addition & 1 deletion crates/mds-core/src/resolver_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1634,7 +1634,7 @@ fn f8_child_can_use_own_fm_import_alias() {

#[test]
fn f8_duplicate_alias_base_and_child_error() {
// F8/ADR-014: same alias in both base and child frontmatter imports → mds::name_collision.
// F8 (frontmatter-first import ordering): same alias in both base and child frontmatter imports → mds::name_collision.
let lib = "@define foo():\nfoo\n@end\n";
let base = concat!(
"---\n",
Expand Down
8 changes: 4 additions & 4 deletions packages/bundler-utils/__test__/hmr-harness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,18 @@
* ## Platform gating (decision D5)
*
* HMR filesystem-event tests are gated to Linux in CI because:
* - macOS FSEvents does not surface read-access events (PF-006) and has
* - macOS FSEvents does not surface read-access events (the read-event self-trigger class that inotify exposes) and has
* higher latency, making timing-sensitive HMR tests unreliable.
* - Windows uses a different notify backend.
* - Linux inotify (after the PF-006 fix in 6b7f2fe) is the reference platform.
* - Linux inotify (after the watcher's self-trigger loop fix) is the reference platform.
*
* Set MDS_HMR=1 to force-enable on any platform (local debugging only).
*
* ## Reliability rules
*
* - All polling loops have a fixed upper bound (maxAttempts). No unbounded while(true).
* - No sleep() calls — all waiting uses polling with bounded retries.
* - ADR-014: @import dependency files are written BEFORE the entry file
* - Deps-before-entry: @import dependency files are written BEFORE the entry file
* (mirrors watch.rs deps-before-entry order to ensure watchers see deps first).
*
* @module hmr-harness
Expand Down Expand Up @@ -50,7 +50,7 @@ export const HMR_ENABLED =
* Write a temporary project of MDS files under os.tmpdir().
*
* Files are written in the order they appear in the `files` array.
* Per ADR-014, callers MUST list @import dependency files BEFORE the entry
* Deps-before-entry: callers MUST list @import dependency files BEFORE the entry
* file so watchers see dependencies registered before the entry is compiled.
*
* @param {Record<string, string>} files - Map of relative filename → content.
Expand Down
2 changes: 1 addition & 1 deletion packages/bundler-utils/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export function createMdsLoader(): MdsLoaderApi {
// Runtime validation: esmImport() must return a thenable (Promise-like).
// new Function() bypasses TypeScript's type checker, so the return type
// annotation is not enforced at runtime. A non-thenable here would cause
// a silent hang rather than a clear error. (applies ADR-016)
// a silent hang rather than a clear error. (runtime re-validation of a value the type checker cannot see)
if (
importResult === null ||
typeof importResult !== 'object' ||
Expand Down
4 changes: 2 additions & 2 deletions packages/rollup-plugin/__test__/watch-e2e.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ describe('rollup-plugin watch e2e — Suite 1 (real watcher)', { skip: !HMR_ENAB

test('T-HMR-b (AC-F2): edit transitive @import dep → fresh bundle', async () => {
const { dir, paths, cleanup } = createTempMdsProject({
// ADR-014: dep BEFORE entry
// deps-before-entry: dep BEFORE entry
'dep.mds': '@define greet(who):\nHi {{who}}! MARKER_A\n@end\n\n@export greet',
'entry.mds': '@import { greet } from "./dep.mds"\n\n{{greet("World")}}',
});
Expand Down Expand Up @@ -245,7 +245,7 @@ describe('rollup-plugin watch e2e — Suite 1 (real watcher)', { skip: !HMR_ENAB
});

test('T-HMR-e (AC-F5): add a second @import dep, edit it → recompile', async () => {
// ADR-014: dep files BEFORE entry
// deps-before-entry: dep files BEFORE entry
const { dir, paths, cleanup } = createTempMdsProject({
'dep1.mds': '@define greet(who):\nHi {{who}}! MARKER_A\n@end\n\n@export greet',
'entry.mds': '@import { greet } from "./dep1.mds"\n\n{{greet("World")}}',
Expand Down
Loading
Loading