v0.4.0 Wave 1: breaking changes, live-bug fixes, and release-safety gates - #308
v0.4.0 Wave 1: breaking changes, live-bug fixes, and release-safety gates#308dean0x wants to merge 34 commits into
Conversation
…er (#293) Community-release safety hardening, three coordinated changes: - Add CODE_OF_CONDUCT.md (Contributor Covenant 2.1) with an enforcement contact, wired into CONTRIBUTING.md, README.md, and the PR template. - Add scripts/verify-no-control-bytes.mjs: CI gate that rejects control bytes and Unicode bidirectional-override characters in tracked sources, plus a pre-commit hook so the scan runs before code ever leaves a workstation. - Add scripts/verify-pr-checks.mjs (PF-017): pre-merge verifier that treats only status=COMPLETED + conclusion=SUCCESS as a pass, so CANCELLED, SKIPPED, STALE, NEUTRAL, ACTION_REQUIRED, QUEUED, and IN_PROGRESS checks can no longer read as "not failed" and slip past an --admin merge. Covered by unit tests against recorded API fixtures for both scanners. Closes #38 Closes #288 Closes #289 Co-Authored-By: Claude <noreply@anthropic.com>
Batches three issues that all mutate the same `--format json` wire object. - #211: uniform `<stdin>` sentinel across all CLI surfaces (lint/check/build), via a single `STDIN_DISPLAY_LABEL` constant and a conditional `StdinRelabeledError` guard that leaves imported-file errors untouched. - #202: diagnostics sorted by byte offset within each file group; no-span diagnostics sort to the end of their group, no-file diagnostics to the end of the list. Ordering is now a defined part of the wire contract. - #203: `unused-import` spans anchor at the unused NAME in selective imports rather than at `@import`, via `ImportDirective::Selective::name_offsets`. BREAKING CHANGE: `--format json` output changes in three ways — diagnostic order within a file group is now defined (ascending byte offset, previously rule-insertion order); the stdin `files[].file` key is `<stdin>` instead of `input.mds`; and `unused-import` `span.offset`/`span.length` point at the name rather than the `@import` keyword. Binding surfaces (napi/WASM/Python) still emit `input.mds` and are unaffected by the file-key change. Closes #211 Closes #202 Closes #203 Co-Authored-By: Claude <noreply@anthropic.com>
Unknown rule names in `mds.json` and in the `rules` option on every binding surface now emit a warning and lint continues, instead of silently no-opping. - CLI: warning goes to stderr so `--format json` stdout stays parseable JSON; `--quiet` suppresses it - mds-core: `ALL_RULE_NAMES`/`KNOWN_LINT_RULES` registry is the single source of truth; `find_unknown_rule_names` + `format_unknown_rule_names_warning` shared by all surfaces - Bindings (napi, WASM, Python): return `lint_warnings: string[]` on the lint result when unknown names are present, absent otherwise - TypeScript: `LintRuleName` union, `LINT_RULE_NAMES` const, and `LintResult.lint_warnings?: string[]` No breaking changes — warn-and-continue is strictly additive. Unknown severity values still hard-fail; the asymmetry is intentional. Closes #224
Tune the wasm-opt pipeline (-Oz --flatten --rereloop -Oz --converge --strip-producers) and switch four tie-free sort sites to sort_unstable (value.rs, evaluator.rs, builtins.rs x2). Also pins the wasm job's Rust toolchain to 1.96.0 so future size measurements are toolchain-stable. CI-measured (Binaryen v129, nodejs + web targets, both identical): before (PR2 line, run 31945656437): 838,150 bytes after (this PR, run 31946611856): 836,126 bytes delta: -2,024 bytes (-0.24%) Headroom against the 850,000-byte guard: 13,874 bytes (1.63%). The guard is NOT raised. The realized saving is small. Pre-implementation analysis predicted -7,527 bytes from flag tuning alone under Binaryen v129, plus a further ~4-6 KB from sort_unstable; the author's local measurement with the bundled wasm-opt v117 showed -2,235 bytes. Reality (-2,024) lands close to the local v117 figure and well short of the v129 prediction. The delta also absorbs the toolchain change from unpinned `stable` to pinned 1.96.0, so the portion attributable to the flag and sort changes alone is not separately isolated. Co-Authored-By: Claude <noreply@anthropic.com>
Establishes the CLI quiet-mode contract and adds a directory-mode summary
line for `mds lint`.
- `mds build --quiet <dir>` suppresses the summary line on fully-successful
runs, matching `mds check` and `mds fmt` (D1-a contract). The line still
prints when `fail_count > 0`, so a non-zero exit is never unexplained.
- `mds lint <dir>` emits a per-run summary on stderr:
`N clean, N with warnings, N with errors, N resource-limited`.
- Under `--quiet` the lint summary is suppressed for clean and warn-only
runs, but always prints when error- or resource-limited files are present.
- D4: the diagnostic truncation-cap notice and the "fix rejected" message
are gated on `!quiet` in both the single-file and directory lint paths.
- The `{"files":...}` JSON envelope is unchanged; no `summary` key added.
Tests cover AC-Q01-Q30, including hostile-filename forgery resistance
(PF-018) and positive controls on every suppression assertion (PF-013).
BREAKING CHANGE: CI scripts grepping `mds build --quiet` stderr for the
"N built, N failed" line on success will no longer see it.
Closes #216
Completes the TypeScript public surface for @mdscript/mds across three fronts. #180 (live bug): `basePath` was accepted by the unknown-option validator but discarded before reaching the backend, so `compile(source, { basePath })` resolved `@import`/`@extends` against the wrong directory. `basePath` now propagates through four typed per-surface builders (`compileSrcOpt`, `checkSrcOpt`, `fileCompileOpt`, `fileCheckOpt`) to the native backend, and the WASM backend rejects non-null `basePath` with `mds::invalid_options` instead of silently ignoring it. #215: `lint()` and `lintVirtual()` are exported from `browser.ts`; all seven lint types and `LINT_RULE_NAMES` are now available from both the Node and browser entry points. #213: deleted the `_CompileBackendOpts`/`_CheckBackendOpts` stubs, unified option forwarding through `forwardOpts`/`METHOD_KEYS`, and added the `BASEPATH_PASSTHROUGH` set so the file surfaces raise a purpose-built error rather than the generic unknown-key message. BREAKING CHANGE: `FileOptions`, `CheckFileOptions` and `LintFileOptions` now declare `basePath?: never`. This narrows the published types: code that previously type-checked while passing `basePath` to `compileFile`, `checkFile` or `lintFile` will now fail to compile, and those surfaces reject `basePath` at runtime. File-based APIs derive their resolution root from the file path, so `basePath` was never honored there. `FileOptions` also no longer extends `CompileOptions`, and `checkFile` takes `CheckFileOptions` rather than `CheckOptions`. Closes #180 Closes #215 Closes #213 Co-Authored-By: Claude <noreply@anthropic.com>
…ental (#303) Marks `mds::fix::apply_fixes` as `#[deprecated(since = "0.4.0")]`, directing callers to `apply_fixes_incremental`, which upholds the same ADR-004 three-tier reverify-gate contract while salvaging the safe subset of a batch instead of refusing the whole plan. - `#[deprecated]` note names the replacement and the `Fn`-vs-`FnOnce` closure-bound delta - 10 per-fn `#[expect(deprecated)]` suppressions on tests exercising the legacy path; `unfulfilled_lint_expectations` fires under `-D warnings` if the attribute is removed - New F-API-3 test pins `apply_fixes` as still callable from an external-crate context - Rustdoc corrections: ADR-001 -> ADR-004, `apply_fixes` -> `apply_fixes_incremental` - CHANGELOG `### Deprecated` section and KNOWLEDGE.md v0.5.0 removal tracker Not a breaking change: `apply_fixes` remains present and functional. Removal is deferred to v0.5.0. Closes #209 Co-Authored-By: Claude <noreply@anthropic.com>
Fixes release-note and documentation defects found in the pre-tag sweep: - CHANGELOG.md: correct v0.4.0 entries and restore accurate release history - packages/mds README + types.ts: fix stale API/type documentation - crates/mds-napi README, examples/linting README: correct doc drift - crates/mds-cli output.rs (+ cli_lint test): align emitted text with docs - .github/workflows/ci.yml: doc-related CI adjustments Co-Authored-By: Claude <noreply@anthropic.com>
…307) Co-authored-by: Claude <noreply@anthropic.com>
| if !quiet { | ||
| eprintln!("fix rejected: {}", safe_inline(&reason)); | ||
| } | ||
| emit_result(format, &original, quiet, named_source); |
There was a problem hiding this comment.
BLOCKING — mds lint <file> --fix --check discards every diagnostic and the entire JSON envelope — complexity, 97%, reproduced on the CLI then confirmed in source.
Anchored here because the defect lines (:1027-1044) are unchanged context. This arm is the sibling that gets it right.
// :1027-1032 (run_lint_file, preview path)
if check {
if !quiet { eprintln!("Would fix: {}", safe_path(path)); }
std::process::exit(1); // exits BEFORE emit_result at :1044
}Reproduced with one fixable legacy-interpolation + one error-severity duplicate-export:
$ mds lint a.mds --fix --check --format json ; echo "exit=$?"
exit=1
--stdout-- <- COMPLETELY EMPTY. Not "{}" — zero bytes.
$ mds lint d --fix --check --format json # same file, dir mode
{"files":[{"diagnostics":[{...,"rule":"duplicate-export","severity":"error",...}]}],"truncated":false,"version":1}
Directory mode does the opposite and says why at :1374-1375: "Emit JSON envelope BEFORE any early exit so consumers always receive parseable output regardless of exit code (AC-F-14 / issue #36)." Human mode loses the same information — Would fix: a.mds is the entire output.
mds lint is under ## [Unreleased] (CHANGELOG.md:115) — it has never shipped. This tag freezes AC-F-14 as true for <dir> and false for <file>, permanently.
Fix (4 lines; preview_fixes returns owned Strings, so result is no longer borrowed and this compiles without restructuring):
if check {
if !quiet { eprintln!("Would fix: {}", safe_path(path)); }
emit_result(format, &result, quiet, named_source); // ADD — mirrors dir mode
std::process::exit(1);
}Add the single-file twin of crates/mds-cli/tests/cli_lint.rs:2309 dir_fix_check_json_emits_parseable_json_before_exit_1 — the absence of that twin is exactly why this survived.
If the fix is judged too risky for the tag, the minimum acceptable alternative is a spec.md §lint-json entry stating AC-F-14 does not hold for single-file --fix --check. Shipping it undocumented is the option to reject.
| /// Error-only entries (`{"file": …, "error": …}`) push the raw display path | ||
| /// without a second sanitization pass — a pre-existing asymmetry; their array | ||
| /// position is still determined by the sanitized sort key. |
There was a problem hiding this comment.
Stale rustdoc contradicts code added in the same wave — reported independently by security (90%), regression R-03 (95%) and rust (92%).
The doc says error-only entries push the raw display path. The code does the opposite, and correctly so — :1454:
let file_key = mds::sanitize_control_chars_wire(&display_path).into_owned();All four error-entry sites (:1468, :1479, :1515, :1591) push file_key, and the diff shows the change explicitly ("file": display_path → "file": file_key). The companion comment at :1448-1453 describes the new behaviour correctly, so two comments in one file now disagree.
No runtime impact today. The risk is a fifth error-entry site added by a maintainer who trusts this doc and pushes display_path directly — reintroducing CWE-150 via a hostile filename into JSON files[].file. Doc drift on an invariant is how invariants die.
Fix: replace these three lines with
/// Error-only entries (`{"file": …, "error": …}`) bypass `to_canonical_json`, so callers
/// must apply `sanitize_control_chars_wire` themselves — see `file_key` in
/// `lint_one_file_accumulating`.
Regression also notes this is an undocumented wire delta: for a filename carrying a control/bidi character, the error-entry file value is now escaped where it previously was raw. Worth a one-line CHANGELOG note under ### Fixed.
| accumulate_result_json(&residual, json_files); | ||
| if let Err(e) = atomic_write_file(file, &new_source) { | ||
| eprintln!("error writing {}: {}", safe_path(file), safe_inline(&e)); | ||
| return FileTally::Error; |
There was a problem hiding this comment.
A write failure under --fix --format json emits a clean JSON result and no error entry — complexity, 92%, reproduced.
accumulate_result_json pushes the post-fix residual before the write is attempted; on failure the function returns FileTally::Error having pushed no JSON error entry. The read-failure path in the same function does it correctly (:1513-1518):
json_files.push(serde_json::json!({ "file": file_key, "error": e.serialize() }));
return FileTally::Error;Reproduced with a read-only directory so atomic_write_file's temp-file create fails:
$ chmod 555 ro/sub && mds lint ro/sub --fix --format json ; echo "exit=$?"
exit=2
--stdout--
{"files":[],"truncated":false,"version":1} <- reads as "clean tree, nothing found"
--stderr--
error writing ro/sub/a.mds: cannot create temp file ... Permission denied (os error 13)
The file on disk still contains the duplicate export. A consumer parsing stdout sees a clean result; only the exit code and stderr disagree — the same divergence class as the --fix --check finding, where the JSON leg lost a convention its human twin never needed.
Fix: attempt the write first; on failure push a structured entry matching the read-failure shape three lines above, and only accumulate_result_json(&residual, json_files) after the write succeeds. Apply the same to the PartiallyFixed arm at :1558-1562.
(atomic_write_file returns miette::Report, not MdsError; if .serialize() is unavailable there, {"file": file_key, "error": {"code":"mds::io","message": safe_inline(&e)}} is an acceptable equivalent. The load-bearing part is that some error entry appears and the post-fix result does not.)
| * `lintVirtual` resolves imports against the caller-supplied module map. | ||
| * Passing a non-null value throws `mds::invalid_options`. | ||
| */ | ||
| basePath?: never; |
There was a problem hiding this comment.
BLOCKING — basePath?: never legalizes { basePath: undefined }, which lintFile/lintVirtual reject at runtime — typescript, 97%.
?: never means "absent, or present with value undefined". exactOptionalPropertyTypes is not enabled (tsconfig.base.json has strict and noUncheckedIndexedAccess only), so this type-checks under tsconfig.types.json:
const p3b: LintFileOptions = { basePath: undefined }; // NO ERRORBut the runtime rejects it. Probe against the built dist/:
OK | compileFile {basePath: undefined}
OK | checkFile {basePath: undefined}
THROW | lintFile {basePath: undefined} -> [mds::invalid_options] unknown option key "basePath"; recognised keys are: vars, rules
THROW | lintVirtual {basePath: undefined} -> [mds::invalid_options] unknown option key "basePath"; recognised keys are: vars, rules
This is a regression, not a pre-existing wart. On main, LintFileOptions omitted basePath entirely, so {basePath: undefined} was a TS2353 excess-property error — the type and the runtime agreed. Adding basePath?: never removed that agreement for the two lint file surfaces while compileFile/checkFile kept it.
The shipped README compounds it: packages/mds/README.md:149,158,182 give all three file-surface types the byte-identical annotation, and :127 states "{basePath: undefined} is treated as absent on both backends". A consumer reading the published tarball reasonably concludes uniform behaviour; two of the three surfaces disagree.
Failure scenario: a wrapper that spells out the full option shape — const opts: LintFileOptions = { vars, rules: {}, basePath: undefined } — compiles clean under strict and throws on every call, while the same shape passed to compileFile works fine.
The published .d.ts is the correct half of this pair; the fix belongs in util/options.ts (see the comment there). Blocking is still right: the change is ~3 lines with zero existing-test churn and this diff introduced it.
| const BASEPATH_REJECTORS: ReadonlyMap<MethodName, () => Error & { code: string }> = new Map([ | ||
| ['compileFile', makeFileBasePathError], | ||
| ['checkFile', makeFileBasePathError], | ||
| ] as const); |
There was a problem hiding this comment.
Runtime half of the basePath?: never divergence — typescript (97%) and consistency C-5 (88%) independently.
lintFile/lintVirtual are absent from this map, so assertKnownKeys does not skip basePath for them (:220) and the key's mere presence trips the generic unknown-key path. That is why {basePath: undefined} throws on these two surfaces and passes on compileFile/checkFile — a type/runtime divergence this diff introduced (see types.ts:359).
Consistency adds the second, permanent cost: the same operation produces two different messages from two published packages. 🔒 Published error strings harden at the tag.
| Call | @mdscript/mds |
@mdscript/mds-napi |
|---|---|---|
compileFile(f, {basePath}) |
option "basePath" is not valid for compileFile/checkFile; … |
byte-identical — locked by U-OV-27 |
checkFile(f, {basePath}) |
same | byte-identical |
lintFile(f, {basePath}) |
unknown option key "basePath"; recognised keys are: vars, rules |
option "basePath" is not valid for lintFile; the base directory is derived from the file path (crates/mds-napi/src/lib.rs:900-906) |
lintVirtual(m, e, {basePath}) |
generic form | purpose-built form (lib.rs:923+) |
The three TS types treat basePath identically (?: never at types.ts:164, :183, :359) while the runtime treats two of four methods differently. The KNOWN RESIDUAL note at :152-161 documents the deferral honestly — but the deferral is what produces the divergence.
Fix: add both methods with the napi strings copied verbatim. assertKnownKeys then skips basePath automatically, so undefined passes and a non-null value still hard-errors:
const BASEPATH_REJECTORS = new Map([
['compileFile', makeFileBasePathError],
['checkFile', makeFileBasePathError],
['lintFile', makeLintFileBasePathError],
['lintVirtual', makeLintVirtualBasePathError],
] as const);then mirror the existing compileFile guard in node.ts:324/:330 and browser.ts:135. Cost is low: grepping packages/mds/__test__/*.mjs for both "not valid for lintFile" and "recognised keys are: vars, rules" finds no existing test asserting either — no golden churn. Extend U-OV-27 to cover the two new messages.
| // Use NUL as delimiter (matching getStagedFiles' -z output) so that git paths | ||
| // containing a literal LF do not split into two batch requests and | ||
| // desynchronize the response parser. | ||
| const stdin = Buffer.from(paths.map(p => `:${p}\0`).join(''), 'utf8'); |
There was a problem hiding this comment.
BLOCKING — --staged mode false-passes on any filename beginning with 0: — security, 98%, reproduced end-to-end (not inferred).
:<path> is git revision syntax, not a literal path. Git's revision parser checks whether the character after : is a stage digit followed by another colon — :0:foo means stage 0 of foo, not the file literally named 0:foo. A tracked file whose name starts with 0: is silently redirected to a different blob, and that different blob is what gets scanned.
$ printf 'CLEAN CONTENT\n' > payload.mds
$ printf 'HOSTILE\033[31m\n' > '0:payload.mds' # raw ESC 0x1B
$ git add -A
$ node scripts/verify-no-control-bytes.mjs --staged
✓ source-hygiene gate: Scanned 2 file(s), 28 byte(s) EXIT=0 ← FALSE PASS
$ node scripts/verify-no-control-bytes.mjs # full-tree, readFileSync
✖ 0:payload.mds: hazardous codepoint U+001B at byte offset 7
✖ source-hygiene gate FAILED — Scanned 2 file(s), 27 byte(s) EXIT=1
The byte counts are the tell: staged mode reports 28 bytes for two files whose real combined size is 27 — it read the 14-byte payload.mds twice and never opened the hostile file at all.
Blast radius is bounded. The authoritative full-tree scan uses readFileSync with no revision parsing and is wired into both ci.yml:363 and release.yml:40, so both catch this. What fails is the local pre-commit layer — defense in depth, and the layer that determines whether contributors trust the gate.
This refutes a claim in the PR description: "Neither new gate script can produce a false pass — both were run with planted positive controls." The positive controls are real and good, but they exercise scanBuffer/isHazardous, not the index-read path's revision parsing. There is no case in scripts/__test__/verify-no-control-bytes.spec.mjs for an ambiguous path. This is precisely the PF-013 shape the file's own D-CB7 header warns about.
Fix (removes the revision parser from the loop entirely): git diff --cached --raw already carries the index blob SHA in field 3 of every header. Address blobs by SHA, never by path.
// getStagedFiles(): capture the new-sha alongside the mode
const newSha = fields[3];
files.push({ path, sha: newSha, mode: newMode, skip, staged: true });
// readAllIndexBlobs(): request by SHA — no `:` prefix, no revision syntax
const stdin = Buffer.from(entries.map(e => `${e.sha}\n`).join(''), 'utf8');
// ...and key the result Map by path while iterating `entries` in the same order.Belt-and-braces alternative if the path form is kept: reject any staged path matching /^[0-3]:/ with exit 1. Either way, add the spec case that plants 0:payload.mds with a raw ESC and asserts exit 1 — the positive control this vector currently lacks.
| since = "0.4.0", | ||
| note = "prefer `LintConfig::from_rules_checked`, which returns the unknowns report \ | ||
| in one atomic call — all built-in callers have migrated" | ||
| )] | ||
| #[must_use] | ||
| pub fn from_rules(rules: HashMap<String, Severity>) -> Self { | ||
| LintConfig { rules } |
There was a problem hiding this comment.
LintConfig::from_rules is deprecated-since-birth and contradicts its own doc — architecture, 88%. Tag-admissible as a breaking change under ADR-010.
LintConfig is entirely new public API in v0.4.0 — git show v0.3.0:crates/mds-core/src/lint/config.rs → file does not exist; git grep "pub struct LintConfig\|from_rules" v0.3.0 -- crates/mds-core/src → nothing. The type has never shipped. Yet the constructor ships already-deprecated, while :273-276 blesses it as a supported escape hatch:
"
from_rulesremains available for external crates that have already performed the unknowns check separately; it will not be removed without a major-version bump."
An intentionally-supported path must not emit deprecation warnings. As written, every legitimate use of the documented escape hatch forces #[allow(deprecated)] — and the repo already has to do this to its own type in two places, crates/mds-core/tests/api_surface.rs:1091 and :1150, plus twice more in the doctest 10 lines above. The API cannot be exercised as documented without suppressing its own warning.
The tag freezes the contradiction: removing it later needs a major bump; keeping it ships a constructor that is both blessed and warned-against for the life of the 0.x line. Right now it has zero external callers by construction.
Fix (preferred, one line net): delete from_rules. Pre-checked external callers use let (config, _) = LintConfig::from_rules_checked(..). Drop the #[allow(deprecated)]/#[expect] scaffolding at api_surface.rs:1091/:1150 and in the doctest.
Fix (alternative): keep from_rules but remove the #[deprecated] attribute, keeping only the "Prefer from_rules_checked" prose at :273.
Shipping both as-is is the only option that should not survive the tag. Note the footgun is not the checked/unchecked pair — from_rules_checked returning (Self, Option<UnknownRuleNames>) is good design and #[must_use]. The footgun is shipping the unchecked half with two mutually exclusive contracts attached.
| python -m pip install --find-links dist --no-index mdscript | ||
| python -c "import mdscript; r = mdscript.compile('Hello {n}!', vars={'n': 'CI'}); print(r.kind, r.output)" | ||
| python -m pip install --find-links dist --no-index markdown-script | ||
| python -c "import markdown_script; r = markdown_script.compile('Hello {n}!', vars={'n': 'CI'}); print(r.kind, r.output)" |
There was a problem hiding this comment.
The wheel-install smoke test cannot fail, and it advertises dead syntax — python, HIGH, 95%. Line modified by this wave's rename commit.
Two defects in one line:
- No assertion. The step passes as long as
import+compiledo not raise.print()is not a gate. {n}no longer interpolates. After the{{x}}migration (feat!: double-brace {{x}} interpolation with lint --fix migration (v0.4.0) #237) single-brace is legacy and is not substituted. Verified against the installed wheel:m.compile('Hello {n}!', vars={'n':'CI'}).output -> 'Hello {n}!' # unchanged m.compile('Hello {{n}}!', vars={'n':'CI'}).output -> 'Hello CI!' # correct
So CI prints markdown Hello {n}! and goes green. This is the only step that exercises the fully-built-and-installed wheel end to end, and it would stay green if variable interpolation were completely broken in the wheel. It also prints the deprecated syntax as if it were the canonical example.
Not a tag blocker — nothing Python ships in v0.4.0 (crates/mds-python/Cargo.toml:14 publish = false, no PyPI/maturin step in release.yml). Gate it on #292/#132, before the first publish.
Fix:
python -c "import markdown_script as m; r = m.compile('Hello {{n}}!', vars={'n': 'CI'}); assert r.kind == 'markdown', r.kind; assert r.output == 'Hello CI!', repr(r.output); print('smoke ok:', r.output)"| `check()` and `checkFile()` accept `{ vars?, basePath? }` — source-map options | ||
| (`sourceMap`, `sourcesContent`) are not valid for check calls and are rejected with | ||
| `mds::invalid_options`. `CompileOptions` retains `sourceMap`/`sourcesContent`. | ||
| TS interface implementers: `check`/`checkFile` signatures narrow to `CheckOptions`. (#196) |
There was a problem hiding this comment.
The CHANGELOG states the opposite of the shipped checkFile contract — regression R-01, 97%. The single item worth gating the tag on from the regression lens.
Both sentences are now false:
packages/mds/src/node.ts:306—export function checkFile(path: string, options?: CheckFileOptions)— notCheckOptionspackages/mds/src/types.ts:183—CheckFileOptionsdeclaresbasePath?: neverpackages/mds/src/node.ts:307-313— passingbasePaththrowsmds::invalid_options, synchronously
The correct entry does exist at CHANGELOG.md:580-595 — but 385 lines later. A consumer reading top-down hits this stale #196 text first and is told to do exactly the thing that now throws, in a way .catch() does not intercept. Because the tag is irreversible, the wrong text is permanent in the 0.4.0 release notes.
Fix — amend the stale #196 entry in place:
- `check()` and `checkFile()` accept `{ vars?, basePath? }` — source-map options
+ `check()` accepts `{ vars?, basePath? }`; `checkFile()` accepts `{ vars? }` only
+ (see the `basePath` rejection entry below) — source-map options
...
- TS interface implementers: `check`/`checkFile` signatures narrow to `CheckOptions`. (#196)
+ TS interface implementers: `check` narrows to `CheckOptions`, `checkFile` to
+ `CheckFileOptions`. (#196, amended by #180)| - **`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. | ||
| - **`mds lint`** — 10-rule static analyzer for `.mds` templates (#61). Available |
There was a problem hiding this comment.
"10-rule" heading over a 9-bullet catalogue — documentation D1 (98%) and regression R-04 (94%) independently.
This wave changed the headline count but not the list beneath it:
-- **`mds lint`** — 9-rule static analyzer for `.mds` templates (#61). Available
+- **`mds lint`** — 10-rule static analyzer for `.mds` templates (#61). AvailableThe enumerated Rules catalogue at :123-131 still contains exactly nine bullets. main was self-consistent (9 bullets under "9-rule"); the branch is self-contradicting, and the inconsistency was introduced here.
The missing rule is legacy-interpolation — the tenth entry in crates/mds-core/src/lint/rules/mod.rs ALL_RULE_NAMES, dispatched at crates/mds-core/src/lint/mod.rs:134. It appears exactly once in the entire CHANGELOG (:676), as an instruction inside the {x}→{{x}} migration — never as a catalogued rule. It is the rule driving the single largest migration in this release.
The catalogue is the documented configuration surface for mds.json lint.rules. A user auditing which rules to disable never learns it exists, and with #224's new warn-on-unknown-name behaviour cannot discover the spelling by guessing — a typo produces a warning naming the recognised rules, but only at runtime.
Fix — add the missing bullet after :131, matching the catalogue's format:
- `legacy-interpolation` (warn): single-brace `{x}` interpolation, superseded by
`{{x}}` (Tier A — auto-fixed by `mds lint --fix`)| | `span`, `fix_edits` | **Raw byte offsets** into the unmodified source — deliberately not sanitized. These are numeric position values and must reflect the original source exactly. | | ||
|
|
||
| This invariant applies across all surfaces that emit `"version": 1` JSON: CLI | ||
| (`mds lint --format json`), napi (`lintVirtual` / `lint` / `lintFile`), WASM | ||
| (`lintVirtual` / `lint`), and Python (`lint_virtual` / `lint` / `lint_file`). | ||
| All four surfaces emit byte-identical values. | ||
| All four surfaces emit byte-identical values on the fields they share, with two exceptions. First, `"lint_warnings"` is a binding-surface-only key: it is absent from the CLI's `--format json` output (the CLI writes unknown-rule warnings to stderr instead). Second, the `"file"` key differs when the source is piped via stdin: `mds lint -` (CLI) relabels the internal virtual-FS key `"input.mds"` to `"<stdin>"` at the output boundary; the binding surfaces (`lintVirtual` / `lint` on napi, WASM, and Python) retain `"input.mds"`. All other fields — `message`, `help`, `rule`, `severity`, `span`, `fix_edits` — are byte-identical across all four surfaces. |
There was a problem hiding this comment.
This sentence is factually wrong about lintVirtual — and it is the only normative statement of the asymmetry — consistency C-1 (95%); documentation D2 (90%) flags a second defect in the same line.
C-1 — lintVirtual does not retain "input.mds". It emits the caller-supplied entry key. The repo's own golden proves it, crates/mds-napi/__test__/index.spec.mjs:1067-1075:
'…"span":{"length":10,"offset":4}}],"file":"main.mds"}],…';
const result = lintVirtual({ 'main.mds': '---\nunused_key: value\n---\nHello!\n' }, 'main.mds');Only the string-source lint(source) entrypoint emits input.mds — via mds::lint_str → lint_source(source, STRING_SOURCE_MAP_LABEL, …) (crates/mds-core/src/lib.rs:1216-1220, sourcemap.rs:79). lintFile/lint_file emit the real path.
Second defect in the same sentence: the parenthetical spells Python's methods in JS camelCase, while :1069-1070 two lines above correctly writes lint_virtual / lint / lint_file.
A surface-agnostic consumer keying diagnostics off result.files[].file gets four different values for "the source identity" of a non-file input, and the only place that says so is this paragraph — which is wrong about one of them.
D2 (avoids PF-015) — the closing "All other fields … are byte-identical across all four surfaces" is a universal quantifier in a normative document whose falsification is a review finding rather than a test failure. It is not mechanically enforced: the only live cross-surface byte comparison, crates/mds-python/tests/test_parity.py:254-278, deliberately uses a clean source (its own docstring says so), so with zero diagnostics message, help, rule, severity, span and fix_edits are never compared between the CLI and any binding at all.
Fix for C-1:
… the binding surfaces retain `"input.mds"` on their **string-source** entrypoint only
(`lint` on napi and WASM, `lint` on Python). `lintFile` / `lint_file` emit the supplied
path, and `lintVirtual` / `lint_virtual` emit the caller-supplied entry key.
Fix for D2: scope the claim to the enumerated set and name what pins it, noting the with-findings CLI-vs-binding comparison is not currently covered by a live differential test.
| )) | ||
| { | ||
| return; | ||
| } |
There was a problem hiding this comment.
The diagnostic is emitted as a short-circuit side effect of &&, now inconsistent with its sibling arm — complexity A3, 90%.
if !is_used
&& !builder.push(make_diag(severity, filename, format!(…), Some(…), imp.offset, "@import".len()))
{
return;
}Nothing marks this line as the emit point. Insert one more predicate after !is_used — the single most likely future edit to this rule — and the diagnostic silently stops being emitted, with no compiler complaint.
This diff already converted the identical construct in the Selective arm to the readable nested form (:137-147), so one 113-line function now spells the same operation two ways.
Fix (4 lines, zero behaviour change):
if !is_used {
if !builder.push(make_diag(…)) {
return;
}
}For the record, the rest of this rule came back clean: the name_offsets.get(i) degradation is written as a total match with an explicit rationale for why it is neither an expect nor a debug_assert! (:119-129, cites PF-005/PF-012), and it degrades offset and length together — the correct choice.
| hazardHits.push({ | ||
| path: entry.path, | ||
| codepoint: hit.codepoint, | ||
| byteOffset: hit.byteOffset, | ||
| hexCtx: hexContext(buf, hit.byteOffset), | ||
| }); |
There was a problem hiding this comment.
Unbounded hazard-hit accumulation — a denial of service against the gate itself — reliability, 100%, reproduced empirically.
scanBuffer returns one record per hazard occurrence and the caller retains every one, each carrying a freshly allocated ~54-char hexCtx. Nothing caps hits per file, hits per run, or lines printed — :637 (const hazardHits = []), this push, and the report loop at :742-746. The NUL short-circuit at :469-475 does not protect this path: a file can be hazard-dense without a single NUL byte (lone-CR legacy text, dense C1, a non-NUL binary blob).
Measured on a 2 MB file of 0x01 in a throwaway git repo:
exit=1
stderr lines: 4,194,323
stderr bytes: 275,713,558
peak RSS: 569,125,184 (569 MB)
wall clock: 14.54 s
~285x memory amplification over input size. Extrapolating: a ~20 MB file OOM-kills Node; a ~50 KB file already emits 100k stderr lines. In the pre-commit hook that stalls the developer's terminal and floods scrollback; in the source-hygiene CI job and in release.yml's version-gate it produces hundreds of MB of Actions log.
Fail direction is closed (Node OOM aborts non-zero), so this is not a fail-open — but it violates the project rule that every loop, retry and resource has an explicit bound. This repo already solved exactly this problem for lint: mds::MAX_DIAGNOSTICS caps at 1,000 and sets truncated.
Fix — mirror the lint cap, ~8 lines:
const MAX_HITS_PER_FILE = 20;
const MAX_HITS_TOTAL = 1000;
let suppressedHits = 0;
// …then report `${suppressedHits} further hazardous codepoint(s) suppressed` after the loop.Truncating output cannot weaken the gate — one retained hit is already sufficient to exit 1.
| /// nested path `sub\d.mds` using the native separator (0x5B < 0x5C), but AFTER | ||
| /// it with the emitted forward slash (0x5B > 0x2F), reversing the array order | ||
| /// relative to the emitted key order. | ||
| fn relative_display(path: &Path, root: &Path) -> String { |
There was a problem hiding this comment.
A shipping, documented wire break with zero test executions on the platform it describes — rust (88%) and testing S1 (92%) independently.
The PR ships this as a breaking wire change: "On Windows, separators are now normalized to forward slashes — a path that appeared as sub\c.mds now appears as sub/c.mds. A consumer splitting on \ will silently stop matching."
Coverage, checked three ways:
.github/workflows/ci.yml:22-23—Rust — fmt, clippy, testisubuntu-latestonly, andrelative_displayis reachable only fromcrates/mds-cli/tests/*.rsand this file's#[cfg(test)]module, so the entire mds-cli test surface is Linux-only.- The two unit tests are Unix-scoped by construction:
relative_display_preserves_literal_backslash_on_unix(:1969) and its control-byte sibling (:2022).crates/mds-cli/tests/cli_lint.rs:2036states outright that there is no#[cfg(windows)]sibling. - The Windows CI matrices (
JS packages,Python — build & test) do build the CLI, but every Windows CLI invocation is single-file or stdin (test_parity.py:267,316,407,488;packages/mds/__test__/lint.spec.mjs:251,400).mds lint <dir>never runs on Windows anywhere, and directory mode is the only caller ofrelative_display.
The code is right; only the evidence is missing. collect_mds_files_inner (output.rs:432-433) builds every entry as dir.join(name), so strip_prefix(root) always succeeds and rel contains only Component::Normal — which on Windows can never contain \ or /. Using .replace('\\', "/") instead would have been a CWE-22 traversal vector on Unix, and the rustdoc names that vector. PF-003/#133 bit this repo on exactly this shape before.
Fix, cheapest first — neither is a code change:
(a) A platform-independent ordering unit test over the emitted strings, closing the rationale at :313-319 (bytes 0x30..=0x5B sort between / 0x2F and \ 0x5C, so "sub[abc.mds" must sort after "sub/d.mds"). Runs on the existing ubuntu job.
(b) One directory-mode case in packages/mds/__test__/lint.spec.mjs, which already spawns the CLI (findMdsCli()) and runs on windows-latest: assert no files[].file contains a backslash, and that files[] is in ascending byte order.
If neither fits the tag window, record the gap against the existing #147/#148 Windows-CI trackers so it is not rediscovered after the crates.io publish is irreversible.
| * derived from the file path). The fields are declared directly so that adding | ||
| * a new string-surface option never implicitly appears on the file surface. | ||
| */ | ||
| export interface FileOptions { |
There was a problem hiding this comment.
FileOptions is missing its Compile prefix — this diff completes the family and makes the gap permanent — consistency C-2, 92%. 🔒 published TS type surface.
| Operation | String surface | File surface |
|---|---|---|
| compile | CompileOptions (:120) |
FileOptions (here) |
| check | CheckOptions (:95) |
CheckFileOptions (:176, new) |
| lint | LintOptions (:311) |
LintFileOptions (:342) |
FileOptions predates this branch, but until now it was the only file-surface option type, so there was no family to be asymmetric with. CheckFileOptions creates the family and makes the gap load-bearing. It is now visible verbatim in three places added by this diff — util/options.ts:79-87 (OptionsFor, where it breaks the column), the three @ts-expect-error comments at __test__/types/consumer-node.ts:69-75, and mirrored into napi (FileOpts/parse_file_opts at crates/mds-napi/src/lib.rs:566 beside CheckFileOpts at :610 and LintFileOpts at :898).
FileOptions is already on npm from v0.1–v0.3 so it must survive as an alias regardless. v0.4.0 is the last release where introducing the symmetric name costs nothing; after the tag, CheckFileOptions/LintFileOptions are permanently paired with a sibling that does not match them.
Fix — non-breaking, additive, ~10 lines:
export interface CompileFileOptions { /* current FileOptions body */ }
/** @deprecated Renamed to {@link CompileFileOptions} in v0.4.0 for symmetry with
* CheckFileOptions / LintFileOptions. Retained as an alias; will be removed in v1.0.0. */
export type FileOptions = CompileFileOptions;Export both from node.ts:348-370, update OptionsFor.compileFile, and rename the private Rust identifiers FileOpts → CompileFileOpts, parse_file_opts → parse_compile_file_opts (private — zero semver cost).
| /// Under --quiet the summary is suppressed on a warn-only or clean run, but is | ||
| /// always printed when error- or resource-limited files are present so the non-zero | ||
| /// exit is never unexplained. --quiet also suppresses the `fix rejected:` and |
There was a problem hiding this comment.
The clap help asserts a --quiet guarantee that lint does not provide — consistency C-4, 92%.
The two clauses of this sentence contradict each other. A warn-only directory run exits 1 — FileTally::WarnOnly = 1 and exit_code(self) = self as i32 (lint.rs:1187-1198), reached via max_tally.exit_code() at lint.rs:1417-1419 — while the summary gate at lint.rs:1405 is:
if !quiet || error_file_count > 0 || limit_file_count > 0 {So warn-only + --quiet = exit 1 with zero stderr. The non-zero exit is unexplained. The rustdoc 90 lines away states this correctly (lint.rs:1226-1227: "mds lint --quiet <dir> on a warn-only tree exits 1 with no output"), but long_about is the text users actually read.
Cross-subcommand — --quiet has two distinct meanings:
| Command | can --quiet yield a non-zero exit with zero stderr? |
|---|---|
build <dir> (build.rs:1645) |
no |
check <dir> (main.rs:371) |
no |
fmt <dir> write (fmt.rs:354) |
no |
fmt --check <dir> (fmt.rs:349) |
YES — exit 1 on would-reformat |
lint <dir> warn-only (lint.rs:1405) |
YES — exit 1 |
lint --fix --check <dir> (lint.rs:1416) |
YES — exit 1 |
Fix:
/// Under --quiet the summary is suppressed on a clean or warn-only run and printed
/// when error- or resource-limited files are present. A warn-only run therefore exits
/// 1 with no stderr under --quiet — same as `mds fmt --check --quiet`. Drop --quiet if
/// a script needs the exit code explained.
| // {limit} resource-limited`. Fixed arity, comma-separated; matches the sibling | ||
| // shape used by `build`/`check`/`fmt` and keeps the line greppable across all | ||
| // summary-emitting subcommands. | ||
| if !quiet || error_file_count > 0 || limit_file_count > 0 { | ||
| eprintln!( | ||
| "{clean_count} clean, {warn_file_count} with warnings, \ | ||
| {error_file_count} with errors, {limit_file_count} resource-limited" | ||
| ); |
There was a problem hiding this comment.
The new lint summary breaks the sibling shape this very comment claims to match — consistency C-3, 90%. 🔒 stderr contract, now normative at spec.md:958-959.
The comment claims the format "matches the sibling shape used by build/check/fmt and keeps the line greppable across all summary-emitting subcommands." The four emitters:
| Subcommand | Emitter | Site |
|---|---|---|
build <dir> |
{ok} built, {fail} failed |
build.rs:1646 |
check <dir> |
{ok} passed, {fail} failed |
main.rs:372 |
fmt <dir> |
{changed} formatted, {unchanged} unchanged, {fail} failed |
fmt.rs:355 |
fmt --check <dir> |
{changed} would reformat, {unchanged} unchanged, {fail} failed |
fmt.rs:350 |
lint <dir> |
{clean} clean, {warn} with warnings, {error} with errors, {limit} resource-limited |
here |
Every sibling terminates in N failed. Lint contains no failed token, and is the only one using multi-word bucket labels where the others use single-word past participles. A CI script running grep -oE '[0-9]+ failed' across mds build/check/fmt output returns nothing for mds lint — silently, with exit 0 from grep's perspective in a pipeline.
Fix — pick one:
- Align the error bucket:
{clean} clean, {warn} with warnings, {error} failed, {limit} resource-limited. This also matches the D3-a rationale at:1216-1218, which explicitly says the "with errors" bucket makes "the same conflationmds build's failed bucket makes". - Or delete the inaccurate clause "and keeps the line greppable across all summary-emitting subcommands" from
:1402-1403, and add a note tospec.md:958-959that lint's summary vocabulary is deliberately distinct.
Option 1 is preferable while the string is still unpublished.
| #[deprecated( | ||
| since = "0.4.0", | ||
| note = "use `apply_fixes_incremental`; not a drop-in swap, the reverify closure must \ | ||
| be `Fn`, not `FnOnce`. To be removed in v0.5.0; see the item docs." | ||
| )] |
There was a problem hiding this comment.
The #[deprecated(note)] carries the hazard that self-announces and omits the one with no compiler signal — documentation D4, 92%.
The note names hazard (a), the FnOnce→Fn bound tightening. That hazard already self-announces: it is a hard compile error the moment a caller swaps the function.
It omits hazard (b): apply_fixes_incremental can return FixOutcome::PartiallyFixed, which apply_fixes never did. A _ => {} arm compiles clean and drops partial fix results at runtime — no compiler signal at all. #[must_use = "a dropped FixOutcome silently discards the fix result"] at :736 does not close this: must_use fires on a dropped value, not on a _ => match arm.
The note string is what a downstream #![deny(warnings)] consumer sees in their build output, and it is the one place that mentions only the hazard they would have found anyway. The trailing "see the item docs." is the only pointer to migration delta #2. The item rustdoc at :683-688 documents (b) correctly and precisely — this is solely about the compiler-visible note.
Related, same root cause (D5, 90%): FixOutcome's enum doc at :263 tells external callers they must include a _ => {} wildcard arm — the exact pattern that triggers hazard (b) — and the two statements never cross-reference each other. Worth one sentence at :263 naming PartiallyFixed explicitly.
Fix:
note = "use `apply_fixes_incremental`; not a drop-in swap. (1) the reverify closure must be \
`Fn`, not `FnOnce`; (2) it can return `FixOutcome::PartiallyFixed`, which a `_` match \
arm silently discards. To be removed in v0.5.0; see the item docs."Deferrable to 0.4.1: crates/mds-core/src/lint/fix.rs does not exist at tag v0.3.0, so no external caller can hit either hazard today.
| # Scoped to this job only (not repo-wide rust-toolchain.toml) to limit blast radius. | ||
| # WHEN BUMPING: rebuild, re-measure with the new compiler, and update the | ||
| # budget-history ledger comment above with the new baseline before changing the pin. | ||
| - uses: dtolnay/rust-toolchain@1.96.0 |
There was a problem hiding this comment.
The new pin resolves to a mutable branch, not a tag or a SHA — dependencies, 92%.
$ gh api repos/dtolnay/rust-toolchain/git/ref/tags/1.96.0
{"message":"Not Found","status":"404"}
$ gh api repos/dtolnay/rust-toolchain/branches/1.96.0
1.96.0 -> 01ba1edad32c6f80dbcce879d3e0fa5a00b2a84e
Branches are mutable, so the action code executing in this job — the only job that runs the 850,000-byte WASM size guard — can change at any time without a commit here. The stated purpose of the pin is that compiler drift must not "silently change the wasm-opt output size independently of any source change"; a mutable installer ref reintroduces exactly that class of uncommitted drift one layer up. The pin constrains the toolchain while leaving the installer floating.
The repo already demonstrates the right pattern — .github/actions/setup-wasm/action.yml:9,12 SHA-pins both third-party actions with a human-readable version comment.
Calibration: not a regression in kind. @stable, @1.88, Swatinem/rust-cache@v2 and mlugg/setup-zig@v2 are all already mutable refs on main. This does not block the tag.
Fix:
- uses: dtolnay/rust-toolchain@01ba1edad32c6f80dbcce879d3e0fa5a00b2a84e # 1.96.0Cooldown itself is compliant: 1.96.0 published 2026-05-28T17:50:42Z — 83 days of soak. Nitpick on :70: the comment says "released 2026-05-25", off by 3 days. The conclusion is unaffected, but that comment is the load-bearing written record of the cooldown decision.
The reason this line matters most: the pin was added to ci.yml and not to release.yml:262, which still uses @stable — and release.yml is the job that builds the WASM that ships. See the summary comment (R5).
| * and the verifier would emit a PASS. EXPECTED_CONTEXTS closes this gap by | ||
| * asserting presence, mirroring Tier A semantics. | ||
| */ | ||
| export const EXPECTED_CONTEXTS = ['Source hygiene']; |
There was a problem hiding this comment.
EXPECTED_CONTEXTS closes the absence gap for 1 of 4 non-required CI jobs — reliability, 85%; corroborated by testing S2 (88%).
The file's own docstring at :30-35 states the reason Tier A+ exists:
"Tier B alone cannot make it binding because Tier B only iterates runs that ALREADY EXIST in the check-run list; an absent job has nothing to iterate. Tier A+ fills this gap by asserting presence (applies ADR-009, avoids PF-013: absence is never evidence of success)."
That reasoning is correct and applies verbatim to three more jobs. Verified against the live branch-protection payload (exactly 6 required contexts) and ci.yml job names:
ci.yml job |
In branch protection? | In EXPECTED_CONTEXTS? |
|---|---|---|
Source hygiene |
no | yes |
Python — build & test |
no | no |
examples/ gitignore coverage |
no | no |
Python — wheel install smoke |
no | no |
Failure scenario: someone adds a paths: filter, renames, or deletes one of those three. It vanishes from the check-run list — Tier A never named it, Tier A+ never named it, Tier B has nothing to iterate — so evaluateChecks returns exitCode: 0 and prints the --admin --match-head-commit merge command. This branch is a live rehearsal: it renames the Python distribution mdscript → markdown-script and rewrites both Python CI jobs (ci.yml:330-331). The job names happened not to change; nothing in the gate would have caught it if they had.
Fix — one line:
export const EXPECTED_CONTEXTS = [
'Source hygiene',
'Python — build & test',
'examples/ gitignore coverage',
'Python — wheel install smoke',
];The em-dash in Python — build & test is U+2014 — copy it from ci.yml, do not retype it. Better still: promote all four to required contexts in branch protection so Tier A covers them and this hardcoded list can shrink back to zero.
complexity-01 (F-01): single-file `--fix --check --format json` called `std::process::exit(1)` inside the `WouldFix` arm without first calling `emit_result`, making the downstream emit unreachable and producing zero bytes on stdout. Fix: call `emit_result` immediately before the exit, mirroring directory mode (AC-F-14 / issue #36 twin). complexity-02 (F-14): in `lint_one_file_accumulating`, both the `Fixed` and `PartiallyFixed` arms called `accumulate_result_json` BEFORE `atomic_write_file`. On a write failure the clean post-fix result was already in the envelope (consumer saw `{"files":[],...}` — reads as clean) while exit was 2. Fix: write first; on failure push a structured `{"file":…,"error":…}` entry matching the read-failure shape. complexity-09: all three `PartiallyFixed` arms (single-file, dir-JSON, dir-human) printed "Partially fixed: …" BEFORE calling `atomic_write_file`, so on a write failure the operator saw a success label followed immediately by an error. Fix: print after write, matching the already-correct `Fixed:` arms. Tests: adds `file_fix_check_json_emits_parseable_json_before_exit_1` (single-file twin of the existing dir test) and `file_fix_json_dir_write_failure_emits_structured_error_not_stale_result` (unix-gated, read-only dir triggers the F-14 path). All 114 cli_lint tests and 11 print_discipline tests pass. TASK_ID: resolve-b2a-lint-output Co-Authored-By: Claude <noreply@anthropic.com>
…rrect soak date
python-01: Replace vacuous wheel smoke test (no assertion, legacy {n} syntax) with
an asserting command that uses double-brace {{n}} interpolation and verifies both
r.kind == 'markdown' and r.output == 'Hello CI!' so interpolation regressions are
actually caught.
dependencies-02: SHA-pin all dtolnay/rust-toolchain refs in ci.yml using SHAs
resolved via gh api repos/dtolnay/rust-toolchain/git/ref/heads/<version>:
stable → 4360b52568e2003a75bf9bc1d59f33a8e3fc893c
1.88 → 2eae45db285e407f22119950686d47e1101e071b
1.96.0 → 01ba1edad32c6f80dbcce879d3e0fa5a00b2a84e
Each pin carries a trailing # <version> comment matching the setup-wasm convention.
The js job's @stable ref is intentionally left unpinned (see performance-06).
dependencies-03: Correct the 1.96.0 release date from 2026-05-25 to 2026-05-28
in the load-bearing soak decision comment.
performance-06: Add a DELIBERATE comment to the js job explaining that @stable is
intentionally not pinned — it provides the sole CI coverage of the stable/released
compiler building the native addon on all three host OSes.
Co-Authored-By: Claude <noreply@anthropic.com>
…admes)
Fixes eight documentation issues classified FIX_NOW by the Triage agent:
consistency-01/documentation-02 (spec.md:1071): Correct wrong claim that
lintVirtual/lint_virtual retain "input.mds" — only the string-source lint()
entrypoint does; lintVirtual/lint_virtual emit the caller-supplied entry key.
Fix Python method casing to snake_case (lint_virtual). Scope the byte-identical
claim to what is mechanically enforced, noting the parity test uses a clean
source with no findings.
regression-06 (spec.md:1071): Add one sentence documenting the source-map
stdin-relabeling asymmetry — mds build - replaces "input.mds" with "<stdin>"
in sources[]; binding surfaces always carry "input.mds" or the caller-supplied
entry key.
consistency-14 (spec.md:898): Document mds check directory-mode summary
string ("N passed, N failed") and its --quiet suppression rule, which was
documented for build and lint but missing for check.
regression-08 (packages/mds/README.md): Add note that basePath: '' (empty
string) is rejected — not treated as absent; callers should omit the key.
documentation-11 (packages/mds/README.md): Hoist sync-throw caveat to cover
all option-validation errors (not just basePath for compileFile/checkFile);
name all three Promise-returning file-path methods (compileFile, checkFile,
lintFile) where .catch() does not capture synchronous validation errors.
consistency-06 (all four surface READMEs): Add one-line note in each lint
section documenting the files[].file values per surface and the CLI-vs-binding
asymmetry.
python-09 (crates/mds-python/README.md): Retarget issue link to reference
both #292 (rename + name registration) and #132 (wheel matrix + PyPI
publishing pipeline) with their correct scopes.
Co-Authored-By: Claude <noreply@anthropic.com>
regression-01 (F-10): Correct stale #196 entry — check() accepts { vars?, basePath? } while checkFile() accepts { vars? } only via CheckFileOptions (basePath?: never). TS implementers must narrow check to CheckOptions, checkFile to CheckFileOptions. Fold in testing-04 residual: warn that diag.span !== undefined is no longer a sufficient guard now that help/span are | null. documentation-01 (F-12): Add missing legacy-interpolation bullet to the 10-rule catalogue — the rule was enumerated in code but absent from the CHANGELOG list, leaving a count of 9 that contradicted the "10-rule" headline. documentation-03: Add ### Removed section for packages/mds/src/index.ts deletion. The file was not in the package exports map so no supported import path is affected; notes the seven internal backend types that were only reachable via that path. regression-07: Replace vacuous "TypeScript interface implementers" warning with a factual note that MdsBaseBackend/MdsNodeBackend are internal types defined only in the deleted index.ts and never reachable via @mdscript/mds published imports. documentation-08: Move MdsError::source_name() and MdsError::is_string_source() from the ### BREAKING interpolation section to ### Added — both are purely additive public methods. Co-Authored-By: Claude <noreply@anthropic.com>
… basePath; native.ts depth guard
Resolves typescript-01 (absorbs consistency-05 and consistency-19) and typescript-03.
typescript-01: lintFile and lintVirtual now emit purpose-built basePath errors
byte-identical to napi (parse_lint_file_opts / parse_lint_virtual_opts) instead
of falling through to the generic "unknown option key" message. Adds two new
error factories (makeLintFileBasePathError, makeLintVirtualBasePathError) and
registers them in BASEPATH_REJECTORS so assertKnownKeys skips basePath for these
methods (allowing {basePath: undefined}) while getBasePathError still hard-errors
on any non-undefined value. Deletes the superseded KNOWN RESIDUAL comment.
consistency-19: tightens getBasePathError guard from `!= null` to `!== undefined`
so explicit {basePath: null} is rejected for all four BASEPATH_REJECTORS methods,
achieving parity with napi's has_named_property gate which fires for any present
key regardless of value.
typescript-03: adds defense-in-depth basePath guards to createNativeBackend's
compileFile and checkFile methods (PF-004 pattern from wrapWithFileOps).
Tests: extends U-OV-27 to cover all four file-surface methods with per-method
wrapperFn/addonFn/wasmScript cases; adds U-OV-36 for {basePath: null}
wrapper-vs-napi byte parity across all four methods. 303 tests, 0 failures.
Co-Authored-By: Claude <noreply@anthropic.com>
…ntrol-bytes
security-01 (PF-024, MERGE-BLOCKING): readAllIndexBlobs built its git
cat-file --batch request as `:${path}`. A staged file named "0:payload.mds"
became ":0:payload.mds", which git parses as stage-0 revision syntax for
"payload.mds" and returns a DIFFERENT (clean) blob — a false pass. Fix:
address blobs by SHA captured from git diff --cached --raw field 3 (never
by :<path> revision syntax). Defense-in-depth: also reject paths matching
/^[0-3]:/ in getStagedFiles() with exit 1.
security-02: getTrackedFiles ran git ls-files -sz scoped to process.cwd()
with no root check, so running from a subdirectory silently scanned a
partial tree and printed an identical pass line. Fix: resolve repo root via
git rev-parse --show-toplevel, exit 1 if cwd differs, and include the
scanned root in the pass line so the output is self-describing.
complexity-07: out.slice(pos, pos + size) validated only NaN/negative;
it never checked pos + size <= out.length. Buffer.slice clamps silently,
so a truncated cat-file response would scan a short-but-valid blob and
pass (fail-open). Fix: assert the bounds; log path/declared-size/available
bytes and process.exit(2) if violated.
Three spec cases added, each with a before/after observation for PF-013
compliance. security-01 confirmed false-pass (exit 0) before the fix.
Co-Authored-By: Claude <noreply@anthropic.com>
reliability-02: EXPECTED_CONTEXTS expanded from 1 to 4 entries; adds
'Python — build & test', 'examples/ gitignore coverage', and
'Python — wheel install smoke'. Tier A+ updated to support matrix-style
prefix matching ('ctx (' prefix) so the single base job name covers all
6 Python matrix variants without listing each one. Tier B skip logic
updated to exclude matrix variants of Tier A+ contexts.
complexity-08: main() arg parsing rewritten — proper loop that excludes
--required-from's value from the PR number search; unknown flags are
rejected (not silently ignored). evaluateChecks() gains prNumber so the
emitted merge command is 'gh pr merge <N> --squash --admin
--match-head-commit <sha>', unambiguous regardless of current branch.
reliability-10: Tier B silent-drop of completed+null conclusion removed.
The null case now falls through to the whitelist check and fails closed
(absorbed by security-13 whitelist).
security-13: Tier B inverted from blacklist to whitelist — only
'success' passes; skipped, neutral, null, and any future GitHub
conclusion value now FAIL. Matches Tier A semantics. Updates two
existing tests that described the old advisory behaviour.
security-11: encodeURIComponent added at all three URL construction
sites (fetchCheckRuns headSha, fetchStatuses headSha,
fetchRequiredContexts branch). branch falls back to baseBranch from PR
API response (network-derived data).
14 new regression tests added; 2 existing tests updated to reflect the
whitelist change. All 139 gate tests pass.
Co-Authored-By: Claude <noreply@anthropic.com>
…s-05 cast, ts-07 prose consistency-02: Add CompileFileOptions interface (symmetry with CheckFileOptions / LintFileOptions). FileOptions kept as @deprecated type alias so existing consumers keep compiling without changes. OptionsFor.compileFile, MdsNodeBackend.compileFile, and all internal call-sites updated to the new name; CompileFileOptions added to the node.ts re-export list. browser.ts is unchanged — file-surface types are node-only. Rust FileOpts (private type alias in mds-napi/src/lib.rs) is out of scope per the allowed-files constraint; noted in implementation report. typescript-04: Delete the MdsBackend type alias. It was exported from neither node.ts nor browser.ts, so consumers could not reach it. Its @deprecated note pointed at MdsNodeBackend, also unexported. Leaving the end-state (project quality rule: strip transition residue). typescript-05 (wasm.ts:448): Drop `as _WasmCompileInput` assertion on compileOpts(options). CompileOptions is structurally compatible with _WasmCompileInput (all members optional; variable — not fresh literal — so no excess-property check). The sibling check() call already passes without a cast. typescript-07 (types.ts LintDiagnostic.span): Soften the doc prose from "key always present; only value is null" to "may be absent or null", matching the span?: declaration. Reviewers explicitly ruled against dropping ? before the tag (testing-04 adjudication); prose now matches the type. README:212 carries the same contradiction — that file is owned by another agent (noted). Co-Authored-By: Claude <noreply@anthropic.com>
performance-01 (F-04): - Pin dtolnay/rust-toolchain in publish-npm to the same 1.96.0 SHA (01ba1edad32c) that ci.yml's wasm job uses, eliminating the compiler drift that let the published WASM binary grow unchecked. - Add post-build WASM size check (850,000-byte threshold, mirroring ci.yml exactly) before the first npm publish step as defence-in-depth if the pins ever drift again (PF-021). reliability-07: - Add --max-time 20 and --connect-timeout 10 to the crates.io index poll curl invocation so each of the 20 attempts has an explicit wall-clock bound (project reliability rule: every loop and resource must have a fixed bound). reliability-12: - Add "Assert tagged SHA has green CI history" step to version-gate (tag pushes only). Reads required contexts from main branch protection (D-PR2/ADR-009 anti-vacuous-green), fetches check-runs for github.sha via bounded pagination (3 pages × 100), and fails closed on any context that is not completed+success (PF-017: CANCELLED/skipped/in_progress are never success). Adds checks: read permission to the version-gate job. compliance-03: - Add publish = false to crates/mds-napi/Cargo.toml and crates/mds-wasm/Cargo.toml to prevent an accidental `cargo publish --workspace` from pushing non-crates.io crates to the irreversible registry. Mirrors the pattern already in crates/mds-python/Cargo.toml. Does not touch mds-wasm's explicit license/repository fields (wasm-pack compatibility). Co-Authored-By: Claude <noreply@anthropic.com>
…solve-b6b-config-rs) architecture-01 (F-09, TAG-BLOCKING) — chose option (a): delete LintConfig::from_rules entirely. The function has never shipped (absent from v0.3.0) and has no external callers — mds-napi, mds-wasm, mds-python, and mds-cli all call from_rules_checked. Deleting it leaves the end-state, not the transition. The consistency-10 pairing with fix.rs is resolved by deletion (no alignment needed). Callers in api_surface.rs at :1089-1092 and :1148-1151 drop their #[expect(deprecated)] scaffolding and call from_rules_checked. The unfulfilled_lint_expectations count in the fix_api_apply_fixes_exists doc is updated from 11 to 10 (api_surface no longer contributes one). documentation-09 — soften the brittle published doctest in find_unknown_rule_names from assert_eq!(KNOWN_LINT_RULES.len(), 10) to assert!(!KNOWN_LINT_RULES.is_empty()). The doctest demonstrates find_unknown_rule_names, not the rule count; a future rule #11 would have broken cargo test --doc. documentation-10 — merge the two consecutive duplicate paragraphs on UnknownRuleNames (#[non_exhaustive] / no-struct-literal) into one, keeping the ADR-010 citation and accessor guidance. complexity-11 — add warning_plural_byte_identical_to_format_equivalent test that pins the plural branch of format_unknown_rule_names_warning to byte-equality with the canonical format!/join equivalent. The singular branch was already pinned via assert_eq! in warning_singular_names_rule_and_full_registry; this closes the gap. complexity-03 — restructure the Alias arm in unused_import::check from the `&&` short-circuit form to a nested-if with an intermediate `let diag = make_diag(...)` binding. This matches the Selective arm's shape (which has an intermediate let binding for name offsets) and makes it explicit that the diagnostic push is unconditionally executed whenever is_used is false — inserting a predicate between them would be syntactically visible. Zero behaviour change; all existing tests pass. Verification: cargo fmt --all --check (0), cargo clippy -p mds-core --all-targets -- -D warnings (0), cargo nextest run -p mds-core (1347/1347), cargo test --doc -p mds-core (all pass). grep 'since = ' crates/**/*.rs: one hit — fix.rs:746 (0.4.0, valid). Co-Authored-By: Claude <noreply@anthropic.com>
… consistency issues Consistency-07/21: move the six BREAKING sections to lead the [Unreleased] section (matching the [0.3.0] precedent), ordered by blast radius: 1. lint JSON wire contract (#202, #203, #211) 2. Error/lint messages \uXXXX literals (#176) 3. Options validation, directory walker, source-map labels, check API (#196) 4. File-method basePath rejection, TS option types, WASM basePath (#180, #213) 5. Interpolation syntax {x} -> {{x}} (#236) 6. Strict cross-type comparisons, @extends FM, interior-verbatim, filesystem (#146, #150, #151, #152, #154) Consistency-07 secondary: add inline issue refs to the three BREAKING headings that previously pushed refs only into #### sub-headings. Consistency-08: fix "see the BREAKING subsection below" in ### Fixed — the referent is now above; replace with anchor link. Consistency-15: replace the sole ' -- ' (line 334 before reorder) with an em dash, matching the 130 em dashes used elsewhere in the file. Documentation-13: soften "The stdin source identity is **always** <stdin>" to "(CLI only — see below)" to prevent skimmers from missing the binding- surface qualification 14 lines later. Regression-02: append "Fixed: <path> and Would fix: <path> now emitted in JSON directory mode" to the lint JSON wire-change ledger — these eprintln! calls in lint.rs:1555/1632 break zero-stderr CI assertions silently. Regression-05: add a ### Fixed bullet noting that LintDiagnostic.to_dict() now conditionally includes "line"/"column" in the span object, and LintResult.files[] now parses these fields; no built-in rule sets them so live-lint output is unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
…, test coverage compliance-06: minimize protection-main.json fixture to only the fields fetchRequiredContexts actually reads (required_status_checks.contexts and checks[].context). Removes enforce_admins, required_signatures, pr-review settings, and app_id beyond the fixture — all were unreachable by any code path, while the admin-policy fields were actively disclosing security posture. compliance-07: declare .devflow/docs/design/ and .devflow/docs/reviews/ as deliberately shared via explicit .gitignore negation patterns. Closes the gap where four tracked files lived under a directory with no declared exception — the policy was enforced by accident, not by the ignore file. architecture-07: fix misdescribing comment in packages/mds/src/util/options.ts. The comment claimed "updating the expected strings in the byte-comparison tests" but no such strings exist; `:56` is a substring check. The guard that actually enforces key order is the U-OV-14 / U-OV-31 wrapper-vs-napi strictEqual message comparisons. architecture-08: add direct unit tests for fetchCheckRuns exercising the MAX_PAGES=20 bound (call count pinned to 20 — fails if cap raised or removed) and the total_count guard, without going through main(). Adds fetchCheckRuns to the spec imports. Note: dependencies-01 + performance-04 (Cargo.toml duplicate comments and provenance qualifiers) were already addressed in commit 9103536 — confirmed no-op. Co-Authored-By: Claude <noreply@anthropic.com>
reliability-01: add MAX_HITS_PER_FILE (20) and MAX_HITS_TOTAL (1000) caps
to the hazardHits accumulation loop in main(). A 2 MB file of 0x01 bytes
empirically produced 569 MB peak RSS and 275 MB of stderr at uncapped
output (285x amplification). One retained hit is already sufficient to
exit 1, so the cap cannot weaken the gate. Truncation is made visible via
a suppressed-count notice in the failure report.
security-03: restructure scanBuffer() to treat 0x00 as an allowlisted
codepoint for BINARY_ALLOWLIST entries rather than early-returning from the
whole file. The previous early return silently disabled all 21 hazard
classes (bidi, C1, BOM, etc.) for any binary-allowlisted file — semantics
the D-CB6 header never documented, arming silently on the first legitimate
entry added.
security-12: add gitVersion() / MIN_GIT_MAJOR / MIN_GIT_MINOR constants
(mirrors the verify-pr-checks.mjs MIN_GH_MAJOR pattern) and check the
installed git version before entering --staged mode. git cat-file
--batch -z requires git >= 2.42; an older git exits 129 with no useful
message. Fail-closed exit 2 (indeterminate) is preserved; this is a
diagnosability fix only.
Tests: 3 new spec cases (146 total, 0 failures).
- reliability-01: hazard-dense fixture, cap exercised, suppressed notice visible
- security-03: NUL-bearing allowlisted file + bidi override exits 1
(before fix: exits 0; after fix: exits 1 — ADR-009 observed)
- security-12: fake old git exits 2 with version diagnostic
Co-Authored-By: Claude <noreply@anthropic.com>
security-04/regression-03/rust-02: Correct relative_display rustdoc —
the three contradicting lines claimed error-only entries push the raw
display path, but all six push sites use the pre-sanitized file_key.
Rewritten to state that both entry types carry identically-sanitized
file values (ADR-008).
rust-03/reliability-09/complexity-10: Rewrite self-contradicting
debug_assert comment — "in production code" and "compiles away in
release mode" cannot both be true. New comment honestly names this as a
dev-only tripwire (PF-005 in intent, not substance) and states the
release consequence: miscounted status line, never data corruption.
rust-04: Harden relative_display fallback — filter to Component::Normal
so the unwrap_or(path) fallback on an absolute path joins only named
segments instead of producing malformed output (//foo/bar.mds or
C:/\/foo/bar.mds). UNREACHABLE today per both call sites; comment
explains. One added filter() call.
consistency-03 (option b): Delete the false "matches the sibling
shape"/"greppable" claim from the AD-216-4 comment. Lint uses
deliberately distinct vocabulary from build/check/fmt ("N failed"):
the four-bucket partition expresses distinct failure modes not captured
by a single failed count. Choosing (b) because tests, spec §7.5, and
CHANGELOG.md all pin the current "with errors" wording, and spec.md/
CHANGELOG.md are being concurrently edited by other agents.
Co-Authored-By: Claude <noreply@anthropic.com>
python-02: Add two regression tests that cover the line/column parsing path (lib.rs:789-798) and the conditional emission in as_json() (lib.rs:600-621) via LintResult(canonical). ADR-009 verified: both tests fail with AssertionError when the parsing is stubbed to hardcoded None. python-10: Replace false comment claiming serde_json::Map preserves insertion order (requires the preserve_order feature, which is not enabled). State the accurate fact: Map is a BTreeMap and keys sort alphabetically regardless of insertion order. python-03: Replace write_text with write_bytes in test_sm_py9_live_cli_ source_map_parity (last remaining write_text fixture site). Adds PF-020 rationale comment matching the four converted sites in conftest and test_parity.py. python-08: Add diagnostic count guard (>= 2) to test_crlf_input_parity alongside the existing files-truthiness check, matching siblings test_ac_p1_24 and test_par5b. python-07: Annotate all three normalizer helpers (drop_file_key x2, strip_file_key) with proper dict[str, Any] -> list[dict[str, Any]] signatures. Adds from typing import Any import. Removes dead # type: ignore[type-arg] comments that implied mypy checked test modules when it does not. Test count: 242 before → 244 after (2 new tests from python-02). All 244 tests pass. Co-Authored-By: Claude <noreply@anthropic.com>
The previous fix for compliance-07 introduced !.devflow/docs/reviews/ and !.devflow/docs/design/ as broad negation patterns, exposing 66 previously- ignored paths as untracked — the entire .devflow/docs/reviews/ tree plus 16 untracked design docs, all of which may contain local file paths and personal email addresses. Replace with a ladder that scopes the exception to exactly the three already- tracked design-plan files. .devflow/docs/reviews/ is restored to fully ignored with no negation. The fourth tracked file (under reviews/) remains tracked via the git index, which overrides gitignore regardless of whether a pattern matches. Verified: 66 untracked → 0 untracked; git ls-files unchanged (same 4 files). Co-Authored-By: Claude <noreply@anthropic.com>
…inferred-obj, regression-10 stale comment typescript-02: copy the compileFile synchronous-throw contract paragraph onto lintFile's JSDoc, adding the missing `.catch()` warning — callers relying on .catch() to handle option-validation errors would silently miss them. testing-03: add LintDiagnostic literals with help: null / span: null to both consumer-node.ts and consumer-browser.ts. ADR-009 verification confirmed: reverting `| null` on either field causes TS2322 on these assignments, making the breaking type widening detectable at compile time. testing-05: add inferred-object case to consumer-node.ts. Empirical check (removing @ts-expect-error) confirmed: TS2322 fires — Type 'string' is not assignable to type 'undefined' — so the PR description's claim that this shape is accepted was wrong. Fixture encodes the ACTUAL compiler behaviour. regression-10: rewrite the "per-surface builders" comment in U-OV-29 to describe the current forwardOpts / METHOD_KEYS mechanism (the per-surface builders were deleted in this wave). Co-Authored-By: Claude <noreply@anthropic.com>
…iet help (resolve-b2c)
testing-01 / F-16 (a): Add platform-independent Rust unit test
`directory_sort_ascending_over_normalized_display_strings` (no #[cfg] guard)
that constructs paths via PathBuf::join and asserts byte-wise ascending order
over the normalised forward-slash display strings. The [/[ ordering
('/' 0x2F < '[' 0x5B) pins that relative_display uses '/' not the native
backslash — ADR-009 verified: changing join("/") to join("\\") causes the
assertion on display_sub_a to fail immediately with "sub\\a.mds" ≠ "sub/a.mds".
Also adds an optional #[cfg(windows)] test documenting the C:\proj\sub\c.mds
→ sub/c.mds normalisation for when the Rust matrix gains windows-latest.
testing-01 / F-16 (b): Add U-L13 to packages/mds/__test__/lint.spec.mjs
(the suite that already runs on windows-latest in CI). Spawns the CLI on the
new lint-dir-order fixture directory (3 files: b.mds, sub/a.mds, sub[after.mds)
and asserts (1) no backslash in any files[].file key and (2) ascending byte-wise
order: "b.mds" < "sub/a.mds" < "sub[after.mds". Follows the CI-hard-fail /
local-warn convention of U-L11 and U-L12.
consistency-04: Fix --quiet long_about in main.rs. The previous text claimed
"the non-zero exit is never unexplained" — false for warn-only directory runs
which exit 1 with zero stderr under --quiet. Replaces the false claim with an
enumerated two-exception model: (a) --fix --check pending-fix case, (b) directory
warn-only run — mirroring the already-correct rustdoc comment ~90 lines away.
Co-Authored-By: Claude <noreply@anthropic.com>
…pers Three independent agents added basePath depth guards for compileFile and checkFile (in both wrapWithFileOps and createNativeBackend) but did not apply the same treatment to lintFile and lintVirtual, which have the same silent-drop exposure via forwardOpts. Add the missing guards for parity. Four agents independently defined captureMsg, VIRTUAL_MODS, and VIRTUAL_ENTRY inside separate test closures in options-validation.spec.mjs. All four captureMsg implementations are semantically identical; extract the three helpers to describe scope to eliminate the duplication. No behavior changes — guards fire in the same order (public wrapper first, depth guard second), and the extracted helpers are functionally equivalent. All 2091 Rust tests, 304 JS tests, and 146 gate tests pass.
…ted value - parser_tests.rs: remove `use super::helpers::*` — all pub(super) items from parser_helpers.rs are already available via `use super::*` (parser.rs does `use helpers::*` which pulls them into parser's namespace). Fixes clippy error `-D unused-imports` introduced when `LintConfig::from_rules` was removed in d28bbcc. - ci.yml: correct Python smoke-test expected value from 'Hello CI!' to 'Hello CI!\n'. The compiler appends a trailing newline on markdown output; the assertion was wrong, not the implementation. Verified locally: repr(r.output) == "'Hello CI!\\n'" against the installed wheel. Co-Authored-By: Claude <noreply@anthropic.com>
Folds in PR #308 (22-commit review-resolution pass): emit-before-exit ordering rule (PF-004, 7 realized defects, ResultSink deferred to #309), ADR-008 open decision on fix_edits[].new_text sanitization, error-only push-site sanitization, LintConfig::from_rules deletion, PartiallyFixed wildcard arm must_use trap, clippy stale cache gotcha, Windows ordering coverage, Python to_dict conditional span fields, and write_bytes fixture requirement (PF-020).
Closes #38
Closes #180
Closes #202
Closes #203
Closes #209
Closes #211
Closes #213
Closes #215
Closes #216
Closes #224
Closes #288
Closes #289
v0.4.0 Wave 1 — the complete pre-tag change set
10 commits · 123 files · +19,719 / −2,181 —
wave/v0.4.0-wave1@e3f8c64→main@113f472.This is the entire v0.4.0 release content landing on
mainas one squash. It is the last gate before thev0.4.0tag, which publishes to crates.io and npm irreversibly.Why this wave exists
The v0.4.0 tag gate was deliberately set to MEDIUM width on 2026-08-09. The rule admits only:
plus community hygiene and release-safety gates. Everything else was deferred to v0.5.0.
The rule explicitly decouples the tag gate from the milestone: "milestone membership is the plan of record for a release theme, not the ship list for the tag." Both edges were exercised with worked precedents — #180 was held in (a small additive fix, but the defect is already reachable by users of the published npm packages), and #155 was deferred (a genuinely breaking
mds-corepublic-trait change, but design-quality debt whose replacement API is still open). The bounding principle: the window justifies breaks whose target shape is settled, not tech debt whose replacement is still being designed.Why one branch of six PRs instead of six PRs to
main[Unreleased]is written once coherently rather than merged six times and reconciled after.types.tsrewrite absorbs PR2's rule-name narrowing instead of conflicting with it.What's in it
Ten commits. Two were not in the original plan: #300 (WASM size, forced by the budget trajectory above) and #306 (the Python rename, forced by an external discovery — see below).
282af17cbb11d413cb2a5c9265b4sort_unstable3372e494c13ecbbasePathplumbing, complete TS public surface61fe58eapply_fixesforapply_fixes_incrementale23834d7072df0markdown-scripte3f8c64The one live shipped bug — #180
basePathwas accepted by the unknown-option validator and then discarded before reaching the backend, socompile(source, { basePath })resolved@import/@extendsagainst the wrong directory. It now propagates through one table-driven choke point,forwardOpts(util/options.ts:265), which is compile-bound to the public interfaces bykeysOf<T>; the WASM backend rejects non-nullbasePathwithmds::invalid_optionsinstead of silently ignoring it. (Note:lintalready forwardedbasePathonmain; onlycompileandcheckdropped it.) This is the defect that qualified for the medium gate.Why the Python package was renamed
mdscripton PyPI is a genuine dormant project, not a squat — 5 releases in April 2021, real working code, ~16 downloads/month, and a summary ("scripting in markdown files with hot reload") that is a direct topical collision. PEP 541 offers no route against a reachable owner (active on GitHub 12 days prior).The decisive constraint: their
top_level.txtis exactlymdscript, so publishing under a different distribution name while keepingimport mdscriptwould put two top-levelmdscript/directories intosite-packages— whoever installs second silently overwrites the first, with no pip error. Both names had to change.markdown-scriptis confirmed available on PyPI (404 on both/pypi/…/jsonand/simple/…/, for both hyphen and underscore forms, which PyPI normalizes to one name).This is not breaking for anyone:
crates/mds-python/Cargo.tomlispublish = falseandrelease.ymlhas no PyPI publish step, so there are zero existing consumers — no compat shim, noProvides-Dist, no alias. Recorded as ADR-012.Why #307 exists — a consolidation gap found pre-merge
PR #293 squash-merged a stale head.
ticket/pr6-community-release-safetyhad 35 commits; only the first 14 were merged. The other 21 (+1,375/−180) were never pushed to any remote. Among them:verify-pr-checks.mjswas missing--adminfrom its emitted merge command, whileRELEASING.mdrequires it — the tool's own output did not work as documented, and it is the tool this very merge depends on. Gate test coverage was also roughly halved (37→73 and 37→46). Recovered as the full 21-commit series; see #307 for the conflict-resolution detail.BREAKING changes
TypeScript
basePath?: neveronFileOptions(types.ts:164),CheckFileOptions(:183, new interface),LintFileOptions(:359). Source-breaking when aCompileOptions/CheckOptions/LintOptions-typed variable is passed to a file surface (TS2322). Inferred-object variables are rejected at compile time (TS2322: Type 'string' is not assignable to type 'undefined'), as are typed vars and spreads of typed vars. The three real, narrower holes are{basePath: undefined}, spread of aRecord<string, unknown>, and index-signature-typed values; the last two are caught by the runtime guard, the first is not (a runtime error on file-surface calls), and that was fixed in this wave.LintDiagnostic.helpand.spanwidened to| null(types.ts:207,:214). A pure declaration widening — the wire has always emitted JSONnull(the payload is hand-built viaserde_json::json!atdiagnostic.rs:808-816; these are always-present keys, never absent). Only the TS type was wrong. Downstream guards written asdiag.span !== undefinedwere already broken at runtime and must becomediag.span != null.packages/mds/src/index.tsdeleted. Low risk: theexportsmap resolves only./dist/node.jsand./dist/browser.js; nothing reachable by a published consumer pointed at it.SourceMapV3and the full lint type set are now exported fromnode.tsandbrowser.ts.Runtime
compileFile(path, {basePath})/checkFile(path, {basePath})now throw synchronously withcode: 'mds::invalid_options'. Synchronous —.catch()on the returned promise does not receive it; usetry/catch.compile/checknow throw onbasePathinstead of silently ignoring it. Migration:MDS_BACKEND=native.basePathnow actually works. Code that passed it and unknowingly depended on it being ignored will see different import resolution. That is the fix, but it changes observable behavior.Wire / output contracts
These break downstream tooling with no compile error:
span.offsetwithin each file group (stable; no-span sorts last). Previously implementation-defined rule-insertion order. Note truncation is not offset-ranked — still the first 1,000 in rule-execution order, re-sorted afterward.files[].fileis now<stdin>, was"input.mds". Deliberate asymmetry now shipping: the CLI emits<stdin>; binding surfaces (napi/WASM/Python) still emitinput.mds. Documented atspec.md:1033and:1071.unused-importspans anchor at the unused name in selective imports;lengthis the name's length, not a constant 7. Alias imports unchanged.Path::Ordto byte-wise comparison of the relative display path (api-utils.mdsnow sorts beforeapi/x.mds). On Windows, separators are now normalized to forward slashes — a path that appeared assub\c.mdsnow appears assub/c.mds. A consumer splitting on\will silently stop matching.mds build --quiet <dir>no longer prints its summary on a fully-successful run (still prints whenfail_count > 0, so a non-zero exit is never unexplained). Scripts grepping that stderr forN built, N failedbreak.mds lint <dir>now always prints a stderr summary on clean runs (suppress with--quiet). Scripts asserting zero stderr break.mds lint --quietnow suppressesfix rejected:and the diagnostic-cap notice. Scripts grepping forfix rejectedmust drop--quiet.Fixed:andWould fix:now emit in JSON directory mode (lint.rs:1541,:1613), previously human-only output. A CI job asserting on empty stderr breaks silently — exit code unchanged.summarykey was added.Rust API — deprecations, not removals
mds::fix::apply_fixes—#[deprecated(since = "0.4.0")](fix.rs:731-735), removal scheduled for v0.5.0 (v0.5.0 removal tracker: deletemds::fix::apply_fixesand migrate ADR-004 coverage #304). Two non-mechanical migration hazards: the reverify closure bound tightensFnOnce→Fn, andapply_fixes_incrementalcan returnFixOutcome::PartiallyFixed, whichapply_fixesnever returned (wildcard match arms compile but silently discard partial results).mds::LintConfig::from_rules—#[deprecated(since = "0.4.0")](config.rs:295-299), preferfrom_rules_checked. No removal scheduled before v1.0.0.Downstream crates with
#![deny(warnings)]calling either will fail to build on 0.4.0. Neither is a removal, but the build break is real.Not in this wave — a correction
The
#[non_exhaustive]sweep (14 types, 4 signature redesigns) is already onmainasd8766af(PR #286, closing #259). It must not be attributed to this wave. Exactly one new#[non_exhaustive]type ships here:UnknownRuleNames. The largenon_exhaustiveblock that appears as added lines in the CHANGELOG diff is primarily a section relocation frome23834d's Keep-a-Changelog restructure; however, the block also carried 10 lines of genuinely new content —MdsError::source_name()andis_string_source(), new in this wave, which have since been moved to### Added(commit9f233cd).What a reviewer should actually open
The wave is large; these are the files where the risk concentrates.
crates/mds-core/src/lint/diagnostic.rs,crates/mds-core/src/lint/rules/unused_import.rs,crates/mds-cli/src/output.rspackages/mds/src/types.ts,packages/mds/src/util/options.ts,packages/mds/__test__/types/consumer-node.tscrates/mds-cli/src/lint.rs:1380-1410(summary emitter and its quiet gate)crates/mds-core/src/lint/config.rs,crates/mds-core/src/options.rscrates/mds-core/src/lint/fix.rs:725-740scripts/verify-pr-checks.mjs(gates every future merge),.github/workflows/ci.ymlcrates/mds-wasm/Cargo.toml— and confirm the stable-sort sites are correct (diagnostic.rs:932is new in this wave, added by commitcbb11d4;fix.rs:361,formatter.rs:262,sourcemap.rs:382are unchanged), where stability is load-bearing for the AD-202-1 wire contractProcess caveats a reviewer should know
e23834d.pr2-run-report.mdis internally inconsistent (a "Fixed (92)" and an "Outstanding (92)" section describing the same items). PR2's true surviving-finding count could not be determined..devflow/docs/design/v040-wave1/(pr1-lint-json-plan.mdandpr5-deprecate-apply-fixes-plan.md) are the originals (88,084 bytes / 580 lines and 70,053 bytes / 424 lines respectively, both with complete headers and well-formed final sections). The other four plan docs are not committed. These were force-added against the stated.gitignore:64policy — verify this is intentional.Remediation summary
This branch now carries approximately 20 remediation commits resolving the 100 review findings documented in
.devflow/docs/reviews/wave-v0.4.0-wave1/2026-08-19_1216/resolution-summary.md.Verification
All numbers from CI run
32154708359at a tree verified byte-identical to the branch tip.cargo nextest run --workspacecargo test --docpytest crates/mds-python/testsnpm test --workspaces)npm run test:gates)cargo clippy --workspace --all-targets -D warningscargo fmt --all --checkRUSTDOCFLAGS="-D warnings")verify-versions.mjs/verify-no-control-bytes.mjsThe WASM figure was re-measured at the branch tip (run
32223495191), not carried forward fromc9265b4—pkg/andpkg-web/are both 836,126, unchanged, so the four commits after the optimization pass consumed none of the remaining headroom.Stale figures that appear in mid-wave artifacts (
~1983nextest /50doctests) predate PR4 and PR5. 2,087 / 52 is current.Risk register
Must verify before tagging
publish-npmpublishes 8 packages before it builds the TS/WASM artifacts (release.yml:279-297). If the build at:284fails, crates.io already has 0.4.0 (irreversible), 8 npm packages are live, and@mdscript/mds— the package users actually install — never publishes. This wave elevates the risk:packages/mds/src/is the most-rewritten area in the diff. The dry-run gives zero coverage of this step. Mitigation: move:284-287above:279— a three-line move that closes the window entirely.publish-cratesdoes not depend on the napi build or the A3 gate (release.yml:168). crates.io publishes in minutes; the 7-target matrix takes far longer. If the A3 name↔loader gate then fails, crates.io holds 0.4.0 forever with no npm release. Mitigation: run the dry-run and confirm A3 green before tagging; structurally, addstage-and-verify-napitoneeds.NPM_TOKENrenders as an empty string, not an error. Last updated 2026-07-24; granular npm tokens commonly expire at 30/90 days. An expired token → crates.io publishes, npm 401s → the R1 partial state with no build failure to explain it. This is the single highest-value manual check before tagging, and the one failure class the dry-run structurally cannot detect.Source hygiene, both Python jobs, andexamples/ gitignore coverageare not among them. Combined with--adminmerges, the control-byte gate this wave introduces cannot block a merge. Partly mitigated byverify-pr-checks.mjs(also new here), which evaluates non-required check-runs too — but only if it is actually run.ci.yml:77,1.96.0), not the publishing job (release.yml:262,@stable).ci.ymlnever runs on tags, so the wasm binary that actually ships is never size-checked — thoughci.yml:164(jsjob) does build and exercise the nodejs-target WASM via@stableon all three OSes, the size is never asserted on any shipping artifact. This is now fixed (commit77e6973pins the release toolchain and adds a size check on the publish path).crates/mds-wasm/Cargo.toml:48-50documents a previously-experienced failure of exactly this shape (post-MVP WASM features that wasm-opt rejects).R632223495191): 836,126 bytes, unchanged, 13,874 headroom. The commits after the optimization pass consumed none of it. Note for future checks: local numbers are not transferable — local wasm-opt v117 reads 2–3 KB higher than CI's Binaryen v129.cargo test --workspaceis ubuntu-only. The newrelative_displaypath normalization (lint.rs:320) is therefore Linux-proven only, and its sole regression test is Unix-by-name. Assessment: absence of proof, not presence of a bug — usingcomponents()rather than.replace('\\', "/")is the correct platform-aware choice and the rustdoc shows the separator-ordering case was reasoned about. It matters because PF-003/#133 bit this repo before.crates/mds-napi/index.d.tsis gitignored and regenerated at release time — the published napi type surface appears in no diff and is pinned by no test. Materially reduced this wave: the napi diff touches only internal helper signatures; no#[napi]-exported signature changed.concurrencygroup onrelease.yml. A re-pushed tag can run two publish pipelines at once. crates.io steps are idempotent; npm steps are not.🔴 The sharpest edge —
publish-npmhas no already-published guardpublish-cratesexplicitly tolerates "already published" (release.yml:190-199,225-234).publish-npmdoes not —:279-297are barenpm publishcalls, and republishing an existing version returns 403EPUBLISHCONFLICT.Consequence: if npm publishing fails partway, re-running the workflow fails immediately on the first already-published package and never reaches the missing ones. This is the highest-probability way to end up stranded. Recovery is usually not hand-publishing (a laptop publish succeeds without provenance, producing an inconsistent release) but cutting
v0.4.1andnpm deprecate-ing the half-published set. Worth ticketing.Feared risks that are actually already mitigated
Stated explicitly so they don't get re-raised in review:
basePath?: never— type and runtime agree, and ADR-011 is implemented as written.browser.ts:17-38re-exports the shared option types (not narrowed variants); runtime rejection lives inbackend/wasm.ts:427-430, 446-465. Both halves are pinned by@ts-expect-errorcompile-time tests that fail if the error ever disappears, now wired intopackages/mds/package.json:42.help/spangenuinely serialize asnull— the wire is hand-built, noskip_serializing_ifanywhere on the path, and all surfaces share oneto_canonical_json. The TS types are correct.git diff main...wave -- crates/mds-napi/{index.js,package.json,Cargo.toml}is empty. The 7-target list matches acrosspackage.json,release.yml:59-87, andverify-napi-names.mjs:36.#[deprecated(since = …)]is correct — exactly two hits repo-wide, both"0.4.0", both introduced in this release.bump-version.mjs's CHANGELOG stamping will work despitee23834drewriting ~1,987 lines:## [Unreleased]is alone onCHANGELOG.md:8and the compare link is intact.pyproject.tomlusesdynamic = ["version"], so there is no second source of truth to drift.main's spec already said unknown rules are warn-and-ignored; there was simply no detection. Exit codes are unchanged — no addedexit()anywhere in the CLI diff.sourcemap.rsdiff is 21 lines, replacing a literal with a named constant.HAZARD_RANGES.length === 21) blocks silent narrowing of the hazard class. The positive controls exercisescanBuffer/isHazardous, covering the main gate paths;verify-no-control-bytes.mjs:372had a revision-parsing gap (:${path}syntax allowing git blob redirection) that this wave's positive controls did not cover. That gap has since been fixed (commit34e4ea1, SHA-based blob addressing + a planted0:payload.mdspositive control on the index-read path).After this merges
Merging this releases nothing. The tag is a separate, deliberate, manual step.
Milestone status: v0.4.0 goes to 0 open / 18 closed. The 12 issues this closes are exactly the 12 currently open. No tag blockers remain in the tracker.
The path to a published v0.4.0
ci.ymldoes not run on tag pushes, andrelease.ymlruns nocargo testand nonpm test. The only correctness assurance at tag time is that the tagged commit is the mergedmaincommit whose CI you already verified. Tag the exact commit you verified — never an amended one.One-time prerequisites are all verified done: the
@mdscriptscope holds all 15 packages at 0.3.0,NPM_TOKENandCARGO_REGISTRY_TOKENboth exist, private vulnerability reporting is enabled. Note npm auth is viaNPM_TOKEN, not the OIDC trusted publishing thatRELEASING.mdcalls preferred.Python — outstanding work
Nothing Python ships in v0.4.0 (
publish = false, no PyPI step inrelease.yml), so none of this blocks the tag.cp314tfree-threaded wheels (a separate ABI), apublish-pythonjob inrelease.yml, and a wheel-version assertion.verify-versions.mjscorrectly does not readpyproject.toml(version isdynamic), andbump-version.mjscorrectly does not touch it.markdown-script(importmarkdown_script) + register on PyPI #292 — registermarkdown-scripton PyPI, now on the v0.5.0 milestone. Verified trusted-publisher values: ownerdean0x, repomdscript, workflowrelease.yml, environment blank. The repo currently has zero Actions environments, so blank matches reality — but if Python bindings: cross-platform wheel matrix + PyPI publishing (+ cp314t) #132 addsenvironment: pypito the publish job, the publisher record must be updated to match or OIDC auth fails with a confusing error. Register and configure together.markdown-scriptstays claimable by anyone until the first upload.crates/mds-python/Cargo.toml:42uses a path dep onmds-core— the classic maturin-sdist failure mode. Not a v0.4.0 risk, but Python bindings: cross-platform wheel matrix + PyPI publishing (+ cp314t) #132's PyPI job will hit it cold.mdscriptand should be corrected before anyone acts on them: the Rename Python distribution tomarkdown-script(importmarkdown_script) + register on PyPI #292 issue body (acceptance criteria still say "register themdscriptname") and the local working-memory notes.depythonizerecursion), tech-debt: centralize MAX_MODULE_COUNT (duplicated in mds-python and mds-wasm) #135 (centralizeMAX_MODULE_COUNT), ci: add cargo-deny / cargo-audit supply-chain gate #136 (cargo-deny/audit gate) — none block Python publishing.Not yet authored
Post-tag wave plans (W2 correctness, W3 infra, W4 deferred queue) do not exist in the repo — verified absent with a positive-control grep. The 23 open v0.5.0 issues plus unmilestoned #223 are the raw material. #223 (decompose
resolver.rs2,554L andbuild.rs2,020L) is scheduled strictly last by its own body: it rewrites both files wholesale, so any behavior fix touching them would have to be rebased across the decomposition if it landed first.Also worth clearing before the tag
The default branch carries 2 open high-severity Dependabot advisories, and there are 22 open dependabot PRs.