diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2509071..2960b52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,10 +100,14 @@ jobs: # raised 650K->700K (2026-07, PR #161 / fix/v0-4-0-149-154): toolchain # drift + new v0.4.0 code (cross-type errors, extended help strings, # @extends frontmatter merging) pushed CI binary to 650,745 bytes. + # raised 700K->750K (2026-07, #61 / feat/mds-lint-61 S2): 9-rule lint + # engine (AnalysisContext, rule dispatch, fix planner) pushed local + # optimized binary to 712,419 bytes; +50K headroom for CI toolchain drift. + # raised 750K->800K (2026-07-11, #61): post-S4 full lint surface (4 bindings + parity) measured 749,801 raw locally with bundled wasm-opt; 750K left ~200B margin vs CI Binaryen variance; raised to 800K (pre-authorized, #61). # Follow-up: pin the wasm build toolchain to make the size deterministic # and re-tighten this guard. - if [ "$raw" -gt 700000 ]; then - echo "::error::WASM binary exceeds 700,000 byte threshold: ${label} is ${raw} bytes" + if [ "$raw" -gt 800000 ]; then + echo "::error::WASM binary exceeds 800,000 byte threshold: ${label} is ${raw} bytes" exit 1 fi done diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf756f..6e8f7fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,58 @@ compiled output, strip them downstream or move them to a non-frontmatter locatio Cross-platform wheel matrix and PyPI publishing are a tracked follow-up (#132) — for now, install from source: `pip install ./crates/mds-python`. (#59) +- **`mds lint`** — 9-rule static analyzer for `.mds` templates (#61). Available + across all surfaces (CLI, Rust, napi, WASM, Python) with byte-identical canonical + JSON output. + + **Rules** (individually configurable via `mds.json` `lint.rules` or the + `rules` API option; severities differ per rule): + - `unused-variable` (warn): frontmatter key defined but never referenced in the body + - `unused-import` (warn): `@import` never used in the file (Tier B: auto-fixed only for standalone files) + - `unused-function` (warn): `@define` function never called in the file (Tier B: auto-fixed only for standalone files) + - `shadow-variable` (off by default / info when enabled): inner-scope variable shadows an outer-scope variable; must be enabled via `mds.json` + - `empty-block` (warn): `@if`/`@elseif`/`@else`/`@for`/`@define`/`@message` body is empty or whitespace-only (auto-fixable) + - `redundant-else` (warn): `@else` body is structurally identical to the `@if`/`@elseif` then-body (Tier C — never auto-fixed) + - `unreachable-branch` (error): branch condition is always-true or always-false (auto-fixable) + - `duplicate-import` (error): same file imported more than once (auto-fixable) + - `duplicate-export` (error): same export name defined more than once (auto-fixable) + + **CLI** (`mds lint`): file, directory, and stdin input modes; `--fix` for + auto-fixable issues (Tier A always; Tier B for standalone files); `--check` + and `--diff` preview modes for CI; `--format json` for machine-readable output; + `--quiet` to suppress warnings; `--vars`/`--set`/`--set-string` for variable + overrides forwarded to the check gate. + + **Exit codes** (lint-specific): `0` = clean, `1` = warnings only, `2` = errors + or analysis failure, `3` = resource limit. + + **Canonical JSON shape** (keys alphabetical, BTreeMap order): + ```json + {"files":[{"diagnostics":[...],"file":"template.mds"}],"truncated":false,"version":1} + ``` + + **Library API**: new public functions in `mds-core` — `lint`, `lint_str`, + `lint_str_with`, `lint_virtual`; `LintResult`, `LintDiagnostic`, + `LintConfig`, `Severity` types. + + **napi** (`@mdscript/mds-napi`): `lint`, `lintFile`, `lintVirtual` exports. + + **WASM** (`@mdscript/mds-wasm`): `lint`, `lintVirtual` exports. + + **Universal TypeScript** (`@mdscript/mds`): `lint()`, `lintFile()`, + `lintVirtual()` with full TypeScript types (`LintResult`, `LintDiagnostic`, + `LintSpan`, `LintFileResult`, `LintOptions`, `LintFileOptions`). Both native + and WASM backends implement the full surface; `lintFile()` on the WASM backend + uses `buildModulesMap` for `@import` resolution. + + **Python** (`mdscript`): `lint()`, `lint_file()`, `lint_virtual()` with keyword-only + `rules` and `base_path` / `vars` options; `LintResult` with `.version`, `.truncated`, + `.files`, `.to_dict()`, `.to_json()`. Stubs shipped in `_mdscript.pyi` / `__init__.pyi`. + + **⚠ TypeScript interface implementers**: `MdsBaseBackend` gained `lint` and + `lintVirtual` as required members; `MdsNodeBackend` gained `lintFile`. Code that + directly implements these interfaces (not just calls them) must add these methods. + ### Changed - **BREAKING:** Interior-verbatim whitespace contract for block bodies and `mds fmt`. diff --git a/README.md b/README.md index 10efee0..50f9cf6 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ mds build [FILE|DIR] [OPTIONS] Compile an MDS template or directory to Markdown mds watch [FILE|DIR] [OPTIONS] Watch and auto-recompile on save mds check [FILE|DIR] [OPTIONS] Validate without rendering mds fmt [FILE|DIR] [OPTIONS] Reformat MDS file(s) in place (opinionated, safety-gated) +mds lint [FILE|DIR] [OPTIONS] Static-analysis lint (9 rules; --fix, --format json) mds init [FILENAME] Create a starter MDS file Global options: @@ -194,6 +195,35 @@ suppresses status but never errors. Reads a `fmt` section from `mds.json` (`{"fmt": {"sort_frontmatter_keys": true}}`) for forward compatibility — the field doesn't drive any formatting behavior yet; frontmatter key sorting is deferred to a future version. +### Static analysis with `mds lint` + +A 9-rule static analyzer that catches common template authoring issues: + +```bash +mds lint template.mds # lint a single file +mds lint . # lint all .mds files recursively (partials included) +mds lint --fix template.mds # auto-fix fixable issues in place +mds lint --format json . # machine-readable JSON output (stdout) +mds lint --quiet template.mds # suppress warnings; exit 2 on errors only +``` + +Rules (configure via `mds.json` `lint.rules`; severities differ per rule): + +| Rule | Severity | Description | +|------|----------|-------------| +| `unused-variable` | warn | Frontmatter variable defined but never referenced in the body | +| `unused-import` | warn | `@import` that is never referenced (Tier B: auto-fixed only for standalone files) | +| `unused-function` | warn | `@define` function that is never called (Tier B: auto-fixed only for standalone files) | +| `shadow-variable` | off/info | Inner-scope variable shadows an outer-scope variable (must be enabled via `mds.json`) | +| `empty-block` | warn | `@if`/`@elseif`/`@else`/`@for`/`@define`/`@message` body is empty or whitespace-only (auto-fixable) | +| `redundant-else` | warn | `@else` body is structurally identical to the `@if`/`@elseif` then-body | +| `unreachable-branch` | **error** | Branch condition is always-true or always-false (auto-fixable) | +| `duplicate-import` | **error** | Same file imported more than once (auto-fixable) | +| `duplicate-export` | **error** | Same export name defined more than once (auto-fixable) | + +Exit codes: `0` = clean, `1` = warnings only, `2` = errors or analysis failure, `3` = resource limit. +JSON output shape: `{"files":[{"file":"…","diagnostics":[…]}],"truncated":false,"version":1}`. + ## Bundler Integration Import `.mds` templates directly in Vite, Rollup, Webpack, and Rspack projects: diff --git a/crates/mds-cli/Cargo.toml b/crates/mds-cli/Cargo.toml index 6e3d712..83345bf 100644 --- a/crates/mds-cli/Cargo.toml +++ b/crates/mds-cli/Cargo.toml @@ -23,8 +23,6 @@ miette = { workspace = true, features = ["fancy"] } notify = { workspace = true } ctrlc = { workspace = true } similar = { workspace = true } - -[dev-dependencies] tempfile = { workspace = true } [target.'cfg(unix)'.dev-dependencies] diff --git a/crates/mds-cli/src/build.rs b/crates/mds-cli/src/build.rs index 99147d8..624d89e 100644 --- a/crates/mds-cli/src/build.rs +++ b/crates/mds-cli/src/build.rs @@ -26,6 +26,33 @@ pub(crate) struct MdsConfig { )] #[serde(default)] pub(crate) fmt: FmtConfig, + /// Per-rule severity overrides for `mds lint` (AC-F-17). + /// + /// Unknown severity VALUES fail config loading loudly (closed enum). + /// Unknown rule NAMES are preserved for forward compat (CLI warns and ignores). + #[serde(default)] + pub(crate) lint: LintCliConfig, +} + +/// mds.json `lint` section: per-rule severity overrides. +/// +/// Mirrors the core `LintConfig` shape but lives in the CLI so it can be +/// loaded alongside `BuildConfig` / `FmtConfig` as part of `MdsConfig`. +/// +/// Unknown severity VALUES (e.g. `"banana"`) cause a hard parse error (exit 2) +/// because `Severity` is a closed enum with no sensible fallback. Unknown rule +/// NAMES are warn-and-ignored at the CLI layer (forward compat). +#[derive(Debug, Default, Deserialize)] +pub(crate) struct LintCliConfig { + #[serde(default)] + pub(crate) rules: HashMap, +} + +impl LintCliConfig { + /// Convert to the core `LintConfig` consumed by `mds::lint_*` functions. + pub(crate) fn into_core_config(self) -> mds::LintConfig { + mds::LintConfig { rules: self.rules } + } } #[derive(Debug, Default, Deserialize)] diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs new file mode 100644 index 0000000..d357560 --- /dev/null +++ b/crates/mds-cli/src/lint.rs @@ -0,0 +1,1016 @@ +//! `lint` subcommand — static analysis of MDS templates beyond `mds check` (issue #61). +//! +//! # Input modes +//! +//! - File path: lint a single `.mds` file (base_dir = file's parent). +//! - Directory: recursively lint every `.mds` file INCLUDING partials, accumulate +//! and continue past per-file failures, exit = max severity across all files. +//! - `-` (stdin): read from stdin, report diagnostics to stderr, exit by severity. +//! With `--fix`: fixed source → stdout, diagnostics → stderr. +//! +//! # Channel discipline +//! +//! - Human diagnostics → **stderr** via miette Report. +//! - `--format json` output → **stdout** only (single JSON object, trailing newline). +//! - `--quiet` suppresses warning+info human diagnostics and summaries, NOT errors. +//! +//! # Exit codes (via direct `std::process::exit`, NEVER via `exit_code()`) +//! +//! - 0: clean (no Warn/Error findings) +//! - 1: warning-severity findings only, no errors +//! - 2: any error-severity finding OR analysis failure (parse/resolve/IO/config-load) +//! OR usage error +//! - 3: ResourceLimit +//! +//! With `--fix`, residual post-fix findings determine the exit code. + +use std::io::{IsTerminal, Write as _}; +use std::path::{Path, PathBuf}; + +use mds::{FileSystem, MdsError, NativeFs, Severity}; +use miette::Result; + +use crate::build::{build_runtime_vars, load_config, read_stdin, resolve_input, RuntimeVarArgs}; +use crate::output::collect_mds_files; + +/// Known lint rule names — used to warn about unknown names in mds.json config. +const KNOWN_RULES: &[&str] = &[ + "unused-variable", + "unused-import", + "unused-function", + "shadow-variable", + "empty-block", + "redundant-else", + "unreachable-branch", + "duplicate-import", + "duplicate-export", +]; + +pub(crate) struct LintArgs { + pub(crate) input: Option, + /// Apply fixes automatically (Tier A always, Tier B when standalone). + pub(crate) fix: bool, + /// Preview --fix: exit 1 if any file would change; never writes. + pub(crate) check: bool, + /// Preview --fix: print unified diff; never writes. + pub(crate) diff: bool, + pub(crate) quiet: bool, + pub(crate) format: LintFormat, + pub(crate) vars: Option, + pub(crate) set_vars: Vec<(String, String)>, + pub(crate) set_string_vars: Vec<(String, String)>, +} + +/// Output format for lint diagnostics. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum LintFormat { + Human, + Json, +} + +/// Bundled mode flags to avoid positional-bool transposition hazard. +#[derive(Clone, Copy)] +struct LintFlags { + fix: bool, + check: bool, + diff: bool, + quiet: bool, + format: LintFormat, +} + +/// Entry point for `mds lint`. Always returns `Ok(())`; exits are via +/// `std::process::exit()` for lint-specific codes, or via the outer `run()` +/// error handler for setup failures (which also exit 2 via this function's catch). +pub(crate) fn run_lint(args: LintArgs) -> Result<()> { + match do_lint(args) { + Ok(()) => Ok(()), + Err(e) => { + eprintln!("{e:?}"); + std::process::exit(2); + } + } +} + +/// Inner runner — all setup errors propagate as `Err`; `run_lint` catches and exits 2. +fn do_lint(args: LintArgs) -> Result<()> { + let LintArgs { + input, + fix, + check, + diff, + quiet, + format, + vars, + set_vars, + set_string_vars, + } = args; + + let flags = LintFlags { + fix, + check, + diff, + quiet, + format, + }; + + let runtime_vars = build_runtime_vars(RuntimeVarArgs { + vars, + set_vars, + set_string_vars, + })?; + + let (input, _auto_detected) = resolve_input(input)?; + + // USAGE ERROR: --fix + --format json + stdin (AC-F-22b). + // DELIBERATE EXCEPTION (AC-F-14): the JSON envelope is deferred for this 3-way combo; + // it stays a plain stderr usage message. All other top-level analysis failures route + // through emit_analysis_failure_json_or_stderr when --format json is active. + if fix && format == LintFormat::Json && input == Path::new("-") { + eprintln!( + "error: --fix --format json with stdin input is not supported; \ + use `mds lint --fix -` for filter mode or `mds lint --format json` for JSON output" + ); + std::process::exit(2); + } + + // Stdin mode. + if input == Path::new("-") { + return run_lint_stdin(flags, runtime_vars); + } + + // Directory mode. + if input.is_dir() { + // Reject a symlinked directory root (build/check/fmt parity). + if input + .symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) + { + // Directory-root symlink → JSON envelope in --format json mode (AC-F-14). + let mds_err = MdsError::Io { + message: format!( + "directory argument must not be a symlink: {}", + input.display() + ), + }; + emit_analysis_failure_json_or_stderr(&mds_err, format); + std::process::exit(2); + } + return run_lint_directory(&input, flags, runtime_vars); + } + + // Single-file mode. + ensure_mds_extension(&input)?; + run_lint_file(&input, flags, runtime_vars) +} + +/// Reject non-.mds extension (exit 2 via caller catch). +fn ensure_mds_extension(path: &Path) -> Result<()> { + if path.extension().and_then(|e| e.to_str()) == Some("mds") { + Ok(()) + } else { + Err(miette::Error::from(MdsError::NotMdsFile { + path: path.display().to_string(), + })) + } +} + +// ── Config helpers ──────────────────────────────────────────────────────────── + +/// Load mds.json and extract the core `LintConfig`, warning about unknown rule names. +fn load_lint_config(dir: &Path) -> Result { + let config_opt = load_config(dir)?; + match config_opt { + None => Ok(mds::LintConfig::default()), + Some((mds_config, _config_dir)) => { + // Warn on unknown rule names — unknown NAMES are ignored for forward compat. + for name in mds_config.lint.rules.keys() { + if !KNOWN_RULES.contains(&name.as_str()) { + eprintln!("warning: unknown lint rule '{name}' in mds.json; ignoring"); + } + } + Ok(mds_config.lint.into_core_config()) + } + } +} + +// ── Read source file ────────────────────────────────────────────────────────── + +/// Read raw source of `path`: symlink-checked and size-capped (mirrors fmt.rs). +/// +/// Returns `MdsError` (not `miette::Error`) so callers can feed the error into +/// `emit_analysis_failure_json_or_stderr` without downcasting (AC-F-14). +fn read_source_file(path: &Path) -> std::result::Result { + let canonical = NativeFs::check_symlink(path)?; + let path_str = canonical.to_str().ok_or_else(|| MdsError::Io { + message: format!("path is not valid UTF-8: {}", path.display()), + })?; + NativeFs::new().read(path_str) +} + +// ── stdout write ────────────────────────────────────────────────────────────── + +/// Write `s` to stdout, treating a broken pipe as a clean early exit rather +/// than an error — matches Unix filter conventions (e.g. `mds lint --fix - | head +/// -n1` closing the pipe early must not surface as a crash or failure). +/// Flushes explicitly so a fixed source not ending in `\n` is never silently +/// truncated before the `std::process::exit` that may follow immediately. +fn write_stdout(s: &str) -> Result<()> { + let mut stdout = std::io::stdout(); + if let Err(e) = stdout.write_all(s.as_bytes()) { + if e.kind() == std::io::ErrorKind::BrokenPipe { + return Ok(()); + } + return Err(miette::miette!("cannot write to stdout: {e}")); + } + if let Err(e) = stdout.flush() { + if e.kind() != std::io::ErrorKind::BrokenPipe { + return Err(miette::miette!("cannot flush stdout: {e}")); + } + } + Ok(()) +} + +// ── Human diagnostic rendering ──────────────────────────────────────────────── + +/// Render one lint diagnostic to stderr, applying `sanitize_control_chars` at the boundary. +/// +/// `--quiet` suppresses Warn and Info; Error always renders. +/// `named_source`: optional `(filename, source_text)` pair attached to the miette Report so +/// the renderer can show the offending source line + caret (span highlighting). When absent, +/// diagnostics are still rendered but without source context. +fn render_diag_human(diag: &mds::LintDiagnostic, quiet: bool, named_source: Option<(&str, &str)>) { + if quiet && matches!(diag.severity, Severity::Info | Severity::Warn) { + return; + } + // Sanitize at the render boundary (AC-F-16): message and help only; raw bytes + // are preserved in the stored diagnostic and in JSON output via to_canonical_json(). + let sanitized = mds::LintDiagnostic { + rule: diag.rule.clone(), + severity: diag.severity, + message: mds::sanitize_control_chars(&diag.message), + help: diag.help.as_deref().map(mds::sanitize_control_chars), + span: diag.span.clone(), + file: diag.file.clone(), + }; + // Attach the source code so miette can render the span with source context + // (source line + caret underline). The labels() implementation on LintDiagnostic + // returns the span; with_source_code() provides the text miette reads to render it. + if let Some((filename, src)) = named_source { + let report = miette::Report::from(sanitized) + .with_source_code(miette::NamedSource::new(filename, src.to_string())); + eprintln!("{report:?}"); + } else { + eprintln!("{:?}", miette::Report::from(sanitized)); + } +} + +/// Render all diagnostics in a `LintResult` to stderr. +/// +/// `named_source` is forwarded to `render_diag_human` for span context rendering. +fn render_result_human(result: &mds::LintResult, quiet: bool, named_source: Option<(&str, &str)>) { + for diag in &result.diagnostics { + render_diag_human(diag, quiet, named_source); + } +} + +// ── Exit code helpers ───────────────────────────────────────────────────────── + +/// Compute the lint exit code from a `LintResult` (not considering analysis failures). +/// +/// - 2: at least one Error-severity finding +/// - 1: at least one Warn-severity finding (no errors) +/// - 0: clean (Info/Off only, or empty) +fn result_exit_code(result: &mds::LintResult) -> i32 { + let has_error = result + .diagnostics + .iter() + .any(|d| d.severity == Severity::Error); + let has_warn = result + .diagnostics + .iter() + .any(|d| d.severity == Severity::Warn); + if has_error { + 2 + } else if has_warn { + 1 + } else { + 0 + } +} + +/// Compute exit code for an `MdsError` from the lint pipeline. +/// +/// - 3: ResourceLimit +/// - 2: everything else (parse, resolve, IO, usage, etc.) +fn mds_error_exit_code(err: &MdsError) -> i32 { + match err { + MdsError::ResourceLimit { .. } => 3, + _ => 2, + } +} + +/// Exit the process with the severity-derived lint exit code when non-zero; +/// no-op for clean (`exit == 0`) results. +/// +/// Exit codes: 0 clean / 1 warn-only / 2 error-or-analysis-failure / 3 resource-limit. +fn exit_by_severity(result: &mds::LintResult) { + let exit = result_exit_code(result); + if exit != 0 { + std::process::exit(exit); + } +} + +// ── Atomic write ────────────────────────────────────────────────────────────── + +/// Write `content` to `path` atomically via a temp file in the same directory. +/// +/// Re-checks for symlink immediately before the write cycle (TOCTOU protection, +/// AC-F-21). Uses `tempfile::Builder` for the temp file so cleanup is automatic +/// on drop if the rename fails. +fn atomic_write_file(path: &Path, content: &str) -> Result<()> { + // Re-check for symlink right before writing (TOCTOU guard). + NativeFs::check_symlink(path).map_err(miette::Error::from)?; + + let parent = path.parent().unwrap_or(Path::new(".")); + // Temp file in same directory so rename is always intra-filesystem. + let mut tmp = tempfile::Builder::new() + .prefix(".mds-lint-fix-") + .suffix(".tmp") + .tempfile_in(parent) + .map_err(|e| miette::miette!("cannot create temp file in {}: {e}", parent.display()))?; + tmp.write_all(content.as_bytes()) + .map_err(|e| miette::miette!("cannot write temp file: {e}"))?; + tmp.flush() + .map_err(|e| miette::miette!("cannot flush temp file: {e}"))?; + // persist() atomically renames the temp file to the target path. + tmp.persist(path) + .map_err(|e| miette::miette!("cannot rename temp file to {}: {e}", path.display()))?; + Ok(()) +} + +// ── Fix pipeline helpers ────────────────────────────────────────────────────── + +/// Outcome of the `--fix` pipeline for one file. +enum FixFileOutcome { + Fixed { + new_source: String, + residual: mds::LintResult, + }, + Rejected { + reason: String, + original: mds::LintResult, + }, + NothingToFix { + original: mds::LintResult, + }, +} + +/// Run the `--fix` pipeline for a single file's lint result. +/// +/// `base_dir` is the file's parent (for reverify recompile). +/// +/// ## Reverify gate (AC-F-20) +/// +/// The reverify closure checks three conditions: +/// 1. Recompile-success — fixed source must still compile. +/// 2. No-new-untargeted-diagnostics — edit must not introduce new problems. +/// 3. Output byte-equality — when the original source is standalone-compilable, +/// compiled output of the fixed source must be byte-identical to the original. +/// +/// If the original source does not compile (e.g. missing runtime vars), the +/// output-diff is skipped — conditions 1 and 2 still apply, and Tier B is +/// already suggestion-only (non-standalone) in that scenario. +fn plan_and_apply_fixes( + result: mds::LintResult, + source: &str, + base_dir: &Path, + runtime_vars: Option>, + config: &mds::LintConfig, +) -> FixFileOutcome { + let is_standalone = result.is_standalone; + let plan = mds::fix::plan_fixes_with_options(&result, source, is_standalone); + + if plan.edits.is_empty() { + return FixFileOutcome::NothingToFix { original: result }; + } + + // AC-F-20 output-delta baseline: compile the original source once. + // If it fails (e.g. missing runtime vars at eval time), skip the output-diff — + // existing gates (recompile-success, no-new-diagnostics) still apply. + let original_output = + mds::compile_str_collecting_warnings(source, Some(base_dir), runtime_vars.clone()) + .ok() + .map(|r| r.output); + + let base_dir_owned = base_dir.to_path_buf(); + let config_clone = config.clone(); + let outcome = mds::fix::apply_fixes(source, plan, &result, move |fixed| { + let residual = mds::lint_str_with( + fixed, + Some(&base_dir_owned), + runtime_vars.clone(), + &config_clone, + )?; + + // AC-F-20 output byte-equality gate: refuse if the fix changes compiled output. + // All shipped auto-fixes (dup-import/dup-export removal, empty-block removal, + // dead-branch removal) are output-neutral by design — any delta indicates a bug. + if let Some(ref orig_out) = original_output { + match mds::compile_str_collecting_warnings( + fixed, + Some(&base_dir_owned), + runtime_vars.clone(), + ) { + Ok(fixed_compile) if fixed_compile.output != *orig_out => { + return Err(MdsError::Io { + message: "lint --fix would change compiled output; \ + batch refused to preserve template semantics" + .to_string(), + }); + } + _ => {} // outputs match, or fixed source didn't compile (lint_str_with caught it) + } + } + + Ok(residual) + }); + + match outcome { + mds::fix::FixOutcome::Fixed { + source: new_source, + residual, + } => FixFileOutcome::Fixed { + new_source, + residual, + }, + mds::fix::FixOutcome::Rejected { source: _, reason } => FixFileOutcome::Rejected { + reason, + original: result, + }, + mds::fix::FixOutcome::NothingToFix => FixFileOutcome::NothingToFix { original: result }, + } +} + +// ── Stdin mode ──────────────────────────────────────────────────────────────── + +fn run_lint_stdin( + flags: LintFlags, + runtime_vars: Option>, +) -> Result<()> { + let LintFlags { + fix, quiet, format, .. + } = flags; + + let (source, cwd) = read_stdin()?; + // mds.json load/parse failure → JSON envelope in --format json mode (AC-F-14). + let config = match load_lint_config(&cwd) { + Ok(c) => c, + Err(e) => { + let mds_err = MdsError::Io { + message: format!("{e}"), + }; + emit_analysis_failure_json_or_stderr(&mds_err, format); + std::process::exit(2); + } + }; + + let result = match mds::lint_str_with(&source, Some(&cwd), runtime_vars.clone(), &config) { + Ok(r) => r, + Err(e) => { + emit_analysis_failure_json_or_stderr(&e, format); + std::process::exit(mds_error_exit_code(&e)); + } + }; + + if fix { + // --fix stdin: apply fixes, emit fixed source to stdout, diagnostics to stderr. + let fix_outcome = plan_and_apply_fixes(result, &source, &cwd, runtime_vars, &config); + let (output_src, diag_result) = match fix_outcome { + FixFileOutcome::Fixed { + new_source, + residual, + } => (new_source, residual), + FixFileOutcome::Rejected { reason, original } => { + eprintln!("fix rejected: {reason}"); + (source, original) + } + FixFileOutcome::NothingToFix { original } => (source, original), + }; + // Stdin diagnostics: no named source for span rendering (no stable filename). + render_result_human(&diag_result, quiet, None); + let _ = write_stdout(&output_src); + exit_by_severity(&diag_result); + return Ok(()); + } + + // Report-only mode. + emit_result(format, &result, quiet, None); + exit_by_severity(&result); + Ok(()) +} + +// ── Single-file mode ────────────────────────────────────────────────────────── + +fn run_lint_file( + path: &Path, + flags: LintFlags, + runtime_vars: Option>, +) -> Result<()> { + let LintFlags { + fix, + check, + diff, + quiet, + format, + } = flags; + + let base_dir = path.parent().unwrap_or(Path::new(".")); + // mds.json load/parse failure → JSON envelope in --format json mode (AC-F-14). + let config = match load_lint_config(base_dir) { + Ok(c) => c, + Err(e) => { + let mds_err = MdsError::Io { + message: format!("{e}"), + }; + emit_analysis_failure_json_or_stderr(&mds_err, format); + std::process::exit(2); + } + }; + // File read failure (not found, symlink, I/O) → JSON envelope in --format json mode (AC-F-14). + let source = match read_source_file(path) { + Ok(s) => s, + Err(e) => { + emit_analysis_failure_json_or_stderr(&e, format); + std::process::exit(mds_error_exit_code(&e)); + } + }; + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + let result = match mds::lint(path, runtime_vars.clone(), &config) { + Ok(r) => r, + Err(e) => { + emit_analysis_failure_json_or_stderr(&e, format); + std::process::exit(mds_error_exit_code(&e)); + } + }; + + if result.truncated && fix { + eprintln!( + "diagnostic cap ({}) reached; further findings were suppressed — \ + re-run --fix to continue", + mds::MAX_DIAGNOSTICS + ); + } + + // Named source for span rendering: (display filename, source text). + let named_source = Some((filename, source.as_str())); + + // ── Write path: --fix without preview ──────────────────────────────────── + if fix && !check && !diff { + let fix_outcome = plan_and_apply_fixes(result, &source, base_dir, runtime_vars, &config); + match fix_outcome { + FixFileOutcome::Fixed { + new_source, + residual, + } => { + emit_result(format, &residual, quiet, named_source); + if !quiet { + eprintln!("Fixed: {}", path.display()); + } + atomic_write_file(path, &new_source)?; + exit_by_severity(&residual); + } + FixFileOutcome::Rejected { reason, original } => { + eprintln!("fix rejected: {reason}"); + emit_result(format, &original, quiet, named_source); + exit_by_severity(&original); + } + FixFileOutcome::NothingToFix { original } => { + emit_result(format, &original, quiet, named_source); + if !quiet && format == LintFormat::Human && original.diagnostics.is_empty() { + eprintln!("Clean: {filename}"); + } + exit_by_severity(&original); + } + } + return Ok(()); + } + + // ── Preview path: --fix --check and/or --fix --diff ─────────────────────── + if fix && (check || diff) { + let is_standalone = result.is_standalone; + let plan = mds::fix::plan_fixes_with_options(&result, &source, is_standalone); + if !plan.edits.is_empty() { + if diff { + let label = path.display().to_string(); + let fixed = mds::fix::apply_plan_unchecked(&source, &plan); + let diff_str = render_diff_lint(&source, &fixed, &label); + let _ = write_stdout(&diff_str); + } + if check { + if !quiet { + eprintln!("Would fix: {}", path.display()); + } + std::process::exit(1); + } + } + // After diff-only preview, render diagnostics and exit by severity. + emit_result(format, &result, quiet, named_source); + exit_by_severity(&result); + return Ok(()); + } + + // ── Report-only mode (no --fix) ─────────────────────────────────────────── + emit_result(format, &result, quiet, named_source); + if !quiet && format == LintFormat::Human && result.diagnostics.is_empty() { + eprintln!("Clean: {filename}"); + } + exit_by_severity(&result); + Ok(()) +} + +// ── Directory mode ──────────────────────────────────────────────────────────── + +/// Aggregate exit-code category for directory mode. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum FileTally { + Clean = 0, + WarnOnly = 1, + Error = 2, + ResourceLimit = 3, +} + +impl FileTally { + fn exit_code(self) -> i32 { + self as i32 + } +} + +fn tally_from_result(result: &mds::LintResult) -> FileTally { + match result_exit_code(result) { + 2 => FileTally::Error, + 1 => FileTally::WarnOnly, + _ => FileTally::Clean, + } +} + +/// Lint every `.mds` file under `dir`, INCLUDING `_`-prefixed partials. +/// +/// Accumulate-and-continue past per-file failures. Exit = max severity across files. +/// Output is path-sorted (F1 — `collect_mds_files` does NOT sort). +fn run_lint_directory( + dir: &Path, + flags: LintFlags, + runtime_vars: Option>, +) -> Result<()> { + const MAX_DEPTH: usize = 64; + let LintFlags { quiet, format, .. } = flags; + + // mds.json load/parse failure → JSON envelope in --format json mode (AC-F-14). + let config = match load_lint_config(dir) { + Ok(c) => c, + Err(e) => { + let mds_err = MdsError::Io { + message: format!("{e}"), + }; + emit_analysis_failure_json_or_stderr(&mds_err, format); + std::process::exit(2); + } + }; + + let mut files = collect_mds_files(dir, MAX_DEPTH, None); + + if files.is_empty() { + if !quiet { + eprintln!("No .mds files found in {}", dir.display()); + } + return Ok(()); + } + + // F1: path-sort explicitly — collect_mds_files does NOT guarantee order. + files.sort(); + + let mut max_tally = FileTally::Clean; + let mut json_files: Vec = Vec::new(); + let mut any_truncated = false; + + for file in &files { + let tally = if format == LintFormat::Json { + lint_one_file_accumulating( + file, + flags, + &runtime_vars, + &config, + &mut json_files, + &mut any_truncated, + ) + } else { + lint_one_file_human(file, flags, &runtime_vars, &config, &mut any_truncated) + }; + if tally > max_tally { + max_tally = tally; + } + } + + if format == LintFormat::Json { + let json = serde_json::json!({ + "version": 1, + "files": json_files, + "truncated": any_truncated, + }); + let _ = write_stdout(&format!( + "{}\n", + serde_json::to_string(&json).expect("canonical lint JSON is always serializable") + )); + } + + if max_tally.exit_code() != 0 { + std::process::exit(max_tally.exit_code()); + } + Ok(()) +} + +/// Lint one file in directory mode, accumulating results into a JSON array. +fn lint_one_file_accumulating( + file: &Path, + flags: LintFlags, + runtime_vars: &Option>, + config: &mds::LintConfig, + json_files: &mut Vec, + any_truncated: &mut bool, +) -> FileTally { + let LintFlags { + fix, check, diff, .. + } = flags; + + // `source` is only consumed in the fix branch (below); the report-only/JSON + // path does not need it — mds::lint() reads the file independently (I-06). + let base_dir = file.parent().unwrap_or(Path::new(".")); + + let result = match mds::lint(file, runtime_vars.clone(), config) { + Ok(r) => r, + Err(ref e) => { + json_files.push(serde_json::json!({ + "file": file.display().to_string(), + "error": e.serialize() + })); + return if matches!(e, MdsError::ResourceLimit { .. }) { + FileTally::ResourceLimit + } else { + FileTally::Error + }; + } + }; + + if result.truncated { + *any_truncated = true; + if fix { + eprintln!( + "{}: diagnostic cap ({}) reached; further findings were suppressed — \ + re-run --fix to continue", + file.display(), + mds::MAX_DIAGNOSTICS + ); + } + } + + if fix && !check && !diff { + // Read source here only (not earlier) — the report-only path does not need + // it. On a read error at this point (rare: lint just succeeded above), + // surface the same error format as other per-file I/O failures and bail. + let source = match read_source_file(file) { + Ok(s) => s, + Err(e) => { + // Per-file I/O failure in directory mode: accumulate structured error (AC-F-14). + json_files.push(serde_json::json!({ + "file": file.display().to_string(), + "error": e.serialize() + })); + return FileTally::Error; + } + }; + let fix_outcome = + plan_and_apply_fixes(result, &source, base_dir, runtime_vars.clone(), config); + match fix_outcome { + FixFileOutcome::Fixed { + new_source, + residual, + } => { + accumulate_result_json(&residual, json_files); + if let Err(e) = atomic_write_file(file, &new_source) { + eprintln!("error writing {}: {e}", file.display()); + return FileTally::Error; + } + tally_from_result(&residual) + } + FixFileOutcome::Rejected { reason, original } => { + eprintln!("{}: fix rejected: {reason}", file.display()); + accumulate_result_json(&original, json_files); + tally_from_result(&original) + } + FixFileOutcome::NothingToFix { original } => { + accumulate_result_json(&original, json_files); + tally_from_result(&original) + } + } + } else { + accumulate_result_json(&result, json_files); + tally_from_result(&result) + } +} + +/// Lint one file in directory mode, rendering diagnostics to stderr (human mode). +fn lint_one_file_human( + file: &Path, + flags: LintFlags, + runtime_vars: &Option>, + config: &mds::LintConfig, + any_truncated: &mut bool, +) -> FileTally { + let LintFlags { + fix, + check, + diff, + quiet, + .. + } = flags; + + let source = match read_source_file(file) { + Ok(s) => s, + Err(e) => { + eprintln!("{:?}", miette::Report::from(e)); + return FileTally::Error; + } + }; + + let base_dir = file.parent().unwrap_or(Path::new(".")); + // Named source for span rendering: file display name + source text. + let file_display = file.to_str().unwrap_or(""); + let named_source = Some((file_display, source.as_str())); + + let result = match mds::lint(file, runtime_vars.clone(), config) { + Ok(r) => r, + Err(ref e) => { + eprintln!("{:?}", miette::Report::from(e.clone())); + return if matches!(e, MdsError::ResourceLimit { .. }) { + FileTally::ResourceLimit + } else { + FileTally::Error + }; + } + }; + + if result.truncated { + *any_truncated = true; + if fix { + eprintln!( + "{}: diagnostic cap ({}) reached; further findings were suppressed — \ + re-run --fix to continue", + file.display(), + mds::MAX_DIAGNOSTICS + ); + } + } + + if fix && !check && !diff { + let fix_outcome = + plan_and_apply_fixes(result, &source, base_dir, runtime_vars.clone(), config); + match fix_outcome { + FixFileOutcome::Fixed { + new_source, + residual, + } => { + render_result_human(&residual, quiet, named_source); + if !quiet { + eprintln!("Fixed: {}", file.display()); + } + if let Err(e) = atomic_write_file(file, &new_source) { + eprintln!("error writing {}: {e}", file.display()); + return FileTally::Error; + } + tally_from_result(&residual) + } + FixFileOutcome::Rejected { reason, original } => { + eprintln!("{}: fix rejected: {reason}", file.display()); + render_result_human(&original, quiet, named_source); + tally_from_result(&original) + } + FixFileOutcome::NothingToFix { original } => { + render_result_human(&original, quiet, named_source); + tally_from_result(&original) + } + } + } else { + render_result_human(&result, quiet, named_source); + tally_from_result(&result) + } +} + +// ── Shared emit helpers ─────────────────────────────────────────────────────── + +/// Emit a `LintResult` to the appropriate channel based on format. +/// +/// `named_source` is forwarded to human rendering for span context; ignored in JSON mode. +fn emit_result( + format: LintFormat, + result: &mds::LintResult, + quiet: bool, + named_source: Option<(&str, &str)>, +) { + if format == LintFormat::Json { + let json = result.to_canonical_json(); + let _ = write_stdout(&format!( + "{}\n", + serde_json::to_string(&json).expect("canonical lint JSON is always serializable") + )); + } else { + render_result_human(result, quiet, named_source); + } +} + +/// Emit an `MdsError` analysis failure. +/// JSON format → stdout envelope; human → stderr via miette. +fn emit_analysis_failure_json_or_stderr(e: &MdsError, format: LintFormat) { + if format == LintFormat::Json { + let envelope = serde_json::json!({ + "version": 1, + "error": e.serialize() + }); + let _ = write_stdout(&format!( + "{}\n", + serde_json::to_string(&envelope).expect("canonical lint JSON is always serializable") + )); + } else { + eprintln!("{:?}", miette::Report::from(e.clone())); + } +} + +/// Extract and accumulate per-file JSON entries from a `LintResult` into `json_files`. +fn accumulate_result_json(result: &mds::LintResult, json_files: &mut Vec) { + let inner = result.to_canonical_json(); + if let Some(arr) = inner["files"].as_array() { + json_files.extend(arr.iter().cloned()); + } +} + +// ── Diff rendering ──────────────────────────────────────────────────────────── + +/// Render a unified diff between `original` and `fixed` with optional colorization. +fn render_diff_lint(original: &str, fixed: &str, label: &str) -> String { + let diff = similar::TextDiff::from_lines(original, fixed); + let unified = diff + .unified_diff() + .context_radius(3) + .header(label, label) + .to_string(); + + if unified.is_empty() || !std::io::stdout().is_terminal() { + return unified; + } + colorize_unified_diff(&unified) +} + +fn colorize_unified_diff(unified: &str) -> String { + const RED: &str = "\x1b[31m"; + const GREEN: &str = "\x1b[32m"; + const CYAN: &str = "\x1b[36m"; + const RESET: &str = "\x1b[0m"; + + let mut out = String::with_capacity(unified.len() + 64); + let mut in_hunk = false; + for line in unified.split_inclusive('\n') { + let color = if line.starts_with("@@") { + in_hunk = true; + CYAN + } else if !in_hunk && (line.starts_with("---") || line.starts_with("+++")) { + CYAN + } else if in_hunk && line.starts_with('+') { + GREEN + } else if in_hunk && line.starts_with('-') { + RED + } else { + "" + }; + if color.is_empty() { + out.push_str(line); + continue; + } + out.push_str(color); + match line.strip_suffix('\n') { + Some(stripped) => { + out.push_str(stripped); + out.push_str(RESET); + out.push('\n'); + } + None => { + out.push_str(line); + out.push_str(RESET); + } + } + } + out +} diff --git a/crates/mds-cli/src/main.rs b/crates/mds-cli/src/main.rs index 630c4af..7d372f6 100644 --- a/crates/mds-cli/src/main.rs +++ b/crates/mds-cli/src/main.rs @@ -6,6 +6,7 @@ use miette::Result; mod build; mod fmt; +mod lint; mod output; mod watch; @@ -105,6 +106,41 @@ enum Commands { #[arg(long)] diff: bool, }, + /// Check MDS files for style and correctness issues beyond `mds check` + /// + /// Runs 9 static-analysis rules (3 error-level, 6 warning-level) on the file + /// without executing it. Partials and imported files are included in directory mode. + /// + /// Exit codes: 0 = clean, 1 = warnings only, 2 = errors or analysis failure, + /// 3 = resource limit. + #[command( + after_help = "Examples:\n mds lint template.mds Lint a single file\n mds lint . Lint all .mds files recursively\n mds lint --fix template.mds Fix auto-fixable issues in place\n mds lint --fix --check template.mds Preview fixes (exit 1 if any would apply)\n mds lint --fix --diff template.mds Show diff of pending fixes\n mds lint --format json template.mds Machine-readable JSON output\n mds lint --quiet template.mds Suppress warnings; exit 2 on errors only\n cat template.mds | mds lint - Lint from stdin\n cat template.mds | mds lint --fix - Fix from stdin, write fixed source to stdout" + )] + Lint { + /// Input .mds file, directory, or `-` for stdin (omit to auto-detect) + input: Option, + /// Apply auto-fixable issues in place (Tier A always; Tier B when standalone) + #[arg(long)] + fix: bool, + /// With --fix: exit 1 if any file would change; never writes + #[arg(long, requires = "fix")] + check: bool, + /// With --fix: print unified diff of pending changes; never writes + #[arg(long, requires = "fix")] + diff: bool, + /// Output format: `human` (default, stderr) or `json` (stdout) + #[arg(long = "format", value_name = "FORMAT", default_value = "human")] + format: String, + /// JSON file with runtime variable overrides + #[arg(long)] + vars: Option, + /// Set a runtime variable (repeatable, e.g. --set name=Alice) + #[arg(long = "set", value_name = "KEY=VALUE", value_parser = parse_key_value)] + set_vars: Vec<(String, String)>, + /// Set a runtime variable as a string (repeatable, no type coercion) + #[arg(long = "set-string", value_name = "KEY=VALUE", value_parser = parse_key_value)] + set_string_vars: Vec<(String, String)>, + }, /// Create a starter MDS file Init { /// Output filename @@ -364,6 +400,38 @@ fn run(cli: Cli) -> Result<()> { diff, quiet, }), + Commands::Lint { + input, + fix, + check, + diff, + format, + vars, + set_vars, + set_string_vars, + } => { + let fmt = match format.as_str() { + "human" => lint::LintFormat::Human, + "json" => lint::LintFormat::Json, + other => { + eprintln!( + "error: unknown --format value '{other}'; expected 'human' or 'json'" + ); + std::process::exit(2); + } + }; + lint::run_lint(lint::LintArgs { + input, + fix, + check, + diff, + quiet, + format: fmt, + vars, + set_vars, + set_string_vars, + }) + } Commands::Init { filename, force } => run_init(filename, force, quiet), Commands::Watch { input, diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs new file mode 100644 index 0000000..d690af3 --- /dev/null +++ b/crates/mds-cli/tests/cli_lint.rs @@ -0,0 +1,818 @@ +//! Integration tests for `mds lint` (issue #61). +//! +//! Coverage maps to acceptance criteria (issue #61): +//! - L-CLI-CHAN1: clean file → exit 0, diagnostics to stderr, stdout empty (human) +//! - L-CLI-CHAN2: warn-only file → exit 1, warning on stderr +//! - L-CLI-CHAN3: error-severity file → exit 2, error on stderr +//! - L-CLI-CHAN4: analysis gate failure → exit 2 (not lint exit 1) +//! - L-CLI-JSON1: --format json clean → exit 0, JSON to stdout, stderr empty +//! - L-CLI-JSON2: --format json warn → exit 1, JSON with diagnostics to stdout +//! - L-CLI-JSON3: --format json gate failure → exit 2, error envelope to stdout +//! - L-CLI-JSON4: --format json nonexistent path → exit 2, JSON error envelope stdout (AC-F-14) +//! - L-CLI-JSON5: --format json malformed mds.json → exit 2, JSON error envelope stdout (AC-F-14) +//! - L-CLI-FIX1: --fix applies auto-fixable issues in place (Tier A) +//! - L-CLI-FIX2: --fix --check exits 1 if fixes pending, never writes +//! - L-CLI-FIX3: block-spanning --fix is refused fail-closed; file unchanged (TEST-3) +//! - L-CLI-STDIN1: stdin (no fix) → diagnostics to stderr, stdout empty +//! - L-CLI-STDIN2: --fix stdin → fixed source to stdout, diagnostics to stderr +//! - L-CLI-VARS: --set passes runtime variables to the gate check +//! - L-CLI-QUIET1: --quiet suppresses warnings, exit 0 on clean +//! - L-CLI-DIR1: directory mode path-sorts and lints all files including partials +//! - L-CLI-RESOURCE: nesting > MAX_NESTING_DEPTH (64) → exit 3 ResourceLimit (TEST-4) +//! - L-CLI-DIR2: directory --format json files[] order is deterministic (TEST-6) +//! - I-24: unreachable-branch --fix is refused (block-spanning); file unchanged, exit 2 +//! - I-26: shadow-variable Info severity emits diagnostic and exits 0 (Info never affects exit) + +mod common; +use common::{fixture, mds_bin}; + +use std::fs; +use std::path::Path; + +/// Run `mds lint [extra_args]`, capturing stdout + stderr separately. +fn lint_path(path: &Path, extra_args: &[&str]) -> std::process::Output { + mds_bin() + .arg("lint") + .arg(path) + .args(extra_args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap() +} + +/// Run `mds lint [args]` with stdin provided as `input`. +fn lint_stdin(input: &str, extra_args: &[&str]) -> std::process::Output { + use std::io::Write; + let mut child = mds_bin() + .arg("lint") + .args(extra_args) + .arg("-") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() +} + +// ── L-CLI-CHAN1: clean file ─────────────────────────────────────────────────── + +#[test] +fn clean_file_exits_0_with_empty_stdout() { + let out = lint_path(&fixture("lint_clean.mds"), &[]); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(0), + "clean file should exit 0; stderr: {stderr}" + ); + assert!( + stdout.is_empty(), + "human mode must not write to stdout; got: {stdout}" + ); +} + +// ── L-CLI-CHAN2: warn-only file ─────────────────────────────────────────────── + +#[test] +fn warn_only_file_exits_1_with_diagnostic_on_stderr() { + let out = lint_path(&fixture("lint_warn_only.mds"), &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(1), + "warn-only file should exit 1; stderr: {stderr}" + ); + assert!( + stderr.contains("unused-variable"), + "expected unused-variable diagnostic on stderr; got: {stderr}" + ); + assert!( + stdout.is_empty(), + "human mode must not write to stdout; got: {stdout}" + ); +} + +// ── L-CLI-SPAN: span context in human render (Step 0, #61) ────────────────── + +/// Verify that miette renders span-labeled source context (source line + caret) +/// for findings with a span. Uses lint_warn_only.mds which triggers unused-variable +/// with approx_offset pointing at the `unused_key` frontmatter line. +/// +/// The rendered stderr must contain the source text from the offending line so the +/// user can see WHERE in the file the finding is. +#[test] +fn span_source_context_appears_in_human_render() { + let out = lint_path(&fixture("lint_warn_only.mds"), &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + // The source line text from the fixture's frontmatter — miette renders it + // in the source context block when labels() and with_source_code() are wired. + // The fixture's third line is: "unused_key: this key is never referenced in the body" + assert!( + stderr.contains("unused_key"), + "expected span context with 'unused_key' source text in miette render; got: {stderr}" + ); + // Miette includes the file+line reference when source is attached. + assert!( + stderr.contains("lint_warn_only.mds"), + "expected filename reference in miette span render; got: {stderr}" + ); +} + +// ── L-CLI-CHAN3: error-severity file ───────────────────────────────────────── + +#[test] +fn error_file_exits_2_with_error_diagnostic_on_stderr() { + let out = lint_path(&fixture("lint_error.mds"), &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(2), + "error-severity finding should exit 2; stderr: {stderr}" + ); + assert!( + stderr.contains("duplicate-export"), + "expected duplicate-export error on stderr; got: {stderr}" + ); + assert!( + stdout.is_empty(), + "human mode must not write to stdout; got: {stdout}" + ); +} + +// ── L-CLI-CHAN4: analysis gate failure ──────────────────────────────────────── + +#[test] +fn analysis_gate_failure_exits_2_not_lint_codes() { + let out = lint_path(&fixture("lint_gate_fail.mds"), &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(2), + "gate failure (file not found) should exit 2; stderr: {stderr}" + ); + // MdsError should be rendered; no lint-specific content + assert!( + stderr.contains("file not found") || stderr.contains("lint_nonexistent"), + "expected file-not-found error on stderr; got: {stderr}" + ); +} + +// ── L-CLI-JSON1: --format json, clean file ─────────────────────────────────── + +#[test] +fn json_format_clean_file_exits_0_with_json_to_stdout() { + let out = lint_path(&fixture("lint_clean.mds"), &["--format", "json"]); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(0), + "clean file --format json should exit 0; stderr: {stderr}" + ); + let json: serde_json::Value = + serde_json::from_str(&stdout).expect("stdout should be valid JSON"); + assert_eq!(json["version"], 1, "version must be 1"); + assert!(json["files"].is_array(), "must have files array"); + assert!( + stderr.is_empty(), + "JSON mode must not write to stderr when clean; got: {stderr}" + ); +} + +// ── L-CLI-JSON2: --format json, warn file ──────────────────────────────────── + +#[test] +fn json_format_warn_file_exits_1_with_diagnostic_json() { + let out = lint_path(&fixture("lint_warn_only.mds"), &["--format", "json"]); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(1), + "warn-only --format json should exit 1; stderr: {stderr}" + ); + let json: serde_json::Value = + serde_json::from_str(&stdout).expect("stdout should be valid JSON"); + assert_eq!(json["version"], 1); + let files = json["files"].as_array().expect("files must be array"); + assert!(!files.is_empty(), "files must be non-empty"); + let diags = files[0]["diagnostics"].as_array().unwrap(); + assert!(!diags.is_empty(), "diagnostics must be non-empty"); + assert_eq!(diags[0]["rule"], "unused-variable"); + assert_eq!(diags[0]["severity"], "warn"); + // Fixable: unused-variable is Tier C (never fixed automatically) + assert_eq!(diags[0]["fixable"], false); + assert!( + stderr.is_empty(), + "JSON mode must not write diagnostics to stderr; got: {stderr}" + ); +} + +// ── L-CLI-JSON3: --format json, gate failure ───────────────────────────────── + +#[test] +fn json_format_gate_failure_exits_2_with_error_envelope_to_stdout() { + let out = lint_path(&fixture("lint_gate_fail.mds"), &["--format", "json"]); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(2), + "gate failure --format json should exit 2" + ); + let json: serde_json::Value = + serde_json::from_str(&stdout).expect("stdout should be valid JSON error envelope"); + assert_eq!(json["version"], 1); + assert!( + json["error"].is_object(), + "error envelope must have 'error' key" + ); + // stdout only — human rendering must not appear on stdout + assert!( + !stdout.contains("help:"), + "human rendering must not appear in JSON stdout" + ); +} + +// ── L-CLI-FIX1: --fix applies auto-fixable issues ──────────────────────────── + +#[test] +fn fix_applies_auto_fixable_issues_in_place() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("lint_error.mds"); + fs::copy(fixture("lint_error.mds"), &target).unwrap(); + + let original = fs::read_to_string(&target).unwrap(); + assert!( + original.contains("@export greet\n@export greet"), + "fixture must have duplicate export" + ); + + let out = lint_path(&target, &["--fix"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(0), + "--fix should exit 0 after fixing duplicate-export; stderr: {stderr}" + ); + + let after = fs::read_to_string(&target).unwrap(); + assert!( + !after.contains("@export greet\n@export greet"), + "duplicate export should be removed after --fix; got:\n{after}" + ); + // Exactly one @export greet should remain + assert_eq!( + after.matches("@export greet").count(), + 1, + "exactly one @export greet should remain; got:\n{after}" + ); +} + +// ── L-CLI-FIX2: --fix --check exits 1 if fixes pending, never writes ───────── + +#[test] +fn fix_check_exits_1_when_fixes_pending_and_never_writes() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("lint_error.mds"); + fs::copy(fixture("lint_error.mds"), &target).unwrap(); + + let original = fs::read_to_string(&target).unwrap(); + + let out = lint_path(&target, &["--fix", "--check"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(1), + "--fix --check should exit 1 when fixes are pending; stderr: {stderr}" + ); + + // File must NOT have been modified. + let after = fs::read_to_string(&target).unwrap(); + assert_eq!(original, after, "--fix --check must not write to the file"); +} + +// ── L-CLI-STDIN1: stdin (no fix) ───────────────────────────────────────────── + +#[test] +fn stdin_mode_report_only_sends_diagnostics_to_stderr() { + let source = "@define greet(name):\n Hello {name}!\n@end\n\n@export greet\n@export greet\n"; + let out = lint_stdin(source, &[]); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(2), + "duplicate-export from stdin should exit 2; stderr: {stderr}" + ); + assert!( + stderr.contains("duplicate-export"), + "diagnostic must appear on stderr; got: {stderr}" + ); + assert!( + stdout.is_empty(), + "stdin report-only mode must not write to stdout; got: {stdout}" + ); +} + +// ── L-CLI-STDIN2: --fix stdin ──────────────────────────────────────────────── + +#[test] +fn stdin_fix_mode_writes_fixed_source_to_stdout() { + let source = "@define greet(name):\n Hello {name}!\n@end\n\n@export greet\n@export greet\n"; + let out = lint_stdin(source, &["--fix"]); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(0), + "--fix stdin should exit 0 after fixing; stderr: {stderr}" + ); + // Fixed source goes to stdout (filter mode) + assert!( + stdout.contains("@export greet"), + "fixed source must appear on stdout; got: {stdout}" + ); + // Should have exactly one @export greet + assert_eq!( + stdout.matches("@export greet").count(), + 1, + "exactly one @export should remain in fixed source; got: {stdout}" + ); + // Diagnostics go to stderr (or are empty after fix) + drop(stderr); +} + +// ── L-CLI-VARS: --set passes runtime variables ──────────────────────────────── + +#[test] +fn set_var_passes_runtime_variable_to_gate_check() { + // Without --set: gate fails (UndefinedVariable) → exit 2 + let out_no_var = lint_path(&fixture("lint_var_required.mds"), &[]); + assert_eq!( + out_no_var.status.code(), + Some(2), + "missing required_var should exit 2 (gate failure)" + ); + + // With --set required_var=foo: gate passes, finds unused_key warning → exit 1 + let out_with_var = lint_path( + &fixture("lint_var_required.mds"), + &["--set", "required_var=foo"], + ); + let stderr = String::from_utf8_lossy(&out_with_var.stderr); + assert_eq!( + out_with_var.status.code(), + Some(1), + "with required_var set, should exit 1 (unused_key warning); stderr: {stderr}" + ); + assert!( + stderr.contains("unused-variable"), + "unused_key should be flagged; got: {stderr}" + ); +} + +// ── L-CLI-QUIET1: --quiet suppresses warnings ───────────────────────────────── + +#[test] +fn quiet_flag_suppresses_warnings_on_warn_only_file() { + let out = lint_path(&fixture("lint_warn_only.mds"), &["--quiet"]); + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + // --quiet suppresses warnings; exit code is still based on severity + // (warn-only file still exits 1 — quiet only suppresses rendering) + assert_eq!( + out.status.code(), + Some(1), + "--quiet warn-only should still exit 1; stderr: {stderr}" + ); + assert!( + !stderr.contains("unused-variable"), + "--quiet should suppress warning diagnostic; got: {stderr}" + ); + assert!( + stdout.is_empty(), + "stdout must remain empty with --quiet; got: {stdout}" + ); +} + +// ── L-CLI-DIR1: directory mode ─────────────────────────────────────────────── + +#[test] +fn directory_mode_lints_all_files_including_partials() { + let dir = tempfile::tempdir().unwrap(); + // Copy only the dedicated lint fixtures into a clean temp dir + for name in &["lint_clean.mds", "lint_warn_only.mds", "_lint_partial.mds"] { + fs::copy(fixture(name), dir.path().join(name)).unwrap(); + } + + let out = lint_path(dir.path(), &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + + // Directory has a warn-only file → exit 1 + assert_eq!( + out.status.code(), + Some(1), + "directory with warn-only file should exit 1; stderr: {stderr}" + ); + // The warning from lint_warn_only.mds should appear + assert!( + stderr.contains("unused-variable"), + "unused-variable warning should appear in directory mode; got: {stderr}" + ); +} + +// ── L-CLI-USAGE-ERR: --fix --format json stdin → exit 2 ───────────────────── + +#[test] +fn fix_json_stdin_is_usage_error_exit_2() { + let out = lint_stdin("Hello {name}!", &["--fix", "--format", "json"]); + assert_eq!( + out.status.code(), + Some(2), + "--fix --format json stdin must exit 2 (usage error)" + ); + // Error message must go to stderr + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.is_empty(), + "usage error message must appear on stderr" + ); +} + +// ── L-CLI-JSON4: nonexistent path → JSON error envelope ───────────────────── +// +// AC-F-14: in --format json mode, every analysis-failure path — including +// "file not found" — must emit `{"version":1,"error":{...}}` to stdout, not a +// human message to stderr. exit 2 is unchanged. + +#[test] +fn json_format_nonexistent_path_emits_error_envelope() { + // Use a path that is guaranteed not to exist. + let out = lint_path( + Path::new("/nonexistent_mds_lint_test_12345.mds"), + &["--format", "json"], + ); + + // Exit code must be 2 (analysis failure — file not found). + assert_eq!( + out.status.code(), + Some(2), + "--format json + nonexistent path must exit 2" + ); + + // stdout must be a parseable JSON error envelope, NOT empty. + let stdout = String::from_utf8_lossy(&out.stdout); + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("stdout must be valid JSON (error envelope); parse error: {e}; stdout: {stdout}") + }); + assert_eq!( + parsed["version"].as_u64(), + Some(1), + "envelope must have version:1; got: {parsed}" + ); + let code = parsed["error"]["code"] + .as_str() + .unwrap_or_else(|| panic!("error.code must be a string; got: {parsed}")); + assert_eq!( + code, "mds::file_not_found", + "error code must be mds::file_not_found; got: {code}" + ); + + // The human error message must NOT appear on stderr (it goes to stdout in JSON mode). + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("mds::file_not_found"), + "JSON mode must not print error to stderr; got stderr: {stderr}" + ); +} + +// ── L-CLI-FIX3: block-spanning --fix refusal (TEST-3) ──────────────────────── +// +// Exercises the KB gotcha "block-spanning Tier A fixes are always refused": +// diag_to_edit() removes only the opening directive line (span-guided byte +// removal per ADR-001), orphaning the @end. The reverify gate calls +// lint_str_with on the edited source, which fails to parse (orphaned @end), +// and refuses the entire fix batch fail-closed. +// +// Fixture: lint_block_span_empty.mds — multi-line empty @define whose body is +// a single blank line (whitespace-only Text node). The empty-block rule fires +// (Tier A, Warn), the fix is attempted and refused, and the residual Warn +// finding determines exit code 1. (applies ADR-001) + +#[test] +fn fix_refused_for_block_spanning_empty_define_and_file_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("lint_block_span_empty.mds"); + fs::copy(fixture("lint_block_span_empty.mds"), &target).unwrap(); + + let original = fs::read_to_string(&target).unwrap(); + assert!( + original.contains("@define empty_fn():"), + "fixture must contain the multi-line empty @define" + ); + + let out = lint_path(&target, &["--fix"]); + let stderr = String::from_utf8_lossy(&out.stderr); + + // The fix must be refused: removing the @define line orphans @end, which + // the reverify gate catches as a parse error and rejects fail-closed. + assert!( + stderr.contains("fix rejected"), + "fix must be refused for block-spanning empty-block; got stderr: {stderr}" + ); + + // Residual finding: the original empty-block Warn survives → exit 1. + assert_eq!( + out.status.code(), + Some(1), + "exit code must reflect residual warn finding after fix refusal; got stderr: {stderr}" + ); + + // Critical: the file on disk must be left UNCHANGED. + let after = fs::read_to_string(&target).unwrap(); + assert_eq!( + original, after, + "file must be left unchanged when --fix is refused" + ); +} + +// ── L-CLI-RESOURCE: exit code 3 for ResourceLimit (TEST-4) ────────────────── +// +// Verifies that a template causing a ResourceLimit in the RESOLVER causes +// `mds lint` to exit 3. We trigger MAX_BLOCKS_PER_MODULE (256) by writing +// 257 uniquely-named @block declarations. The resolver's collect_block() +// increments a counter on each @block and fails with MdsError::ResourceLimit +// when count > MAX_BLOCKS_PER_MODULE (i.e. on the 257th block). The check +// gate propagates this error through mds::lint() and the CLI maps +// MdsError::ResourceLimit to exit code 3 via mds_error_exit_code. +// +// Note: deeply nested @if/@for blocks (> MAX_NESTING_DEPTH=64) are caught +// by the PARSER with MdsError::Syntax (exit 2, not 3); the facts walker's +// ResourceLimit for nesting is never reached because the parser fails first. +// The @block approach is used here because it triggers ResourceLimit in the +// resolver (inside the check gate), which is the correct path to exit code 3. + +#[test] +fn too_many_block_declarations_exits_3_resource_limit() { + // MAX_BLOCKS_PER_MODULE is 256; the 257th @block triggers ResourceLimit. + // All blocks must have unique names (the resolver rejects duplicates). + const BLOCK_COUNT: usize = 257; + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("many_blocks.mds"); + + let mut content = String::new(); + for i in 0..BLOCK_COUNT { + content.push_str(&format!("@block block_{i}:\n@end\n")); + } + fs::write(&target, &content).unwrap(); + + let out = lint_path(&target, &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(3), + "{BLOCK_COUNT} @block declarations must exit 3 (ResourceLimit, MAX_BLOCKS_PER_MODULE); \ + stderr: {stderr}" + ); +} + +// ── L-CLI-DIR2: directory --format json file-order determinism (TEST-6) ────── +// +// Verifies the F1 invariant: files[] in --format json directory-mode output is +// always in sorted (path-ascending) order regardless of filesystem walk order. +// Exercises the explicit `files.sort()` in run_lint_directory, which is +// required because collect_mds_files() does NOT guarantee any walk order. +// +// Files MUST carry diagnostics: to_canonical_json() only emits file entries +// for files that have at least one diagnostic — clean files are omitted from +// the files[] array entirely. We use lint_warn_only.mds (unused-variable warn) +// so all 3 copies produce entries in the output. +// +// Files are created in REVERSE alphabetical order (c→b→a) to stress-test the +// F1 sort; an absent sort would produce non-deterministic or reverse output. +// Two consecutive runs are compared to assert cross-run determinism. + +#[test] +fn directory_json_files_array_is_deterministically_sorted() { + let dir = tempfile::tempdir().unwrap(); + // Use warn-only files so each copy appears in the files[] JSON array. + // Create them in reverse alphabetical order to stress-test the F1 sort. + for name in &["lint_dir_c.mds", "lint_dir_b.mds", "lint_dir_a.mds"] { + fs::copy(fixture("lint_warn_only.mds"), dir.path().join(name)).unwrap(); + } + + // Run twice to assert cross-run determinism. + let out1 = lint_path(dir.path(), &["--format", "json"]); + let out2 = lint_path(dir.path(), &["--format", "json"]); + let stdout1 = String::from_utf8_lossy(&out1.stdout); + let stdout2 = String::from_utf8_lossy(&out2.stdout); + + assert_eq!( + out1.status.code(), + Some(1), + "directory with warn-only files must exit 1; stderr: {}", + String::from_utf8_lossy(&out1.stderr) + ); + + // Both runs must produce byte-identical output (cross-run determinism). + assert_eq!( + stdout1, stdout2, + "directory mode --format json output must be identical across two consecutive runs" + ); + + let json: serde_json::Value = + serde_json::from_str(&stdout1).expect("stdout must be valid JSON"); + let files = json["files"] + .as_array() + .expect("JSON output must have a files array"); + assert_eq!( + files.len(), + 3, + "all 3 .mds files must appear in the output; got: {files:?}" + ); + + // F1 invariant: files[] must be in sorted (path-ascending) order. + let paths: Vec<&str> = files + .iter() + .map(|f| { + f["file"] + .as_str() + .expect("each entry must have a file string") + }) + .collect(); + let mut sorted_paths = paths.clone(); + sorted_paths.sort(); + assert_eq!( + paths, sorted_paths, + "files[] must be in sorted path order (F1 invariant); got: {paths:?}" + ); +} + +// ── I-24: unreachable-branch --fix refusal (block-spanning, ADR-001) ───────── +// +// unreachable-branch is Tier A (auto-fixable per tier.rs), but the fix is always +// refused for @if blocks: diag_to_edit() removes only the opening @if directive +// line (span-guided byte removal per ADR-001), orphaning @else/@end. The reverify +// gate calls lint_str_with on the edited source, which fails to parse (orphaned +// @else), and refuses the entire fix batch fail-closed. +// +// Fixture: lint_unreachable_branch.mds — always-true @if "x" == "x": with a +// later @else branch (the later branch makes unreachable-branch fire at its +// default Error severity). After fix refusal the residual Error finding +// determines exit 2. Non-vacuous: asserts "fix rejected" in stderr, exit 2, +// AND file content byte-identical to before --fix. (applies ADR-001) + +#[test] +fn fix_refused_for_unreachable_branch_and_file_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("lint_unreachable_branch.mds"); + fs::copy(fixture("lint_unreachable_branch.mds"), &target).unwrap(); + + let original = fs::read_to_string(&target).unwrap(); + assert!( + original.contains("@if \"x\" == \"x\":"), + "fixture must contain the always-true @if condition" + ); + assert!( + original.contains("@else:"), + "fixture must contain a later @else branch" + ); + + let out = lint_path(&target, &["--fix"]); + let stderr = String::from_utf8_lossy(&out.stderr); + + // The fix must be refused: removing the @if line orphans @else/@end, caught + // by the reverify gate as a parse error and refused fail-closed (ADR-001). + assert!( + stderr.contains("fix rejected"), + "fix must be refused for block-spanning unreachable-branch; got stderr: {stderr}" + ); + + // Residual: unreachable-branch Error finding survives fix refusal → exit 2. + assert_eq!( + out.status.code(), + Some(2), + "residual Error finding after fix refusal must exit 2; got stderr: {stderr}" + ); + + // Critical: file on disk must be left UNCHANGED. + let after = fs::read_to_string(&target).unwrap(); + assert_eq!( + original, after, + "file must be unchanged when unreachable-branch --fix is refused" + ); +} + +// ── I-26: shadow-variable Info severity → diagnostic emitted, exit 0 ───────── +// +// shadow-variable is default-off and always Info severity. When enabled via +// mds.json, Info findings ARE rendered to stderr in human mode but NEVER +// contribute to the exit code. This test asserts BOTH properties: the finding +// appears in output AND the process exits 0 — catching regressions that either +// suppress the diagnostic or wrongly escalate Info to a non-zero exit. +// +// Fixture: loop_var_shadow.mds — @for item in items: shadows the frontmatter +// key `item` (all vars are defined; check gate passes). mds.json in the same +// temp dir enables shadow-variable at Info severity (the built-in default when +// explicitly configured as "info"). The fixture has no Warn/Error findings, so +// the only diagnostic is the Info shadow-variable — exit must be 0. + +#[test] +fn shadow_variable_info_emits_diagnostic_and_exits_0() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("loop_var_shadow.mds"); + fs::copy(fixture("loop_var_shadow.mds"), &target).unwrap(); + + // Enable shadow-variable at Info severity via mds.json in the same directory. + // The upward config search finds this mds.json for the fixture in the same dir. + fs::write( + dir.path().join("mds.json"), + r#"{ "lint": { "rules": { "shadow-variable": "info" } } }"#, + ) + .unwrap(); + + let out = lint_path(&target, &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + + // The shadow-variable diagnostic must be emitted (rule is enabled and fires). + assert!( + stderr.contains("shadow-variable"), + "shadow-variable Info finding must appear in stderr; got: {stderr}" + ); + + // Info severity never contributes to exit code — must exit 0. + assert_eq!( + out.status.code(), + Some(0), + "Info-severity shadow-variable must not affect exit code; got stderr: {stderr}" + ); + + // Human mode must not write to stdout. + assert!( + stdout.is_empty(), + "human mode must not write to stdout; got: {stdout}" + ); +} + +// ── L-CLI-JSON5: malformed mds.json → JSON error envelope ─────────────────── +// +// AC-F-14: config-load failure must also emit the JSON envelope to stdout in +// --format json mode, so that JSON/LSP consumers always get parseable output. + +#[test] +fn json_format_malformed_config_emits_error_envelope() { + let dir = tempfile::tempdir().unwrap(); + // A syntactically valid .mds file — lint must fail at the config stage, not parse stage. + let mds_file = dir.path().join("test.mds"); + fs::write(&mds_file, "---\nfoo: bar\n---\nHello world").unwrap(); + // Malformed mds.json in the same directory — will be found by the upward config walk. + fs::write(dir.path().join("mds.json"), "{ this is not valid json }").unwrap(); + + let out = lint_path(&mds_file, &["--format", "json"]); + + // Exit code must be 2 (config-load failure is an analysis failure). + assert_eq!( + out.status.code(), + Some(2), + "--format json + malformed mds.json must exit 2; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + + // stdout must be a parseable JSON error envelope. + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!( + "stdout must be valid JSON (error envelope); parse error: {e}; \ + stdout: {stdout}; stderr: {stderr}" + ) + }); + assert_eq!( + parsed["version"].as_u64(), + Some(1), + "envelope must have version:1; got: {parsed}" + ); + assert!( + parsed["error"]["code"].is_string(), + "error envelope must have a code field; got: {parsed}" + ); + + // The error details must NOT be printed to stderr in JSON mode. + assert!( + !stderr.contains("invalid mds.json"), + "config error must go to stdout in JSON mode, not stderr; got stderr: {stderr}" + ); +} diff --git a/crates/mds-cli/tests/fixtures/_lint_partial.mds b/crates/mds-cli/tests/fixtures/_lint_partial.mds new file mode 100644 index 0000000..0e3e79e --- /dev/null +++ b/crates/mds-cli/tests/fixtures/_lint_partial.mds @@ -0,0 +1,5 @@ +--- +partial_var: value +--- + +This is a partial file. diff --git a/crates/mds-cli/tests/fixtures/lint_block_span_empty.mds b/crates/mds-cli/tests/fixtures/lint_block_span_empty.mds new file mode 100644 index 0000000..cb778c4 --- /dev/null +++ b/crates/mds-cli/tests/fixtures/lint_block_span_empty.mds @@ -0,0 +1,3 @@ +@define empty_fn(): + +@end diff --git a/crates/mds-cli/tests/fixtures/lint_clean.mds b/crates/mds-cli/tests/fixtures/lint_clean.mds new file mode 100644 index 0000000..ce5e3ff --- /dev/null +++ b/crates/mds-cli/tests/fixtures/lint_clean.mds @@ -0,0 +1,5 @@ +--- +greeting: Hello +--- + +{greeting}, world! diff --git a/crates/mds-cli/tests/fixtures/lint_error.mds b/crates/mds-cli/tests/fixtures/lint_error.mds new file mode 100644 index 0000000..bec6245 --- /dev/null +++ b/crates/mds-cli/tests/fixtures/lint_error.mds @@ -0,0 +1,6 @@ +@define greet(name): + Hello {name}! +@end + +@export greet +@export greet diff --git a/crates/mds-cli/tests/fixtures/lint_gate_fail.mds b/crates/mds-cli/tests/fixtures/lint_gate_fail.mds new file mode 100644 index 0000000..d262bd1 --- /dev/null +++ b/crates/mds-cli/tests/fixtures/lint_gate_fail.mds @@ -0,0 +1,2 @@ +@import "./lint_nonexistent_file_xyz.mds" as lib +Hello {lib.greeting}! diff --git a/crates/mds-cli/tests/fixtures/lint_unreachable_branch.mds b/crates/mds-cli/tests/fixtures/lint_unreachable_branch.mds new file mode 100644 index 0000000..2e5d8e2 --- /dev/null +++ b/crates/mds-cli/tests/fixtures/lint_unreachable_branch.mds @@ -0,0 +1,5 @@ +@if "x" == "x": +hello +@else: +world +@end diff --git a/crates/mds-cli/tests/fixtures/lint_var_required.mds b/crates/mds-cli/tests/fixtures/lint_var_required.mds new file mode 100644 index 0000000..35d1723 --- /dev/null +++ b/crates/mds-cli/tests/fixtures/lint_var_required.mds @@ -0,0 +1,5 @@ +--- +unused_key: default +--- + +Hello {required_var}! diff --git a/crates/mds-cli/tests/fixtures/lint_warn_only.mds b/crates/mds-cli/tests/fixtures/lint_warn_only.mds new file mode 100644 index 0000000..1f612c4 --- /dev/null +++ b/crates/mds-cli/tests/fixtures/lint_warn_only.mds @@ -0,0 +1,6 @@ +--- +greeting: Hello +unused_key: this key is never referenced in the body +--- + +{greeting}, world! diff --git a/crates/mds-core/src/ast.rs b/crates/mds-core/src/ast.rs index ed7066b..a2a4849 100644 --- a/crates/mds-core/src/ast.rs +++ b/crates/mds-core/src/ast.rs @@ -276,11 +276,24 @@ pub enum ImportDirective { #[derive(Debug, Clone)] pub enum ExportDirective { /// `@export name` - Named { name: String }, + Named { + name: String, + /// Byte offset of the `@export` token in the source (for diagnostic spans). + offset: usize, + }, /// `@export name from "path"` - ReExport { name: String, path: String }, + ReExport { + name: String, + path: String, + /// Byte offset of the `@export` token in the source (for diagnostic spans). + offset: usize, + }, /// `@export * from "path"` - Wildcard { path: String }, + Wildcard { + path: String, + /// Byte offset of the `@export` token in the source (for diagnostic spans). + offset: usize, + }, } #[derive(Debug, Clone)] diff --git a/crates/mds-core/src/lib.rs b/crates/mds-core/src/lib.rs index ae30a74..40574d6 100644 --- a/crates/mds-core/src/lib.rs +++ b/crates/mds-core/src/lib.rs @@ -48,6 +48,7 @@ pub(crate) mod formatter; pub(crate) mod fs; pub(crate) mod lexer; pub(crate) mod limits; +pub(crate) mod lint; pub(crate) mod options; pub(crate) mod parser; pub(crate) mod resolver; @@ -57,6 +58,7 @@ pub(crate) mod value; pub use formatter::{format_str, format_str_with}; pub use fs::{FileSystem, NativeFs, VirtualFs}; +pub use lint::{fix, sanitize_control_chars, LintConfig, LintDiagnostic, LintResult, Severity}; pub use options::{ format_unknown_keys_error, json_type_name, parse_json_vars, reject_unknown_json_keys, VarsError, }; @@ -240,6 +242,15 @@ pub const MAX_FILE_SIZE: u64 = limits::MAX_FILE_SIZE; /// CLI binary — eliminating the duplicate definition. pub const MAX_TRAVERSAL_DEPTH: usize = limits::MAX_TRAVERSAL_DEPTH; +/// Maximum number of lint diagnostics collected per file before truncation. +/// +/// When the `lint_*` entry points accumulate this many diagnostics for a single +/// file, collection stops and `LintResult::truncated` is set to `true`. Re-run +/// after resolving visible findings to surface the rest. +/// +/// Pin guard: L-API-5 / AC-API-10. +pub const MAX_DIAGNOSTICS: usize = limits::MAX_DIAGNOSTICS; + /// Compile an MDS file, returning a [`CompileResult`]. /// /// The output shape is intrinsic to the template: a template containing any @@ -907,6 +918,150 @@ pub fn check_virtual_collecting_warnings( Ok(((), warnings)) } +// ── Lint entry points ───────────────────────────────────────────────────────── + +/// Lint an MDS source string with default options. +/// +/// Runs the check gate (resolve+validate) first: returns `Err(MdsError)` when the +/// template does not compile. On a clean gate, applies the 9 lint rules and returns +/// a `LintResult` (empty in S1 — rules arrive in S2). +/// +/// # Examples +/// +/// ```rust +/// let result = mds::lint_str("Hello!\n")?; +/// assert!(result.diagnostics.is_empty()); +/// # Ok::<(), Box>(()) +/// ``` +#[must_use = "lint findings should be used"] +pub fn lint_str(source: &str) -> Result { + lint_str_with(source, None, None, &LintConfig::default()) +} + +/// Lint an MDS source string with full options. +/// +/// `base_dir` sets the root for resolving `@import` paths; defaults to the current +/// directory. `runtime_vars` are injected into the check gate (not into lint rules). +/// `config` carries per-rule severity overrides. +/// +/// Pipeline (AC-PERF-01): check gate ONCE → lint entry source. +/// +/// # Examples +/// +/// ```rust +/// let result = mds::lint_str_with( +/// "Hello!\n", +/// None, +/// None, +/// &mds::LintConfig::default(), +/// )?; +/// assert!(result.diagnostics.is_empty()); +/// # Ok::<(), Box>(()) +/// ``` +#[must_use = "lint findings should be used"] +pub fn lint_str_with( + source: &str, + base_dir: Option<&Path>, + runtime_vars: Option>, + config: &LintConfig, +) -> Result { + let dir = resolve_base_dir(base_dir)?; + let vars = runtime_vars.unwrap_or_default(); + // Step 1: check gate — resolve+validate ONCE (AC-PERF-01). + { + let mut cache = ModuleCache::new(); + let mut warnings = vec![]; + cache.resolve_source_intrinsic(source, &dir, &vars, &mut warnings)?; + } + // Step 2: lint the entry source. + // Use the same default filename as the WASM backend so lint(source) produces + // a byte-identical "file" key across all surfaces (AC-API-06). + lint::lint_source(source, "input.mds", config) +} + +/// Lint an MDS file. +/// +/// Runs the check gate first (returns `Err` on compile failure), then applies lint +/// rules to the entry file source. +/// +/// # Examples +/// +/// ```rust,no_run +/// use std::path::Path; +/// let result = mds::lint(Path::new("template.mds"), None, &mds::LintConfig::default())?; +/// # Ok::<(), Box>(()) +/// ``` +#[must_use = "lint findings should be used"] +pub fn lint( + path: &Path, + runtime_vars: Option>, + config: &LintConfig, +) -> Result { + let path_str = path_to_str(path)?; + let vars = runtime_vars.unwrap_or_default(); + // Step 1: check gate — resolve+validate ONCE (AC-PERF-01). + { + let mut cache = ModuleCache::new(); + let mut warnings = vec![]; + cache.resolve_path_intrinsic(path_str, &vars, &mut warnings)?; + } + // Read source for lint re-parse (mirrors NativeFs::read size guard). + let bytes = + std::fs::read(path).map_err(|e| MdsError::io(format!("cannot read {path_str}: {e}")))?; + if bytes.len() as u64 > limits::MAX_FILE_SIZE { + return Err(MdsError::resource_limit(format!( + "file too large ({} bytes, max {} bytes): {path_str}", + bytes.len(), + limits::MAX_FILE_SIZE, + ))); + } + let source = String::from_utf8(bytes) + .map_err(|e| MdsError::io(format!("invalid UTF-8 in {path_str}: {e}")))?; + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(path_str); + lint::lint_source(&source, filename, config) +} + +/// Lint an entry module from a virtual filesystem. +/// +/// Runs the check gate first (returns `Err` on compile failure), then applies lint +/// rules to the entry source. +/// +/// # Examples +/// +/// ```rust +/// use std::collections::HashMap; +/// let mut modules = HashMap::new(); +/// modules.insert("main.mds".to_string(), "Hello!\n".to_string()); +/// let result = mds::lint_virtual(modules, "main.mds", None, &mds::LintConfig::default())?; +/// assert!(result.diagnostics.is_empty()); +/// # Ok::<(), Box>(()) +/// ``` +#[must_use = "lint findings should be used"] +pub fn lint_virtual( + modules: HashMap, + entry: &str, + runtime_vars: Option>, + config: &LintConfig, +) -> Result { + let vars = runtime_vars.unwrap_or_default(); + // Get the entry source before moving `modules` into the check gate. + let source = modules + .get(entry) + .ok_or_else(|| MdsError::file_not_found(entry))? + .clone(); + // Step 1: check gate — resolve+validate ONCE (AC-PERF-01). + { + let mut cache = ModuleCache::virtual_fs(modules); + let mut warnings = vec![]; + cache.resolve_virtual_intrinsic(entry, &vars, &mut warnings)?; + } + // Step 2: lint the entry source. + lint::lint_source(&source, entry, config) +} + /// Convenience wrapper around [`compile`] for callers who have a path as `&str`. /// /// Warnings (e.g. empty `@include`) are printed to stderr. @@ -981,7 +1136,7 @@ pub fn scan_imports(source: &str) -> Result, MdsError> { } ast::Node::Export( ast::ExportDirective::ReExport { path, .. } - | ast::ExportDirective::Wildcard { path }, + | ast::ExportDirective::Wildcard { path, .. }, ) => { paths.insert(path.clone()); } diff --git a/crates/mds-core/src/limits.rs b/crates/mds-core/src/limits.rs index 3b271a7..b8e2de6 100644 --- a/crates/mds-core/src/limits.rs +++ b/crates/mds-core/src/limits.rs @@ -82,6 +82,17 @@ pub(crate) const MAX_BLOCKS_PER_MODULE: usize = 256; /// Exceeding this limit surfaces as `mds::resource_limit` (P4). pub(crate) const MAX_FRONTMATTER_MERGE_DEPTH: usize = 64; +/// Maximum number of lint diagnostics collected per file. +/// +/// When the accumulated diagnostic count reaches this limit, collection stops and +/// `LintResult::truncated` is set to `true`. The truncation marker tells callers to +/// re-run after addressing visible findings (especially in `--fix` mode, where +/// remaining diagnostics may be revealed on subsequent passes). +/// +/// Re-exported as a public constant via `lib.rs` so bindings and tests can pin its +/// value (L-API-5 / AC-API-10). +pub(crate) const MAX_DIAGNOSTICS: usize = 1_000; + /// Maximum cumulative byte size of all message content produced by a `@message`-bearing template. /// /// Caps the aggregate content across the entire message array at the same ceiling as diff --git a/crates/mds-core/src/lint/config.rs b/crates/mds-core/src/lint/config.rs new file mode 100644 index 0000000..ddc747e --- /dev/null +++ b/crates/mds-core/src/lint/config.rs @@ -0,0 +1,42 @@ +//! Lint configuration: `LintConfig` with per-rule severity overrides. +//! +//! `LintConfig` is public (lives in mds-core) — the CLI converts the `mds.json` +//! `lint.rules` section into it, and all `lint_*` entry points accept a `&LintConfig`. +//! +//! **Unknown rule NAMEs** are preserved in the map (warn-and-ignore at the CLI layer). +//! **Unknown severity VALUES** fail loudly via serde deserialization error (closed enum). + +use std::collections::HashMap; + +use super::diagnostic::Severity; + +/// Per-rule severity override configuration. +/// +/// Loaded from the `lint.rules` section of `mds.json`: +/// ```json +/// { "lint": { "rules": { "unused-variable": "off", "shadow-variable": "warn" } } } +/// ``` +/// +/// Absent rules default to the engine's built-in severity (defined per rule in the +/// rule catalog). Unknown rule names in the map are preserved and may emit a +/// warn-at-CLI-layer diagnostic (so forward-compat: a new rule name from a newer +/// version of mds does not break older configs). +/// +/// Unknown severity *values* (e.g. `"verbose"`) cause a hard parse error (`exit 2`) +/// because the closed enum has no sensible fallback. +#[derive(Debug, Default, Clone)] +pub struct LintConfig { + /// Per-rule severity overrides. Key = rule name (e.g. `"unused-variable"`), + /// value = desired severity. Empty map = all rules at built-in defaults. + pub rules: HashMap, +} + +impl LintConfig { + /// Look up the configured severity for a rule name. + /// + /// Returns `None` when the rule has no explicit override — callers should fall + /// back to the rule's built-in default severity. + pub fn severity_for(&self, rule: &str) -> Option<&Severity> { + self.rules.get(rule) + } +} diff --git a/crates/mds-core/src/lint/diagnostic.rs b/crates/mds-core/src/lint/diagnostic.rs new file mode 100644 index 0000000..8852578 --- /dev/null +++ b/crates/mds-core/src/lint/diagnostic.rs @@ -0,0 +1,537 @@ +//! Lint diagnostic types: `LintDiagnostic`, `Severity`, `LintResult`. +//! +//! `LintDiagnostic` implements `std::error::Error` and `miette::Diagnostic` so it can +//! be rendered by miette at the CLI human-render boundary. The `severity()` override +//! maps our `Severity` enum to miette's rendering tiers (Error/Warning/Advice). +//! +//! **Sanitization discipline**: `sanitize_control_chars` is a render-boundary helper. +//! It is NOT called in `LintDiagnostic` constructors — the raw message is preserved +//! intact for `LintResult::to_canonical_json()` (typed serialization is safe; C0/C1 +//! bytes in JSON string values are legal and the consumer can handle them). Apply +//! `sanitize_control_chars` only at the CLI human-render step (mds-cli/src/lint.rs). + +use std::fmt; + +use crate::error::SerializedSpan; +use crate::limits::MAX_DIAGNOSTICS; + +// ── Severity ────────────────────────────────────────────────────────────────── + +/// Per-rule severity level. +/// +/// `Off` silences the rule entirely (no diagnostic emitted, exit code unaffected). +/// `Info` renders as an advice note; never affects the exit code. +/// `Warn` renders as a warning; produces exit code 1 (warning-only run). +/// `Error` renders as an error; produces exit code 2. +/// +/// Serialization: `"off"` / `"info"` / `"warn"` / `"error"` (closed enum — unknown +/// severity VALUE strings fail loudly via serde deserialization error, not +/// warn-and-ignore; only unknown rule NAMES get the lenient treatment). +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + /// Rule is silenced — no diagnostic, no exit-code contribution. + Off, + /// Informational; rendered as miette Advice (ℹ). Never affects exit code. + Info, + /// Warning; rendered as miette Warning (⚠). Produces exit code 1 when no errors present. + Warn, + /// Error; rendered as miette Error (✖). Produces exit code 2. + Error, +} + +impl fmt::Display for Severity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Severity::Off => f.write_str("off"), + Severity::Info => f.write_str("info"), + Severity::Warn => f.write_str("warn"), + Severity::Error => f.write_str("error"), + } + } +} + +// ── LintDiagnostic ──────────────────────────────────────────────────────────── + +/// A single lint finding. +/// +/// Implements `std::error::Error + miette::Diagnostic` so it can be rendered by +/// miette at the CLI boundary: `eprintln!("{:?}", miette::Report::from(diag))`. +/// The `severity()` override maps `Severity::Info` → Advice, `Warn` → Warning, +/// `Error` → Error; `Off` diagnostics are never constructed (the lint engine filters +/// them before collecting). +/// +/// Attach a named source for miette span rendering: +/// ```rust,no_run +/// // At the CLI render boundary: +/// // let diag = diag.with_source(Arc::new(miette::NamedSource::new(filename, src))); +/// ``` +/// +/// **JSON**: use `LintResult::to_canonical_json()` — never construct JSON manually. +/// **Sanitization**: apply `sanitize_control_chars` at the CLI render boundary only. +pub struct LintDiagnostic { + /// Short rule identifier, e.g. `"unused-variable"`. Becomes the miette code + /// `mds::lint::`. + pub rule: String, + /// Effective severity of this finding (never `Off` — `Off` diagnostics are not + /// collected). + pub severity: Severity, + /// Human-readable finding description. Raw — do not sanitize in the constructor. + pub message: String, + /// Optional fix hint shown below the message. + pub help: Option, + /// Source span for miette label rendering and JSON `span` field. + pub span: Option, + /// Source file path (for per-file JSON grouping and miette NamedSource). + pub file: Option, +} + +impl fmt::Debug for LintDiagnostic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LintDiagnostic") + .field("rule", &self.rule) + .field("severity", &self.severity) + .field("message", &self.message) + .field("help", &self.help) + .field("span", &self.span) + .field("file", &self.file) + .finish() + } +} + +impl fmt::Display for LintDiagnostic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[{}] {}", self.rule, self.message) + } +} + +impl std::error::Error for LintDiagnostic {} + +impl miette::Diagnostic for LintDiagnostic { + /// Dynamic code `mds::lint::` — stable across runs, usable by tooling. + fn code<'a>(&'a self) -> Option> { + Some(Box::new(format!("mds::lint::{}", self.rule))) + } + + /// Map our Severity to miette's rendering tier. + fn severity(&self) -> Option { + match self.severity { + Severity::Off => None, // should never happen; Off diagnostics are filtered + Severity::Info => Some(miette::Severity::Advice), + Severity::Warn => Some(miette::Severity::Warning), + Severity::Error => Some(miette::Severity::Error), + } + } + + fn help<'a>(&'a self) -> Option> { + self.help + .as_deref() + .map(|h| -> Box { Box::new(h) }) + } + + /// Return the finding's span as a single labeled span for miette source rendering. + /// + /// The label text is the diagnostic message so the caret points directly at the + /// offending byte range with a one-line summary. Source code is NOT embedded in + /// `LintDiagnostic` itself — attach it at the CLI render boundary via + /// `miette::Report::from(diag).with_source_code(NamedSource::new(file, src))`. + fn labels(&self) -> Option + '_>> { + let span = self.span.as_ref()?; + let labeled = miette::LabeledSpan::at( + miette::SourceSpan::new(span.offset.into(), span.length), + self.message.as_str(), + ); + Some(Box::new(std::iter::once(labeled))) + } +} + +// ── LintResult ──────────────────────────────────────────────────────────────── + +/// The result of a lint pass on one or more modules. +/// +/// `diagnostics` is the collected findings, capped at `MAX_DIAGNOSTICS` per file. +/// When `truncated` is `true`, collection was stopped early and the caller should +/// re-run after resolving visible findings. +/// +/// `is_standalone` is `true` when the entry module has no `@import` or `@extends` +/// directives — used to determine whether Tier B fixes (unused-import, unused-function) +/// are safe to apply (they change compiled output for non-standalone files because the +/// importer's compiled output depends on what the imported module exports). +#[derive(Debug)] +pub struct LintResult { + /// Collected lint findings. Never contains `Severity::Off` diagnostics. + pub diagnostics: Vec, + /// `true` when the `MAX_DIAGNOSTICS` cap was reached for at least one file. + pub truncated: bool, + /// `true` when the entry has no @import or @extends (Tier B fixes are safe). + pub is_standalone: bool, +} + +impl LintResult { + /// Produce the canonical, LSP-stable JSON wire format. + /// + /// Schema: + /// ```json + /// { + /// "version": 1, + /// "files": [ + /// { + /// "file": "", + /// "diagnostics": [ + /// { + /// "rule": "unused-variable", + /// "severity": "warn", + /// "message": "...", + /// "help": "...", + /// "fixable": false, + /// "span": { "offset": 0, "length": 5, "line": 1, "column": 1 } + /// } + /// ] + /// } + /// ], + /// "truncated": false + /// } + /// ``` + /// + /// Per-file grouping: diagnostics without a `file` are grouped under the string + /// key `""` (a defensive fallback; in practice every rule sets `file: + /// Some(..)`). + /// The `fixable` field reflects tier semantics: `true` for Tier A rules and for + /// Tier B rules when the file is standalone, `false` otherwise. + /// + /// **NEVER** build this JSON via `format!()` — use `serde_json::json!()` so + /// control characters in message/help are serialized safely. + pub fn to_canonical_json(&self) -> serde_json::Value { + use std::collections::BTreeMap; + + // Group diagnostics by file, preserving insertion order within each group. + // BTreeMap gives deterministic (sorted) file ordering in the output. + let mut by_file: BTreeMap> = BTreeMap::new(); + + for diag in &self.diagnostics { + let key = diag.file.clone().unwrap_or_else(|| "".to_string()); + let span_json = diag.span.as_ref().map(|s| { + let mut obj = serde_json::json!({ + "offset": s.offset, + "length": s.length, + }); + if let Some(line) = s.line { + obj["line"] = serde_json::Value::from(line); + } + if let Some(col) = s.column { + obj["column"] = serde_json::Value::from(col); + } + obj + }); + + let d = serde_json::json!({ + "rule": diag.rule, + "severity": diag.severity.to_string(), + "message": diag.message, + "help": diag.help, + "fixable": super::tier::is_fixable(&diag.rule, self.is_standalone), + "span": span_json, + }); + + by_file.entry(key).or_default().push(d); + } + + let files: Vec = by_file + .into_iter() + .map(|(file, diagnostics)| { + serde_json::json!({ + "file": file, + "diagnostics": diagnostics, + }) + }) + .collect(); + + serde_json::json!({ + "version": 1, + "files": files, + "truncated": self.truncated, + }) + } +} + +// ── LintResult builder ──────────────────────────────────────────────────────── + +/// Accumulate diagnostics with `MAX_DIAGNOSTICS` truncation enforcement. +/// +/// Used internally by the facts walk and rule dispatch to build a `LintResult` +/// without needing to check the cap at each call site. +pub(crate) struct LintResultBuilder { + diagnostics: Vec, + truncated: bool, +} + +impl LintResultBuilder { + pub(crate) fn new() -> Self { + LintResultBuilder { + diagnostics: Vec::new(), + truncated: false, + } + } + + /// Push a diagnostic unless the cap has been reached. + /// + /// Returns `true` when the diagnostic was accepted; `false` when the cap was + /// hit (and `truncated` is set). The caller should stop collecting when this + /// returns `false`. + /// + /// Called by rule implementations in `lint/rules/*.rs`. + pub(crate) fn push(&mut self, diag: LintDiagnostic) -> bool { + if self.diagnostics.len() >= MAX_DIAGNOSTICS { + self.truncated = true; + return false; + } + self.diagnostics.push(diag); + true + } + + pub(crate) fn build(self, is_standalone: bool) -> LintResult { + LintResult { + diagnostics: self.diagnostics, + truncated: self.truncated, + is_standalone, + } + } +} + +// ── sanitize_control_chars ──────────────────────────────────────────────────── + +/// Strip or escape C0 (U+0000–U+001F incl. ESC), DEL (U+007F), and C1 +/// (U+0080–U+009F) control characters from a string, except `\n` (U+000A) +/// and `\t` (U+0009). +/// +/// Applied ONLY at the CLI human-render boundary — NOT in `LintDiagnostic` +/// constructors. The raw message is preserved in `to_canonical_json()` output +/// because typed JSON serialization escapes control characters safely, and mutating +/// the constructor would corrupt the LSP-stable wire format. +/// +/// Replacement strategy: replace each control character with its Unicode escape +/// `\uXXXX` to make the rendered text visually safe on terminals without silently +/// dropping information that a developer might need to diagnose rule logic. +/// DEL (U+007F) is included because some terminals interpret it as a backspace, +/// which can corrupt human-readable output. +pub fn sanitize_control_chars(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + let is_c0 = ch < '\u{0020}' && ch != '\n' && ch != '\t'; + let is_del = ch == '\u{007F}'; + let is_c1 = ('\u{0080}'..='\u{009F}').contains(&ch); + if is_c0 || is_del || is_c1 { + // Replace with Unicode escape so the byte is visible but harmless. + let _ = fmt::write(&mut out, format_args!("\\u{:04X}", ch as u32)); + } else { + out.push(ch); + } + } + out +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── L-U-H2: sanitize_control_chars ─────────────────────────────────────── + + #[test] + fn sanitize_preserves_normal_text() { + assert_eq!(sanitize_control_chars("hello world"), "hello world"); + } + + #[test] + fn sanitize_preserves_newline_and_tab() { + assert_eq!(sanitize_control_chars("hello\nworld\t!"), "hello\nworld\t!"); + } + + #[test] + fn sanitize_escapes_nul() { + assert_eq!(sanitize_control_chars("a\x00b"), "a\\u0000b"); + } + + #[test] + fn sanitize_escapes_esc() { + assert_eq!(sanitize_control_chars("a\x1Bb"), "a\\u001Bb"); + } + + #[test] + fn sanitize_escapes_c1_range() { + // U+0080 is the first C1 control character. + assert_eq!(sanitize_control_chars("a\u{0080}b"), "a\\u0080b"); + // U+009F is the last C1 control character. + assert_eq!(sanitize_control_chars("a\u{009F}b"), "a\\u009Fb"); + } + + #[test] + fn sanitize_escapes_del() { + // I-14: DEL (U+007F) is interpreted by some terminals — ensure it is escaped. + assert_eq!(sanitize_control_chars("a\u{007F}b"), "a\\u007Fb"); + } + + #[test] + fn sanitize_escapes_multiple_controls() { + let input = "\x07bell\x0Dcarriage\x1Bescape\u{0085}nel"; + let output = sanitize_control_chars(input); + assert!(output.contains("\\u0007")); + assert!(output.contains("\\u000D")); + assert!(output.contains("\\u001B")); + assert!(output.contains("\\u0085")); + } + + /// L-U-H2 regression: raw message is NOT sanitized in to_canonical_json — + /// JSON serialization handles control characters safely via `serde_json`. + #[test] + fn canonical_json_raw_message_preserved() { + let result = LintResult { + diagnostics: vec![LintDiagnostic { + rule: "test-rule".to_string(), + severity: Severity::Warn, + message: "msg\x1Bwith\x00controls".to_string(), + help: None, + span: None, + file: Some("f.mds".to_string()), + }], + truncated: false, + is_standalone: false, + }; + let json = result.to_canonical_json(); + let raw_msg = json["files"][0]["diagnostics"][0]["message"] + .as_str() + .unwrap(); + // The raw bytes should appear in JSON (serde_json escapes them as \u00xx). + // Crucially, we must NOT have applied sanitize_control_chars in the constructor. + assert!( + raw_msg.contains('\x1B') || raw_msg.contains("\\u001B"), + "JSON should preserve or properly escape ESC byte, got: {raw_msg:?}" + ); + assert!( + raw_msg.contains('\x00') || raw_msg.contains("\\u0000"), + "JSON should preserve or properly escape NUL byte, got: {raw_msg:?}" + ); + } + + // ── LintResultBuilder truncation ────────────────────────────────────────── + + #[test] + fn builder_truncates_at_max_diagnostics() { + let mut builder = LintResultBuilder::new(); + for i in 0..MAX_DIAGNOSTICS { + let accepted = builder.push(LintDiagnostic { + rule: format!("r{i}"), + severity: Severity::Warn, + message: format!("m{i}"), + help: None, + span: None, + file: None, + }); + assert!(accepted, "diagnostic {i} should be accepted"); + } + // The (MAX_DIAGNOSTICS+1)-th push should be rejected. + let rejected = builder.push(LintDiagnostic { + rule: "overflow".to_string(), + severity: Severity::Warn, + message: "overflow".to_string(), + help: None, + span: None, + file: None, + }); + assert!(!rejected, "push beyond cap should return false"); + + let result = builder.build(false); + assert_eq!(result.diagnostics.len(), MAX_DIAGNOSTICS); + assert!(result.truncated, "truncated must be true when cap was hit"); + } + + // ── I-25: truncated wire format ─────────────────────────────────────────── + + #[test] + fn builder_truncated_canonical_json() { + // I-25: verify the truncated path in the serialized wire format. + // Push MAX_DIAGNOSTICS + 1 to trigger truncation, then confirm the JSON + // output has `"truncated": true` and the diagnostics array is capped. + let mut builder = LintResultBuilder::new(); + for i in 0..=MAX_DIAGNOSTICS { + builder.push(LintDiagnostic { + rule: format!("r{i}"), + severity: Severity::Warn, + message: format!("m{i}"), + help: None, + span: None, + file: Some("f.mds".to_string()), + }); + } + let result = builder.build(false); + assert!(result.truncated, "struct truncated flag must be set"); + + let json = result.to_canonical_json(); + + assert_eq!( + json["truncated"], + serde_json::Value::Bool(true), + "serialized truncated field must be true" + ); + + let diags = json["files"][0]["diagnostics"].as_array().unwrap(); + assert_eq!( + diags.len(), + MAX_DIAGNOSTICS, + "serialized diagnostics array must be capped at MAX_DIAGNOSTICS" + ); + } + + // ── LintDiagnostic miette::Diagnostic impl ──────────────────────────────── + + #[test] + fn diagnostic_code_is_namespaced() { + use miette::Diagnostic; + let diag = LintDiagnostic { + rule: "unused-variable".to_string(), + severity: Severity::Warn, + message: "x".to_string(), + help: None, + span: None, + file: None, + }; + let code = diag.code().unwrap().to_string(); + assert_eq!(code, "mds::lint::unused-variable"); + } + + #[test] + fn diagnostic_severity_maps_correctly() { + use miette::Diagnostic; + + let warn = LintDiagnostic { + rule: "r".to_string(), + severity: Severity::Warn, + message: "x".to_string(), + help: None, + span: None, + file: None, + }; + assert_eq!(warn.severity(), Some(miette::Severity::Warning)); + + let info = LintDiagnostic { + rule: "r".to_string(), + severity: Severity::Info, + message: "x".to_string(), + help: None, + span: None, + file: None, + }; + assert_eq!(info.severity(), Some(miette::Severity::Advice)); + + let err = LintDiagnostic { + rule: "r".to_string(), + severity: Severity::Error, + message: "x".to_string(), + help: None, + span: None, + file: None, + }; + assert_eq!(err.severity(), Some(miette::Severity::Error)); + } +} diff --git a/crates/mds-core/src/lint/facts.rs b/crates/mds-core/src/lint/facts.rs new file mode 100644 index 0000000..a784c96 --- /dev/null +++ b/crates/mds-core/src/lint/facts.rs @@ -0,0 +1,907 @@ +//! Facts walk: single-pass AST traversal building `AnalysisContext`. +//! +//! The facts walk visits the entry module's AST exactly ONCE, collecting all +//! information that rule functions will need (symbol tables, import maps, export +//! sets, etc.). Rules are then applied as plain functions over the completed +//! `AnalysisContext` — no second traversal required (AC-PERF-02). +//! +//! **Recursion bound**: the walk tracks nesting depth and returns a +//! `MdsError::ResourceLimit` when depth exceeds `MAX_NESTING_DEPTH` (AC-PERF-04). +//! This mirrors the parser's own depth limit to prevent stack overflow on +//! adversarially-crafted inputs. + +use std::collections::HashSet; + +use crate::ast::{ + Arg, Condition, DefineBlock, Expr, ForBlock, IfBlock, ImportDirective, IncludeDirective, + Module, Node, +}; +use crate::error::MdsError; +use crate::limits::MAX_NESTING_DEPTH; + +// ── Fact types ───────────────────────────────────────────────────────────────── + +/// Collected information about an import directive. +#[derive(Debug, Clone)] +pub struct ImportFact { + /// Raw path string as written in source (not normalized). + pub path: String, + /// Import kind (Alias / Merge / Selective). + pub kind: ImportKind, + /// Alias name for `@import "path" as alias` forms. + pub alias: Option, + /// Names for `@import { name1, name2 } from "path"` forms. + pub names: Vec, + /// Byte offset of the `@import` token in the source. + pub offset: usize, +} + +/// Import kind discriminant. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImportKind { + /// `@import "path" as alias` + Alias, + /// `@import "path"` (merge — injects all exports + `prompt` into scope) + Merge, + /// `@import { name1, name2 } from "path"` + Selective, +} + +/// Collected information about an export directive. +#[derive(Debug, Clone)] +pub struct ExportFact { + /// Export kind (Named / ReExport / Wildcard). + pub kind: ExportKind, + /// Exported name for Named/ReExport variants. + pub name: Option, + /// Path for ReExport/Wildcard variants. + pub path: Option, + /// Byte offset of the `@export` token in the source (D2 offsets). + pub offset: usize, +} + +/// Export kind discriminant. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExportKind { + /// `@export name` + Named, + /// `@export name from "path"` + ReExport, + /// `@export * from "path"` + Wildcard, +} + +/// Collected information about a `@define` block. +#[derive(Debug, Clone)] +pub struct DefineFact { + /// Function name. + pub name: String, + /// Byte offset of the `@define` token in the source. + pub offset: usize, +} + +/// A frontmatter variable key fact with approximate source span. +#[derive(Debug, Clone)] +pub struct FmVarFact { + /// The YAML key name. + pub name: String, + /// Approximate byte offset in the source (from FM content + substring search). + /// `None` when the key cannot be located via substring search. + pub approx_offset: Option, +} + +/// A shadow variable pair: a name in an inner scope that shadows an outer-scope binding. +#[derive(Debug, Clone)] +pub struct ShadowPair { + /// The shadowed variable name (same for inner and outer). + pub name: String, + /// Kind of the inner (shadowing) binding. + pub inner_kind: ShadowKind, + /// Kind of the outer (shadowed) binding. + pub outer_kind: ShadowKind, + /// Byte offset of the inner (shadowing) binding site. + pub offset: usize, +} + +/// Discriminant for what kind of binding is involved in a shadow relationship. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShadowKind { + /// A frontmatter variable key. + FmVar, + /// An import alias (`@import "path" as alias`). + ImportAlias, + /// A `@for` loop variable. + ForVar, + /// A `@define` parameter. + DefineParam, +} + +// ── AnalysisContext ──────────────────────────────────────────────────────────── + +/// Pre-sized fact set built from a single AST traversal. +/// +/// All fields are populated by `collect_facts`; rule functions read them +/// but never mutate them. +#[derive(Debug, Default)] +pub struct AnalysisContext { + /// Whether the module has any explicit `@export` directives (affecting + /// unused-function semantics — default-public modules export everything). + pub has_explicit_exports: bool, + + /// Whether the module is a partial or @extends child (suppresses unused-* rules). + pub is_partial_or_extends: bool, + + // ── Import/Export facts ─────────────────────────────────────────────────── + /// All import directives found in module body (top-level order). + pub imports: Vec, + + /// All export directives found in module body. + pub exports: Vec, + + // ── Define facts ───────────────────────────────────────────────────────── + /// All `@define` blocks found in module body. + pub defines: Vec, + + // ── Frontmatter variable facts ──────────────────────────────────────────── + /// Frontmatter variable keys (excludes reserved keys: imports, type, extends, prompt). + pub frontmatter_vars: Vec, + + // ── Usage tracking ──────────────────────────────────────────────────────── + /// Variable names referenced in body expressions + /// (`Expr::Var`, `Expr::MemberAccess::object`, `Arg::Var`, `Arg::MemberAccess::object`). + pub used_vars: HashSet, + + /// Function names directly called + /// (`Expr::Call::name`, `Arg::Call::name`). + pub used_calls: HashSet, + + /// Qualified call namespaces (`Expr::QualifiedCall::namespace`) — for alias usage. + pub used_namespaces: HashSet, + + /// @include alias names (`IncludeDirective::alias`) — for alias usage. + pub used_include_aliases: HashSet, + + // ── Shadow pairs ───────────────────────────────────────────────────────── + /// Enumerated shadow variable pairs (for `shadow-variable` rule). + pub shadow_pairs: Vec, +} + +// ── Walk scope (private) ─────────────────────────────────────────────────────── + +/// Threading state for shadow-variable pair detection during the facts walk. +struct WalkScope { + /// Frontmatter variable names (available at all times). + fm_keys: HashSet, + /// Import alias names (available at module level). + import_aliases: HashSet, + /// Stack of active @for loop variable names (innermost last). + for_var_stack: Vec, + /// Stack of key variables (key, _) in @for loops. + for_key_stack: Vec, + /// Active @define parameter names (only while inside a @define body). + define_params: HashSet, +} + +impl WalkScope { + fn new() -> Self { + WalkScope { + fm_keys: HashSet::new(), + import_aliases: HashSet::new(), + for_var_stack: Vec::new(), + for_key_stack: Vec::new(), + define_params: HashSet::new(), + } + } +} + +// ── Facts walk ──────────────────────────────────────────────────────────────── + +/// Perform a single pre-sized traversal of `module.body`, collecting all facts +/// into an `AnalysisContext`. +/// +/// `source` is the raw source string, used for frontmatter key span approximation. +/// +/// Returns `Err(MdsError::ResourceLimit)` when the AST nesting exceeds +/// `MAX_NESTING_DEPTH` (defense against adversarially crafted inputs that would +/// exhaust the call stack in recursive rule implementations). +pub(super) fn collect_facts( + module: &Module, + is_partial_or_extends: bool, + source: &str, +) -> Result { + let mut ctx = AnalysisContext { + is_partial_or_extends, + ..Default::default() + }; + + // ── 1. Pre-collect frontmatter vars ───────────────────────────────────── + if let Some(fm) = &module.frontmatter { + collect_frontmatter_vars(fm, source, &mut ctx); + } + + // ── 2. Build walk scope for shadow detection ───────────────────────────── + let mut scope = WalkScope::new(); + for fv in &ctx.frontmatter_vars { + scope.fm_keys.insert(fv.name.clone()); + } + + // ── 3. Pre-scan top-level imports into scope (for shadow detection of ForVar over ImportAlias) + for node in &module.body { + if let Node::Import(imp) = node { + match imp { + ImportDirective::Alias { alias, .. } => { + scope.import_aliases.insert(alias.clone()); + } + ImportDirective::Merge { .. } | ImportDirective::Selective { .. } => {} + } + } + } + + // ── 4. Main walk ───────────────────────────────────────────────────────── + walk_nodes(&module.body, &mut ctx, &mut scope, 0)?; + + Ok(ctx) +} + +/// Collect frontmatter variable keys from the raw YAML string. +/// +/// Reserved keys (imports, type, extends, prompt) are excluded. +/// Approximate source offsets are computed via substring search in `source`. +fn collect_frontmatter_vars(fm: &crate::ast::Frontmatter, source: &str, ctx: &mut AnalysisContext) { + // Reserved keys per Appendix A (unused-variable skip-set). + const RESERVED: &[&str] = &["imports", "type", "extends", "prompt"]; + + let yaml_result = serde_yaml_ng::from_str::(&fm.raw); + let yaml = match yaml_result { + Ok(v) => v, + Err(_) => return, // malformed YAML — skip; resolver would have caught this + }; + + let mapping = match &yaml { + serde_yaml_ng::Value::Mapping(m) => m, + _ => return, + }; + + // Find the byte offset of the frontmatter content in the source. + // Frontmatter starts after the first `---\n` line. + let fm_content_start = find_frontmatter_content_start(source); + + for (key, _) in mapping { + let serde_yaml_ng::Value::String(key_str) = key else { + continue; + }; + if RESERVED.contains(&key_str.as_str()) { + continue; + } + + // Approximate offset: search for `key_str:` at start of a line in fm content. + let approx_offset = + fm_content_start.and_then(|start| find_yaml_key_in_source(source, start, key_str)); + + ctx.frontmatter_vars.push(FmVarFact { + name: key_str.clone(), + approx_offset, + }); + } +} + +/// Find the byte offset where frontmatter YAML content starts in `source`. +/// +/// Returns `Some(offset)` pointing to the first byte after the opening `---\n`, +/// or `None` if no frontmatter fence is found. +fn find_frontmatter_content_start(source: &str) -> Option { + // The frontmatter opens with `---\n` (or `---\r\n`; handle both). + let fence_end = source + .find("---\n") + .map(|p| p + 4) + .or_else(|| source.find("---\r\n").map(|p| p + 5))?; + Some(fence_end) +} + +/// Search for a YAML key name at the start of a line in the frontmatter region. +/// +/// Returns the byte offset in `source` of the key's first character, or `None` +/// if not found. +fn find_yaml_key_in_source(source: &str, fm_start: usize, key: &str) -> Option { + if fm_start >= source.len() { + return None; + } + let fm_region = &source[fm_start..]; + let search = format!("{key}:"); + // Look for `key:` at start of a line (either at the very start or after `\n`). + let mut search_from = 0; + loop { + let rel = fm_region[search_from..].find(search.as_str())?; + let abs_rel = search_from + rel; + // Check it's at a line boundary. + let at_line_start = abs_rel == 0 || fm_region.as_bytes().get(abs_rel - 1) == Some(&b'\n'); + if at_line_start { + return Some(fm_start + abs_rel); + } + // Advance past the whole matched substring. `search` ends with an ASCII `:`, + // so `abs_rel + search.len()` always lands on a UTF-8 char boundary — advancing + // by a single byte would slice mid-character and panic when `key` (or the byte + // before the next match) is multi-byte (e.g. a non-ASCII frontmatter key that + // also appears inside an earlier quoted value). + search_from = abs_rel + search.len(); + if search_from >= fm_region.len() { + return None; + } + } +} + +/// Recursive body walker — bounded by `MAX_NESTING_DEPTH`. +/// +/// Populates `ctx` with import/export/define/usage facts and shadow pairs. +fn walk_nodes( + nodes: &[Node], + ctx: &mut AnalysisContext, + scope: &mut WalkScope, + depth: usize, +) -> Result<(), MdsError> { + if depth > MAX_NESTING_DEPTH { + return Err(MdsError::resource_limit(format!( + "template nesting exceeds maximum depth of {MAX_NESTING_DEPTH}" + ))); + } + + for node in nodes { + match node { + // ── Import directive ───────────────────────────────────────────── + Node::Import(imp) => { + collect_import_fact(imp, ctx); + } + + // ── Export directive ───────────────────────────────────────────── + Node::Export(exp) => { + ctx.has_explicit_exports = true; + collect_export_fact(exp, ctx); + } + + // ── Define block ───────────────────────────────────────────────── + Node::Define(b) => { + collect_define_fact(b, ctx, scope, depth)?; + } + + // ── If block (recurse into all branches) ───────────────────────── + Node::If(b) => { + walk_if_block(b, ctx, scope, depth)?; + } + + // ── For block ──────────────────────────────────────────────────── + Node::For(b) => { + walk_for_block(b, ctx, scope, depth)?; + } + + // ── Message block ───────────────────────────────────────────────── + Node::Message(b) => { + walk_nodes(&b.body, ctx, scope, depth + 1)?; + } + + // ── Block (template inheritance placeholder) ────────────────────── + Node::Block(b) => { + walk_nodes(&b.body, ctx, scope, depth + 1)?; + } + + // ── Interpolation ───────────────────────────────────────────────── + Node::Interpolation(i) => { + extract_expr_refs(&i.expr, ctx); + } + + // ── Include directive ───────────────────────────────────────────── + Node::Include(i) => { + collect_include_ref(i, ctx); + } + + // ── Leaf nodes (no children, no refs to collect here) ───────────── + Node::Text(_) | Node::EscapedBrace => {} + } + } + + Ok(()) +} + +fn collect_import_fact(imp: &ImportDirective, ctx: &mut AnalysisContext) { + match imp { + ImportDirective::Alias { + path, + alias, + offset, + } => { + ctx.imports.push(ImportFact { + path: path.clone(), + kind: ImportKind::Alias, + alias: Some(alias.clone()), + names: vec![], + offset: *offset, + }); + } + ImportDirective::Merge { path, offset } => { + ctx.imports.push(ImportFact { + path: path.clone(), + kind: ImportKind::Merge, + alias: None, + names: vec![], + offset: *offset, + }); + } + ImportDirective::Selective { + names, + path, + offset, + } => { + ctx.imports.push(ImportFact { + path: path.clone(), + kind: ImportKind::Selective, + alias: None, + names: names.clone(), + offset: *offset, + }); + } + } +} + +fn collect_export_fact(exp: &crate::ast::ExportDirective, ctx: &mut AnalysisContext) { + use crate::ast::ExportDirective; + match exp { + ExportDirective::Named { name, offset } => { + ctx.exports.push(ExportFact { + kind: ExportKind::Named, + name: Some(name.clone()), + path: None, + offset: *offset, + }); + } + ExportDirective::ReExport { name, path, offset } => { + ctx.exports.push(ExportFact { + kind: ExportKind::ReExport, + name: Some(name.clone()), + path: Some(path.clone()), + offset: *offset, + }); + } + ExportDirective::Wildcard { path, offset } => { + ctx.exports.push(ExportFact { + kind: ExportKind::Wildcard, + name: None, + path: Some(path.clone()), + offset: *offset, + }); + } + } +} + +fn collect_define_fact( + b: &DefineBlock, + ctx: &mut AnalysisContext, + scope: &mut WalkScope, + depth: usize, +) -> Result<(), MdsError> { + let param_names: Vec = b.params.iter().map(|p| p.name.clone()).collect(); + + ctx.defines.push(DefineFact { + name: b.name.clone(), + offset: b.offset, + }); + + // Shadow detection: @define params over FM keys or @for vars. + for pname in ¶m_names { + if scope.fm_keys.contains(pname) { + ctx.shadow_pairs.push(ShadowPair { + name: pname.clone(), + inner_kind: ShadowKind::DefineParam, + outer_kind: ShadowKind::FmVar, + offset: b.offset, + }); + } + if scope.for_var_stack.contains(pname) || scope.for_key_stack.contains(pname) { + ctx.shadow_pairs.push(ShadowPair { + name: pname.clone(), + inner_kind: ShadowKind::DefineParam, + outer_kind: ShadowKind::ForVar, + offset: b.offset, + }); + } + } + + // Recurse into @define body with params in scope. + let old_params = std::mem::replace(&mut scope.define_params, param_names.into_iter().collect()); + walk_nodes(&b.body, ctx, scope, depth + 1)?; + scope.define_params = old_params; + + Ok(()) +} + +fn walk_if_block( + b: &IfBlock, + ctx: &mut AnalysisContext, + scope: &mut WalkScope, + depth: usize, +) -> Result<(), MdsError> { + extract_condition_refs(&b.condition, ctx); + walk_nodes(&b.then_body, ctx, scope, depth + 1)?; + for (cond, branch_body) in &b.elseif_branches { + extract_condition_refs(cond, ctx); + walk_nodes(branch_body, ctx, scope, depth + 1)?; + } + if let Some(else_body) = &b.else_body { + walk_nodes(else_body, ctx, scope, depth + 1)?; + } + Ok(()) +} + +fn walk_for_block( + b: &ForBlock, + ctx: &mut AnalysisContext, + scope: &mut WalkScope, + depth: usize, +) -> Result<(), MdsError> { + // Extract iterable expression refs. + extract_expr_refs(&b.iterable, ctx); + + // Shadow detection: @for var over FM key, import alias, or outer @for var. + check_for_var_shadow(&b.var, b.offset, scope, ctx); + if let Some(kv) = &b.key_var { + check_for_var_shadow(kv, b.offset, scope, ctx); + } + + // Push @for vars onto scope stack for nested shadow detection. + scope.for_var_stack.push(b.var.clone()); + if let Some(kv) = &b.key_var { + scope.for_key_stack.push(kv.clone()); + } + + walk_nodes(&b.body, ctx, scope, depth + 1)?; + + scope.for_var_stack.pop(); + if b.key_var.is_some() { + scope.for_key_stack.pop(); + } + + Ok(()) +} + +/// Check whether a @for loop variable shadows something in scope, and record the pair. +fn check_for_var_shadow(var: &str, offset: usize, scope: &WalkScope, ctx: &mut AnalysisContext) { + if scope.fm_keys.contains(var) { + ctx.shadow_pairs.push(ShadowPair { + name: var.to_string(), + inner_kind: ShadowKind::ForVar, + outer_kind: ShadowKind::FmVar, + offset, + }); + } else if scope.import_aliases.contains(var) { + ctx.shadow_pairs.push(ShadowPair { + name: var.to_string(), + inner_kind: ShadowKind::ForVar, + outer_kind: ShadowKind::ImportAlias, + offset, + }); + } else if scope.for_var_stack.iter().any(|v| v == var) + || scope.for_key_stack.iter().any(|v| v == var) + { + ctx.shadow_pairs.push(ShadowPair { + name: var.to_string(), + inner_kind: ShadowKind::ForVar, + outer_kind: ShadowKind::ForVar, + offset, + }); + } +} + +fn collect_include_ref(i: &IncludeDirective, ctx: &mut AnalysisContext) { + ctx.used_include_aliases.insert(i.alias.clone()); +} + +// ── Expression reference extraction ────────────────────────────────────────── + +/// Extract variable and function call references from an expression. +fn extract_expr_refs(expr: &Expr, ctx: &mut AnalysisContext) { + match expr { + Expr::Var(name) => { + ctx.used_vars.insert(name.clone()); + } + Expr::MemberAccess { object, .. } => { + ctx.used_vars.insert(object.clone()); + } + Expr::Call { name, args } => { + ctx.used_calls.insert(name.clone()); + for arg in args { + extract_arg_refs(arg, ctx); + } + } + Expr::QualifiedCall { + namespace, args, .. + } => { + ctx.used_namespaces.insert(namespace.clone()); + for arg in args { + extract_arg_refs(arg, ctx); + } + } + Expr::StringLiteral(_) + | Expr::NumberLiteral(_) + | Expr::BooleanLiteral(_) + | Expr::NullLiteral => {} + } +} + +/// Extract variable and function call references from a function argument. +fn extract_arg_refs(arg: &Arg, ctx: &mut AnalysisContext) { + match arg { + Arg::Var(name) => { + ctx.used_vars.insert(name.clone()); + } + Arg::MemberAccess { object, .. } => { + ctx.used_vars.insert(object.clone()); + } + Arg::Call { name, args } => { + ctx.used_calls.insert(name.clone()); + for a in args { + extract_arg_refs(a, ctx); + } + } + Arg::StringLiteral(_) + | Arg::NumberLiteral(_) + | Arg::BooleanLiteral(_) + | Arg::NullLiteral => {} + } +} + +/// Extract variable and function call references from a condition. +fn extract_condition_refs(cond: &Condition, ctx: &mut AnalysisContext) { + match cond { + Condition::Truthy(expr) | Condition::Not(expr) => extract_expr_refs(expr, ctx), + Condition::Eq(l, r) | Condition::NotEq(l, r) => { + extract_expr_refs(l, ctx); + extract_expr_refs(r, ctx); + } + Condition::And(conds) | Condition::Or(conds) => { + for c in conds { + extract_condition_refs(c, ctx); + } + } + } +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::lexer::tokenize; + use crate::parser::parse_with_ctx; + + fn parse(src: &str) -> Module { + let tokens = tokenize(src, "test.mds").unwrap(); + parse_with_ctx(&tokens, "", src).unwrap() + } + + fn facts(src: &str) -> AnalysisContext { + let module = parse(src); + let is_partial = crate::lint::is_partial_by_name("test.mds"); + let is_extends = module.extends.is_some(); + collect_facts(&module, is_partial || is_extends, src).unwrap() + } + + #[test] + fn collect_facts_empty_module() { + let module = parse("Hello!\n"); + let ctx = collect_facts(&module, false, "Hello!\n").unwrap(); + assert!(!ctx.has_explicit_exports); + assert!(!ctx.is_partial_or_extends); + assert!(ctx.imports.is_empty()); + assert!(ctx.exports.is_empty()); + } + + #[test] + fn collect_facts_detects_explicit_exports() { + let src = "@define greet():\nhello\n@end\n@export greet\n"; + let module = parse(src); + let ctx = collect_facts(&module, false, src).unwrap(); + assert!(ctx.has_explicit_exports, "should detect @export directive"); + assert_eq!(ctx.exports.len(), 1); + assert_eq!(ctx.exports[0].name.as_deref(), Some("greet")); + } + + #[test] + fn collect_facts_partial_flag_propagated() { + let module = parse("Hello!\n"); + let ctx = collect_facts(&module, true, "Hello!\n").unwrap(); + assert!(ctx.is_partial_or_extends); + } + + /// AC-PERF-04: Nesting deeper than MAX_NESTING_DEPTH (64) returns ResourceLimit. + #[test] + fn collect_facts_depth_limit_enforced() { + use crate::ast::{Condition, Expr, IfBlock, Module, Node, TextNode}; + + let depth = MAX_NESTING_DEPTH + 1; + let mut inner: Vec = vec![Node::Text(TextNode { + text: "deep\n".to_string(), + offset: 0, + })]; + for _ in 0..depth { + inner = vec![Node::If(IfBlock { + condition: Condition::Truthy(Expr::Var("x".to_string())), + then_body: inner, + elseif_branches: vec![], + else_body: None, + offset: 0, + })]; + } + let module = Module { + frontmatter: None, + extends: None, + body: inner, + }; + + let result = collect_facts(&module, false, ""); + assert!( + result.is_err(), + "nesting depth {depth} should exceed MAX_NESTING_DEPTH={MAX_NESTING_DEPTH}" + ); + let err = result.unwrap_err(); + assert!( + err.to_string().contains("nesting"), + "error should mention nesting, got: {err}" + ); + } + + // ── Import/export collection ────────────────────────────────────────────── + + #[test] + fn collects_alias_import() { + let src = "@import \"./utils.mds\" as utils\n"; + let ctx = facts(src); + assert_eq!(ctx.imports.len(), 1); + assert_eq!(ctx.imports[0].kind, ImportKind::Alias); + assert_eq!(ctx.imports[0].alias.as_deref(), Some("utils")); + assert_eq!(ctx.imports[0].path, "./utils.mds"); + } + + #[test] + fn collects_merge_import() { + let src = "@import \"./utils.mds\"\n"; + let ctx = facts(src); + assert_eq!(ctx.imports.len(), 1); + assert_eq!(ctx.imports[0].kind, ImportKind::Merge); + assert!(ctx.imports[0].alias.is_none()); + } + + #[test] + fn collects_selective_import() { + let src = "@import { greet, farewell } from \"./utils.mds\"\n"; + let ctx = facts(src); + assert_eq!(ctx.imports.len(), 1); + assert_eq!(ctx.imports[0].kind, ImportKind::Selective); + assert_eq!(ctx.imports[0].names, vec!["greet", "farewell"]); + } + + #[test] + fn collects_define_facts() { + let src = "@define greet(name, suffix):\nhello {name}{suffix}\n@end\n"; + let ctx = facts(src); + assert_eq!(ctx.defines.len(), 1); + assert_eq!(ctx.defines[0].name, "greet"); + } + + #[test] + fn collects_var_uses_from_interpolation() { + let src = "{name}\n"; + let ctx = facts(src); + assert!( + ctx.used_vars.contains("name"), + "should collect Expr::Var ref" + ); + } + + #[test] + fn collects_call_from_interpolation() { + let src = "{greet(\"world\")}\n"; + let ctx = facts(src); + assert!(ctx.used_calls.contains("greet"), "should collect Call ref"); + } + + #[test] + fn collects_namespace_from_qualified_call() { + let src = "@import \"./lib.mds\" as lib\n{lib.greet(\"world\")}\n"; + let ctx = facts(src); + assert!( + ctx.used_namespaces.contains("lib"), + "should collect QualifiedCall namespace" + ); + } + + #[test] + fn collects_var_uses_from_condition() { + let src = "@if role == \"admin\":\nhello\n@end\n"; + let ctx = facts(src); + assert!( + ctx.used_vars.contains("role"), + "should collect var ref from condition" + ); + } + + #[test] + fn collects_var_uses_from_for_iterable() { + let src = "@define greet(items):\n@for item in items:\n{item}\n@end\n@end\n"; + let ctx = facts(src); + // `items` is referenced as @for iterable + assert!( + ctx.used_vars.contains("items"), + "should collect @for iterable as var use" + ); + } + + #[test] + fn collects_include_aliases() { + let src = "@import \"./lib.mds\" as lib\n@include lib\n"; + let ctx = facts(src); + assert!( + ctx.used_include_aliases.contains("lib"), + "should collect @include alias" + ); + } + + // ── Shadow pair detection ───────────────────────────────────────────────── + + #[test] + fn detects_for_var_shadowing_fm_key() { + let src = "---\nname: World\n---\n@for name in items:\n{name}\n@end\n"; + let ctx = facts(src); + let shadow = ctx.shadow_pairs.iter().find(|p| { + p.name == "name" + && p.inner_kind == ShadowKind::ForVar + && p.outer_kind == ShadowKind::FmVar + }); + assert!(shadow.is_some(), "should detect @for var shadowing FM key"); + } + + #[test] + fn detects_define_param_shadowing_fm_key() { + let src = "---\nuser: Alice\n---\n@define greet(user):\nhello {user}\n@end\n"; + let ctx = facts(src); + let shadow = ctx.shadow_pairs.iter().find(|p| { + p.name == "user" + && p.inner_kind == ShadowKind::DefineParam + && p.outer_kind == ShadowKind::FmVar + }); + assert!( + shadow.is_some(), + "should detect @define param shadowing FM key" + ); + } + + // ── Frontmatter var collection ──────────────────────────────────────────── + + #[test] + fn collects_frontmatter_vars_excludes_reserved() { + let src = + "---\nname: World\ntype: mds\nimports: []\nextends: base\nprompt: hi\n---\nHello!\n"; + let ctx = facts(src); + assert!( + ctx.frontmatter_vars.iter().any(|v| v.name == "name"), + "should collect 'name' var" + ); + for reserved in &["type", "imports", "extends", "prompt"] { + assert!( + !ctx.frontmatter_vars.iter().any(|v| &v.name == reserved), + "should exclude reserved key '{reserved}'" + ); + } + } + + /// Regression: a non-ASCII frontmatter key that ALSO appears inside an earlier + /// quoted value must not panic the key-offset search. The old `abs_rel + 1` + /// advance sliced mid-character (`byte index N is not a char boundary`). + #[test] + fn non_ascii_frontmatter_key_does_not_panic() { + let src = "---\nintro: \"say évar: now\"\névar: 1\n---\nHello\n"; + // Must not panic during the facts walk. + let ctx = facts(src); + assert!( + ctx.frontmatter_vars.iter().any(|v| v.name == "évar"), + "should collect the non-ASCII frontmatter key without panicking" + ); + } +} diff --git a/crates/mds-core/src/lint/fix.rs b/crates/mds-core/src/lint/fix.rs new file mode 100644 index 0000000..8bb9ac1 --- /dev/null +++ b/crates/mds-core/src/lint/fix.rs @@ -0,0 +1,977 @@ +//! Lint `--fix` planner: tiered fix generation, overlap detection, and reverify gate. +//! +//! ## Tier contract (T5 / AC-F-18) +//! +//! | Tier | Rules | Semantics | +//! |------|---------------------------------------------------|-----------| +//! | A | duplicate-import, duplicate-export, | Auto-fixable (span-removal); gated by reverify | +//! | | unreachable-branch, empty-block | | +//! | B | unused-import, unused-function | Fixable only when a recompile-diff proves output-neutral | +//! | C | unused-variable, redundant-else, shadow-variable | NEVER fixed (report-only) | +//! +//! ## ADR-001 discipline +//! +//! All edits are span-guided byte rewrites of the ORIGINAL source string. +//! AST re-serialization is NEVER performed. Edits are byte-range removals on the +//! raw source (the AST span tells us exactly which bytes to remove). +//! +//! ## CRLF discipline (AC-F-24) +//! +//! Line-removal spans must consume the COMPLETE line terminator (`\r\n`, `\r`, +//! or `\n`). A `\n`-only assumption leaves stray `\r` bytes that the reverify +//! gate cannot catch (the compiled output would be output-equivalent). +//! See [`extend_span_to_line_end`]. +//! +//! ## fix.rs is pure (no I/O) +//! +//! File I/O and atomic writes are the CLI's responsibility. `fix.rs` operates +//! entirely on in-memory byte slices. The caller owns the file read and write. +//! +//! ## Overlap detection (AC-F-19) +//! +//! Edit spans may not overlap. Overlapping edits are rejected fail-closed — when +//! any overlap is detected, the ENTIRE batch is abandoned (no partial write). +//! +//! ## Reverify gate (AC-F-20) +//! +//! After applying all non-overlapping edits right-to-left (one single pass), +//! the caller invokes a reverify callback with the fixed source. The fix is +//! REFUSED if the callback reports: +//! - A new compile error (not targeted by the original fixes) +//! - A new lint diagnostic not present in the original result +//! - Any compiled-output delta (for Tier B rules) +//! +//! ## Idempotence note (AC-F-25) +//! +//! When `LintResult::truncated` is true, applying fixes and re-running lint may +//! surface previously-suppressed diagnostics. The idempotence guarantee holds only +//! for non-truncated results. + +use crate::error::MdsError; +use crate::lint::diagnostic::{LintDiagnostic, LintResult, Severity}; + +// Tier classification lives in the leaf `tier` module to break the would-be +// circular dependency (fix.rs → diagnostic.rs → fix.rs). Re-export here so +// the public API surface at `mds::fix::FixTier` etc. is unchanged. +pub use super::tier::{is_fixable, rule_tier, FixTier}; + +// ── Fix plan ───────────────────────────────────────────────────────────────── + +/// A single byte-range edit on the source string. +/// +/// The edit removes the bytes in `[start, end)` from the source. The +/// `end` is exclusive; the removed range must not exceed the source length. +/// +/// **CRLF note**: `end` must be chosen to include the complete line terminator +/// (call [`extend_to_line_end`] to adjust if needed). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct ByteEdit { + /// Inclusive start byte offset of the removal. + pub start: usize, + /// Exclusive end byte offset of the removal. + pub end: usize, + /// Rule that generated this edit (for audit/logging). + pub rule: String, +} + +/// A plan of fix edits for a single file's source. +#[derive(Debug, Default)] +pub struct FixPlan { + /// Sorted, non-overlapping byte edits to apply right-to-left. + /// `None` before planning; `Some(edits)` after `plan_fixes` succeeds. + pub edits: Vec, + /// `true` if overlap detection rejected the batch. + pub overlap_rejected: bool, + /// `true` if the source `LintResult` was truncated (idempotence caveat). + pub truncated: bool, +} + +// ── Outcome ─────────────────────────────────────────────────────────────────── + +/// The outcome of applying a `FixPlan` to a source string. +#[derive(Debug)] +pub enum FixOutcome { + /// All edits applied successfully; the fixed source is returned. + Fixed { + /// The fixed source string. + source: String, + /// Residual diagnostics after applying fixes (from reverify). + residual: LintResult, + }, + /// The edit batch was rejected (overlap detected or reverify failed). + Rejected { + /// The original (unchanged) source. + source: String, + /// Human-readable reason for rejection. + reason: String, + }, + /// No fixable edits were found in the lint result. + NothingToFix, +} + +// ── Planning ───────────────────────────────────────────────────────────────── + +/// Build a `FixPlan` from a `LintResult` and the source string. +/// +/// Only Tier A diagnostics are planned here — Tier B requires a caller-supplied +/// `is_standalone` flag that this pure planner doesn't have. Callers handling +/// Tier B should call `plan_fixes_with_options`. +/// +/// The returned plan contains sorted, non-overlapping edits or sets +/// `overlap_rejected = true` if overlapping spans were detected. +#[must_use = "a dropped FixPlan silently discards planned fix edits"] +pub fn plan_fixes(lint_result: &LintResult, source: &str) -> FixPlan { + plan_fixes_with_options(lint_result, source, false) +} + +/// Build a `FixPlan` with control over Tier B inclusion. +/// +/// `include_tier_b`: when `true`, Tier B edits are included (use only for +/// standalone files where a recompile-diff can be obtained). +#[must_use = "a dropped FixPlan silently discards planned fix edits"] +pub fn plan_fixes_with_options( + lint_result: &LintResult, + source: &str, + include_tier_b: bool, +) -> FixPlan { + let mut plan = FixPlan { + edits: Vec::new(), + overlap_rejected: false, + truncated: lint_result.truncated, + }; + + // Collect byte edits from fixable diagnostics. + for diag in &lint_result.diagnostics { + if diag.severity == Severity::Off { + continue; // should never happen + } + + let tier = rule_tier(&diag.rule); + let include = match tier { + FixTier::A => true, + FixTier::B => include_tier_b, + FixTier::C => false, + }; + if !include { + continue; + } + + if let Some(edit) = diag_to_edit(diag, source) { + plan.edits.push(edit); + } + } + + // Sort edits by start position (ascending). + plan.edits.sort(); + + // Overlap detection: reject the entire batch if any pair overlaps. + if has_overlapping_edits(&plan.edits) { + plan.overlap_rejected = true; + plan.edits.clear(); + } + + plan +} + +/// Convert a diagnostic to a byte edit, if the diagnostic has a span +/// that maps to a complete removable line. +/// +/// For Tier A rules, the edit removes the entire directive line containing +/// the span (including its line terminator — CRLF discipline, AC-F-24). +fn diag_to_edit(diag: &LintDiagnostic, source: &str) -> Option { + let span = diag.span.as_ref()?; + let offset = span.offset; + + // Find the start of the line containing `offset`. + // `str::get(..offset)` returns None for out-of-range or non-char-boundary + // offsets — fail-closed per ADR-001 rather than panicking on a bad span. + let prefix = source.get(..offset)?; + let line_start = prefix.rfind('\n').map(|p| p + 1).unwrap_or(0); + + // Find the end of the line (including the terminator — CRLF or LF). + let line_end = extend_to_line_end(source, offset); + + if line_start >= line_end || line_end > source.len() { + return None; + } + + Some(ByteEdit { + start: line_start, + end: line_end, + rule: diag.rule.clone(), + }) +} + +/// Extend a byte position to include the complete line terminator at or after `pos`. +/// +/// Returns the byte offset AFTER the terminator (`\r\n`, `\r`, or `\n`). +/// If `pos` is past the end of `source`, returns `source.len()`. +/// +/// **CRLF discipline (AC-F-24)**: always include `\r\n` as a unit, not just `\n`. +pub fn extend_to_line_end(source: &str, pos: usize) -> usize { + let bytes = source.as_bytes(); + // Clamp: if pos is past the end of source, start scanning from source.len(). + // This satisfies the documented contract ("if pos is past the end of source, + // returns source.len()") — applies ADR-001 fail-closed semantics. + let mut i = pos.min(bytes.len()); + // Advance to the end of the current line content (before the newline). + while i < bytes.len() && bytes[i] != b'\n' && bytes[i] != b'\r' { + i += 1; + } + // Consume the line terminator. + if i < bytes.len() { + if bytes[i] == b'\r' { + i += 1; // consume \r + if i < bytes.len() && bytes[i] == b'\n' { + i += 1; // consume \n in \r\n + } + } else { + i += 1; // consume \n + } + } + i +} + +/// Check whether any two edits in a sorted list overlap. +/// +/// Two edits `a` and `b` (with `a.start <= b.start`) overlap when `a.end > b.start`. +fn has_overlapping_edits(edits: &[ByteEdit]) -> bool { + for window in edits.windows(2) { + let a = &window[0]; + let b = &window[1]; + if a.end > b.start { + return true; + } + } + false +} + +// ── Application ─────────────────────────────────────────────────────────────── + +/// Apply a `FixPlan` to a source string, returning the fixed source. +/// +/// Edits are applied **right-to-left** (highest start offset first) in a +/// single pass, so earlier edits' offsets remain valid after later edits are +/// applied. +/// +/// # `_unchecked` suffix — ADR-001 +/// +/// This function bypasses the ADR-001 reverify gate (compile-equivalence +/// check) — it applies edits without recompiling or verifying that the fixed +/// source produces identical compiled output. Production code that writes back +/// to disk **must** use [`apply_fixes`] instead, which gates on the reverify +/// callback before returning `FixOutcome::Fixed`. `apply_plan_unchecked` is +/// provided for the `--fix --diff` / `--fix --check` diff-preview path (which +/// computes the delta without writing it) and for unit tests. Calling it on a +/// write path without a subsequent reverify is an anti-pattern — the reverify +/// gate is the only guard against a fix that accidentally changes compiled +/// semantics. +/// +/// The caller must pass `plan` with `overlap_rejected == false`; if true, +/// calling this function is a logic error (use [`apply_fixes`] which checks +/// this). +/// +/// # Panics +/// +/// Does not panic — invalid spans produce no change (the edit is skipped with +/// a `debug_assert` violation in debug builds). +pub fn apply_plan_unchecked(source: &str, plan: &FixPlan) -> String { + debug_assert!( + !plan.overlap_rejected, + "apply_plan_unchecked called on a rejected (overlapping) plan" + ); + + if plan.edits.is_empty() { + return source.to_string(); + } + + let mut result = source.as_bytes().to_vec(); + + // `plan.edits` is already sorted ascending by start offset (guaranteed by + // plan_fixes_with_options which calls `edits.sort()` before returning). + // Iterate right-to-left with `.rev()` — no clone or re-sort needed. + debug_assert!( + plan.edits.windows(2).all(|w| w[0].start <= w[1].start), + "apply_plan_unchecked: edits must be sorted ascending by start offset" + ); + for edit in plan.edits.iter().rev() { + let start = edit.start; + let end = edit.end; + if end > result.len() || start > end { + debug_assert!( + false, + "fix edit out of bounds: start={start} end={end} len={}", + result.len() + ); + continue; + } + result.drain(start..end); + } + + // Safety: source is valid UTF-8; edits remove whole character sequences + // (line-by-line removal at newline boundaries). Invalid UTF-8 after edit + // is a logic error — use lossy conversion in that case. + String::from_utf8(result) + .unwrap_or_else(|e| String::from_utf8_lossy(e.into_bytes().as_slice()).into_owned()) +} + +/// Apply a `FixPlan` with a reverify callback. +/// +/// The `reverify` callback is called with the fixed source and must return: +/// - `Ok(LintResult)`: the lint result of the fixed source (may be empty). +/// - `Err(MdsError)`: the fixed source failed the check gate. +/// +/// `original` is the lint result the plan was built from — it establishes the +/// baseline of diagnostics that already existed BEFORE any fix. Pre-existing +/// findings (e.g. a Tier C `unused-variable` that coexists with a fixable +/// `duplicate-import`) are expected to survive into the residual and must NOT +/// cause the fix to be refused (AC-F-23: residual findings determine the exit +/// code). Only a genuinely NEW untargeted diagnostic is a regression. +/// +/// The fix is REFUSED if: +/// - The plan has `overlap_rejected = true`. +/// - The `reverify` callback returns `Err`. The CLI reverify path checks three +/// conditions inside this closure (AC-F-20): (1) recompile-success — the fixed +/// source must still compile; (2) no-new-untargeted-diagnostics — the residual +/// must not introduce new findings beyond the targeted rules; (3) output +/// byte-equality — when the original source is standalone-compilable, compiled +/// output of the fixed source must be byte-identical to the original (enforced by +/// the caller returning `Err` on delta). All real auto-fixes are output-neutral +/// by design; any delta signals a bug in the fix logic and must be refused. +/// - The residual contains MORE diagnostics of an untargeted rule than `original` +/// did (i.e. the edit introduced a new, non-fixed problem). +/// +/// Returns `FixOutcome::Fixed`, `FixOutcome::Rejected`, or `FixOutcome::NothingToFix`. +#[must_use = "a dropped FixOutcome silently discards the fix result"] +pub fn apply_fixes(source: &str, plan: FixPlan, original: &LintResult, reverify: F) -> FixOutcome +where + F: FnOnce(&str) -> Result, +{ + if plan.edits.is_empty() && !plan.overlap_rejected { + return FixOutcome::NothingToFix; + } + + if plan.overlap_rejected { + return FixOutcome::Rejected { + source: source.to_string(), + reason: "Overlapping fix spans detected — batch rejected to avoid data corruption." + .to_string(), + }; + } + + let fixed_source = apply_plan_unchecked(source, &plan); + + // Build the set of rules targeted by this fix batch. + let targeted_rules: std::collections::HashSet<&str> = + plan.edits.iter().map(|e| e.rule.as_str()).collect(); + + // Local helper: count non-targeted diagnostic occurrences per rule. + // Called for both the original baseline and the post-fix residual so the + // regression check (below) can compare the two counts in one place. + fn count_untargeted_per_rule<'a>( + diags: &'a [LintDiagnostic], + targeted: &std::collections::HashSet<&str>, + ) -> std::collections::HashMap<&'a str, usize> { + let mut counts = std::collections::HashMap::new(); + for d in diags { + let rule = d.rule.as_str(); + if !targeted.contains(rule) { + *counts.entry(rule).or_insert(0) += 1; + } + } + counts + } + + // Baseline: per-rule count of NON-targeted diagnostics that were already present + // before the fix. A pre-existing untargeted finding must not trip the gate — only + // an untargeted rule whose count INCREASES is a regression the edit introduced. + let baseline = count_untargeted_per_rule(&original.diagnostics, &targeted_rules); + + // Reverify: run the lint engine on the fixed source. + match reverify(&fixed_source) { + Err(err) => FixOutcome::Rejected { + source: source.to_string(), + reason: format!("Reverify failed: fixed source does not compile: {err}"), + }, + Ok(residual) => { + // Count untargeted diagnostics in the residual, per rule. + let residual_counts = count_untargeted_per_rule(&residual.diagnostics, &targeted_rules); + + // A regression is an untargeted rule whose count grew vs. the original — + // i.e. a NEW problem the edit introduced (pre-existing findings survive + // untouched and are allowed through, per AC-F-23). + let mut regressed: Vec<&str> = Vec::new(); + for (rule, count) in &residual_counts { + if *count > baseline.get(rule).copied().unwrap_or(0) { + regressed.push(rule); + } + } + + if !regressed.is_empty() { + regressed.sort_unstable(); + return FixOutcome::Rejected { + source: source.to_string(), + reason: format!( + "Reverify produced new untargeted diagnostics: {regressed:?}. \ + Fix batch reverted." + ), + }; + } + + FixOutcome::Fixed { + source: fixed_source, + residual, + } + } + } +} + +// ── LintResult extension ────────────────────────────────────────────────────── + +/// Extension methods on `LintResult` for fix-tier metadata. +/// +/// The `fixable` flag in the canonical JSON is populated by the CLI layer based +/// on `rule_tier`. This module provides the underlying classification. +pub fn fixable_diagnostics(result: &LintResult, is_standalone: bool) -> Vec<&LintDiagnostic> { + result + .diagnostics + .iter() + .filter(|d| is_fixable(&d.rule, is_standalone)) + .collect() +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::SerializedSpan; + use crate::lint::diagnostic::{LintDiagnostic, LintResult, Severity}; + + fn make_diag(rule: &str, offset: usize, length: usize) -> LintDiagnostic { + LintDiagnostic { + rule: rule.to_string(), + severity: Severity::Error, + message: format!("test {rule}"), + help: None, + span: Some(SerializedSpan { + offset, + length, + line: None, + column: None, + }), + file: Some("test.mds".to_string()), + } + } + + fn make_result(diags: Vec) -> LintResult { + LintResult { + diagnostics: diags, + truncated: false, + is_standalone: false, + } + } + + // ── Tier classification ─────────────────────────────────────────────────── + + #[test] + fn tier_a_rules_are_fixable() { + for rule in &[ + "duplicate-import", + "duplicate-export", + "unreachable-branch", + "empty-block", + ] { + assert_eq!(rule_tier(rule), FixTier::A, "expected Tier A for {rule}"); + assert!(is_fixable(rule, false), "{rule} should be fixable (Tier A)"); + } + } + + #[test] + fn tier_b_rules_fixable_only_standalone() { + for rule in &["unused-import", "unused-function"] { + assert_eq!(rule_tier(rule), FixTier::B, "expected Tier B for {rule}"); + assert!(is_fixable(rule, true), "{rule} fixable when standalone"); + assert!( + !is_fixable(rule, false), + "{rule} not fixable when non-standalone" + ); + } + } + + #[test] + fn tier_c_rules_never_fixable() { + for rule in &["unused-variable", "redundant-else", "shadow-variable"] { + assert_eq!(rule_tier(rule), FixTier::C, "expected Tier C for {rule}"); + assert!(!is_fixable(rule, true), "{rule} should never be fixable"); + assert!(!is_fixable(rule, false), "{rule} should never be fixable"); + } + } + + // ── L-FIX-CRLF1: CRLF line-end extension ──────────────────────────────── + + #[test] + fn extend_to_line_end_lf() { + let source = "hello\nworld\n"; + // Starting at offset 0 (start of "hello"), should extend to include \n. + let end = extend_to_line_end(source, 0); + assert_eq!(end, 6, "LF: should consume hello\\n (6 bytes)"); + } + + #[test] + fn extend_to_line_end_crlf() { + let source = "hello\r\nworld\r\n"; + let end = extend_to_line_end(source, 0); + assert_eq!(end, 7, "CRLF: should consume hello\\r\\n (7 bytes)"); + } + + #[test] + fn extend_to_line_end_cr_only() { + let source = "hello\rworld\r"; + let end = extend_to_line_end(source, 0); + assert_eq!(end, 6, "CR: should consume hello\\r (6 bytes)"); + } + + /// I-09 regression: `extend_to_line_end` with `pos` past source end must return + /// `source.len()`, not `pos`. + /// + /// Doc contract: "If pos is past the end of source, returns source.len()." + /// Without `pos.min(bytes.len())`, `i` starts at `pos` and the while-loop body + /// never executes, so the function would return `pos` (out-of-range). The clamp + /// fixes this (applies ADR-001 fail-closed semantics). + #[test] + fn extend_to_line_end_past_end_returns_source_len() { + let source = "hello\n"; // 6 bytes + // pos well past end + assert_eq!( + extend_to_line_end(source, source.len() + 10), + source.len(), + "pos past end must return source.len(), not pos" + ); + // pos == source.len() (exactly at the end boundary) must also return source.len() + assert_eq!( + extend_to_line_end(source, source.len()), + source.len(), + "pos == source.len() must return source.len()" + ); + } + + /// L-FIX-CRLF1: Applying a fix on a CRLF file leaves no stray `\r` bytes. + /// + /// "Stray \r" = a `\r` NOT followed by `\n`. Remaining lines may keep their + /// own `\r\n` — that is correct CRLF discipline, not a defect. + /// + /// Source breakdown (CRLF, bytes 0-based): + /// bytes 0-26: `@import "./utils.mds" as u1` (27 bytes) + /// byte 27: `\r` + /// byte 28: `\n` + /// bytes 29-55: `@import "./utils.mds" as u2` (27 bytes) + /// byte 56: `\r` + /// byte 57: `\n` + #[test] + fn l_fix_crlf1_fix_removes_complete_crlf_terminator() { + let source = "@import \"./utils.mds\" as u1\r\n@import \"./utils.mds\" as u2\r\n"; + + // Byte 29 = start of second `@import` in CRLF source. + // (27 content bytes + \r + \n = 29 bytes for line 1) + let second_import_offset: usize = 29; + debug_assert_eq!( + &source[second_import_offset..second_import_offset + 7], + "@import", + "sanity: offset should point to second @import" + ); + + let diag = make_diag("duplicate-import", second_import_offset, "@import".len()); + let result = make_result(vec![diag]); + + let plan = plan_fixes(&result, source); + assert!( + !plan.overlap_rejected, + "should not reject non-overlapping edits" + ); + assert!(!plan.edits.is_empty(), "should produce at least one edit"); + + let fixed = apply_plan_unchecked(source, &plan); + + // The fixed source should contain no STRAY \r bytes (each \r must be followed by \n). + let bytes = fixed.as_bytes(); + for (i, &b) in bytes.iter().enumerate() { + if b == b'\r' { + assert!( + i + 1 < bytes.len() && bytes[i + 1] == b'\n', + "CRLF fix: stray \\r at position {i} in fixed source: {:?}", + fixed + ); + } + } + + // The second import should be gone; the first should remain. + assert!( + fixed.contains("as u1"), + "CRLF fix: first import should survive; got: {:?}", + fixed + ); + assert!( + !fixed.contains("as u2"), + "CRLF fix: second (duplicate) import should be removed; got: {:?}", + fixed + ); + } + + // ── L-FIX-OVL1: Overlap detection ──────────────────────────────────────── + + /// AC-F-19: two diagnostics that map to the same line produce identical + /// ByteEdits (`{ start: 0, end: 23 }`). The overlap detector fires because + /// `a.end (23) > b.start (0)`. The whole batch is rejected — edits cleared, + /// `apply_fixes` returns `FixOutcome::Rejected`. + #[test] + fn l_fix_ovl1_overlapping_edits_rejected() { + let source = + "@import \"./a.mds\" as a\n@import \"./b.mds\" as b\n@import \"./a.mds\" as c\n"; + // Both diag1 (offset 0) and diag2 (offset 2) are on the same line → same + // computed line span → overlap detected. + let diag1 = make_diag("duplicate-import", 0, "@import".len()); + let diag2 = make_diag("duplicate-import", 2, "@import".len()); + + let result = make_result(vec![diag1, diag2]); + let plan = plan_fixes(&result, source); + + // Unconditional: overlap must be detected for same-line edits (AC-F-19). + assert!( + plan.overlap_rejected, + "two edits on the same line must trigger overlap detection" + ); + assert!( + plan.edits.is_empty(), + "rejected plan must have empty edits; got: {:?}", + plan.edits + ); + + // apply_fixes on a rejected plan must return FixOutcome::Rejected + // (the reverify closure must never be called in this path). + let outcome = apply_fixes(source, plan, &result, |_| { + panic!("reverify must not be called when the batch is overlap-rejected") + }); + assert!( + matches!(outcome, FixOutcome::Rejected { .. }), + "apply_fixes on an overlap-rejected plan must return Rejected; got: {outcome:?}" + ); + } + + #[test] + fn non_overlapping_edits_not_rejected() { + let source = "@import \"./a.mds\" as a\n@import \"./a.mds\" as b\n"; + // First @import at offset 0, second at offset 23. + let diag = make_diag("duplicate-import", 23, "@import".len()); + let result = make_result(vec![diag]); + + let plan = plan_fixes(&result, source); + assert!( + !plan.overlap_rejected, + "non-overlapping edits should not be rejected" + ); + } + + // ── REL-1: slice-panic guard for non-char-boundary / out-of-range offsets ─ + + /// REL-1 regression: a diagnostic with a non-char-boundary offset must NOT + /// cause a panic. `diag_to_edit` returns `None` (edit skipped). + /// + /// `"é"` encodes to 2 bytes (U+00E9 → 0xC3 0xA9). Offset 1 splits the + /// character — `source[..1]` panics on the pre-fix code; + /// `source.get(..1)` returns `None` with the fix (applies ADR-001). + #[test] + fn rel1_non_char_boundary_offset_does_not_panic() { + let source = "é\n"; // 3 bytes: 0xC3 0xA9 0x0A + // Offset 1 is inside the multibyte 'é' — NOT a char boundary. + let diag = make_diag("duplicate-import", 1, 1); + let result = make_result(vec![diag]); + + // Must not panic — edit is skipped (None from diag_to_edit). + let plan = plan_fixes(&result, source); + assert!( + plan.edits.is_empty(), + "non-char-boundary offset must produce no edit; got: {:?}", + plan.edits + ); + assert!( + !plan.overlap_rejected, + "no overlap rejection expected when there are no valid edits" + ); + } + + /// REL-1 regression: a diagnostic with an out-of-range offset must NOT + /// cause a panic. `diag_to_edit` returns `None` (edit skipped). + #[test] + fn rel1_out_of_range_offset_does_not_panic() { + let source = "hello\n"; // 6 bytes + // Offset 100 is beyond the source length. + let diag = make_diag("duplicate-import", 100, 1); + let result = make_result(vec![diag]); + + let plan = plan_fixes(&result, source); + assert!( + plan.edits.is_empty(), + "out-of-range offset must produce no edit; got: {:?}", + plan.edits + ); + assert!( + !plan.overlap_rejected, + "no overlap rejection expected when there are no valid edits" + ); + } + + // ── L-FIX-REV1: Reverify gate ──────────────────────────────────────────── + + #[test] + fn l_fix_rev1_reverify_failure_rejects_fix() { + let source = "@import \"./a.mds\" as a\n@import \"./a.mds\" as b\n"; + let diag = make_diag("duplicate-import", 23, "@import".len()); + let result = make_result(vec![diag]); + let plan = plan_fixes(&result, source); + + // Reverify callback that always fails. + let outcome = apply_fixes(source, plan, &result, |_fixed| { + Err(MdsError::syntax("simulated compile failure after fix")) + }); + + assert!( + matches!(outcome, FixOutcome::Rejected { .. }), + "reverify failure should reject the fix" + ); + } + + /// A single non-overlapping `duplicate-import` on line 2 must plan, apply, + /// pass the (stubbed) reverify, and return `FixOutcome::Fixed`. + /// + /// The source has the second `@import` starting at byte 23 + /// (`"@import \"./a.mds\" as a\n"` = 23 bytes), which is a valid char + /// boundary, so `diag_to_edit` succeeds and the plan is non-empty. + #[test] + fn reverify_success_returns_fixed() { + let source = "@import \"./a.mds\" as a\n@import \"./a.mds\" as b\nHello!\n"; + let diag = make_diag("duplicate-import", 23, "@import".len()); + let result = make_result(vec![diag]); + let plan = plan_fixes(&result, source); + + // Preconditions (non-vacuous): the plan MUST have edits. + assert!( + !plan.edits.is_empty(), + "duplicate-import at byte 23 must produce an edit (assertion must not be vacuous)" + ); + assert!( + !plan.overlap_rejected, + "single non-overlapping edit must not be rejected" + ); + + // Reverify callback that succeeds with an empty residual. + let outcome = apply_fixes(source, plan, &result, |_fixed| { + Ok(LintResult { + diagnostics: vec![], + truncated: false, + is_standalone: true, + }) + }); + + assert!( + matches!(outcome, FixOutcome::Fixed { .. }), + "successful reverify must return Fixed outcome; got: {outcome:?}" + ); + } + + /// AC-F-23 regression guard: a pre-existing untargeted diagnostic (e.g. a Tier C + /// `unused-variable` that coexists with a fixable `duplicate-import`) survives the + /// reverify but must NOT cause the fix to be refused — residual findings are + /// expected to remain and determine the exit code. + #[test] + fn reverify_preexisting_untargeted_survives_and_fix_applies() { + let source = "@import \"./a.mds\" as a\n@import \"./a.mds\" as b\nHello!\n"; + let dup = make_diag("duplicate-import", 23, "@import".len()); // Tier A → targeted + let unused = make_diag("unused-variable", 0, 1); // Tier C → untargeted, pre-existing + let result = make_result(vec![dup, unused]); + let plan = plan_fixes(&result, source); + assert!(!plan.overlap_rejected && !plan.edits.is_empty()); + + // The untargeted unused-variable is still present after the fix — same count. + let outcome = apply_fixes(source, plan, &result, |_fixed| { + Ok(make_result(vec![make_diag("unused-variable", 0, 1)])) + }); + assert!( + matches!(outcome, FixOutcome::Fixed { .. }), + "a surviving pre-existing untargeted diagnostic must not refuse the fix" + ); + } + + /// A genuinely NEW untargeted diagnostic introduced by the edit IS a regression + /// and must refuse the fix. + #[test] + fn reverify_new_untargeted_diagnostic_is_rejected() { + let source = "@import \"./a.mds\" as a\n@import \"./a.mds\" as b\nHello!\n"; + let dup = make_diag("duplicate-import", 23, "@import".len()); + let result = make_result(vec![dup]); // no untargeted diagnostics in the baseline + let plan = plan_fixes(&result, source); + assert!(!plan.overlap_rejected && !plan.edits.is_empty()); + + // Reverify surfaces an empty-block diagnostic that was NOT present before. + let outcome = apply_fixes(source, plan, &result, |_fixed| { + Ok(make_result(vec![make_diag("empty-block", 0, 1)])) + }); + assert!( + matches!(outcome, FixOutcome::Rejected { .. }), + "a new untargeted diagnostic must refuse the fix" + ); + } + + // ── Idempotence (AC-F-25) ───────────────────────────────────────────────── + + /// Plan on an already-fixed source produces no edits (idempotence). + /// This test excludes capped (truncated) results per AC-F-25. + #[test] + fn fix_is_idempotent_on_non_truncated_results() { + let source = "@import \"./a.mds\" as a\nHello!\n"; + // Source has no fixable issues. + let empty_result = LintResult { + diagnostics: vec![], + truncated: false, + is_standalone: false, + }; + let plan = plan_fixes(&empty_result, source); + assert!(plan.edits.is_empty(), "no edits on already-clean source"); + } + + // ── Cap / truncation interplay ──────────────────────────────────────────── + + #[test] + fn truncated_plan_notes_truncation() { + let truncated_result = LintResult { + diagnostics: vec![], + truncated: true, + is_standalone: false, + }; + let plan = plan_fixes(&truncated_result, "Hello!\n"); + assert!(plan.truncated, "truncated flag should propagate to plan"); + } + + // ── L-FIX-REV1: AC-F-20 output-delta gate ──────────────────────────────── + + /// I-13: End-to-end Tier B coverage — `unused-function` on a standalone file. + /// + /// This test closes the coverage gap identified in I-13: no test previously + /// exercised `plan_fixes_with_options(result, source, include_tier_b=true)` through + /// `apply_fixes` on a REAL Tier B diagnostic with a real reverify closure. + /// + /// **Why the refusal path**: `diag_to_edit` removes only the `@define dead():` + /// opening line, leaving the body (`World!\n`) and `@end` orphaned. The reverify + /// parse fails (`MdsError::Syntax`) and `apply_fixes` returns `Rejected`. This is + /// the correct fail-closed behavior documented in the KNOWLEDGE.md "block-spanning + /// fixes are always refused" gotcha — and exercises the reverify/output-neutrality + /// gate for Tier B explicitly. + /// + /// **Why unused-function, not unused-import**: a standalone file has no `@import` + /// by definition (`is_standalone = !is_partial_or_extends && imports.is_empty()`), + /// so `unused-import` cannot fire on a standalone file. `unused-function` fires + /// when `has_explicit_exports && !exported && !called` — achieved here with an + /// explicit `@export greet` plus an unexported, uncalled `@define dead():`. + #[test] + fn tier_b_unused_function_standalone_apply_is_refused() { + // Standalone source: no @import, no @extends, has explicit @export. + // `dead` is unexported and uncalled → fires unused-function (Tier B). + let source = + "@define greet():\nHello!\n@end\n@define dead():\nWorld!\n@end\n@export greet\n"; + + // Step 1: obtain a real lint result via the public API (not a stub). + let lint_result = crate::lint_str(source).expect("source should lint without error"); + assert!( + lint_result + .diagnostics + .iter() + .any(|d| d.rule == "unused-function"), + "unused-function must fire for `dead`; diagnostics: {:?}", + lint_result.diagnostics + ); + + // Step 2: plan with Tier B included. + let plan = plan_fixes_with_options(&lint_result, source, /* include_tier_b= */ true); + assert!( + !plan.overlap_rejected, + "no overlap expected on a single Tier B edit" + ); + assert!( + !plan.edits.is_empty(), + "plan must be non-empty for Tier B unused-function; edits: {:?}", + plan.edits + ); + assert!( + plan.edits.iter().any(|e| e.rule == "unused-function"), + "edit must target the unused-function rule; edits: {:?}", + plan.edits + ); + + // Step 3: apply with a real reverify closure. + // Removing only `@define dead():\n` leaves `World!\n@end` orphaned — + // the reverify parse fails (Syntax error) → apply_fixes returns Rejected. + let outcome = apply_fixes(source, plan, &lint_result, crate::lint_str); + + assert!( + matches!(outcome, FixOutcome::Rejected { .. }), + "Tier B unused-function fix must be Rejected (block-span refusal: orphaned @end); \ + got: {outcome:?}" + ); + } + + /// L-FIX-REV1: A reverify closure that detects an output delta MUST cause + /// `apply_fixes` to return `FixOutcome::Rejected`. + /// + /// White-box test: we inject a synthetic ByteEdit that removes non-dead content + /// ("World" from "Hello World!\n"), then pass a reverify closure that compares + /// compiled outputs. The delta (fixed → "Hello !\n") must cause rejection. + /// + /// This verifies the mechanism the CLI relies on: when the reverify closure + /// returns `Err` due to an output delta, the entire fix batch is refused. + #[test] + fn l_fix_rev1_output_delta_causes_rejection() { + let source = "Hello World!\n"; + // An empty LintResult — no real diagnostics needed for this mechanism test. + let original_result = LintResult { + diagnostics: vec![], + truncated: false, + is_standalone: true, + }; + // Synthetic ByteEdit removes " World" (bytes 5-12) — this is NOT a real lint + // fix; it simulates a hypothetical broken fix that changes compiled output. + let plan = FixPlan { + edits: vec![ByteEdit { + start: 5, + end: 12, + rule: "duplicate-import".to_string(), + }], + overlap_rejected: false, + truncated: false, + }; + + // Capture original compiled output as the baseline. + let original_output = crate::compile_str(source) + .expect("source should compile cleanly") + .output; + + let outcome = apply_fixes(source, plan, &original_result, move |fixed| { + // Simulate the CLI output-delta gate (AC-F-20): + // lint first, then compare compiled outputs. + let residual = crate::lint_str(fixed)?; + let fixed_output = crate::compile_str(fixed) + .expect("fixed source should still compile") + .output; + if fixed_output != original_output { + return Err(crate::error::MdsError::Io { + message: "lint --fix would change compiled output; batch refused".to_string(), + }); + } + Ok(residual) + }); + + assert!( + matches!(outcome, FixOutcome::Rejected { .. }), + "apply_fixes must return Rejected when reverify detects an output delta; got: {outcome:?}" + ); + } +} diff --git a/crates/mds-core/src/lint/mod.rs b/crates/mds-core/src/lint/mod.rs new file mode 100644 index 0000000..b83ddc6 --- /dev/null +++ b/crates/mds-core/src/lint/mod.rs @@ -0,0 +1,150 @@ +//! Lint engine — static analysis of MDS templates beyond `mds check`. +//! +//! The engine runs AFTER the check gate (resolve+validate) passes, confirming the +//! template compiles correctly. It then independently tokenizes and parses the entry +//! source for a single-pass facts walk, applies the 9 lint rules as plain +//! functions, and returns a `LintResult`. +//! +//! ## Pipeline (per file) +//! +//! 1. **Check gate** — run existing `resolve_*_intrinsic` ONCE (validity gate). +//! Analysis failure → return `Err(MdsError)`. Never run the pipeline twice. +//! 2. **Re-parse entry** — `tokenize` + `parse_with_ctx` the entry source string +//! independently (mirrors the `scan_imports` pattern). +//! 3. **Facts walk** — one traversal building `AnalysisContext`. +//! Recursion bounded at `MAX_NESTING_DEPTH=64` → `ResourceLimit` if exceeded. +//! 4. **Rule dispatch** — local-AST rules (empty_block, redundant_else, +//! unreachable_branch, duplicate_*) each re-walk the AST; semantic rules +//! query only `AnalysisContext`. Total: 1 facts walk + N rule walks. +//! 5. **Return** `LintResult` with diagnostics capped at `MAX_DIAGNOSTICS`. +//! +//! ## Architecture invariants +//! +//! - `resolver.rs`/`validator.rs`/runtime `Scope` are NEVER touched by this module. +//! - No new mds-core dependencies (zero-dep budget for WASM size). +//! - Per-file fresh resolve (v1): no cross-file ModuleCache "optimization". +//! - Non-generic rule dispatch: no monomorphization per rule. + +pub mod config; +pub mod diagnostic; +pub(crate) mod facts; +pub mod fix; +pub(crate) mod rules; +pub(crate) mod tier; + +pub use config::LintConfig; +pub use diagnostic::{sanitize_control_chars, LintDiagnostic, LintResult, Severity}; + +use crate::error::MdsError; +use crate::{lexer, parser}; + +use self::diagnostic::LintResultBuilder; +use self::facts::collect_facts; + +// ── Partial/extends detection ───────────────────────────────────────────────── + +/// Detect whether a module path/filename indicates a partial file. +/// +/// Partials are detected by filename convention: a file whose basename starts +/// with `_` is a partial (e.g. `_header.mds`). Combined with the parsed module's +/// `extends` field (checked at the call site) to suppress unused-* rules. +pub(crate) fn is_partial_by_name(path: &str) -> bool { + std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with('_')) + .unwrap_or(false) +} + +// ── Core lint runner ────────────────────────────────────────────────────────── + +/// Run the lint engine over a parsed module source. +/// +/// `source` is the raw entry source string (already used for the check gate). +/// `filename` is the display name used in diagnostics and JSON grouping. +/// Returns `Ok(LintResult)` with diagnostics produced by all 9 rules. +pub(crate) fn lint_source( + source: &str, + filename: &str, + config: &LintConfig, +) -> Result { + // Re-parse the entry source independently (mirrors scan_imports, lib.rs:952). + let tokens = lexer::tokenize(source, filename)?; + let module = parser::parse_with_ctx(&tokens, filename, source)?; + + let is_partial = is_partial_by_name(filename); + let is_extends = module.extends.is_some(); + + let ctx = collect_facts(&module, is_partial || is_extends, source)?; + + // A file is standalone when it has no @import or @extends — Tier B fixes (unused-import, + // unused-function) are only safe for standalone files because removing an export changes + // what importers receive. + let is_standalone = !ctx.is_partial_or_extends && ctx.imports.is_empty(); + + // Rule dispatch — non-generic plain-fn dispatch (AC-PERF-02, no monomorphization). + let mut builder = LintResultBuilder::new(); + run_rules(&module, &ctx, filename, config, &mut builder); + + Ok(builder.build(is_standalone)) +} + +/// Apply all 9 lint rules over the module and facts context. +/// +/// Non-generic dispatch: each rule is a plain function call with no monomorphization. +/// Rules are listed in the same order as the implementation steps (local-AST first, +/// semantic second) for readability. +fn run_rules( + module: &crate::ast::Module, + ctx: &facts::AnalysisContext, + filename: &str, + config: &LintConfig, + builder: &mut LintResultBuilder, +) { + // Step 4 — local-AST rules + rules::empty_block::check(module, ctx, filename, config, builder); + rules::redundant_else::check(module, ctx, filename, config, builder); + rules::unreachable_branch::check(module, ctx, filename, config, builder); + rules::duplicate_import::check(module, ctx, filename, config, builder); + rules::duplicate_export::check(module, ctx, filename, config, builder); + + // Step 5 — semantic rules + rules::unused_variable::check(module, ctx, filename, config, builder); + rules::unused_import::check(module, ctx, filename, config, builder); + rules::unused_function::check(module, ctx, filename, config, builder); + rules::shadow_variable::check(module, ctx, filename, config, builder); +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::lint::config::LintConfig; + + fn lint_str(source: &str) -> Result { + lint_source(source, "test.mds", &LintConfig::default()) + } + + #[test] + fn lint_source_valid_returns_no_diagnostics_for_plain_text() { + let result = lint_str("Hello!\n").unwrap(); + assert!(result.diagnostics.is_empty()); + assert!(!result.truncated); + } + + #[test] + fn lint_source_invalid_source_returns_err() { + // Missing @end — parse error. + let result = lint_str("@if x:\nhello\n"); + assert!(result.is_err(), "should fail for invalid source"); + } + + #[test] + fn is_partial_by_name_detects_underscore_prefix() { + assert!(is_partial_by_name("_header.mds")); + assert!(is_partial_by_name("dir/_partial.mds")); + assert!(!is_partial_by_name("header.mds")); + assert!(!is_partial_by_name("dir/main.mds")); + } +} diff --git a/crates/mds-core/src/lint/rules/duplicate_export.rs b/crates/mds-core/src/lint/rules/duplicate_export.rs new file mode 100644 index 0000000..eb7bc6c --- /dev/null +++ b/crates/mds-core/src/lint/rules/duplicate_export.rs @@ -0,0 +1,222 @@ +//! Rule: `duplicate-export` +//! +//! **Severity**: Error (default) | **Tier**: A (auto-fixable) +//! +//! Flags duplicate export declarations within the same module: +//! +//! - `Named`: two `@export name` directives with the same name. +//! - `ReExport`: two `@export name from "path"` with the same exported name. +//! - `Wildcard`: two `@export * from "path"` with identical paths. +//! +//! **Note**: the resolver already deduplicates duplicate Named exports via `IndexSet` +//! (making them silent no-ops at runtime). This lint rule surfaces the issue before +//! the resolver silently discards the second declaration. +//! +//! **Wildcard vs Named cross-file overlap** is the resolver's job (NameCollision +//! error) — the lint rule stays single-file, matching the module-local AST. +//! +//! ## D2 spans +//! +//! Export spans use the D2 `offset: usize` field added to all `ExportDirective` +//! variants in step 1 (commit 76616ea). The `l_u_de1_export_offsets_are_real` +//! test asserts these are real byte positions in the source. + +use crate::ast::Module; +use crate::error::SerializedSpan; +use crate::lint::config::LintConfig; +use crate::lint::diagnostic::{LintDiagnostic, LintResultBuilder, Severity}; +use crate::lint::facts::{AnalysisContext, ExportKind}; +use crate::lint::tier::first_occurrence; + +pub(crate) const RULE: &str = "duplicate-export"; + +/// Check the module for duplicate export declarations. +pub(crate) fn check( + _module: &Module, + ctx: &AnalysisContext, + filename: &str, + config: &LintConfig, + builder: &mut LintResultBuilder, +) { + let severity = resolve_severity(config); + if severity == Severity::Off { + return; + } + + // Check Named/ReExport: duplicate name keys. + let mut seen_names: std::collections::HashMap = std::collections::HashMap::new(); + + // Check Wildcard: duplicate paths. + let mut seen_wildcards: std::collections::HashMap = + std::collections::HashMap::new(); + + for exp in &ctx.exports { + match exp.kind { + ExportKind::Named | ExportKind::ReExport => { + if let Some(name) = &exp.name { + if !first_occurrence(&mut seen_names, name.clone(), exp.offset) + && !builder.push(LintDiagnostic { + rule: RULE.to_string(), + severity, + message: format!( + "Duplicate export: '{}' is exported more than once.", + name + ), + help: Some("Remove the duplicate @export directive.".to_string()), + span: Some(SerializedSpan { + offset: exp.offset, + length: "@export".len(), + line: None, + column: None, + }), + file: Some(filename.to_string()), + }) + { + return; + } + } + } + ExportKind::Wildcard => { + if let Some(path) = &exp.path { + if !first_occurrence(&mut seen_wildcards, path.clone(), exp.offset) + && !builder.push(LintDiagnostic { + rule: RULE.to_string(), + severity, + message: format!( + "Duplicate wildcard export from '{}': already exported above.", + path + ), + help: Some( + "Remove the duplicate @export * from directive.".to_string(), + ), + span: Some(SerializedSpan { + offset: exp.offset, + length: "@export".len(), + line: None, + column: None, + }), + file: Some(filename.to_string()), + }) + { + return; + } + } + } + } + } +} + +fn resolve_severity(config: &LintConfig) -> Severity { + config + .severity_for(RULE) + .copied() + .unwrap_or(Severity::Error) +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::lexer::tokenize; + use crate::lint::facts::collect_facts; + use crate::parser::parse_with_ctx; + + fn lint_src(src: &str) -> Vec { + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let ctx = collect_facts(&module, false, src).unwrap(); + let mut builder = LintResultBuilder::new(); + check( + &module, + &ctx, + "test.mds", + &LintConfig::default(), + &mut builder, + ); + builder.build(false).diagnostics + } + + /// L-U-DE1 (D2 canary): Export directive offsets must be real byte positions. + /// + /// This test asserts that the `offset` field on each export in `ctx.exports` + /// corresponds to the actual byte position of `@export` in the source. + #[test] + fn l_u_de1_export_offsets_are_real() { + let src = "@define greet():\nhello\n@end\n@export greet\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let ctx = collect_facts(&module, false, src).unwrap(); + + assert_eq!(ctx.exports.len(), 1, "should have exactly one export"); + let exp = &ctx.exports[0]; + + // The `@export greet` starts after the @define block. + // Source layout: "@define greet():\nhello\n@end\n" = 29 bytes, then "@export greet\n" + let expected_offset = src.find("@export").expect("@export must appear in source"); + assert_eq!( + exp.offset, expected_offset, + "D2 canary: export offset ({}) must match actual @export position ({})", + exp.offset, expected_offset + ); + + // Verify the source bytes at the offset are indeed "@export". + let at_offset = &src[exp.offset..exp.offset + "@export".len()]; + assert_eq!(at_offset, "@export", "bytes at offset must be '@export'"); + } + + /// L-U-DE1: Duplicate Named export fires. + #[test] + fn duplicate_named_export_fires() { + let src = "@define greet():\nhello\n@end\n@export greet\n@export greet\n"; + let diags = lint_src(src); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire for duplicate @export greet; got: {:?}", + diags + ); + } + + /// Non-duplicate exports do not fire. + #[test] + fn distinct_exports_do_not_fire() { + let src = + "@define greet():\nhello\n@end\n@define bye():\ngoodbye\n@end\n@export greet\n@export bye\n"; + let diags = lint_src(src); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "should not fire for distinct exports; got: {:?}", + diags + ); + } + + /// Duplicate wildcard exports fires. + #[test] + fn duplicate_wildcard_export_fires() { + // Note: @export * from duplicates currently parse OK (no validator check). + // This test may need to be checked against check_str first if validator is changed. + // For now, assert that the lint rule detects it at the AST level. + let src = "@export * from \"./lib.mds\"\n@export * from \"./lib.mds\"\n"; + let diags = lint_src(src); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire for duplicate @export * from; got: {:?}", + diags + ); + } + + /// Rule=off suppresses. + #[test] + fn rule_off_suppresses() { + let src = "@define greet():\nhello\n@end\n@export greet\n@export greet\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let ctx = collect_facts(&module, false, src).unwrap(); + let mut builder = LintResultBuilder::new(); + let config = LintConfig { + rules: [(RULE.to_string(), Severity::Off)].into_iter().collect(), + }; + check(&module, &ctx, "test.mds", &config, &mut builder); + assert!(builder.build(false).diagnostics.is_empty()); + } +} diff --git a/crates/mds-core/src/lint/rules/duplicate_import.rs b/crates/mds-core/src/lint/rules/duplicate_import.rs new file mode 100644 index 0000000..7ce6639 --- /dev/null +++ b/crates/mds-core/src/lint/rules/duplicate_import.rs @@ -0,0 +1,256 @@ +//! Rule: `duplicate-import` +//! +//! **Severity**: Error (default) | **Tier**: A (auto-fixable) +//! +//! Flags two or more `@import` directives that resolve to the same path after +//! lexical normalization (T4: interior segment-collapse — resolving `.` and +//! interior `..` textually without filesystem operations). +//! +//! Same path imported in different FORMS (alias vs merge vs selective) = flag. +//! Same path in the same form with the same alias = flag. +//! +//! ## Symlink/case limitation (documented v1) +//! +//! Symlinks and case-insensitive filesystems may cause `./a.mds` and +//! `./A.mds` to resolve to the same file without this lint detecting it. +//! This is a known v1 limitation — the normalization is purely textual. + +use crate::ast::Module; +use crate::error::SerializedSpan; +use crate::lint::config::LintConfig; +use crate::lint::diagnostic::{LintDiagnostic, LintResultBuilder, Severity}; +use crate::lint::facts::AnalysisContext; +use crate::lint::tier::first_occurrence; + +pub(crate) const RULE: &str = "duplicate-import"; + +/// Check the module for duplicate import paths. +pub(crate) fn check( + _module: &Module, + ctx: &AnalysisContext, + filename: &str, + config: &LintConfig, + builder: &mut LintResultBuilder, +) { + let severity = resolve_severity(config); + if severity == Severity::Off { + return; + } + + // Group imports by normalized path and check for duplicates. + // O(n) pass: build map from normalized path → first-seen offset. + // O(n) second pass: flag any that have been seen before. + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); + + for imp in &ctx.imports { + let norm = normalize_import_path(&imp.path); + if !first_occurrence(&mut seen, norm, imp.offset) + && !builder.push(LintDiagnostic { + rule: RULE.to_string(), + severity, + message: format!( + "Duplicate import: '{}' is imported more than once.", + imp.path + ), + help: Some( + "Remove the duplicate import. If different forms are needed (alias vs \ + merge), consolidate into one import directive." + .to_string(), + ), + span: Some(SerializedSpan { + offset: imp.offset, + length: "@import".len(), + line: None, + column: None, + }), + file: Some(filename.to_string()), + }) + { + return; + } + } +} + +fn resolve_severity(config: &LintConfig) -> Severity { + config + .severity_for(RULE) + .copied() + .unwrap_or(Severity::Error) +} + +/// Lexical path normalization: collapse `.` segments and interior `..` segments. +/// +/// Never performs filesystem operations. Documented v1 limitation: symlinks and +/// case-insensitive filesystems may cause false negatives. +/// +/// ## Algorithm (D1) +/// +/// Split on `/`, then: +/// - `.` segment → skip (same-directory marker). +/// - `..` segment → pop the last non-`..` segment (if any); otherwise retain `..`. +/// - Any other segment → push. +/// +/// Examples: +/// - `"./utils.mds"` → `"utils.mds"` (leading `./` collapsed to nothing) +/// - `"../lib/utils.mds"` → `"../lib/utils.mds"` (leading `..` preserved) +/// - `"a/./b.mds"` → `"a/b.mds"` (interior `.` collapsed) +/// - `"a/../b.mds"` → `"b.mds"` (interior `..` collapsed) +pub(crate) fn normalize_import_path(path: &str) -> String { + let mut segments: Vec<&str> = Vec::new(); + + for part in path.split('/') { + match part { + "." => {} // skip: current-directory marker + ".." => { + // Pop the last non-`..` segment, or preserve `..` if at the root. + match segments.last() { + Some(&"..") | None => { + segments.push(".."); + } + Some(_) => { + segments.pop(); + } + } + } + other => { + segments.push(other); + } + } + } + + segments.join("/") +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::lexer::tokenize; + use crate::lint::facts::collect_facts; + use crate::parser::parse_with_ctx; + + fn lint_src(src: &str) -> Vec { + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let ctx = collect_facts(&module, false, src).unwrap(); + let mut builder = LintResultBuilder::new(); + check( + &module, + &ctx, + "test.mds", + &LintConfig::default(), + &mut builder, + ); + builder.build(false).diagnostics + } + + // ── L-U-H1: normalize_import_path unit tests ────────────────────────────── + + #[test] + fn normalize_leading_dot_slash() { + assert_eq!(normalize_import_path("./utils.mds"), "utils.mds"); + } + + #[test] + fn normalize_no_prefix() { + assert_eq!(normalize_import_path("utils.mds"), "utils.mds"); + } + + #[test] + fn normalize_interior_dot() { + assert_eq!(normalize_import_path("a/./b.mds"), "a/b.mds"); + } + + #[test] + fn normalize_interior_dotdot() { + assert_eq!(normalize_import_path("a/../b.mds"), "b.mds"); + } + + #[test] + fn normalize_leading_dotdot_preserved() { + assert_eq!( + normalize_import_path("../lib/utils.mds"), + "../lib/utils.mds" + ); + } + + #[test] + fn normalize_multiple_leading_dotdot() { + assert_eq!( + normalize_import_path("../../lib/utils.mds"), + "../../lib/utils.mds" + ); + } + + #[test] + fn normalize_complex_path() { + // `a/b/../c/./d.mds` → remove `../`, keep `c`, collapse `.` → `a/c/d.mds` + assert_eq!(normalize_import_path("a/b/../c/./d.mds"), "a/c/d.mds"); + } + + // ── L-U-DI1: duplicate-import rule tests ───────────────────────────────── + + /// L-U-DI1: Same path twice fires. + #[test] + fn same_path_twice_fires() { + let src = "@import \"./utils.mds\" as u1\n@import \"./utils.mds\" as u2\n"; + let diags = lint_src(src); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire for same path twice; got: {:?}", + diags + ); + } + + /// Same path with `./` prefix and without = duplicate (after normalization). + #[test] + fn dot_slash_vs_bare_is_duplicate() { + let src = "@import \"./utils.mds\" as u1\n@import \"utils.mds\" as u2\n"; + let diags = lint_src(src); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire: ./utils.mds == utils.mds after normalization; got: {:?}", + diags + ); + } + + /// Different paths do not fire. + #[test] + fn different_paths_do_not_fire() { + let src = "@import \"./utils.mds\" as u1\n@import \"./other.mds\" as u2\n"; + let diags = lint_src(src); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "should not fire for different paths; got: {:?}", + diags + ); + } + + /// Same path in different forms (alias vs merge) fires. + #[test] + fn different_forms_same_path_fires() { + let src = "@import \"./utils.mds\" as u\n@import \"./utils.mds\"\n"; + let diags = lint_src(src); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire: alias+merge of same path; got: {:?}", + diags + ); + } + + /// Rule=off suppresses. + #[test] + fn rule_off_suppresses() { + let src = "@import \"./utils.mds\" as u1\n@import \"./utils.mds\" as u2\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let ctx = collect_facts(&module, false, src).unwrap(); + let mut builder = LintResultBuilder::new(); + let config = LintConfig { + rules: [(RULE.to_string(), Severity::Off)].into_iter().collect(), + }; + check(&module, &ctx, "test.mds", &config, &mut builder); + assert!(builder.build(false).diagnostics.is_empty()); + } +} diff --git a/crates/mds-core/src/lint/rules/empty_block.rs b/crates/mds-core/src/lint/rules/empty_block.rs new file mode 100644 index 0000000..bdb8fe3 --- /dev/null +++ b/crates/mds-core/src/lint/rules/empty_block.rs @@ -0,0 +1,447 @@ +//! Rule: `empty-block` +//! +//! **Severity**: Warn (default) | **Tier**: A (auto-fixable) +//! +//! A directive block whose body is empty or contains only whitespace is almost +//! certainly a mistake. Empty blocks produce no output and may indicate a forgotten +//! body, stale template scaffolding, or accidental body erasure. +//! +//! ## Coverage +//! +//! Fires on: `@if`, `@elseif`, `@else`, `@for`, `@define`, `@message`. +//! NEVER fires on: `@block` — empty block bodies are the documented default +//! placeholder pattern (`@block tools:` / `@end` = "inherit parent default"). +//! +//! ## Whitespace-only bodies (F2) +//! +//! The lexer emits `Token::Text` for a whitespace-only line between a directive +//! and `@end`. The parser produces a `Node::Text` with whitespace-only `.text`. +//! Confirmed at the parse level in the `f2_whitespace_body_is_text_node` test. +//! +//! ## @message note +//! +//! An empty `@message user:` body may be intentional for priming turns +//! (e.g. an empty assistant placeholder), so this warning is suppressible via +//! `mds.json` `"lint": { "rules": { "empty-block": "off" } }`. The @block +//! exemption rationale is documented above. + +use crate::ast::{IfBlock, Module, Node}; +use crate::error::SerializedSpan; +use crate::lint::config::LintConfig; +use crate::lint::diagnostic::{LintDiagnostic, LintResultBuilder, Severity}; +use crate::lint::facts::AnalysisContext; + +pub(crate) const RULE: &str = "empty-block"; + +/// Check the module for empty or whitespace-only block bodies. +pub(crate) fn check( + module: &Module, + _ctx: &AnalysisContext, + filename: &str, + config: &LintConfig, + builder: &mut LintResultBuilder, +) { + let severity = resolve_severity(config); + if severity == Severity::Off { + return; + } + + check_nodes(&module.body, filename, &severity, builder); +} + +fn resolve_severity(config: &LintConfig) -> Severity { + config.severity_for(RULE).copied().unwrap_or(Severity::Warn) +} + +/// Recursively check a node list for empty bodies. +/// +/// Recursion depth is pre-bounded by the parser's `enter_block` guard +/// (MAX_NESTING_DEPTH=64), so no local depth counter is needed here. +fn check_nodes( + nodes: &[Node], + filename: &str, + severity: &Severity, + builder: &mut LintResultBuilder, +) { + for node in nodes { + match node { + Node::If(b) => { + check_if_block(b, filename, severity, builder); + } + Node::For(b) => { + if flag_if_empty( + &b.body, + filename, + severity, + make_diag( + *severity, + filename, + "@for body is empty".to_string(), + Some("Add content inside the @for block or remove it.".to_string()), + b.offset, + "@for".len(), + ), + builder, + ) { + return; + } + } + Node::Define(b) => { + if flag_if_empty( + &b.body, + filename, + severity, + make_diag( + *severity, + filename, + format!("@define '{}' body is empty", b.name), + Some("Add a body to the function or remove the definition.".to_string()), + b.offset, + "@define".len() + 1 + b.name.len(), + ), + builder, + ) { + return; + } + } + Node::Message(b) => { + if flag_if_empty( + &b.body, + filename, + severity, + make_diag( + *severity, + filename, + "@message body is empty".to_string(), + Some( + "Add content to the message block or remove it. \ + Empty @message is allowed for priming but often accidental." + .to_string(), + ), + b.offset, + "@message".len(), + ), + builder, + ) { + return; + } + } + // @block: intentional placeholder pattern — NEVER flagged. + Node::Block(b) => { + check_nodes(&b.body, filename, severity, builder); + } + // Leaf nodes. + Node::Text(_) + | Node::Interpolation(_) + | Node::EscapedBrace + | Node::Import(_) + | Node::Export(_) + | Node::Include(_) => {} + } + } +} + +fn check_if_block( + b: &IfBlock, + filename: &str, + severity: &Severity, + builder: &mut LintResultBuilder, +) { + // Check then-body. + if flag_if_empty( + &b.then_body, + filename, + severity, + make_diag( + *severity, + filename, + "@if then-body is empty".to_string(), + Some("Add content inside the @if block or remove it.".to_string()), + b.offset, + "@if".len(), + ), + builder, + ) { + return; + } + + // Check @elseif branches. + for (_, branch_body) in &b.elseif_branches { + // No per-elseif offset stored in the AST — use the @if offset as an approximation. + if flag_if_empty( + branch_body, + filename, + severity, + make_diag( + *severity, + filename, + "@elseif body is empty".to_string(), + Some("Add content inside the @elseif block or remove it.".to_string()), + b.offset, // approximate: no per-elseif offset in AST + "@elseif".len(), + ), + builder, + ) { + return; + } + } + + // Check @else body. + if let Some(else_body) = &b.else_body { + // Last check in this function — no further work follows regardless of return. + flag_if_empty( + else_body, + filename, + severity, + make_diag( + *severity, + filename, + "@else body is empty".to_string(), + Some("Add content inside the @else block or remove it.".to_string()), + b.offset, + "@else".len(), + ), + builder, + ); + } +} + +/// If `body` is empty or whitespace-only, push `diag` and return `true` +/// (diagnostic limit reached — caller should stop processing). Otherwise recurse +/// via `check_nodes` and return `false`. +fn flag_if_empty( + body: &[Node], + filename: &str, + severity: &Severity, + diag: LintDiagnostic, + builder: &mut LintResultBuilder, +) -> bool { + if is_empty_or_whitespace(body) { + !builder.push(diag) + } else { + check_nodes(body, filename, severity, builder); + false + } +} + +/// A body is "empty" if it contains no nodes, OR all nodes are whitespace-only Text. +/// +/// **F2 verified**: the parser emits `Node::Text(TextNode { text: " \n", ... })` +/// for whitespace-only lines between a directive and `@end`, confirmed by the +/// `f2_whitespace_body_is_text_node` test below. +fn is_empty_or_whitespace(body: &[Node]) -> bool { + body.is_empty() + || body.iter().all(|node| { + if let Node::Text(t) = node { + t.text.chars().all(char::is_whitespace) + } else { + false + } + }) +} + +fn make_diag( + severity: Severity, + filename: &str, + message: String, + help: Option, + offset: usize, + length: usize, +) -> LintDiagnostic { + LintDiagnostic { + rule: RULE.to_string(), + severity, + message, + help, + span: Some(SerializedSpan { + offset, + length, + line: None, + column: None, + }), + file: Some(filename.to_string()), + } +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::lexer::tokenize; + use crate::lint::facts::collect_facts; + use crate::parser::parse_with_ctx; + + fn lint_src(src: &str) -> Vec { + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let ctx = collect_facts(&module, false, src).unwrap(); + let mut builder = LintResultBuilder::new(); + check( + &module, + &ctx, + "test.mds", + &LintConfig::default(), + &mut builder, + ); + builder.build(false).diagnostics + } + + /// F2: parse-level assertion confirming whitespace-only bodies produce a Text node. + /// + /// This test is the RED gate for the whitespace-only predicate. If the parser + /// changes to strip or omit whitespace Text nodes in directive bodies, this test + /// fails and the `is_empty_or_whitespace` predicate needs adjustment. + #[test] + fn f2_whitespace_body_is_text_node() { + let src = "@if x:\n \n@end\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let Node::If(block) = &module.body[0] else { + panic!("expected If block"); + }; + assert_eq!( + block.then_body.len(), + 1, + "whitespace-only body should have exactly one Text node, not be empty" + ); + let Node::Text(t) = &block.then_body[0] else { + panic!( + "expected Text node in whitespace-only body, got: {:?}", + block.then_body[0] + ); + }; + assert!( + t.text.chars().all(char::is_whitespace), + "body Text node should be all whitespace, got: {:?}", + t.text + ); + } + + /// L-U-EB1: @if with completely empty body fires. + #[test] + fn if_empty_body_fires() { + let diags = lint_src("@if x:\n@end\n"); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire for empty @if body; got: {:?}", + diags + ); + } + + /// L-U-EB2: @if with whitespace-only body fires. + #[test] + fn if_whitespace_only_body_fires() { + let diags = lint_src("@if x:\n \n@end\n"); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire for whitespace-only @if body; got: {:?}", + diags + ); + } + + /// @if with content does NOT fire. + #[test] + fn if_with_content_does_not_fire() { + let diags = lint_src("@if x:\nhello\n@end\n"); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "should not fire when @if body has content" + ); + } + + /// @for with empty body fires. + #[test] + fn for_empty_body_fires() { + let diags = lint_src("@for x in items:\n@end\n"); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire for empty @for body; got: {:?}", + diags + ); + } + + /// @define with empty body fires. + #[test] + fn define_empty_body_fires() { + let diags = lint_src("@define greet():\n@end\n"); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire for empty @define body; got: {:?}", + diags + ); + } + + /// @message with empty body fires. + #[test] + fn message_empty_body_fires() { + let diags = lint_src("@message user:\n@end\n"); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire for empty @message body; got: {:?}", + diags + ); + } + + /// @block with empty body does NOT fire (intentional placeholder pattern). + #[test] + fn block_empty_body_does_not_fire() { + let diags = lint_src("@block tools:\n@end\n"); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "@block exemption: should NOT fire for empty @block body; got: {:?}", + diags + ); + } + + /// TEST-7: @elseif with empty body fires. + /// + /// The `@elseif` path was the one uncovered branch among the six directives + /// checked by `check_if_block` and `check_nodes`. This test locks in that + /// coverage: when the then-body of @if has content but the @elseif body is + /// empty, exactly the @elseif finding must fire. + #[test] + fn elseif_empty_body_fires() { + // @if then-body has content ("hello") — only @elseif body is empty. + let diags = lint_src("@if x:\nhello\n@elseif y:\n@end\n"); + assert!( + diags + .iter() + .any(|d| d.rule == RULE && d.message.contains("@elseif")), + "should fire for empty @elseif body; got: {:?}", + diags + ); + } + + /// @else with empty body fires. + #[test] + fn else_empty_body_fires() { + let diags = lint_src("@if x:\nhello\n@else:\n@end\n"); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire for empty @else body; got: {:?}", + diags + ); + } + + /// Turning off the rule via config produces no diagnostics. + #[test] + fn rule_off_suppresses_all() { + let src = "@if x:\n@end\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let ctx = collect_facts(&module, false, src).unwrap(); + let mut builder = LintResultBuilder::new(); + let config = LintConfig { + rules: [("empty-block".to_string(), Severity::Off)] + .into_iter() + .collect(), + }; + check(&module, &ctx, "test.mds", &config, &mut builder); + let result = builder.build(false); + assert!( + result.diagnostics.is_empty(), + "rule=off should produce no diagnostics" + ); + } +} diff --git a/crates/mds-core/src/lint/rules/mod.rs b/crates/mds-core/src/lint/rules/mod.rs new file mode 100644 index 0000000..5f7f7c5 --- /dev/null +++ b/crates/mds-core/src/lint/rules/mod.rs @@ -0,0 +1,30 @@ +//! Lint rule implementations. +//! +//! Each rule is a plain function `check(module, ctx, filename, config, builder)` with +//! no generics — non-generic dispatch prevents monomorphization overhead (AC-PERF-02). +//! +//! ## Rule catalogue (built-in severity defaults) +//! +//! | Rule | Severity | Tier | Description | +//! |----------------------|----------|------|-----------------------------------------------| +//! | `empty-block` | Warn | A | Block body is empty or whitespace-only | +//! | `redundant-else` | Warn | C | @else body structurally identical to then-body| +//! | `unreachable-branch` | Error | A | Literal↔literal always-true/false conditions | +//! | `duplicate-import` | Error | A | Same import path declared twice | +//! | `duplicate-export` | Error | A | Same export name declared twice | +//! | `unused-variable` | Warn | C | FM key never referenced in body | +//! | `unused-import` | Warn | B | Import not used in body | +//! | `unused-function` | Warn | B | @define not exported and not called | +//! | `shadow-variable` | Info | C | Inner scope name hides outer binding | + +pub(crate) mod duplicate_export; +pub(crate) mod duplicate_import; +pub(crate) mod empty_block; +pub(crate) mod redundant_else; +pub(crate) mod shadow_variable; +pub(crate) mod unreachable_branch; +pub(crate) mod unused_function; +pub(crate) mod unused_import; +pub(crate) mod unused_variable; + +pub(crate) mod structural_eq; diff --git a/crates/mds-core/src/lint/rules/redundant_else.rs b/crates/mds-core/src/lint/rules/redundant_else.rs new file mode 100644 index 0000000..94c6d17 --- /dev/null +++ b/crates/mds-core/src/lint/rules/redundant_else.rs @@ -0,0 +1,214 @@ +//! Rule: `redundant-else` +//! +//! **Severity**: Warn (default) | **Tier**: C (never auto-fixed) +//! +//! When an `@else` body is structurally identical to the `@if` then-body, the +//! conditional is pointless — both branches produce the same output regardless of +//! the condition. This is almost always a copy-paste mistake. +//! +//! Structural identity ignores all `offset` and `len` fields (position-independent). +//! `f64` literals are compared via `to_bits()` (NaN-safe, parser rejects NaN anyway). +//! +//! ## Tier C: report-only +//! +//! This rule is Tier C because "fixing" it by removing the `@else` changes the +//! conditional structure in a way that may not be what the author intended — they +//! might want to keep the @else and fix the content instead. The diagnostic message +//! guides the author; no auto-fix is generated. + +use crate::ast::{Module, Node}; +use crate::error::SerializedSpan; +use crate::lint::config::LintConfig; +use crate::lint::diagnostic::{LintDiagnostic, LintResultBuilder, Severity}; +use crate::lint::facts::AnalysisContext; +use crate::lint::rules::structural_eq::nodes_eq; + +pub(crate) const RULE: &str = "redundant-else"; + +/// Check the module for @if/@else pairs where both branches are structurally identical. +pub(crate) fn check( + module: &Module, + _ctx: &AnalysisContext, + filename: &str, + config: &LintConfig, + builder: &mut LintResultBuilder, +) { + let severity = resolve_severity(config); + if severity == Severity::Off { + return; + } + + check_nodes(&module.body, filename, &severity, builder); +} + +fn resolve_severity(config: &LintConfig) -> Severity { + config.severity_for(RULE).copied().unwrap_or(Severity::Warn) +} + +/// Recursion depth is pre-bounded by the parser's `enter_block` guard +/// (MAX_NESTING_DEPTH=64), so no local depth counter is needed here. +fn check_nodes( + nodes: &[Node], + filename: &str, + severity: &Severity, + builder: &mut LintResultBuilder, +) { + for node in nodes { + match node { + Node::If(b) => { + // Check if @else body is structurally identical to then-body. + // Only meaningful with NO intervening @elseif branches: with an @elseif + // present, then==else does NOT make the conditional redundant (the elseif + // arm still produces different output), so firing here would be a false + // positive with a factually wrong "same output regardless" message. + if let Some(else_body) = &b.else_body { + if b.elseif_branches.is_empty() + && nodes_eq(&b.then_body, else_body) + && !builder.push(LintDiagnostic { + rule: RULE.to_string(), + severity: *severity, + message: "The @else body is identical to the @if body — \ + the conditional produces the same output regardless \ + of the condition." + .to_string(), + help: Some( + "Remove the @else branch or make its content different \ + from the @if body." + .to_string(), + ), + span: Some(SerializedSpan { + offset: b.offset, + length: "@if".len(), + line: None, + column: None, + }), + file: Some(filename.to_string()), + }) + { + return; + } + } + + // Recurse into all branches. + check_nodes(&b.then_body, filename, severity, builder); + for (_, branch_body) in &b.elseif_branches { + check_nodes(branch_body, filename, severity, builder); + } + if let Some(else_body) = &b.else_body { + check_nodes(else_body, filename, severity, builder); + } + } + Node::For(b) => check_nodes(&b.body, filename, severity, builder), + Node::Define(b) => check_nodes(&b.body, filename, severity, builder), + Node::Message(b) => check_nodes(&b.body, filename, severity, builder), + Node::Block(b) => check_nodes(&b.body, filename, severity, builder), + Node::Text(_) + | Node::Interpolation(_) + | Node::EscapedBrace + | Node::Import(_) + | Node::Export(_) + | Node::Include(_) => {} + } + } +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::lexer::tokenize; + use crate::lint::facts::collect_facts; + use crate::parser::parse_with_ctx; + + fn lint_src(src: &str) -> Vec { + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let ctx = collect_facts(&module, false, src).unwrap(); + let mut builder = LintResultBuilder::new(); + check( + &module, + &ctx, + "test.mds", + &LintConfig::default(), + &mut builder, + ); + builder.build(false).diagnostics + } + + /// L-U-RE1: identical then/else bodies fire. + #[test] + fn identical_then_else_fires() { + let src = "@if x:\nhello\n@else:\nhello\n@end\n"; + let diags = lint_src(src); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire for identical then/else; got: {:?}", + diags + ); + } + + /// Different content does not fire. + #[test] + fn different_then_else_does_not_fire() { + let src = "@if x:\nhello\n@else:\nworld\n@end\n"; + let diags = lint_src(src); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "should not fire for different then/else; got: {:?}", + diags + ); + } + + /// No @else body: should not fire. + #[test] + fn no_else_does_not_fire() { + let src = "@if x:\nhello\n@end\n"; + let diags = lint_src(src); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "should not fire when no @else" + ); + } + + /// With an intervening @elseif, identical then/else is NOT redundant — the elseif + /// arm still produces different output, so the rule must not fire (false positive). + #[test] + fn identical_then_else_with_elseif_does_not_fire() { + let src = "@if x:\nhello\n@elseif y:\nworld\n@else:\nhello\n@end\n"; + let diags = lint_src(src); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "must not fire when an @elseif branch differs; got: {:?}", + diags + ); + } + + /// Structural identity ignores offsets — same text at different source positions. + #[test] + fn structural_eq_ignores_offsets() { + // The `hello\n` appears at different byte offsets in then vs else. + // Structural equality must still consider them identical. + let src = "@if x:\nhello\n@else:\nhello\n@end\n"; + let diags = lint_src(src); + assert!( + diags.iter().any(|d| d.rule == RULE), + "offset difference must not affect structural equality" + ); + } + + /// Rule=off suppresses. + #[test] + fn rule_off_suppresses() { + let src = "@if x:\nhello\n@else:\nhello\n@end\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let ctx = collect_facts(&module, false, src).unwrap(); + let mut builder = LintResultBuilder::new(); + let config = LintConfig { + rules: [(RULE.to_string(), Severity::Off)].into_iter().collect(), + }; + check(&module, &ctx, "test.mds", &config, &mut builder); + assert!(builder.build(false).diagnostics.is_empty()); + } +} diff --git a/crates/mds-core/src/lint/rules/shadow_variable.rs b/crates/mds-core/src/lint/rules/shadow_variable.rs new file mode 100644 index 0000000..d5e47ed --- /dev/null +++ b/crates/mds-core/src/lint/rules/shadow_variable.rs @@ -0,0 +1,192 @@ +//! Rule: `shadow-variable` +//! +//! **Severity**: Info (default) | **DEFAULT OFF** (silent unless enabled via config) +//! +//! An inner-scope name that shadows an outer-scope binding. Shadowing is +//! intentionally allowed by the MDS spec (spec §6: "inner scope wins, no warning +//! by default") but may be unintentional in some codebases. +//! +//! Because shadowing is explicitly supported, this rule ships at `Info` severity +//! and is **default-off**: it fires only when enabled via `mds.json`: +//! +//! ```json +//! { "lint": { "rules": { "shadow-variable": "info" } } } +//! ``` +//! +//! Or elevated to a warning: +//! ```json +//! { "lint": { "rules": { "shadow-variable": "warn" } } } +//! ``` +//! +//! ## Enumerated shadow pairs (Appendix A) +//! +//! 1. `@for var/key_var` over a frontmatter key +//! 2. `@define param` over a frontmatter key +//! 3. nested `@for var` over an outer `@for var` +//! 4. `@define param` over a `@for var` in the enclosing scope +//! 5. `@for var` over an import alias +//! +//! @block cannot nest (it is not a loop/define) — excluded. +//! +//! ## Note on suppression +//! +//! This rule is NOT suppressed on partials/@extends (it's default-off anyway; +//! its default-off nature already prevents spurious CI failures on library files). + +use crate::ast::Module; +use crate::error::SerializedSpan; +use crate::lint::config::LintConfig; +use crate::lint::diagnostic::{LintDiagnostic, LintResultBuilder, Severity}; +use crate::lint::facts::{AnalysisContext, ShadowKind}; + +pub(crate) const RULE: &str = "shadow-variable"; + +/// Check the module for shadow variable pairs. +/// +/// **Default-off**: returns immediately unless the config explicitly enables the rule. +pub(crate) fn check( + _module: &Module, + ctx: &AnalysisContext, + filename: &str, + config: &LintConfig, + builder: &mut LintResultBuilder, +) { + // Default severity is Off — rule is silent unless explicitly enabled. + let severity = resolve_severity(config); + if severity == Severity::Off { + return; + } + + for pair in &ctx.shadow_pairs { + let inner_desc = kind_desc(&pair.inner_kind); + let outer_desc = kind_desc(&pair.outer_kind); + + if !builder.push(LintDiagnostic { + rule: RULE.to_string(), + severity, + message: format!( + "Variable '{}' ({}) shadows an outer {} with the same name.", + pair.name, inner_desc, outer_desc + ), + help: Some(format!( + "Rename '{}' in the inner scope to avoid shadowing.", + pair.name + )), + span: Some(SerializedSpan { + offset: pair.offset, + length: pair.name.len(), + line: None, + column: None, + }), + file: Some(filename.to_string()), + }) { + return; + } + } +} + +/// Built-in default severity: Off (rule is default-off). +fn resolve_severity(config: &LintConfig) -> Severity { + config.severity_for(RULE).copied().unwrap_or(Severity::Off) +} + +fn kind_desc(kind: &ShadowKind) -> &'static str { + match kind { + ShadowKind::FmVar => "frontmatter variable", + ShadowKind::ImportAlias => "import alias", + ShadowKind::ForVar => "@for loop variable", + ShadowKind::DefineParam => "@define parameter", + } +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::lexer::tokenize; + use crate::lint::facts::collect_facts; + use crate::parser::parse_with_ctx; + + fn lint_with_config(src: &str, config: &LintConfig) -> Vec { + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let ctx = collect_facts(&module, false, src).unwrap(); + let mut builder = LintResultBuilder::new(); + check(&module, &ctx, "test.mds", config, &mut builder); + builder.build(false).diagnostics + } + + fn enabled_config() -> LintConfig { + LintConfig { + rules: [(RULE.to_string(), Severity::Info)].into_iter().collect(), + } + } + + /// L-U-SV1 (default-off): Shadow rule is silent with default config. + #[test] + fn default_off_produces_no_diagnostics() { + let src = "---\nname: World\n---\n@for name in items:\n{name}\n@end\n"; + let diags = lint_with_config(src, &LintConfig::default()); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "shadow-variable should be default-off; got: {:?}", + diags + ); + } + + /// When explicitly enabled, @for var over FM key fires. + #[test] + fn enabled_for_var_over_fm_key_fires() { + let src = "---\nname: World\n---\n@for name in items:\n{name}\n@end\n"; + let diags = lint_with_config(src, &enabled_config()); + assert!( + diags + .iter() + .any(|d| d.rule == RULE && d.message.contains("name")), + "should fire for @for var 'name' shadowing FM key; got: {:?}", + diags + ); + } + + /// @define param over FM key fires when enabled. + #[test] + fn enabled_define_param_over_fm_key_fires() { + let src = "---\nuser: Alice\n---\n@define greet(user):\nhello {user}\n@end\n"; + let diags = lint_with_config(src, &enabled_config()); + assert!( + diags + .iter() + .any(|d| d.rule == RULE && d.message.contains("user")), + "should fire for @define param 'user' shadowing FM key; got: {:?}", + diags + ); + } + + /// No shadow → no diagnostic even when enabled. + #[test] + fn no_shadow_no_diagnostic() { + let src = "---\nname: World\n---\n@for item in items:\n{item}\n@end\n"; + let diags = lint_with_config(src, &enabled_config()); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "no shadow should produce no diagnostic; got: {:?}", + diags + ); + } + + /// Rule=info enables it; rule=off silences even if it was enabled before. + #[test] + fn rule_off_suppresses() { + let src = "---\nname: World\n---\n@for name in items:\n{name}\n@end\n"; + let config = LintConfig { + rules: [(RULE.to_string(), Severity::Off)].into_iter().collect(), + }; + let diags = lint_with_config(src, &config); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "rule=off should suppress; got: {:?}", + diags + ); + } +} diff --git a/crates/mds-core/src/lint/rules/structural_eq.rs b/crates/mds-core/src/lint/rules/structural_eq.rs new file mode 100644 index 0000000..d1b3a18 --- /dev/null +++ b/crates/mds-core/src/lint/rules/structural_eq.rs @@ -0,0 +1,256 @@ +//! Manual structural equality helpers for AST nodes. +//! +//! The AST deliberately does NOT derive `PartialEq` on `Node`, `Condition`, `Expr`, +//! or `Arg` because `Expr::NumberLiteral(f64)` uses IEEE 754 semantics where +//! `NaN != NaN` (see ast.rs comment). These helpers implement structural comparison +//! that: +//! +//! - Ignores all `offset` and `len` fields (position-independent comparison). +//! - Compares `f64` values via `to_bits()` (bitwise equality, treating `NaN == NaN`). +//! Note: the parser rejects NaN/infinity literals, so in practice this case never +//! occurs on well-formed input. +//! +//! Used by `redundant_else` (body identity) and `unreachable_branch` (duplicate +//! @elseif condition detection). + +use crate::ast::{Arg, Condition, Expr, ImportDirective, Node}; + +/// Compare two expression trees for structural identity (offset/len ignored). +pub(crate) fn exprs_eq(a: &Expr, b: &Expr) -> bool { + match (a, b) { + (Expr::Var(x), Expr::Var(y)) => x == y, + (Expr::StringLiteral(x), Expr::StringLiteral(y)) => x == y, + // NaN-safe: compare via bits. Parser rejects NaN/infinity, but be defensive. + (Expr::NumberLiteral(x), Expr::NumberLiteral(y)) => x.to_bits() == y.to_bits(), + (Expr::BooleanLiteral(x), Expr::BooleanLiteral(y)) => x == y, + (Expr::NullLiteral, Expr::NullLiteral) => true, + (Expr::Call { name: n1, args: a1 }, Expr::Call { name: n2, args: a2 }) => { + n1 == n2 && args_eq(a1, a2) + } + ( + Expr::QualifiedCall { + namespace: ns1, + name: n1, + args: a1, + }, + Expr::QualifiedCall { + namespace: ns2, + name: n2, + args: a2, + }, + ) => ns1 == ns2 && n1 == n2 && args_eq(a1, a2), + ( + Expr::MemberAccess { + object: o1, + fields: f1, + }, + Expr::MemberAccess { + object: o2, + fields: f2, + }, + ) => o1 == o2 && f1 == f2, + _ => false, + } +} + +/// Compare two argument lists for structural identity. +pub(crate) fn args_eq(a: &[Arg], b: &[Arg]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| arg_eq(x, y)) +} + +/// Compare two function arguments for structural identity. +fn arg_eq(a: &Arg, b: &Arg) -> bool { + match (a, b) { + (Arg::StringLiteral(x), Arg::StringLiteral(y)) => x == y, + (Arg::NumberLiteral(x), Arg::NumberLiteral(y)) => x.to_bits() == y.to_bits(), + (Arg::BooleanLiteral(x), Arg::BooleanLiteral(y)) => x == y, + (Arg::NullLiteral, Arg::NullLiteral) => true, + (Arg::Var(x), Arg::Var(y)) => x == y, + (Arg::Call { name: n1, args: a1 }, Arg::Call { name: n2, args: a2 }) => { + n1 == n2 && args_eq(a1, a2) + } + ( + Arg::MemberAccess { + object: o1, + fields: f1, + }, + Arg::MemberAccess { + object: o2, + fields: f2, + }, + ) => o1 == o2 && f1 == f2, + _ => false, + } +} + +/// Compare two conditions for structural identity (offset/len ignored). +pub(crate) fn conditions_eq(a: &Condition, b: &Condition) -> bool { + match (a, b) { + (Condition::Truthy(e1), Condition::Truthy(e2)) => exprs_eq(e1, e2), + (Condition::Not(e1), Condition::Not(e2)) => exprs_eq(e1, e2), + (Condition::Eq(l1, r1), Condition::Eq(l2, r2)) => exprs_eq(l1, l2) && exprs_eq(r1, r2), + (Condition::NotEq(l1, r1), Condition::NotEq(l2, r2)) => { + exprs_eq(l1, l2) && exprs_eq(r1, r2) + } + (Condition::And(v1), Condition::And(v2)) | (Condition::Or(v1), Condition::Or(v2)) => { + v1.len() == v2.len() && v1.iter().zip(v2).all(|(x, y)| conditions_eq(x, y)) + } + _ => false, + } +} + +/// Compare two node slices for structural identity (all offset/len fields ignored). +pub(crate) fn nodes_eq(a: &[Node], b: &[Node]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| node_eq(x, y)) +} + +/// Compare two AST nodes for structural identity. +pub(crate) fn node_eq(a: &Node, b: &Node) -> bool { + match (a, b) { + (Node::Text(t1), Node::Text(t2)) => t1.text == t2.text, + (Node::EscapedBrace, Node::EscapedBrace) => true, + (Node::Interpolation(i1), Node::Interpolation(i2)) => exprs_eq(&i1.expr, &i2.expr), + (Node::If(b1), Node::If(b2)) => { + conditions_eq(&b1.condition, &b2.condition) + && nodes_eq(&b1.then_body, &b2.then_body) + && b1.elseif_branches.len() == b2.elseif_branches.len() + && b1 + .elseif_branches + .iter() + .zip(&b2.elseif_branches) + .all(|((c1, n1), (c2, n2))| conditions_eq(c1, c2) && nodes_eq(n1, n2)) + && match (&b1.else_body, &b2.else_body) { + (None, None) => true, + (Some(n1), Some(n2)) => nodes_eq(n1, n2), + _ => false, + } + } + (Node::For(f1), Node::For(f2)) => { + f1.var == f2.var + && f1.key_var == f2.key_var + && exprs_eq(&f1.iterable, &f2.iterable) + && nodes_eq(&f1.body, &f2.body) + } + (Node::Define(d1), Node::Define(d2)) => { + d1.name == d2.name + && d1 + .params + .iter() + .map(|p| &p.name) + .eq(d2.params.iter().map(|p| &p.name)) + && nodes_eq(&d1.body, &d2.body) + } + (Node::Import(i1), Node::Import(i2)) => imports_eq(i1, i2), + (Node::Export(_), Node::Export(_)) => false, // offset-bearing; not used in structural checks + (Node::Include(i1), Node::Include(i2)) => i1.alias == i2.alias, + (Node::Message(m1), Node::Message(m2)) => { + exprs_eq(&m1.role, &m2.role) && nodes_eq(&m1.body, &m2.body) + } + (Node::Block(b1), Node::Block(b2)) => b1.name == b2.name && nodes_eq(&b1.body, &b2.body), + _ => false, + } +} + +fn imports_eq(a: &ImportDirective, b: &ImportDirective) -> bool { + match (a, b) { + ( + ImportDirective::Alias { + path: p1, + alias: a1, + .. + }, + ImportDirective::Alias { + path: p2, + alias: a2, + .. + }, + ) => p1 == p2 && a1 == a2, + (ImportDirective::Merge { path: p1, .. }, ImportDirective::Merge { path: p2, .. }) => { + p1 == p2 + } + ( + ImportDirective::Selective { + names: n1, + path: p1, + .. + }, + ImportDirective::Selective { + names: n2, + path: p2, + .. + }, + ) => n1 == n2 && p1 == p2, + _ => false, + } +} + +/// Return `true` if `expr` is a literal value (cannot be a variable or call). +pub(crate) fn is_literal(expr: &Expr) -> bool { + matches!( + expr, + Expr::StringLiteral(_) + | Expr::NumberLiteral(_) + | Expr::BooleanLiteral(_) + | Expr::NullLiteral + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ast::{Expr, Node, TextNode}; + + #[test] + fn exprs_eq_var() { + let a = Expr::Var("x".to_string()); + let b = Expr::Var("x".to_string()); + let c = Expr::Var("y".to_string()); + assert!(exprs_eq(&a, &b)); + assert!(!exprs_eq(&a, &c)); + } + + #[test] + fn exprs_eq_number_literal_nan_safe() { + // NaN != NaN in IEEE 754, but to_bits() comparison makes them "equal" structurally. + // In practice the parser never produces NaN, but the helper must not panic. + let nan1 = Expr::NumberLiteral(f64::NAN); + let nan2 = Expr::NumberLiteral(f64::NAN); + // Both have the same bit pattern if produced by f64::NAN (canonical NaN). + assert!(exprs_eq(&nan1, &nan2)); + } + + #[test] + fn nodes_eq_text_same() { + let a = Node::Text(TextNode { + text: "hello".to_string(), + offset: 0, + }); + let b = Node::Text(TextNode { + text: "hello".to_string(), + offset: 99, // different offset — should still be equal + }); + assert!(node_eq(&a, &b)); + } + + #[test] + fn nodes_eq_text_different() { + let a = Node::Text(TextNode { + text: "hello".to_string(), + offset: 0, + }); + let b = Node::Text(TextNode { + text: "world".to_string(), + offset: 0, + }); + assert!(!node_eq(&a, &b)); + } + + #[test] + fn is_literal_detects_literals() { + assert!(is_literal(&Expr::StringLiteral("x".to_string()))); + assert!(is_literal(&Expr::NumberLiteral(42.0))); + assert!(is_literal(&Expr::BooleanLiteral(true))); + assert!(is_literal(&Expr::NullLiteral)); + assert!(!is_literal(&Expr::Var("x".to_string()))); + } +} diff --git a/crates/mds-core/src/lint/rules/unreachable_branch.rs b/crates/mds-core/src/lint/rules/unreachable_branch.rs new file mode 100644 index 0000000..9ff4c5f --- /dev/null +++ b/crates/mds-core/src/lint/rules/unreachable_branch.rs @@ -0,0 +1,452 @@ +//! Rule: `unreachable-branch` +//! +//! **Severity**: Error (default) | **Tier**: A (auto-fixable) +//! +//! Fires on two distinct patterns: +//! +//! ## Pattern 1: Always-true / always-false literal conditions +//! +//! `@if "x" == "x":` is always-true — later branches (`@elseif`, `@else`) are +//! unreachable. `@if "x" == "y":` is always-false — the then-body is dead code. +//! +//! Only `Condition::Eq` and `Condition::NotEq` are flagged when **both sides are +//! literal expressions** (`StringLiteral`, `NumberLiteral`, `BooleanLiteral`, +//! `NullLiteral`). Variable comparisons are never flagged — the value is not +//! statically known at analysis time. +//! +//! ## Pattern 2: Duplicate structural @elseif conditions +//! +//! A later `@elseif` condition that is structurally identical to an earlier +//! `@if` or `@elseif` condition can never be reached (the earlier arm matched +//! first), making the branch dead. +//! +//! ## F3 precondition: fixtures must pass `check` first +//! +//! The `l_u_ub0_check_gate_passes` test below asserts that each test fixture +//! source passes `mds::check_str`. If a future validator change rejects +//! constant conditions, that test fails loudly, signalling that the unreachable- +//! branch rule would silently produce zero findings on valid inputs. + +use crate::ast::{Condition, IfBlock, Module, Node}; +use crate::error::SerializedSpan; +use crate::lint::config::LintConfig; +use crate::lint::diagnostic::{LintDiagnostic, LintResultBuilder, Severity}; +use crate::lint::facts::AnalysisContext; +use crate::lint::rules::structural_eq::{conditions_eq, exprs_eq, is_literal}; + +pub(crate) const RULE: &str = "unreachable-branch"; + +/// Check the module for unreachable branches. +pub(crate) fn check( + module: &Module, + _ctx: &AnalysisContext, + filename: &str, + config: &LintConfig, + builder: &mut LintResultBuilder, +) { + let severity = resolve_severity(config); + if severity == Severity::Off { + return; + } + + check_nodes(&module.body, filename, &severity, builder); +} + +fn resolve_severity(config: &LintConfig) -> Severity { + config + .severity_for(RULE) + .copied() + .unwrap_or(Severity::Error) +} + +/// Recursion depth is pre-bounded by the parser's `enter_block` guard +/// (MAX_NESTING_DEPTH=64), so no local depth counter is needed here. +fn check_nodes( + nodes: &[Node], + filename: &str, + severity: &Severity, + builder: &mut LintResultBuilder, +) { + for node in nodes { + match node { + Node::If(b) => { + check_if_block(b, filename, severity, builder); + // Recurse into bodies. + check_nodes(&b.then_body, filename, severity, builder); + for (_, body) in &b.elseif_branches { + check_nodes(body, filename, severity, builder); + } + if let Some(else_body) = &b.else_body { + check_nodes(else_body, filename, severity, builder); + } + } + Node::For(b) => check_nodes(&b.body, filename, severity, builder), + Node::Define(b) => check_nodes(&b.body, filename, severity, builder), + Node::Message(b) => check_nodes(&b.body, filename, severity, builder), + Node::Block(b) => check_nodes(&b.body, filename, severity, builder), + Node::Text(_) + | Node::Interpolation(_) + | Node::EscapedBrace + | Node::Import(_) + | Node::Export(_) + | Node::Include(_) => {} + } + } +} + +fn check_if_block( + b: &IfBlock, + filename: &str, + severity: &Severity, + builder: &mut LintResultBuilder, +) { + // Pattern 1: check the primary @if condition. + match classify_condition(&b.condition) { + ConditionClass::AlwaysTrue => { + // Always-true primary condition → LATER branches (@elseif/@else) are unreachable. + // Appendix A: "always-true → LATER branches unreachable." + // If there are no later branches, nothing is unreachable — do not flag (M2 FP fix). + let has_later_branches = !b.elseif_branches.is_empty() || b.else_body.is_some(); + if has_later_branches + && !builder.push(make_diag( + *severity, + filename, + "@if condition is always true — @elseif/@else branches are unreachable" + .to_string(), + Some( + "Replace the constant condition with a variable or remove later branches." + .to_string(), + ), + b.offset, + "@if".len(), + )) + { + return; + } + } + ConditionClass::AlwaysFalse => { + // Always-false primary condition → then-body is dead code, regardless of later branches. + if !builder.push(make_diag( + *severity, + filename, + "@if condition is always false — the then-body is dead code".to_string(), + Some( + "Replace the constant condition with a variable or remove the dead branch." + .to_string(), + ), + b.offset, + "@if".len(), + )) { + return; + } + } + ConditionClass::Unknown => {} + } + + // Pattern 2: Duplicate @elseif conditions. + // Collect all seen conditions in order; flag a branch if its condition equals any prior one. + let mut seen_conditions: Vec<&Condition> = vec![&b.condition]; + + for (cond, _body) in &b.elseif_branches { + // Check if this @elseif condition duplicates any prior condition. + let is_duplicate = seen_conditions + .iter() + .any(|prior| conditions_eq(prior, cond)); + + if is_duplicate { + // Emit ONE finding for the duplicate. Skip the always-true/false check below — + // the duplicate detection already identifies this dead code (M4 dedup). + if !builder.push(make_diag( + *severity, + filename, + "@elseif condition is structurally identical to an earlier branch — \ + this branch can never be reached." + .to_string(), + Some("Remove the duplicate @elseif branch or change its condition.".to_string()), + b.offset, + "@elseif".len(), + )) { + return; + } + } else { + // Not a duplicate — check if this @elseif is always-true or always-false. + match classify_condition(cond) { + ConditionClass::AlwaysTrue => { + if !builder.push(make_diag( + *severity, + filename, + "@elseif condition is always true".to_string(), + Some("Replace the constant condition with a variable.".to_string()), + b.offset, + "@elseif".len(), + )) { + return; + } + } + ConditionClass::AlwaysFalse => { + if !builder.push(make_diag( + *severity, + filename, + "@elseif condition is always false — this branch is dead code".to_string(), + Some( + "Replace the constant condition with a variable or remove the dead branch." + .to_string(), + ), + b.offset, + "@elseif".len(), + )) { + return; + } + } + ConditionClass::Unknown => {} + } + } + + seen_conditions.push(cond); + } +} + +enum ConditionClass { + AlwaysTrue, + AlwaysFalse, + Unknown, +} + +/// Classify a condition as always-true, always-false, or statically unknown. +/// +/// Only `Condition::Eq` and `Condition::NotEq` with BOTH sides being literals are +/// flaggable. Variable comparisons return `Unknown`. +fn classify_condition(cond: &Condition) -> ConditionClass { + match cond { + Condition::Eq(lhs, rhs) if is_literal(lhs) && is_literal(rhs) => { + if exprs_eq(lhs, rhs) { + ConditionClass::AlwaysTrue + } else { + ConditionClass::AlwaysFalse + } + } + Condition::NotEq(lhs, rhs) if is_literal(lhs) && is_literal(rhs) => { + if exprs_eq(lhs, rhs) { + ConditionClass::AlwaysFalse + } else { + ConditionClass::AlwaysTrue + } + } + _ => ConditionClass::Unknown, + } +} + +fn make_diag( + severity: Severity, + filename: &str, + message: String, + help: Option, + offset: usize, + length: usize, +) -> LintDiagnostic { + LintDiagnostic { + rule: RULE.to_string(), + severity, + message, + help, + span: Some(SerializedSpan { + offset, + length, + line: None, + column: None, + }), + file: Some(filename.to_string()), + } +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::lexer::tokenize; + use crate::lint::facts::collect_facts; + use crate::parser::parse_with_ctx; + + fn lint_src(src: &str) -> Vec { + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let ctx = collect_facts(&module, false, src).unwrap(); + let mut builder = LintResultBuilder::new(); + check( + &module, + &ctx, + "test.mds", + &LintConfig::default(), + &mut builder, + ); + builder.build(false).diagnostics + } + + // ── L-U-UB0: F3 precondition — fixtures must pass check_str first ───────── + + /// L-U-UB0: All unreachable-branch fixture sources must pass mds::check_str. + /// + /// If a future validator change rejects constant conditions, this test fails + /// loudly — signalling that the unreachable-branch rule would be dead on those inputs. + #[test] + fn l_u_ub0_check_gate_passes_for_all_fixtures() { + let fixtures = [ + // Always-true: literal == same-literal + "@if \"x\" == \"x\":\nhello\n@end\n", + // Always-false: different literals + "@if \"x\" == \"y\":\nhello\n@end\n", + // Literal != literal (NotEq, always-true) + "@if \"a\" != \"b\":\nhello\n@end\n", + // Duplicate @elseif condition — uses variable x, must define it in frontmatter + "---\nx: hello\n---\n@if x == \"a\":\nfoo\n@elseif x == \"a\":\nbar\n@end\n", + // Number literals + "@if 1 == 1:\nhello\n@end\n", + // Bool literals + "@if true == true:\nhello\n@end\n", + ]; + for src in &fixtures { + let result = crate::check_str(src); + assert!( + result.is_ok(), + "F3 precondition: fixture must pass check_str before unreachable-branch tests\n\ + fixture: {src:?}\n\ + error: {:?}", + result.unwrap_err() + ); + } + } + + /// L-U-UB1: Always-true condition with later branches fires. + /// + /// M2: always-true @if with @elseif/@else → later branches unreachable → fires. + #[test] + fn always_true_literal_eq_fires_when_later_branches_present() { + // @else branch makes the later-branch unreachable. + let src = "@if \"x\" == \"x\":\nhello\n@else:\nworld\n@end\n"; + let diags = lint_src(src); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire for always-true literal condition with @else; got: {:?}", + diags + ); + } + + /// M2 FP fix: always-true @if with NO later branches must NOT fire. + /// + /// Appendix A: "always-true → LATER branches unreachable." + /// With no @elseif or @else, there is nothing unreachable. + #[test] + fn always_true_no_later_branches_does_not_fire() { + let diags = lint_src("@if \"yes\" == \"yes\":\nbody\n@end\n"); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "M2: must NOT fire when always-true @if has no @elseif/@else (nothing is unreachable); \ + got: {:?}", + diags + ); + } + + /// Always-false condition fires (then-body is dead code, regardless of later branches). + #[test] + fn always_false_literal_eq_fires() { + let diags = lint_src("@if \"x\" == \"y\":\nhello\n@end\n"); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire for always-false literal condition; got: {:?}", + diags + ); + } + + /// NotEq always-true: "a" != "b" is always-true → fires only when later branches exist. + #[test] + fn always_true_literal_neq_fires_when_later_branches_present() { + let src = "@if \"a\" != \"b\":\nhello\n@elseif x == \"c\":\nworld\n@end\n"; + let diags = lint_src(src); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire for always-true != condition with @elseif; got: {:?}", + diags + ); + } + + /// Variable comparison never fires. + #[test] + fn variable_comparison_does_not_fire() { + let diags = lint_src("@if role == \"admin\":\nhello\n@end\n"); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "should NOT fire for variable comparison; got: {:?}", + diags + ); + } + + /// Duplicate @elseif condition fires. + #[test] + fn duplicate_elseif_condition_fires() { + let src = "@if x == \"a\":\nfoo\n@elseif x == \"a\":\nbar\n@end\n"; + let diags = lint_src(src); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire for duplicate @elseif condition; got: {:?}", + diags + ); + } + + /// Non-duplicate @elseif condition does not fire. + #[test] + fn distinct_elseif_condition_does_not_fire() { + let src = "@if x == \"a\":\nfoo\n@elseif x == \"b\":\nbar\n@end\n"; + let diags = lint_src(src); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "should NOT fire for distinct @elseif conditions; got: {:?}", + diags + ); + } + + /// Number literal always-true: 1 == 1 fires when later branches exist. + #[test] + fn number_literal_always_true_fires_with_later_branches() { + let src = "@if 1 == 1:\nhello\n@else:\nworld\n@end\n"; + let diags = lint_src(src); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire for 1 == 1 with @else; got: {:?}", + diags + ); + } + + /// M4: always-true @if + always-true duplicate @elseif → at most 2 findings (not 3). + /// + /// Before M4: 3 findings (Pattern 1 + Pattern 2 duplicate + Pattern 2 always-true). + /// After M4: 2 findings (Pattern 1 + Pattern 2 duplicate only; always-true skipped as redundant). + #[test] + fn triple_report_dedup_yields_at_most_two_findings() { + // @if "a"=="a" (always-true, has @elseif) + @elseif "a"=="a" (duplicate + always-true). + let src = "@if \"a\" == \"a\":\nfoo\n@elseif \"a\" == \"a\":\nbar\n@end\n"; + let diags = lint_src(src); + let count = diags.iter().filter(|d| d.rule == RULE).count(); + assert_eq!( + count, 2, + "M4: duplicate always-true @elseif must yield exactly 2 findings \ + (not 3 — always-true and duplicate are the same dead code); got {count}: {:?}", + diags + ); + } + + /// Rule=error is the default; rule=off suppresses. + #[test] + fn rule_off_suppresses() { + let src = "@if \"x\" == \"x\":\nhello\n@end\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let ctx = collect_facts(&module, false, src).unwrap(); + let mut builder = LintResultBuilder::new(); + let config = LintConfig { + rules: [(RULE.to_string(), Severity::Off)].into_iter().collect(), + }; + check(&module, &ctx, "test.mds", &config, &mut builder); + assert!(builder.build(false).diagnostics.is_empty()); + } +} diff --git a/crates/mds-core/src/lint/rules/unused_function.rs b/crates/mds-core/src/lint/rules/unused_function.rs new file mode 100644 index 0000000..fc17279 --- /dev/null +++ b/crates/mds-core/src/lint/rules/unused_function.rs @@ -0,0 +1,233 @@ +//! Rule: `unused-function` +//! +//! **Severity**: Warn (default) | **Tier**: B (recompile-diff-proven) +//! +//! A `@define` function that is neither exported nor called within the module +//! is dead code. The function definition contributes nothing to the compiled +//! output and should be removed or exported. +//! +//! ## Firing condition +//! +//! Fires ONLY when: +//! 1. `ctx.has_explicit_exports` is `true` — when there are NO explicit exports, +//! every `@define` function is implicitly exported (default-public semantics). +//! Flagging unexported functions in that case would be a false positive. +//! 2. The function is NOT exported (not in any `@export name` or `@export name from`). +//! 3. The function is NOT called anywhere in the module body (not in `used_calls`). +//! +//! ## Self-recursion +//! +//! A function that calls itself (`{myFn(myFn())}`) adds `myFn` to `used_calls`, +//! so it is treated as "called" and not flagged. This matches Appendix A: self- +//! recursion is treated as used. (Note: true mutual recursion would require a +//! second traverse — not needed in v1, and infinite recursion is a runtime error.) +//! +//! ## Suppression on partials/@extends +//! +//! Suppressed when `ctx.is_partial_or_extends` is true. + +use crate::ast::Module; +use crate::error::SerializedSpan; +use crate::lint::config::LintConfig; +use crate::lint::diagnostic::{LintDiagnostic, LintResultBuilder, Severity}; +use crate::lint::facts::{AnalysisContext, ExportKind}; + +pub(crate) const RULE: &str = "unused-function"; + +/// Check the module for unexported, uncalled @define functions. +pub(crate) fn check( + _module: &Module, + ctx: &AnalysisContext, + filename: &str, + config: &LintConfig, + builder: &mut LintResultBuilder, +) { + let severity = resolve_severity(config); + if severity == Severity::Off { + return; + } + + // Suppressed on partials / @extends children. + if ctx.is_partial_or_extends { + return; + } + + // Only fires when there are explicit exports (default-public modules export everything). + if !ctx.has_explicit_exports { + return; + } + + // Build set of explicitly exported function names. + let exported_names: std::collections::HashSet = ctx + .exports + .iter() + .filter(|e| matches!(e.kind, ExportKind::Named | ExportKind::ReExport)) + .filter_map(|e| e.name.clone()) + .collect(); + + for def in &ctx.defines { + let is_exported = exported_names.contains(&def.name); + let is_called = ctx.used_calls.contains(&def.name); + + if !is_exported && !is_called && !builder.push(LintDiagnostic { + rule: RULE.to_string(), + severity, + message: format!( + "Function '{}' is defined but never exported or called.", + def.name + ), + help: Some( + "Export the function with @export or call it somewhere, or remove the definition." + .to_string(), + ), + span: Some(SerializedSpan { + offset: def.offset, + length: "@define".len() + 1 + def.name.len(), + line: None, + column: None, + }), + file: Some(filename.to_string()), + }) { + return; + } + } +} + +fn resolve_severity(config: &LintConfig) -> Severity { + config.severity_for(RULE).copied().unwrap_or(Severity::Warn) +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::lexer::tokenize; + use crate::lint::facts::collect_facts; + use crate::parser::parse_with_ctx; + + fn lint_src(src: &str) -> Vec { + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let ctx = collect_facts(&module, false, src).unwrap(); + let mut builder = LintResultBuilder::new(); + check( + &module, + &ctx, + "test.mds", + &LintConfig::default(), + &mut builder, + ); + builder.build(false).diagnostics + } + + /// L-U-UF1: Unexported, uncalled function fires (when has_explicit_exports). + #[test] + fn unexported_uncalled_fires_when_module_has_exports() { + // has_explicit_exports=true (there's an @export for `greet`), but `format_name` is unused. + let src = + "@define greet():\nhello\n@end\n@define format_name(x):\n{x}!\n@end\n@export greet\n"; + let diags = lint_src(src); + assert!( + diags + .iter() + .any(|d| d.rule == RULE && d.message.contains("format_name")), + "should fire for unexported/uncalled format_name; got: {:?}", + diags + ); + } + + /// No exports → default-public → rule does NOT fire. + #[test] + fn no_exports_does_not_fire() { + let src = "@define greet():\nhello\n@end\n"; + let diags = lint_src(src); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "no exports → default-public, should not fire; got: {:?}", + diags + ); + } + + /// Exported function does not fire. + #[test] + fn exported_function_does_not_fire() { + let src = "@define greet():\nhello\n@end\n@export greet\n"; + let diags = lint_src(src); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "exported function should not fire; got: {:?}", + diags + ); + } + + /// Called function does not fire. + #[test] + fn called_function_does_not_fire() { + let src = + "@define greet():\nhello\n@end\n@define helper():\n@end\n@export greet\n{greet()}\n"; + // `greet` is exported AND called; `helper` is not exported but also not called. + let diags = lint_src(src); + // `helper` is the only one that should fire (it's unused). + // `greet` should not fire. + assert!( + !diags + .iter() + .any(|d| d.rule == RULE && d.message.contains("greet")), + "called+exported function greet should not fire; got: {:?}", + diags + ); + } + + /// Self-recursive function is treated as "called" — does not fire. + #[test] + fn self_recursive_function_not_flagged() { + // `count` calls itself → in used_calls → treated as called. + let src = + "@define count(n):\n{count(n)}\n@end\n@export greet\n@define greet():\nhello\n@end\n"; + let diags = lint_src(src); + assert!( + !diags + .iter() + .any(|d| d.rule == RULE && d.message.contains("count")), + "self-recursive function should not be flagged; got: {:?}", + diags + ); + } + + /// Partial file suppresses unused-function. + #[test] + fn partial_suppresses_unused_function() { + let src = "@define greet():\nhello\n@end\n@export other\n@define other():\nhello\n@end\n"; + let tokens = tokenize(src, "_partial.mds").unwrap(); + let module = parse_with_ctx(&tokens, "_partial.mds", src).unwrap(); + let ctx = collect_facts(&module, true, src).unwrap(); + let mut builder = LintResultBuilder::new(); + check( + &module, + &ctx, + "_partial.mds", + &LintConfig::default(), + &mut builder, + ); + assert!( + builder.build(false).diagnostics.is_empty(), + "partial should suppress unused-function" + ); + } + + /// Rule=off suppresses. + #[test] + fn rule_off_suppresses() { + let src = "@define greet():\nhello\n@end\n@define dead():\n@end\n@export greet\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let ctx = collect_facts(&module, false, src).unwrap(); + let mut builder = LintResultBuilder::new(); + let config = LintConfig { + rules: [(RULE.to_string(), Severity::Off)].into_iter().collect(), + }; + check(&module, &ctx, "test.mds", &config, &mut builder); + assert!(builder.build(false).diagnostics.is_empty()); + } +} diff --git a/crates/mds-core/src/lint/rules/unused_import.rs b/crates/mds-core/src/lint/rules/unused_import.rs new file mode 100644 index 0000000..f36f140 --- /dev/null +++ b/crates/mds-core/src/lint/rules/unused_import.rs @@ -0,0 +1,343 @@ +//! Rule: `unused-import` +//! +//! **Severity**: Warn (default) | **Tier**: B (recompile-diff-proven) +//! +//! An import that is never used in the module body wastes the resolver's work +//! (and in partial-eval contexts, the loading of an external file). +//! +//! ## Per-form semantics (Appendix A) +//! +//! ### Alias import (`@import "path" as alias`) +//! +//! Used when `alias` appears as: +//! - `Expr::QualifiedCall { namespace: alias, .. }` — `{alias.func(...)}` +//! - `IncludeDirective { alias }` — `@include alias` +//! +//! ### Selective import (`@import { name1, name2 } from "path"`) +//! +//! Each name is checked individually (better UX). A name is used when it appears as: +//! - `Expr::Call { name, .. }` — `{name(...)}` +//! - `Arg::Call { name, .. }` — `func(name(...))` +//! - `Arg::Var(name)` — `func(name)` (passing a function ref as argument) +//! - `Expr::Var(name)` — `{name}` (using the imported name as a variable) +//! +//! ### Merge import (`@import "path"`) +//! +//! **Always treated as used** (conservative). A merge import injects all the +//! imported module's exports plus the `prompt` variable into scope — tracking +//! which injected symbols are actually used would require cross-file analysis, +//! which is out of scope for v1. +//! +//! ### Re-export exemption +//! +//! A selective import name that appears in a `@export name` or +//! `@export name from "path"` directive is considered "used" even without a +//! call-site reference (the module re-exports it). +//! +//! ### Frontmatter `imports:` key +//! +//! The frontmatter `imports:` YAML key is not tracked as AST import nodes — +//! it is handled by the resolver outside the AST. This lint rule does not flag +//! frontmatter imports in v1 (document in rule doc comment). +//! +//! ## Suppression on partials/@extends +//! +//! Suppressed when `ctx.is_partial_or_extends` is true. + +use crate::ast::Module; +use crate::error::SerializedSpan; +use crate::lint::config::LintConfig; +use crate::lint::diagnostic::{LintDiagnostic, LintResultBuilder, Severity}; +use crate::lint::facts::{AnalysisContext, ExportKind, ImportKind}; + +pub(crate) const RULE: &str = "unused-import"; + +/// Check the module for unused imports. +pub(crate) fn check( + _module: &Module, + ctx: &AnalysisContext, + filename: &str, + config: &LintConfig, + builder: &mut LintResultBuilder, +) { + let severity = resolve_severity(config); + if severity == Severity::Off { + return; + } + + // Suppressed on partials / @extends children. + if ctx.is_partial_or_extends { + return; + } + + // Build re-export set: names that are re-exported (exemption for selective imports). + // Wildcard re-exports (@export * from ...) are intentionally NOT in this exemption set: + // a wildcard re-export operates on the imported module's own exports, not on a local + // import binding — a file with @import + @export * re-exports from different paths and + // there is no syntactic link between them at the name level. Adding wildcard to this + // set would require cross-file resolution that is deliberately out of scope for v1 + // (adjudicated more-correct than Appendix A's literal wording). + let reexport_names: std::collections::HashSet = ctx + .exports + .iter() + .filter(|e| matches!(e.kind, ExportKind::Named | ExportKind::ReExport)) + .filter_map(|e| e.name.clone()) + .collect(); + + for imp in &ctx.imports { + match imp.kind { + ImportKind::Merge => { + // Always treated as used (conservative — cross-file analysis needed). + continue; + } + ImportKind::Alias => { + let alias = imp.alias.as_deref().unwrap_or(""); + let is_used = + ctx.used_namespaces.contains(alias) || ctx.used_include_aliases.contains(alias); + if !is_used + && !builder.push(make_diag( + severity, + filename, + format!( + "Import alias '{}' from '{}' is never used.", + alias, imp.path + ), + Some( + "Remove the @import or use the alias with @include or as \ + a qualified call (`alias.func(...)`)." + .to_string(), + ), + imp.offset, + )) + { + return; + } + } + ImportKind::Selective => { + // Per-name flagging: each name checked individually. + for name in &imp.names { + let is_used = ctx.used_calls.contains(name) + || ctx.used_vars.contains(name) + || reexport_names.contains(name); + if !is_used + && !builder.push(make_diag( + severity, + filename, + format!( + "Imported name '{}' from '{}' is never used.", + name, imp.path + ), + Some(format!( + "Remove '{}' from the selective import or use it in the body.", + name + )), + imp.offset, + )) + { + return; + } + } + } + } + } +} + +fn resolve_severity(config: &LintConfig) -> Severity { + config.severity_for(RULE).copied().unwrap_or(Severity::Warn) +} + +/// Build an unused-import diagnostic. The span always covers the `@import` keyword +/// (length = 7), so `offset` is the only caller-supplied span parameter. +fn make_diag( + severity: Severity, + filename: &str, + message: String, + help: Option, + offset: usize, +) -> LintDiagnostic { + LintDiagnostic { + rule: RULE.to_string(), + severity, + message, + help, + span: Some(SerializedSpan { + offset, + length: "@import".len(), + line: None, + column: None, + }), + file: Some(filename.to_string()), + } +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::lexer::tokenize; + use crate::lint::facts::collect_facts; + use crate::parser::parse_with_ctx; + + fn lint_src(src: &str) -> Vec { + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let ctx = collect_facts(&module, false, src).unwrap(); + let mut builder = LintResultBuilder::new(); + check( + &module, + &ctx, + "test.mds", + &LintConfig::default(), + &mut builder, + ); + builder.build(false).diagnostics + } + + /// L-U-UI1: Unused alias import fires. + #[test] + fn unused_alias_import_fires() { + let src = "@import \"./lib.mds\" as lib\nHello!\n"; + let diags = lint_src(src); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire for unused alias import; got: {:?}", + diags + ); + } + + /// Used alias import (via qualified call) does not fire. + #[test] + fn used_alias_via_qualified_call_does_not_fire() { + let src = "@import \"./lib.mds\" as lib\n{lib.greet(\"world\")}\n"; + let diags = lint_src(src); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "should not fire for alias used in QualifiedCall; got: {:?}", + diags + ); + } + + /// Used alias import (via @include) does not fire. + #[test] + fn used_alias_via_include_does_not_fire() { + let src = "@import \"./lib.mds\" as lib\n@include lib\n"; + let diags = lint_src(src); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "should not fire for alias used in @include; got: {:?}", + diags + ); + } + + /// L-U-UI2: Unused selective import name fires per-name. + #[test] + fn unused_selective_import_fires() { + let src = "@import { greet } from \"./lib.mds\"\nHello!\n"; + let diags = lint_src(src); + assert!( + diags + .iter() + .any(|d| d.rule == RULE && d.message.contains("greet")), + "should fire for unused selective import 'greet'; got: {:?}", + diags + ); + } + + /// Used selective import name does not fire. + #[test] + fn used_selective_import_does_not_fire() { + let src = "@import { greet } from \"./lib.mds\"\n{greet(\"world\")}\n"; + let diags = lint_src(src); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "should not fire for used selective import; got: {:?}", + diags + ); + } + + /// Merge import is always treated as used (conservative). + #[test] + fn merge_import_always_used() { + let src = "@import \"./lib.mds\"\nHello!\n"; + let diags = lint_src(src); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "merge import should always be treated as used; got: {:?}", + diags + ); + } + + /// Re-export exemption: selective name that is re-exported is not flagged. + #[test] + fn selective_name_reexported_not_flagged() { + let src = "@import { greet } from \"./lib.mds\"\n@export greet\n"; + let diags = lint_src(src); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "re-exported selective import should not be flagged; got: {:?}", + diags + ); + } + + /// Partial file suppresses unused-import. + #[test] + fn partial_suppresses_unused_import() { + let src = "@import \"./lib.mds\" as lib\nHello!\n"; + let tokens = tokenize(src, "_partial.mds").unwrap(); + let module = parse_with_ctx(&tokens, "_partial.mds", src).unwrap(); + let ctx = collect_facts(&module, true, src).unwrap(); // is_partial=true + let mut builder = LintResultBuilder::new(); + check( + &module, + &ctx, + "_partial.mds", + &LintConfig::default(), + &mut builder, + ); + assert!( + builder.build(false).diagnostics.is_empty(), + "partial should suppress unused-import" + ); + } + + /// TEST-5: Wildcard re-export (`@export * from "path"`) does NOT exempt a + /// selective import from the unused-import rule. + /// + /// The KB-adjudicated semantic: `@export * from "..."` operates on the imported + /// module's own exports at a different namespace level — there is no syntactic + /// link between the wildcard re-export and any local import binding. Only + /// `@export name` (Named) and `@export name from "path"` (ReExport) suppress + /// their matching local import binding. + #[test] + fn wildcard_reexport_does_not_exempt_unused_import() { + // `greet` is selectively imported but never referenced in the body. + // The wildcard re-export is from an unrelated path and carries no name + // binding — it must NOT exempt `greet` from the unused-import finding. + let src = "@import { greet } from \"./lib.mds\"\n@export * from \"./other.mds\"\n"; + let diags = lint_src(src); + assert!( + diags + .iter() + .any(|d| d.rule == RULE && d.message.contains("greet")), + "wildcard re-export must NOT exempt a selective import from unused-import; \ + got: {:?}", + diags + ); + } + + /// Rule=off suppresses. + #[test] + fn rule_off_suppresses() { + let src = "@import \"./lib.mds\" as lib\nHello!\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let ctx = collect_facts(&module, false, src).unwrap(); + let mut builder = LintResultBuilder::new(); + let config = LintConfig { + rules: [(RULE.to_string(), Severity::Off)].into_iter().collect(), + }; + check(&module, &ctx, "test.mds", &config, &mut builder); + assert!(builder.build(false).diagnostics.is_empty()); + } +} diff --git a/crates/mds-core/src/lint/rules/unused_variable.rs b/crates/mds-core/src/lint/rules/unused_variable.rs new file mode 100644 index 0000000..634302b --- /dev/null +++ b/crates/mds-core/src/lint/rules/unused_variable.rs @@ -0,0 +1,223 @@ +//! Rule: `unused-variable` +//! +//! **Severity**: Warn (default) | **Tier**: C (never auto-fixed) +//! +//! A frontmatter variable key that is never referenced in the template body +//! contributes nothing to the compiled output. It may be a leftover from +//! template refactoring or an accidental typo. +//! +//! ## What counts as a "use" +//! +//! A frontmatter key is "used" when it appears as any of: +//! - `Expr::Var(key)` — `{key}` interpolation +//! - `Expr::MemberAccess { object: key, .. }` — `{key.field}` +//! - `Arg::Var(key)` — `func(key)` argument +//! - `Arg::MemberAccess { object: key, .. }` — `func(key.field)` argument +//! +//! This covers ALL recursive bodies including `@if` conditions, `@for` iterables, +//! and nested `@define`/`@message` bodies. +//! +//! ## Reserved skip-set +//! +//! The following keys are NEVER flagged: `imports`, `type`, `extends`, `prompt`. +//! These are either structural keys consumed by the resolver/compiler or +//! automatically injected by merge imports. +//! +//! ## Tier C: report-only +//! +//! Frontmatter keys may affect YAML output (when compiled with `---` pass-through) +//! and removing them could change non-body behaviour. Tier C means the user must +//! remove the variable manually — no auto-fix is generated. +//! +//! ## Suppression on partials/@extends +//! +//! Unused-* rules are suppressed when `ctx.is_partial_or_extends` is true: +//! a partial library file's variables may be consumed by its importers; an +//! `@extends` child template's variables may satisfy its parent's expectations. + +use crate::ast::Module; +use crate::error::SerializedSpan; +use crate::lint::config::LintConfig; +use crate::lint::diagnostic::{LintDiagnostic, LintResultBuilder, Severity}; +use crate::lint::facts::AnalysisContext; + +pub(crate) const RULE: &str = "unused-variable"; + +/// Check the module for unused frontmatter variables. +pub(crate) fn check( + _module: &Module, + ctx: &AnalysisContext, + filename: &str, + config: &LintConfig, + builder: &mut LintResultBuilder, +) { + let severity = resolve_severity(config); + if severity == Severity::Off { + return; + } + + // Suppressed on partials / @extends children. + if ctx.is_partial_or_extends { + return; + } + + for fv in &ctx.frontmatter_vars { + // A key is "used" if it appears in any body expression as a variable or member object. + let is_used = ctx.used_vars.contains(&fv.name); + + if !is_used + && !builder.push(LintDiagnostic { + rule: RULE.to_string(), + severity, + message: format!( + "Variable '{}' is defined in frontmatter but never referenced in the body.", + fv.name + ), + help: Some( + "Remove the frontmatter key or reference it in the template body.".to_string(), + ), + span: fv.approx_offset.map(|off| SerializedSpan { + offset: off, + length: fv.name.len(), + line: None, + column: None, + }), + file: Some(filename.to_string()), + }) + { + return; + } + } +} + +fn resolve_severity(config: &LintConfig) -> Severity { + config.severity_for(RULE).copied().unwrap_or(Severity::Warn) +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::lexer::tokenize; + use crate::lint::facts::collect_facts; + use crate::parser::parse_with_ctx; + + fn lint_src(src: &str) -> Vec { + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let ctx = collect_facts(&module, false, src).unwrap(); + let mut builder = LintResultBuilder::new(); + check( + &module, + &ctx, + "test.mds", + &LintConfig::default(), + &mut builder, + ); + builder.build(false).diagnostics + } + + fn lint_src_partial(src: &str) -> Vec { + let tokens = tokenize(src, "_partial.mds").unwrap(); + let module = parse_with_ctx(&tokens, "_partial.mds", src).unwrap(); + let ctx = collect_facts(&module, true, src).unwrap(); // is_partial=true + let mut builder = LintResultBuilder::new(); + check( + &module, + &ctx, + "_partial.mds", + &LintConfig::default(), + &mut builder, + ); + builder.build(false).diagnostics + } + + /// L-U-UV1: Unused FM key fires. + #[test] + fn unused_fm_key_fires() { + let src = "---\nname: World\n---\nHello!\n"; + let diags = lint_src(src); + assert!( + diags.iter().any(|d| d.rule == RULE), + "should fire for unused FM key 'name'; got: {:?}", + diags + ); + } + + /// L-U-UV2: Used FM key does not fire. + #[test] + fn used_fm_key_does_not_fire() { + let src = "---\nname: World\n---\nHello {name}!\n"; + let diags = lint_src(src); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "should not fire when 'name' is used; got: {:?}", + diags + ); + } + + /// Reserved keys (imports, type, extends, prompt) are never flagged. + #[test] + fn reserved_keys_not_flagged() { + // `type` and `imports` are reserved and excluded from frontmatter_vars. + let src = "---\ntype: mds\nimports: []\nprompt: hi\n---\nHello!\n"; + let diags = lint_src(src); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "reserved keys must not be flagged; got: {:?}", + diags + ); + } + + /// FM var used in @if condition is not flagged. + #[test] + fn fm_var_used_in_condition_not_flagged() { + let src = "---\nrole: admin\n---\n@if role == \"admin\":\nhello\n@end\n"; + let diags = lint_src(src); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "FM var used in condition should not be flagged; got: {:?}", + diags + ); + } + + /// FM var used as @for iterable is not flagged. + #[test] + fn fm_var_used_as_for_iterable_not_flagged() { + let src = "---\nitems: []\n---\n@for item in items:\n{item}\n@end\n"; + let diags = lint_src(src); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "FM var used as @for iterable should not be flagged; got: {:?}", + diags + ); + } + + /// Partial file suppresses unused-variable. + #[test] + fn partial_suppresses_unused_variable() { + let src = "---\nname: World\n---\nHello!\n"; + let diags = lint_src_partial(src); + assert!( + !diags.iter().any(|d| d.rule == RULE), + "partial file should suppress unused-variable; got: {:?}", + diags + ); + } + + /// Rule=off suppresses. + #[test] + fn rule_off_suppresses() { + let src = "---\nname: World\n---\nHello!\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let ctx = collect_facts(&module, false, src).unwrap(); + let mut builder = LintResultBuilder::new(); + let config = LintConfig { + rules: [(RULE.to_string(), Severity::Off)].into_iter().collect(), + }; + check(&module, &ctx, "test.mds", &config, &mut builder); + assert!(builder.build(false).diagnostics.is_empty()); + } +} diff --git a/crates/mds-core/src/lint/tier.rs b/crates/mds-core/src/lint/tier.rs new file mode 100644 index 0000000..26a963e --- /dev/null +++ b/crates/mds-core/src/lint/tier.rs @@ -0,0 +1,130 @@ +//! Fix tier classification for lint rules. +//! +//! A leaf module imported by both `diagnostic.rs` (for the JSON `fixable` field) +//! and `fix.rs` (for fix planning). Having it here breaks what would otherwise +//! be a circular dependency: `fix.rs` imports `LintResult` from `diagnostic.rs`, +//! so `diagnostic.rs` cannot import from `fix.rs`. +//! +//! | Tier | Rules | Semantics | +//! |------|---------------------------------------------------|-----------| +//! | A | duplicate-import, duplicate-export, | Auto-fixable; gated by reverify | +//! | | unreachable-branch, empty-block | | +//! | B | unused-import, unused-function | Fixable only when standalone | +//! | C | unused-variable, redundant-else, shadow-variable | Never fixed | + +/// Fix tier for a lint rule. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FixTier { + /// Auto-fixable with a reverify gate. Diagnostic `fixable` = `true`. + A, + /// Fixable only when recompile proves output-neutral. `fixable` = `true` + /// for standalone files; `fixable` = `false` for non-standalone. + B, + /// Never fixed. Diagnostic `fixable` = `false`. + C, +} + +/// Classify a rule into its fix tier. +pub fn rule_tier(rule: &str) -> FixTier { + match rule { + "duplicate-import" | "duplicate-export" | "unreachable-branch" | "empty-block" => { + FixTier::A + } + "unused-import" | "unused-function" => FixTier::B, + _ => FixTier::C, // unused-variable, redundant-else, shadow-variable, unknown + } +} + +/// Return `true` when this diagnostic is auto-fixable (Tier A or B standalone). +/// +/// The `is_standalone` flag controls whether Tier B diagnostics are fixable. +pub fn is_fixable(rule: &str, is_standalone: bool) -> bool { + match rule_tier(rule) { + FixTier::A => true, + FixTier::B => is_standalone, + FixTier::C => false, + } +} + +/// Record `key` → `offset` as the first occurrence and return `true`; if `key` +/// was already present, leave the map unchanged and return `false`. +/// +/// Shared by `duplicate-import` and `duplicate-export` for the +/// "push-first-occurrence, flag-duplicate" pattern. Extracted here because +/// `tier.rs` is the one lint leaf module with no rule-specific imports — +/// placing it here avoids duplicating an identical private fn in each rule file. +pub(crate) fn first_occurrence( + seen: &mut std::collections::HashMap, + key: K, + offset: usize, +) -> bool { + if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(key) { + e.insert(offset); + true + } else { + false + } +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// ARC-3: Every registered rule name must map to its documented FixTier. + /// + /// Enumerating all 9 rules explicitly means that a newly-added rule which + /// falls silently into the `_ => FixTier::C` catch-all arm will cause this + /// test to fail (once its expected tier is added here), preventing silent + /// misclassification in the JSON `fixable` field and the fix planner. + /// + /// Tier A: duplicate-import, duplicate-export, unreachable-branch, empty-block + /// Tier B: unused-import, unused-function + /// Tier C: unused-variable, redundant-else, shadow-variable + #[test] + fn all_nine_rules_map_to_expected_tier() { + // Tier A — auto-fixable with reverify gate + assert_eq!( + rule_tier("duplicate-import"), + FixTier::A, + "duplicate-import" + ); + assert_eq!( + rule_tier("duplicate-export"), + FixTier::A, + "duplicate-export" + ); + assert_eq!( + rule_tier("unreachable-branch"), + FixTier::A, + "unreachable-branch" + ); + assert_eq!(rule_tier("empty-block"), FixTier::A, "empty-block"); + // Tier B — standalone-only fixable + assert_eq!(rule_tier("unused-import"), FixTier::B, "unused-import"); + assert_eq!(rule_tier("unused-function"), FixTier::B, "unused-function"); + // Tier C — report-only, never fixed + assert_eq!(rule_tier("unused-variable"), FixTier::C, "unused-variable"); + assert_eq!(rule_tier("redundant-else"), FixTier::C, "redundant-else"); + assert_eq!(rule_tier("shadow-variable"), FixTier::C, "shadow-variable"); + } + + /// first_occurrence: first call inserts and returns true. + #[test] + fn first_occurrence_new_key_returns_true() { + let mut map = std::collections::HashMap::new(); + assert!(first_occurrence(&mut map, "a".to_string(), 0)); + assert_eq!(map.get("a"), Some(&0)); + } + + /// first_occurrence: second call on same key returns false without clobbering. + #[test] + fn first_occurrence_duplicate_key_returns_false() { + let mut map = std::collections::HashMap::new(); + first_occurrence(&mut map, "a".to_string(), 0); + assert!(!first_occurrence(&mut map, "a".to_string(), 99)); + // Original offset is preserved. + assert_eq!(map.get("a"), Some(&0)); + } +} diff --git a/crates/mds-core/src/parser.rs b/crates/mds-core/src/parser.rs index 9e900a0..1f1ce5a 100644 --- a/crates/mds-core/src/parser.rs +++ b/crates/mds-core/src/parser.rs @@ -309,7 +309,7 @@ impl Parser<'_> { return parse_import_directive(trimmed, offset); } if is_directive_token(trimmed, "@export") { - return parse_export_directive(trimmed); + return parse_export_directive(trimmed, offset); } if let Some(rest) = trimmed.strip_prefix("@include ") { let alias = rest.trim().to_string(); diff --git a/crates/mds-core/src/parser_helpers.rs b/crates/mds-core/src/parser_helpers.rs index 643d40b..f3b367c 100644 --- a/crates/mds-core/src/parser_helpers.rs +++ b/crates/mds-core/src/parser_helpers.rs @@ -867,7 +867,11 @@ pub(super) fn parse_import_directive(directive: &str, offset: usize) -> Result Result { +/// +/// `offset` is the byte position of the `@export` token in the source — threaded in +/// from the call site in `parser.rs` and stored on every ExportDirective variant for +/// span-based lint diagnostics (D2). +pub(super) fn parse_export_directive(directive: &str, offset: usize) -> Result { let rest = directive.trim_start_matches("@export").trim(); // Wildcard re-export: @export * from "path" @@ -876,7 +880,7 @@ pub(super) fn parse_export_directive(directive: &str) -> Result .or_else(|| rest.strip_prefix("*from ")) { let path = parse_quoted_path(from_part.trim())?; - return Ok(Node::Export(ExportDirective::Wildcard { path })); + return Ok(Node::Export(ExportDirective::Wildcard { path, offset })); } // Check for "name from" pattern: @export name from "path" @@ -889,7 +893,11 @@ pub(super) fn parse_export_directive(directive: &str) -> Result ))); } let path = parse_quoted_path(parts[2])?; - return Ok(Node::Export(ExportDirective::ReExport { name, path })); + return Ok(Node::Export(ExportDirective::ReExport { + name, + path, + offset, + })); } // Named export: @export name @@ -900,7 +908,7 @@ pub(super) fn parse_export_directive(directive: &str) -> Result if !is_valid_identifier(&name) { return Err(MdsError::syntax(format!("invalid export name: '{name}'"))); } - Ok(Node::Export(ExportDirective::Named { name })) + Ok(Node::Export(ExportDirective::Named { name, offset })) } /// Parse a quoted path like `"./utils.mds"` and return the inner string. diff --git a/crates/mds-core/src/parser_tests.rs b/crates/mds-core/src/parser_tests.rs index af1a3de..c47bcff 100644 --- a/crates/mds-core/src/parser_tests.rs +++ b/crates/mds-core/src/parser_tests.rs @@ -93,6 +93,68 @@ fn parse_import_selective() { } } +// D2 canary tests: ExportDirective variants carry the byte offset of the @export token. +// These tests are RED until `offset: usize` is added to all three ExportDirective +// variants in ast.rs and threaded through parse_export_directive. + +#[test] +fn export_named_carries_offset() { + // "@export greet" starts at byte 0. + let src = "@export greet\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "", src).unwrap(); + if let Node::Export(ExportDirective::Named { name, offset }) = &module.body[0] { + assert_eq!(name, "greet"); + assert_eq!( + *offset, 0, + "Named export offset must be 0 when at source start" + ); + } else { + panic!("expected Named export node"); + } +} + +#[test] +fn export_reexport_carries_offset() { + // A preamble line puts @export at byte offset > 0. + let src = "some text\n@export greet from \"./greetings.mds\"\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "", src).unwrap(); + // Find the Export node (body may also contain Text nodes). + let export_node = module + .body + .iter() + .find(|n| matches!(n, Node::Export(_))) + .expect("expected Export node"); + if let Node::Export(ExportDirective::ReExport { name, offset, .. }) = export_node { + assert_eq!(name, "greet"); + // "some text\n" is 10 bytes, so @export begins at byte 10. + assert_eq!( + *offset, 10, + "ReExport offset must equal byte position in source" + ); + } else { + panic!("expected ReExport node"); + } +} + +#[test] +fn export_wildcard_carries_offset() { + // "@export * from ..." at source start → offset 0. + let src = "@export * from \"./formatting.mds\"\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "", src).unwrap(); + if let Node::Export(ExportDirective::Wildcard { path, offset }) = &module.body[0] { + assert_eq!(path, "./formatting.mds"); + assert_eq!( + *offset, 0, + "Wildcard export offset must be 0 when at source start" + ); + } else { + panic!("expected Wildcard export node"); + } +} + #[test] fn parse_export_named() { let src = "@export greet\n"; diff --git a/crates/mds-core/src/resolver.rs b/crates/mds-core/src/resolver.rs index 9abb8d0..bf0571e 100644 --- a/crates/mds-core/src/resolver.rs +++ b/crates/mds-core/src/resolver.rs @@ -1323,12 +1323,13 @@ impl ModuleCache { ) -> Result<(), MdsError> { defs.has_explicit_exports = true; match export { - ExportDirective::Named { name } => { + ExportDirective::Named { name, .. } => { defs.explicit_exports.insert(name.clone()); } ExportDirective::ReExport { name, path: import_path, + .. } => { // Resolve the source module and bring in the function for // re-export only. Per spec: "@export from does not bring the @@ -1349,7 +1350,9 @@ impl ModuleCache { defs.functions.insert(name.clone(), func); defs.explicit_exports.insert(name.clone()); } - ExportDirective::Wildcard { path: import_path } => { + ExportDirective::Wildcard { + path: import_path, .. + } => { // Re-export all exports from the target module. These are // available to importers but NOT in the current file's scope. // Note: resolve_import_from calls validate_import_path internally, diff --git a/crates/mds-core/tests/api_surface.rs b/crates/mds-core/tests/api_surface.rs index 7a833e8..4eb0c1b 100644 --- a/crates/mds-core/tests/api_surface.rs +++ b/crates/mds-core/tests/api_surface.rs @@ -2,8 +2,9 @@ use std::collections::HashMap; use std::path::Path; use mds::{ - CompileResult, CompiledOutput, FileSystem, MdsError, ModuleCache, NativeFs, Value, VirtualFs, - MAX_FILE_SIZE, MAX_TRAVERSAL_DEPTH, + CompileResult, CompiledOutput, FileSystem, LintConfig, LintDiagnostic, LintResult, MdsError, + ModuleCache, NativeFs, Severity, Value, VirtualFs, MAX_DIAGNOSTICS, MAX_FILE_SIZE, + MAX_TRAVERSAL_DEPTH, }; #[test] @@ -953,6 +954,336 @@ fn compile_max_file_size_still_enforced() { ); } +// ── Lint API surface pins (L-API-1/2/3/4/5) ────────────────────────────────── + +/// L-API-1: lint_* function signatures mirror check_* conventions. +#[test] +#[allow(clippy::type_complexity)] +fn lint_api_signatures_exist() { + // lint_str: simplest form, no options. + let _: fn(&str) -> Result = mds::lint_str; + + // lint_str_with: full options — base_dir, runtime_vars, config. + let _: fn( + &str, + Option<&Path>, + Option>, + &LintConfig, + ) -> Result = mds::lint_str_with; + + // lint: file-based entry point. + let _: fn(&Path, Option>, &LintConfig) -> Result = + mds::lint; + + // lint_virtual: virtual-FS entry point. + let _: fn( + HashMap, + &str, + Option>, + &LintConfig, + ) -> Result = mds::lint_virtual; +} + +/// L-API-2: pub types LintDiagnostic, Severity, LintConfig, LintResult are accessible +/// and have the expected fields/variants. +#[test] +fn lint_types_exist() { + // Severity has four variants with lowercase serde names. + let _off = Severity::Off; + let _info = Severity::Info; + let _warn = Severity::Warn; + let _err = Severity::Error; + + // LintConfig has a `rules` field (HashMap). + let config = LintConfig { + rules: HashMap::from([("unused-variable".to_string(), Severity::Off)]), + }; + assert_eq!(config.rules.get("unused-variable"), Some(&Severity::Off)); + + // LintDiagnostic has the expected fields. + let diag = LintDiagnostic { + rule: "unused-variable".to_string(), + severity: Severity::Warn, + message: "Variable 'name' is never used".to_string(), + help: Some("Remove the frontmatter key or reference it in the body".to_string()), + span: None, + file: Some("test.mds".to_string()), + }; + assert_eq!(diag.rule, "unused-variable"); + assert_eq!(diag.severity, Severity::Warn); + + // LintResult has diagnostics, truncated, and is_standalone fields. + let result = LintResult { + diagnostics: vec![diag], + truncated: false, + is_standalone: false, + }; + assert_eq!(result.diagnostics.len(), 1); + assert!(!result.truncated); +} + +/// L-API-4: MdsError enum is unchanged — lint findings are LintDiagnostic, not MdsError variants. +#[test] +fn mds_error_variants_unchanged_by_lint() { + // All pre-existing MdsError variants still exist and are exhaustively matched. + // This is a compile-time check — if new variants were accidentally added, this match + // would not produce an "unreachable pattern" warning (since we allow it via `_ => {}`). + #[allow(unreachable_patterns)] + match (MdsError::Io { + message: "x".to_string(), + }) { + MdsError::Syntax { .. } + | MdsError::UndefinedVariable { .. } + | MdsError::UndefinedFunction { .. } + | MdsError::ArityMismatch { .. } + | MdsError::TypeError { .. } + | MdsError::CircularImport { .. } + | MdsError::FileNotFound { .. } + | MdsError::ImportError { .. } + | MdsError::NameCollision { .. } + | MdsError::NotMdsFile { .. } + | MdsError::Io { .. } + | MdsError::ResourceLimit { .. } + | MdsError::YamlError { .. } + | MdsError::JsonError { .. } + | MdsError::Recursion { .. } + | MdsError::ExportError { .. } + | MdsError::BuiltinError { .. } + | MdsError::FormatterInvariant { .. } => {} + _ => {} + } +} + +/// L-API-5: MAX_DIAGNOSTICS is 1_000, a publicly re-exported constant from limits.rs. +#[test] +fn max_diagnostics_pinned() { + assert_eq!(MAX_DIAGNOSTICS, 1_000); +} + +/// L-U-JSON1: LintResult::to_canonical_json produces the expected schema shape. +#[test] +fn lint_canonical_json_schema() { + use mds::SerializedSpan; + + let result = LintResult { + diagnostics: vec![LintDiagnostic { + rule: "unused-variable".to_string(), + severity: Severity::Warn, + message: "Variable 'name' is never used".to_string(), + help: Some("Remove the frontmatter key or reference it in the body".to_string()), + span: Some(SerializedSpan { + offset: 4, + length: 4, + line: Some(2), + column: Some(1), + }), + file: Some("test.mds".to_string()), + }], + truncated: false, + is_standalone: false, + }; + + let json = result.to_canonical_json(); + + // Top-level shape. + assert_eq!(json["version"], 1, "version must be 1"); + assert!(json["files"].is_array(), "files must be an array"); + assert!( + !json["truncated"].as_bool().unwrap_or(true), + "truncated must be false" + ); + + // Per-file grouping. + let files = json["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "one file entry for test.mds"); + let file_entry = &files[0]; + assert_eq!(file_entry["file"], "test.mds"); + assert!(file_entry["diagnostics"].is_array()); + + // Per-diagnostic shape. + let diags = file_entry["diagnostics"].as_array().unwrap(); + assert_eq!(diags.len(), 1); + let d = &diags[0]; + assert_eq!(d["rule"], "unused-variable"); + assert_eq!(d["severity"], "warn"); + assert!(d["message"].is_string()); + assert!(d["help"].is_string()); + assert_eq!(d["fixable"], false); + + // SerializedError-compatible span shape. + let span = &d["span"]; + assert_eq!(span["offset"], 4); + assert_eq!(span["length"], 4); + assert_eq!(span["line"], 2); + assert_eq!(span["column"], 1); +} + +/// L-U-JSON1b: to_canonical_json fixable field reflects tier semantics and is_standalone flag. +#[test] +fn lint_canonical_json_fixable_semantics() { + use mds::LintDiagnostic; + + // Tier A rule (duplicate-import) → fixable regardless of is_standalone. + let tier_a = LintResult { + diagnostics: vec![LintDiagnostic { + rule: "duplicate-import".to_string(), + severity: Severity::Error, + message: "Duplicate import".to_string(), + help: None, + span: None, + file: Some("a.mds".to_string()), + }], + truncated: false, + is_standalone: false, // even non-standalone Tier A is fixable + }; + let json = tier_a.to_canonical_json(); + assert_eq!(json["files"][0]["diagnostics"][0]["fixable"], true); + + // Tier B rule (unused-import) → fixable only for standalone files. + let tier_b_non_standalone = LintResult { + diagnostics: vec![LintDiagnostic { + rule: "unused-import".to_string(), + severity: Severity::Warn, + message: "Unused import".to_string(), + help: None, + span: None, + file: Some("b.mds".to_string()), + }], + truncated: false, + is_standalone: false, + }; + let json = tier_b_non_standalone.to_canonical_json(); + assert_eq!(json["files"][0]["diagnostics"][0]["fixable"], false); + + let tier_b_standalone = LintResult { + diagnostics: vec![LintDiagnostic { + rule: "unused-import".to_string(), + severity: Severity::Warn, + message: "Unused import".to_string(), + help: None, + span: None, + file: Some("c.mds".to_string()), + }], + truncated: false, + is_standalone: true, + }; + let json = tier_b_standalone.to_canonical_json(); + assert_eq!(json["files"][0]["diagnostics"][0]["fixable"], true); + + // Tier C rule (unused-variable) → never fixable. + let tier_c = LintResult { + diagnostics: vec![LintDiagnostic { + rule: "unused-variable".to_string(), + severity: Severity::Warn, + message: "Unused variable".to_string(), + help: None, + span: None, + file: Some("d.mds".to_string()), + }], + truncated: false, + is_standalone: true, // even standalone Tier C is not fixable + }; + let json = tier_c.to_canonical_json(); + assert_eq!(json["files"][0]["diagnostics"][0]["fixable"], false); +} + +/// lint_str on a trivially valid template returns an empty LintResult (no diagnostics). +#[test] +fn lint_str_trivial_source_returns_empty() { + let result = mds::lint_str("Hello!\n").expect("lint_str should succeed for valid source"); + assert!( + result.diagnostics.is_empty(), + "trivial source should produce no diagnostics: {result:?}" + ); + assert!(!result.truncated); + // A file with no imports or @extends is standalone. + assert!( + result.is_standalone, + "plain source with no imports should be standalone" + ); +} + +/// lint_str_with on a source that has an @import is not standalone. +#[test] +fn lint_str_with_imports_is_not_standalone() { + // Source with an import that doesn't exist → check gate fails, MdsError. + // Use a virtual-fs approach via lint_virtual to test is_standalone=false. + let mut modules = std::collections::HashMap::new(); + modules.insert("lib.mds".to_string(), "Hello!\n".to_string()); + modules.insert( + "consumer.mds".to_string(), + "@import \"./lib.mds\" as lib\nHi!\n".to_string(), + ); + let config = LintConfig::default(); + let result = mds::lint_virtual(modules, "consumer.mds", None, &config).expect("should lint OK"); + // consumer.mds has @import — it is NOT standalone. + assert!( + !result.is_standalone, + "file with @import should not be standalone" + ); +} + +/// lint_virtual on a valid virtual FS returns an empty LintResult. +#[test] +fn lint_virtual_trivial_returns_empty() { + let mut modules = HashMap::new(); + modules.insert("main.mds".to_string(), "Hello!\n".to_string()); + let config = LintConfig::default(); + let result = + mds::lint_virtual(modules, "main.mds", None, &config).expect("lint_virtual should succeed"); + assert!(result.diagnostics.is_empty()); +} + +/// lint_str on an invalid template (syntax error) returns Err(MdsError). +#[test] +fn lint_str_invalid_source_returns_err() { + // Unclosed @if block is a syntax error — should fail the check gate. + let result = mds::lint_str("@if x:\nhello\n"); + assert!( + result.is_err(), + "lint_str should return Err for invalid source" + ); +} + +// ── D2: ExportDirective offset regression (L-API-3) ───────────────────────── +// +// ExportDirective is pub(crate), so we cannot name it directly in an integration +// test. Instead we verify that parsing and resolution of all three export forms +// still succeed after the D2 offset field addition — a regression in any variant +// would surface here as a compile or runtime failure. + +#[test] +fn export_directive_forms_still_resolve_after_d2() { + // Named export: @export greet + // ReExport: @export greet from "..." + // Wildcard: @export * from "..." + // All three forms must still parse and resolve without error. + let named_src = "@define greet():\nhello\n@end\n@export greet\n"; + let reexport_src = "@export greet from \"./lib.mds\"\n"; + let wildcard_src = "@export * from \"./lib.mds\"\n"; + let lib_src = "@define greet():\nhello\n@end\n@export greet\n"; + + // Named: check that a module with @export named resolves. + let result = mds::check_str(named_src); + assert!( + result.is_ok(), + "Named export form should resolve: {result:?}" + ); + + // ReExport and Wildcard require an importable lib module — use virtual FS. + let mut modules = std::collections::HashMap::new(); + modules.insert("lib.mds".to_string(), lib_src.to_string()); + modules.insert("main_reexport.mds".to_string(), reexport_src.to_string()); + let result = mds::check_virtual(modules.clone(), "main_reexport.mds", None); + assert!(result.is_ok(), "ReExport form should resolve: {result:?}"); + + let mut modules2 = std::collections::HashMap::new(); + modules2.insert("lib.mds".to_string(), lib_src.to_string()); + modules2.insert("main_wildcard.mds".to_string(), wildcard_src.to_string()); + let result = mds::check_virtual(modules2, "main_wildcard.mds", None); + assert!(result.is_ok(), "Wildcard form should resolve: {result:?}"); +} + // ── NativeFs::check_symlink public API pin ──────────────────────────────────── #[test] diff --git a/crates/mds-napi/__test__/index.spec.mjs b/crates/mds-napi/__test__/index.spec.mjs index 5e6d765..f031c3a 100644 --- a/crates/mds-napi/__test__/index.spec.mjs +++ b/crates/mds-napi/__test__/index.spec.mjs @@ -758,3 +758,225 @@ describe('intrinsic output shape', () => { ); }); }); + +// ── Lint tests ──────────────────────────────────────────────────────────────── + +const { lint, lintFile, lintVirtual } = addon; + +describe('lint', () => { + // L-N-1: clean source returns canonical shape + test('L-N-1: lint on clean source returns version:1, empty files, truncated:false', () => { + const result = lint('Hello World!\n'); + assert.equal(result.version, 1, 'version must be 1'); + assert.ok(Array.isArray(result.files), 'files must be an array'); + assert.equal(result.files.length, 0, 'clean source should have no file entries'); + assert.equal(result.truncated, false, 'truncated must be false'); + }); + + // L-N-2: null/undefined options accepted + test('L-N-2: lint accepts null options', () => { + const result = lint('Hello World!\n', null); + assert.equal(result.version, 1); + }); + + test('L-N-3: lint accepts undefined options', () => { + const result = lint('Hello World!\n', undefined); + assert.equal(result.version, 1); + }); + + // L-N-4: unused-variable finding on frontmatter key + test('L-N-4: lint detects unused frontmatter variable', () => { + const source = '---\nunused_key: value\n---\nHello!\n'; + const result = lint(source); + assert.equal(result.version, 1); + // There should be at least one file entry with a diagnostic for unused-variable. + const allDiags = result.files.flatMap((f) => f.diagnostics); + const hasUnused = allDiags.some((d) => d.rule === 'unused-variable'); + assert.ok(hasUnused, `expected unused-variable diagnostic; got: ${JSON.stringify(allDiags)}`); + }); + + // L-N-5: rules option silences a rule + test('L-N-5: rules option can silence a rule', () => { + const source = '---\nunused_key: value\n---\nHello!\n'; + const result = lint(source, { rules: { 'unused-variable': 'off' } }); + const allDiags = result.files.flatMap((f) => f.diagnostics); + const hasUnused = allDiags.some((d) => d.rule === 'unused-variable'); + assert.ok(!hasUnused, `unused-variable should be silenced; got: ${JSON.stringify(allDiags)}`); + }); + + // L-N-6: unknown severity value in rules throws mds::invalid_options + test('L-N-6: unknown severity value in rules throws mds::invalid_options', () => { + assert.throws( + () => lint('Hello!\n', { rules: { 'unused-variable': 'verbose' } }), + (err) => { + assert.equal(err.code, 'mds::invalid_options', `got: ${err.code}`); + return true; + }, + ); + }); + + // L-N-7: unknown option key throws mds::invalid_options + test('L-N-7: unknown option key throws mds::invalid_options', () => { + assert.throws( + () => lint('Hello!\n', { unknownKey: true }), + (err) => { + assert.equal(err.code, 'mds::invalid_options', `got: ${err.code}`); + return true; + }, + ); + }); + + // L-N-8: parse error throws (check gate enforced) + test('L-N-8: lint on invalid source throws with mds:: error code', () => { + assert.throws( + () => lint('Hello {undefined_var}!\n'), + (err) => { + assert.ok(err instanceof Error); + assert.ok(err.code.startsWith('mds::'), `expected mds:: code, got: ${err.code}`); + return true; + }, + ); + }); +}); + +describe('lintFile', () => { + // L-NF-1: lint a valid file + test('L-NF-1: lintFile on clean file returns canonical shape', () => { + const result = lintFile(SIMPLE_MDS); + assert.equal(result.version, 1); + assert.ok(Array.isArray(result.files)); + assert.equal(result.truncated, false); + }); + + // L-NF-2: nonexistent file throws mds::file_not_found + test('L-NF-2: lintFile on nonexistent file throws mds::file_not_found', () => { + assert.throws( + () => lintFile('/nonexistent/path/file.mds'), + (err) => { + assert.ok(err instanceof Error); + assert.equal(err.code, 'mds::file_not_found', `got: ${err.code}`); + return true; + }, + ); + }); + + // L-NF-3: basePath option rejected + test('L-NF-3: lintFile rejects basePath option', () => { + assert.throws( + () => lintFile(SIMPLE_MDS, { basePath: '/some/path' }), + (err) => { + assert.equal(err.code, 'mds::invalid_options', `got: ${err.code}`); + return true; + }, + ); + }); + + // L-NF-4: rules option accepted + test('L-NF-4: lintFile accepts rules option', () => { + const result = lintFile(SIMPLE_MDS, { rules: { 'unused-variable': 'off' } }); + assert.equal(result.version, 1); + }); +}); + +describe('lintVirtual', () => { + // L-NV-1: clean virtual module + test('L-NV-1: lintVirtual on clean module returns canonical shape', () => { + const modules = { 'main.mds': 'Hello World!\n' }; + const result = lintVirtual(modules, 'main.mds'); + assert.equal(result.version, 1); + assert.ok(Array.isArray(result.files)); + assert.equal(result.truncated, false); + }); + + // L-NV-2: finding in virtual module + test('L-NV-2: lintVirtual detects lint findings', () => { + const modules = { 'main.mds': '---\nunused_key: value\n---\nHello!\n' }; + const result = lintVirtual(modules, 'main.mds'); + assert.equal(result.version, 1); + const allDiags = result.files.flatMap((f) => f.diagnostics); + const hasUnused = allDiags.some((d) => d.rule === 'unused-variable'); + assert.ok(hasUnused, `expected unused-variable; got: ${JSON.stringify(allDiags)}`); + }); + + // L-NV-3: rules option accepted + test('L-NV-3: lintVirtual accepts rules option', () => { + const modules = { 'main.mds': '---\nunused_key: value\n---\nHello!\n' }; + const result = lintVirtual(modules, 'main.mds', { rules: { 'unused-variable': 'off' } }); + const allDiags = result.files.flatMap((f) => f.diagnostics); + const hasUnused = allDiags.some((d) => d.rule === 'unused-variable'); + assert.ok(!hasUnused, 'unused-variable should be silenced'); + }); + + // L-NV-4: basePath option rejected with correct function name + test('L-NV-4: lintVirtual rejects basePath option', () => { + const modules = { 'main.mds': 'Hello World!\n' }; + assert.throws( + () => lintVirtual(modules, 'main.mds', { basePath: '/some/path' }), + (err) => { + assert.equal(err.code, 'mds::invalid_options', `got: ${err.code}`); + assert.ok( + err.message.includes('lintVirtual'), + `expected message to name lintVirtual; got: ${err.message}`, + ); + assert.ok( + !err.message.includes('lintFile'), + `expected message NOT to name lintFile; got: ${err.message}`, + ); + return true; + }, + ); + }); +}); + +// ── Lint parity guard: lint + lintFile produce canonical JSON ───────────────── + +describe('lint parity (AC-API-06 guard)', () => { + // P-L-1: lint and lintFile produce identical JSON for same source + test('P-L-1: lint and lintFile produce identical JSON for the same file', () => { + const fileResult = lintFile(SIMPLE_MDS); + const source = fs.readFileSync(SIMPLE_MDS, 'utf8'); + // lint(source, {basePath: dir}) should produce the same canonical JSON. + const dir = path.dirname(SIMPLE_MDS); + const strResult = lint(source, { basePath: dir }); + // Compare JSON serializations — they must be byte-identical. + assert.equal( + JSON.stringify(strResult), + JSON.stringify(fileResult), + 'lint and lintFile must produce identical canonical JSON for the same source', + ); + }); + + // P-L-2: lintVirtual matches checked-in canonical JSON goldens (AC-API-06) + // + // These goldens are derived from the Rust core serializer and checked in — not + // regenerated from the napi binding, so the comparison is non-circular. A drift + // in the serializer would fail this test before it ships to consumers. + // + // Keys are in BTreeMap (alphabetical) order: + // {"files":[...],"truncated":false,"version":1} + test('P-L-2: lintVirtual clean source matches canonical golden', () => { + const CLEAN_GOLDEN = '{"files":[],"truncated":false,"version":1}'; + const result = lintVirtual({ 'main.mds': 'Hello World!\n' }, 'main.mds'); + assert.equal( + JSON.stringify(result), + CLEAN_GOLDEN, + `napi lintVirtual clean golden mismatch:\n got: ${JSON.stringify(result)}\n expect: ${CLEAN_GOLDEN}`, + ); + }); + + test('P-L-3: lintVirtual unused-variable source matches canonical golden', () => { + const UNUSED_GOLDEN = + '{"files":[{"diagnostics":[{"fixable":false,"help":"Remove the frontmatter key or reference it in the template body.",' + + '"message":"Variable \'unused_key\' is defined in frontmatter but never referenced in the body.",' + + '"rule":"unused-variable","severity":"warn","span":{"length":10,"offset":4}}],"file":"main.mds"}],"truncated":false,"version":1}'; + const result = lintVirtual( + { 'main.mds': '---\nunused_key: value\n---\nHello!\n' }, + 'main.mds', + ); + assert.equal( + JSON.stringify(result), + UNUSED_GOLDEN, + `napi lintVirtual unused-variable golden mismatch:\n got: ${JSON.stringify(result)}\n expect: ${UNUSED_GOLDEN}`, + ); + }); +}); diff --git a/crates/mds-napi/src/lib.rs b/crates/mds-napi/src/lib.rs index 98272a1..e9c62a0 100644 --- a/crates/mds-napi/src/lib.rs +++ b/crates/mds-napi/src/lib.rs @@ -65,6 +65,17 @@ use napi_derive::napi; /// when the caller passes a string, so the limit must be re-enforced here. const MAX_SOURCE_SIZE: usize = mds::MAX_FILE_SIZE as usize; +/// Maximum number of module entries accepted by `lintVirtual`. +/// +/// Mirrors the WASM and Python bindings. 256 modules is well above any realistic +/// template graph; the cap prevents a caller from exhausting memory with thousands +/// of small virtual modules. +const MAX_MODULE_COUNT: usize = 256; + +/// Maximum aggregate byte size of all `lintVirtual` module values combined +/// (same ceiling as a single source input). +const MAX_MODULES_AGGREGATE_SIZE: usize = MAX_SOURCE_SIZE; + // ── Return types ────────────────────────────────────────────────────────────── /// Result returned by `check` and `checkFile`. @@ -632,3 +643,293 @@ pub fn check_file(env: Env, path: String, opts: Option) -> napi::Result< Ok(CheckResult { warnings }) } + +// ── Lint options parsing ────────────────────────────────────────────────────── + +/// Extract and validate the `rules` option: `Record` → `mds::LintConfig`. +/// +/// Returns the default config (all rules at built-in defaults) when `rules` is absent, +/// `null`, or `undefined`. Validates each severity value against the closed enum. +fn extract_rules_direct(env: &Env, obj: &Object) -> napi::Result { + if !obj.has_named_property("rules")? { + return Ok(mds::LintConfig::default()); + } + let val: Unknown = obj.get_named_property_unchecked("rules")?; + let vt = val.get_type()?; + match vt { + ValueType::Undefined | ValueType::Null => Ok(mds::LintConfig::default()), + ValueType::Object => { + // Deserialize the rules sub-object; js arrays also satisfy Object so + // we guard against that in the JSON shape check below. + let rules_json: serde_json::Value = env.from_js_value(val)?; + let serde_json::Value::Object(rules_map) = rules_json else { + return Err(throw_options_error( + env, + &format!( + "options.rules must be a plain object, got {}", + mds::json_type_name(&rules_json) + ), + )); + }; + let mut rules = HashMap::new(); + for (key, val) in rules_map { + let serde_json::Value::String(s) = &val else { + return Err(throw_options_error( + env, + &format!( + "options.rules[\"{key}\"] must be a severity string, got {}", + mds::json_type_name(&val) + ), + )); + }; + // Parse severity via serde — validates against the closed enum. + let severity: mds::Severity = + serde_json::from_str(&format!("\"{s}\"")).map_err(|_| { + throw_options_error( + env, + &format!( + "options.rules[\"{key}\"]: unknown severity \"{s}\"; \ + valid values are \"off\", \"info\", \"warn\", \"error\"" + ), + ) + })?; + rules.insert(key, severity); + } + Ok(mds::LintConfig { rules }) + } + other => Err(throw_options_error( + env, + &format!( + "options.rules must be a plain object, got {}", + napi_type_name(other) + ), + )), + } +} + +/// Parse options for `lint` and `lintVirtual` (source-string / virtual variants). +/// +/// Valid keys: `basePath`, `vars`, `rules`. +/// Returns `(base_path, vars, lint_config)`. +type LintOpts = ( + Option, + Option>, + mds::LintConfig, +); + +fn parse_lint_opts(env: &Env, opts: Option) -> napi::Result { + let Some(opts_obj) = opts else { + return Ok((None, None, mds::LintConfig::default())); + }; + + reject_unknown_napi_keys(env, &opts_obj, &["basePath", "vars", "rules"])?; + let base_path = extract_base_path_direct(env, &opts_obj)?; + let vars = extract_vars_direct(env, &opts_obj)?; + let lint_config = extract_rules_direct(env, &opts_obj)?; + + Ok((base_path, vars, lint_config)) +} + +/// Parse options for `lintFile` (file-path variant). +/// +/// Valid keys: `vars`, `rules`. `basePath` is not accepted (derived from file path). +type LintFileOpts = (Option>, mds::LintConfig); + +fn parse_lint_file_opts(env: &Env, opts: Option) -> napi::Result { + let Some(opts_obj) = opts else { + return Ok((None, mds::LintConfig::default())); + }; + + if opts_obj.has_named_property("basePath")? { + return Err(throw_options_error( + env, + "option \"basePath\" is not valid for lintFile; \ + the base directory is derived from the file path", + )); + } + + reject_unknown_napi_keys(env, &opts_obj, &["vars", "rules"])?; + let vars = extract_vars_direct(env, &opts_obj)?; + let lint_config = extract_rules_direct(env, &opts_obj)?; + + Ok((vars, lint_config)) +} + +/// Parse options for `lintVirtual` (virtual-module variant). +/// +/// Valid keys: `vars`, `rules`. `basePath` is not accepted — virtual modules +/// have no file path; `@import` paths in a virtual module are resolved +/// against the module map, not the filesystem. +fn parse_lint_virtual_opts(env: &Env, opts: Option) -> napi::Result { + let Some(opts_obj) = opts else { + return Ok((None, mds::LintConfig::default())); + }; + + if opts_obj.has_named_property("basePath")? { + return Err(throw_options_error( + env, + "option \"basePath\" is not valid for lintVirtual; \ + virtual modules have no file path", + )); + } + + reject_unknown_napi_keys(env, &opts_obj, &["vars", "rules"])?; + let vars = extract_vars_direct(env, &opts_obj)?; + let lint_config = extract_rules_direct(env, &opts_obj)?; + + Ok((vars, lint_config)) +} + +// ── Public lint exports ─────────────────────────────────────────────────────── + +/// Lint an MDS template source string for static analysis findings. +/// +/// Runs the check gate (resolve+validate) first — on a compile error, throws a +/// JS `Error` with the same structure as `compile`. On a clean gate, applies +/// the lint rules and returns the canonical lint result object. +/// +/// ## Arguments +/// +/// - `source`: MDS template source text. +/// - `opts`: optional configuration object: +/// - `basePath` (string): base directory for resolving `@import` paths. +/// - `vars` (`Record`): runtime variable overrides. +/// - `rules` (`Record`): per-rule severity overrides +/// (e.g. `{ "shadow-variable": "warn", "unused-variable": "off" }`). +/// +/// ## Returns +/// +/// On success, the canonical lint JSON object: +/// `{ version: 1, files: [{file, diagnostics: [{rule, severity, message, help, fixable, span?},...]},...], truncated: bool }` +/// +/// On failure, throws a JS `Error` with the same structure as `compile`. +#[napi] +pub fn lint(env: Env, source: String, opts: Option) -> napi::Result { + check_source_size(&env, &source)?; + + let (base_path, vars, lint_config) = parse_lint_opts(&env, opts)?; + + let result = run_catching( + &env, + AssertUnwindSafe(move || { + mds::lint_str_with(&source, base_path.as_deref(), vars, &lint_config) + }), + )?; + + Ok(result.to_canonical_json()) +} + +/// Lint an MDS template file for static analysis findings. +/// +/// ## Arguments +/// +/// - `path`: path to the `.mds` file to lint. +/// - `opts`: optional configuration object: +/// - `vars` (`Record`): runtime variable overrides. +/// - `rules` (`Record`): per-rule severity overrides. +/// +/// `basePath` is not accepted — the base directory is derived from the file's +/// own directory. +/// +/// ## Returns +/// +/// Same shape as `lint`. Dependencies in the returned JSON are absolute filesystem paths. +#[napi(js_name = "lintFile")] +pub fn lint_file(env: Env, path: String, opts: Option) -> napi::Result { + let (vars, lint_config) = parse_lint_file_opts(&env, opts)?; + + let path_buf = PathBuf::from(path); + let result = run_catching( + &env, + AssertUnwindSafe(move || mds::lint(&path_buf, vars, &lint_config)), + )?; + + Ok(result.to_canonical_json()) +} + +/// Lint a multi-module virtual filesystem for static analysis findings. +/// +/// Provides an explicit virtual module map and entry point for lint, enabling +/// callers to lint templates without touching the filesystem — useful for +/// editor integrations, LSP servers, and bundler plugins. +/// +/// ## Arguments +/// +/// - `modules`: `Record` mapping module key → source string. +/// - `entry`: the key within `modules` to use as the lint entry point. +/// - `opts`: optional configuration object: +/// - `vars` (`Record`): runtime variable overrides. +/// - `rules` (`Record`): per-rule severity overrides. +/// +/// ## Returns +/// +/// Same shape as `lint`. +#[napi(js_name = "lintVirtual")] +pub fn lint_virtual( + env: Env, + modules: serde_json::Value, + entry: String, + opts: Option, +) -> napi::Result { + // Parse the modules map. + let serde_json::Value::Object(mods_map) = modules else { + return Err(throw_options_error( + &env, + &format!( + "modules must be a plain object, got {}", + mds::json_type_name(&modules) + ), + )); + }; + + // Convert and validate, enforcing the same module-count and size caps the WASM + // and Python bindings apply — the virtual-FS path bypasses the file layer's own + // size guard, so it must be re-enforced here (defense-in-depth, cross-binding parity). + if mods_map.len() > MAX_MODULE_COUNT { + return Err(throw_resource_limit( + &env, + &format!( + "modules exceeds maximum module count of {MAX_MODULE_COUNT} ({} provided)", + mods_map.len() + ), + )); + } + let mut mods: HashMap = HashMap::with_capacity(mods_map.len()); + let mut aggregate: usize = 0; + for (key, val) in mods_map { + let serde_json::Value::String(s) = val else { + return Err(throw_options_error( + &env, + &format!("modules[\"{key}\"] must be a string source"), + )); + }; + if s.len() > MAX_SOURCE_SIZE { + return Err(throw_resource_limit( + &env, + &format!( + "modules[\"{key}\"] exceeds maximum size of {MAX_SOURCE_SIZE} bytes ({} bytes provided)", + s.len() + ), + )); + } + aggregate = aggregate.saturating_add(s.len()); + if aggregate > MAX_MODULES_AGGREGATE_SIZE { + return Err(throw_resource_limit( + &env, + &format!( + "modules aggregate size exceeds maximum of {MAX_MODULES_AGGREGATE_SIZE} bytes" + ), + )); + } + mods.insert(key, s); + } + + let (vars, lint_config) = parse_lint_virtual_opts(&env, opts)?; + + let result = run_catching( + &env, + AssertUnwindSafe(move || mds::lint_virtual(mods, &entry, vars, &lint_config)), + )?; + + Ok(result.to_canonical_json()) +} diff --git a/crates/mds-python/pyrightconfig.json b/crates/mds-python/pyrightconfig.json new file mode 100644 index 0000000..c2e0c5a --- /dev/null +++ b/crates/mds-python/pyrightconfig.json @@ -0,0 +1,5 @@ +{ + "venvPath": "../..", + "venv": ".venv", + "extraPaths": ["python"] +} diff --git a/crates/mds-python/python/mdscript/__init__.py b/crates/mds-python/python/mdscript/__init__.py index a0e7750..262ed94 100644 --- a/crates/mds-python/python/mdscript/__init__.py +++ b/crates/mds-python/python/mdscript/__init__.py @@ -23,6 +23,7 @@ from ._mdscript import ( CheckResult, CompileResult, + LintResult, MdsError, Message, Span, @@ -32,6 +33,9 @@ compile, compile_file, compile_virtual, + lint, + lint_file, + lint_virtual, scan_imports, ) @@ -48,6 +52,7 @@ __all__ = [ "CheckResult", "CompileResult", + "LintResult", "MdsError", "Message", "Span", @@ -58,5 +63,8 @@ "compile", "compile_file", "compile_virtual", + "lint", + "lint_file", + "lint_virtual", "scan_imports", ] diff --git a/crates/mds-python/python/mdscript/__init__.pyi b/crates/mds-python/python/mdscript/__init__.pyi index 1ee74a8..c599c3e 100644 --- a/crates/mds-python/python/mdscript/__init__.pyi +++ b/crates/mds-python/python/mdscript/__init__.pyi @@ -8,6 +8,7 @@ from __future__ import annotations from ._mdscript import CheckResult as CheckResult from ._mdscript import CompileResult as CompileResult +from ._mdscript import LintResult as LintResult from ._mdscript import MdsError as MdsError from ._mdscript import Message as Message from ._mdscript import Span as Span @@ -17,12 +18,16 @@ from ._mdscript import check_virtual as check_virtual from ._mdscript import compile as compile from ._mdscript import compile_file as compile_file from ._mdscript import compile_virtual as compile_virtual +from ._mdscript import lint as lint +from ._mdscript import lint_file as lint_file +from ._mdscript import lint_virtual as lint_virtual from ._mdscript import scan_imports as scan_imports __version__: str __all__ = [ "CheckResult", "CompileResult", + "LintResult", "MdsError", "Message", "Span", @@ -33,5 +38,8 @@ __all__ = [ "compile", "compile_file", "compile_virtual", + "lint", + "lint_file", + "lint_virtual", "scan_imports", ] diff --git a/crates/mds-python/python/mdscript/_mdscript.pyi b/crates/mds-python/python/mdscript/_mdscript.pyi index bb60499..ac79197 100644 --- a/crates/mds-python/python/mdscript/_mdscript.pyi +++ b/crates/mds-python/python/mdscript/_mdscript.pyi @@ -90,6 +90,26 @@ class CompileResult: def __eq__(self, other: object, /) -> bool: ... __hash__: None # type: ignore[assignment] +@final +class LintResult: + """The result of :func:`lint`, :func:`lint_file`, or :func:`lint_virtual`. + + Canonical JSON shape: ``{"files": [...], "truncated": false, "version": 1}``. + Keys are in BTreeMap (alphabetical) order — byte-identical across all surfaces. + """ + + @property + def version(self) -> int: ... + @property + def truncated(self) -> bool: ... + @property + def files(self) -> list[dict[str, Any]]: ... + def __new__(cls, canonical: Mapping[str, Any]) -> LintResult: ... + def to_dict(self) -> dict[str, Any]: ... + def to_json(self) -> str: ... + def __eq__(self, other: object, /) -> bool: ... + __hash__: None # type: ignore[assignment] + class MdsError(Exception): """Raised for every MDS compilation failure. @@ -128,3 +148,23 @@ def check_virtual( vars: _Vars | None = ..., ) -> CheckResult: ... def scan_imports(source: str, /) -> list[str]: ... +def lint( + source: str, + *, + vars: _Vars | None = ..., + base_path: _StrPath | None = ..., + rules: Mapping[str, str] | None = ..., +) -> LintResult: ... +def lint_file( + path: _StrPath, + *, + vars: _Vars | None = ..., + rules: Mapping[str, str] | None = ..., +) -> LintResult: ... +def lint_virtual( + modules: Mapping[str, str], + entry: str, + *, + vars: _Vars | None = ..., + rules: Mapping[str, str] | None = ..., +) -> LintResult: ... diff --git a/crates/mds-python/src/lib.rs b/crates/mds-python/src/lib.rs index cba378d..db66c53 100644 --- a/crates/mds-python/src/lib.rs +++ b/crates/mds-python/src/lib.rs @@ -1,17 +1,19 @@ //! Native Python bindings for the MDS compiler via PyO3. //! -//! Exposes seven functions to Python as the native extension module +//! Exposes ten functions to Python as the native extension module //! `mdscript._mdscript` (re-exported by the pure-Python `mdscript` package): //! [`compile`], [`compile_file`], [`compile_virtual`], [`check`], [`check_file`], -//! [`check_virtual`], and [`scan_imports`]. +//! [`check_virtual`], [`scan_imports`], [`lint`], [`lint_file`], and +//! [`lint_virtual`]. //! //! ## Design mirror //! //! The four string/file functions mirror `crates/mds-napi` for error, panic, //! resource-limit, vars, and options handling; the three virtual/scan functions -//! mirror `crates/mds-wasm` (the virtual filesystem model). All compilation output -//! funnels through the single shared serializer [`mds::CompileResult::to_canonical_json`], -//! so the wire shape is byte-identical to the Node.js and WASM bindings by construction. +//! mirror `crates/mds-wasm` (the virtual filesystem model). Compile output funnels +//! through [`mds::CompileResult::to_canonical_json`] and lint output through +//! [`mds::LintResult::to_canonical_json`], so the wire shape is byte-identical to +//! the Node.js and WASM bindings by construction. //! //! ## Canonical result object //! @@ -362,6 +364,93 @@ impl CompileResult { } } +/// The result of [`lint`], [`lint_file`], or [`lint_virtual`]. +/// +/// Stores the canonical `to_canonical_json()` value as its single backing store; +/// typed getters and `to_dict()`/`to_json()` read from it so they can never diverge. +/// `__eq__` is wire equality. Byte-identical to the WASM and Node.js lint surfaces. +#[pyclass(frozen, eq, skip_from_py_object, module = "mdscript")] +#[derive(Clone, PartialEq)] +pub struct LintResult { + /// Single authoritative source of truth — the canonical lint JSON. + value: serde_json::Value, +} + +#[pymethods] +impl LintResult { + /// Reconstruct from a canonical mapping (used by unpickling). + #[new] + fn new(canonical: &Bound<'_, PyAny>) -> PyResult { + let value: serde_json::Value = depythonize(canonical).map_err(|e| { + options_error(canonical.py(), &format!("invalid LintResult state: {e}")) + })?; + Ok(LintResult { value }) + } + + /// Wire schema version — currently always `1`. + #[getter] + fn version(&self) -> u64 { + self.value + .get("version") + .and_then(serde_json::Value::as_u64) + .unwrap_or(1) + } + + /// Per-file finding groups as a list of dicts + /// (`[{"file": str, "diagnostics": [...]}, ...]`). + #[getter] + fn files<'py>(&self, py: Python<'py>) -> PyResult> { + let files = self + .value + .get("files") + .cloned() + .unwrap_or(serde_json::Value::Array(vec![])); + value_to_py(py, &files) + } + + /// `True` when the per-file diagnostic cap was hit; re-run after fixing. + #[getter] + fn truncated(&self) -> bool { + self.value + .get("truncated") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + } + + fn __repr__(&self) -> String { + let n = self + .value + .get("files") + .and_then(serde_json::Value::as_array) + .map_or(0, Vec::len); + format!( + "LintResult(version={}, files=<{n} file(s)>, truncated={})", + self.version(), + self.truncated(), + ) + } + + /// Return the canonical lint result as a plain Python `dict`. + /// + /// Byte-identical to the Node.js and WASM `lint()` surfaces. + fn to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + value_to_py(py, &self.value) + } + + /// Return the canonical lint result as a JSON string. + fn to_json(&self) -> String { + self.value.to_string() + } + + /// Reconstruct on unpickle via `LintResult(canonical_dict)`. + fn __reduce__<'py>( + &self, + py: Python<'py>, + ) -> PyResult<(Bound<'py, PyType>, (Bound<'py, PyAny>,))> { + Ok((py.get_type::(), (self.to_dict(py)?,))) + } +} + // ── Error / value conversion helpers ─────────────────────────────────────────── /// Convert an [`mds::MdsError`] into a raised [`MdsError`] with typed attributes. @@ -668,6 +757,55 @@ fn parse_modules(py: Python<'_>, modules: &Bound<'_, PyAny>) -> PyResult, rules: Option<&Bound<'_, PyAny>>) -> PyResult { + let Some(obj) = rules else { + return Ok(mds::LintConfig::default()); + }; + if obj.is_none() { + return Ok(mds::LintConfig::default()); + } + let json: serde_json::Value = + depythonize(obj).map_err(|e| options_error(py, &format!("invalid rules: {e}")))?; + let serde_json::Value::Object(map) = json else { + return Err(options_error( + py, + &format!( + "rules must be a mapping of str to str, got {}", + json_type_name(&json) + ), + )); + }; + let mut rules_map = HashMap::with_capacity(map.len()); + for (key, val) in map { + let serde_json::Value::String(s) = &val else { + return Err(options_error( + py, + &format!( + "rules[{key:?}] must be a string, got {}", + json_type_name(&val) + ), + )); + }; + let severity: mds::Severity = serde_json::from_value(serde_json::Value::String(s.clone())) + .map_err(|_| { + options_error( + py, + &format!( + "rules[{key:?}]: unknown severity {s:?}; \ + expected \"off\", \"info\", \"warn\", or \"error\"" + ), + ) + })?; + rules_map.insert(key, severity); + } + Ok(mds::LintConfig { rules: rules_map }) +} + // ── Public functions ──────────────────────────────────────────────────────────── /// Compile an MDS template source string. @@ -797,6 +935,78 @@ fn scan_imports(py: Python<'_>, source: String) -> PyResult> { run_catching(py, move || mds::scan_imports(&source)) } +/// Lint an MDS template source string for static analysis findings. +/// +/// Runs the check gate first — on a compile error, raises [`MdsError`] with the same +/// attributes as [`compile`]. On a clean gate, applies the lint rules and returns the +/// canonical [`LintResult`]. +/// +/// `vars`, `base_path`, and `rules` are all keyword-only. +#[pyfunction] +#[pyo3(signature = (source, *, vars=None, base_path=None, rules=None))] +fn lint( + py: Python<'_>, + source: String, + vars: Option>, + base_path: Option, + rules: Option>, +) -> PyResult { + check_source_size(py, &source)?; + check_base_path(py, &base_path)?; + let vars = extract_vars(py, vars.as_ref())?; + let lint_config = extract_rules(py, rules.as_ref())?; + let result = run_catching(py, move || { + mds::lint_str_with(&source, base_path.as_deref(), vars, &lint_config) + })?; + Ok(LintResult { + value: result.to_canonical_json(), + }) +} + +/// Lint an MDS template file (`path` is a str or `os.PathLike`). +/// +/// The base directory is derived from the file's own directory. `vars` and `rules` +/// are keyword-only. No `base_path` argument — mirrors [`compile_file`]. +#[pyfunction] +#[pyo3(signature = (path, *, vars=None, rules=None))] +fn lint_file( + py: Python<'_>, + path: PathBuf, + vars: Option>, + rules: Option>, +) -> PyResult { + let vars = extract_vars(py, vars.as_ref())?; + let lint_config = extract_rules(py, rules.as_ref())?; + let result = run_catching(py, move || mds::lint(&path, vars, &lint_config))?; + Ok(LintResult { + value: result.to_canonical_json(), + }) +} + +/// Lint a module from an in-memory virtual filesystem. +/// +/// `modules` maps module key → source string; `entry` is the key to lint. `vars` and +/// `rules` are keyword-only. Mirrors [`compile_virtual`] for the virtual-FS surface. +#[pyfunction] +#[pyo3(signature = (modules, entry, *, vars=None, rules=None))] +fn lint_virtual( + py: Python<'_>, + modules: Bound<'_, PyAny>, + entry: String, + vars: Option>, + rules: Option>, +) -> PyResult { + let modules = parse_modules(py, &modules)?; + let vars = extract_vars(py, vars.as_ref())?; + let lint_config = extract_rules(py, rules.as_ref())?; + let result = run_catching(py, move || { + mds::lint_virtual(modules, &entry, vars, &lint_config) + })?; + Ok(LintResult { + value: result.to_canonical_json(), + }) +} + // ── Module ────────────────────────────────────────────────────────────────────── /// The native extension module — registered as `mdscript._mdscript`. @@ -811,6 +1021,7 @@ fn _mdscript(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_function(wrap_pyfunction!(compile, m)?)?; m.add_function(wrap_pyfunction!(compile_file, m)?)?; m.add_function(wrap_pyfunction!(compile_virtual, m)?)?; @@ -818,5 +1029,8 @@ fn _mdscript(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(check_file, m)?)?; m.add_function(wrap_pyfunction!(check_virtual, m)?)?; m.add_function(wrap_pyfunction!(scan_imports, m)?)?; + m.add_function(wrap_pyfunction!(lint, m)?)?; + m.add_function(wrap_pyfunction!(lint_file, m)?)?; + m.add_function(wrap_pyfunction!(lint_virtual, m)?)?; Ok(()) } diff --git a/crates/mds-python/tests/fixtures/lint_warn_only.mds b/crates/mds-python/tests/fixtures/lint_warn_only.mds new file mode 100644 index 0000000..1f612c4 --- /dev/null +++ b/crates/mds-python/tests/fixtures/lint_warn_only.mds @@ -0,0 +1,6 @@ +--- +greeting: Hello +unused_key: this key is never referenced in the body +--- + +{greeting}, world! diff --git a/crates/mds-python/tests/test_contract.py b/crates/mds-python/tests/test_contract.py index c20b8bd..e5d2af8 100644 --- a/crates/mds-python/tests/test_contract.py +++ b/crates/mds-python/tests/test_contract.py @@ -10,6 +10,7 @@ MD = m.compile("Hello {name}!\n", vars={"name": "Alice"}) MSG = m.compile("@message user:\nHi\n@end\n") +LR = m.lint("---\nunused_key: value\n---\nHello!\n") # ── C2: CompileResult members + frozen ────────────────────────────────────────── @@ -151,6 +152,13 @@ def test_eq_is_wire_equality() -> None: def test_unhashable() -> None: - for obj in (MD, MSG, m.check("Hi\n"), m.Span(1, 2, 3, 4), m.Message("u", "c")): + for obj in (MD, MSG, LR, m.check("Hi\n"), m.Span(1, 2, 3, 4), m.Message("u", "c")): with pytest.raises(TypeError): hash(obj) + + +def test_lint_result_frozen() -> None: + with pytest.raises(AttributeError): + LR.version = 2 # type: ignore[misc] + with pytest.raises(AttributeError): + LR.truncated = True # type: ignore[misc] diff --git a/crates/mds-python/tests/test_lint.py b/crates/mds-python/tests/test_lint.py new file mode 100644 index 0000000..8d36117 --- /dev/null +++ b/crates/mds-python/tests/test_lint.py @@ -0,0 +1,208 @@ +"""Lint surface tests — lint, lint_file, lint_virtual (AC-API-05, AC-API-06).""" + +from __future__ import annotations + +import json +import pathlib + +import pytest + +import mdscript as m + +# ── Helpers ────────────────────────────────────────────────────────────────────── + +CLEAN_SOURCE = "Hello World!\n" +UNUSED_SOURCE = "---\nunused_key: value\n---\nHello!\n" + + +def _all_diags(result: m.LintResult) -> list[dict]: + """Flatten all per-file diagnostics from a LintResult.""" + return [d for f in result.to_dict()["files"] for d in f["diagnostics"]] + + +# ── lint (source string) ───────────────────────────────────────────────────────── + + +def test_l1_lint_clean_source_canonical_shape() -> None: + r = m.lint(CLEAN_SOURCE) + assert r.version == 1 + assert r.truncated is False + files = r.to_dict()["files"] + assert isinstance(files, list) + assert files == [], "clean source should produce no file findings" + + +def test_l2_lint_accepts_none_rules() -> None: + r = m.lint(CLEAN_SOURCE, rules=None) + assert r.version == 1 + + +def test_l3_lint_detects_unused_variable() -> None: + r = m.lint(UNUSED_SOURCE) + diags = _all_diags(r) + rules = [d["rule"] for d in diags] + assert "unused-variable" in rules, f"expected unused-variable; got: {rules}" + + +def test_l4_lint_rules_option_silences_rule() -> None: + r = m.lint(UNUSED_SOURCE, rules={"unused-variable": "off"}) + diags = _all_diags(r) + rules = [d["rule"] for d in diags] + assert "unused-variable" not in rules, f"unused-variable should be silenced; got: {rules}" + + +def test_l5_lint_rules_unknown_severity_raises() -> None: + with pytest.raises(m.MdsError) as ei: + m.lint(CLEAN_SOURCE, rules={"unused-variable": "verbose"}) + assert ei.value.code == "mds::invalid_options" + + +def test_l6_lint_rules_non_mapping_raises() -> None: + with pytest.raises(m.MdsError) as ei: + m.lint(CLEAN_SOURCE, rules=["off"]) # type: ignore[arg-type] + assert ei.value.code == "mds::invalid_options" + + +def test_l7_lint_invalid_source_raises() -> None: + """The check gate fires before lint — an unresolvable variable raises MdsError.""" + with pytest.raises(m.MdsError) as ei: + m.lint("Hello {undefined_var}!\n") + assert ei.value.code.startswith("mds::") + + +def test_l8_lint_returns_lint_result_instance() -> None: + r = m.lint(CLEAN_SOURCE) + assert isinstance(r, m.LintResult) + + +def test_l9_lint_result_to_json_roundtrip() -> None: + r = m.lint(UNUSED_SOURCE) + parsed = json.loads(r.to_json()) + assert parsed["version"] == 1 + assert isinstance(parsed["files"], list) + + +# ── lint_file ──────────────────────────────────────────────────────────────────── + + +def test_lf1_lint_file_clean(fixtures: pathlib.Path) -> None: + r = m.lint_file(fixtures / "simple.mds") + assert r.version == 1 + assert r.truncated is False + + +def test_lf2_lint_file_detects_findings(fixtures: pathlib.Path) -> None: + r = m.lint_file(fixtures / "lint_warn_only.mds") + diags = _all_diags(r) + rules = [d["rule"] for d in diags] + assert "unused-variable" in rules, f"expected unused-variable; got: {rules}" + + +def test_lf3_lint_file_rules_silences(fixtures: pathlib.Path) -> None: + r = m.lint_file(fixtures / "lint_warn_only.mds", rules={"unused-variable": "off"}) + diags = _all_diags(r) + rules = [d["rule"] for d in diags] + assert "unused-variable" not in rules + + +def test_lf4_lint_file_str_path(fixtures: pathlib.Path) -> None: + r = m.lint_file(str(fixtures / "simple.mds")) + assert r.version == 1 + + +def test_lf5_lint_file_not_found_raises() -> None: + with pytest.raises(m.MdsError) as ei: + m.lint_file("/nonexistent/path/no_such_file.mds") + assert ei.value.code == "mds::file_not_found" + + +# ── lint_virtual ───────────────────────────────────────────────────────────────── + + +def test_lv1_lint_virtual_clean() -> None: + modules = {"main.mds": CLEAN_SOURCE} + r = m.lint_virtual(modules, "main.mds") + assert r.version == 1 + assert r.truncated is False + + +def test_lv2_lint_virtual_detects_findings() -> None: + modules = {"main.mds": UNUSED_SOURCE} + r = m.lint_virtual(modules, "main.mds") + diags = _all_diags(r) + rules = [d["rule"] for d in diags] + assert "unused-variable" in rules, f"expected unused-variable; got: {rules}" + + +def test_lv3_lint_virtual_rules_silences() -> None: + modules = {"main.mds": UNUSED_SOURCE} + r = m.lint_virtual(modules, "main.mds", rules={"unused-variable": "off"}) + diags = _all_diags(r) + rules = [d["rule"] for d in diags] + assert "unused-variable" not in rules + + +def test_lv4_lint_virtual_non_mapping_modules_raises() -> None: + with pytest.raises(m.MdsError) as ei: + m.lint_virtual(["main.mds"], "main.mds") # type: ignore[arg-type] + assert ei.value.code == "mds::invalid_options" + + +# ── Parity: lint and lint_file produce identical canonical JSON ────────────────── +# AC-API-06: cross-surface JSON byte-identity + + +def test_p_l1_lint_and_lint_file_canonical_json_identical(fixtures: pathlib.Path) -> None: + """lint(source) and lint_file(path) produce identical JSON for clean sources. + + For sources with no findings, the JSON is ``{"files":[],"truncated":false,"version":1}`` + on all surfaces — byte-identical because the empty ``files`` array carries no filename + keys (the file key only appears when there are diagnostics, and differs between surfaces: + lint() uses ``"input.mds"`` while lint_file() uses the basename). + """ + path = fixtures / "simple.mds" + file_result = m.lint_file(path) + source = path.read_text(encoding="utf-8") + str_result = m.lint(source, base_path=fixtures) + # Both must report no findings. + assert str_result.to_json() == file_result.to_json(), ( + "lint and lint_file must produce identical canonical JSON for clean source\n" + f" lint: {str_result.to_json()}\n" + f" lint_file: {file_result.to_json()}" + ) + + +# ── LintResult class contract ──────────────────────────────────────────────────── + + +def test_lr1_lint_result_files_getter_returns_list() -> None: + r = m.lint(UNUSED_SOURCE) + files = r.files + assert isinstance(files, list) + assert len(files) >= 1, "at least one file entry expected for source with findings" + + +def test_lr2_lint_result_equality() -> None: + r1 = m.lint(CLEAN_SOURCE) + r2 = m.lint(CLEAN_SOURCE) + assert r1 == r2 + + +def test_lr3_lint_result_repr() -> None: + r = m.lint(CLEAN_SOURCE) + rep = repr(r) + assert "LintResult" in rep + assert "version=" in rep + + +def test_lr4_lint_result_diagnostic_shape() -> None: + """Each diagnostic in a LintResult has the required fields.""" + r = m.lint(UNUSED_SOURCE) + diags = _all_diags(r) + assert diags, "expected at least one diagnostic" + d = diags[0] + assert "rule" in d + assert "severity" in d + assert "message" in d + assert "help" in d + assert "fixable" in d diff --git a/crates/mds-python/tests/test_parity.py b/crates/mds-python/tests/test_parity.py index c9fbfa8..a3aa4f5 100644 --- a/crates/mds-python/tests/test_parity.py +++ b/crates/mds-python/tests/test_parity.py @@ -11,6 +11,7 @@ import json import pathlib +import subprocess import pytest @@ -131,3 +132,83 @@ def test_par3_error_code_parity_with_napi(code: str, thunk) -> None: # type: ig with pytest.raises(m.MdsError) as ei: thunk() assert ei.value.code == code + + +# ── PAR4 / PAR5: lint canonical-JSON byte-parity (AC-API-06) ───────────────────── +# +# LINT_GOLDENS are captured from the Rust core serializer and checked in. +# They are NEVER regenerated from the Python binding, so the comparison is +# non-circular: a drift in the serializer would fail the parity test. +# +# All surfaces (Python, CLI, napi, WASM) emit keys in BTreeMap alphabetical order: +# {"files":[...],"truncated":false,"version":1} +# +# lint_virtual() is used here to control the entry key ("main.mds") so the golden +# is byte-identical regardless of which surface produced it. lint() and lint_file() +# differ in their file key ("input.mds" vs basename) when findings are present. + +LINT_GOLDENS: list[tuple[str, str, dict[str, str], str]] = [ + ( + "clean", + "Hello World!\n", + {}, + '{"files":[],"truncated":false,"version":1}', + ), + ( + "unused_variable", + "---\nunused_key: value\n---\nHello!\n", + {}, + ( + '{"files":[{"diagnostics":[{"fixable":false,"help":"Remove the frontmatter' + ' key or reference it in the template body.","message":"Variable' + " 'unused_key' is defined in frontmatter but never referenced in the" + ' body.","rule":"unused-variable","severity":"warn","span":{"length":10,' + '"offset":4}}],"file":"main.mds"}],"truncated":false,"version":1}' + ), + ), + ( + "silenced", + "---\nunused_key: value\n---\nHello!\n", + {"unused-variable": "off"}, + '{"files":[],"truncated":false,"version":1}', + ), +] + + +@pytest.mark.parametrize("name,src,rules,golden", LINT_GOLDENS, ids=[g[0] for g in LINT_GOLDENS]) +def test_par4_lint_virtual_matches_golden( + name: str, src: str, rules: dict[str, str], golden: str +) -> None: + """Python lint_virtual() must produce byte-identical JSON to the checked-in golden.""" + result = m.lint_virtual({"main.mds": src}, "main.mds", rules=rules or None) + assert result.to_json() == golden, ( + f"lint_virtual golden mismatch for '{name}':\n" + f" got: {result.to_json()}\n" + f" expect: {golden}" + ) + + +def test_par5_live_cli_lint_json_parity(mds_cli: pathlib.Path, tmp_path: pathlib.Path) -> None: + """CLI `mds lint --format json` must emit byte-identical JSON to Python lint_virtual. + + Uses a clean source (no findings) so the JSON is ``{"files":[],...}`` — byte-identical + across surfaces without depending on the entry-key convention (basename vs "input.mds"). + """ + src = "Hello World!\n" + mds_file = tmp_path / "main.mds" + mds_file.write_text(src, encoding="utf-8") + out = subprocess.run( + [str(mds_cli), "lint", "--format", "json", str(mds_file)], + capture_output=True, + text=True, + encoding="utf-8", + ) + # CLI exits 0 for a clean file (no findings at warning/error severity). + assert out.returncode == 0, f"CLI lint exited {out.returncode}: {out.stderr}" + cli_json = out.stdout.strip() + py_json = m.lint_virtual({"main.mds": src}, "main.mds").to_json() + assert cli_json == py_json, ( + "CLI lint --format json must match Python lint_virtual byte-for-byte:\n" + f" CLI: {cli_json}\n" + f" py: {py_json}" + ) diff --git a/crates/mds-python/tests/test_typing.py b/crates/mds-python/tests/test_typing.py index 1d3e56e..3631390 100644 --- a/crates/mds-python/tests/test_typing.py +++ b/crates/mds-python/tests/test_typing.py @@ -10,6 +10,9 @@ import pytest SAMPLE = Path(__file__).parent / "typecheck_sample.py" +# Pyright project root: the mds-python package directory (contains pyrightconfig.json +# with extraPaths pointing to ./python so "import mdscript" resolves). +_PYRIGHT_PROJECT = Path(__file__).parent.parent # Known, structurally-justified stub/runtime diffs `stubtest` cannot resolve # statically — not stub bugs. Substring-matched against each `error:` line so a @@ -51,7 +54,7 @@ def test_c6_mypy_strict_clean() -> None: ) def test_c6_pyright_clean() -> None: proc = subprocess.run( - [sys.executable, "-m", "pyright", str(SAMPLE)], + [sys.executable, "-m", "pyright", "--project", str(_PYRIGHT_PROJECT), str(SAMPLE)], capture_output=True, text=True, ) diff --git a/crates/mds-python/tests/typecheck_sample.py b/crates/mds-python/tests/typecheck_sample.py index 5512643..d9559f6 100644 --- a/crates/mds-python/tests/typecheck_sample.py +++ b/crates/mds-python/tests/typecheck_sample.py @@ -1,15 +1,17 @@ """Type-checking sample — must pass `mypy --strict` and `pyright` cleanly (AC-C6). Exercises the public API with correct types, including `Optional` narrowing on the -discriminated `CompileResult`. +discriminated `CompileResult` and explicit coverage of the lint surface (AC-C6 per +review #171: lint / lint_file / lint_virtual and LintResult attributes). """ from __future__ import annotations import pathlib +from typing import Any import mdscript -from mdscript import CheckResult, CompileResult, MdsError, Message, Span +from mdscript import CheckResult, CompileResult, LintResult, MdsError, Message, Span def render_markdown() -> str: @@ -63,3 +65,58 @@ def describe_error() -> str: def package_version() -> str: return mdscript.__version__ + + +# --------------------------------------------------------------------------- +# Lint surface (AC-C6 coverage — review #171) +# All three lint entry-points and every LintResult attribute are exercised with +# explicitly-annotated locals so a stub type error surfaces under strict checkers. +# --------------------------------------------------------------------------- + + +def lint_source(source: str) -> int: + """Lint inline source; return the number of files in the result.""" + result: LintResult = mdscript.lint(source) + version: int = result.version + truncated: bool = result.truncated + files: list[dict[str, Any]] = result.files + _ = (version, truncated) + return len(files) + + +def lint_source_with_options(source: str) -> str: + """Lint source with all optional keyword arguments; return canonical JSON.""" + rules: dict[str, str] = {"shadow-variable": "warn", "unused-variable": "off"} + result: LintResult = mdscript.lint( + source, + base_path="/tmp", + vars={"env": "ci"}, + rules=rules, + ) + d: dict[str, Any] = result.to_dict() + _ = d + return result.to_json() + + +def lint_from_file(path: pathlib.Path) -> LintResult: + """Lint a .mds file on disk and return the result.""" + return mdscript.lint_file( + path, + vars={"count": 1}, + rules={"unused-variable": "off"}, + ) + + +def lint_virtual_graph() -> bool: + """Lint a virtual module graph; return whether the result was truncated.""" + modules: dict[str, str] = {"entry.mds": "Hello!\n"} + result: LintResult = mdscript.lint_virtual( + modules, + "entry.mds", + vars={"name": "world"}, + rules={"shadow-variable": "warn"}, + ) + files: list[dict[str, Any]] = result.files + j: str = result.to_json() + _ = (files, j) + return result.truncated diff --git a/crates/mds-wasm/src/lib.rs b/crates/mds-wasm/src/lib.rs index 9a76c81..024a42d 100644 --- a/crates/mds-wasm/src/lib.rs +++ b/crates/mds-wasm/src/lib.rs @@ -1,6 +1,6 @@ //! WebAssembly bindings for the MDS compiler. //! -//! Exposes [`compile`] and [`check`] to JavaScript via `wasm-bindgen`. +//! Exposes [`compile`], [`check`], and [`lint`] to JavaScript via `wasm-bindgen`. //! All compilation runs against an in-memory virtual filesystem — no //! OS file access occurs inside the WASM boundary. //! @@ -274,19 +274,25 @@ fn extract_modules(obj: &js_sys::Object) -> Result, JsVa json_type_name(&modules_json) ))); }; - parse_modules_from_map(mods_map) + parse_modules_from_map(mods_map, "options.modules") } /// Parse a modules map (after deserialization) into HashMap. /// -/// Extracted from the original `parse_modules` to allow reuse by `extract_modules`. +/// `field_label` names the field in error messages (e.g. `"options.modules"` for the +/// compile/check path where modules are nested under `options`, or `"modules"` for +/// `lintVirtual` where the map is a top-level positional argument). +/// +/// Extracted from the original `parse_modules` to allow reuse by `extract_modules` and +/// `lint_virtual`. fn parse_modules_from_map( mods: serde_json::Map, + field_label: &str, ) -> Result, JsValue> { if mods.len() > MAX_MODULE_COUNT { return Err(js_error( &format!( - "options.modules exceeds maximum module count of {} ({} provided)", + "{field_label} exceeds maximum module count of {} ({} provided)", MAX_MODULE_COUNT, mods.len() ), @@ -303,7 +309,7 @@ fn parse_modules_from_map( if s.len() > MAX_SOURCE_SIZE { return Err(js_error( &format!( - "options.modules[\"{key}\"] exceeds maximum size of {} bytes ({} bytes provided)", + "{field_label}[\"{key}\"] exceeds maximum size of {} bytes ({} bytes provided)", MAX_SOURCE_SIZE, s.len() ), @@ -314,7 +320,7 @@ fn parse_modules_from_map( if aggregate_size > MAX_MODULES_AGGREGATE_SIZE { return Err(js_error( &format!( - "options.modules aggregate size exceeds maximum of {} bytes", + "{field_label} aggregate size exceeds maximum of {} bytes", MAX_MODULES_AGGREGATE_SIZE ), "mds::resource_limit", @@ -324,7 +330,7 @@ fn parse_modules_from_map( } other => { return Err(options_error(&format!( - "options.modules[\"{key}\"] must be a string, got {}", + "{field_label}[\"{key}\"] must be a string, got {}", json_type_name(&other) ))); } @@ -384,6 +390,132 @@ fn parse_options(options: JsValue) -> Result { }) } +// ── Lint options ────────────────────────────────────────────────────────────── + +/// Parsed options for the `lint` and `lint_virtual` functions. +/// +/// Extends the standard options with a `rules` field — absent in `compile`/`check`. +struct ParsedLintOptions { + /// Standard options (filename, extra_modules, vars). + opts: ParsedOptions, + /// Per-rule severity overrides parsed from `options.rules`. + lint_config: mds::LintConfig, +} + +/// Extract and validate the `rules` field from a lint options object. +/// +/// `options.rules` is an optional `Record` where keys are rule +/// names and values are severity strings (`"off"` | `"info"` | `"warn"` | `"error"`). +/// An absent or null/undefined `rules` key returns the default config (all rules at +/// built-in defaults). An unknown severity VALUE is a hard error (closed enum). +fn extract_rules(obj: &js_sys::Object) -> Result { + let val = get_prop_js(obj, "rules"); + if val.is_undefined() || val.is_null() { + return Ok(mds::LintConfig::default()); + } + // Deserialize the rules sub-object via serde_wasm_bindgen. + let rules_json: serde_json::Value = serde_wasm_bindgen::from_value(val) + .map_err(|e| options_error(&format!("invalid options.rules: {e}")))?; + let serde_json::Value::Object(rules_map) = rules_json else { + return Err(options_error(&format!( + "options.rules must be a plain object, got {}", + json_type_name(&rules_json) + ))); + }; + + let mut rules = std::collections::HashMap::new(); + for (key, val) in rules_map { + let serde_json::Value::String(s) = &val else { + return Err(options_error(&format!( + "options.rules[\"{key}\"] must be a severity string, got {}", + json_type_name(&val) + ))); + }; + // Parse the severity via serde (validates against the closed enum). + let severity: mds::Severity = serde_json::from_str(&format!("\"{s}\"")).map_err(|_| { + options_error(&format!( + "options.rules[\"{key}\"]: unknown severity \"{s}\"; \ + valid values are \"off\", \"info\", \"warn\", \"error\"" + )) + })?; + rules.insert(key, severity); + } + Ok(mds::LintConfig { rules }) +} + +/// Parse the JS options for `lint` and `lint_virtual`. +/// +/// Same as `parse_options` but also accepts a `rules` key: +/// - `filename`, `modules`, `vars`: inherited from the standard options. +/// - `rules`: `Record` of per-rule severity overrides. +fn parse_lint_options(options: JsValue) -> Result { + // null / undefined → all defaults. + if options.is_null() || options.is_undefined() { + return Ok(ParsedLintOptions { + opts: ParsedOptions::default(), + lint_config: mds::LintConfig::default(), + }); + } + + // Reject non-objects (including arrays). + if !options.is_object() || js_sys::Array::is_array(&options) { + return Err(options_error("options must be a plain object")); + } + + // SAFETY: we verified options.is_object() above. + let obj: js_sys::Object = options.unchecked_into(); + + reject_unknown_wasm_keys(&obj, &["filename", "modules", "vars", "rules"])?; + + let filename = extract_filename(&obj)?; + let extra_modules = extract_modules(&obj)?; + let vars = extract_vars(&obj)?; + let lint_config = extract_rules(&obj)?; + + Ok(ParsedLintOptions { + opts: ParsedOptions { + filename, + extra_modules, + vars, + }, + lint_config, + }) +} + +/// Parse the JS options for `lint_virtual` (no `filename` or `modules` — those are +/// explicit parameters). +/// +/// Valid keys: `vars`, `rules`. +fn parse_lint_virtual_options(options: JsValue) -> Result { + if options.is_null() || options.is_undefined() { + return Ok(ParsedLintOptions { + opts: ParsedOptions::default(), + lint_config: mds::LintConfig::default(), + }); + } + + if !options.is_object() || js_sys::Array::is_array(&options) { + return Err(options_error("options must be a plain object")); + } + + // SAFETY: we verified options.is_object() above. + let obj: js_sys::Object = options.unchecked_into(); + + reject_unknown_wasm_keys(&obj, &["vars", "rules"])?; + + let vars = extract_vars(&obj)?; + let lint_config = extract_rules(&obj)?; + + Ok(ParsedLintOptions { + opts: ParsedOptions { + filename: DEFAULT_FILENAME.to_string(), + extra_modules: HashMap::new(), + vars, + }, + lint_config, + }) +} + /// Build the virtual filesystem module map. /// /// Inserts `source` under `filename`, then merges `extra_modules`. Returns @@ -538,6 +670,126 @@ pub fn check(source: &str, options: JsValue) -> Result { })) } +/// Lint an MDS template source string for static analysis findings. +/// +/// Runs the check gate (resolve+validate) first — on a compile error, throws a +/// JS `Error` with the same structure as [`check`]. On a clean gate, applies +/// the lint rules and returns the canonical lint result object. +/// +/// ## Arguments +/// +/// - `source`: MDS template source text. +/// - `options`: optional configuration object: +/// - `filename` (string, default `"input.mds"`): the entry module key. +/// - `modules` (`Record`): additional virtual modules for import resolution. +/// - `vars` (`Record`): runtime variable overrides. +/// - `rules` (`Record`): per-rule severity overrides (e.g. +/// `{ "shadow-variable": "warn", "unused-variable": "off" }`). +/// +/// ## Returns +/// +/// On success, the canonical lint JSON object: +/// `{ version: 1, files: [{file, diagnostics: [{rule, severity, message, help, fixable, span?},...]},...], truncated: bool }` +/// +/// On failure (parse or validation error), throws a JS `Error` with the same +/// structure as [`compile`]. +/// +/// ## Example (JavaScript) +/// +/// ```js +/// const result = lint('Hello!\n'); +/// console.log(result.version); // 1 +/// console.log(result.files); // [] +/// console.log(result.truncated); // false +/// +/// // With rules overrides: +/// const result2 = lint(source, { rules: { 'shadow-variable': 'warn' } }); +/// ``` +#[wasm_bindgen] +pub fn lint(source: &str, options: JsValue) -> Result { + check_source_size(source)?; + + // Owned String required so the closure satisfies UnwindSafe. + let source = source.to_string(); + + catch_panic(AssertUnwindSafe(move || { + let lint_opts = parse_lint_options(options)?; + let modules = build_modules( + source, + &lint_opts.opts.filename, + lint_opts.opts.extra_modules, + )?; + let result = mds::lint_virtual( + modules, + &lint_opts.opts.filename, + lint_opts.opts.vars, + &lint_opts.lint_config, + ) + .map_err(mds_error_to_js)?; + + to_js(&result.to_canonical_json()) + })) +} + +/// Lint a multi-module virtual filesystem for static analysis findings. +/// +/// Unlike [`lint`], the caller provides the full module map and entry key +/// explicitly — no source injection and no `filename`/`modules` option keys. +/// This mirrors the `compile_virtual` pattern in other binding layers. +/// +/// ## Arguments +/// +/// - `modules`: JS object mapping module key → source string (e.g. +/// `{ "main.mds": "...", "_partial.mds": "..." }`). +/// - `entry`: the key within `modules` to use as the lint entry point. +/// - `options`: optional configuration object: +/// - `vars` (`Record`): runtime variable overrides. +/// - `rules` (`Record`): per-rule severity overrides. +/// +/// ## Returns +/// +/// Same shape as [`lint`]. +/// +/// ## Example (JavaScript) +/// +/// ```js +/// const result = lintVirtual( +/// { 'main.mds': '@import { greet } from "./helper.mds"\n{greet("World")}\n', +/// 'helper.mds': '@define greet(name): Hello {name}!\n@end\n' }, +/// 'main.mds', +/// ); +/// console.log(result.files.length); // number of files with findings +/// ``` +#[wasm_bindgen(js_name = "lintVirtual")] +pub fn lint_virtual(modules: JsValue, entry: &str, options: JsValue) -> Result { + // Deserialize the modules map. + let modules_json: serde_json::Value = serde_wasm_bindgen::from_value(modules) + .map_err(|e| options_error(&format!("invalid modules: {e}")))?; + let serde_json::Value::Object(mods_map) = modules_json else { + return Err(options_error(&format!( + "modules must be a plain object, got {}", + json_type_name(&modules_json) + ))); + }; + + let entry_owned = entry.to_string(); + + catch_panic(AssertUnwindSafe(move || { + let mods = parse_modules_from_map(mods_map, "modules")?; + let lint_opts = parse_lint_virtual_options(options)?; + + let result = mds::lint_virtual( + mods, + &entry_owned, + lint_opts.opts.vars, + &lint_opts.lint_config, + ) + .map_err(mds_error_to_js)?; + + to_js(&result.to_canonical_json()) + })) +} + /// Extract all import and re-export paths from an MDS source string. /// /// Returns an array of path strings (`string[]`) in insertion order, deduplicated. diff --git a/packages/mds/__test__/backend-contract.spec.mjs b/packages/mds/__test__/backend-contract.spec.mjs index 50de4f1..7e8e250 100644 --- a/packages/mds/__test__/backend-contract.spec.mjs +++ b/packages/mds/__test__/backend-contract.spec.mjs @@ -22,22 +22,22 @@ import { createNativeBackend } from '../dist/backend/native.js'; // --------------------------------------------------------------------------- describe('backend contract — method manifest', () => { - test('U-BC1: BASE_METHODS contains compile, check (no compileMessages)', () => { + test('U-BC1: BASE_METHODS contains compile, check, lint', () => { assert.deepEqual( [...BASE_METHODS].sort(), - ['check', 'compile'], + ['check', 'compile', 'lint'], ); }); - test('U-BC2: NODE_METHODS contains compileFile, checkFile (no compileMessagesFile)', () => { + test('U-BC2: NODE_METHODS contains compileFile, checkFile, lintFile', () => { assert.deepEqual( [...NODE_METHODS].sort(), - ['checkFile', 'compileFile'], + ['checkFile', 'compileFile', 'lintFile'], ); }); - test('U-BC3: WASM_EXPORTS contains BASE_METHODS plus scanImports', () => { - const expected = [...BASE_METHODS, 'scanImports'].sort(); + test('U-BC3: WASM_EXPORTS contains BASE_METHODS plus scanImports and lintVirtual', () => { + const expected = [...BASE_METHODS, 'scanImports', 'lintVirtual'].sort(); assert.deepEqual([...WASM_EXPORTS].sort(), expected); }); @@ -58,19 +58,21 @@ describe('backend contract — validateBackendMethods', () => { const stub = { compile: () => {}, check: () => {}, + lint: () => {}, }; assert.doesNotThrow(() => validateBackendMethods(stub, BASE_METHODS, 'test stub')); }); test('U-BC5: validateBackendMethods throws when a method is missing', () => { - const stub = { compile: () => {} }; // missing check + const stub = { compile: () => {} }; // missing check and lint assert.throws( () => validateBackendMethods(stub, BASE_METHODS, 'test stub'), (err) => { assert.ok(err instanceof Error); + // First missing method in iteration order; must name the missing method. assert.ok( - err.message.includes('check'), - `error must name the missing method, got: ${err.message}`, + err.message.includes('check') || err.message.includes('lint'), + `error must name a missing method, got: ${err.message}`, ); assert.ok( err.message.includes('test stub'), @@ -85,6 +87,7 @@ describe('backend contract — validateBackendMethods', () => { const stub = { compile: () => {}, check: () => {}, + lint: () => {}, extraMethod: () => {}, }; assert.doesNotThrow(() => validateBackendMethods(stub, BASE_METHODS, 'test stub')); @@ -93,7 +96,7 @@ describe('backend contract — validateBackendMethods', () => { test('U-BC7: validateBackendMethods throws when a property exists but is not a function', () => { const stub = { compile: () => {}, - check: 42, // wrong type + check: 42, // wrong type — missing lint too, but check is found first }; assert.throws( () => validateBackendMethods(stub, BASE_METHODS, 'test stub'), @@ -281,6 +284,68 @@ describe('backend contract — assertResultShape check', () => { }); }); +// --------------------------------------------------------------------------- +// assertResultShape — lint +// --------------------------------------------------------------------------- + +describe('backend contract — assertResultShape lint', () => { + test('U-BC9c: lint — valid result passes', () => { + assert.doesNotThrow(() => + assertResultShape({ version: 1, files: [], truncated: false }, 'lint'), + ); + }); + + test('U-BC9d: lint — valid result with file entries and extra fields passes', () => { + assert.doesNotThrow(() => + assertResultShape( + { + version: 1, + files: [{ file: 'foo.mds', diagnostics: [] }], + truncated: false, + extra: 'ignored', + }, + 'lint', + ), + ); + }); + + test('U-BC9e: lint — missing version is rejected', () => { + assert.throws( + () => assertResultShape({ files: [], truncated: false }, 'lint'), + (err) => { + assert.ok(err instanceof Error); + assert.ok(err.message.includes('version'), `expected "version" in error, got: ${err.message}`); + assert.equal(/** @type {any} */ (err).code, 'mds::invalid_backend_result'); + return true; + }, + ); + }); + + test('U-BC9f: lint — non-array files is rejected', () => { + assert.throws( + () => assertResultShape({ version: 1, files: 'bad', truncated: false }, 'lint'), + (err) => { + assert.ok(err instanceof Error); + assert.ok(err.message.includes('files'), `expected "files" in error, got: ${err.message}`); + assert.equal(/** @type {any} */ (err).code, 'mds::invalid_backend_result'); + return true; + }, + ); + }); + + test('U-BC9g: lint — missing truncated is rejected', () => { + assert.throws( + () => assertResultShape({ version: 1, files: [] }, 'lint'), + (err) => { + assert.ok(err instanceof Error); + assert.ok(err.message.includes('truncated'), `expected "truncated" in error, got: ${err.message}`); + assert.equal(/** @type {any} */ (err).code, 'mds::invalid_backend_result'); + return true; + }, + ); + }); +}); + // --------------------------------------------------------------------------- // Performance: O(1) validation — no per-element array traversal (AC-PERF-04) // --------------------------------------------------------------------------- @@ -364,16 +429,19 @@ describe('backend contract — parity: WASM backend exposes BASE_METHODS (AC-API // Stubs return valid discriminated-union shapes. const validMarkdownResult = { kind: 'markdown', output: '', warnings: [], dependencies: [] }; const validCheckResult = { warnings: [] }; + const validLintResult = { version: 1, files: [], truncated: false }; const stubWasmModule = { compile: () => validMarkdownResult, check: () => validCheckResult, + lint: () => validLintResult, + lintVirtual: () => validLintResult, scanImports: () => [], }; const wasmBackend = createWasmBackend(stubWasmModule); - test('U-BC13: WASM backend exposes every BASE_METHODS name as a function', () => { + test('U-BC13: WASM backend exposes every BASE_METHODS name as a function (includes lint)', () => { for (const method of BASE_METHODS) { assert.equal( typeof (/** @type {any} */ (wasmBackend))[method], @@ -411,12 +479,16 @@ describe('backend contract — parity: WASM backend exposes BASE_METHODS (AC-API describe('backend contract — parity: native backend exposes BASE_METHODS + NODE_METHODS (AC-API-09)', () => { const validMarkdownResult = { kind: 'markdown', output: '', warnings: [], dependencies: [] }; const validCheckResult = { warnings: [] }; + const validLintResult = { version: 1, files: [], truncated: false }; const stubAddon = { compile: () => validMarkdownResult, check: () => validCheckResult, compileFile: () => validMarkdownResult, checkFile: () => validCheckResult, + lint: () => validLintResult, + lintFile: () => validLintResult, + lintVirtual: () => validLintResult, }; const nativeBackend = createNativeBackend(stubAddon); diff --git a/packages/mds/__test__/fixtures/lint_warn.mds b/packages/mds/__test__/fixtures/lint_warn.mds new file mode 100644 index 0000000..1f612c4 --- /dev/null +++ b/packages/mds/__test__/fixtures/lint_warn.mds @@ -0,0 +1,6 @@ +--- +greeting: Hello +unused_key: this key is never referenced in the body +--- + +{greeting}, world! diff --git a/packages/mds/__test__/lint.spec.mjs b/packages/mds/__test__/lint.spec.mjs new file mode 100644 index 0000000..ce7c137 --- /dev/null +++ b/packages/mds/__test__/lint.spec.mjs @@ -0,0 +1,255 @@ +/** + * lint(), lintFile(), lintVirtual() tests for @mdscript/mds universal package. + * Tests: U-L1 through U-LV5 + * + * All tests run against whichever backend init() selects (native if available, + * WASM fallback). Shape assertions use assertResultShape for the canonical lint + * result contract. Byte-identical cross-surface parity is covered in the + * parity goldens (Step 12 — crates/mds-python/tests/test_parity.py and + * crates/mds-napi/__test__/index.spec.mjs). + */ +import { test, describe, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { SIMPLE_MDS, FIXTURES } from './helpers.mjs'; +import path from 'node:path'; +import { lint, lintFile, lintVirtual, isMdsError, init } from '../dist/node.js'; +import { assertResultShape } from '../dist/backend/contract.js'; + +// Fixture with unused frontmatter variable (triggers unused-variable finding). +const LINT_WARN_MDS = path.join(FIXTURES, 'lint_warn.mds'); + +// Source strings for virtual lint tests. +const CLEAN_SOURCE = 'Hello World!\n'; +const UNUSED_SOURCE = '---\nunused_key: value\n---\nHello!\n'; + +// --------------------------------------------------------------------------- +// lint() — source string +// --------------------------------------------------------------------------- + +describe('lint', () => { + before(() => init()); + + test('U-L1: lint clean source returns version:1, empty files, truncated:false', () => { + const result = lint(CLEAN_SOURCE); + assert.equal(result.version, 1, 'version must be 1'); + assert.ok(Array.isArray(result.files), 'files must be an array'); + assert.equal(result.files.length, 0, 'clean source should have no file entries'); + assert.equal(result.truncated, false, 'truncated must be false'); + }); + + test('U-L2: lint result passes assertResultShape', () => { + const result = lint(CLEAN_SOURCE); + assert.doesNotThrow(() => assertResultShape(result, 'lint')); + }); + + test('U-L3: lint detects unused frontmatter variable', () => { + const result = lint(UNUSED_SOURCE); + assert.equal(result.version, 1); + const allDiags = result.files.flatMap((f) => f.diagnostics); + const hasUnused = allDiags.some((d) => d.rule === 'unused-variable'); + assert.ok(hasUnused, `expected unused-variable finding; got: ${JSON.stringify(allDiags)}`); + }); + + test('U-L4: lint rules option silences a rule', () => { + const result = lint(UNUSED_SOURCE, { rules: { 'unused-variable': 'off' } }); + const allDiags = result.files.flatMap((f) => f.diagnostics); + const hasUnused = allDiags.some((d) => d.rule === 'unused-variable'); + assert.ok(!hasUnused, `unused-variable should be silenced; got: ${JSON.stringify(allDiags)}`); + }); + + test('U-L5: lint accepts null options', () => { + const result = lint(CLEAN_SOURCE, undefined); + assert.equal(result.version, 1); + }); + + test('U-L6: lint invalid source throws MdsError', () => { + assert.throws( + () => lint('Hello {undefined_var}!\n'), + (err) => { + assert.ok(isMdsError(err), `expected MdsError, got: ${err}`); + assert.ok(err.code.startsWith('mds::'), `expected mds:: code, got: ${err.code}`); + return true; + }, + ); + }); + + test('U-L7: lint unknown severity in rules throws MdsError', () => { + assert.throws( + () => lint(CLEAN_SOURCE, { rules: { 'unused-variable': 'verbose' } }), + (err) => { + assert.ok(isMdsError(err), `expected MdsError, got: ${err}`); + assert.equal(err.code, 'mds::invalid_options', `got: ${err.code}`); + return true; + }, + ); + }); + + test('U-L8: lint diagnostic has required fields', () => { + const result = lint(UNUSED_SOURCE); + const allDiags = result.files.flatMap((f) => f.diagnostics); + assert.ok(allDiags.length > 0, 'expected at least one diagnostic'); + const d = allDiags[0]; + assert.ok(typeof d.rule === 'string', 'diagnostic.rule must be a string'); + assert.ok(typeof d.severity === 'string', 'diagnostic.severity must be a string'); + assert.ok(typeof d.message === 'string', 'diagnostic.message must be a string'); + assert.ok(typeof d.fixable === 'boolean', 'diagnostic.fixable must be a boolean'); + }); +}); + +// --------------------------------------------------------------------------- +// lintFile() — file path +// --------------------------------------------------------------------------- + +describe('lintFile', () => { + before(() => init()); + + test('U-LF1: lintFile clean file returns valid shape', async () => { + const result = await lintFile(SIMPLE_MDS); + assert.equal(result.version, 1); + assert.ok(Array.isArray(result.files)); + assert.equal(result.truncated, false); + assertResultShape(result, 'lint'); + }); + + test('U-LF2: lintFile with findings returns file entries', async () => { + const result = await lintFile(LINT_WARN_MDS); + assert.equal(result.version, 1); + const allDiags = result.files.flatMap((f) => f.diagnostics); + const hasUnused = allDiags.some((d) => d.rule === 'unused-variable'); + assert.ok(hasUnused, `expected unused-variable in ${LINT_WARN_MDS}; got: ${JSON.stringify(allDiags)}`); + }); + + test('U-LF3: lintFile rules option silences rule', async () => { + const result = await lintFile(LINT_WARN_MDS, { rules: { 'unused-variable': 'off' } }); + const allDiags = result.files.flatMap((f) => f.diagnostics); + const hasUnused = allDiags.some((d) => d.rule === 'unused-variable'); + assert.ok(!hasUnused, 'unused-variable should be silenced'); + }); + + test('U-LF4: lintFile nonexistent file rejects', async () => { + await assert.rejects( + () => lintFile('/nonexistent/path/no_such.mds'), + (err) => { + assert.ok(err instanceof Error); + return true; + }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// lintVirtual() — virtual filesystem +// --------------------------------------------------------------------------- + +describe('lintVirtual', () => { + before(() => init()); + + test('U-LV1: lintVirtual clean module returns version:1, empty files', () => { + const result = lintVirtual({ 'main.mds': CLEAN_SOURCE }, 'main.mds'); + assert.equal(result.version, 1); + assert.ok(Array.isArray(result.files)); + assert.equal(result.files.length, 0); + assert.equal(result.truncated, false); + assertResultShape(result, 'lint'); + }); + + test('U-LV2: lintVirtual detects findings in virtual module', () => { + const result = lintVirtual({ 'main.mds': UNUSED_SOURCE }, 'main.mds'); + const allDiags = result.files.flatMap((f) => f.diagnostics); + const hasUnused = allDiags.some((d) => d.rule === 'unused-variable'); + assert.ok(hasUnused, `expected unused-variable; got: ${JSON.stringify(allDiags)}`); + }); + + test('U-LV3: lintVirtual rules option silences rule', () => { + const result = lintVirtual( + { 'main.mds': UNUSED_SOURCE }, + 'main.mds', + { rules: { 'unused-variable': 'off' } }, + ); + const allDiags = result.files.flatMap((f) => f.diagnostics); + const hasUnused = allDiags.some((d) => d.rule === 'unused-variable'); + assert.ok(!hasUnused, 'unused-variable should be silenced'); + }); + + test('U-LV4: lintVirtual entry not in modules throws', () => { + assert.throws( + () => lintVirtual({ 'main.mds': CLEAN_SOURCE }, 'other.mds'), + (err) => { + assert.ok(err instanceof Error); + return true; + }, + ); + }); + + test('U-LV5: lintVirtual result shape is canonical lint JSON', () => { + const result = lintVirtual({ 'main.mds': UNUSED_SOURCE }, 'main.mds'); + // The file key in the JSON must match the entry key. + assert.ok(result.files.length > 0, 'expected at least one file entry'); + assert.equal(result.files[0].file, 'main.mds', 'file key must match entry'); + }); + + test('U-LV6: cross-surface parity: clean source produces identical shape from lint/lintVirtual/lintFile', async () => { + // Clean sources produce {version:1, files:[], truncated:false} on all surfaces. + // Key order may differ between surfaces (serde_json BTreeMap vs JS insertion + // order) so compare with deepEqual on parsed objects. + const expected = { version: 1, files: [], truncated: false }; + const fromLint = lint(CLEAN_SOURCE); + const fromLintVirtual = lintVirtual({ 'main.mds': CLEAN_SOURCE }, 'main.mds'); + const fromLintFile = await lintFile(SIMPLE_MDS); + assert.deepEqual(fromLint, expected, 'lint clean must match expected shape'); + assert.deepEqual(fromLintVirtual, expected, 'lintVirtual clean must match expected shape'); + assert.deepEqual(fromLintFile, expected, 'lintFile clean must match expected shape'); + }); +}); + +// --------------------------------------------------------------------------- +// Lint canonical JSON goldens (Step 12 — AC-API-06) +// --------------------------------------------------------------------------- +// +// Goldens are derived from the Rust core serializer and checked in. Comparing +// JS-serialized lintVirtual() output against these strings catches key-order +// drift or shape changes across releases. Non-circular: goldens are NOT +// regenerated from the universal package itself. +// +// Keys in BTreeMap alphabetical order: {"files":[...],"truncated":false,"version":1} + +describe('lint canonical JSON goldens', () => { + before(() => init()); + + test('U-LG1: lintVirtual clean source matches canonical golden', () => { + const CLEAN_GOLDEN = '{"files":[],"truncated":false,"version":1}'; + const result = lintVirtual({ 'main.mds': CLEAN_SOURCE }, 'main.mds'); + assert.equal( + JSON.stringify(result), + CLEAN_GOLDEN, + `lintVirtual clean golden mismatch: got ${JSON.stringify(result)}`, + ); + }); + + test('U-LG2: lintVirtual unused-variable source matches canonical golden', () => { + const UNUSED_GOLDEN = + '{"files":[{"diagnostics":[{"fixable":false,"help":"Remove the frontmatter key or reference it in the template body.",' + + '"message":"Variable \'unused_key\' is defined in frontmatter but never referenced in the body.",' + + '"rule":"unused-variable","severity":"warn","span":{"length":10,"offset":4}}],"file":"main.mds"}],"truncated":false,"version":1}'; + const result = lintVirtual({ 'main.mds': UNUSED_SOURCE }, 'main.mds'); + assert.equal( + JSON.stringify(result), + UNUSED_GOLDEN, + `lintVirtual unused-variable golden mismatch: got ${JSON.stringify(result)}`, + ); + }); + + test('U-LG3: lintVirtual silenced rule produces same clean golden', () => { + const CLEAN_GOLDEN = '{"files":[],"truncated":false,"version":1}'; + const result = lintVirtual( + { 'main.mds': UNUSED_SOURCE }, + 'main.mds', + { rules: { 'unused-variable': 'off' } }, + ); + assert.equal( + JSON.stringify(result), + CLEAN_GOLDEN, + `lintVirtual silenced golden mismatch: got ${JSON.stringify(result)}`, + ); + }); +}); diff --git a/packages/mds/__test__/wasm-backend.spec.mjs b/packages/mds/__test__/wasm-backend.spec.mjs index b48e184..f15bb9d 100644 --- a/packages/mds/__test__/wasm-backend.spec.mjs +++ b/packages/mds/__test__/wasm-backend.spec.mjs @@ -269,11 +269,13 @@ describe('wasm backend — browser shape validation', () => { _resetForTesting(0); }); - test('U-WB17: validateWasmShape accepts a well-formed module (compile, check, scanImports)', () => { + test('U-WB17: validateWasmShape accepts a well-formed module (compile, check, lint, scanImports, lintVirtual)', () => { const validMod = { compile: () => {}, check: () => {}, + lint: () => {}, scanImports: () => [], + lintVirtual: () => {}, }; assert.doesNotThrow( () => validateWasmShape(validMod), @@ -312,7 +314,7 @@ describe('wasm backend — browser shape validation', () => { }); test('U-WB20: validateWasmShape throws when scanImports is missing', () => { - const mod = { compile: () => {}, check: () => {} }; + const mod = { compile: () => {}, check: () => {}, lint: () => {}, lintVirtual: () => {} }; assert.throws( () => validateWasmShape(mod), (err) => { diff --git a/packages/mds/src/backend/contract.ts b/packages/mds/src/backend/contract.ts index c9e4a4b..1fe7a45 100644 --- a/packages/mds/src/backend/contract.ts +++ b/packages/mds/src/backend/contract.ts @@ -22,20 +22,21 @@ * Base backend methods — shared by the browser-safe MdsBaseBackend and all * Node.js backends. WASM module exports also belong here (plus scanImports). * compile and check are the only synchronous source-string operations. + * lint is a source-string lint operation available on all backends. */ -export const BASE_METHODS = ['compile', 'check'] as const; +export const BASE_METHODS = ['compile', 'check', 'lint'] as const; /** * Node-only file-based methods. These extend BASE_METHODS on MdsNodeBackend * and on the native addon (NapiAddon). */ -export const NODE_METHODS = ['compileFile', 'checkFile'] as const; +export const NODE_METHODS = ['compileFile', 'checkFile', 'lintFile'] as const; /** * WASM module exports — BASE_METHODS plus the import scanner needed for - * JS-side file resolution. + * JS-side file resolution, plus lintVirtual for multi-module lint. */ -export const WASM_EXPORTS = [...BASE_METHODS, 'scanImports'] as const; +export const WASM_EXPORTS = [...BASE_METHODS, 'scanImports', 'lintVirtual'] as const; export type BaseMethodName = (typeof BASE_METHODS)[number]; export type NodeMethodName = (typeof NODE_METHODS)[number]; @@ -82,8 +83,9 @@ export function validateBackendMethods( * * 'compile' covers both MarkdownResult and MessagesResult — the function * branches on `result.kind` to validate the correct shape variant. + * 'lint' validates the canonical lint JSON shape (version/files/truncated). */ -export type ResultKind = 'compile' | 'check'; +export type ResultKind = 'compile' | 'check' | 'lint'; // --------------------------------------------------------------------------- // Shallow return-shape validator (AC-PERF-04 — O(1), no element access) @@ -106,6 +108,9 @@ export type ResultKind = 'compile' | 'check'; * For kind='check': * assert warnings is array. * + * For kind='lint': + * assert version is number; assert files is array; assert truncated is boolean. + * * Extra fields on a valid result are silently tolerated. * Throws an Error with code `mds::invalid_backend_result` on mismatch. */ @@ -160,6 +165,21 @@ export function assertResultShape(result: unknown, kind: ResultKind): void { } break; } + case 'lint': { + // Validate the canonical lint JSON shape: version (number), files (array), + // truncated (boolean). Validation is shallow (AC-PERF-04 principle): + // files elements are not iterated. + if (typeof r['version'] !== 'number') { + throw makeShapeError(kind, '"version" must be a number'); + } + if (!Array.isArray(r['files'])) { + throw makeShapeError(kind, '"files" must be an array'); + } + if (typeof r['truncated'] !== 'boolean') { + throw makeShapeError(kind, '"truncated" must be a boolean'); + } + break; + } default: { // Exhaustiveness guard — TypeScript narrows `kind` to `never` here. const _exhaustive: never = kind; diff --git a/packages/mds/src/backend/native.ts b/packages/mds/src/backend/native.ts index 8e4a214..baffed9 100644 --- a/packages/mds/src/backend/native.ts +++ b/packages/mds/src/backend/native.ts @@ -4,21 +4,52 @@ import type { CompileOptions, CompileResult, FileOptions, + LintFileOptions, + LintOptions, + LintResult, MdsNodeBackend, } from '../types.js'; import { varsOpt } from '../util/options.js'; import { assertResultShape, validateBackendMethods, BASE_METHODS, NODE_METHODS } from './contract.js'; +/** Options forwarded to the napi addon for source-string lint (accepts basePath). */ +type NapiLintOpts = { basePath?: string; vars?: Record; rules?: Record }; +/** Options forwarded to the napi addon for file-based and virtual lint. */ +type NapiLintFileOpts = { vars?: Record; rules?: Record }; + /** * Shape of the napi addon exports. * compile/check accept { basePath?, vars? } for string sources. * compileFile/checkFile accept { vars? } for file paths. + * lint/lintFile/lintVirtual accept { basePath?, vars?, rules? }. */ interface NapiAddon { compile(source: string, opts?: { basePath?: string; vars?: Record }): unknown; check(source: string, opts?: { basePath?: string; vars?: Record }): unknown; compileFile(path: string, opts?: { vars?: Record }): unknown; checkFile(path: string, opts?: { vars?: Record }): unknown; + lint(source: string, opts?: NapiLintOpts): unknown; + lintFile(path: string, opts?: NapiLintFileOpts): unknown; + lintVirtual(modules: Record, entry: string, opts?: NapiLintFileOpts): unknown; +} + +/** Build lint options from LintOptions, omitting null/undefined entries. */ +function lintOpt(options?: LintOptions): NapiLintOpts | undefined { + if (options == null) return undefined; + const out: NapiLintOpts = {}; + if (options.basePath != null) out.basePath = options.basePath; + if (options.vars != null) out.vars = options.vars; + if (options.rules != null) out.rules = options.rules; + return Object.keys(out).length > 0 ? out : undefined; +} + +/** Build lint file options from LintFileOptions, omitting null/undefined entries. */ +function lintFileOpt(options?: LintFileOptions): NapiLintFileOpts | undefined { + if (options == null) return undefined; + const out: NapiLintFileOpts = {}; + if (options.vars != null) out.vars = options.vars; + if (options.rules != null) out.rules = options.rules; + return Object.keys(out).length > 0 ? out : undefined; } /** @@ -61,6 +92,24 @@ export function createNativeBackend(addon: NapiAddon): MdsNodeBackend { return result as CheckResult; }, + lint(source: string, options?: LintOptions): LintResult { + const result: unknown = addon.lint(source, lintOpt(options)); + assertResultShape(result, 'lint'); + return result as LintResult; + }, + + async lintFile(path: string, options?: LintFileOptions): Promise { + const result: unknown = await addon.lintFile(path, lintFileOpt(options)); + assertResultShape(result, 'lint'); + return result as LintResult; + }, + + lintVirtual(modules: Record, entry: string, options?: LintFileOptions): LintResult { + const result: unknown = addon.lintVirtual(modules, entry, lintFileOpt(options)); + assertResultShape(result, 'lint'); + return result as LintResult; + }, + getBackend(): BackendType { return 'native'; }, diff --git a/packages/mds/src/backend/wasm.ts b/packages/mds/src/backend/wasm.ts index e652916..7604e78 100644 --- a/packages/mds/src/backend/wasm.ts +++ b/packages/mds/src/backend/wasm.ts @@ -5,15 +5,17 @@ import type { CompileResult, FileOptions, InitOptions, + LintFileOptions, + LintOptions, + LintResult, MdsBaseBackend, } from '../types.js'; import { assertResultShape, validateBackendMethods, WASM_EXPORTS } from './contract.js'; /** * Shape of the WASM module exports (built with wasm-pack). - * The WASM module exports compile(source, options), check(source, options), - * and scanImports(source) for dependency resolution. - * options: { filename?, modules?, vars? } + * The WASM module exports compile, check, lint, lintVirtual, and scanImports. + * options shapes mirror the Rust wasm-bindgen signatures. * * Exported so callers can type-annotate pre-loaded modules passed to * createWasmBackend(). @@ -21,6 +23,18 @@ import { assertResultShape, validateBackendMethods, WASM_EXPORTS } from './contr export interface WasmModule { compile(source: string, options?: { filename?: string; modules?: Record; vars?: Record }): unknown; check(source: string, options?: { filename?: string; modules?: Record; vars?: Record }): unknown; + /** Lint a source string. Returns the canonical lint JSON object. */ + lint(source: string, options?: { + filename?: string; + modules?: Record; + vars?: Record; + rules?: Record; + }): unknown; + /** Lint a multi-module virtual filesystem. Returns the canonical lint JSON object. */ + lintVirtual(modules: Record, entry: string, options?: { + vars?: Record; + rules?: Record; + }): unknown; scanImports(source: string): string[]; default?: (input?: unknown) => Promise; } @@ -334,8 +348,10 @@ export function fileOpts( * Create a WASM backend instance from a pre-initialized WasmModule. * * Synchronous factory — mirrors createNativeBackend(addon) pattern. - * Returns MdsBaseBackend (compile, check, getBackend) without file operations. - * File operations (compileFile, checkFile) are added in node.ts via wrapWithFileOps(). + * Returns MdsBaseBackend (compile, check, lint, lintVirtual, getBackend) + * without file operations. + * File operations (compileFile, checkFile, lintFile) are added in node.ts + * via wrapWithFileOps(). */ export function createWasmBackend(wasmModule: WasmModule): MdsBaseBackend { return { @@ -351,6 +367,43 @@ export function createWasmBackend(wasmModule: WasmModule): MdsBaseBackend { return result as CheckResult; }, + lint(source: string, options?: LintOptions): LintResult { + // WASM backend does not support basePath (no filesystem access). + // vars and rules are forwarded; basePath is silently ignored. + const opts: { + vars?: Record; + rules?: Record; + } = {}; + if (options?.vars != null) opts.vars = options.vars; + if (options?.rules != null) opts.rules = options.rules; + const result: unknown = wasmModule.lint( + source, + Object.keys(opts).length > 0 ? opts : undefined, + ); + assertResultShape(result, 'lint'); + return result as LintResult; + }, + + lintVirtual( + modules: Record, + entry: string, + options?: LintFileOptions, + ): LintResult { + const opts: { + vars?: Record; + rules?: Record; + } = {}; + if (options?.vars != null) opts.vars = options.vars; + if (options?.rules != null) opts.rules = options.rules; + const result: unknown = wasmModule.lintVirtual( + modules, + entry, + Object.keys(opts).length > 0 ? opts : undefined, + ); + assertResultShape(result, 'lint'); + return result as LintResult; + }, + getBackend(): BackendType { return 'wasm'; }, diff --git a/packages/mds/src/index.ts b/packages/mds/src/index.ts index a32d0d3..54d3ac1 100644 --- a/packages/mds/src/index.ts +++ b/packages/mds/src/index.ts @@ -6,6 +6,13 @@ export type { CheckResult, CompileOptions, FileOptions, + LintDiagnostic, + LintFileOptions, + LintFileReport, + LintOptions, + LintResult, + LintSpan, + RuleSeverity, MdsErrorSpan, MdsError, BackendType, diff --git a/packages/mds/src/node.ts b/packages/mds/src/node.ts index 4188144..435e82f 100644 --- a/packages/mds/src/node.ts +++ b/packages/mds/src/node.ts @@ -7,6 +7,9 @@ import type { CompileOptions, FileOptions, InitOptions, + LintFileOptions, + LintOptions, + LintResult, } from './types.js'; import { assertResultShape } from './backend/contract.js'; import { initWasmNode, createWasmBackend, fileOpts } from './backend/wasm.js'; @@ -97,6 +100,40 @@ function wrapWithFileOps( assertResultShape(result, 'check'); return result as CheckResult; }, + + async lintFile(path: string, options?: LintFileOptions): Promise { + // Use buildModulesMap so the WASM check gate can resolve @import chains, + // matching the behaviour of the native lintFile (which uses NativeFs). + // Note: entryFilename is project-root-relative (e.g. "templates/foo.mds"), + // not just the basename — this differs from the native backend's filename + // in the canonical JSON. Use lintVirtual for byte-identical cross-surface + // comparison. + const { entryFilename, modules } = await buildModulesMap( + path, + (src) => wasmModule.scanImports(src), + ); + const entrySource = modules[entryFilename]; + if (entrySource === undefined) { + throw new Error( + `buildModulesMap did not populate entry file "${entryFilename}" in modules map`, + ); + } + // Build a copy of modules without the entry (lint() inserts it separately). + const extraModules: Record = { ...modules }; + delete extraModules[entryFilename]; + const lintOpts: { + filename: string; + modules?: Record; + vars?: Record; + rules?: Record; + } = { filename: entryFilename }; + if (Object.keys(extraModules).length > 0) lintOpts.modules = extraModules; + if (options?.vars != null) lintOpts.vars = options.vars; + if (options?.rules != null) lintOpts.rules = options.rules; + const result: unknown = wasmModule.lint(entrySource, lintOpts); + assertResultShape(result, 'lint'); + return result as LintResult; + }, }; } @@ -221,6 +258,25 @@ export function checkFile(path: string, options?: FileOptions): Promise { + return assertReady().lintFile(path, options); +} + +/** Lint a multi-module virtual filesystem. Caller provides the full module map and entry key. Requires init() to have been called and awaited first. */ +export function lintVirtual( + modules: Record, + entry: string, + options?: LintFileOptions, +): LintResult { + return assertReady().lintVirtual(modules, entry, options); +} + /** Returns which backend is currently active: 'native' or 'wasm'. Requires init() to have been called and awaited first. */ export function getBackend(): BackendType { return assertReady().getBackend(); @@ -234,6 +290,13 @@ export type { CompileResult, FileOptions, InitOptions, + LintDiagnostic, + LintFileOptions, + LintFileReport, + LintOptions, + LintResult, + LintSpan, + RuleSeverity, MarkdownResult, Message, MessagesResult, diff --git a/packages/mds/src/types.ts b/packages/mds/src/types.ts index b69df1c..099fae9 100644 --- a/packages/mds/src/types.ts +++ b/packages/mds/src/types.ts @@ -58,6 +58,88 @@ export interface FileOptions { vars?: Record; } +// --------------------------------------------------------------------------- +// Lint types +// --------------------------------------------------------------------------- + +/** Byte-range span locating a lint diagnostic within its source file. */ +export interface LintSpan { + /** Byte offset from the start of the source string. */ + offset: number; + /** Byte length of the diagnostic span. */ + length: number; +} + +/** A single lint finding produced by a lint rule. */ +export interface LintDiagnostic { + /** Rule identifier (e.g. `"unused-variable"`, `"duplicate-import"`). */ + rule: string; + /** Severity level: `"error"`, `"warn"`, or `"info"`. */ + severity: 'error' | 'warn' | 'info'; + /** Human-readable description of the finding. */ + message: string; + /** Optional guidance on how to resolve the finding. */ + help?: string; + /** Whether the lint engine can auto-fix this diagnostic (`--fix`). */ + fixable: boolean; + /** Source location of the finding, if available. */ + span?: LintSpan; +} + +/** + * Severity value accepted for a per-rule override in `LintOptions.rules` / + * `LintFileOptions.rules`. Mirrors the Rust `Severity` enum serialization: + * `"off"` silences the rule; `"info"` / `"warn"` / `"error"` set its level. + */ +export type RuleSeverity = 'error' | 'warn' | 'info' | 'off'; + +/** All diagnostics for a single file in a lint result. */ +export interface LintFileReport { + /** Path or name of the linted file. */ + file: string; + /** Diagnostics produced for this file. */ + diagnostics: LintDiagnostic[]; +} + +/** + * Canonical lint result returned by all lint surfaces (CLI `--format json`, + * napi, WASM, Python). All surfaces produce byte-identical JSON for the same + * input and rules configuration. + */ +export interface LintResult { + /** Schema version; always 1 in this release. */ + version: number; + /** Per-file findings. Empty when the source is clean. */ + files: LintFileReport[]; + /** + * `true` when the diagnostic count exceeded `MAX_DIAGNOSTICS` (1000) and + * earlier diagnostics were dropped. Re-run after fixing to surface the rest. + */ + truncated: boolean; +} + +/** Options for source-string lint operations. */ +export interface LintOptions { + /** Runtime variables injected into the check gate (not the lint rules). */ + vars?: Record; + /** Per-rule severity overrides, e.g. `{ 'shadow-variable': 'warn' }`. */ + rules?: Record; + /** + * Base directory for resolving `@import` directives in the source string. + * Required when the source contains `@import` or `@extends`. + * Ignored by the WASM backend (which cannot access the filesystem). + */ + basePath?: string; +} + +/** Options for file-based and virtual lint operations. */ +export interface LintFileOptions { + /** Runtime variables injected into the check gate (not the lint rules). */ + vars?: Record; + /** Per-rule severity overrides, e.g. `{ 'shadow-variable': 'warn' }`. */ + rules?: Record; +} + /** Source location of a compiler error. */ export interface MdsErrorSpan { /** Byte offset from the start of the source string. */ @@ -95,22 +177,34 @@ export interface InitOptions { } /** - * Browser-safe backend interface — compile/check/getBackend only. + * Browser-safe backend interface — compile/check/lint/lintVirtual/getBackend. * Does not include file operations (which require node:fs). */ export interface MdsBaseBackend { compile(source: string, options?: CompileOptions): CompileResult; check(source: string, options?: CompileOptions): CheckResult; + /** + * Lint an MDS source string. + * Runs the check gate first; returns a LintResult with per-rule findings. + */ + lint(source: string, options?: LintOptions): LintResult; + /** + * Lint a multi-module virtual filesystem. + * Caller provides the full module map and entry key. + */ + lintVirtual(modules: Record, entry: string, options?: LintFileOptions): LintResult; getBackend(): BackendType; } /** * Full backend interface for Node.js environments. - * Extends MdsBaseBackend with file-based compile/check operations. + * Extends MdsBaseBackend with file-based compile/check/lint operations. */ export interface MdsNodeBackend extends MdsBaseBackend { compileFile(path: string, options?: FileOptions): Promise; checkFile(path: string, options?: FileOptions): Promise; + /** Lint an MDS file, resolving @import directives relative to the file. */ + lintFile(path: string, options?: LintFileOptions): Promise; } /** diff --git a/spec.md b/spec.md index e149d63..82e29c1 100644 --- a/spec.md +++ b/spec.md @@ -800,7 +800,7 @@ Errors include a diagnostic code (`mds::*`), file path, line number, column, a v 3. **Block scope**: `@for` loop vars scoped to their `@for...@end` block 4. **Function scope**: params scoped to function body, shadow outer vars 5. **Import scope**: namespaced (aliased) or merged (unaliased), never implicit leaking -6. **Shadowing**: inner scope wins, no warning (intentional override) +6. **Shadowing**: inner scope wins, no warning (intentional override). Teams that want visibility into shadowed variables can enable the opt-in `shadow-variable` lint (info severity, default-off) in `mds.json`. --- @@ -813,6 +813,7 @@ Errors include a diagnostic code (`mds::*`), file path, line number, column, a v | `mds build [FILE\|DIR]` | Compile an `.mds` template (or a directory of templates) | | `mds check [FILE\|DIR]` | Validate a template or directory without rendering | | `mds fmt [FILE\|DIR]` | Auto-format `.mds` templates in place (safety-gated) | +| `mds lint [FILE\|DIR]` | Static-analysis lint of `.mds` templates | | `mds init [FILENAME]` | Create a starter `.mds` file | ### 7.2 `mds build` @@ -899,7 +900,77 @@ Every rewrite is **safety-gated**: the formatter re-compiles both the original a | `--check` | Exit non-zero without writing if any file would change. | | `--diff` | Print a unified diff of proposed changes without writing. | -### 7.5 `mds init` +### 7.5 `mds lint` + +```bash +mds lint # Auto-detect single .mds in current dir +mds lint template.mds # Lint a single file +mds lint src/ # Lint all .mds files recursively (incl. partials) +mds lint --fix template.mds # Auto-fix fixable issues in place +mds lint --fix --check template.mds # Preview --fix: exit 1 if any file would change +mds lint --fix --diff template.mds # Preview --fix: print unified diff without writing +mds lint --format json template.mds # Machine-readable JSON output (stdout) +mds lint --quiet template.mds # Suppress warnings; exit 2 on errors only +cat template.mds | mds lint - # Lint from stdin +cat template.mds | mds lint --fix - # Fix from stdin, write fixed source to stdout +``` + +**Channel discipline:** +- Human-readable diagnostics → **stderr** (via miette). +- `--format json` output → **stdout** (single JSON object, one trailing newline). +- `--quiet` suppresses warning-severity and info human diagnostics, NOT errors. + +**Options:** + +| Option | Description | +|--------|-------------| +| `--fix` | Apply auto-fixable issues in place. Tier A fixes apply always; Tier B fixes apply only to standalone (non-importing) files. | +| `--check` | With `--fix`: exit 1 if any file would change; never writes. Useful for CI. | +| `--diff` | With `--fix`: print unified diff of pending changes without writing. | +| `--format ` | Output format: `human` (default, stderr) or `json` (stdout). | +| `--vars ` | JSON file with runtime variable overrides (forwarded to the check gate). | +| `--set KEY=VALUE` | Set a single variable. Repeatable. Type coercion applies. | +| `--set-string KEY=VALUE` | Set a single variable as a string, bypassing type coercion. Repeatable. | +| `-q, --quiet` | Suppress warning/info human diagnostics; errors still print. | + +**Exit codes** (lint-specific; differ from `mds build`/`mds check`): + +| Code | Meaning | +|------|---------| +| `0` | Clean — no warning- or error-severity findings | +| `1` | Warning-severity findings only (no errors) | +| `2` | Any error-severity finding, analysis failure (parse/resolve/IO/config), or usage error | +| `3` | Resource limit exceeded | + +With `--fix`, residual post-fix findings determine the exit code. + +**JSON output format** (`--format json`): + +```json +{ + "files": [ + { + "diagnostics": [ + { + "fixable": false, + "help": "Remove the frontmatter key or reference it in the template body.", + "message": "Variable 'foo' is defined in frontmatter but never referenced in the body.", + "rule": "unused-variable", + "severity": "warn", + "span": { "length": 3, "offset": 4 } + } + ], + "file": "template.mds" + } + ], + "truncated": false, + "version": 1 +} +``` + +Keys are in alphabetical order (BTreeMap serialization). `"truncated": true` when the result set was capped by the per-file diagnostic cap of 1,000. `"span"` is absent for diagnostics that lack a source location. + +### 7.6 `mds init` ```bash mds init # Creates hello.mds in current directory @@ -909,7 +980,7 @@ mds init my-prompt.mds --force # Overwrite if file already exists Creates a compilable starter template. Path traversal (e.g. `../escaped.mds`) is rejected. -### 7.6 Auto-Detection +### 7.7 Auto-Detection When no `FILE` argument is given to `mds build` or `mds check`, the compiler scans the current directory for `.mds` files: @@ -917,7 +988,7 @@ When no `FILE` argument is given to `mds build` or `mds check`, the compiler sca - **Zero found** → error with hint to run `mds init`. - **Multiple found** → error listing the files with a hint to specify one. -### 7.7 `mds.json` Project Config +### 7.8 `mds.json` Project Config Place `mds.json` in the project root (or any ancestor directory). The compiler walks up from the input file to find it. @@ -925,6 +996,12 @@ Place `mds.json` in the project root (or any ancestor directory). The compiler w { "build": { "output_dir": "dist" + }, + "lint": { + "rules": { + "unused-variable": "warn", + "unused-import": "off" + } } } ``` @@ -932,10 +1009,13 @@ Place `mds.json` in the project root (or any ancestor directory). The compiler w | Field | Type | Description | |-------|------|-------------| | `build.output_dir` | string | Relative path to output directory. Must not contain `..` components. | +| `lint.rules` | object | Per-rule severity overrides for `mds lint`. Keys are rule names; values are `"warn"`, `"error"`, or `"off"`. Unknown severity values cause a hard config-load error. Unknown rule names are warn-and-ignored (forward compat). | Maximum config file size: 1 MB. -### 7.8 Exit Codes +### 7.9 Exit Codes + +**`mds build`, `mds check`, `mds fmt`, `mds init`:** | Code | Meaning | |------|---------| @@ -944,9 +1024,44 @@ Maximum config file size: 1 MB. | `2` | I/O or file-system error (file not found, not an MDS file, I/O failure) | | `3` | Resource limit exceeded (output too large, too many iterations, message count exceeds `MAX_MESSAGE_COUNT` (10,000), or cumulative message content exceeds 50 MB) | +**`mds lint`** (see §7.5 for per-code meaning): + +| Code | Meaning | +|------|---------| +| `0` | Clean — no warning- or error-severity findings | +| `1` | Warning-severity findings only (no errors) | +| `2` | Error-severity finding, analysis failure, or usage error | +| `3` | Resource limit exceeded | + +--- + +## 8. Lint Rule Catalog + +Rules default to the severities shown below — **not all rules default to `warn`**. Override per rule in `mds.json` or via the `rules` option (library API). Severity values: `"warn"`, `"error"`, `"info"`, `"off"`. + +| Rule | Default Severity | Fixable | Tier | Description | +|------|-----------------|---------|------|-------------| +| `unused-variable` | warn | no | C | A frontmatter variable is defined but never referenced in the template body. | +| `unused-import` | warn | suggestion¹ | B | An `@import` statement imports a name that is never used in the file. | +| `unused-function` | warn | suggestion¹ | B | A `@define` function is defined but never called in the file. | +| `shadow-variable` | info (default-off²) | no | C | A variable declared in an inner scope (e.g. `@for`) shadows an outer-scope variable of the same name. | +| `empty-block` | warn | yes (A)³ | A | A control-flow block (`@if`, `@elseif`, `@else`, `@for`, `@define`, `@message`) has an empty or whitespace-only body. | +| `redundant-else` | warn | no | C | An `@else` block whose body is structurally identical to the preceding `@if`/`@elseif` then-body (detected via structural equality). Tier C — never auto-fixed. | +| `unreachable-branch` | **error** | yes (A)³ | A | A branch condition (`@if`/`@elseif`) is always-true (with later branches) or always-false, making some code dead. | +| `duplicate-import` | **error** | yes (A) | A | The same file is imported more than once in a single file (modulo alias). | +| `duplicate-export` | **error** | yes (A) | A | The same export name is defined more than once in a single file. | + +¹ **Tier B suggestion**: `unused-import` and `unused-function` fixes are suggestion-only in practice — neither is ever auto-applied by `--fix`. `unused-import` fires only on files that contain `@import` statements, which are by definition not standalone, so `fixable` is always `false`. `unused-function` can fire on a standalone file (`is_fixable` returns `true`), but a `@define` block always spans multiple lines; the block-span reverify gate (ADR-001) refuses the fix for the same reason as footnote ³. Set the rule to `"off"` in `mds.json` to silence it. + +² **`shadow-variable` is default-off**: it emits at `info` severity but is suppressed at the `info` level by default (only shown when explicitly enabled via `mds.json`). It never affects the exit code. + +³ **Tier A block-spanning fixes are always refused**: The fix planner removes only the single directive line containing the span, leaving the matching `@end` orphaned. The reverify gate catches the resulting parse error and refuses the fix (fail-closed; applies ADR-001). As a result, all `empty-block` and `unreachable-branch` fixes that span multiple lines are always reported as suggestion-only — they are never written to disk. Single-directive blocks that reduce to a no-op on removal are not affected. Follow-up: #172. + +**Tier A** fixes always apply (`--fix`) and are gated by a post-fix reverify (recompile-success + no-new-diagnostics + output byte-equality). **Tier B** fixes apply only when the file is standalone (no `@import` / `@extends`). **Tier C** rules are report-only — never auto-fixed. + --- -## 8. Complete Example +## 9. Complete Example ### Input: `welcome.mds`