diff --git a/.gitignore b/.gitignore index b16769fa..b265fcba 100644 --- a/.gitignore +++ b/.gitignore @@ -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 — diff --git a/CHANGELOG.md b/CHANGELOG.md index 936cbbdd..64d0c9b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 diff --git a/crates/mds-cli/src/watch.rs b/crates/mds-cli/src/watch.rs index e1a555dd..a0c947f1 100644 --- a/crates/mds-cli/src/watch.rs +++ b/crates/mds-cli/src/watch.rs @@ -6,7 +6,7 @@ //! //! - **Single-file mode**: watches the entry file and all its transitive imports. //! On each rebuild the dependency set is recomputed from fresh compilation output -//! (ADR-016: never trust a stale dep set). +//! (the freshness rule under "Key invariants": never trust a stale dep set). //! //! - **Directory mode**: recursive watch on the root dir; tracks a reverse-dependency //! graph so editing a shared partial recompiles all transitive importers. @@ -46,10 +46,17 @@ //! - `--quiet` suppresses status + warnings but NOT compile errors. //! - Exit 0 on clean Ctrl+C; non-zero only on startup failure. //! - Compile errors during watching never terminate the watcher. -//! - All loops have fixed upper bounds (ADR-021 / reliability.md): the idle tick +//! - All loops have fixed upper bounds (reconcile rule / reliability.md): the idle tick //! against an absolute deadline, and the debounce window against an absolute cap //! (window <= cap) and a message bound (<= 10 000 per window). //! - All `.mds` reads go through `compile_to_content` (PF-004). +//! - **Freshness rule** (design decision of 2026-06; kept in git history as legacy +//! decision 016 in `88ddbcc~1:.devflow/decisions/decisions.md`): the dependency set +//! and the `--vars` file are re-derived from fresh compile output / from disk on +//! every rebuild — never served from a cached snapshot. +//! - **Reconcile rule** (legacy decision 021, same file): the idle tick only re-arms +//! watches cheaply; a full directory rescan happens only on watch loss/recovery, so +//! idle cost is O(1) in tree size. use std::collections::{BTreeSet, HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -352,7 +359,7 @@ pub(crate) fn state_differs(paths: &HashSet, prev: &StampMap) -> bool { /// Decide whether a missing/recovered external dep dir should trigger a full /// reconcile, and compute the new "missing" set for the next tick. /// -/// Edge-triggered (ADR-021 / AC-P1): a missing external dir forces a reconcile +/// Edge-triggered (reconcile rule / AC-P1): a missing external dir forces a reconcile /// only when it *reappears* (was in `prev_missing`, now exists). A dir that stays /// missing across ticks does NOT trigger a walk — otherwise a permanently-deleted /// cross-root dep dir would cause an O(tree) rescan on every idle tick. @@ -389,7 +396,7 @@ pub(crate) fn external_recovery_decision( /// /// Rejects a symlinked vars file at startup (build parity — PF-004). /// Falls back to the raw path when the file does not yet exist (the user may create -/// it later; the vars file is reloaded on every rebuild — ADR-016 — so a duplicate +/// it later; the vars file is reloaded on every rebuild — freshness rule — so a duplicate /// key introduced after startup is caught on the next rebuild, #326). pub(crate) fn canonicalize_vars_path(vars: Option) -> Result, MdsError> { match vars { @@ -795,7 +802,7 @@ fn drain_debounce(rx: &mpsc::Receiver, debounce_ms: u64) -> DebounceOutcome DebounceOutcome { paths, end } } -// ── Poll-interval clamp (ADR-021) ───────────────────────────────────────────── +// ── Poll-interval clamp (reconcile rule) ───────────────────────────────────────────── /// Convert a raw `--poll-interval` value (milliseconds) into a tick duration. /// @@ -859,7 +866,7 @@ pub(crate) fn run_watch(args: WatchArgs) -> Result<()> { let canonical_input = mds::NativeFs::check_symlink(&resolved_input).map_err(miette::Error::from)?; - // Clamp poll_interval: 0 = disable; nonzero ≥ 50ms floor (ADR-021). + // Clamp poll_interval: 0 = disable; nonzero ≥ 50ms floor (reconcile rule). let tick_opt: Option = clamp_poll_interval(poll_interval); if is_dir { @@ -935,11 +942,11 @@ struct FileWatchState { /// Subset of `watched_dirs` that have been successfully armed (registered with the /// OS watcher). Used by `liveness_probe_file` to skip the `watcher.watch()` syscall /// for dirs that are already known-good — steady-state idle cost becomes O(missing_dirs) - /// ≈ O(0) rather than O(watched_dirs) (ADR-021 / issue #1). + /// ≈ O(0) rather than O(watched_dirs) (reconcile rule / issue #1). armed_dirs: BTreeSet, /// Set of paths relevant to the current build (entry + deps + vars). foi: HashSet, - /// Snapshot of `(mtime, size)` used by the liveness probe (ADR-021). + /// Snapshot of `(mtime, size)` used by the liveness probe (reconcile rule). last_mtimes: StampMap, /// Content-dedup map keyed by output-path string (or `""`). last_written: HashMap, @@ -961,7 +968,7 @@ enum FileEventAction { Rebuild, } -/// Run the idle-tick liveness probe for single-file mode (ADR-021). +/// Run the idle-tick liveness probe for single-file mode (reconcile rule). /// /// Re-arms watches for dirs that were missing or not yet armed; skips the /// `watcher.watch()` syscall for dirs already known-good (`armed_dirs`). @@ -974,7 +981,7 @@ fn liveness_probe_file( watcher: &mut RecommendedWatcher, state: &mut FileWatchState, ) -> bool { - // 1. Re-arm watches for dirs that need attention (ADR-021 idle-O(1) fix). + // 1. Re-arm watches for dirs that need attention (reconcile rule idle-O(1) fix). // A dir "needs attention" if it was previously missing OR not yet armed. // Already-armed, currently-present dirs are not touched — steady-state idle // cost becomes O(missing_dirs) ≈ O(0), not O(watched_dirs). @@ -1011,7 +1018,7 @@ fn liveness_probe_file( state.armed_dirs.remove(d); } } - // Edge-triggered recovery (ADR-021): mirrors external_recovery_decision used in + // Edge-triggered recovery (reconcile rule): mirrors external_recovery_decision used in // dir mode — a dir that STAYS missing must not trigger recovery every tick. let (dirs_recovery, now_missing_dirs) = external_recovery_decision(&state.missing_watched_dirs, &dir_statuses); @@ -1090,7 +1097,7 @@ fn handle_fs_event_file( /// `watcher` is passed separately (non-Clone, distinct lifecycle role). /// /// # Invariants preserved -/// - ADR-016: `foi` and `watched_dirs` always recomputed from fresh dep output. +/// - Freshness rule: `foi` and `watched_dirs` always recomputed from fresh dep output. /// - PF-004: all reads go through `compile_to_content`. /// - Error-settle: `last_mtimes` updated on vars error, compile error, and write error. fn rebuild_file( @@ -1179,7 +1186,7 @@ fn rebuild_file( crate::build::emit_duplicate_vars_file_warnings(&resolved, ctx.quiet); } - // ADR-016: always recompute dep set from fresh output. + // Freshness rule: always recompute dep set from fresh output. let new_dirs = dirs_to_watch(&ctx.entry, &compiled.dependencies, ctx.vars_path.as_deref()); state.watched_dirs = resync_watches(watcher, &state.watched_dirs, &new_dirs); @@ -1483,7 +1490,7 @@ fn run_watch_file( let mut state = FileWatchState { // armed_dirs mirrors watched_dirs at startup: all dirs that were successfully - // registered in the loop above are considered armed (ADR-021 idle-O(1) fix). + // registered in the loop above are considered armed (reconcile rule idle-O(1) fix). armed_dirs: watched_dirs.clone(), watched_dirs, foi, @@ -1535,7 +1542,7 @@ fn run_watch_file( match clock.recv_next(&rx) { Err(mpsc::RecvTimeoutError::Disconnected) => break, Ok(None) => { - // Idle tick — run liveness probe (ADR-021). + // Idle tick — run liveness probe (reconcile rule). if liveness_probe_file(&ctx, &mut watcher, &mut state) { rebuild_file(&ctx, &mut watcher, &mut state); } @@ -1630,7 +1637,7 @@ impl DirWatchState { /// The retained set is used only to decide what to *watch* and what to re-seed — /// never as a substitute for recompiling. It therefore only ever widens what may /// trigger a rebuild, and the cost of a stale edge is one recompile whose output - /// the `last_written` dedup then suppresses. ADR-016's freshness rule is about the + /// the `last_written` dedup then suppresses. The freshness rule is about the /// dep set a *rebuild* records, and that still comes from fresh `compile_to_content` /// output on every success; a failed compile produces no fresh set to record. fn record_error(&mut self, src: &Path) { @@ -1676,7 +1683,7 @@ impl DirWatchState { } } -/// State for the dir-mode liveness probe (ADR-021). +/// State for the dir-mode liveness probe (reconcile rule). struct LivenessState { /// Set to true on the very first tick so we do a reconcile after startup. first_tick: bool, @@ -1686,14 +1693,14 @@ struct LivenessState { /// /// Mirrors the `armed_dirs` discipline from file mode: skip `watcher.watch(root, …)` /// on healthy ticks so the OS-level re-WalkDir / FSEvents stream teardown does not - /// happen every idle tick — O(1) idle cost regardless of subtree size (ADR-021). + /// happen every idle tick — O(1) idle cost regardless of subtree size (reconcile rule). root_armed: bool, /// External dep dirs that were missing on the previous tick. /// /// Recovery is **edge-triggered**: a missing external dir triggers a full /// reconcile only when it *reappears* (vanish→reappear), never while it stays /// missing. A permanently-missing external dir must NOT force an O(tree) walk - /// on every idle tick (ADR-021 / AC-P1). + /// on every idle tick (reconcile rule / AC-P1). missing_external_dirs: BTreeSet, /// External dep dirs that are currently armed with the OS watcher. /// @@ -1716,7 +1723,7 @@ struct LivenessState { /// and external-only deps where the caller decides skip/continue). /// /// # Invariants preserved -/// - ADR-016: dep set recomputed from fresh `compile_to_content` output. +/// - Freshness rule: dep set recomputed from fresh `compile_to_content` output. /// - PF-004: all reads go through `compile_to_content`. /// /// Does **not** touch `state.last_mtimes`: the content backstop's baseline is settled @@ -1872,7 +1879,7 @@ struct DirWatchCtx { quiet: bool, } -/// Run the idle-tick liveness probe for directory mode (ADR-021, DD1). +/// Run the idle-tick liveness probe for directory mode (reconcile rule, DD1). /// /// Re-arms root + external dirs + vars dir. Applies edge-triggered recovery /// to decide whether a full reconcile (collect_mds_files diff) is needed. @@ -1883,7 +1890,7 @@ fn liveness_probe_dir( liveness: &mut LivenessState, state: &mut DirWatchState, ) { - // 1. Re-arm root as Recursive (gated — ADR-021 / issue #1 idle O(1) fix). + // 1. Re-arm root as Recursive (gated — reconcile rule / issue #1 idle O(1) fix). // // Skip the `watcher.watch()` syscall on healthy ticks when root is already armed: // on Linux `notify` re-WalkDirs the entire subtree + calls `inotify_add_watch` per @@ -1966,7 +1973,7 @@ fn liveness_probe_dir( } } - // 2. Recovery trigger (ADR-021): + // 2. Recovery trigger (reconcile rule): // `root_now_exists && !root_ok` = existing root whose re-arm failed (genuine watch loss). // A *missing* root is handled by the `root_was_missing && root_now_exists` vanish→reappear // edge and must NOT trigger recovery on every tick while absent (per-tick error spam). @@ -2029,7 +2036,7 @@ fn liveness_probe_dir( }) { // --set/--set-string are fixed for the session and warned once at // startup — discarded (via `resolved.vars` below). The vars file is - // reloaded on every rebuild (ADR-016); this self-heal path emits under + // reloaded on every rebuild (freshness rule); this self-heal path emits under // the same content-changed gate as `handle_fs_event_dir`, so one // logical edit observed by both paths still warns once — tests I17 and // I19. Without this, a self-heal recompile driven purely by this @@ -2157,7 +2164,7 @@ fn handle_fs_event_dir( clear_terminal(); } - // ADR-016: reload vars from disk on every rebuild. + // Freshness rule: reload vars from disk on every rebuild. // Soft-error: vars file may be temporarily absent (AC-W7 / AC-C5). let resolved = match build_runtime_vars(RuntimeVarArgs { vars: ctx.vars_path_raw.clone(), @@ -2166,7 +2173,7 @@ fn handle_fs_event_dir( }) { // --set/--set-string are fixed for the session and warned once at startup — // discarded (via `resolved.vars` below). The vars file is reloaded on every - // rebuild (ADR-016), so its duplicate keys are re-reported too — but only when + // rebuild (freshness rule), so its duplicate keys are re-reported too — but only when // this batch produces an OBSERVABLE rebuild (#326, test I17): at // `--debounce 0` a single edit can generate more than one raw FS event, each // reaching this function separately, so the warning is emitted after @@ -2314,7 +2321,7 @@ fn dir_watch_startup( // Watch the vars dir if it is outside root — soft warning on failure (mirrors the // external-dep-dir convention and the liveness probe's best-effort re-arm semantics; - // a transient failure must not abort the session, applies ADR-021 / consistency fix). + // a transient failure must not abort the session, applies the reconcile rule / consistency fix). if let Some(ref vd) = vars_dir_extra { if let Err(e) = watcher.watch(vd, RecursiveMode::NonRecursive) { eprint_warning(&format!( @@ -2638,7 +2645,7 @@ fn run_watch_dir( match clock.recv_next(&rx) { Err(mpsc::RecvTimeoutError::Disconnected) => break, Ok(None) => { - // Idle tick — run liveness probe (ADR-021, DD1). + // Idle tick — run liveness probe (reconcile rule, DD1). liveness_probe_dir(&ctx, &mut watcher, &mut liveness, &mut state); continue; } @@ -2779,7 +2786,7 @@ fn process_dir_batch_vars_changed( /// Steps: /// 1. Partition changed paths into `existing` / `deleted`. /// 2. Compute seeds = existing ∪ deleted ∪ (errored ∩ real-change batch). -/// 3. Compute affected = transitive importers of seeds (ADR-016 snapshot). +/// 3. Compute affected = transitive importers of seeds (freshness-rule snapshot). /// 4. Compile each affected source that exists and is not an external-only dep. /// 5. Delete outputs for removed sources. /// @@ -2927,7 +2934,7 @@ fn process_dir_batch_incremental( // (issue #2 / reliability.md): when a cross-root @import is edited away, the now- // unused dir stays in the set, causing the liveness probe to re-arm it on every tick // forever. Recompute from the current `forward_deps` after each batch so abandoned - // external dirs are unwatched and removed (applies ADR-021 / mirrors the prune + // external dirs are unwatched and removed (applies the reconcile rule / mirrors the prune // already done in `process_dir_batch_vars_changed`). let live_ext_dirs: BTreeSet = state .forward_deps @@ -3341,7 +3348,7 @@ mod tests { } // external_recovery_decision: a dir that STAYS missing across ticks does NOT - // trigger recovery (ADR-021 / AC-P1 — no per-tick full-tree walk). + // trigger recovery (reconcile rule / AC-P1 — no per-tick full-tree walk). #[test] fn external_recovery_missing_stays_missing_no_recovery() { let gone = PathBuf::from("/elsewhere/shared"); diff --git a/crates/mds-cli/tests/cli_watch.rs b/crates/mds-cli/tests/cli_watch.rs index ab216733..4792cde8 100644 --- a/crates/mds-cli/tests/cli_watch.rs +++ b/crates/mds-cli/tests/cli_watch.rs @@ -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. @@ -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. @@ -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}" ); } @@ -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 @@ -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. @@ -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. @@ -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; @@ -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); @@ -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}" ); @@ -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). @@ -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!( diff --git a/crates/mds-core/src/error.rs b/crates/mds-core/src/error.rs index f12bfc30..0a5479ee 100644 --- a/crates/mds-core/src/error.rs +++ b/crates/mds-core/src/error.rs @@ -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, diff --git a/crates/mds-core/src/evaluator.rs b/crates/mds-core/src/evaluator.rs index fec6c685..1c1b740d 100644 --- a/crates/mds-core/src/evaluator.rs +++ b/crates/mds-core/src/evaluator.rs @@ -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()`). diff --git a/crates/mds-core/src/resolver.rs b/crates/mds-core/src/resolver.rs index 1af0c49c..92a58d33 100644 --- a/crates/mds-core/src/resolver.rs +++ b/crates/mds-core/src/resolver.rs @@ -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 @@ -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). /// @@ -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). @@ -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 = base .frontmatter_values .as_ref() @@ -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 @@ -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, @@ -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, @@ -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)?; @@ -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, @@ -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. diff --git a/crates/mds-core/src/resolver_tests.rs b/crates/mds-core/src/resolver_tests.rs index d3afa2d2..405244e4 100644 --- a/crates/mds-core/src/resolver_tests.rs +++ b/crates/mds-core/src/resolver_tests.rs @@ -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", diff --git a/packages/bundler-utils/__test__/hmr-harness.mjs b/packages/bundler-utils/__test__/hmr-harness.mjs index 707e282d..759c16de 100644 --- a/packages/bundler-utils/__test__/hmr-harness.mjs +++ b/packages/bundler-utils/__test__/hmr-harness.mjs @@ -10,10 +10,10 @@ * ## 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). * @@ -21,7 +21,7 @@ * * - 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 @@ -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} files - Map of relative filename → content. diff --git a/packages/bundler-utils/src/loader.ts b/packages/bundler-utils/src/loader.ts index cabc909b..a67a7122 100644 --- a/packages/bundler-utils/src/loader.ts +++ b/packages/bundler-utils/src/loader.ts @@ -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' || diff --git a/packages/rollup-plugin/__test__/watch-e2e.spec.mjs b/packages/rollup-plugin/__test__/watch-e2e.spec.mjs index ff326110..35a17e24 100644 --- a/packages/rollup-plugin/__test__/watch-e2e.spec.mjs +++ b/packages/rollup-plugin/__test__/watch-e2e.spec.mjs @@ -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")}}', }); @@ -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")}}', diff --git a/packages/rspack-loader/__test__/hmr-e2e.spec.mjs b/packages/rspack-loader/__test__/hmr-e2e.spec.mjs index e53cccc3..a9f2890e 100644 --- a/packages/rspack-loader/__test__/hmr-e2e.spec.mjs +++ b/packages/rspack-loader/__test__/hmr-e2e.spec.mjs @@ -132,7 +132,7 @@ describe('rspack-loader HMR e2e — Suite 1 (real watcher)', { skip: !HMR_ENABLE }); test('T-HMR-b (AC-F2): edit transitive @import dep → fresh bundle', async () => { - // ADR-014: dep files BEFORE entry + // deps-before-entry: dep files BEFORE entry const { dir, paths, cleanup } = createTempMdsProject({ '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")}}', @@ -236,7 +236,7 @@ describe('rspack-loader HMR e2e — Suite 1 (real watcher)', { skip: !HMR_ENABLE }); 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")}}', @@ -317,7 +317,7 @@ describe('rspack-loader HMR e2e — Suite 1 (real watcher)', { skip: !HMR_ENABLE describe('rspack-loader HMR e2e — Suite 3 edge cases', { skip: !HMR_ENABLED && 'HMR e2e tests are Linux-gated; set MDS_HMR=1 to run' }, () => { test('T-E-del (AC-E1): delete @imported dep → rspack errors; recreate → recovers', async () => { - // ADR-014: dep BEFORE entry + // deps-before-entry: dep BEFORE entry const { dir, paths, cleanup } = createTempMdsProject({ 'dep.mds': '@define greet(who):\nHi {{who}}! DEP_MARKER\n@end\n\n@export greet', 'entry.mds': '@import { greet } from "./dep.mds"\n\n{{greet("World")}}', diff --git a/packages/vite-plugin/__test__/hmr-e2e.spec.mjs b/packages/vite-plugin/__test__/hmr-e2e.spec.mjs index 106ba04d..35601a1c 100644 --- a/packages/vite-plugin/__test__/hmr-e2e.spec.mjs +++ b/packages/vite-plugin/__test__/hmr-e2e.spec.mjs @@ -189,7 +189,7 @@ describe('vite-plugin HMR e2e — Suite 1 (real server)', { skip: !HMR_ENABLED & test('T-HMR-b (AC-F2): edit transitive @import dep → fresh transform', 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")}}', }, diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index 689263b6..77b8d932 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -61,7 +61,7 @@ const canonCache = new Map(); * * Falls back to path.resolve when realpathSync throws (e.g. deleted file, D7). * Never throws — a malformed input degrades to "no match" instead of propagating - * into Vite's HMR dispatch (applies ADR-016: re-validate inputs at runtime). + * into Vite's HMR dispatch (inputs are re-validated at runtime rather than trusted from their types). */ function canon(p: string): string { try { diff --git a/packages/webpack-loader/__test__/hmr-e2e.spec.mjs b/packages/webpack-loader/__test__/hmr-e2e.spec.mjs index 81dbaea5..168d7ad8 100644 --- a/packages/webpack-loader/__test__/hmr-e2e.spec.mjs +++ b/packages/webpack-loader/__test__/hmr-e2e.spec.mjs @@ -136,7 +136,7 @@ describe('webpack-loader HMR e2e — Suite 1 (real watcher)', { skip: !HMR_ENABL }); test('T-HMR-b (AC-F2): edit transitive @import dep → fresh bundle', async () => { - // ADR-014: dep files BEFORE entry + // deps-before-entry: dep files BEFORE entry const { dir, paths, cleanup } = createTempMdsProject({ '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")}}', @@ -244,7 +244,7 @@ describe('webpack-loader HMR e2e — Suite 1 (real watcher)', { skip: !HMR_ENABL }); 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")}}', @@ -327,7 +327,7 @@ describe('webpack-loader HMR e2e — Suite 1 (real watcher)', { skip: !HMR_ENABL describe('webpack-loader HMR e2e — Suite 3 edge cases', { skip: !HMR_ENABLED && 'HMR e2e tests are Linux-gated; set MDS_HMR=1 to run' }, () => { test('T-E-del (AC-E1): delete @imported dep → webpack errors; recreate → recovers', async () => { - // ADR-014: dep BEFORE entry + // deps-before-entry: dep BEFORE entry const { dir, paths, cleanup } = createTempMdsProject({ 'dep.mds': '@define greet(who):\nHi {{who}}! DEP_MARKER\n@end\n\n@export greet', 'entry.mds': '@import { greet } from "./dep.mds"\n\n{{greet("World")}}', diff --git a/scripts/__test__/verify-ledger-citations.spec.mjs b/scripts/__test__/verify-ledger-citations.spec.mjs new file mode 100644 index 00000000..001a4694 --- /dev/null +++ b/scripts/__test__/verify-ledger-citations.spec.mjs @@ -0,0 +1,225 @@ +/** + * Tests for scripts/verify-ledger-citations.mjs + * + * D1.3 (comment-only sweep + ledger-citation gate): every planted denylist or + * ceiling token in this file is built AT RUNTIME via string concatenation + * (e.g. `['ADR', '099'].join('-')`) — no bare ADR-NNN or PF-NNN literal + * appears anywhere in this file. T-D1-16 verifies this mechanically, against + * both the guard script's own source and this spec's own source. + * + * Helpers below (runScanner, mkTempGitRepo, cleanup) are re-implemented from + * scratch, matching the shape of scripts/__test__/verify-no-control-bytes.mjs + * — not imported from that sibling, so this spec has no runtime dependency on + * it. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'node:fs'; +import { join, resolve, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync, execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { + CEILING, + DENYLIST, + classify, + scanText, +} from '../verify-ledger-citations.mjs'; + +const ROOT = resolve(fileURLToPath(import.meta.url), '../../..'); +const SCANNER = join(ROOT, 'scripts/verify-ledger-citations.mjs'); +const SPEC_SELF = fileURLToPath(import.meta.url); + +// --------------------------------------------------------------------------- +// Helper: run the guard script as a subprocess +// --------------------------------------------------------------------------- +function runScanner(args = [], opts = {}) { + const r = spawnSync(process.execPath, [SCANNER, ...args], { + cwd: opts.cwd ?? ROOT, + encoding: 'utf8', + env: { ...process.env, ...(opts.env ?? {}) }, + timeout: 30000, + }); + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; +} + +// --------------------------------------------------------------------------- +// Helper: create a minimal git repo in a temp directory +// --------------------------------------------------------------------------- +function mkTempGitRepo() { + const dir = mkdtempSync(join(tmpdir(), 'mds-ledger-')); + const git = (...args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: 'pipe' }); + git('init'); + git('config', 'user.email', 'test@test.test'); + git('config', 'user.name', 'Test'); + return { dir, git }; +} + +function cleanup(dir) { + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } +} + +function writeAndAdd(dir, git, relPath, content) { + const abs = join(dir, relPath); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + git('add', relPath); +} + +// A file with no citation tokens — keeps the scanned set non-empty when the +// file under test lives entirely under an excluded path. +const PLAIN_CONTENT = 'nothing to see here\n'; + +// A citation-token regex built the same piece-wise way the guard script +// builds its own — kept local so this spec never depends on an internal, +// unexported helper of the script under test. Self-clean because "ADR"/"PF" +// and the trailing "-\d{3}" never sit adjacent in this file's own source. +function countCitationTokens(text) { + const re = new RegExp('\\b(' + 'ADR' + '|' + 'PF' + ')' + '-' + '(\\d{3})\\b', 'g'); + const matches = text.match(re); + return matches === null ? 0 : matches.length; +} + +// --------------------------------------------------------------------------- +// T-D1-10: real-tree run — stays RED until the sweep commits land, because +// the tree does not yet clear the non-vacuity floor without them. +// --------------------------------------------------------------------------- +describe('T-D1-10: real tree', () => { + test('real repo tree (cwd: ROOT) exits 0 with files >= 500 and tokens >= 1000', () => { + const r = runScanner([], { cwd: ROOT }); + assert.equal(r.status, 0, `expected exit 0; stderr: ${r.stderr}\nstdout: ${r.stdout}`); + const m = r.stdout.match(/scanned (\d+) file\(s\), (\d+) citation token\(s\)/); + assert.ok(m, `success output must include "scanned N file(s), M citation token(s)"; got: ${r.stdout}`); + const files = parseInt(m[1], 10); + const tokens = parseInt(m[2], 10); + assert.ok(files >= 500, `expected >= 500 files scanned; got ${files}`); + assert.ok(tokens >= 1000, `expected >= 1000 citation tokens; got ${tokens}`); + }); +}); + +// --------------------------------------------------------------------------- +// T-D1-11: ceiling control — a number the ledger has not minted +// --------------------------------------------------------------------------- +describe('T-D1-11: ceiling control', () => { + test('an unminted ADR number exits 1 and is named as not minted', () => { + const { dir, git } = mkTempGitRepo(); + try { + const token = ['ADR', '099'].join('-'); + writeAndAdd(dir, git, 'src/note.rs', `// ${token}: plan-local, not yet minted\n`); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1, `expected exit 1; stdout: ${r.stdout}\nstderr: ${r.stderr}`); + assert.ok(r.stderr.includes('not minted'), `expected "not minted" in output; got: ${r.stderr}`); + } finally { cleanup(dir); } + }); +}); + +// --------------------------------------------------------------------------- +// T-D1-12: denylist control — a number that carried a retired, source-local +// meaning until the 2026-09 sweep +// --------------------------------------------------------------------------- +describe('T-D1-12: denylist control', () => { + test('a retired-meaning ADR number exits 1 and names the source-local meaning', () => { + const { dir, git } = mkTempGitRepo(); + try { + const token = ['ADR', '021'].join('-'); + writeAndAdd(dir, git, 'src/note.rs', `// applies ${token}\n`); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1, `expected exit 1; stdout: ${r.stdout}\nstderr: ${r.stderr}`); + assert.ok(r.stderr.includes('source-local meaning'), `expected "source-local meaning" in output; got: ${r.stderr}`); + } finally { cleanup(dir); } + }); +}); + +// --------------------------------------------------------------------------- +// T-D1-13: legitimate id — minted, under the ceiling, not denylisted +// --------------------------------------------------------------------------- +describe('T-D1-13: legitimate id', () => { + test('a minted, non-denylisted PF number exits 0 and counts one citation token', () => { + const { dir, git } = mkTempGitRepo(); + try { + const token = ['PF', '004'].join('-'); + writeAndAdd(dir, git, 'src/note.rs', `// all .mds reads funnel through one path (${token}).\n`); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 0, `expected exit 0; stdout: ${r.stdout}\nstderr: ${r.stderr}`); + assert.ok(r.stdout.includes('1 citation token'), `expected "1 citation token" in output; got: ${r.stdout}`); + } finally { cleanup(dir); } + }); +}); + +// --------------------------------------------------------------------------- +// T-D1-14: scope exclusions — a denylisted token that lives ONLY under an +// excluded path must not be scanned at all +// --------------------------------------------------------------------------- +describe('T-D1-14: scope exclusions', () => { + test('a denylisted token present only under .devflow/features and CHANGELOG.md exits 0', () => { + const { dir, git } = mkTempGitRepo(); + try { + const token = ['ADR', '021'].join('-'); + writeAndAdd(dir, git, 'src/plain.rs', PLAIN_CONTENT); + writeAndAdd(dir, git, '.devflow/features/x/KNOWLEDGE.md', `mentions ${token} for history\n`); + writeAndAdd(dir, git, 'CHANGELOG.md', `mentions ${token} for history\n`); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 0, `expected exit 0 (excluded paths must not be scanned); stdout: ${r.stdout}\nstderr: ${r.stderr}`); + } finally { cleanup(dir); } + }); +}); + +// --------------------------------------------------------------------------- +// T-D1-15: empty repo — non-vacuity guard +// --------------------------------------------------------------------------- +describe('T-D1-15: empty repo', () => { + test('a git repo with zero tracked files exits 1 and names zero files', () => { + const { dir } = mkTempGitRepo(); + try { + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1, `expected exit 1; stdout: ${r.stdout}\nstderr: ${r.stderr}`); + const combined = r.stdout + r.stderr; + assert.ok(combined.includes('zero files'), `expected "zero files" in output; got: ${combined}`); + } finally { cleanup(dir); } + }); +}); + +// --------------------------------------------------------------------------- +// T-D1-16: self-clean — the guard cannot flag its own source or this spec +// --------------------------------------------------------------------------- +describe('T-D1-16: self-clean', () => { + test('the guard script and this spec have zero citation-token regex matches against themselves', () => { + const scriptText = readFileSync(SCANNER, 'utf8'); + assert.equal(countCitationTokens(scriptText), 0, 'script must be self-clean (no literal citation tokens)'); + assert.equal(scanText(SCANNER, scriptText).length, 0, 'script must produce zero findings against itself'); + + const specText = readFileSync(SPEC_SELF, 'utf8'); + assert.equal(countCitationTokens(specText), 0, 'spec must be self-clean (no literal citation tokens)'); + assert.equal(scanText(SPEC_SELF, specText).length, 0, 'spec must produce zero findings against itself'); + }); +}); + +// --------------------------------------------------------------------------- +// T-D1-17: golden — the frozen ceiling and denylist cannot silently drift +// --------------------------------------------------------------------------- +describe('T-D1-17: golden', () => { + test('CEILING and DENYLIST match the frozen snapshot, and classify()/scanText() agree with it', () => { + assert.deepEqual(CEILING, { ADR: 17, PF: 53 }); + + assert.deepEqual(DENYLIST.map(d => d.num), [14, 16, 19, 21, 22, 23]); + for (const entry of DENYLIST) { + assert.equal(entry.prefix, 'ADR', `entry for ${entry.num} must have prefix "ADR"`); + assert.ok(entry.reason.length > 0, `entry for ${entry.num} must have a non-empty reason`); + } + + const minted = classify('PF', 4); + assert.equal(minted.ok, true, 'this PF id is minted, under ceiling, and not denylisted'); + + const retired = classify('ADR', 21); + assert.equal(retired.ok, false); + assert.ok(retired.reason.includes('source-local meaning')); + + const unminted = classify('ADR', 99); + assert.equal(unminted.ok, false); + assert.ok(unminted.reason.includes('not minted')); + + assert.deepEqual(scanText('x.rs', 'no citation tokens in this text at all'), []); + }); +}); diff --git a/scripts/verify-ledger-citations.mjs b/scripts/verify-ledger-citations.mjs new file mode 100644 index 00000000..f5f53630 --- /dev/null +++ b/scripts/verify-ledger-citations.mjs @@ -0,0 +1,293 @@ +#!/usr/bin/env node +/** + * D1.3 (D-C4): Ledger-citation gate — fails on a decision-log id citation in + * tracked source that is either not yet minted in the learning ledger, or + * that carried a different, source-local meaning before the 2026-09 sweep + * replaced every such citation with the decision spelled out inline. + * + * Two failure classes, checked in this order (denylist first — several + * denylisted numbers sit ABOVE the ceiling too, and the denylist reason must + * win so an operator is told "this number meant something else here", not + * just "this number is not minted yet"): + * + * R1 ceiling — CEILING is a frozen snapshot of the highest minted id per + * prefix at sweep time. The learning ledger (.devflow/learning/) is + * untracked and therefore absent in CI, so this gate cannot read it at + * run time to stay live — the snapshot is the only thing a CI checkout + * can see. A number above the ceiling reads as "the learning ledger has + * not minted this — a plan-local number", because that is the only + * pattern this sweep ever found above the snapshot line: draft plan + * prose that got merged as if the id already existed. Bumping the + * ceiling (when the ledger genuinely mints the next ADR or PF number) is + * the deliberate moment to grep tracked source for the newly-legal number — + * this file is not the place that grep runs. + * + * R2 denylist — six ADR numbers (14, 16, 19, 21, 22, 23) carried a + * different, source-local meaning in comments and docs before the + * 2026-09 sweep rewrote every citation to spell the decision out inline. + * The numbers are frozen here, not re-derived from anything — this gate + * never reads the learning ledger. + * + * Residual (not mechanically detectable): a semantic re-collision, where an + * id is BOTH validly minted AND legitimately cited for its real meaning, but + * a source comment elsewhere happens to reuse the same three digits for an + * unrelated, informal sense. Today the one instance in this tree is the + * pitfall id for a bare relative filename passed where an absolute path was + * required (distinct from that same id's watcher self-trigger meaning in + * this same tree). This gate cannot distinguish the two senses of one + * number; the control is the added-lines id check every PR gate already + * runs, plus human review. + * + * Scope: every tracked, non-symlink, non-gitlink file, EXCEPT anything under + * `.devflow/` (the learning ledger and per-feature knowledge bases cite + * these ids by design and are not sweep targets) and `CHANGELOG.md` (release + * history is immutable once a section ships; only its `[Unreleased]` head + * was in scope for the sweep itself, and this gate does not special-case + * "which half of one file"). + * + * The token regex is assembled from string pieces (`'ADR'`, `'PF'`, the + * hyphen, `'\\d{3}'`) rather than written as one literal, so this file's own + * source never contains an `ADR-NNN`/`PF-NNN`-shaped substring — see the + * self-clean test in the paired spec. + * + * Usage: + * node scripts/verify-ledger-citations.mjs + * + * Exit codes: + * 0 — no unminted or retired-meaning citation found (prints file count and + * citation-token count for non-vacuity) + * 1 — a finding was reported, OR zero files were in scope, OR the current + * directory is not inside a git work tree (all fail-closed) + * 2 — a git subcommand failed unexpectedly (indeterminate, not clean) + */ +'use strict'; + +import { spawnSync } from 'node:child_process'; +import { readFileSync, realpathSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** + * True when this module is the process entry point. + * + * Comparing realpaths (not raw argv[1] against import.meta.url) handles both + * percent-encoded paths (a space in the path) and symlinked temp dirs + * (macOS /tmp and /var/folders) — either trap would otherwise make main() + * silently never run. + * + * @param {string} metaUrl — the caller's import.meta.url + * @returns {boolean} + */ +function isMainModule(metaUrl) { + const entry = process.argv[1]; + if (!entry) return false; + const modulePath = fileURLToPath(metaUrl); + try { + return realpathSync(entry) === realpathSync(modulePath); + } catch { + return pathToFileURL(resolve(entry)).href === metaUrl; + } +} + +// --------------------------------------------------------------------------- +// R1: frozen ceiling snapshot. See the header comment above for why this is +// a snapshot rather than a live read of the (untracked, CI-absent) ledger. +// --------------------------------------------------------------------------- +export const CEILING = { ADR: 17, PF: 53 }; + +const CEILING_REASON = + 'cites an id the learning ledger has not minted — a plan-local number'; + +// --------------------------------------------------------------------------- +// R2: frozen denylist. See the header comment above for the 2026-09 sweep +// this reflects. +// --------------------------------------------------------------------------- +const DENYLIST_REASON = + 'this id carried a different, source-local meaning until the 2026-09 sweep; name the invariant instead'; + +export const DENYLIST = [14, 16, 19, 21, 22, 23].map(num => ({ + prefix: 'ADR', + num, + reason: DENYLIST_REASON, +})); + +/** + * Classify one (prefix, num) citation. + * + * Denylist is checked BEFORE the ceiling: several denylisted numbers sit + * above CEILING.ADR, and a denylisted number must report as retired-meaning, + * not as merely unminted. + * + * @param {string} prefix — 'ADR' or 'PF' + * @param {number} num — the parsed 3-digit id + * @returns {{ok: true} | {ok: false, reason: string}} + */ +export function classify(prefix, num) { + const denied = DENYLIST.find(d => d.prefix === prefix && d.num === num); + if (denied) return { ok: false, reason: denied.reason }; + const ceiling = CEILING[prefix]; + if (typeof ceiling !== 'number' || num > ceiling) { + return { ok: false, reason: CEILING_REASON }; + } + return { ok: true }; +} + +/** Assembled from pieces — see the header comment's self-clean note. */ +function buildTokenRegex() { + return new RegExp('\\b(' + 'ADR' + '|' + 'PF' + ')' + '-' + '(\\d{3})\\b', 'g'); +} + +/** + * Scan one file's text for citation tokens that classify as a finding + * (unminted or retired-meaning). Tokens that classify ok are not returned — + * callers that also need the total token count re-run buildTokenRegex(). + * + * @param {string} path — the file's path, used only to label findings + * @param {string} text — the file's decoded UTF-8 content + * @returns {Array<{path: string, line: number, token: string, reason: string}>} + */ +export function scanText(path, text) { + const re = buildTokenRegex(); + const findings = []; + let match; + while ((match = re.exec(text)) !== null) { + const prefix = match[1]; + const num = parseInt(match[2], 10); + const result = classify(prefix, num); + if (!result.ok) { + const line = text.slice(0, match.index).split('\n').length; + findings.push({ path, line, token: match[0], reason: result.reason }); + } + } + return findings; +} + +/** Total citation-token matches in text, regardless of classify() result. */ +function countTokens(text) { + const re = buildTokenRegex(); + let n = 0; + while (re.exec(text) !== null) n++; + return n; +} + +// --------------------------------------------------------------------------- +// git helpers — same shape as scripts/verify-no-control-bytes.mjs. +// --------------------------------------------------------------------------- + +function gitExec(args, cwd = process.cwd()) { + const result = spawnSync('git', args, { + cwd, + encoding: 'buffer', + maxBuffer: 64 * 1024 * 1024, + timeout: 30_000, + }); + if (result.error) { + console.error(`✖ ledger-citation gate: git error: ${result.error.message}`); + process.exit(2); + } + return result; +} + +/** Verify we are inside a git work tree (exit 1 if not — a known, named, fail-closed case). */ +function assertGitRepo(cwd) { + const r = gitExec(['rev-parse', '--is-inside-work-tree'], cwd); + if (r.status !== 0) { + console.error('✖ ledger-citation gate: not inside a git work tree'); + process.exit(1); + } +} + +/** + * Tracked files via `git ls-files -sz`. Skips git modes 120000 (symlink) and + * 160000 (gitlink) — same treatment as scripts/verify-no-control-bytes.mjs. + * + * `git ls-files -sz` output format (each entry NUL-terminated): + * \t\0... + */ +function getTrackedFiles(cwd) { + const r = gitExec(['ls-files', '-sz'], cwd); + if (r.status !== 0) { + console.error('✖ ledger-citation gate: git ls-files failed'); + process.exit(2); + } + const entries = r.stdout.toString('utf8').split('\0').filter(s => s.length > 0); + const files = []; + for (const entry of entries) { + const tabIdx = entry.indexOf('\t'); + if (tabIdx === -1) continue; // Malformed entry — skip + const meta = entry.slice(0, tabIdx); + const path = entry.slice(tabIdx + 1); + const mode = parseInt(meta.split(' ')[0], 8); + const skip = mode === 0o120000 || mode === 0o160000; + files.push({ path, mode, skip }); + } + return files; +} + +const EXCLUDED_FILE = 'CHANGELOG.md'; +const EXCLUDED_PREFIX = '.devflow/'; + +function inScope(path) { + if (path === EXCLUDED_FILE) return false; + if (path.startsWith(EXCLUDED_PREFIX)) return false; + return true; +} + +const MAX_FINDINGS_PRINTED = 200; + +function main() { + const cwd = process.cwd(); + + assertGitRepo(cwd); + + const all = getTrackedFiles(cwd); + const scannable = all.filter(e => !e.skip && inScope(e.path)); + + if (scannable.length === 0) { + console.error('✖ ledger-citation gate: zero files scanned (non-vacuity: an empty scan is not a pass)'); + process.exit(1); + } + + let scannedFiles = 0; + let citationTokens = 0; + const findings = []; + + for (const entry of scannable) { + const absolutePath = resolve(cwd, entry.path); + let buf; + try { + buf = readFileSync(absolutePath); + } catch { + continue; // Unreadable tracked path — nothing further this gate can do about it. + } + if (buf.includes(0x00)) continue; // Binary — not scanned for text citations. + + const text = buf.toString('utf8'); + scannedFiles += 1; + citationTokens += countTokens(text); + findings.push(...scanText(entry.path, text)); + } + + if (findings.length > 0) { + const printed = findings.slice(0, MAX_FINDINGS_PRINTED); + for (const f of printed) { + console.error(`✖ ledger-citation gate: ${f.path}:${f.line}: ${f.token} — ${f.reason}`); + } + const remaining = findings.length - printed.length; + if (remaining > 0) { + console.error(` … ${remaining} more`); + } + process.exit(1); + } + + console.log( + `✓ ledger-citation gate: scanned ${scannedFiles} file(s), ${citationTokens} citation token(s); ` + + 'none unminted or retired-meaning', + ); + process.exit(0); +} + +// Run only when executed directly (not imported by tests). See isMainModule. +if (isMainModule(import.meta.url)) { + main(); +}