Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
76616ea
feat(core): add offset to ExportDirective variants (D2, #61)
dean0x Jul 10, 2026
05bee39
feat(core): lint engine scaffolding — types, config, canonical JSON, …
dean0x Jul 10, 2026
5cf36b4
feat(wasm): lint stub export + size baseline (#61)
dean0x Jul 10, 2026
4304d15
feat(core): 9-rule lint engine — 5 local-AST + 4 semantic rules (#61)
dean0x Jul 10, 2026
462b03d
feat(core): tiered --fix planner with overlap rejection and reverify …
dean0x Jul 10, 2026
9a65849
chore(ci): raise wasm size budget for lint engine (#61)
dean0x Jul 10, 2026
8964e7a
docs(handoff): record wasm size measurement and 700K→750K guard raise…
dean0x Jul 10, 2026
7e92d0f
feat(lint): wire fixable field in LintResult + LintCliConfig in mds.j…
dean0x Jul 10, 2026
9bb6ff7
feat(cli): add `mds lint` subcommand with --fix, --check, --diff, --f…
dean0x Jul 10, 2026
a05ea22
docs(handoff): S3 CLI phase complete — mds lint subcommand, fixable w…
dean0x Jul 10, 2026
3888b5e
feat(core): span-labeled human rendering for lint diagnostics (#61)
dean0x Jul 10, 2026
2780bf2
feat(wasm): finalize lint + lint_virtual with rules option (#61)
dean0x Jul 10, 2026
5f78dd9
feat(napi): lint/lintFile/lintVirtual + index.d.ts type declarations …
dean0x Jul 10, 2026
d69fced
feat(python): lint/lint_file/lint_virtual bindings + LintResult (#61)
dean0x Jul 10, 2026
eacb47f
docs(handoff): S4a bindings phase complete — WASM/napi/Python lint pa…
dean0x Jul 10, 2026
a7e2420
feat(mds): lint/lintFile/lintVirtual universal wrapper + byte-parity …
dean0x Jul 10, 2026
9b648de
docs: mds lint spec, README, CHANGELOG (#61)
dean0x Jul 10, 2026
74c45ff
chore(ci): raise wasm size budget 750K->800K — lint surface at 749,80…
dean0x Jul 10, 2026
ae04b58
refactor(lint): extract tier classification to leaf module, remove se…
dean0x Jul 10, 2026
f7455fa
fix: address self-review issues
dean0x Jul 10, 2026
ca90951
fix(lint): unreachable-branch FP + dedup, --fix output-delta gate (#61)
dean0x Jul 10, 2026
7c60549
docs: correct spec §8 rule catalog, §6 shadow note, CHANGELOG (#61)
dean0x Jul 10, 2026
e4dc865
fix(cli): emit JSON error envelope for top-level lint analysis failur…
dean0x Jul 11, 2026
ad82ba2
fix(cli): remove redundant field pattern flagged by clippy 1.97 (#61)
dean0x Jul 11, 2026
0e02aab
fix(mds): narrow LintDiagnostic.severity and lintVirtual types per re…
dean0x Jul 11, 2026
f89c872
test(python): extend AC-C6 strict typecheck sample to cover lint surf…
dean0x Jul 11, 2026
0d235ad
fix(napi): lintVirtual basePath rejection names the correct function …
dean0x Jul 11, 2026
d463294
docs: correct mds lint rule catalog, severities, fixability, and sche…
dean0x Jul 11, 2026
ccd04ed
refactor(lint): derive Copy on Severity, drop redundant clones per re…
dean0x Jul 11, 2026
e8318d8
perf(lint): drop redundant String alloc in facts walk; correct traver…
dean0x Jul 11, 2026
56841a0
refactor(lint): revert compute_line_column visibility; make span sche…
dean0x Jul 11, 2026
8998ef1
fix(lint): align native lint(source) file key with wasm backend per r…
dean0x Jul 11, 2026
41a440b
test(lint): cover wildcard-reexport + empty @elseif + tier table; ded…
dean0x Jul 11, 2026
6cd1804
test(cli): cover block-span --fix refusal, exit-3 ResourceLimit, dir-…
dean0x Jul 11, 2026
19d4e7c
refactor(cli): flush/broken-pipe stdout, extract lint exit helper, do…
dean0x Jul 11, 2026
4e07771
refactor(lint): simplify resolution refactors per review #171
dean0x Jul 11, 2026
f0aac49
style: apply rustfmt to lint resolution changes per review #171
dean0x Jul 11, 2026
cb759f3
docs(lint): fix diagnostic cap value, JSON key order, footnote mechan…
dean0x Jul 11, 2026
405c74e
fix(lint): I-09 clamp extend_to_line_end; I-13 Tier B e2e test; I-19 …
dean0x Jul 11, 2026
492ce67
fix(lint): correct null-key doc, escape DEL in sanitize, verify trunc…
dean0x Jul 11, 2026
a976872
refactor(lint): deduplicate empty-block/unused-import logic; add recu…
dean0x Jul 11, 2026
83fe7bf
perf(lint): defer source read to fix branch; borrow runtime_vars per-…
dean0x Jul 11, 2026
c87827c
test(lint): add CLI end-to-end tests for unreachable-branch --fix ref…
dean0x Jul 11, 2026
36cca64
fix(lint): parameterize field label in parse_modules_from_map; align …
dean0x Jul 11, 2026
9698b68
fix(lint): resolve Python binding issues I-04/I-33/I-34/I-35
dean0x Jul 11, 2026
250ec26
refactor(lint): rename LintFileResult→LintFileReport, narrow rules ty…
dean0x Jul 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 0 additions & 2 deletions crates/mds-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
27 changes: 27 additions & 0 deletions crates/mds-cli/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, mds::Severity>,
}

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)]
Expand Down
Loading
Loading