From 7c803549d921ea3cda06ff85048aadac9a45b12d Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 16 Sep 2026 13:20:13 +0300 Subject: [PATCH 1/7] =?UTF-8?q?docs(spec,security):=20specify=20the=20impo?= =?UTF-8?q?rt=20filesystem=20constraints=20in=20=C2=A74.6=20=E2=80=94=20re?= =?UTF-8?q?lative=20form,=20NUL=20bytes,=20symlink=20rejection,=20root=20c?= =?UTF-8?q?ontainment,=20path=20encoding=20=E2=80=94=20and=20point=20SECUR?= =?UTF-8?q?ITY.md=20at=20them=20(#327)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SECURITY.md | 4 ++++ spec.md | 32 +++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 684a12fe..36f5f728 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -59,6 +59,10 @@ input. The compiler enforces several defense-in-depth controls: Consequence: hard links, ACLs, xattrs, and owner/group of a pre-existing target are not preserved (permission bits are, on Unix) — see spec §7.2 "Output writing". +The symlink, containment, NUL-byte and path-encoding rules above are specified +normatively — with their error codes and the tests that pin them — in `spec.md` +§4.6 "Filesystem constraints"; this section is the overview. + ### Resource limits | Limit | Value | Location | diff --git a/spec.md b/spec.md index 288e8527..7590d5da 100644 --- a/spec.md +++ b/spec.md @@ -358,9 +358,39 @@ MDS supports three import styles: - Without alias (merge): exports enter current scope (name collision → compilation error) - Selective: only listed names are brought into scope - Circular imports → compilation error -- Resolved import paths stay inside the project root (see §5 Project Root); a path that escapes it is a compilation error +- Resolved import paths stay inside the project root (see §5 Project Root and "Filesystem constraints" below); a path that escapes it is a compilation error - Import resolution is recursive (imports can import) +#### Filesystem constraints + +The resolver applies the rules below to the paths it opens — the entry file, each +`@import` target (body directives and frontmatter `imports:` entries alike), and the +base directory of a string compile — on the native filesystem backend (`NativeFs`: +the CLI, the Rust API, and the file-path entry points of the napi and Python +bindings). The in-memory backend (`VirtualFs`: `compile_virtual`, `lint_virtual`, and +the WASM binding) has no symlinks and no host paths; the NUL-byte, empty-path and +containment rules apply to it unchanged. Each rule is a compilation error, reported +with the code shown. + +| Constraint | Rule | Code and message | +|---|---|---| +| Relative form | An import path starts with `./` or `../`; bare module names and absolute paths are refused before any filesystem access. | `mds::import` — `import path must be relative (start with './' or '../'): ""` | +| Empty path | An empty import path is refused. | `mds::import` — `import path is empty` | +| NUL bytes | A path containing U+0000 is refused before it reaches the operating system. | `mds::import` — `import path contains null byte` | +| Symlink rejection | A path whose final component is a symbolic link is refused. The check canonicalizes the parent directory, joins the file name, canonicalizes the result and compares the two, so it is the resolved target that is validated, not the string the template wrote. Symbolic links in parent directories are followed, and the resolved path is then subject to the containment rule. Applies to the entry file, each import target and the base directory of a string compile; the CLI applies the same check to the `--vars` file. | `mds::import` — `symlinks are not allowed in imports: ` | +| Root containment | After resolution the canonical path must lie inside the project root (§5 Project Root). A `..` sequence or a symlinked parent that leads outside the root is refused; on the virtual backend, `..` above the virtual root is refused. | `mds::import` — `import path escapes project directory: ""` | +| Path encoding | An entry path or base directory that is not valid UTF-8 is refused at the public API boundary rather than converted lossily. On the CLI this is exit 2 (§7.9). | `mds::io` — `path is not valid UTF-8` (entry path) or `base_dir path is not valid UTF-8` (base directory) | +| Segment count | On the virtual backend an import that resolves to more than 256 path segments is refused. | `mds::resource_limit` — `import path exceeds maximum segment count (256)` | + +Enforced by `validate_relative_import`, `NativeFs::check_symlink` and +`NativeFs::check_path_traversal` (`crates/mds-core/src/fs.rs`), `validate_import_path` +(`crates/mds-core/src/resolver.rs`) and `path_to_str` / `resolve_base_dir` +(`crates/mds-core/src/lib.rs`); pinned by the `native_normalize_*` and +`vfs_normalize_*` tests in `fs.rs`, `symlink_import_rejected` and +`path_traversal_import_rejected` in `crates/mds-cli/tests/security.rs`, and the +`*_rejects_non_utf8_*` tests in `crates/mds-core/tests/api_surface.rs`. Directory-mode +commands additionally skip symlinked entries inside the tree (§7.2). + --- ### 4.7 Exports From 44df359fd214c1cfd65c3dde559470fafd696ca3 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 16 Sep 2026 13:20:58 +0300 Subject: [PATCH 2/7] =?UTF-8?q?docs(spec):=20document=20fmt.sort=5Ffrontma?= =?UTF-8?q?tter=5Fkeys=20in=20=C2=A77.8=20as=20reserved=20and=20inert=20(#?= =?UTF-8?q?328)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spec.md | 1 + 1 file changed, 1 insertion(+) diff --git a/spec.md b/spec.md index 7590d5da..0d305951 100644 --- a/spec.md +++ b/spec.md @@ -1339,6 +1339,7 @@ Place `mds.json` in the repository root or any ancestor directory of the input f | `build.source_map` | bool | Enable source-map generation for all builds (equivalent to `--source-map`). Ignored for messages-mode templates. Default: `false`. | | `build.embed_sources` | bool | Embed source file contents in `sourcesContent[]` (equivalent to `--embed-sources`). Has no effect when `build.source_map` is `false`. Default: `false`. | | `lint.rules` | object | Per-rule severity overrides for `mds lint`. Keys are rule names; values are `"warn"`, `"error"`, or `"off"`. Unknown severity values cause a hard config-load error. An unknown rule name emits a warning naming it and listing the rules this build recognises, the config still loads, and lint continues — the unknown rule is not enforced (forward compat: a config naming a rule added in a newer release warns instead of failing on an older binary). Under `mds lint`, the warning goes to stderr and is suppressed by `--quiet`; `mds build`, `mds check`, `mds fmt`, and `mds watch` also read this file but do not emit the unknown-rule warning. On the `lint` API surfaces it is returned in `lint_warnings`. | +| `fmt.sort_frontmatter_keys` | bool | **Reserved — accepted, currently inert.** The key is parsed and type-checked (a non-boolean value is a hard config-load error, like any other field) so that `{"fmt": {"sort_frontmatter_keys": true}}` is valid today, but it drives no formatting behaviour in this version: `mds fmt` does not sort frontmatter keys and there is no matching CLI flag. Frontmatter key sorting is deferred to a future version; when it ships, this key will control it without a breaking `mds.json` schema change. Default: `true`. Pinned by `fmt_config_valid_section_loads_cleanly` and `fmt_config_malformed_bool_field_fails_loading` in `crates/mds-cli/src/build.rs`. | Maximum config file size: 1 MB. From 6f26d1ace00ab2a0dd440ab7a235e594bb516cad Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 16 Sep 2026 13:22:50 +0300 Subject: [PATCH 3/7] =?UTF-8?q?docs(spec):=20add=20the=20=C2=A75=20error-c?= =?UTF-8?q?ode=20registry,=20cross-reference=20it=20from=20=C2=A77.9,=20fi?= =?UTF-8?q?x=20the=20MdsError=20JSDoc=20example=20code=20(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bundler-utils/__test__/errors.spec.mjs | 2 +- packages/mds/src/types.ts | 2 +- spec.md | 58 +++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/bundler-utils/__test__/errors.spec.mjs b/packages/bundler-utils/__test__/errors.spec.mjs index 8d12ff63..6d476c35 100644 --- a/packages/bundler-utils/__test__/errors.spec.mjs +++ b/packages/bundler-utils/__test__/errors.spec.mjs @@ -10,7 +10,7 @@ import { formatMdsError } from '../dist/index.js'; // --------------------------------------------------------------------------- function makeMdsError(opts = {}) { const err = new Error(opts.message ?? 'Something went wrong'); - err.code = opts.code ?? 'mds::undefined_variable'; + err.code = opts.code ?? 'mds::undefined_var'; if (opts.help !== undefined) err.help = opts.help; if (opts.span !== undefined) err.span = opts.span; return err; diff --git a/packages/mds/src/types.ts b/packages/mds/src/types.ts index 0a9cb275..a4267b02 100644 --- a/packages/mds/src/types.ts +++ b/packages/mds/src/types.ts @@ -397,7 +397,7 @@ export interface MdsErrorSpan { /** Error thrown by the MDS compiler. Use `isMdsError` to identify these. */ export interface MdsError extends Error { - /** Namespaced error code, e.g. `"mds::undefined_variable"`. */ + /** Namespaced error code, e.g. `"mds::undefined_var"`. */ code: string; /** Optional human-readable guidance on how to fix the error. */ help?: string; diff --git a/spec.md b/spec.md index 0d305951..1f58e066 100644 --- a/spec.md +++ b/spec.md @@ -873,6 +873,62 @@ mds::undefined_var Errors include a diagnostic code (`mds::*`), file path, line number, column, a visual span, and a contextual explanation. Compilation fails fast on first error; no partial output. +### Error Codes + +Each `MdsError` the compiler produces, and each error a binding synthesises at its +boundary, carries a `code` of the form `mds::`. On the CLI the code is the first +line of the rendered diagnostic (above) and the `error.code` field of the +`mds lint --format json` envelope; on napi and WASM it is `err.code`; on Python it is +`MdsError.code`; in `@mdscript/mds` it is the `code` property `isMdsError()` checks. +Codes are stable identifiers a consumer may branch on; renaming or removing one is a +breaking change. + +"Exit" is the CLI exit class (§7.9) for `mds build`, `mds check`, `mds fmt` and +`mds watch` startup — 1 template or content error, 2 I/O or file-system error, +3 resource limit — followed by the `mds lint` code, which reports analysis failures +as 2 except for the two carve-outs shown. + +| Code | Meaning | Raised by | Exit | Surfaces | +|---|---|---|---|---| +| `mds::syntax` | Parse error: unexpected token, unclosed block, malformed directive | parser | 1 / 2 | all | +| `mds::undefined_var` | Variable not defined in frontmatter, imports or runtime vars | validator, evaluator | 1 / 2 | all | +| `mds::undefined_fn` | Function not defined with `@define` or imported | validator | 1 / 2 | all | +| `mds::arity` | Wrong number of arguments in a call | validator | 1 / 2 | all | +| `mds::builtin` | A built-in function rejected its arguments at runtime | evaluator | 1 / 2 | all | +| `mds::type_error` | `@for` over a value that is not an array | evaluator | 1 / 2 | all | +| `mds::type_mismatch` | Cross-type `==` / `!=` comparison | evaluator | 1 / 2 | all | +| `mds::circular_import` | Import graph contains a cycle | resolver | 1 / 2 | all | +| `mds::file_not_found` | Entry file or import target does not exist (native backend) | `NativeFs`, CLI input check | 2 / 2 | CLI, Rust, napi, Python | +| `mds::import` | An `@import` the resolver refuses: not `./`/`../`-relative, empty, NUL byte, symlinked final component, escapes the project root, or another import-directive violation (§4.6 "Filesystem constraints") | resolver, `NativeFs`, `VirtualFs` | 1 / 2 | all | +| `mds::name_collision` | A merge import or definition redefines a name already in scope | resolver | 1 / 2 | all | +| `mds::not_mds` | Input is not an MDS file (no `.mds` extension and no `type: mds` frontmatter) | CLI input check, file API | 2 / 2 | CLI, Rust | +| `mds::io` | Filesystem or I/O failure; a path or base directory that is not valid UTF-8; on the CLI also a `--vars` file that is a symlink and a `lint --fix` rewrite refused by the compile-equivalence check | `mds-core` API boundary, CLI | 2 / 2 | CLI, Rust, napi, Python | +| `mds::resource_limit` | A documented limit exceeded (§4.1 resource-limits table, `SECURITY.md`); bindings also raise it before compilation for oversized sources, module maps and counts | evaluator, resolver, `VirtualFs`, bindings | 3 / 3 | all | +| `mds::yaml` | Frontmatter YAML the parser itself refuses (syntax, duplicate keys, nesting beyond the parser's limits — §4.1) | resolver | 1 / 2 | all | +| `mds::json` | Malformed JSON, or a non-object root, in `load_vars_str` and other JSON sites | `mds-core` vars API | 1 / 2 | Rust, CLI | +| `mds::invalid_vars` | `--vars` file is malformed JSON or not an object (`load_vars_file`) | `mds-core` vars API | 1 / 2 | CLI, Rust | +| `mds::var_conflict` | Same key given to both `--set` and `--set-string` | CLI | 1 / 1 | CLI | +| `mds::module_not_found` | Virtual backend: a key absent from the module map | `VirtualFs` | 1 / 2 | napi, WASM, Python, Rust | +| `mds::recursion` | A `@define` calls itself, directly or indirectly | evaluator | 1 / 2 | all | +| `mds::export` | `@export` of a name that is not defined, or an invalid re-export | resolver | 1 / 2 | all | +| `mds::extends` | Template inheritance error (E1–E10, §4.11) | resolver | 1 / 2 | all | +| `mds::mixed_content` | Content outside `@message` blocks in a messages template | evaluator | 1 / 2 | all | +| `mds::expected_markdown` | Rust API only: `CompileResult::into_markdown()` on a messages result | `mds-core` API | n/a | Rust | +| `mds::expected_messages` | Rust API only: `CompileResult::into_messages()` on a markdown result | `mds-core` API | n/a | Rust | +| `mds::formatter_invariant` | The formatter's rewrite failed the compile-equivalence gate — a formatter defect; nothing is written | formatter | 1 / n/a | CLI (`fmt`), Rust | +| `mds::internal` | A panic caught at a binding boundary, or a result that could not be serialised; the raw payload is attached as `detail` only under the off-by-default `debug-panics` feature (`SECURITY.md`) | napi, WASM, Python | n/a | napi, WASM, Python | +| `mds::invalid_options` | Malformed or type-incorrect options: unknown keys, wrong types, `basePath` on file methods or on the WASM backend, source-map options on `check`, an empty `basePath` | napi, WASM, Python, `@mdscript/mds` | n/a | napi, WASM, Python, `@mdscript/mds` | +| `mds::filename_collision` | `options.modules` already contains the entry `filename` | WASM (surfaced through `@mdscript/mds`) | n/a | WASM, `@mdscript/mds` | +| `mds::invalid_backend_result` | The selected backend returned a result of an unexpected shape | `@mdscript/mds` | n/a | `@mdscript/mds` | + +CLI-authored errors that are not `MdsError`s — an unreadable, oversized or malformed +`mds.json`, `mds init` refusing a `..` path, a failed stdout write — carry no `mds::` +code; they exit 1 under `build`/`check`/`fmt` and 2 under `lint`. A panic on the CLI is +not converted into an error object: it is a Rust panic with exit code 101. The +`mds::syntax`-through-`mds::formatter_invariant` rows correspond one-to-one to the +`MdsError` variants in `crates/mds-core/src/error.rs`; the last four are synthesised +by the bindings and do not exist in `mds-core`. + --- ## 6. Scoping Rules @@ -1363,6 +1419,8 @@ Maximum config file size: 1 MB. | `2` | Error-severity finding, analysis failure, or usage error (including a directory with nothing to lint, or a directory entry whose path is not valid UTF-8) | | `3` | Resource limit exceeded | +The code-by-code classification behind these tables is the "Error Codes" registry in §5. + --- ## 8. Lint Rule Catalog From 34b58c7d5bd17f0b675173a896e6d584d0bb4497 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 16 Sep 2026 13:27:50 +0300 Subject: [PATCH 4/7] docs(security,release): correct the debug-panics crate roster and scope its release-build claim; add the partial-failure recovery runbook; fix the Unreleased and publish-ordering notes (#313) --- RELEASING.md | 47 ++++++++++++++++++++++++++++++++++++++++------- SECURITY.md | 29 ++++++++++++++++++++--------- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index c3eefa30..6b727f15 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -341,9 +341,9 @@ The release is driven by pushing a `vX.Y.Z` tag. This is how all versions have s printed command without modification.) 3. **Tag the merged commit and push:** Wait for the `CI` workflow run on the merge commit to finish green - (`gh run list --commit ` / `gh run watch `): the release's - version-gate asserts a completed+success CI run for the tagged SHA and fails - closed while it is still running. + (`gh run list --commit ` / bounded polling with `gh run view --json + status,conclusion`): the release's version-gate asserts a completed+success CI + run for the tagged SHA and fails closed while it is still running. ```bash git tag -a vX.Y.Z -m vX.Y.Z git push origin vX.Y.Z @@ -382,15 +382,47 @@ The `release.yml` workflow runs, in order: `load-test-musl-arm64`, `build-python`, AND `rehearse-publish-python` succeed. `cargo publish` `mds-core`, polls the crates.io index for up to 5 min (bounded, max 20 × 15 s), then `mds-cli`. - 8. **publish-npm** and **publish-python** (parallel, both after publish-crates) - — publish npm packages (with provenance) and PyPI `markdown-script` (OIDC - trusted publishing + PEP 740 attestations, `skip-existing: true`). + 8. **publish-npm**, then **publish-python** (`publish-python` `needs` + `publish-npm`) — publish npm packages (with provenance) and PyPI + `markdown-script` (OIDC trusted publishing + PEP 740 attestations, + `skip-existing: true`). 9. **github-release** — `gh release create` with generated notes; runs only after all three publish jobs succeed. `publish-testpypi` never runs on a tag: it is guarded by `inputs.testpypi`, which only a `workflow_dispatch` can set. On a tag push it reports `skipped`. +### When a tag-push release fails part-way + +`release.yml` is a chain of gates followed by irreversible publishes. The name of +the failed job tells you what has already happened: nothing publishes before +`Publish to crates.io`, and inside that job nothing is irreversible until the +`Publish mds-core` step succeeds. + +| Failed job | Already irreversible | Recovery | +|---|---|---| +| `Version gate` — version mismatch, CI-history gate on a still-running CI run, credential or OIDC probe, source hygiene | Nothing | Fix the cause. CI still running or just finished: wait for it, then `gh run rerun --failed`. Wrong commit tagged (version mismatch, hygiene): delete the tag (`git tag -d vX.Y.Z && git push origin :refs/tags/vX.Y.Z`), land the fix via PR, wait for its CI, re-tag. | +| `build-napi`, `build-python`, `Stage + verify platform packages`, `Alpine load test (linux-arm64-musl)`, `Rehearse PyPI publish (no upload)` | Nothing | Transient (runner, Docker Hub pull, registry outage): `gh run rerun --failed` — re-runs the failed jobs and their dependents; upstream artifacts are reused. Genuine defect: land the fix, bump to the next patch version, tag that. Do not move a tag that a defect was found on. | +| `Publish to crates.io` at `Publish mds-core` — the v0.4.0 case: HTTP 403 from a revoked token, run 33569514359 | Nothing | Fix the secret or cause, then `gh run rerun --failed`. | +| `Publish to crates.io` after `mds-core` is live — index poll timeout, `Publish mds-cli` failure | `mds-core@X.Y.Z` on crates.io (cannot be deleted; `cargo yank` only hides it from new resolutions) | Transient: `gh run rerun --failed` — the `mds-core` step treats "already uploaded" as success and proceeds to `mds-cli`. Defect in `mds-cli`: the version is consumed for the whole workspace (one coordinated version); land the fix, bump to the next patch, optionally `cargo yank --version X.Y.Z mds-core`, and say so in the GitHub Release body. | +| `Publish to npm` — any point | Both crates on crates.io; zero or more `@mdscript/*` packages at X.Y.Z on npm | `gh run rerun --failed`: `napi prepublish` tolerates E403 for platform packages already published and `publish_if_absent` skips versions `npm view` already sees. **Do not publish from a workstation** — a laptop `npm publish` carries no OIDC provenance attestation, and that gap is permanent for the version. If a code or workflow change is required for the re-run to succeed: bump to the next patch; npm unpublish is restricted by registry policy and leaves the version number consumed either way, so retire a partial set with `npm deprecate` rather than trying to remove it. | +| `Publish to PyPI` — the v0.4.1 case: `manifest unknown` on an annotated-tag-object pin (#350) | crates.io and the npm packages at X.Y.Z; `GitHub Release` was skipped | Transient: `gh run rerun --failed` (`skip-existing: true` makes the upload re-run safe). Workflow or pin defect: per the tag-immutability rule above, the fix goes to a new commit rather than the tagged one, so land it and bump to the next patch (v0.4.2 was that release), then create the partial version's GitHub Release by hand so the tag is not left bare — `gh release create vX.Y.Z --title vX.Y.Z --generate-notes --latest=false` — with a body line naming which registries the version reached (v0.4.1 was backfilled this way on 2026-09-04). | +| `GitHub Release` | Everything is published | `gh run rerun --failed`, or by hand: `gh release create vX.Y.Z --title vX.Y.Z --generate-notes` (the job itself falls back to `gh release edit`). | + +Across rows: + +- Recover with `gh run rerun --failed`, not by re-pushing the tag while a + run is in flight: the `release-` concurrency group has + `cancel-in-progress: false`, a second push queues behind the first, and a + cancelled run reads as not-failed to the pre-merge verifier. +- Moving a tag is acceptable only while nothing has been published. Once + `mds-core` is on crates.io the version belongs to the artifacts built from the + tagged commit; re-running later jobs against a different commit would publish npm + or PyPI artifacts that do not match what crates.io holds. The next patch version + is the recovery. +- Run the Post-release checks below per registry after a partial recovery — they are + how you confirm the recovery reached every leg that had failed. + ## Post-release - Verify each package on its registry (crates.io, npmjs.com) and that npm shows @@ -400,7 +432,8 @@ The `release.yml` workflow runs, in order: - npm: `npm i @mdscript/mds` then `node -e "import('@mdscript/mds').then(m=>m.init())"` - Python: `pip install markdown-script` then `python -c "import markdown_script as m; print(m.compile('{{x}}', vars={'x':'ok'}).output)"` -- Open a fresh `## [Unreleased]` section in `CHANGELOG.md`. +- Confirm `## [Unreleased]` is empty — `bump-version.mjs` inserts the new version + heading beneath it and leaves the heading in place. ## Notes diff --git a/SECURITY.md b/SECURITY.md index 36f5f728..e2475f10 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -89,12 +89,23 @@ growth, or non-termination. ## ⚠️ The `debug-panics` feature must never ship enabled -`mds-core`, `mds-wasm`, and `mds-napi` expose an off-by-default `debug-panics` -Cargo feature. It surfaces the raw Rust panic payload (as `err.detail` on -`mds::internal` errors) to help diagnose unexpected panics during local -development. - -**Never enable `debug-panics` in a published or production build.** Panic -messages can contain absolute filesystem paths and other internal details that -should not be exposed to template authors or end users. All release builds and -published artifacts are built with the feature disabled. +The three binding crates — `mds-napi`, `mds-wasm` and `mds-python` — declare an +off-by-default `debug-panics` Cargo feature (`crates/mds-napi/Cargo.toml`, +`crates/mds-wasm/Cargo.toml`, `crates/mds-python/Cargo.toml`). `mds-core` and +`mds-cli` have no such feature: the CLI installs no panic hook, so a panic there is +a plain Rust panic (exit code 101) with no error object to attach a payload to. When +enabled, the feature surfaces the raw Rust panic payload as `err.detail` on +`mds::internal` errors thrown at the binding boundary, to help diagnose unexpected +panics during local development. + +**Never enable `debug-panics` in a published or production build.** Panic messages +can contain absolute filesystem paths and other internal details that should not be +exposed to template authors or end users. The feature is off unless opted into +explicitly: none of the three crates lists it in a `default` feature set +(`mds-python`'s default is `extension-module` only), and the commands that build the +published artifacts — `napi build --release` in `release.yml` for the addon, the +`@mdscript/mds-wasm` build script (`wasm-pack build ../../crates/mds-wasm --target +nodejs …` and `--target web …`) for the WASM package, and `maturin` with +`pyproject.toml`'s `features = ["pyo3/abi3-py311"]` for the wheels — pass no +`--features debug-panics`. No automated gate asserts this; it is checked by reading +those three build sites. From 4ac7ba573f24a41467e84c9bf8ae5d9d099a351c Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 16 Sep 2026 13:30:21 +0300 Subject: [PATCH 5/7] chore(community,changelog): list Python and Rspack in the bug-report template; record the package-lock consistency check; CHANGELOG entry (#327, #328, #313) --- .github/ISSUE_TEMPLATE/bug_report.yml | 8 +++++--- CHANGELOG.md | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 320dcb04..effadb13 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -19,9 +19,11 @@ body: - Native addon (`@mdscript/mds-napi`) - WASM (`@mdscript/mds-wasm`) - Universal bindings (`@mdscript/mds`) + - Python (`markdown-script`) - Vite plugin - Rollup plugin - Webpack loader + - Rspack loader - Other / not sure validations: required: true @@ -29,7 +31,7 @@ body: id: version attributes: label: Version - description: Crate or npm package version (e.g. `mds-cli 0.1.0`, `@mdscript/mds 0.1.0`). + description: Crate, npm package, or PyPI package version (e.g. `mds-cli X.Y.Z`, `@mdscript/mds X.Y.Z`, `markdown-script X.Y.Z`). placeholder: "0.1.0" validations: required: true @@ -54,7 +56,7 @@ body: id: environment attributes: label: Environment - description: OS + arch, Node version (if applicable), Rust version (if building from source). - placeholder: "macOS 14 arm64, Node 22.3, rustc 1.88" + description: OS + arch, Node version (if applicable), Python version (if applicable), Rust version (if building from source). + placeholder: "macOS 14 arm64, Node 22.3, Python 3.12, rustc 1.88" validations: required: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 64d0c9b9..d511e47d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -187,6 +187,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Internal +- **Docs bundle (#327, #328, #313).** `spec.md` gains a "Filesystem constraints" subsection under §4.6 (relative form, NUL bytes, symlink rejection, root containment, path encoding — with error codes and the tests that pin them; `SECURITY.md` points at it), an "Error Codes" registry under §5 (26 `mds-core` codes and 4 binding-only codes with their CLI exit class and surfaces; §7.9 cross-references it), and a `fmt.sort_frontmatter_keys` row in §7.8 marked reserved and inert. `SECURITY.md`'s `debug-panics` roster names the crates that declare it (`mds-napi`, `mds-wasm`, `mds-python` — not `mds-core`) and its release-build claim is scoped to the three build sites it rests on. `RELEASING.md` gains a partial-failure recovery runbook (failed job → what is already irreversible → recovery command). The bug-report template lists the Python package and the Rspack loader. `package-lock.json` was verified consistent (`npm ci`, `npm ls --all`) and left untouched. - **`atomic_write_file` replace-by-rename contract documented (#226).** The temp-file-then-rename write used by `mds fmt`, `mds lint --fix` and (with #227) `mds build`/`mds watch` outputs and `.map` sidecars gives the target a new inode, so hard links, ACLs, xattrs and owner/group of a pre-existing target are not preserved (permission bits are, on Unix). Stated in spec §7.2 "Output writing", `SECURITY.md`, the helper's rustdoc and `RELEASING.md`. - Cargo dependency sweep: napi 3.9.0 → 3.12.2, napi-derive 3.5.6 → 3.6.3, napi-build 2.3.2 → 2.4.1 (napi-sys 3.3.0, napi-derive-backend 6.1.2), pyo3 0.29.0 → 0.29.2, clap 4.6.1 → 4.6.6, similar 3.1.1 → 3.2.0, wasm-bindgen 0.2.121 → 0.2.126 (js-sys 0.3.103, wasm-bindgen-futures 0.4.76, wasm-bindgen-test 0.3.76), serde 1.0.228 → 1.0.229, serde_json 1.0.150 → 1.0.151, thiserror 2.0.18 → 2.0.20, libc 0.2.186 → 0.2.189. Supersedes Dependabot #354 #360 #359 #358 #280 #251 #249 #246 #243. - npm dependency sweep: relaxed the three phantom floor pins to caret ranges — fast-uri 3.1.5 → ^3.1.6 (oldest release patching GHSA-5jgf-p345-68v8, GHSA-fph4-wmhf-6fwf, GHSA-f65p-4m7j-42xc, GHSA-jqff-g426-hqxp), nanoid 3.3.18 → ^3.3.18, js-yaml 4.3.1 → ^4.3.1 (#336); @napi-rs/cli ^3.0.0 → ^3.8.6 (lock 3.7.0 → 3.8.6); vite lock 8.1.5 → 8.2.2; Dependabot `ignore` rules for semver-major bumps of the three phantom pins. Supersedes Dependabot #315 #332 #346 #362 #355 #357 #279. From 64728a541ba071b5104a847282a37f308463e48a Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 16 Sep 2026 13:41:23 +0300 Subject: [PATCH 6/7] docs(kb,tests): 10 lint rules in test_parity; 14 three-byte format hazards in the mds-lint KB; complete the feature index; refresh mds-cli and mds-lint KBs against current code --- .devflow/features/index.md | 8 ++++++-- .devflow/features/mds-cli/KNOWLEDGE.md | 6 ++++-- .devflow/features/mds-lint/KNOWLEDGE.md | 13 ++++++++----- crates/mds-python/tests/test_parity.py | 2 +- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.devflow/features/index.md b/.devflow/features/index.md index 0f8e30fa..19b87deb 100644 --- a/.devflow/features/index.md +++ b/.devflow/features/index.md @@ -1,7 +1,11 @@ # Feature Knowledge Index +- **bundler-plugins** — packages/bundler-utils/, packages/vite-plugin/, packages/rollup-plugin/, packages/webpack-loader/, packages/rspack-loader/ — Use when adding a new bundler integration, modifying the emitted-module contract, debugging HMR behavior, working on the CJS compatibility shim, updating the transformer/loader factory, registering a new package in the release pipeline, or investigating why a .mds file emits unexpected output. Keywords: createMdsTransformer, createMdsLoader, bundler-utils, vite-plugin, rollup-plugin, webpack-loader, rspack-loader, addWatchFile, addDependency, handleHotUpdate, emitted module contract, export default string, export default Message[], safeJsonForJs, escapeForJs, metadata, kind, markdown, messages, discriminated union, mds.d.ts, MdsMessage, string | MdsMessage[]. +- **mds-cli** — crates/mds-cli/ — Use when adding new subcommands, changing output-path resolution logic, modifying the watch architecture, adding new compile paths, updating mds.json config handling, debugging stdout/stderr stream separation, investigating exit codes, adding directory-mode build/check support, or working on stale-output cleanup. Keywords: mds build, mds check, mds watch, mds init, OutputKind, run_build, run_watch, build.rs, output.rs, watch.rs, mds.json, output_dir, resolve_output_base, OutputBase, output_path_for, compile_and_write, compile_to_content, intrinsic extension, run_build_directory, run_check_directory, is_partial, collect_mds_files, probe_and_remove_stale, canonicalize_out_dir, output_base_no_ext, continue-on-error, subtree mirror, symlink guard, 10 MiB cap. +- **mds-compiler** — crates/mds-core/ — Use when working on the MDS compilation pipeline, adding directives, modifying scope/variable handling, extending the module system, debugging output rendering, working with @message blocks, the intrinsic output format, CompiledOutput, CompileResult, or mixed-content errors. Keywords: lexer, parser, evaluator, resolver, validator, scope, frontmatter, interpolation, directive, import, include, define, for, if, message, @message, CompiledOutput, CompileResult, into_markdown, into_messages, intrinsic, mixed_content, MixedContent, has_message_block, process_module_intrinsic, collect_messages_strict, evaluate_messages_intrinsic, TextNode.offset. - **mds-fmt** — crates/mds-core/src, crates/mds-cli/src — Use when modifying the mds fmt formatter engine (crates/mds-core/src/formatter.rs), the mds fmt CLI subcommand (crates/mds-cli/src/fmt.rs), any change to mds-core's output model (clean_output, evaluate_nodes, @message/@define body evaluation, the lexer's fence recognition) that could silently break the formatter's compile-equivalence guarantee, or changes to the shared directory walker (output.rs). Keywords: mds fmt, format_str, format_str_with, format_str_named, FormatterInvariant, clean_output, compile-equivalence, idempotent, assert_equivalent, structural_equivalent, strip_trailing_insignificant_text, in_raw_content, raw_content_spans, protected_spans, R1 R2 R3 R4, safety gate, token lossiness, @message body, @define body, @block body, FmtConfig, FmtFlags, interior-verbatim contract, try_scan_fence_at, FenceMatch, deep_merge_yaml, RESERVED_MERGE_KEYS, is_default_excluded_dir, is_within_default_excluded_dir, walker exclusions, node_modules, hidden dirs, effective_parent, bare filename, atomic_write_file. -- **mds-lint** — crates/mds-core/src/lint, crates/mds-cli/src, crates/mds-wasm/src, crates/mds-napi/src, crates/mds-python/src, packages/mds/src — Use when adding or modifying lint rules, extending the --fix pipeline, changing the JSON wire format, wiring lint into a binding layer, debugging unexpected exit codes and reverify gate refusals, or working on the ESC/bidi/newline injection defences. Keywords: mds lint, LintDiagnostic, fix_removals, fix_edits, TextEdit, FixLineSpan, diag_to_edits, LintResult, LintConfig, to_canonical_json, fix tier, reverify gate, FixOutcome, PartiallyFixed, apply_fixes_incremental, preview_fixes, PreviewOutcome, set_diag_display_path, AnalysisContext, ElseifBranch, end_offset, DefineFact, assertKnownKeys, CheckOptions, unreachable-branch, unused-variable, duplicate-import, empty-block, legacy-interpolation, is_output_neutral, all_output_neutral, Tier A Tier B Tier C, structural-standalone, compile-clean, is_standalone, sanitize_control_chars, sanitize_control_chars_wire, named_source_for_render, neutralize_source_for_render, SanitizedReport, SanitizedNode, MAX_AUX_DEPTH, EscapeMode, HUMAN WIRE, eprint_warning, safe_path, safe_inline, safe_file_display, preview_text_for, print_discipline, reverify_failure_reason, LintDirCtx, config_cache, dedup_contained_or_identical, EXIT 0 1 2 3, render_error_sanitized, eprint_error, display_sanitized, MdsError::display_sanitized, ESC-injection, CWE-150, CWE-117, bidi, Trojan-Source, CVE-2021-42574, U+061C, U+202E, U+FEFF, U+2028, U+2029, PF-014, PF-005, construction-time sanitization, per-field rule, Cow, #176, ADR-008, ResultSink, from_rules_checked, relative_display, write_bytes, PF-020, #309, emit-ordering. -- **source-map-security** — crates/mds-core/src, crates/mds-cli/src, packages/mds/src — Use when working with Source Map v3 generation, sources[] path relativization, the relativize_source choke-point, FileSystem::source_root(), CompileOptions.source_map_base, cross-surface source-map parity tests, or the Windows verbatim UNC path fix. Keywords: source map, sources[], relativize_source, source_map_base, source_root, path containment, basename fallback, PF-005, ADR-005, SEC-3, Windows verbatim UNC, path_to_unified, compute_source_map_base, apply_source_map_file_label, CF-SM2, V-SM1, differential test, two-level anchoring, map-relative, root-relative. - **mds-js** — packages/mds/src, packages/mds/__test__ — Use when modifying the JS/TS public API surface, adding backend methods, changing option types, debugging basePath rejection behaviour, changing result types, updating the backend contract, working on WASM/native backend validation, or debugging why a backend result is rejected. Keywords: compileFile, compile, check, checkFile, lint, lintFile, lintVirtual, CompileResult, MarkdownResult, MessagesResult, CheckResult, LintResult, LintDiagnostic, LintFileOptions, CompileFileOptions, FileOptions, assertResultShape, validateBackendMethods, METHOD_KEYS, forwardOpts, assertKnownKeys, getBasePathError, BASEPATH_REJECTORS, BASE_METHODS, NODE_METHODS, WASM_EXPORTS, discriminated union, kind, mds::invalid_backend_result, mds::invalid_options, basePath, synchronous throw, native.ts, wasm.ts, contract.ts, types.ts, node.ts, browser.ts, options.ts. +- **mds-lint** — crates/mds-core/src/lint, crates/mds-cli/src, crates/mds-wasm/src, crates/mds-napi/src, crates/mds-python/src, packages/mds/src — Use when adding or modifying lint rules, extending the --fix pipeline, changing the JSON wire format, wiring lint into a binding layer, debugging unexpected exit codes and reverify gate refusals, or working on the ESC/bidi/newline injection defences. Keywords: mds lint, LintDiagnostic, fix_removals, fix_edits, TextEdit, FixLineSpan, diag_to_edits, LintResult, LintConfig, to_canonical_json, fix tier, reverify gate, FixOutcome, PartiallyFixed, apply_fixes_incremental, preview_fixes, PreviewOutcome, set_diag_display_path, AnalysisContext, ElseifBranch, end_offset, DefineFact, assertKnownKeys, CheckOptions, unreachable-branch, unused-variable, duplicate-import, empty-block, legacy-interpolation, is_output_neutral, all_output_neutral, Tier A Tier B Tier C, structural-standalone, compile-clean, is_standalone, sanitize_control_chars, sanitize_control_chars_wire, named_source_for_render, neutralize_source_for_render, SanitizedReport, SanitizedNode, MAX_AUX_DEPTH, EscapeMode, HUMAN WIRE, eprint_warning, safe_path, safe_inline, safe_file_display, preview_text_for, print_discipline, reverify_failure_reason, LintDirCtx, config_cache, dedup_contained_or_identical, EXIT 0 1 2 3, render_error_sanitized, eprint_error, display_sanitized, MdsError::display_sanitized, ESC-injection, CWE-150, CWE-117, bidi, Trojan-Source, CVE-2021-42574, U+061C, U+202E, U+FEFF, U+2028, U+2029, PF-014, PF-005, construction-time sanitization, per-field rule, Cow, #176, ADR-008, ResultSink, from_rules_checked, relative_display, write_bytes, PF-020, #309, emit-ordering. +- **mds-napi** — crates/mds-napi/ — Use when modifying the native addon API surface, adding new napi exports, debugging FFI marshaling, working on error serialization, understanding the discriminated-union wire format, or investigating why a JS caller gets unexpected result shapes. Keywords: mds-napi, napi-rs, compile, compileFile, check, checkFile, build_canonical_result, CheckResult, serde_json::Value, ToNapiValue, discriminated union, kind, output, messages, absent field, mds::mixed_content, mds::internal, mds::invalid_options, mds::resource_limit, throw_mds_error, run_catching, catch_unwind. - **release-pipeline** — .github/workflows, .github/actions, scripts, scripts/__test__ — Use when modifying release.yml, adding CI jobs, updating TIER_B_EXPECTED_SKIPPED, adjusting the pull_request surface trigger, debugging a publish failure, running the pre-merge verifier, or reasoning about the publish job ordering. Keywords: release, release.yml, verify-pr-checks, TIER_B_EXPECTED_SKIPPED, RELEASE_SURFACE, rehearse-publish-python, tag-push, TestPyPI, publish-crates, publish-npm, publish-python, github-release, version-gate, stage-and-verify-napi, ADR-013, PF-040. +- **source-map-security** — crates/mds-core/src, crates/mds-cli/src, packages/mds/src — Use when working with Source Map v3 generation, sources[] path relativization, the relativize_source choke-point, FileSystem::source_root(), CompileOptions.source_map_base, cross-surface source-map parity tests, or the Windows verbatim UNC path fix. Keywords: source map, sources[], relativize_source, source_map_base, source_root, path containment, basename fallback, PF-005, ADR-005, SEC-3, Windows verbatim UNC, path_to_unified, compute_source_map_base, apply_source_map_file_label, CF-SM2, V-SM1, differential test, two-level anchoring, map-relative, root-relative. diff --git a/.devflow/features/mds-cli/KNOWLEDGE.md b/.devflow/features/mds-cli/KNOWLEDGE.md index bce1b1a4..2532bcce 100644 --- a/.devflow/features/mds-cli/KNOWLEDGE.md +++ b/.devflow/features/mds-cli/KNOWLEDGE.md @@ -14,14 +14,14 @@ referencedFiles: - crates/mds-cli/tests/intrinsic_output.rs - crates/mds-cli/Cargo.toml created: 2026-06-26 -updated: 2026-09-15 +updated: 2026-09-16 --- # MDS CLI (mds-cli) ## Overview -`crates/mds-cli/` implements the `mds` binary with four subcommands: `build`, `check`, `watch`, and `init`. The CLI delegates all compilation to `mds-core`; its job is input resolution, output routing, config loading, and process lifecycle. After the intrinsic-output refactor, **the output extension is derived from the compiled result's kind** — there is no `--format` flag. Markdown templates produce `.md` files; messages templates produce `.json` files. +`crates/mds-cli/` implements the `mds` binary with six subcommands: `build`, `check`, `fmt`, `lint`, `watch`, and `init`. The CLI delegates all compilation to `mds-core`; its job is input resolution, output routing, config loading, and process lifecycle. After the intrinsic-output refactor, **the output extension is derived from the compiled result's kind** — there is no `--format` flag. Markdown templates produce `.md` files; messages templates produce `.json` files. The CLI now supports both single-file and directory modes for `build` and `check`. Directory mode (`mds build ` / `mds check `) recursively compiles all non-partial `.mds` files under the given root, mirrors the subtree into an optional `--out-dir`, and continues on error with a final summary. @@ -205,6 +205,8 @@ Exit codes: - Watch mode derives the extension from `compiled.kind.extension()` after each compile. On deletion it must probe both `.md` and `.json` since the kind is not known. - The `CompileOutput` struct in `build.rs` is a local CLI struct (content + kind + deps) — not the same as `mds::CompiledOutput` (the Rust enum). The naming is similar but they are different types. - `mds.json build.output_dir` rejects `..` components at parse time to prevent path traversal. This check runs in both single-file and directory mode. +- Debounce is a quiet period, not a fixed window (#379, `watch.rs`): the first relevant content event opens a `--debounce` window and every further content event restarts it (`Access` events and watch errors do not restart it); the window is bounded by `debounce_cap = max(10 × window, 1s)` and `--debounce` itself is clamped to `MAX_DEBOUNCE_MS = 60_000` (60s), with an additional `MAX_DEBOUNCE_MESSAGES = 10_000` drained-message cap; a window's exit reason is one of `DebounceEnd::{Quiet, Cap, MessageLimit, Disabled, Interrupted, Disconnected}`. +- Every write the CLI performs funnels through `atomic_write_file` (`output.rs`, #227): temp-file + rename, refusing a symlink at the target; `Durability::Fsync` is used for source rewrites (`fmt`, `lint --fix`) and `Durability::RenameOnly` for reproducible derived artifacts (`build`/`watch`/`init`, #386). `crates/mds-cli/tests/write_funnel.rs` is a lexical guard that fails if a new raw `fs::write`/`File::create` site appears in `crates/mds-cli/src/**` outside its allow-list. An empty directory is now a hard failure (not silent success) for `build`/`check`/`fmt`/`lint` (#204), and a directory whose only `.mds` files are partials is the same "nothing to do" failure (#387). ## Key Files diff --git a/.devflow/features/mds-lint/KNOWLEDGE.md b/.devflow/features/mds-lint/KNOWLEDGE.md index 63805bb5..12a4adb1 100644 --- a/.devflow/features/mds-lint/KNOWLEDGE.md +++ b/.devflow/features/mds-lint/KNOWLEDGE.md @@ -11,7 +11,7 @@ directories: - crates/mds-python/src - packages/mds/src created: 2026-07-11 -updated: 2026-09-15 +updated: 2026-09-16 --- # mds lint — Static Analysis Engine and Tiered --fix @@ -346,9 +346,9 @@ Source text passed to `NamedSource` uses a different function: `neutralize_sourc - **C0/DEL (1-byte)** → `?` (1 byte) - **C1 (U+0080–U+009F) AND U+061C** (both 2-byte UTF-8) → U+00A0 NBSP (2 bytes). U+061C is in the 2-byte branch. -- **The other 11 format hazards** (U+200E/U+200F, U+2028/U+2029, U+202A–U+202E, U+2066–U+2069, U+FEFF — all 3-byte) → U+FFFD REPLACEMENT CHARACTER (3 bytes). +- **The other 14 format hazards** (U+200E/U+200F, U+2028/U+2029, U+202A–U+202E, U+2066–U+2069, U+FEFF — all 3-byte) → U+FFFD REPLACEMENT CHARACTER (3 bytes). -The split is implemented via two private predicates: `is_two_byte_format_hazard(ch)` (only U+061C) and `is_three_byte_format_hazard(ch)` (the remaining 11). An unconditional `assert_eq!` (promoted from `debug_assert_eq!`, #220) in `neutralize_source_for_render` catches byte-length violations immediately, in release builds too. +The split is implemented via two private predicates: `is_two_byte_format_hazard(ch)` (only U+061C) and `is_three_byte_format_hazard(ch)` (the remaining 14). An unconditional `assert_eq!` (promoted from `debug_assert_eq!`, #220) in `neutralize_source_for_render` catches byte-length violations immediately, in release builds too. ### `named_source_for_render` — The Single NamedSource Builder @@ -371,6 +371,7 @@ The auxiliary diagnostic graph (`source` cause chain, `related`, `diagnostic_sou |----------|------|--------| | `eprint_error` (output.rs) via `SanitizedReport` | HUMAN for prose | message, help, label text, entire auxiliary graph — every report rendered to CLI stderr | | `eprint_warning` (output.rs) | prose HUMAN; interpolated identifiers/paths WIRE | HUMAN for the warning body prose; `safe_path` / `safe_inline` for any untrusted value the caller interpolates into it | +| `emit_duplicate_vars_file_warnings` (build.rs) | prose HUMAN via `eprint_warning`; interpolated key/path/count WIRE | warns on `--vars`-file duplicate keys (#326) and on the count omitted past `mds::VarsLoad`'s cap; per AD-224-3 every interpolated value is wrapped in `safe_inline`/`safe_path` at the interpolation site, never hoisted into a `let` first; no-op when `quiet` (AD-224-5) | | `safe_inline(value)` (output.rs) | WIRE | any single-line untrusted value interpolated into a status, warning, or error line: rule names, config paths, `--format` args, `io::Error` causes | | `safe_path(p)` / `safe_file_display(name)` (output.rs) | WIRE | CLI status-line path display (`Clean:`, `Fixed:`, `Would fix:`, `Compiled to`, …) | | `named_source_for_render(file, source)` (diagnostic.rs) | WIRE for filename; neutralize for source | the single `NamedSource` builder used by `MdsError::at()`, `check_equivalence`, `render_diag_human` | @@ -423,7 +424,7 @@ The test anchor inventory covers five surfaces across both error and lint paths. **T-15** `web.rs` WASM (F5/F5-DEL/F6/F6-C1) **T-16f** `diagnostic.rs` — U+2028 in wire message **T-16g** `diagnostic.rs` — wire-mode newline escaping / HUMAN mode preserves `\n` -**T-16h** `diagnostic.rs` — WIRE and HUMAN modes differ only on `\n` +**T-16h** `diagnostic.rs` — WIRE and HUMAN modes differ only on `\n`; use a newline-bearing key to catch a dropped sanitizer at runtime **T-16i** `diagnostic.rs` — WIRE mode: borrowed-on-clean, idempotent **T-NS-1/2/3** `diagnostic.rs` — `named_source_for_render`: hostile filename WIRE, hostile filename bidi class, source neutralized without changing byte length **T-AUX-1/2/3** `output.rs` — `SanitizedReport`: cause chain escaped+preserved, related diagnostics escaped+preserved, cyclic cause chain bounded at `MAX_AUX_DEPTH` @@ -575,6 +576,8 @@ LintDiagnostic.fix_removals (FixLineSpan) OR .fix_edits (TextEdit) **Clippy stale cache can report a pass on dirty code**: Deleting `LintConfig::from_rules` (PR #308) surfaced an unused `use super::helpers::*;` in `crates/mds-core/src/parser_tests.rs`. A `cargo clippy --workspace --all-targets` run immediately after reported a stale cached pass. Touch the file to force a real re-check before trusting a `clippy` clean result after a deletion. +**`safe_inline` is generic over `Display`, including `usize`**: `emit_duplicate_vars_file_warnings` calls `safe_inline(resolved.duplicate_vars_file_keys_omitted)` (build.rs:660) on a plain count, not just on untrusted strings. A `usize` can never carry a control byte, but `print_discipline.rs`'s guard is purely lexical — it matches on the callee name at the interpolation site, not on the argument's type — so skipping the wrapper because "it's just a number" fails the guard exactly like an unwrapped string would. Wrap every interpolated value, even a provably-safe one, or the print-discipline test fails closed. + ## Related Follow-ups / Known Limitations - **#173**: `run_lint_file` FixFileOutcome 3rd-copy duplication and dir-mode JSON per-file wrapper churn. @@ -587,7 +590,7 @@ LintDiagnostic.fix_removals (FixLineSpan) OR .fix_edits (TextEdit) - `crates/mds-core/src/lint/mod.rs` — engine entry point; `lint_source()`, `run_rules()`, partial detection - `crates/mds-core/src/lint/tier.rs` — fix tier table (leaf module); `is_output_neutral(rule)`; `first_occurrence` helper -- `crates/mds-core/src/lint/diagnostic.rs` — `LintDiagnostic`, `LintResult`, `to_canonical_json()` (WIRE: message/help/file key); `sanitize_control_chars` (HUMAN, `Cow`, `#[must_use]`, idempotent); `sanitize_control_chars_wire` (WIRE, new public API, shares one impl via `EscapeMode`); `neutralize_source_for_render` (byte-length-preserving: C0/DEL → `?`, C1+U+061C → NBSP, other 11 hazards → U+FFFD); `named_source_for_render` (new public API, the single `NamedSource` builder); `is_two_byte_format_hazard` / `is_three_byte_format_hazard` +- `crates/mds-core/src/lint/diagnostic.rs` — `LintDiagnostic`, `LintResult`, `to_canonical_json()` (WIRE: message/help/file key); `sanitize_control_chars` (HUMAN, `Cow`, `#[must_use]`, idempotent); `sanitize_control_chars_wire` (WIRE, new public API, shares one impl via `EscapeMode`); `neutralize_source_for_render` (byte-length-preserving: C0/DEL → `?`, C1+U+061C → NBSP, other 14 hazards → U+FFFD); `named_source_for_render` (new public API, the single `NamedSource` builder); `is_two_byte_format_hazard` / `is_three_byte_format_hazard` - `crates/mds-core/src/error.rs` — `MdsError`: `serialize()` (WIRE message/help); `display_sanitized()` (HUMAN Display for TTY); raw `Display` documented as unsanitized; `at()` (uses `named_source_for_render` — inherited by all `*_at` constructors) - `crates/mds-core/src/lib.rs` — `CompileResult::to_canonical_json()` (WIRE warnings, distinct from `LintResult::to_canonical_json`); `emit_warnings()` (HUMAN for prose; identifiers WIRE at construction) - `crates/mds-core/src/lint/fix.rs` — `plan_fixes_with_options`, `diag_to_edits`, `ByteEdit`, `apply_plan_unchecked`, `dedup_contained_or_identical`, `apply_fixes_incremental`, `FixOutcome`; `reverify_failure_reason()` (sole construction site for `Rejected.reason`, WIRE) diff --git a/crates/mds-python/tests/test_parity.py b/crates/mds-python/tests/test_parity.py index 38a659f4..1263e97f 100644 --- a/crates/mds-python/tests/test_parity.py +++ b/crates/mds-python/tests/test_parity.py @@ -211,7 +211,7 @@ def test_par4_lint_virtual_matches_golden( # emits them as JSON null. This diverged report.to_dict() from result.to_dict()["files"][i] # for any diagnostic with a null help or span. # -# None of the current 9 lint rules produce null help or span in production, so the +# None of the current 10 lint rules produce null help or span in production, so the # divergence was invisible to the existing LINT_GOLDENS. This test constructs a # LintResult directly from a canonical dict (via LintResult.__new__) to exercise the # null-help / null-span path explicitly, providing a non-circular differential check. From 25ec3be422a501046288884921a67d1d34f20d31 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 16 Sep 2026 13:43:43 +0300 Subject: [PATCH 7/7] =?UTF-8?q?test(core):=20pin=20the=20spec=20=C2=A75=20?= =?UTF-8?q?error-code=20registry=20to=20error.rs=20code()=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/mds-core/tests/spec_error_codes.rs | 189 ++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 crates/mds-core/tests/spec_error_codes.rs diff --git a/crates/mds-core/tests/spec_error_codes.rs b/crates/mds-core/tests/spec_error_codes.rs new file mode 100644 index 00000000..bd5a27a1 --- /dev/null +++ b/crates/mds-core/tests/spec_error_codes.rs @@ -0,0 +1,189 @@ +//! Guard (#313): the spec §5 "Error Codes" registry stays in sync with the `code(mds::…)` +//! attributes `error.rs` actually declares. +//! +//! A registry hand-maintained in `spec.md` drifts silently the moment a new `MdsError` +//! variant is added or a `code(mds::…)` name changes and nobody updates the prose table +//! next to it. This test converts "is every code documented?" into a machine-checked +//! invariant: every `code(mds::)` attribute in `crates/mds-core/src/error.rs` must +//! appear as a backticked `` `mds::` `` cell inside the spec's "### Error Codes" +//! section, and so must the four binding-only codes that `mds-core` never raises itself +//! (napi, WASM and Python synthesise them at their own boundary). +//! +//! Both files are read `CARGO_MANIFEST_DIR`-relative, the same pattern used by +//! `yaml_funnel.rs` and `assert_promotions.rs`. + +use std::path::Path; + +/// The four codes synthesised by the napi/WASM/Python bindings that have no +/// `code(mds::…)` attribute in `error.rs` because `mds-core` never raises them itself. +const BINDING_ONLY_CODES: &[&str] = &[ + "mds::internal", + "mds::invalid_options", + "mds::filename_collision", + "mds::invalid_backend_result", +]; + +fn read_error_rs() -> String { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/error.rs"); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())) +} + +fn read_spec_md() -> String { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../spec.md"); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())) +} + +/// Extract every `mds::` from a `code(mds::)` attribute in `error.rs` +/// source text. Hand-rolled (no `regex` dependency): scans for the literal needle +/// `code(mds::`, then walks forward while the following bytes are `[a-z_]`, and keeps +/// the match only when the run is immediately closed by `)`. +/// +/// The scan loop is bounded by the source length: each iteration advances `search_from` +/// past the just-found needle occurrence, so the number of iterations can never exceed +/// the byte length of `source`. +fn extract_codes(source: &str) -> Vec { + const NEEDLE: &str = "code(mds::"; + let bytes = source.as_bytes(); + let max_iterations = source.len() + 1; + let mut codes = Vec::new(); + let mut search_from = 0usize; + let mut iterations = 0usize; + + while let Some(rel_pos) = source[search_from..].find(NEEDLE) { + iterations += 1; + assert!( + iterations <= max_iterations, + "unbounded scan: extract_codes exceeded a byte-count-safe iteration bound" + ); + + let name_start = search_from + rel_pos + NEEDLE.len(); + let mut name_end = name_start; + while name_end < bytes.len() + && (bytes[name_end].is_ascii_lowercase() || bytes[name_end] == b'_') + { + name_end += 1; + } + + if name_end < bytes.len() && bytes[name_end] == b')' && name_end > name_start { + codes.push(format!("mds::{}", &source[name_start..name_end])); + } + + // Always past the just-found needle text, so this strictly advances. + search_from = name_start; + } + + codes +} + +/// Isolate the spec's "### Error Codes" section: from that heading (inclusive) up to +/// — but not including — the next line that is exactly `---` or starts with `## `. +/// +/// Bounded by the number of lines in `spec` after the heading. +fn error_codes_section(spec: &str) -> Option { + let heading = "### Error Codes"; + let heading_start = spec.find(heading)?; + let after = &spec[heading_start..]; + + let max_iterations = after.matches('\n').count() + 1; + let mut iterations = 0usize; + let mut search_from = heading.len(); + + loop { + if search_from >= after.len() { + return Some(after.to_string()); + } + let rest = &after[search_from..]; + let line_end = rest.find('\n').map_or(after.len(), |i| search_from + i); + let line = &after[search_from..line_end]; + if line == "---" || line.starts_with("## ") { + return Some(after[..search_from].to_string()); + } + + search_from = line_end + 1; + iterations += 1; + assert!( + iterations <= max_iterations, + "unbounded scan: error_codes_section exceeded a line-count-safe iteration bound" + ); + } +} + +/// Whether `code` (e.g. `"mds::syntax"`) appears as a backticked table cell inside +/// `section`. +fn documented(section: &str, code: &str) -> bool { + section.contains(&format!("`{code}`")) +} + +#[test] +fn core_codes_are_documented_in_spec() { + let error_rs = read_error_rs(); + let codes = extract_codes(&error_rs); + + // Non-vacuity precondition: the scan actually found something to check. No + // hard-coded `== 26` here — a new code landing later must not make this test + // brittle, only the missing-documentation check below should ever fail it. + assert!( + !codes.is_empty(), + "non-vacuity: expected at least one code(mds::…) attribute in {}", + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src/error.rs") + .display() + ); + + let spec = read_spec_md(); + let section = + error_codes_section(&spec).expect("spec.md must contain a \"### Error Codes\" section"); + assert!( + !section.is_empty(), + "the \"### Error Codes\" section must not be empty" + ); + + let missing: Vec<&str> = codes + .iter() + .map(String::as_str) + .filter(|code| !documented(§ion, code)) + .collect(); + + assert!( + missing.is_empty(), + "code(mds::…) attributes in error.rs with no matching `mds::` cell in \ + spec.md's \"### Error Codes\" section: {missing:?}" + ); +} + +#[test] +fn binding_only_codes_are_documented_in_spec() { + let spec = read_spec_md(); + let section = + error_codes_section(&spec).expect("spec.md must contain a \"### Error Codes\" section"); + + let missing: Vec<&str> = BINDING_ONLY_CODES + .iter() + .copied() + .filter(|code| !documented(§ion, code)) + .collect(); + + assert!( + missing.is_empty(), + "binding-only codes with no matching `mds::` cell in spec.md's \ + \"### Error Codes\" section: {missing:?}" + ); +} + +/// Non-vacuity control for `documented`: a code that was never registered anywhere +/// must read as undocumented. Without this, a `documented` that always returns `true` +/// (e.g. a typo that made the `contains` check vacuous) would pass both tests above +/// silently. +#[test] +fn documented_rejects_a_code_not_in_the_section() { + let spec = read_spec_md(); + let section = + error_codes_section(&spec).expect("spec.md must contain a \"### Error Codes\" section"); + + assert!( + !documented(§ion, "mds::definitely_not_a_code"), + "negative control failed: a bogus code must not read as documented" + ); +}