From 282af173e6737cda0e75fd681b68fbd22e6facd8 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Thu, 13 Aug 2026 10:56:24 +0300 Subject: [PATCH 01/34] chore: add Code of Conduct, control-byte CI gate, and PR check verifier (#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 --- .github/PULL_REQUEST_TEMPLATE.md | 4 + .github/workflows/ci.yml | 23 + .github/workflows/release.yml | 10 + CHANGELOG.md | 26 + CLAUDE.md | 2 + CODE_OF_CONDUCT.md | 83 ++ CONTRIBUTING.md | 87 ++ README.md | 3 +- RELEASING.md | 23 +- crates/mds-cli/tests/cli_lint.rs | 13 +- crates/mds-napi/__test__/index.spec.mjs | 3 +- crates/mds-wasm/tests/web.rs | 2 +- package.json | 5 +- .../bundler-utils/__test__/transform.spec.mjs | 12 +- scripts/__test__/code-of-conduct.spec.mjs | 109 +++ .../fixtures/checks-main-113f472.json | 1 + .../fixtures/checks-pr239-f168944.json | 1 + .../fixtures/checks-pr240-e9dace1.json | 1 + .../fixtures/contributor-covenant-2.1.md | 83 ++ .../__test__/fixtures/protection-main.json | 1 + .../fixtures/status-pr239-f168944.json | 1 + .../fixtures/status-pr240-e9dace1.json | 1 + .../__test__/verify-no-control-bytes.spec.mjs | 791 ++++++++++++++++++ scripts/__test__/verify-pr-checks.spec.mjs | 528 ++++++++++++ scripts/hooks/pre-commit | 37 + scripts/verify-no-control-bytes.mjs | 634 ++++++++++++++ scripts/verify-pr-checks.mjs | 538 ++++++++++++ 27 files changed, 3002 insertions(+), 20 deletions(-) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 scripts/__test__/code-of-conduct.spec.mjs create mode 100644 scripts/__test__/fixtures/checks-main-113f472.json create mode 100644 scripts/__test__/fixtures/checks-pr239-f168944.json create mode 100644 scripts/__test__/fixtures/checks-pr240-e9dace1.json create mode 100644 scripts/__test__/fixtures/contributor-covenant-2.1.md create mode 100644 scripts/__test__/fixtures/protection-main.json create mode 100644 scripts/__test__/fixtures/status-pr239-f168944.json create mode 100644 scripts/__test__/fixtures/status-pr240-e9dace1.json create mode 100644 scripts/__test__/verify-no-control-bytes.spec.mjs create mode 100644 scripts/__test__/verify-pr-checks.spec.mjs create mode 100755 scripts/hooks/pre-commit create mode 100644 scripts/verify-no-control-bytes.mjs create mode 100644 scripts/verify-pr-checks.mjs diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c604cb35..aafa980f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -20,3 +20,7 @@ PR titles follow Conventional Commits (feat:, fix:, refactor:, chore:, docs:, .. `cargo clippy --workspace --all-targets -- -D warnings` - [ ] JS gates pass (if touched): `npm run build --workspaces && npm test --workspaces` - [ ] No new compiler/linter warnings +- [ ] Source hygiene: `node scripts/verify-no-control-bytes.mjs` exits 0 +- [ ] **Before any `--admin` merge**: run `node scripts/verify-pr-checks.mjs ` + and use the `gh pr merge --squash --match-head-commit ` command it emits + (PF-017: a cancelled run reads as green without this check) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2555e6a..3903b096 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -306,3 +306,26 @@ jobs: - name: pytest against the installed wheel (perf, advisory) continue-on-error: true run: pytest crates/mds-python/tests -q -m perf + + # ------------------------------------------------------------------------- + # #288: Source-hygiene gate — rejects hazardous codepoints (control bytes, + # bidi overrides, BOM) from tracked source. Scans the full tracked tree via + # `git ls-files`, reads content at codepoint level (pure Node; no grep -P + # which BSD grep lacks). Positive-control suite proves the check is live. + # + # D-CB7: BSD grep lacks -P and exits 2 with empty output, making the absence + # of hazard bytes indistinguishable from a broken invocation (avoids PF-013). + # D-CB5: Zero-files-scanned is exit 1, not exit 0 (avoids PF-016). + # ------------------------------------------------------------------------- + source-hygiene: + name: Source hygiene + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + - name: Scan tracked source for hazardous codepoints + run: node scripts/verify-no-control-bytes.mjs + - name: Run positive-control and class-completeness suite + run: node --test scripts/__test__/*.spec.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e389c2cf..5ff47a40 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,16 @@ jobs: with: { node-version: 22 } - name: "Assert synchronized versions, no file: refs" run: node scripts/verify-versions.mjs + # #288: Source-hygiene gate — also runs on tag pushes via this job. + # ci.yml does not run on tag pushes, so these two steps ensure the full + # gate (scanner + positive-control suite) is enforced at release time. + # The positive-control suite pins HAZARD_RANGES (D-CB1a) so a silently- + # narrowed hazard class cannot exit 0 on the release path (ADR-009/PF-013). + # Uses the same Node 22 install above. + - name: "Assert no hazardous codepoints in tracked source" + run: node scripts/verify-no-control-bytes.mjs + - name: "Run positive-control and class-completeness suite" + run: node --test scripts/__test__/*.spec.mjs # --------------------------------------------------------------------------- # A6 — cross-compile the native addon for all 7 targets. diff --git a/CHANGELOG.md b/CHANGELOG.md index 66f954a3..d6f0e2b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -624,6 +624,32 @@ diagnostic messages must update to check for the `\uXXXX` literal form instead. (span attribution machinery, `end_offset` fields, `FixLineSpan` planner) pushed the optimized WASM binary to ~808 KB. The guard in `ci.yml` was raised accordingly. +- **Code of Conduct** (#38): `CODE_OF_CONDUCT.md` at the repository root, using + Contributor Covenant 2.1 with `deanshrn@gmail.com` as the enforcement contact. + Linked from `CONTRIBUTING.md` and `README.md`. + +- **Source-hygiene CI gate** (#288): `scripts/verify-no-control-bytes.mjs` scans + every tracked file for hazardous codepoints — C0 control characters (excluding + TAB and LF), DEL, C1 (at codepoint level, catching UTF-8-encoded NEL U+0085), + the twelve `Bidi_Control=Yes` characters (Trojan Source / CVE-2021-42574), the + JavaScript line/paragraph separators U+2028 and U+2029, and U+FEFF (BOM). + Runs in CI on every pull_request and on tag pushes (release.yml). An opt-in + pre-commit hook (`scripts/hooks/pre-commit`) is provided; it reads the staged + blob via `git cat-file`, not the working tree. Also remediates seven live + U+0085 bytes that had been injected into tracked source by the edit tooling + (PF-018). + +- **Pre-merge check verifier** (#289): `scripts/verify-pr-checks.mjs` guards + against PF-017 (a cancelled CI run reads as "not failing" to `gh pr merge + --admin`). It evaluates three tiers: Tier A asserts every required + branch-protection context is `completed+success`; Tier B fails on any + non-required check-run that concluded + `failure/cancelled/timed_out/action_required/stale`; Tier C (legacy commit + statuses) is advisory. It emits a `gh pr merge --squash --match-head-commit + ` command pinned to the verified SHA. Exit 0: Tier A and Tier B pass; + exit 1: any Tier A/B failure or zero check-runs found; exit 2: + tool/permission errors. + ### Changed - **napi and Python `compileFile` / `compile_file` now emit root-relative diff --git a/CLAUDE.md b/CLAUDE.md index 316cd0c6..ccf141a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,3 +48,5 @@ See @RELEASING.md for the full runbook. - `crates/mds-python/build.rs` emits a cdylib-scoped `-undefined dynamic_lookup` so bare `cargo build` links the extension on macOS (Linux allows undefined cdylib symbols; maturin passes the flag itself when it builds the wheel) - Local Python dev: `maturin develop` needs an active **virtualenv** + `python3` on PATH; CI has no venv so it uses `pip install ./crates/mds-python` (the maturin PEP 517 backend). Wheels are `cp311-abi3` (one per platform) - `crates/mds-python` is free-threading ready (frozen result classes, `#[pymodule(gil_used = false)]`, GIL released around each compile); the `cp314t` free-threaded wheel is a separate ABI and is deferred with the wheel matrix + PyPI publishing (follow-up to #132) +- **Source hygiene gate** (#288): `node scripts/verify-no-control-bytes.mjs` scans tracked source for hazardous codepoints (C0, C1, bidi, BOM). BSD grep has no `-P` (exits 2, empty output reads as clean) — never use grep to verify absence of control bytes; the gate uses pure Node codepoint iteration. When writing codepoints in source or docs, use numeric notation (U+202E, 0x202e) rather than `\uXXXX` escapes — the edit tooling decodes 4-hex `\uXXXX` to live bytes (PF-018). +- **Pre-merge check verifier** (#289, PF-017): a CANCELLED GitHub Actions run reads as "not failing" to `gh pr merge --admin`, which can merge an unverified head. Before any `--admin` merge, run `node scripts/verify-pr-checks.mjs ` and use the `gh pr merge --squash --match-head-commit ` command it emits. This verifies all required contexts are `completed+success` and pins the SHA. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..c3c37718 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,83 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at deanshrn@gmail.com. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd4ac1c4..f151a403 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,6 +62,44 @@ MDS_BACKEND=native npm test -w @mdscript/mds MDS_BACKEND=wasm npm test -w @mdscript/mds ``` +### Source hygiene + +All tracked source must be free of hazardous codepoints. The gate runs +automatically in CI (`source-hygiene` job) and can be run locally: + +```bash +node scripts/verify-no-control-bytes.mjs # full tracked-tree scan +node scripts/verify-no-control-bytes.mjs --staged # staged-only (pre-commit) +npm run test:gates # positive-control spec suite +``` + +Exit codes are a contract: `0` no hazards found (prints file and byte counts +for non-vacuity), `1` hazard found or scan failed closed (zero files scanned, +unreadable path, stale allowlist entry, git not on PATH), `2` indeterminate +(a git subcommand failed unexpectedly — never treat `2` as clean). + +**Opt-in pre-commit hook** (replaces `.git/hooks` wholesale — document your +existing local hooks before enabling): + +```bash +git config core.hooksPath scripts/hooks +``` + +**Hazard class**: C0 (0x00-0x1F) excluding TAB and LF, DEL (0x7F), C1 +(0x80-0x9F at codepoint level — catches UTF-8-encoded NEL 0xC2 0x85), the +twelve Unicode `Bidi_Control=Yes` codepoints including U+061C (Trojan Source, +CVE-2021-42574), plus U+2028 (LS), U+2029 (PS), and U+FEFF (BOM). CR (U+000D) +is permitted only as the first byte of CRLF. + +**BSD grep trap**: macOS ships BSD grep, which has no `-P` flag and exits 2 +with empty output. That empty output is indistinguishable from a clean scan. +The gate uses pure Node codepoint iteration — never grep. + +**Authoring rule**: when writing code or documentation that mentions hazardous +codepoints, use numeric notation (`U+202E`, `0x202e`, or `String.fromCodePoint(0x202e)`) +rather than backslash-u escapes. The edit tooling decodes the 4-hex-digit form +`\uXXXX` to live bytes, injecting the hazard into the very file that warns about it. + ## Pull requests - **Conventional Commits**: PR titles and commits follow @@ -73,8 +111,57 @@ MDS_BACKEND=wasm npm test -w @mdscript/mds implementation details. - **No regressions**: every existing test must still pass. +## Merging + +**Admin merges require the pre-merge check verifier.** GitHub's `--admin` +flag bypasses required-status enforcement; a cancelled CI run reads as +"not failing" rather than as failing (PF-017). Run the verifier before any +`gh pr merge --admin`: + +```bash +node scripts/verify-pr-checks.mjs +``` + +The verifier reads required contexts from live branch protection, checks that +every context is `status=completed` AND `conclusion=success`, and on pass +emits a `gh pr merge --squash --match-head-commit ` command pinned to +the verified SHA (closes the TOCTOU window). + +Exit codes are a contract: `0` all Tier A and Tier B checks passed, `1` any +Tier A failure (required context missing or non-success), any Tier B failure +(non-required check-run concluded failure/cancelled/timed_out/action_required/ +stale), or zero check-runs found, `2` the tool could not tell (protection +unreadable, no required contexts configured, `gh` older than 2.31, incomplete +pagination). **Only `0` means verified** — never read `2` as a pass. + +Tier B is load-bearing: `source-hygiene` is not among `main`'s required +branch-protection contexts, so Tier B is the sole mechanism that makes a +failing `source-hygiene` run block an `--admin` merge. + +Scope, stated so it is not assumed: the verifier checks the checks *on one +commit*. It does **not** assert that the head is up to date with the base +branch, so a stale-but-green head can still be merged under `--admin` even +after the verifier passes. Keep the branch rebased. It does **not** assert that +`source-hygiene` is a required context — `--admin` bypasses required-status +enforcement outright for non-required checks, and Tier B is the binding +mechanism. Tier B skips non-required check-runs still `queued` or `in_progress`: +a verifier pass issued while `source-hygiene` is still running has verified +nothing about source hygiene. Ensure all jobs have completed before running the +verifier. + +If the base branch is unprotected (e.g. a wave branch), supply `--required-from`: + +```bash +node scripts/verify-pr-checks.mjs --required-from main +``` + ## Security Please report vulnerabilities privately. See [SECURITY.md](./SECURITY.md). Do not open public issues for security problems. +## Code of Conduct + +This project follows the [Contributor Covenant 2.1](CODE_OF_CONDUCT.md). By +participating, you agree to abide by its terms. + diff --git a/README.md b/README.md index 4a96df14..923b6382 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,8 @@ See [spec.md](spec.md) for the full MDS v0.4.0 language specification. ## Contributing Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for the local -workflow and quality gates. +workflow and quality gates. By participating you agree to the +[Contributor Covenant 2.1](CODE_OF_CONDUCT.md). ## Security diff --git a/RELEASING.md b/RELEASING.md index 9385b043..32ca105c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -34,7 +34,6 @@ These are **not** automated and must be done before the first release: `mds-core` and `mds-cli` on crates.io. 4. **Enable GitHub private vulnerability reporting** (Settings → Code security → Private vulnerability reporting) so the SECURITY.md flow works. -5. Add **`CODE_OF_CONDUCT.md`** (tracked in #38) if not already present. ## Pre-flight (before tagging) @@ -58,6 +57,12 @@ npm run build --workspaces --if-present npm test --workspaces --if-present node scripts/verify-versions.mjs +# Source hygiene and pre-merge check gates +node scripts/verify-no-control-bytes.mjs +npm run test:gates # positive-control spec suite +# Before any --admin merge (PF-017 guard — cancelled runs read as green): +node scripts/verify-pr-checks.mjs + # Packaging spot-check (inspect tarball contents) npm pack -w @mdscript/mds --dry-run npm pack -w @mdscript/mds-wasm --dry-run @@ -88,9 +93,19 @@ The release is driven by pushing a `vX.Y.Z` tag. This is how all versions have s 1. **Bump versions:** `node scripts/bump-version.mjs X.Y.Z` (updates all manifests and stamps the CHANGELOG, opening a fresh `[Unreleased]`). -2. **Land the bump on `main`:** open a PR (CI-gated). `main` is protected and the - sole code-owner can't self-approve, so the merge needs an admin override - (`enforce_admins=false` permits it). Squash-merge to keep linear history. +2. **Land the bump on `main`:** open a PR (CI-gated). Once CI is green, run the + pre-merge check verifier before merging — a cancelled run reads as green under + `--admin` (PF-017): + ```bash + node scripts/verify-pr-checks.mjs + ``` + On exit 0 the script prints the exact merge command — copy and run it verbatim: + ```bash + gh pr merge --squash --match-head-commit + ``` + (`main` is protected; the sole code-owner can't self-approve so `--admin` is + required. `--match-head-commit` closes the TOCTOU window between verification + and merge.) 3. **Tag the merged commit and push:** ```bash git tag -a vX.Y.Z -m vX.Y.Z diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index ca302a57..8c62f350 100644 --- a/crates/mds-cli/tests/cli_lint.rs +++ b/crates/mds-cli/tests/cli_lint.rs @@ -1734,7 +1734,7 @@ fn lint_del_and_c1_in_diagnostic_frame_is_sanitized() { /// T-9 [AC-C3]: `mds lint --format json` on a source whose `duplicate-import` /// diagnostic message embeds a raw C1 control character (U+0085 NEL) must emit /// valid JSON with no raw control bytes anywhere — in particular the embedded -/// path must be escaped to the 6-char literal `…`. +/// path must be escaped to its 6-character JSON escape (backslash, u, 0, 0, 8, 5). /// /// ## Why this vector? /// @@ -1750,7 +1750,7 @@ fn lint_del_and_c1_in_diagnostic_frame_is_sanitized() { /// imported twice and embeds the raw import path in its message. A module /// whose file *name* contains U+0085 therefore injects that byte into the /// diagnostic message. When `to_canonical_json` serializes the result, it -/// must sanitize U+0085 → `…` (6-char ASCII literal); if that +/// must sanitize U+0085 into its 6-character ASCII JSON escape; if that /// sanitization is removed the raw 0xC2 0x85 bytes appear in the JSON wire. /// /// ## Failure mode (regression guard) @@ -1760,7 +1760,8 @@ fn lint_del_and_c1_in_diagnostic_frame_is_sanitized() { /// - Gate 2 FAILS: `assert_no_control_chars` finds U+0085 (a C1 char) in /// the JSON wire output /// - Gate 3 FAILS: the per-message check finds U+0085 in the diagnostic message -/// - The positive assertion FAILS: `…` is not present when raw bytes leak +/// - The positive assertion FAILS: the 6-character escape is not present when +/// raw bytes leak #[test] fn lint_json_hostile_source_output_contains_no_raw_control_bytes() { let dir = tempfile::tempdir().unwrap(); @@ -1834,13 +1835,13 @@ fn lint_json_hostile_source_output_contains_no_raw_control_bytes() { assert_no_control_chars(msg, "T-9 diagnostic message"); } - // Positive assertion (non-vacuous, PF-013): the sanitized literal `…` + // Positive assertion (non-vacuous, PF-013): the sanitized escape for U+0085 // must appear in at least one message. If sanitization is removed the raw // U+0085 character leaks and this assertion fails because the 6-char literal // is absent while the raw codepoint (caught by Gate 2/3) is present. // - // After JSON deserialisation by serde_json the string value is `…` - // (6 chars: backslash, u, 0, 0, 8, 5). + // After JSON deserialisation by serde_json the string value is the + // 6-character sequence: backslash, u, 0, 0, 8, 5. let has_sanitized_nel = all_diags.iter().any(|d| { d["message"] .as_str() diff --git a/crates/mds-napi/__test__/index.spec.mjs b/crates/mds-napi/__test__/index.spec.mjs index 21230f21..45570d59 100644 --- a/crates/mds-napi/__test__/index.spec.mjs +++ b/crates/mds-napi/__test__/index.spec.mjs @@ -1323,7 +1323,8 @@ describe('ESC-injection hardening (issue #176 / CWE-150)', () => { // U+0085 (NEL) is a C1 control char that passes serde_yaml_ng YAML parsing // (unlike ESC/DEL), making it a reachable C1 ESC-injection vector for lintVirtual. // The duplicate-import rule fires and embeds the raw module name in its message; - // after sanitization the message must carry … and no raw C1 chars. + // after sanitization the message must carry the 6-character escape for + // U+0085 (backslash, u, 0, 0, 8, 5) and no raw C1 chars. const nel = String.fromCharCode(0x85); const moduleName = `fo${nel}o.mds`; const modules = { diff --git a/crates/mds-wasm/tests/web.rs b/crates/mds-wasm/tests/web.rs index d25da474..8c49e1f2 100644 --- a/crates/mds-wasm/tests/web.rs +++ b/crates/mds-wasm/tests/web.rs @@ -930,7 +930,7 @@ fn wasm_del_in_error_message_is_escaped() { fn wasm_lint_virtual_nel_in_module_name_sanitizes_message() { // T-15/F6-C1: U+0085 (NEL/C1) in lintVirtual module name — same lint-path pattern // as F6 with a C1 control character. NEL passes serde_yaml_ng (unlike ESC/DEL), - // making it a reachable C1 vector. Verifies the sanitized … literal appears. + // making it a reachable C1 vector. Verifies the sanitized U+0085 literal appears. let nel = '\u{0085}'; let module_name = format!("fo{nel}o.mds"); let main_src = format!("@import \"./{module_name}\"\n@import \"./{module_name}\"\n"); diff --git a/package.json b/package.json index 2b916639..2362a57d 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,8 @@ { "private": true, "workspaces": ["packages/*", "crates/mds-napi"], - "engines": { "node": ">=22.0.0" } + "engines": { "node": ">=22.0.0" }, + "scripts": { + "test:gates": "node --test scripts/__test__/*.spec.mjs" + } } diff --git a/packages/bundler-utils/__test__/transform.spec.mjs b/packages/bundler-utils/__test__/transform.spec.mjs index 80dcc97c..378cf806 100644 --- a/packages/bundler-utils/__test__/transform.spec.mjs +++ b/packages/bundler-utils/__test__/transform.spec.mjs @@ -180,8 +180,8 @@ describe('createMdsTransformer', () => { }); test('U+2028 and U+2029 in output are escaped in export default line', async () => { - const u2028 = '
'; - const u2029 = '
'; + const u2028 = String.fromCodePoint(0x2028); + const u2029 = String.fromCodePoint(0x2029); const mds = createMockMds({ async compileFile() { return { @@ -223,8 +223,8 @@ describe('createMdsTransformer', () => { }); test('metadata is safe for inline script embedding (no or U+2028/U+2029)', async () => { - const u2028 = '
'; - const u2029 = '
'; + const u2028 = String.fromCodePoint(0x2028); + const u2029 = String.fromCodePoint(0x2029); const mds = createMockMds({ async compileFile() { return { @@ -377,8 +377,8 @@ describe('createMdsTransformer — intrinsic bundler export', () => { }); test('AC-API-14: messages with U+2028/U+2029 are safe in JSON array export', async () => { - const u2028 = '
'; - const u2029 = '
'; + const u2028 = String.fromCodePoint(0x2028); + const u2029 = String.fromCodePoint(0x2029); const mds = createMockMds({ async compileFile() { return { diff --git a/scripts/__test__/code-of-conduct.spec.mjs b/scripts/__test__/code-of-conduct.spec.mjs new file mode 100644 index 00000000..ab3a253b --- /dev/null +++ b/scripts/__test__/code-of-conduct.spec.mjs @@ -0,0 +1,109 @@ +/** + * Tests for CODE_OF_CONDUCT.md (AC-1, AC-2 — issue #38) + * + * Verifies that the committed Code of Conduct is genuine Contributor Covenant 2.1 + * text with only the maintainer contact substituted, using an offline fixture so + * no network access is required at test time. + * + * applies ADR-009, avoids PF-013: the sha256 digest anchors the fixture to the + * upstream source — asserting only its length would not catch content tampering. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, statSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(fileURLToPath(import.meta.url), '../../..'); +const FIXTURES = join(ROOT, 'scripts/__test__/fixtures'); + +// --------------------------------------------------------------------------- +// AC-1, AC-2: Code of Conduct fixture verification +// --------------------------------------------------------------------------- +describe('AC-1 AC-2: Code of Conduct verification', () => { + // D-COC2: the fixture is the recorded UPSTREAM text, and the sha256 below is + // the whole point of recording it — without a pinned digest, "CODE_OF_CONDUCT.md + // differs from the fixture in exactly one line" can be satisfied by editing the + // fixture. `hash.length === 64` is true of every sha256 ever computed and + // asserts nothing (applies ADR-009, avoids PF-013). + // + // Provenance — reproducible derivation (a reviewer can re-derive FIXTURE_SHA256 + // independently without trusting this file alone): + // + // Source URL (Contributor Covenant 2.1): + // https://raw.githubusercontent.com/EthicalSource/contributor_covenant/ + // release/content/version/2/1/code_of_conduct.md + // + // The upstream file carries a Hugo TOML front-matter block (+++ ... +++) as + // site metadata followed by a blank line before the document body. Strip both + // and hash the remainder: + // + // URL='https://raw.githubusercontent.com/EthicalSource/contributor_covenant/release/content/version/2/1/code_of_conduct.md' + // curl -sL "$URL" \ + // | awk '/^\+\+\+$/{c++; if(c==2){emit=1}; next} emit && !started && /^$/{next} emit{started=1; print}' \ + // | shasum -a 256 + // # Expected: 369bf7301883368fc19203bd0f1233fed2b83f0378ad19c4d0708bf61925339b + // + // Verified 2026-08-13: the command above produces FIXTURE_SHA256 (5478 bytes). + // + // Historical reference — upstream unstripped digest at plan-authoring time + // (the plan recorded sha256 977d781349351fd7c1f076e4c7dc7de2a05b40e12c773542c3815dd4ce7f37ba, + // 5480 bytes; the upstream body has since changed — 5579 bytes unstripped as of + // 2026-08-13 — but the stripped body matches the fixture exactly). + // + // If re-running the derivation command above produces a hash other than + // FIXTURE_SHA256, the upstream body has changed; review the diff and update + // the fixture and this comment if the change is legitimate. + const FIXTURE_SHA256 = '369bf7301883368fc19203bd0f1233fed2b83f0378ad19c4d0708bf61925339b'; + const FIXTURE_BYTES = 5478; + + test('fixture matches its recorded sha256 and byte count exactly', () => { + const fixturePath = join(FIXTURES, 'contributor-covenant-2.1.md'); + const buf = readFileSync(fixturePath); + const hash = createHash('sha256').update(buf).digest('hex'); + const size = statSync(fixturePath).size; + assert.equal(size, FIXTURE_BYTES, `fixture must be exactly ${FIXTURE_BYTES} bytes; got ${size}`); + assert.equal(hash, FIXTURE_SHA256, + 'fixture no longer matches the recorded upstream digest — the vendored Contributor ' + + 'Covenant text was modified; restore it rather than updating this constant'); + }); + + test('CODE_OF_CONDUCT.md differs from fixture in exactly one line (contact substitution)', () => { + const cocPath = join(ROOT, 'CODE_OF_CONDUCT.md'); + const fixturePath = join(FIXTURES, 'contributor-covenant-2.1.md'); + const coc = readFileSync(cocPath, 'utf8'); + const fixture = readFileSync(fixturePath, 'utf8'); + + const cocLines = coc.split('\n'); + const fixtureLines = fixture.split('\n'); + + // Find differing lines + const maxLen = Math.max(cocLines.length, fixtureLines.length); + const diffs = []; + for (let i = 0; i < maxLen; i++) { + if (cocLines[i] !== fixtureLines[i]) { + diffs.push({ lineNo: i + 1, coc: cocLines[i], fixture: fixtureLines[i] }); + } + } + + assert.equal(diffs.length, 1, + `CODE_OF_CONDUCT.md must differ from fixture in exactly 1 line; got ${diffs.length} diff(s): ` + + JSON.stringify(diffs)); + assert.ok(diffs[0].coc.includes('deanshrn@gmail.com'), + `the differing line must contain 'deanshrn@gmail.com'; got: ${diffs[0].coc}`); + assert.ok( + (diffs[0].fixture ?? '').includes('[INSERT CONTACT METHOD]'), + `fixture's differing line must contain '[INSERT CONTACT METHOD]'; got: ${diffs[0].fixture}` + ); + }); + + test('CODE_OF_CONDUCT.md does not contain [INSERT CONTACT METHOD]', () => { + const coc = readFileSync(join(ROOT, 'CODE_OF_CONDUCT.md'), 'utf8'); + assert.ok(!coc.includes('[INSERT CONTACT METHOD]'), + 'CODE_OF_CONDUCT.md must not contain [INSERT CONTACT METHOD]'); + assert.ok(coc.includes('deanshrn@gmail.com'), + 'CODE_OF_CONDUCT.md must contain the contact email'); + }); +}); diff --git a/scripts/__test__/fixtures/checks-main-113f472.json b/scripts/__test__/fixtures/checks-main-113f472.json new file mode 100644 index 00000000..6447a18a --- /dev/null +++ b/scripts/__test__/fixtures/checks-main-113f472.json @@ -0,0 +1 @@ +{"total_count":18,"check_runs":[{"id":93277985118,"name":"Analyze (rust)","node_id":"CR_kwDOSZrySs8AAAAVt80ZXg","head_sha":"113f472684d6ee7e398d54c1aadc22b2ad747ae1","external_id":"872bf908-fdd5-568f-8a39-a3501fb0bef4","url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277985118","html_url":"https://github.com/dean0x/mdscript/actions/runs/31326647668/job/93277985118","details_url":"https://github.com/dean0x/mdscript/actions/runs/31326647668/job/93277985118","status":"completed","conclusion":"success","started_at":"2026-08-09T17:32:06Z","completed_at":"2026-08-09T17:34:28Z","output":{"title":null,"summary":null,"text":null,"annotations_count":0,"annotations_url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277985118/annotations"},"check_suite":{"id":84976778237},"app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2026-06-18T16:17:48Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","code_quality":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","drives":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]},"pull_requests":[]},{"id":93277985102,"name":"Analyze (actions)","node_id":"CR_kwDOSZrySs8AAAAVt80ZTg","head_sha":"113f472684d6ee7e398d54c1aadc22b2ad747ae1","external_id":"4cf06604-198c-5a3d-8f72-cff3cad0f308","url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277985102","html_url":"https://github.com/dean0x/mdscript/actions/runs/31326647668/job/93277985102","details_url":"https://github.com/dean0x/mdscript/actions/runs/31326647668/job/93277985102","status":"completed","conclusion":"success","started_at":"2026-08-09T17:32:06Z","completed_at":"2026-08-09T17:32:50Z","output":{"title":null,"summary":null,"text":null,"annotations_count":0,"annotations_url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277985102/annotations"},"check_suite":{"id":84976778237},"app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2026-06-18T16:17:48Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","code_quality":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","drives":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]},"pull_requests":[]},{"id":93277985090,"name":"Analyze (javascript-typescript)","node_id":"CR_kwDOSZrySs8AAAAVt80ZQg","head_sha":"113f472684d6ee7e398d54c1aadc22b2ad747ae1","external_id":"04742353-aa2c-535a-b9bb-3ef6570c9c55","url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277985090","html_url":"https://github.com/dean0x/mdscript/actions/runs/31326647668/job/93277985090","details_url":"https://github.com/dean0x/mdscript/actions/runs/31326647668/job/93277985090","status":"completed","conclusion":"success","started_at":"2026-08-09T17:32:06Z","completed_at":"2026-08-09T17:33:28Z","output":{"title":null,"summary":null,"text":null,"annotations_count":0,"annotations_url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277985090/annotations"},"check_suite":{"id":84976778237},"app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2026-06-18T16:17:48Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","code_quality":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","drives":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]},"pull_requests":[]},{"id":93277985073,"name":"Analyze (python)","node_id":"CR_kwDOSZrySs8AAAAVt80ZMQ","head_sha":"113f472684d6ee7e398d54c1aadc22b2ad747ae1","external_id":"97bd7258-8980-5678-891a-8f3fcb6be231","url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277985073","html_url":"https://github.com/dean0x/mdscript/actions/runs/31326647668/job/93277985073","details_url":"https://github.com/dean0x/mdscript/actions/runs/31326647668/job/93277985073","status":"completed","conclusion":"success","started_at":"2026-08-09T17:32:06Z","completed_at":"2026-08-09T17:33:03Z","output":{"title":null,"summary":null,"text":null,"annotations_count":0,"annotations_url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277985073/annotations"},"check_suite":{"id":84976778237},"app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2026-06-18T16:17:48Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","code_quality":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","drives":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]},"pull_requests":[]},{"id":93277984678,"name":"Python — build & test (windows-latest, 3.13)","node_id":"CR_kwDOSZrySs8AAAAVt80Xpg","head_sha":"113f472684d6ee7e398d54c1aadc22b2ad747ae1","external_id":"dbeedd06-a99e-59b0-a96a-df9afb5f51c0","url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984678","html_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984678","details_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984678","status":"completed","conclusion":"success","started_at":"2026-08-09T17:32:06Z","completed_at":"2026-08-09T17:34:04Z","output":{"title":null,"summary":null,"text":null,"annotations_count":1,"annotations_url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984678/annotations"},"check_suite":{"id":84976779019},"app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2026-06-18T16:17:48Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","code_quality":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","drives":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]},"pull_requests":[]},{"id":93277984676,"name":"Python — build & test (macos-latest, 3.11)","node_id":"CR_kwDOSZrySs8AAAAVt80XpA","head_sha":"113f472684d6ee7e398d54c1aadc22b2ad747ae1","external_id":"e345ad8f-1277-515d-9e05-a7ebecf17090","url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984676","html_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984676","details_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984676","status":"completed","conclusion":"success","started_at":"2026-08-09T17:32:07Z","completed_at":"2026-08-09T17:32:51Z","output":{"title":null,"summary":null,"text":null,"annotations_count":1,"annotations_url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984676/annotations"},"check_suite":{"id":84976779019},"app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2026-06-18T16:17:48Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","code_quality":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","drives":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]},"pull_requests":[]},{"id":93277984673,"name":"Python — build & test (ubuntu-latest, 3.11)","node_id":"CR_kwDOSZrySs8AAAAVt80XoQ","head_sha":"113f472684d6ee7e398d54c1aadc22b2ad747ae1","external_id":"fb40c97d-49cd-5bbb-8fc3-dd1e1fc5b206","url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984673","html_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984673","details_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984673","status":"completed","conclusion":"success","started_at":"2026-08-09T17:32:06Z","completed_at":"2026-08-09T17:32:46Z","output":{"title":null,"summary":null,"text":null,"annotations_count":1,"annotations_url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984673/annotations"},"check_suite":{"id":84976779019},"app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2026-06-18T16:17:48Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","code_quality":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","drives":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]},"pull_requests":[]},{"id":93277984667,"name":"Python — build & test (windows-latest, 3.11)","node_id":"CR_kwDOSZrySs8AAAAVt80Xmw","head_sha":"113f472684d6ee7e398d54c1aadc22b2ad747ae1","external_id":"517319ba-df47-5e06-bccd-ed5454b139b0","url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984667","html_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984667","details_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984667","status":"completed","conclusion":"success","started_at":"2026-08-09T17:32:07Z","completed_at":"2026-08-09T17:34:07Z","output":{"title":null,"summary":null,"text":null,"annotations_count":1,"annotations_url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984667/annotations"},"check_suite":{"id":84976779019},"app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2026-06-18T16:17:48Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","code_quality":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","drives":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]},"pull_requests":[]},{"id":93277984664,"name":"Python — build & test (ubuntu-latest, 3.13)","node_id":"CR_kwDOSZrySs8AAAAVt80XmA","head_sha":"113f472684d6ee7e398d54c1aadc22b2ad747ae1","external_id":"ddfd938d-4556-5211-be2e-217238a84f96","url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984664","html_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984664","details_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984664","status":"completed","conclusion":"success","started_at":"2026-08-09T17:32:06Z","completed_at":"2026-08-09T17:32:56Z","output":{"title":null,"summary":null,"text":null,"annotations_count":1,"annotations_url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984664/annotations"},"check_suite":{"id":84976779019},"app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2026-06-18T16:17:48Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","code_quality":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","drives":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]},"pull_requests":[]},{"id":93277984663,"name":"Python — build & test (macos-latest, 3.13)","node_id":"CR_kwDOSZrySs8AAAAVt80Xlw","head_sha":"113f472684d6ee7e398d54c1aadc22b2ad747ae1","external_id":"8f759491-5790-5262-bdcc-733319bc4c3f","url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984663","html_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984663","details_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984663","status":"completed","conclusion":"success","started_at":"2026-08-09T17:32:07Z","completed_at":"2026-08-09T17:33:12Z","output":{"title":null,"summary":null,"text":null,"annotations_count":1,"annotations_url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984663/annotations"},"check_suite":{"id":84976779019},"app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2026-06-18T16:17:48Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","code_quality":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","drives":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]},"pull_requests":[]},{"id":93277984645,"name":"JS packages — build & test (macos-latest)","node_id":"CR_kwDOSZrySs8AAAAVt80XhQ","head_sha":"113f472684d6ee7e398d54c1aadc22b2ad747ae1","external_id":"1bf4c4ad-62f3-50f2-a547-1f2b0f845f74","url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984645","html_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984645","details_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984645","status":"completed","conclusion":"success","started_at":"2026-08-09T17:32:07Z","completed_at":"2026-08-09T17:33:46Z","output":{"title":null,"summary":null,"text":null,"annotations_count":1,"annotations_url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984645/annotations"},"check_suite":{"id":84976779019},"app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2026-06-18T16:17:48Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","code_quality":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","drives":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]},"pull_requests":[]},{"id":93277984638,"name":"MSRV (Rust 1.88)","node_id":"CR_kwDOSZrySs8AAAAVt80Xfg","head_sha":"113f472684d6ee7e398d54c1aadc22b2ad747ae1","external_id":"00b78193-8e2c-52f2-9901-9de7a14c9c7d","url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984638","html_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984638","details_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984638","status":"completed","conclusion":"success","started_at":"2026-08-09T17:32:06Z","completed_at":"2026-08-09T17:32:27Z","output":{"title":null,"summary":null,"text":null,"annotations_count":1,"annotations_url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984638/annotations"},"check_suite":{"id":84976779019},"app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2026-06-18T16:17:48Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","code_quality":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","drives":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]},"pull_requests":[]},{"id":93277984636,"name":"JS packages — build & test (windows-latest)","node_id":"CR_kwDOSZrySs8AAAAVt80XfA","head_sha":"113f472684d6ee7e398d54c1aadc22b2ad747ae1","external_id":"ef930824-c7e1-5aeb-af68-d205b391dfd2","url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984636","html_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984636","details_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984636","status":"completed","conclusion":"success","started_at":"2026-08-09T17:32:06Z","completed_at":"2026-08-09T17:36:15Z","output":{"title":null,"summary":null,"text":null,"annotations_count":1,"annotations_url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984636/annotations"},"check_suite":{"id":84976779019},"app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2026-06-18T16:17:48Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","code_quality":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","drives":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]},"pull_requests":[]},{"id":93277984633,"name":"JS packages — build & test (ubuntu-latest)","node_id":"CR_kwDOSZrySs8AAAAVt80XeQ","head_sha":"113f472684d6ee7e398d54c1aadc22b2ad747ae1","external_id":"6ab1c9e6-400a-517c-a31b-acb1824cf47d","url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984633","html_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984633","details_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984633","status":"completed","conclusion":"success","started_at":"2026-08-09T17:32:06Z","completed_at":"2026-08-09T17:34:07Z","output":{"title":null,"summary":null,"text":null,"annotations_count":1,"annotations_url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984633/annotations"},"check_suite":{"id":84976779019},"app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2026-06-18T16:17:48Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","code_quality":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","drives":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]},"pull_requests":[]},{"id":93277984625,"name":"Python — wheel install smoke","node_id":"CR_kwDOSZrySs8AAAAVt80XcQ","head_sha":"113f472684d6ee7e398d54c1aadc22b2ad747ae1","external_id":"7b5b36c4-fede-50be-851b-0b1b0e6956e6","url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984625","html_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984625","details_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984625","status":"completed","conclusion":"success","started_at":"2026-08-09T17:32:07Z","completed_at":"2026-08-09T17:32:41Z","output":{"title":null,"summary":null,"text":null,"annotations_count":1,"annotations_url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984625/annotations"},"check_suite":{"id":84976779019},"app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2026-06-18T16:17:48Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","code_quality":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","drives":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]},"pull_requests":[]},{"id":93277984618,"name":"Rust — fmt, clippy, test","node_id":"CR_kwDOSZrySs8AAAAVt80Xag","head_sha":"113f472684d6ee7e398d54c1aadc22b2ad747ae1","external_id":"3d468161-463d-586b-89ee-cbfb23ea3e22","url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984618","html_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984618","details_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984618","status":"completed","conclusion":"success","started_at":"2026-08-09T17:32:06Z","completed_at":"2026-08-09T17:33:02Z","output":{"title":null,"summary":null,"text":null,"annotations_count":1,"annotations_url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984618/annotations"},"check_suite":{"id":84976779019},"app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2026-06-18T16:17:48Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","code_quality":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","drives":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]},"pull_requests":[]},{"id":93277984617,"name":"WASM — build & test","node_id":"CR_kwDOSZrySs8AAAAVt80XaQ","head_sha":"113f472684d6ee7e398d54c1aadc22b2ad747ae1","external_id":"98b8fcc4-85f8-5fd4-9a03-19b170335046","url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984617","html_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984617","details_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984617","status":"completed","conclusion":"success","started_at":"2026-08-09T17:32:06Z","completed_at":"2026-08-09T17:33:13Z","output":{"title":null,"summary":null,"text":null,"annotations_count":3,"annotations_url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984617/annotations"},"check_suite":{"id":84976779019},"app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2026-06-18T16:17:48Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","code_quality":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","drives":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]},"pull_requests":[]},{"id":93277984607,"name":"examples/ gitignore coverage","node_id":"CR_kwDOSZrySs8AAAAVt80XXw","head_sha":"113f472684d6ee7e398d54c1aadc22b2ad747ae1","external_id":"62f8b6c6-dd67-5fb1-8bc5-01e77da4dfbc","url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984607","html_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984607","details_url":"https://github.com/dean0x/mdscript/actions/runs/31326648041/job/93277984607","status":"completed","conclusion":"success","started_at":"2026-08-09T17:32:06Z","completed_at":"2026-08-09T17:32:19Z","output":{"title":null,"summary":null,"text":null,"annotations_count":0,"annotations_url":"https://api.github.com/repos/dean0x/mdscript/check-runs/93277984607/annotations"},"check_suite":{"id":84976779019},"app":{"id":15368,"client_id":"Iv1.05c79e9ad1f6bdfa","slug":"github-actions","node_id":"MDM6QXBwMTUzNjg=","owner":{"login":"github","id":9919,"node_id":"MDEyOk9yZ2FuaXphdGlvbjk5MTk=","avatar_url":"https://avatars.githubusercontent.com/u/9919?v=4","gravatar_id":"","url":"https://api.github.com/users/github","html_url":"https://github.com/github","followers_url":"https://api.github.com/users/github/followers","following_url":"https://api.github.com/users/github/following{/other_user}","gists_url":"https://api.github.com/users/github/gists{/gist_id}","starred_url":"https://api.github.com/users/github/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github/subscriptions","organizations_url":"https://api.github.com/users/github/orgs","repos_url":"https://api.github.com/users/github/repos","events_url":"https://api.github.com/users/github/events{/privacy}","received_events_url":"https://api.github.com/users/github/received_events","type":"Organization","user_view_type":"public","site_admin":false},"name":"GitHub Actions","description":"Automate your workflow from idea to production","external_url":"https://help.github.com/en/actions","html_url":"https://github.com/apps/github-actions","created_at":"2018-07-30T09:30:17Z","updated_at":"2026-06-18T16:17:48Z","permissions":{"actions":"write","administration":"read","artifact_metadata":"write","attestations":"write","checks":"write","code_quality":"write","contents":"write","copilot_requests":"write","deployments":"write","discussions":"write","drives":"write","issues":"write","merge_queues":"write","metadata":"read","models":"read","packages":"write","pages":"write","pull_requests":"write","repository_hooks":"write","repository_projects":"write","security_events":"write","statuses":"write","vulnerability_alerts":"read"},"events":["branch_protection_rule","check_run","check_suite","create","delete","deployment","deployment_status","discussion","discussion_comment","fork","gollum","issues","issue_comment","label","merge_group","milestone","page_build","public","pull_request","pull_request_review","pull_request_review_comment","push","registry_package","release","repository","repository_dispatch","status","watch","workflow_dispatch","workflow_run"]},"pull_requests":[]}]} \ No newline at end of file diff --git a/scripts/__test__/fixtures/checks-pr239-f168944.json b/scripts/__test__/fixtures/checks-pr239-f168944.json new file mode 100644 index 00000000..e7a8ee1c --- /dev/null +++ b/scripts/__test__/fixtures/checks-pr239-f168944.json @@ -0,0 +1 @@ +{"total_count":0,"check_runs":[]} \ No newline at end of file diff --git a/scripts/__test__/fixtures/checks-pr240-e9dace1.json b/scripts/__test__/fixtures/checks-pr240-e9dace1.json new file mode 100644 index 00000000..e7a8ee1c --- /dev/null +++ b/scripts/__test__/fixtures/checks-pr240-e9dace1.json @@ -0,0 +1 @@ +{"total_count":0,"check_runs":[]} \ No newline at end of file diff --git a/scripts/__test__/fixtures/contributor-covenant-2.1.md b/scripts/__test__/fixtures/contributor-covenant-2.1.md new file mode 100644 index 00000000..737de08a --- /dev/null +++ b/scripts/__test__/fixtures/contributor-covenant-2.1.md @@ -0,0 +1,83 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [INSERT CONTACT METHOD]. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/scripts/__test__/fixtures/protection-main.json b/scripts/__test__/fixtures/protection-main.json new file mode 100644 index 00000000..8287869b --- /dev/null +++ b/scripts/__test__/fixtures/protection-main.json @@ -0,0 +1 @@ +{"url":"https://api.github.com/repos/dean0x/mdscript/branches/main/protection","required_status_checks":{"url":"https://api.github.com/repos/dean0x/mdscript/branches/main/protection/required_status_checks","strict":true,"contexts":["Rust — fmt, clippy, test","MSRV (Rust 1.88)","WASM — build & test","JS packages — build & test (ubuntu-latest)","JS packages — build & test (macos-latest)","JS packages — build & test (windows-latest)"],"contexts_url":"https://api.github.com/repos/dean0x/mdscript/branches/main/protection/required_status_checks/contexts","checks":[{"context":"Rust — fmt, clippy, test","app_id":15368},{"context":"MSRV (Rust 1.88)","app_id":15368},{"context":"WASM — build & test","app_id":15368},{"context":"JS packages — build & test (ubuntu-latest)","app_id":15368},{"context":"JS packages — build & test (macos-latest)","app_id":15368},{"context":"JS packages — build & test (windows-latest)","app_id":15368}]},"required_pull_request_reviews":{"url":"https://api.github.com/repos/dean0x/mdscript/branches/main/protection/required_pull_request_reviews","dismiss_stale_reviews":true,"require_code_owner_reviews":true,"require_last_push_approval":false,"required_approving_review_count":1},"required_signatures":{"url":"https://api.github.com/repos/dean0x/mdscript/branches/main/protection/required_signatures","enabled":false},"enforce_admins":{"url":"https://api.github.com/repos/dean0x/mdscript/branches/main/protection/enforce_admins","enabled":false},"required_linear_history":{"enabled":true},"allow_force_pushes":{"enabled":false},"allow_deletions":{"enabled":false},"block_creations":{"enabled":false},"required_conversation_resolution":{"enabled":false},"lock_branch":{"enabled":false},"allow_fork_syncing":{"enabled":false}} \ No newline at end of file diff --git a/scripts/__test__/fixtures/status-pr239-f168944.json b/scripts/__test__/fixtures/status-pr239-f168944.json new file mode 100644 index 00000000..14fde9e2 --- /dev/null +++ b/scripts/__test__/fixtures/status-pr239-f168944.json @@ -0,0 +1 @@ +{"state":"pending","statuses":[],"sha":"f168944602ee4fd13187d3500a45adebd5a0b655","total_count":0,"repository":{"id":1234891338,"node_id":"R_kgDOSZrySg","name":"mdscript","full_name":"dean0x/mdscript","private":false,"owner":{"login":"dean0x","id":19309140,"node_id":"MDQ6VXNlcjE5MzA5MTQw","avatar_url":"https://avatars.githubusercontent.com/u/19309140?v=4","gravatar_id":"","url":"https://api.github.com/users/dean0x","html_url":"https://github.com/dean0x","followers_url":"https://api.github.com/users/dean0x/followers","following_url":"https://api.github.com/users/dean0x/following{/other_user}","gists_url":"https://api.github.com/users/dean0x/gists{/gist_id}","starred_url":"https://api.github.com/users/dean0x/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/dean0x/subscriptions","organizations_url":"https://api.github.com/users/dean0x/orgs","repos_url":"https://api.github.com/users/dean0x/repos","events_url":"https://api.github.com/users/dean0x/events{/privacy}","received_events_url":"https://api.github.com/users/dean0x/received_events","type":"User","user_view_type":"public","site_admin":false},"html_url":"https://github.com/dean0x/mdscript","description":"A template language for composable LLM prompt engineering. Write prompts with variables, loops, conditionals, functions, and imports, then compile to clean Markdown.","fork":false,"url":"https://api.github.com/repos/dean0x/mdscript","forks_url":"https://api.github.com/repos/dean0x/mdscript/forks","keys_url":"https://api.github.com/repos/dean0x/mdscript/keys{/key_id}","collaborators_url":"https://api.github.com/repos/dean0x/mdscript/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/dean0x/mdscript/teams","hooks_url":"https://api.github.com/repos/dean0x/mdscript/hooks","issue_events_url":"https://api.github.com/repos/dean0x/mdscript/issues/events{/number}","events_url":"https://api.github.com/repos/dean0x/mdscript/events","assignees_url":"https://api.github.com/repos/dean0x/mdscript/assignees{/user}","branches_url":"https://api.github.com/repos/dean0x/mdscript/branches{/branch}","tags_url":"https://api.github.com/repos/dean0x/mdscript/tags","blobs_url":"https://api.github.com/repos/dean0x/mdscript/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/dean0x/mdscript/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/dean0x/mdscript/git/refs{/sha}","trees_url":"https://api.github.com/repos/dean0x/mdscript/git/trees{/sha}","statuses_url":"https://api.github.com/repos/dean0x/mdscript/statuses/{sha}","languages_url":"https://api.github.com/repos/dean0x/mdscript/languages","stargazers_url":"https://api.github.com/repos/dean0x/mdscript/stargazers","contributors_url":"https://api.github.com/repos/dean0x/mdscript/contributors","subscribers_url":"https://api.github.com/repos/dean0x/mdscript/subscribers","subscription_url":"https://api.github.com/repos/dean0x/mdscript/subscription","commits_url":"https://api.github.com/repos/dean0x/mdscript/commits{/sha}","git_commits_url":"https://api.github.com/repos/dean0x/mdscript/git/commits{/sha}","comments_url":"https://api.github.com/repos/dean0x/mdscript/comments{/number}","issue_comment_url":"https://api.github.com/repos/dean0x/mdscript/issues/comments{/number}","contents_url":"https://api.github.com/repos/dean0x/mdscript/contents/{+path}","compare_url":"https://api.github.com/repos/dean0x/mdscript/compare/{base}...{head}","merges_url":"https://api.github.com/repos/dean0x/mdscript/merges","archive_url":"https://api.github.com/repos/dean0x/mdscript/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/dean0x/mdscript/downloads","issues_url":"https://api.github.com/repos/dean0x/mdscript/issues{/number}","pulls_url":"https://api.github.com/repos/dean0x/mdscript/pulls{/number}","milestones_url":"https://api.github.com/repos/dean0x/mdscript/milestones{/number}","notifications_url":"https://api.github.com/repos/dean0x/mdscript/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/dean0x/mdscript/labels{/name}","releases_url":"https://api.github.com/repos/dean0x/mdscript/releases{/id}","deployments_url":"https://api.github.com/repos/dean0x/mdscript/deployments"},"commit_url":"https://api.github.com/repos/dean0x/mdscript/commits/f168944602ee4fd13187d3500a45adebd5a0b655","url":"https://api.github.com/repos/dean0x/mdscript/commits/f168944602ee4fd13187d3500a45adebd5a0b655/status"} \ No newline at end of file diff --git a/scripts/__test__/fixtures/status-pr240-e9dace1.json b/scripts/__test__/fixtures/status-pr240-e9dace1.json new file mode 100644 index 00000000..6a24be38 --- /dev/null +++ b/scripts/__test__/fixtures/status-pr240-e9dace1.json @@ -0,0 +1 @@ +{"state":"failure","statuses":[{"url":"https://api.github.com/repos/dean0x/mdscript/statuses/e9dace17ac4a70b513cceb0ca8f8b0271f72410c","avatar_url":"https://avatars.githubusercontent.com/oa/358121?v=4","id":50955921794,"node_id":"SC_kwDOSZrySs8AAAAL3TWpgg","state":"error","description":"You have used your limit of private tests","target_url":"https://app.snyk.io/org/dean0x/pr-checks/734c7c36-097f-4228-91c8-40d27bf29efd","context":"security/snyk (dean0x)","created_at":"2026-07-23T09:39:56Z","updated_at":"2026-07-23T09:39:56Z"}],"sha":"e9dace17ac4a70b513cceb0ca8f8b0271f72410c","total_count":1,"repository":{"id":1234891338,"node_id":"R_kgDOSZrySg","name":"mdscript","full_name":"dean0x/mdscript","private":false,"owner":{"login":"dean0x","id":19309140,"node_id":"MDQ6VXNlcjE5MzA5MTQw","avatar_url":"https://avatars.githubusercontent.com/u/19309140?v=4","gravatar_id":"","url":"https://api.github.com/users/dean0x","html_url":"https://github.com/dean0x","followers_url":"https://api.github.com/users/dean0x/followers","following_url":"https://api.github.com/users/dean0x/following{/other_user}","gists_url":"https://api.github.com/users/dean0x/gists{/gist_id}","starred_url":"https://api.github.com/users/dean0x/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/dean0x/subscriptions","organizations_url":"https://api.github.com/users/dean0x/orgs","repos_url":"https://api.github.com/users/dean0x/repos","events_url":"https://api.github.com/users/dean0x/events{/privacy}","received_events_url":"https://api.github.com/users/dean0x/received_events","type":"User","user_view_type":"public","site_admin":false},"html_url":"https://github.com/dean0x/mdscript","description":"A template language for composable LLM prompt engineering. Write prompts with variables, loops, conditionals, functions, and imports, then compile to clean Markdown.","fork":false,"url":"https://api.github.com/repos/dean0x/mdscript","forks_url":"https://api.github.com/repos/dean0x/mdscript/forks","keys_url":"https://api.github.com/repos/dean0x/mdscript/keys{/key_id}","collaborators_url":"https://api.github.com/repos/dean0x/mdscript/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/dean0x/mdscript/teams","hooks_url":"https://api.github.com/repos/dean0x/mdscript/hooks","issue_events_url":"https://api.github.com/repos/dean0x/mdscript/issues/events{/number}","events_url":"https://api.github.com/repos/dean0x/mdscript/events","assignees_url":"https://api.github.com/repos/dean0x/mdscript/assignees{/user}","branches_url":"https://api.github.com/repos/dean0x/mdscript/branches{/branch}","tags_url":"https://api.github.com/repos/dean0x/mdscript/tags","blobs_url":"https://api.github.com/repos/dean0x/mdscript/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/dean0x/mdscript/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/dean0x/mdscript/git/refs{/sha}","trees_url":"https://api.github.com/repos/dean0x/mdscript/git/trees{/sha}","statuses_url":"https://api.github.com/repos/dean0x/mdscript/statuses/{sha}","languages_url":"https://api.github.com/repos/dean0x/mdscript/languages","stargazers_url":"https://api.github.com/repos/dean0x/mdscript/stargazers","contributors_url":"https://api.github.com/repos/dean0x/mdscript/contributors","subscribers_url":"https://api.github.com/repos/dean0x/mdscript/subscribers","subscription_url":"https://api.github.com/repos/dean0x/mdscript/subscription","commits_url":"https://api.github.com/repos/dean0x/mdscript/commits{/sha}","git_commits_url":"https://api.github.com/repos/dean0x/mdscript/git/commits{/sha}","comments_url":"https://api.github.com/repos/dean0x/mdscript/comments{/number}","issue_comment_url":"https://api.github.com/repos/dean0x/mdscript/issues/comments{/number}","contents_url":"https://api.github.com/repos/dean0x/mdscript/contents/{+path}","compare_url":"https://api.github.com/repos/dean0x/mdscript/compare/{base}...{head}","merges_url":"https://api.github.com/repos/dean0x/mdscript/merges","archive_url":"https://api.github.com/repos/dean0x/mdscript/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/dean0x/mdscript/downloads","issues_url":"https://api.github.com/repos/dean0x/mdscript/issues{/number}","pulls_url":"https://api.github.com/repos/dean0x/mdscript/pulls{/number}","milestones_url":"https://api.github.com/repos/dean0x/mdscript/milestones{/number}","notifications_url":"https://api.github.com/repos/dean0x/mdscript/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/dean0x/mdscript/labels{/name}","releases_url":"https://api.github.com/repos/dean0x/mdscript/releases{/id}","deployments_url":"https://api.github.com/repos/dean0x/mdscript/deployments"},"commit_url":"https://api.github.com/repos/dean0x/mdscript/commits/e9dace17ac4a70b513cceb0ca8f8b0271f72410c","url":"https://api.github.com/repos/dean0x/mdscript/commits/e9dace17ac4a70b513cceb0ca8f8b0271f72410c/status"} \ No newline at end of file diff --git a/scripts/__test__/verify-no-control-bytes.spec.mjs b/scripts/__test__/verify-no-control-bytes.spec.mjs new file mode 100644 index 00000000..01c8679e --- /dev/null +++ b/scripts/__test__/verify-no-control-bytes.spec.mjs @@ -0,0 +1,791 @@ +/** + * Tests for scripts/verify-no-control-bytes.mjs + * + * All hazard bytes are constructed AT RUNTIME using Buffer.from([0xNN]) or + * String.fromCodePoint(0xNNNN). No hazard literal or backslash-u escape + * appears in this file. (avoids PF-018, applies D-CB2) + * + * Tests that require a real git repository use mkdtemp + git init. The + * hermetic-git test (AC-11) proves the git ls-files discovery path rather + * than just the byte predicate, running in the PRIMARY checkout context + * (avoids PF-016). + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, rmSync, readFileSync, symlinkSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync, execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { + HAZARD_RANGES, + isHazardous, + scanBuffer, + BINARY_ALLOWLIST, + HAZARD_ALLOWLIST, +} from '../verify-no-control-bytes.mjs'; + +const ROOT = resolve(fileURLToPath(import.meta.url), '../../..'); +const SCANNER = join(ROOT, 'scripts/verify-no-control-bytes.mjs'); + +// --------------------------------------------------------------------------- +// Helper: run scanner as subprocess +// --------------------------------------------------------------------------- +function runScanner(args = [], opts = {}) { + const r = spawnSync(process.execPath, [SCANNER, ...args], { + cwd: opts.cwd ?? ROOT, + encoding: 'utf8', + env: { ...process.env, ...(opts.env ?? {}) }, + timeout: 30000, + }); + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; +} + +// --------------------------------------------------------------------------- +// Helper: create a minimal git repo in a temp directory +// --------------------------------------------------------------------------- +function mkTempGitRepo() { + const dir = mkdtempSync(join(tmpdir(), 'mds-scan-')); + const git = (...args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: 'pipe' }); + git('init'); + git('config', 'user.email', 'test@test.test'); + git('config', 'user.name', 'Test'); + return { dir, git }; +} + +function cleanup(dir) { + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } +} + +// --------------------------------------------------------------------------- +// AC-12, AC-13: Golden-set completeness — hazard class cannot silently narrow +// --------------------------------------------------------------------------- +describe('AC-12 AC-13: hazard class golden set', () => { + + test('HAZARD_RANGES has exactly 21 entries (golden count)', () => { + // D-CB1a: this count is the golden reference. If an entry is removed, + // this test fails — silent narrowing is impossible. + assert.equal(HAZARD_RANGES.length, 21, + `HAZARD_RANGES must have 21 entries; got ${HAZARD_RANGES.length}. ` + + `A member was silently removed (D-CB1a prevents this).`); + }); + + test('HAZARD_RANGES contains every required member', () => { + // Golden list of expected entries (AC-12). Numbers = individual codepoints. + const expectedNumbers = new Set([ + 0x061c, // U+061C Arabic Letter Mark + 0x200e, // U+200E LRM + 0x200f, // U+200F RLM + 0x202a, // U+202A LRE + 0x202b, // U+202B RLE + 0x202c, // U+202C PDF + 0x202d, // U+202D LRO + 0x202e, // U+202E RLO + 0x2066, // U+2066 LRI + 0x2067, // U+2067 RLI + 0x2068, // U+2068 FSI + 0x2069, // U+2069 PDI + 0x2028, // U+2028 LS + 0x2029, // U+2029 PS + 0xfeff, // U+FEFF BOM + ]); + const expectedRanges = [ + { from: 0x00, to: 0x08 }, // C0 below TAB + { from: 0x0b, to: 0x0c }, // C0 VT/FF + { from: 0x0e, to: 0x1f }, // C0 above CR + { from: 0x7f, to: 0x7f }, // DEL + { from: 0x80, to: 0x9f }, // C1 + ]; + const crlfEntry = HAZARD_RANGES.find(e => e && typeof e === 'object' && e.crlfException); + + // Check all expected numeric codepoints are present + const actualNumbers = new Set(HAZARD_RANGES.filter(e => typeof e === 'number')); + for (const cp of expectedNumbers) { + assert.ok(actualNumbers.has(cp), + `Missing codepoint U+${cp.toString(16).toUpperCase().padStart(4, '0')} from HAZARD_RANGES`); + } + for (const cp of actualNumbers) { + assert.ok(expectedNumbers.has(cp), + `Unexpected codepoint U+${cp.toString(16).toUpperCase().padStart(4, '0')} in HAZARD_RANGES`); + } + + // Check all expected ranges are present + for (const er of expectedRanges) { + const found = HAZARD_RANGES.some(e => + e && typeof e === 'object' && !e.crlfException && e.from === er.from && e.to === er.to); + assert.ok(found, `Missing range { from: 0x${er.from.toString(16)}, to: 0x${er.to.toString(16)} }`); + } + + // Check CR CRLF-exception entry exists + assert.ok(crlfEntry && crlfEntry.cp === 0x0d, + 'Missing { cp: 0x0d, crlfException: true } entry for CR'); + }); + + test('AC-13: documented divergence from Rust assert_no_control_chars', () => { + // The Rust helper flags ALL CR unconditionally. + // The JS scanner permits CR when immediately followed by LF (D-CB3). + // This is the ONLY documented divergence. + + // Verify CR alone = hazardous in JS scanner + assert.equal(isHazardous(0x0d, null), true, 'lone CR must be hazardous'); + assert.equal(isHazardous(0x0d, 0x61), true, 'CR followed by non-LF must be hazardous'); + + // Verify CR + LF = NOT hazardous (the CRLF exception) + assert.equal(isHazardous(0x0d, 0x0a), false, 'CR followed by LF (CRLF) must NOT be hazardous (D-CB3)'); + + // Confirm all other C0 entries match (no other divergence) + for (let cp = 0x00; cp <= 0x1f; cp++) { + if (cp === 0x09 || cp === 0x0a || cp === 0x0d) continue; // TAB, LF, CR handled specially + assert.equal(isHazardous(cp, null), true, `C0 0x${cp.toString(16).padStart(2,'0')} must be hazardous`); + } + + // Verify no false positives on TAB and LF + assert.equal(isHazardous(0x09, null), false, 'TAB must NOT be hazardous'); + assert.equal(isHazardous(0x0a, null), false, 'LF must NOT be hazardous'); + }); + + test('C1 range covers U+0080-U+009F including NEL (U+0085), and stops at U+00A0', () => { + // AC-12: The C1 range { from: 0x80, to: 0x9f } must cover all codepoints in + // that band, including U+0085 (C1 NEL) — the exact byte PF-018 injected into + // tracked source three times in this repo. + // + // The genuine non-vacuity guard for D-CB1a is the golden-count test at line 67 + // (exactly 21 entries) and the bidirectional membership assertions above (lines + // 104-124); those tests catch both removal and narrowing. This test documents + // the boundary behaviour of the C1 range specifically. + const nel = 0x85; // U+0085 — C1 NEL; written as hex, not backslash-u (D-CB2) + assert.equal(isHazardous(nel, null), true, + 'U+0085 (C1 NEL) must be detected — this is the exact byte PF-018 injected into tracked source'); + + // Verify U+0080 (C1 low end) and U+009F (C1 high end) are caught + assert.equal(isHazardous(0x80, null), true, 'U+0080 (C1 boundary) must be hazardous'); + assert.equal(isHazardous(0x9f, null), true, 'U+009F (C1 boundary) must be hazardous'); + // Confirm U+00A0 is NOT hazardous (just outside C1 range) + assert.equal(isHazardous(0xa0, null), false, 'U+00A0 (NBSP) must NOT be hazardous'); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-7, AC-8, AC-9: positive controls and false-positive tests +// --------------------------------------------------------------------------- +describe('AC-7 AC-8 AC-9: positive controls and clean-file checks', () => { + + test('AC-7 PC-1: planted ESC (0x1B) in .rs file → exits 1 naming file and U+001B', () => { + const { dir, git } = mkTempGitRepo(); + try { + const esc = Buffer.from([0x1b]); // ESC byte — constructed at runtime, not a literal + const content = Buffer.concat([Buffer.from('fn main() { '), esc, Buffer.from(' }')]); + writeFileSync(join(dir, 'src.rs'), content); + git('add', 'src.rs'); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1, 'scanner must exit 1 on ESC in tracked .rs file'); + assert.ok(r.stderr.includes('src.rs'), 'error must name the file'); + assert.ok(r.stderr.includes('U+001B'), 'error must include U+001B codepoint'); + } finally { cleanup(dir); } + }); + + test('AC-7 PC-2: planted ESC in .md file → exits 1', () => { + const { dir, git } = mkTempGitRepo(); + try { + const esc = Buffer.from([0x1b]); + writeFileSync(join(dir, 'doc.md'), Buffer.concat([Buffer.from('# heading '), esc])); + git('add', 'doc.md'); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1); + assert.ok(r.stderr.includes('doc.md')); + assert.ok(r.stderr.includes('U+001B')); + } finally { cleanup(dir); } + }); + + test('AC-7 PC-3: planted ESC in .json file → exits 1', () => { + const { dir, git } = mkTempGitRepo(); + try { + const esc = Buffer.from([0x1b]); + writeFileSync(join(dir, 'data.json'), Buffer.concat([Buffer.from('{"a":"'), esc, Buffer.from('"}')])); + git('add', 'data.json'); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1); + assert.ok(r.stderr.includes('data.json')); + assert.ok(r.stderr.includes('U+001B')); + } finally { cleanup(dir); } + }); + + test('AC-7 PC-4: planted RLO (U+202E) in .md file → exits 1 naming U+202E', () => { + const { dir, git } = mkTempGitRepo(); + try { + // U+202E = Right-to-Left Override (Trojan Source bidi char) + const rlo = Buffer.from(String.fromCodePoint(0x202e), 'utf8'); + writeFileSync(join(dir, 'evil.md'), Buffer.concat([Buffer.from('normal '), rlo, Buffer.from(' text')])); + git('add', 'evil.md'); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1); + assert.ok(r.stderr.includes('evil.md')); + assert.ok(r.stderr.includes('U+202E')); + } finally { cleanup(dir); } + }); + + test('AC-8 PC-5: planted U+0085 (C1 NEL, 0xC2 0x85) → exits 1 (the case the baseline missed)', () => { + const { dir, git } = mkTempGitRepo(); + try { + // UTF-8 encoding of U+0085 = 0xC2 0x85 (two bytes) + const nel = Buffer.from([0xc2, 0x85]); + writeFileSync(join(dir, 'nel.txt'), Buffer.concat([Buffer.from('a'), nel, Buffer.from('b')])); + git('add', 'nel.txt'); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1, 'scanner must detect U+0085 (C1 NEL at codepoint level)'); + assert.ok(r.stderr.includes('U+0085'), 'error must reference U+0085'); + } finally { cleanup(dir); } + }); + + test('AC-9 NEG-1: clean international text (accented Latin, CJK, emoji) → exits 0', () => { + const { dir, git } = mkTempGitRepo(); + try { + // These are all valid multi-byte UTF-8 sequences with no hazard codepoints + const content = 'café 日本語 emoji: 🎉\nTabbed\there\n'; + writeFileSync(join(dir, 'intl.md'), content, 'utf8'); + git('add', 'intl.md'); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 0, 'clean international text must exit 0'); + } finally { cleanup(dir); } + }); + + test('AC-9 NEG-3: UTF-8 continuation bytes are not false-positived', () => { + // U+00E9 (é) encodes as 0xC3 0xA9. The continuation byte 0xA9 is in + // range 0x80-0xBF — NOT in the C1 range 0x80-0x9F at codepoint level. + // A naive byte-level C1 check would incorrectly flag 0x89 in 0xE2 0x89 0xA0 ≠. + const neq = 0x2260; // U+2260 NOT EQUAL TO — encodes as 0xE2 0x89 0xA0 + // 0x89 is a continuation byte here; codepoint 0x2260 is NOT in the C1 range + assert.equal(isHazardous(neq, null), false, 'U+2260 (not-equal) must not be hazardous'); + // The codepoint 0x89 on its own IS in C1 range, but UTF-8 continuation bytes + // should never appear as standalone codepoints in valid UTF-8 + assert.equal(isHazardous(0x89, null), true, 'U+0089 itself IS C1-hazardous (standalone codepoint)'); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-10: CR policy +// --------------------------------------------------------------------------- +describe('AC-10: CR policy — CRLF permitted, lone CR rejected', () => { + + test('lone CR (0x0D not followed by LF) → exits 1', () => { + const { dir, git } = mkTempGitRepo(); + try { + // 0x61 0x0D 0x62 = ab (lone CR, not CRLF) + writeFileSync(join(dir, 'lone-cr.txt'), Buffer.from([0x61, 0x0d, 0x62])); + git('add', 'lone-cr.txt'); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1, 'lone CR must be rejected'); + assert.ok(r.stderr.includes('U+000D'), 'error must name U+000D'); + } finally { cleanup(dir); } + }); + + test('CRLF (CR immediately followed by LF) → exits 0', () => { + const { dir, git } = mkTempGitRepo(); + try { + // 0x61 0x0D 0x0A 0x62 = ab + writeFileSync(join(dir, 'crlf.txt'), Buffer.from([0x61, 0x0d, 0x0a, 0x62])); + git('add', 'crlf.txt'); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 0, 'CRLF must be permitted'); + } finally { cleanup(dir); } + }); + + test('isHazardous(CR, LF) = false, isHazardous(CR, non-LF) = true', () => { + assert.equal(isHazardous(0x0d, 0x0a), false, 'CR+LF (CRLF) — not hazardous'); + assert.equal(isHazardous(0x0d, 0x61), true, 'CR+a (lone-ish CR) — hazardous'); + assert.equal(isHazardous(0x0d, null), true, 'CR at EOF — hazardous'); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-11: hermetic git repo proves git ls-files discovery path (avoids PF-016) +// --------------------------------------------------------------------------- +describe('AC-11: git ls-files discovery path', () => { + + test('planted 0x1B in tracked file exits 1; untracked file exits 0', () => { + const { dir, git } = mkTempGitRepo(); + try { + const esc = Buffer.from([0x1b]); + writeFileSync(join(dir, 'tracked.md'), Buffer.concat([Buffer.from('evil '), esc])); + git('add', 'tracked.md'); + + // Scanner reads git-tracked files — should find the hostile byte + const r1 = runScanner([], { cwd: dir }); + assert.equal(r1.status, 1, 'tracked file with ESC → scanner must exit 1'); + + // Remove from git tracking (but keep on disk as untracked) + git('rm', '--cached', 'tracked.md'); + const r2 = runScanner([], { cwd: dir }); + // With zero tracked files, non-vacuity guard fires (exit 1) — which is correct. + // The scanner proves it reads the tracked set: the hostile file is on disk but untracked. + // If it read the working tree, it would still find the hostile byte even after `git rm --cached`. + // Since zero tracked files → non-vacuity exit 1, we know the scanner used git ls-files. + // To confirm: add a clean file and verify the scanner passes. + writeFileSync(join(dir, 'clean.md'), 'clean content\n'); + git('add', 'clean.md'); + const r3 = runScanner([], { cwd: dir }); + assert.equal(r3.status, 0, + 'after removing hostile file from tracking and adding a clean file, scanner must exit 0 ' + + '(proves working-tree untracked file is NOT scanned)'); + } finally { cleanup(dir); } + }); + +}); + +// --------------------------------------------------------------------------- +// AC-5, AC-6: full-tree scan and non-vacuity guard +// --------------------------------------------------------------------------- +describe('AC-5 AC-6: full-tree scan and non-vacuity', () => { + + test('AC-5 AC-30: scanner exits 0 on real repo tree with >= 500 files and >= 4MB in under 5 s', () => { + // AC-30 (clause a): full tracked-tree scan must complete in under 5 seconds wall-clock. + // A generous CI-safe bound of 5 s is used; local runs are typically < 1 s. + const start = Date.now(); + const r = runScanner([], { cwd: ROOT }); + const elapsed = Date.now() - start; + assert.equal(r.status, 0, `scanner must exit 0 on clean repo tree; stderr: ${r.stderr}`); + assert.ok(elapsed < 5000, + `full-tree scan must complete in < 5 s wall-clock (AC-30); took ${elapsed}ms`); + // Parse scanned file count and byte count from success output + const m = r.stdout.match(/Scanned (\d+) file\(s\), (\d+) byte\(s\)/); + assert.ok(m, `success output must include "Scanned N file(s), M byte(s)"; got: ${r.stdout}`); + const files = parseInt(m[1], 10); + const bytes = parseInt(m[2], 10); + assert.ok(files >= 500, `expected >= 500 files scanned; got ${files}`); + assert.ok(bytes >= 4_000_000, `expected >= 4,000,000 bytes; got ${bytes}`); + }); + + test('AC-6: empty git repo (zero tracked files) → exits 1 with non-vacuity message', () => { + const { dir } = mkTempGitRepo(); + try { + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1, 'empty repo must exit 1 (non-vacuity guard)'); + assert.ok( + r.stderr.includes('zero files scanned') || r.stderr.includes('empty scan'), + `error must mention zero files; got: ${r.stderr}` + ); + } finally { cleanup(dir); } + }); + + test('AC-6 --staged: no staged content (amend/nothing staged) → exits 0 with explicit message', () => { + // D-CB5's non-vacuity guard applies to full-tree mode only. In --staged mode + // an empty ACMR-filtered set is a legitimate state — `git commit --amend + // --no-edit` and `--allow-empty` produce exactly this. Exiting 1 here blocks + // valid commits and trains contributors to reach for --no-verify, which + // disables the gate for ALL commits. Fix: exit 0 with an explicit message. + const { dir } = mkTempGitRepo(); + try { + // Nothing staged — `git diff --cached` returns empty. + const r = runScanner(['--staged'], { cwd: dir }); + assert.equal(r.status, 0, '--staged with nothing staged must exit 0 (no content to scan)'); + assert.ok( + r.stdout.includes('nothing to scan') || r.stdout.includes('no staged'), + `stdout must explain why scanning was skipped; got stdout: ${r.stdout}` + ); + } finally { cleanup(dir); } + }); + + test('AC-6 --staged: deletion-only commit → exits 0 with deletion count', () => { + // A commit that removes files only (git rm) yields zero ACMR-filtered paths + // because D = deletion is excluded from the ACMR filter. The scanner must + // exit 0, not 1. D-CB5 non-vacuity applies to full-tree mode only. + const { dir, git } = mkTempGitRepo(); + try { + // Commit a clean file, then stage its deletion + writeFileSync(join(dir, 'to-delete.md'), 'content\n'); + git('add', 'to-delete.md'); + git('commit', '-m', 'add file'); + git('rm', 'to-delete.md'); + // The deletion is staged; ACMR filter excludes it → zero content-bearing paths + const r = runScanner(['--staged'], { cwd: dir }); + assert.equal(r.status, 0, 'deletion-only staged set must exit 0'); + assert.ok( + r.stdout.includes('deletion') || r.stdout.includes('nothing to scan'), + `stdout must explain why scanning was skipped; got stdout: ${r.stdout}` + ); + } finally { cleanup(dir); } + }); + +}); + +// --------------------------------------------------------------------------- +// AC-16: error cases — invalid UTF-8, NUL, no git, not a repo +// --------------------------------------------------------------------------- +describe('AC-16 AC-20: error cases', () => { + + test('AC-16: invalid UTF-8 → exits non-zero with distinct message', () => { + const { dir, git } = mkTempGitRepo(); + try { + // 0xFF 0xFE 0x41 is not valid UTF-8 (0xFF is never valid) + writeFileSync(join(dir, 'bad.txt'), Buffer.from([0xff, 0xfe, 0x41])); + git('add', 'bad.txt'); + const r = runScanner([], { cwd: dir }); + assert.notEqual(r.status, 0, 'invalid UTF-8 must exit non-zero'); + assert.ok( + r.stderr.includes('invalid UTF-8') || r.stderr.includes('UTF-8'), + `error must mention UTF-8; got: ${r.stderr}` + ); + } finally { cleanup(dir); } + }); + + test('AC-16: NUL byte not in BINARY_ALLOWLIST → exits 1', () => { + const { dir, git } = mkTempGitRepo(); + try { + writeFileSync(join(dir, 'nul.dat'), Buffer.from([0x41, 0x00, 0x42])); + git('add', 'nul.dat'); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1, 'NUL byte not in BINARY_ALLOWLIST must exit 1'); + assert.ok( + r.stderr.includes('NUL') || r.stderr.includes('BINARY_ALLOWLIST'), + `error must mention NUL or BINARY_ALLOWLIST; got: ${r.stderr}` + ); + } finally { cleanup(dir); } + }); + + test('AC-16: not inside a git work tree → exits 1 (fail-closed)', () => { + // AC-16 mandates exit 1 for all four named failure conditions. "Not a git + // work tree" is a known, named failure — it is fail-closed (exit 1), not + // indeterminate (exit 2). + const dir = mkdtempSync(join(tmpdir(), 'mds-nogit-')); + try { + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1, 'non-git directory must exit 1 (fail-closed)'); + } finally { cleanup(dir); } + }); + + test('AC-14: all 13 previously-live hazards are gone from the four dirty files', () => { + // Verify S0 remediation: the four files that had U+0085/U+2028/U+2029 are now clean. + const dirtyFiles = [ + 'crates/mds-cli/tests/cli_lint.rs', + 'crates/mds-napi/__test__/index.spec.mjs', + 'crates/mds-wasm/tests/web.rs', + 'packages/bundler-utils/__test__/transform.spec.mjs', + ]; + // Run scanner in explicit-path mode on just these four files + // (they are in the real repo working tree, not a temp git repo) + const r = runScanner(dirtyFiles, { cwd: ROOT }); + assert.equal(r.status, 0, + `previously-dirty files must be clean after S0 remediation; stderr: ${r.stderr}`); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-18: --staged mode reads from git index, not working tree +// --------------------------------------------------------------------------- +describe('AC-18: --staged mode reads index blob, not working tree', () => { + + test('Case A: clean staged blob, hostile working tree → exits 0', () => { + const { dir, git } = mkTempGitRepo(); + try { + // Stage a clean file + writeFileSync(join(dir, 'f.txt'), 'clean content\n'); + git('add', 'f.txt'); + // Now overwrite the working tree with a hostile byte WITHOUT staging + const esc = Buffer.from([0x1b]); + writeFileSync(join(dir, 'f.txt'), Buffer.concat([Buffer.from('evil '), esc])); + // --staged reads the INDEX (clean), not the working tree + const r = runScanner(['--staged'], { cwd: dir }); + assert.equal(r.status, 0, + 'clean staged blob + hostile working tree → exit 0 (index is scanned, not working tree)'); + } finally { cleanup(dir); } + }); + + test('Case B: hostile staged blob, clean working tree → exits 1', () => { + const { dir, git } = mkTempGitRepo(); + try { + // Stage a hostile file + const esc = Buffer.from([0x1b]); + writeFileSync(join(dir, 'f.txt'), Buffer.concat([Buffer.from('evil '), esc])); + git('add', 'f.txt'); + // Overwrite working tree with clean content WITHOUT re-staging + writeFileSync(join(dir, 'f.txt'), 'clean now\n'); + // --staged reads the INDEX (hostile), not the working tree + const r = runScanner(['--staged'], { cwd: dir }); + assert.equal(r.status, 1, + 'hostile staged blob + clean working tree → exit 1 (index is scanned, not working tree)'); + } finally { cleanup(dir); } + }); + +}); + +// --------------------------------------------------------------------------- +// AC-15: scanner source itself has no hazard bytes and no PCRE grep flag +// --------------------------------------------------------------------------- +describe('AC-15: scanner source is self-clean', () => { + + test('scanner source files pass their own gate', () => { + const scriptFiles = [ + 'scripts/verify-no-control-bytes.mjs', + 'scripts/verify-pr-checks.mjs', + ]; + const r = runScanner(scriptFiles, { cwd: ROOT }); + assert.equal(r.status, 0, `scanner source files must pass their own gate; stderr: ${r.stderr}`); + }); + + test('scanner source files contain no forbidden grep flag and no backslash-u escape (AC-15, PF-018)', () => { + // AC-15: the scripts, spec files, and hook must not invoke the BSD-incompatible + // grep PCRE flag (BSD grep lacks it, exits 2 with empty output that reads as + // clean — D-CB7), and must not contain a backslash-u-plus-4-hex escape (the + // edit-tooling decode vector that injected live hazard bytes into this repo + // three times — PF-018, D-CB2). + // + // Both search patterns are built from parts / numeric char codes so this test + // does not trip its own rule when the spec file is in the checked file set. + // The forbidden grep invocation is 'grep' joined with ' -P'; split here so + // the contiguous substring is absent from this source file. + const grepPFlag = 'grep' + ' -P'; + // 0x5C = backslash, then 'u' and 4 hex digits: + const bs = String.fromCodePoint(0x5c); + const bsUPattern = new RegExp(bs + 'u[0-9a-fA-F]{4}'); + + const fileSet = [ + 'scripts/verify-no-control-bytes.mjs', + 'scripts/verify-pr-checks.mjs', + 'scripts/__test__/verify-no-control-bytes.spec.mjs', + 'scripts/__test__/verify-pr-checks.spec.mjs', + 'scripts/hooks/pre-commit', + ]; + + for (const rel of fileSet) { + const src = readFileSync(join(ROOT, rel), 'utf8'); + assert.ok( + !src.includes(grepPFlag), + `${rel}: must not invoke the POSIX-extension grep flag (BSD grep lacks PCRE support, exits 2 — AC-15, D-CB7)` + ); + assert.ok( + !bsUPattern.test(src), + `${rel}: must not contain a backslash-u escape (edit tooling decodes them into live bytes — AC-15, PF-018)` + ); + } + }); + +}); + +// --------------------------------------------------------------------------- +// AC-30: hex context stored per hit as a pre-computed string, not as the raw +// buffer — one-file-at-a-time memory discipline, verified by code shape. +// +// AC-30 has three clauses: +// (a) Wall-clock full-tree scan < 5 s — asserted with Date.now() in the +// AC-5 test above (generous CI-safe bound). +// (b) --staged mode < 2 s for a 20-file commit — not directly timed here; +// the same structural bound holds (git cat-file reads one blob at a time). +// (c) MUST NOT hold more than one file's contents in memory at a time — +// verified by code shape: at verify-no-control-bytes.mjs:473, +// hazardHits.push stores { hexCtx } (a pre-computed string) not { buf } +// (the raw buffer), so the buffer is GC-eligible after each iteration. +// +// This describe block tests clause (c) indirectly: by proving the correct +// hexCtx string reaches the output across multiple files, it demonstrates +// that hexCtx was computed and stored before buf went out of scope — which +// is only possible if buf was NOT retained in hazardHits. +// --------------------------------------------------------------------------- +describe('AC-30: hex context stored as string per hit, not as file buffer', () => { + + test('scanner reports hex context for every hazard across multiple files', () => { + // Verify that hexCtx is computed and stored correctly for each hit. + // Memory discipline (clause c) is by code shape: hazardHits stores { hexCtx } + // not { buf } (scanner:473), so buf is GC-eligible after each file's iteration. + // This test proves the correct context string reaches the output regardless + // of how many files are scanned. + const { dir, git } = mkTempGitRepo(); + try { + // Construct two files each with an ESC at a known position + // a.md: "hello " (6 bytes) then ESC — offset 6 + // b.md: "foo " (4 bytes) then ESC — offset 4 + const esc = Buffer.from([0x1b]); + writeFileSync(join(dir, 'a.md'), Buffer.concat([Buffer.from('hello '), esc, Buffer.from(' world')])); + writeFileSync(join(dir, 'b.md'), Buffer.concat([Buffer.from('foo '), esc])); + git('add', 'a.md', 'b.md'); + + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1, 'scanner must exit 1 for files with hazard bytes'); + // Both files must be named + assert.ok(r.stderr.includes('a.md'), 'must report hazard in a.md'); + assert.ok(r.stderr.includes('b.md'), 'must report hazard in b.md'); + // Hex context lines must appear (proves hexCtx is pre-computed and stored) + assert.ok(r.stderr.includes('context:'), 'must include hex context lines'); + // The codepoint must be identified + assert.ok(r.stderr.includes('U+001B'), 'must name the hazardous codepoint'); + } finally { cleanup(dir); } + }); + +}); + +// --------------------------------------------------------------------------- +// AC-17: allowlist entries are exercised-or-stale +// --------------------------------------------------------------------------- +describe('AC-17: allowlist entries are self-invalidating', () => { + + test('BINARY_ALLOWLIST is empty (no entries)', () => { + assert.equal(BINARY_ALLOWLIST.length, 0, 'BINARY_ALLOWLIST must be empty (D-CB4, D-CB6)'); + }); + + test('HAZARD_ALLOWLIST is empty (no entries)', () => { + assert.equal(HAZARD_ALLOWLIST.length, 0, 'HAZARD_ALLOWLIST must be empty (D-CB4, D-CB6)'); + }); + + test('scanBuffer reports allowlisted hazards as allowed, others as not', () => { + // The allowlist is empty in the shipped file, so the exercised/stale + // machinery below can only be reached by an entry that does not exist yet. + // Assert the predicate the machinery depends on, then drive the whole + // script with a patched allowlist in the integration cases that follow. + const esc = Buffer.from([0x61, 0x1b, 0x62]); + const notAllowed = scanBuffer(esc, 'x.md', new Set()); + assert.equal(notAllowed.length, 1); + assert.equal(notAllowed[0].codepoint, 0x1b); + assert.equal(notAllowed[0].allowed, false); + + const allowed = scanBuffer(esc, 'x.md', new Set([0x1b])); + assert.equal(allowed.length, 1, 'an allowlisted hazard must still be REPORTED to the caller'); + assert.equal(allowed[0].allowed, true, 'so the entry can be recorded as exercised, not stale'); + }); + + /** + * Write a copy of the scanner with a patched HAZARD_ALLOWLIST into `dir`. + * Patching the source is the only way to exercise a non-empty allowlist + * while keeping the shipped allowlist empty (D-CB4). + */ + function writePatchedScanner(dir, entryLiteral) { + const marker = 'export const HAZARD_ALLOWLIST = ['; + const src = readFileSync(SCANNER, 'utf8'); + assert.ok(src.includes(marker), 'scanner must declare HAZARD_ALLOWLIST for this test to patch'); + const patched = src.replace(marker, `${marker} ${entryLiteral},`); + assert.notEqual(patched, src, 'patch must have applied'); + const target = join(dir, 'scan.mjs'); + writeFileSync(target, patched); + return target; + } + + function runPatched(dir, target) { + const r = spawnSync(process.execPath, [target], { cwd: dir, encoding: 'utf8', timeout: 30000 }); + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; + } + + test('Case 1: an exercised entry exits 0 and is named in the output', () => { + const { dir, git } = mkTempGitRepo(); + try { + writeFileSync(join(dir, 'evil.md'), Buffer.concat([Buffer.from('x '), Buffer.from([0x1b])])); + const target = writePatchedScanner(dir, "{ path: 'evil.md', codepoints: [0x1b], reason: 'test fixture' }"); + git('add', 'evil.md', 'scan.mjs'); + const r = runPatched(dir, target); + assert.equal(r.status, 0, `exercised allowlist entry must pass; stderr: ${r.stderr}`); + assert.ok(r.stdout.includes('evil.md'), `output must name the exercised entry; got: ${r.stdout}`); + assert.ok(r.stdout.includes('U+001B'), `output must name the allowed codepoint; got: ${r.stdout}`); + assert.ok(r.stdout.includes('test fixture'), `output must quote the written reason; got: ${r.stdout}`); + } finally { cleanup(dir); } + }); + + test('Case 2: an entry naming a file that is not tracked exits 1 as stale', () => { + const { dir, git } = mkTempGitRepo(); + try { + writeFileSync(join(dir, 'clean.md'), 'nothing to see\n'); + const target = writePatchedScanner(dir, "{ path: 'gone.md', codepoints: [0x1b], reason: 'test fixture' }"); + git('add', 'clean.md', 'scan.mjs'); + const r = runPatched(dir, target); + assert.equal(r.status, 1, 'an allowlist entry for an untracked path must fail'); + assert.ok(r.stderr.includes('stale'), `must identify the entry as stale; got: ${r.stderr}`); + assert.ok(r.stderr.includes('gone.md'), 'must name the stale path'); + } finally { cleanup(dir); } + }); + + test('Case 3: an entry whose declared codepoint no longer occurs exits 1 as stale', () => { + const { dir, git } = mkTempGitRepo(); + try { + writeFileSync(join(dir, 'evil.md'), 'the hazard byte has since been removed\n'); + const target = writePatchedScanner(dir, "{ path: 'evil.md', codepoints: [0x1b], reason: 'test fixture' }"); + git('add', 'evil.md', 'scan.mjs'); + const r = runPatched(dir, target); + assert.equal(r.status, 1, 'an allowlist entry whose codepoint is gone must fail'); + assert.ok(r.stderr.includes('U+001B'), `must name the declared codepoint; got: ${r.stderr}`); + } finally { cleanup(dir); } + }); + +}); + +// --------------------------------------------------------------------------- +// Entry-point guard: the gate must actually RUN wherever the repo is checked out +// --------------------------------------------------------------------------- +describe('the scanner runs from a path containing a space', () => { + + test('planted ESC is still caught when the script path contains a space', () => { + // `import.meta.url === "file://" + process.argv[1]` is false for any path a + // file URL percent-encodes, so main() never runs and the gate exits 0 + // having scanned nothing — a silent pass indistinguishable from a clean + // tree. The scanner has no local imports, so a copy is a faithful subject. + const dir = mkdtempSync(join(tmpdir(), 'mds scan space-')); + try { + assert.ok(dir.includes(' '), 'this test is meaningless unless the path has a space'); + execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@test.test'], { cwd: dir, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'pipe' }); + const target = join(dir, 'scan.mjs'); + writeFileSync(target, readFileSync(SCANNER)); + writeFileSync(join(dir, 'evil.md'), Buffer.concat([Buffer.from('x '), Buffer.from([0x1b])])); + execFileSync('git', ['add', 'evil.md', 'scan.mjs'], { cwd: dir, stdio: 'pipe' }); + + const r = spawnSync(process.execPath, [target], { cwd: dir, encoding: 'utf8', timeout: 30000 }); + assert.equal(r.status, 1, + `scanner must run (and fail) from a spaced path; got status ${r.status}, stdout: ${r.stdout}`); + assert.ok(r.stderr.includes('U+001B'), 'must report the planted byte'); + } finally { cleanup(dir); } + }); + +}); + +// --------------------------------------------------------------------------- +// AC-20: non-regular git entries are skipped, not read +// --------------------------------------------------------------------------- +describe('AC-20: symlinks are skipped and counted, not read', () => { + + test('a tracked symlink (mode 120000) is skipped and reported', () => { + const { dir, git } = mkTempGitRepo(); + try { + writeFileSync(join(dir, 'real.md'), 'clean content\n'); + symlinkSync('real.md', join(dir, 'link.md')); + git('add', 'real.md', 'link.md'); + const modes = execFileSync('git', ['ls-files', '-s'], { cwd: dir, encoding: 'utf8' }); + assert.ok(modes.includes('120000'), 'the fixture must actually stage a symlink'); + + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 0, `symlink must not produce a read error; stderr: ${r.stderr}`); + assert.ok(/1 symlink\/gitlink skipped/.test(r.stdout), + `skipped entries must be counted separately; got: ${r.stdout}`); + assert.ok(/Scanned 1 file\(s\)/.test(r.stdout), 'only the regular file is scanned'); + } finally { cleanup(dir); } + }); + +}); + +// --------------------------------------------------------------------------- +// AC-16: git missing from PATH fails closed with exit 1 +// --------------------------------------------------------------------------- +describe('AC-16: git not on PATH', () => { + + test('scanner exits 1 when git cannot be found (fail-closed)', () => { + // AC-16 mandates exit 1 for all four named failure conditions. "Git not on + // PATH" is a known, named failure — it is fail-closed (exit 1), not + // indeterminate (exit 2). + // Give the child a PATH containing node but not git, so the failure is + // specifically "git is missing" and not "node is missing". + const binDir = mkdtempSync(join(tmpdir(), 'mds-nopath-')); + try { + symlinkSync(process.execPath, join(binDir, 'node')); + const r = spawnSync(process.execPath, [SCANNER], { + cwd: ROOT, + encoding: 'utf8', + env: { PATH: binDir }, + timeout: 30000, + }); + assert.equal(r.status, 1, `missing git must exit 1 (fail-closed); stdout: ${r.stdout}, stderr: ${r.stderr}`); + assert.ok(/git is not on PATH/.test(r.stderr), `must name the condition; got: ${r.stderr}`); + } finally { cleanup(binDir); } + }); + +}); diff --git a/scripts/__test__/verify-pr-checks.spec.mjs b/scripts/__test__/verify-pr-checks.spec.mjs new file mode 100644 index 00000000..4f6a3791 --- /dev/null +++ b/scripts/__test__/verify-pr-checks.spec.mjs @@ -0,0 +1,528 @@ +/** + * Tests for scripts/verify-pr-checks.mjs + * + * All tests drive the pure `evaluateChecks` function with fixture data so they + * run offline — no real GitHub API calls. The fixtures are captured verbatim + * from the live API at planning time (see scripts/__test__/fixtures/). + * + * applies ADR-009, avoids PF-013: every test prints counts; absence of checks + * is explicitly FAIL (zero check-runs test). + * avoids PF-017: cancelled/skipped/in_progress are all tested as NOT-PASS. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { evaluateChecks, main, fetchRequiredContexts } from '../verify-pr-checks.mjs'; + +const ROOT = resolve(fileURLToPath(import.meta.url), '../../..'); +const FIXTURES = join(ROOT, 'scripts/__test__/fixtures'); + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +function loadProtection() { + const raw = JSON.parse(readFileSync(join(FIXTURES, 'protection-main.json'), 'utf8')); + return raw.required_status_checks.contexts; +} + +function loadCheckRuns(fixtureName) { + const raw = JSON.parse(readFileSync(join(FIXTURES, fixtureName), 'utf8')); + return raw.check_runs ?? []; +} + +function loadStatuses(fixtureName) { + const raw = JSON.parse(readFileSync(join(FIXTURES, fixtureName), 'utf8')); + return raw.statuses ?? []; +} + +const REQUIRED = loadProtection(); +// From the live protection fixture, the 6 required contexts are: +// "Rust — fmt, clippy, test", "MSRV (Rust 1.88)", "WASM — build & test", +// "JS packages — build & test (ubuntu-latest)", +// "JS packages — build & test (macos-latest)", +// "JS packages — build & test (windows-latest)" +assert.equal(REQUIRED.length, 6, 'fixture must have 6 required contexts'); + +const HEAD_113F472 = '113f472684d6ee7e398d54c1aadc22b2ad747ae1'; +const HEAD_F168944 = 'f168944'; // PR #239 +const HEAD_E9DACE1 = 'e9dace1'; // PR #240 + +// --------------------------------------------------------------------------- +// AC-22: Historical fixtures reproduce correctly +// --------------------------------------------------------------------------- +describe('AC-21 AC-22: historical fixture evaluation', () => { + + test('113f472 (main baseline, 18 check-runs, all success) → PASS (exit 0)', () => { + const checkRuns = loadCheckRuns('checks-main-113f472.json'); + assert.equal(checkRuns.length, 18, 'fixture must have 18 check-runs'); + const result = evaluateChecks({ requiredContexts: REQUIRED, checkRuns, statuses: [], headSha: HEAD_113F472 }); + assert.equal(result.exitCode, 0, `expected PASS; lines: ${result.lines.join('\n')}`); + assert.ok(result.pass, 'evaluateChecks must return pass=true'); + }); + + test('f168944 (PR #239, zero check-runs) → FAIL (exit 1) naming all 6 required contexts', () => { + const checkRuns = loadCheckRuns('checks-pr239-f168944.json'); + const statuses = loadStatuses('status-pr239-f168944.json'); + assert.equal(checkRuns.length, 0, 'PR #239 fixture must have 0 check-runs'); + const result = evaluateChecks({ requiredContexts: REQUIRED, checkRuns, statuses, headSha: HEAD_F168944 }); + assert.equal(result.exitCode, 1, `expected FAIL; lines: ${result.lines.join('\n')}`); + assert.ok(!result.pass); + const allLines = result.lines.join('\n'); + // Non-vacuity guard fires: zero check-runs → FAIL + assert.ok(allLines.includes('zero check-runs'), `must mention zero check-runs; got: ${allLines}`); + // AC-22: every required context must be named so the operator knows what was absent, + // not just that "zero check-runs" occurred (avoids vacuous failure messages). + for (const ctx of REQUIRED) { + assert.ok(allLines.includes(ctx), + `must name absent required context "${ctx}"; got:\n${allLines}`); + } + }); + + test('e9dace1 (PR #240, zero check-runs, Snyk error status) → FAIL (exit 1)', () => { + const checkRuns = loadCheckRuns('checks-pr240-e9dace1.json'); + const statuses = loadStatuses('status-pr240-e9dace1.json'); + assert.equal(checkRuns.length, 0, 'PR #240 fixture must have 0 check-runs'); + const snykStatus = statuses.find(s => s.context === 'security/snyk (dean0x)'); + assert.ok(snykStatus, 'PR #240 fixture must have snyk status'); + assert.equal(snykStatus.state, 'error'); + const result = evaluateChecks({ requiredContexts: REQUIRED, checkRuns, statuses, headSha: HEAD_E9DACE1 }); + assert.equal(result.exitCode, 1, `expected FAIL; lines: ${result.lines.join('\n')}`); + // Zero check-runs triggers non-vacuity guard; Snyk status is Tier C (advisory) + const allLines = result.lines.join('\n'); + assert.ok(allLines.includes('zero check-runs'), `must fail on zero check-runs; got: ${allLines}`); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-23: Partial case — the one `gh pr checks --required` exits 0 on +// --------------------------------------------------------------------------- +describe('AC-23: partial case (5 of 6 required present)', () => { + + test('17 of 18 check-runs (MSRV deleted) → FAIL naming MSRV', () => { + // Synthesize by removing the MSRV check-run from the 113f472 fixture. + // This is the case `gh pr checks --required` exits 0 on (all present checks are green) + // but the tool catches: a required context is absent. + const allRuns = loadCheckRuns('checks-main-113f472.json'); + const msrvName = 'MSRV (Rust 1.88)'; + const withoutMsrv = allRuns.filter(cr => cr.name !== msrvName); + assert.equal(withoutMsrv.length, 17, 'should have 17 runs after removing MSRV'); + + const result = evaluateChecks({ + requiredContexts: REQUIRED, + checkRuns: withoutMsrv, + statuses: [], + headSha: HEAD_113F472, + }); + assert.equal(result.exitCode, 1, 'must FAIL when one required context is absent'); + const allLines = result.lines.join('\n'); + assert.ok(allLines.includes(msrvName), + `failure message must name "${msrvName}"; got: ${allLines}`); + assert.ok(allLines.includes('not found') || allLines.includes('never ran'), + `message must indicate the context never ran; got: ${allLines}`); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-24: All non-success terminal and non-terminal states fail (avoids PF-017) +// --------------------------------------------------------------------------- +describe('AC-24: non-success states → FAIL, quoting the observed state', () => { + + // Build a passing baseline from the 113f472 fixture, then mutate one required check + function buildPassingRuns() { + return loadCheckRuns('checks-main-113f472.json').map(cr => ({ ...cr })); + } + + const NON_SUCCESS_CASES = [ + { status: 'completed', conclusion: 'cancelled' }, + { status: 'completed', conclusion: 'skipped' }, + { status: 'completed', conclusion: 'neutral' }, + { status: 'completed', conclusion: 'timed_out' }, + { status: 'completed', conclusion: 'action_required' }, + { status: 'completed', conclusion: 'stale' }, + { status: 'queued', conclusion: null }, + { status: 'in_progress', conclusion: null }, + ]; + + for (const { status, conclusion } of NON_SUCCESS_CASES) { + test(`required check with status=${status} conclusion=${conclusion ?? 'null'} → FAIL`, () => { + const runs = buildPassingRuns(); + const target = runs.find(cr => REQUIRED.includes(cr.name)); + assert.ok(target, 'must find a required check-run to mutate'); + target.status = status; + target.conclusion = conclusion; + + const result = evaluateChecks({ requiredContexts: REQUIRED, checkRuns: runs, statuses: [], headSha: HEAD_113F472 }); + assert.equal(result.exitCode, 1, `status=${status} conclusion=${conclusion} must exit 1`); + const allLines = result.lines.join('\n'); + // Message must quote the observed status verbatim (avoids PF-017) + assert.ok(allLines.includes(status), `failure must quote observed status "${status}"`); + if (conclusion) { + assert.ok(allLines.includes(conclusion), `failure must quote observed conclusion "${conclusion}"`); + } + }); + } + + test('control: all-success baseline still exits 0 (suite is not failing unconditionally)', () => { + const runs = buildPassingRuns(); + const result = evaluateChecks({ requiredContexts: REQUIRED, checkRuns: runs, statuses: [], headSha: HEAD_113F472 }); + assert.equal(result.exitCode, 0, 'all-success baseline must pass'); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-25: Zero check-runs is never a pass (avoids PF-013) +// --------------------------------------------------------------------------- +describe('AC-25: zero check-runs never passes', () => { + + test('total_count=0, empty check_runs, even with success status → FAIL', () => { + const result = evaluateChecks({ + requiredContexts: REQUIRED, + checkRuns: [], + statuses: [{ context: 'some-check', state: 'success' }], + headSha: HEAD_F168944, + }); + assert.equal(result.exitCode, 1, 'zero check-runs must exit 1 regardless of statuses'); + const allLines = result.lines.join('\n'); + // Must print counts (avoids PF-013) + assert.ok(allLines.includes('check-runs: 0'), `must print check-run count; got: ${allLines}`); + }); + + test('output always includes counts (applies ADR-009)', () => { + const runs = loadCheckRuns('checks-main-113f472.json'); + const result = evaluateChecks({ requiredContexts: REQUIRED, checkRuns: runs, statuses: [], headSha: HEAD_113F472 }); + const allLines = result.lines.join('\n'); + // Counts must appear whether pass or fail + assert.ok(allLines.includes('check-runs:'), `must print check-runs count; got: ${allLines}`); + assert.ok(allLines.includes('required contexts:'), `must print required-contexts count; got: ${allLines}`); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-26: Three-valued exit contract +// --------------------------------------------------------------------------- +describe('AC-26 AC-27: exit codes and merge command', () => { + + test('PASS → exit 0 with --match-head-commit in output', () => { + const runs = loadCheckRuns('checks-main-113f472.json'); + const result = evaluateChecks({ requiredContexts: REQUIRED, checkRuns: runs, statuses: [], headSha: HEAD_113F472 }); + assert.equal(result.exitCode, 0); + assert.ok(result.mergeCommand, 'PASS must produce a mergeCommand'); + // D-PR5: merge command must include --match-head-commit + assert.ok(result.mergeCommand.includes('--match-head-commit'), 'merge command must include --match-head-commit'); + assert.ok(result.mergeCommand.includes(HEAD_113F472), 'merge command must include the verified SHA'); + }); + + test('FAIL → exit 1 (not 0, not 2)', () => { + const result = evaluateChecks({ requiredContexts: REQUIRED, checkRuns: [], statuses: [], headSha: HEAD_F168944 }); + assert.equal(result.exitCode, 1); + assert.ok(!result.pass); + }); + + test('evaluateChecks never returns exit 0 when pass=false', () => { + // Verify the invariant: exitCode===0 iff pass===true + const failResult = evaluateChecks({ requiredContexts: REQUIRED, checkRuns: [], statuses: [], headSha: 'abc' }); + assert.equal(failResult.exitCode === 0, failResult.pass, + 'exitCode===0 must equal pass===true'); + + const passResult = evaluateChecks({ + requiredContexts: REQUIRED, + checkRuns: loadCheckRuns('checks-main-113f472.json'), + statuses: [], + headSha: HEAD_113F472, + }); + assert.equal(passResult.exitCode === 0, passResult.pass, + 'exitCode===0 must equal pass===true on pass case'); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-26, AC-28, AC-29: the live path, driven end-to-end with an injected gh +// runner. These replace source-text greps: asserting that a file CONTAINS the +// string "process.exit(2)" proves nothing about whether that branch is +// reachable (applies ADR-009, avoids PF-013). Each case below drives main() +// and asserts the returned exit code. +// --------------------------------------------------------------------------- + +const OK_GH_VERSION = () => ({ major: 2, minor: 88 }); + +/** + * Build a gh runner stub from a route table. Each entry is matched against the + * API path by substring; the value is either a JSON object (success) or an + * `{ __error: true, status }` shape mirroring defaultGhRunner's failure return. + */ +function stubRunner(routes, callLog = []) { + return (args) => { + const url = args[args.length - 1]; + callLog.push(url); + for (const [needle, value] of routes) { + if (url.includes(needle)) { + return typeof value === 'function' ? value(url) : value; + } + } + return { __error: true, status: 404, stderr: `no stub route for ${url}` }; + }; +} + +const PR_OK = { head: { sha: HEAD_113F472 }, base: { ref: 'main' } }; +const PROTECTION_OK = JSON.parse(readFileSync(join(FIXTURES, 'protection-main.json'), 'utf8')); +const CHECKS_OK = JSON.parse(readFileSync(join(FIXTURES, 'checks-main-113f472.json'), 'utf8')); + +describe('AC-26 AC-28 AC-29: live path exit codes (injected runner)', () => { + + test('happy path → exit 0', () => { + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', PROTECTION_OK], + ['/check-runs', CHECKS_OK], + ['/status', { statuses: [] }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 0); + }); + + test('AC-29: unprotected base (404 on protection) → exit 2, never 0', () => { + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', { __error: true, status: 404, stderr: 'Not Found' }], + ['/check-runs', CHECKS_OK], + ['/status', { statuses: [] }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 2); + }); + + test('AC-29: --required-from branch also unprotected → exit 2', () => { + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', { __error: true, status: 404, stderr: 'Not Found' }], + ]); + assert.equal(main(['1', '--required-from', 'nope'], runner, OK_GH_VERSION), 2); + }); + + test('AC-26: protection unreadable (403) → exit 2', () => { + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', { __error: true, status: 403, stderr: 'Forbidden' }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 2); + }); + + test('AC-26: gh older than 2.31 → exit 2 before any API call', () => { + const calls = []; + const runner = stubRunner([['/pulls/', PR_OK]], calls); + assert.equal(main(['1'], runner, () => ({ major: 2, minor: 30 })), 2); + assert.equal(calls.length, 0, 'must not query the API when gh is too old'); + }); + + test('AC-26: gh missing entirely (version probe returns null) → exit 2', () => { + const runner = stubRunner([['/pulls/', PR_OK]]); + assert.equal(main(['1'], runner, () => null), 2); + }); + + test('AC-26: no PR number argument → exit 2', () => { + const runner = stubRunner([]); + assert.equal(main([], runner, OK_GH_VERSION), 2); + }); + + test('AC-26: --required-from with no value → exit 2', () => { + const runner = stubRunner([['/pulls/', PR_OK]]); + assert.equal(main(['1', '--required-from'], runner, OK_GH_VERSION), 2); + }); + + test('protected branch listing ZERO required contexts → exit 2, not 0', () => { + // The vacuous-green shape: protection exists, required set is empty. + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', { required_status_checks: { contexts: [], checks: [] } }], + ['/check-runs', CHECKS_OK], + ['/status', { statuses: [] }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 2); + }); + + test('required contexts are read from the UNION of contexts[] and checks[]', () => { + // A protection payload that populates only the newer `checks` array must + // still yield a required set — reading `contexts` alone would be empty. + const onlyChecks = { + required_status_checks: { + contexts: [], + checks: REQUIRED.map(c => ({ context: c, app_id: 15368 })), + }, + }; + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', onlyChecks], + ['/check-runs', CHECKS_OK], + ['/status', { statuses: [] }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 0); + + const res = fetchRequiredContexts('main', null, runner); + assert.ok(res.ok); + assert.deepEqual([...res.contexts].sort(), [...REQUIRED].sort()); + }); + + test('AC-28: pagination stops at the page bound and exits 2 (never loops)', () => { + // Stub a server that always reports more pages than it will ever deliver. + let pages = 0; + const fullPage = { + total_count: 100000, + check_runs: Array.from({ length: 100 }, (_, i) => ({ + name: `job-${i}`, status: 'completed', conclusion: 'success', + })), + }; + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', PROTECTION_OK], + ['/check-runs', () => { pages++; return fullPage; }], + ['/status', { statuses: [] }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 2, 'page cap must exit 2'); + assert.ok(pages <= 20, `pagination must be bounded; issued ${pages} page requests`); + assert.ok(pages >= 2, 'the stub must actually have been paginated'); + }); + + test('AC-28: total_count larger than the collected set → exit 2, not a partial verdict', () => { + const truncated = { total_count: 18, check_runs: CHECKS_OK.check_runs.slice(0, 5) }; + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', PROTECTION_OK], + ['/check-runs', truncated], + ['/status', { statuses: [] }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 2); + }); + + test('AC-30: the live path issues at most page-bound + 3 API calls', () => { + const calls = []; + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', PROTECTION_OK], + ['/check-runs', CHECKS_OK], + ['/status', { statuses: [] }], + ], calls); + assert.equal(main(['1'], runner, OK_GH_VERSION), 0); + assert.equal(calls.length, 4, `expected 4 API calls (pr, protection, checks, status); got ${calls.length}`); + const checkCall = calls.find(u => u.includes('/check-runs')); + assert.ok(checkCall.includes('filter=latest'), 'filter=latest must be pinned explicitly (D-PR4a)'); + }); + + test('check-runs API error → exit 2 (indeterminate), not 1', () => { + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', PROTECTION_OK], + ['/check-runs', { __error: true, status: 500, stderr: 'server error' }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 2); + }); + + test('a real FAIL still exits 1, so exit 2 has not swallowed the FAIL path', () => { + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', PROTECTION_OK], + ['/check-runs', { total_count: 0, check_runs: [] }], + ['/status', { statuses: [] }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 1); + }); + +}); + +// --------------------------------------------------------------------------- +// Entry-point guard: the verifier must actually RUN wherever it is checked out +// --------------------------------------------------------------------------- +describe('the verifier runs from a spaced / symlinked path', () => { + + test('invoked with no arguments it exits 2 (usage), never a silent 0', () => { + // A merge gate that no-ops and exits 0 is the worst possible failure mode: + // the operator reads it as "verified" and merges. Copy the script to a path + // with a space (mkdtemp is also symlinked on macOS) and confirm it runs. + const dir = mkdtempSync(join(tmpdir(), 'mds verify space-')); + try { + assert.ok(dir.includes(' '), 'this test is meaningless unless the path has a space'); + const target = join(dir, 'verify-pr-checks.mjs'); + writeFileSync(target, readFileSync(join(ROOT, 'scripts/verify-pr-checks.mjs'))); + const r = spawnSync(process.execPath, [target], { encoding: 'utf8', timeout: 30000 }); + assert.equal(r.status, 2, + `expected usage exit 2; got ${r.status} (0 means the script never ran). stdout: ${r.stdout}`); + assert.ok(r.stderr.includes('Usage:'), `must print usage; got: ${r.stderr}`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + +}); + +// --------------------------------------------------------------------------- +// Duplicate check-run names must not mask a failure +// --------------------------------------------------------------------------- +describe('duplicate check-run names are all evaluated', () => { + + test('a failing run is not masked by a later success under the same name', () => { + const ctx = REQUIRED[0]; + const runs = [ + ...loadCheckRuns('checks-main-113f472.json').filter(cr => cr.name !== ctx), + { name: ctx, status: 'completed', conclusion: 'failure' }, + { name: ctx, status: 'completed', conclusion: 'success' }, + ]; + const result = evaluateChecks({ requiredContexts: REQUIRED, checkRuns: runs, statuses: [], headSha: HEAD_113F472 }); + assert.equal(result.exitCode, 1, + 'a failing required check-run must fail even when a later run shares its name'); + assert.ok(result.lines.join('\n').includes('failure'), 'must quote the observed conclusion'); + }); + +}); + +// --------------------------------------------------------------------------- +// Vacuity guard on the pure function itself +// --------------------------------------------------------------------------- +describe('empty required set is indeterminate, never a pass', () => { + + test('evaluateChecks with zero required contexts → exit 2', () => { + const result = evaluateChecks({ + requiredContexts: [], + checkRuns: [{ name: 'anything', status: 'completed', conclusion: 'success' }], + statuses: [], + headSha: HEAD_113F472, + }); + assert.equal(result.exitCode, 2, 'zero required contexts must be indeterminate (exit 2)'); + assert.equal(result.pass, false); + assert.ok(!result.mergeCommand, 'must not emit a merge command it cannot justify'); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-13 (documentation): D-PR2a union of check-runs and statuses +// --------------------------------------------------------------------------- +describe('D-PR2a: required context satisfied by commit status', () => { + test('required context present only in statuses (not check-runs) → PASS', () => { + // Build check-runs with one required context removed from check-runs, + // but that context is present in commit statuses as success. + const allRuns = loadCheckRuns('checks-main-113f472.json'); + const msrvName = 'MSRV (Rust 1.88)'; + const withoutMsrv = allRuns.filter(cr => cr.name !== msrvName); + + // Simulate MSRV being satisfied via commit status instead + const statuses = [{ context: msrvName, state: 'success' }]; + + const result = evaluateChecks({ + requiredContexts: REQUIRED, + checkRuns: withoutMsrv, + statuses, + headSha: HEAD_113F472, + }); + assert.equal(result.exitCode, 0, + 'required context satisfied via commit status must pass (D-PR2a)'); + }); +}); diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit new file mode 100755 index 00000000..c81d3f2f --- /dev/null +++ b/scripts/hooks/pre-commit @@ -0,0 +1,37 @@ +#!/bin/sh +# Pre-commit hook: run the source-hygiene gate in --staged mode. +# +# Opt-in: git config core.hooksPath scripts/hooks +# +# IMPORTANT: Setting core.hooksPath REPLACES .git/hooks entirely rather than +# merging with it. Any local hooks you have in .git/hooks will no longer run +# while this setting is active. Document your local hooks before opting in. +# +# This hook reads staged file content from the git index (git cat-file blob +# :), not from the working tree. Staging a clean file then modifying +# the working copy will NOT bypass the check. (D-CB8) +# +# D-CB7: Uses pure Node codepoint iteration. BSD grep (macOS default) lacks +# -P and exits 2 with empty output, making absence of hazard bytes look the +# same as a broken invocation. This hook never invokes grep. +# +# Exit 0: commit proceeds (no hazard bytes in staged content). +# Exit 1: commit rejected — hazard byte found, OR scanner script is missing +# (restore scripts/verify-no-control-bytes.mjs or pass --no-verify +# explicitly to bypass the gate intentionally). +# Exit 2: scanner reported an unexpected error (three-value contract from +# verify-no-control-bytes.mjs); git treats this as rejection. + +set -e + +REPO_ROOT=$(git rev-parse --show-toplevel) +HOOK_SCRIPT="$REPO_ROOT/scripts/verify-no-control-bytes.mjs" + +if [ ! -f "$HOOK_SCRIPT" ]; then + echo "pre-commit: scripts/verify-no-control-bytes.mjs not found — commit blocked (D-CB5)." >&2 + echo "pre-commit: Restore the script or use --no-verify to bypass intentionally." >&2 + exit 1 +fi + +# Run in --staged mode: reads git index, not working tree (D-CB8). +exec node "$HOOK_SCRIPT" --staged diff --git a/scripts/verify-no-control-bytes.mjs b/scripts/verify-no-control-bytes.mjs new file mode 100644 index 00000000..85099d62 --- /dev/null +++ b/scripts/verify-no-control-bytes.mjs @@ -0,0 +1,634 @@ +#!/usr/bin/env node +/** + * D-CB1: Source-hygiene gate — scans tracked git source for hazardous codepoints. + * + * The hazard class is derived from crates/mds-cli/tests/common/mod.rs:38-62 + * (`assert_no_control_chars`), with ONE documented divergence: + * + * D-CB3 DIVERGENCE: CR (U+000D) is permitted when immediately followed by LF + * (i.e., CRLF line endings are allowed). The Rust helper flags all CR + * unconditionally. This carve-out preserves the 17 CRLF pairs in the two + * `mds fmt` fixture files. A future `.gitattributes text=auto` would normalize + * those fixtures and may break the fmt tests — documented here so that change + * is deliberate, not accidental. + * + * D-CB2: No hazard codepoint appears as a literal or backslash-u escape in + * this file. All are written as numeric values. The edit tooling decodes + * \uXXXX (4-hex) patterns into live bytes — this file's self-scan guards + * against that vector (avoids PF-018). + * + * D-CB7: Pure Node codepoint iteration — no grep. BSD grep (macOS default) + * lacks -P and exits 2 with empty output, making the absence of hazard bytes + * indistinguishable from a grep invocation that cannot run (avoids PF-013). + * + * D-CB5: Fails closed. In full-tree mode, zero-files-scanned is exit 1, not + * exit 0 (avoids PF-016 — an empty scan masquerades as clean). In --staged + * mode, an empty ACMR-filtered set is a legitimate state (deletion-only + * commits, amend with no content changes) and exits 0 with an explicit + * message; the full-tree scan is the authoritative non-vacuity gate. + * + * D-CB8: --staged mode reads file content from the git index via a single + * `git cat-file --batch` subprocess (all staged blobs at once), never from + * the working tree. Staging a clean file then modifying the working copy + * does not bypass the hook. + * + * Usage: + * node scripts/verify-no-control-bytes.mjs # full tree scan + * node scripts/verify-no-control-bytes.mjs --staged # pre-commit (index) + * node scripts/verify-no-control-bytes.mjs ... # explicit paths + * + * Exit codes: + * 0 — no hazards found (prints file count and byte count for non-vacuity) + * 1 — hazard found, zero files scanned, invalid UTF-8, un-allowlisted NUL, + * unreadable tracked path, stale/unmatched allowlist entry, git missing + * from PATH, or not inside a git work tree (all fail-closed) + * 2 — indeterminate: a git subcommand (ls-files / diff --cached / cat-file) + * failed unexpectedly + */ +'use strict'; + +import { spawnSync } from 'node:child_process'; +import { readFileSync, realpathSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** + * True when this module is the process entry point. + * + * Two traps make the obvious `import.meta.url === 'file://' + process.argv[1]` + * wrong, and both fail SILENTLY: main() never runs, the process exits 0, and a + * gate that scanned nothing is indistinguishable from a clean tree. + * 1. Percent-encoding — any path containing a space never matches. + * 2. Symlinks — Node resolves import.meta.url through realpath, while + * process.argv[1] keeps the path as typed (on macOS /tmp and + * /var/folders are symlinks, so this is the common case, not a corner). + * Comparing realpaths handles both (applies ADR-009, avoids PF-013). + * + * @param {string} metaUrl — the caller's import.meta.url + * @returns {boolean} + */ +export function isMainModule(metaUrl) { + const entry = process.argv[1]; + if (!entry) return false; + const modulePath = fileURLToPath(metaUrl); + try { + return realpathSync(entry) === realpathSync(modulePath); + } catch { + return pathToFileURL(resolve(entry)).href === metaUrl; + } +} + +// --------------------------------------------------------------------------- +// D-CB1: Hazard class definition. +// +// Exported so tests can import and assert completeness (D-CB1a: golden-set +// test prevents silent narrowing). +// +// Entry forms: +// { from, to } — inclusive codepoint range +// { cp, crlfException: true } — single codepoint with CRLF exception +// number — single codepoint +// --------------------------------------------------------------------------- +export const HAZARD_RANGES = [ + // C0 control characters (0x00-0x1F), excluding TAB (0x09) and LF (0x0A). + // D-CB3: CR (0x0D) has a CRLF exception — see entry below. + { from: 0x00, to: 0x08 }, // 1. C0: NUL..BS (below TAB) + { from: 0x0b, to: 0x0c }, // 2. C0: VT, FF (between LF and CR) + { cp: 0x0d, crlfException: true }, // 3. CR — lone CR fails; CRLF passes (D-CB3) + { from: 0x0e, to: 0x1f }, // 4. C0: SO..US (above CR) + { from: 0x7f, to: 0x7f }, // 5. DEL + { from: 0x80, to: 0x9f }, // 6. C1 (at codepoint level — catches 0xC2 0x80-0x9F + // in UTF-8; continuation bytes are NOT matched + // because a 0x80-0x9F byte following a start byte + // is decoded to a codepoint >= 0x100 that falls + // outside this range) + // Twelve Unicode Bidi_Control=Yes codepoints (Trojan Source, CVE-2021-42574). + 0x061c, // 7. U+061C Arabic Letter Mark + 0x200e, // 8. U+200E Left-to-Right Mark + 0x200f, // 9. U+200F Right-to-Left Mark + 0x202a, // 10. U+202A Left-to-Right Embedding + 0x202b, // 11. U+202B Right-to-Left Embedding + 0x202c, // 12. U+202C Pop Directional Formatting + 0x202d, // 13. U+202D Left-to-Right Override + 0x202e, // 14. U+202E Right-to-Left Override + 0x2066, // 15. U+2066 Left-to-Right Isolate + 0x2067, // 16. U+2067 Right-to-Left Isolate + 0x2068, // 17. U+2068 First Strong Isolate + 0x2069, // 18. U+2069 Pop Directional Isolate + // JavaScript line/paragraph terminators (outside Bidi_Control, still hazardous + // because JS parsers treat them as line endings inside string literals). + 0x2028, // 19. U+2028 Line Separator + 0x2029, // 20. U+2029 Paragraph Separator + // Byte-Order Mark / Zero-Width No-Break Space. + 0xfeff, // 21. U+FEFF BOM / ZWNBSP +]; +// HAZARD_RANGES has 21 entries. AC-12 lists the same class; the test asserts +// this exact count and composition (D-CB1a: golden-set completeness guard). + +// --------------------------------------------------------------------------- +// D-CB6: Two enumerated allowlists, both empty. Every entry carries a written +// `reason`. A stale entry (path absent or declared codepoints no longer +// present) is itself an exit 1. +// +// BINARY_ALLOWLIST: files containing NUL bytes (intentional binary content). +// HAZARD_ALLOWLIST: files with specific hazardous codepoints for test fixtures. +// --------------------------------------------------------------------------- +export const BINARY_ALLOWLIST = [ + // { path: 'relative/from/root', reason: 'explanation' } +]; + +export const HAZARD_ALLOWLIST = [ + // { path: 'relative/from/root', codepoints: [0x...], reason: 'explanation' } +]; + +// --------------------------------------------------------------------------- +// Hazard predicate +// --------------------------------------------------------------------------- + +/** + * @param {number} cp — Unicode codepoint being tested + * @param {number|null} nextCp — codepoint immediately following cp (for CRLF) + * @returns {boolean} + */ +export function isHazardous(cp, nextCp) { + for (const entry of HAZARD_RANGES) { + if (typeof entry === 'number') { + if (cp === entry) return true; + } else if (entry.crlfException) { + // D-CB3: CR (0x0D) is only hazardous when the NEXT char is NOT LF. + if (cp === entry.cp && nextCp !== 0x0a) return true; + } else { + if (cp >= entry.from && cp <= entry.to) return true; + } + } + return false; +} + +// --------------------------------------------------------------------------- +// Hexdump context helper (D-CB7: readable failure output) +// --------------------------------------------------------------------------- + +/** + * Return ±8-byte hex context around `offset` in `buf`. + * @param {Buffer} buf + * @param {number} offset + * @returns {string} + */ +function hexContext(buf, offset) { + const start = Math.max(0, offset - 8); + const end = Math.min(buf.length, offset + 10); + const hex = []; + for (let i = start; i < end; i++) { + const byte = buf[i].toString(16).padStart(2, '0'); + hex.push(i === offset ? `[${byte}]` : byte); + } + return hex.join(' '); +} + + +// --------------------------------------------------------------------------- +// git helpers +// --------------------------------------------------------------------------- + +function gitExec(args, cwd = process.cwd()) { + const result = spawnSync('git', args, { cwd, encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 }); + if (result.error) { + if (result.error.code === 'ENOENT') { + // AC-16: fail-closed (exit 1) — "git missing" is a known, named failure, + // not an indeterminate tool error. + console.error('✖ verify-no-control-bytes: git is not on PATH'); + process.exit(1); + } + console.error(`✖ verify-no-control-bytes: git error: ${result.error.message}`); + process.exit(2); + } + return result; +} + +/** Verify we are inside a git work tree (exit 1 if not — AC-16: fail-closed). */ +function assertGitRepo(cwd) { + const r = gitExec(['rev-parse', '--is-inside-work-tree'], cwd); + if (r.status !== 0) { + // AC-16: fail-closed (exit 1) — "not a git repo" is a known, named failure, + // not an indeterminate tool error. + console.error('✖ verify-no-control-bytes: not inside a git work tree'); + process.exit(1); + } +} + +/** + * Get file list in default mode via `git ls-files -sz`. + * Returns array of { path, mode } objects. + * D-CB5a: skips git modes 120000 (symlink) and 160000 (gitlink). + * + * `git ls-files -sz` output format (each entry NUL-terminated): + * \t\0... + * The TAB separates the staging info from the file path within ONE NUL record. + */ +function getTrackedFiles(cwd) { + const r = gitExec(['ls-files', '-sz'], cwd); + if (r.status !== 0) { + console.error('✖ verify-no-control-bytes: git ls-files failed'); + process.exit(2); + } + // Each NUL-terminated entry is " \t" + const entries = r.stdout.toString('utf8').split('\0').filter(s => s.length > 0); + const files = []; + for (const entry of entries) { + const tabIdx = entry.indexOf('\t'); + if (tabIdx === -1) continue; // Malformed entry — skip + const meta = entry.slice(0, tabIdx); + const path = entry.slice(tabIdx + 1); + // meta format: " " + const mode = parseInt(meta.split(' ')[0], 8); + if (mode === 0o120000 || mode === 0o160000) { + files.push({ path, mode, skip: true }); + } else { + files.push({ path, mode, skip: false }); + } + } + return files; +} + +/** + * Get staged file list for --staged mode. + * Uses `git diff --cached --name-only -z --diff-filter=ACMR` for paths. + * D-CB8: content is fetched later in batch via readAllIndexBlobs(). + */ +function getStagedFiles(cwd) { + const r = gitExec(['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMR'], cwd); + if (r.status !== 0) { + // D-CB5: fail closed. `git diff --cached` exits 0 even when nothing is + // staged, so a non-zero status is a real tool failure (corrupt index, + // unreadable object). Treating it as "no staged files" would let the + // pre-commit hook report success on a scan that never happened. + console.error( + `✖ verify-no-control-bytes: git diff --cached failed (status ${r.status}): ` + + `${r.stderr.toString('utf8').trim()}`, + ); + process.exit(2); + } + const paths = r.stdout.toString('utf8').split('\0').filter(s => s.length > 0); + return paths.map(p => ({ path: p, mode: 0o100644, skip: false, staged: true })); +} + +/** + * Read all staged blobs in one `git cat-file --batch` subprocess. + * D-CB8: collapses N per-file spawns into one, never reads the working tree. + * + * `git cat-file --batch` output for each valid blob: + * blob \n + * bytes> + * \n ← one-byte LF terminator after content + * + * @param {string[]} paths — repo-relative staged paths + * @param {string} cwd + * @returns {Map} + */ +function readAllIndexBlobs(paths, cwd) { + if (paths.length === 0) return new Map(); + + // Input: ":\n" for every staged path + const stdin = Buffer.from(paths.map(p => `:${p}\n`).join(''), 'utf8'); + const r = spawnSync('git', ['cat-file', '--batch'], { + cwd, + input: stdin, + encoding: 'buffer', + maxBuffer: 256 * 1024 * 1024, + }); + if (r.error) { + console.error(`✖ verify-no-control-bytes: git cat-file --batch: ${r.error.message}`); + process.exit(2); + } + if (r.status !== 0) { + console.error( + `✖ verify-no-control-bytes: git cat-file --batch exited ${r.status}: ` + + `${r.stderr.toString('utf8').trim()}`, + ); + process.exit(2); + } + + const out = r.stdout; + const results = new Map(); + let pos = 0; + + for (const path of paths) { + if (pos >= out.length) { + console.error(`✖ verify-no-control-bytes: unexpected end of cat-file output for ${path}`); + process.exit(2); + } + // Find header line (terminated by LF) + let nlPos = pos; + while (nlPos < out.length && out[nlPos] !== 0x0a) nlPos++; + if (nlPos >= out.length) { + console.error(`✖ verify-no-control-bytes: malformed cat-file header for ${path}`); + process.exit(2); + } + const header = out.slice(pos, nlPos).toString('utf8'); + pos = nlPos + 1; // advance past header LF + + // Check for "missing" response (no content follows) + if (header.endsWith(' missing')) { + console.error(`✖ verify-no-control-bytes: staged path not in index: ${path}`); + process.exit(2); + } + // Parse " blob " + const parts = header.split(' '); + if (parts.length !== 3 || parts[1] !== 'blob') { + console.error( + `✖ verify-no-control-bytes: unexpected cat-file response for ${path}: ${header}`, + ); + process.exit(2); + } + const size = parseInt(parts[2], 10); + if (Number.isNaN(size) || size < 0) { + console.error(`✖ verify-no-control-bytes: invalid blob size for ${path}: ${header}`); + process.exit(2); + } + results.set(path, out.slice(pos, pos + size)); + pos += size + 1; // advance past content + terminator LF + } + + return results; +} + +// --------------------------------------------------------------------------- +// Scanner core +// --------------------------------------------------------------------------- + +/** + * Scan a single file buffer for hazardous codepoints. + * + * The UTF-8 decode and hazard check are fused into one inline pass — no + * intermediate {cp, byteOffset} array is allocated. Only hit records are + * retained. This eliminates the ~53x heap amplification of the former + * decodeUtf8() materialisation (AC-30). + * + * nextCp look-ahead: isHazardous() uses nextCp only to check `nextCp !== 0x0A` + * (CRLF exception for CR). LF is ASCII, so `buf[nextStart] === 0x0A` iff the + * next codepoint is LF — passing the raw lead byte is correct here. + * + * @param {Buffer} buf — raw file bytes + * @param {string} relPath — repo-relative path (for error messages) + * @param {Set} allowedCps — codepoints explicitly allowlisted for this file + * @returns {{ codepoint: number, byteOffset: number, allowed?: boolean }[]} + * Every hazard occurrence, including allowlisted ones. Allowlisted hits carry + * `allowed: true` so the caller can record the entry as exercised — dropping + * them here would make every HAZARD_ALLOWLIST entry look stale (D-CB6). + */ +export function scanBuffer(buf, relPath, allowedCps) { + // Check for NUL (binary file indicator) + if (buf.includes(0x00)) { + const inBinaryAllowlist = BINARY_ALLOWLIST.some(e => e.path === relPath); + if (!inBinaryAllowlist) { + return [{ codepoint: 0x00, byteOffset: buf.indexOf(0x00), binaryError: true }]; + } + return []; // Allowed binary file + } + + // Inline UTF-8 decode + hazard scan (no intermediate codepoint array). + const hits = []; + let i = 0; + while (i < buf.length) { + const b0 = buf[i]; + let cp, len; + + if (b0 <= 0x7f) { + cp = b0; + len = 1; + } else if ((b0 & 0xe0) === 0xc0) { + if (i + 1 >= buf.length || (buf[i + 1] & 0xc0) !== 0x80) { + return [{ codepoint: -1, byteOffset: i, invalidUtf8: true }]; + } + cp = ((b0 & 0x1f) << 6) | (buf[i + 1] & 0x3f); + len = 2; + } else if ((b0 & 0xf0) === 0xe0) { + if (i + 2 >= buf.length || (buf[i + 1] & 0xc0) !== 0x80 || (buf[i + 2] & 0xc0) !== 0x80) { + return [{ codepoint: -1, byteOffset: i, invalidUtf8: true }]; + } + cp = ((b0 & 0x0f) << 12) | ((buf[i + 1] & 0x3f) << 6) | (buf[i + 2] & 0x3f); + len = 3; + } else if ((b0 & 0xf8) === 0xf0) { + if (i + 3 >= buf.length || + (buf[i + 1] & 0xc0) !== 0x80 || + (buf[i + 2] & 0xc0) !== 0x80 || + (buf[i + 3] & 0xc0) !== 0x80) { + return [{ codepoint: -1, byteOffset: i, invalidUtf8: true }]; + } + cp = ((b0 & 0x07) << 18) | + ((buf[i + 1] & 0x3f) << 12) | + ((buf[i + 2] & 0x3f) << 6) | + (buf[i + 3] & 0x3f); + len = 4; + } else { + return [{ codepoint: -1, byteOffset: i, invalidUtf8: true }]; // Invalid lead byte + } + + // Peek first byte of next sequence for CRLF look-ahead (see JSDoc above). + const nextStart = i + len; + const nextCp = nextStart < buf.length ? buf[nextStart] : null; + if (isHazardous(cp, nextCp)) { + hits.push({ codepoint: cp, byteOffset: i, allowed: allowedCps.has(cp) }); + } + i += len; + } + return hits; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main() { + const args = process.argv.slice(2); + const isStaged = args.includes('--staged'); + const explicitPaths = args.filter(a => a !== '--staged'); + + const cwd = process.cwd(); + + // Verify git is accessible and we are in a repo (D-CB5) + assertGitRepo(cwd); + + // ---- Build file list ---- + let fileEntries; + let skippedCount = 0; + + if (explicitPaths.length > 0) { + // Explicit path mode (used by tests with temp repos) + fileEntries = explicitPaths.map(p => ({ + path: p, + mode: 0o100644, + skip: false, + staged: false, + absolutePath: resolve(cwd, p), + })); + } else if (isStaged) { + // D-CB8: staged mode — read from git index + fileEntries = getStagedFiles(cwd).map(e => ({ ...e, absolutePath: null })); + } else { + // Default: full tracked tree + const all = getTrackedFiles(cwd); + skippedCount = all.filter(e => e.skip).length; + fileEntries = all + .filter(e => !e.skip) + .map(e => ({ ...e, absolutePath: resolve(cwd, e.path) })); + } + + // ---- D-CB5: Non-vacuity guard (AC-6) ---- + // Full-tree mode: zero tracked files means path discovery broke — fail closed. + // --staged mode: an empty ACMR-filtered set is a LEGITIMATE state: + // • deletion-only commit (git rm): ACMR excludes deletions; files ARE staged. + // • amend with no content changes: index equals HEAD; diff is empty. + // In both cases exit 0 with an explicit message. The full-tree scan is the + // authoritative non-vacuity gate; the hook must not block valid commits. + if (fileEntries.length === 0) { + if (!isStaged) { + console.error('✖ verify-no-control-bytes: zero files scanned (D-CB5: empty scan is not a pass)'); + console.error(' If this is a new repo with no commits, run `git add` first.'); + process.exit(1); + } + // --staged: check unfiltered diff to provide an accurate message. + const rAll = gitExec(['diff', '--cached', '--name-only', '-z'], cwd); + if (rAll.status !== 0) { + console.error( + `✖ verify-no-control-bytes: git diff --cached (unfiltered) failed: ` + + `${rAll.stderr.toString('utf8').trim()}`, + ); + process.exit(2); + } + const allPaths = rAll.stdout.toString('utf8').split('\0').filter(s => s.length > 0); + if (allPaths.length > 0) { + // Staged changes exist but are all deletions — nothing for the content scanner to do. + console.log( + `✓ source-hygiene gate: 0 content-bearing staged paths` + + ` (${allPaths.length} deletion(s)) — nothing to scan`, + ); + } else { + // Index equals HEAD (amend --no-edit, reword, --allow-empty, etc.). + console.log('✓ source-hygiene gate: no staged content — nothing to scan'); + } + process.exit(0); + } + + // ---- Validate allowlists upfront (D-CB6) ---- + const errors = []; + + // Build allowlist lookup: path -> Set + const hazardAllowMap = new Map(); // relPath -> Set + for (const entry of HAZARD_ALLOWLIST) { + if (!hazardAllowMap.has(entry.path)) hazardAllowMap.set(entry.path, new Set()); + for (const cp of entry.codepoints) { + hazardAllowMap.get(entry.path).add(cp); + } + } + + // ---- In --staged mode, pre-fetch all blobs in one subprocess (D-CB8) ---- + // readAllIndexBlobs() collapses N per-file `git cat-file blob` spawns into a + // single `git cat-file --batch` call (~11 ms/file saved for large staged sets). + const blobMap = isStaged ? readAllIndexBlobs(fileEntries.map(e => e.path), cwd) : null; + + // ---- Scan each file ---- + let totalBytes = 0; + let scannedFiles = 0; + const exercisedAllowlist = new Set(); // tracks which allowlist entries are hit + // AC-30: hexCtx is pre-computed so the file buffer is not retained beyond the + // scan of a single file. { path, codepoint, byteOffset, hexCtx } + const hazardHits = []; + + for (const entry of fileEntries) { + let buf; + try { + if (isStaged) { + buf = blobMap.get(entry.path); + if (buf === undefined) { + errors.push(`Cannot read staged blob for ${entry.path}: not in batch output`); + continue; + } + } else { + buf = readFileSync(entry.absolutePath || resolve(cwd, entry.path)); + } + } catch (err) { + errors.push(`Cannot read ${entry.path}: ${err.message}`); + continue; + } + + totalBytes += buf.length; + scannedFiles++; + + const allowedCps = hazardAllowMap.get(entry.path) ?? new Set(); + const hits = scanBuffer(buf, entry.path, allowedCps); + + for (const hit of hits) { + if (hit.invalidUtf8) { + errors.push(`${entry.path}: invalid UTF-8 content (not a text file?)`); + } else if (hit.binaryError) { + errors.push(`${entry.path}: contains NUL bytes — add to BINARY_ALLOWLIST with a reason`); + } else if (hit.allowed) { + // D-CB6: the allowlist entry is genuinely exercised — record it so the + // stale-entry check below does not flag it. + exercisedAllowlist.add(`${entry.path}:${hit.codepoint}`); + } else { + // AC-30: compute hex context now so buf is not retained after this iteration. + hazardHits.push({ path: entry.path, codepoint: hit.codepoint, byteOffset: hit.byteOffset, hexCtx: hexContext(buf, hit.byteOffset) }); + } + } + } + + // ---- Stale allowlist check (D-CB6) ---- + for (const entry of BINARY_ALLOWLIST) { + const exists = fileEntries.some(e => e.path === entry.path); + if (!exists) { + errors.push(`BINARY_ALLOWLIST: stale entry "${entry.path}" — file is no longer tracked`); + } + } + for (const entry of HAZARD_ALLOWLIST) { + const exists = fileEntries.some(e => e.path === entry.path); + if (!exists) { + errors.push(`HAZARD_ALLOWLIST: stale entry "${entry.path}" — file is no longer tracked`); + } else { + // Verify the declared codepoints actually occur in the file + for (const cp of entry.codepoints) { + const key = `${entry.path}:${cp}`; + if (!exercisedAllowlist.has(key)) { + errors.push(`HAZARD_ALLOWLIST: stale entry "${entry.path}" cp U+${cp.toString(16).toUpperCase().padStart(4, '0')} — codepoint not found in file`); + } + } + } + } + + // ---- Report ---- + const passStats = `Scanned ${scannedFiles} file(s), ${totalBytes} byte(s)` + + (skippedCount > 0 ? `, ${skippedCount} symlink/gitlink skipped` : ''); + + // AC-17: name every allowlist entry that was actually exercised by this run. + for (const entry of HAZARD_ALLOWLIST) { + const exercisedCps = entry.codepoints.filter(cp => exercisedAllowlist.has(`${entry.path}:${cp}`)); + if (exercisedCps.length > 0) { + const cpList = exercisedCps + .map(cp => `U+${cp.toString(16).toUpperCase().padStart(4, '0')}`) + .join(', '); + console.log(` allowlist exercised: ${entry.path} [${cpList}] (reason: ${entry.reason})`); + } + } + + if (hazardHits.length > 0 || errors.length > 0) { + for (const e of errors) { + console.error(`✖ ${e}`); + } + for (const hit of hazardHits) { + const cpHex = `U+${hit.codepoint.toString(16).toUpperCase().padStart(4, '0')}`; + console.error(`✖ ${hit.path}: hazardous codepoint ${cpHex} at byte offset ${hit.byteOffset}`); + console.error(` context: ${hit.hexCtx}`); + } + console.error(`✖ source-hygiene gate FAILED — ${passStats}`); + process.exit(1); + } + + console.log(`✓ source-hygiene gate: ${passStats}`); + process.exit(0); +} + +// Run only when executed directly (not imported by tests). See isMainModule. +if (isMainModule(import.meta.url)) { + main(); +} diff --git a/scripts/verify-pr-checks.mjs b/scripts/verify-pr-checks.mjs new file mode 100644 index 00000000..36012447 --- /dev/null +++ b/scripts/verify-pr-checks.mjs @@ -0,0 +1,538 @@ +#!/usr/bin/env node +/** + * D-PR1: Pre-merge check verifier — asserts that all required branch-protection + * contexts are completed+success before an --admin merge. + * + * Addresses PF-017: a CANCELLED GitHub Actions run is neither success nor + * failure. `gh pr merge --admin` treats a cancelled run as not-failing and + * merges, bypassing the required-status gate. This tool explicitly checks + * status=completed AND conclusion=success for every required context, and + * treats cancelled/skipped/stale/in_progress as NOT passing. + * + * D-PR2: Required contexts are read LIVE from branch protection — never + * hardcoded. On 403 the script exits 2. On 404 (unprotected base) the script + * exits 2 unless --required-from is supplied. A protected branch that + * lists ZERO required contexts also exits 2: "all 0 required contexts passed" + * is a vacuous green, and vacuous greens are what PF-017 was made of. + * + * D-PR2a: A required context is resolved against the UNION of check-runs AND + * commit statuses (GitHub branch protection accepts either namespace). + * + * D-PR3: Three tiers: + * Tier A (required): MUST be completed+success — missing/cancelled/etc = FAIL + * Tier B (non-required check-runs): failure/cancelled/timed_out = FAIL + * Tier C (legacy commit statuses): advisory unless the context is required + * + * D-PR4: Non-vacuity guard — zero check-runs = FAIL (the #239 case). + * Counts are always printed on every run (avoids PF-013). + * + * D-PR4a: Pagination is bounded at MAX_PAGES; reaching it exits 2. + * `filter=latest` is pinned explicitly (default today, but implicit + * defaults can change and this gate's verdict depends on it). + * + * D-PR5: On PASS the tool prints a merge command with --match-head-commit + * , closing the TOCTOU window where the verified SHA diverges + * from HEAD by the time the merge runs. + * + * D-PR6: Exit codes — 0 PASS, 1 FAIL, 2 indeterminate. "Cannot tell" is + * never 0. + * + * Usage: + * node scripts/verify-pr-checks.mjs + * node scripts/verify-pr-checks.mjs --required-from + * + * Exit codes: + * 0 — all required contexts completed+success; prints `gh pr merge` command + * 1 — one or more required contexts missing/cancelled/failed/etc + * 2 — tool error: gh missing/too old, protection unreadable, pagination error + */ +'use strict'; + +import { spawnSync } from 'node:child_process'; +import { realpathSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** + * True when this module is the process entry point. + * + * Deliberately not `import.meta.url === 'file://' + process.argv[1]`: that + * comparison is false for any path a file URL percent-encodes (a space) and + * for any symlinked path (Node resolves import.meta.url through realpath but + * leaves argv[1] as typed — on macOS /tmp and /var/folders are symlinks). Both + * failures are silent: main() never runs and the merge gate exits 0 having + * verified nothing. Kept local so each scripts/verify-*.mjs stays standalone. + */ +function isMainModule(metaUrl) { + const entry = process.argv[1]; + if (!entry) return false; + const modulePath = fileURLToPath(metaUrl); + try { + return realpathSync(entry) === realpathSync(modulePath); + } catch { + return pathToFileURL(resolve(entry)).href === metaUrl; + } +} + +// D-PR4a: hard page cap — exit 2 rather than evaluating a partial result +const MAX_PAGES = 20; +// D-PR5: minimum gh version required for --match-head-commit +const MIN_GH_MAJOR = 2; +const MIN_GH_MINOR = 31; + +// --------------------------------------------------------------------------- +// gh runner (thin IO shim; injected in tests for offline operation) +// --------------------------------------------------------------------------- + +/** + * Default runner: calls `gh api` and returns parsed JSON. + * @param {string[]} args + * @returns {any} + */ +function defaultGhRunner(args) { + const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); + if (r.error) { + const stderr = r.error.code === 'ENOENT' + ? 'gh is not on PATH' + : `gh error: ${r.error.message}`; + return { __error: true, status: -1, stderr }; + } + if (r.status !== 0) { + // Return status code so caller can handle 404/403 + return { __error: true, status: r.status, stderr: r.stderr }; + } + try { + return JSON.parse(r.stdout); + } catch { + return { __error: true, status: r.status, raw: r.stdout, stderr: r.stderr }; + } +} + +// --------------------------------------------------------------------------- +// D-PR1: Pure evaluation function (no I/O — fully testable offline) +// --------------------------------------------------------------------------- + +/** + * @typedef {{ + * name: string; + * status: string; // 'completed' | 'queued' | 'in_progress' | ... + * conclusion: string | null; // 'success' | 'failure' | 'cancelled' | ... + * }} CheckRun + * + * @typedef {{ + * context: string; + * state: string; // 'success' | 'failure' | 'error' | 'pending' + * }} CommitStatus + * + * @typedef {{ + * requiredContexts: string[]; + * checkRuns: CheckRun[]; + * statuses: CommitStatus[]; + * headSha: string; + * }} EvaluateInput + * + * @typedef {{ + * pass: boolean; + * exitCode: number; // 0, 1, or 2 + * lines: string[]; // human-readable output lines + * mergeCommand?: string; + * }} EvaluateResult + */ + +/** + * Evaluate check-run and status data against required contexts. + * This is the pure decision function — inject any data source. + * + * applies ADR-009, avoids PF-013: prints counts on every call. + * avoids PF-017: required contexts must be status=completed AND conclusion=success. + * + * @param {EvaluateInput} input + * @returns {EvaluateResult} + */ +export function evaluateChecks({ requiredContexts, checkRuns, statuses, headSha }) { + const lines = []; + const failures = []; + let pass = true; + + const nChecks = checkRuns.length; + const nStatuses = statuses.length; + const nRequired = requiredContexts.length; + + // D-PR4: Non-vacuity guard — an empty required set can never be evidence of + // merge safety. "All 0 required contexts passed" is the same vacuous green + // that ADR-009 forbids: the tool cannot tell, so it exits 2, never 0. + // Reachable whenever protection exists but lists no required status checks, + // or when a future API shape stops populating `contexts`. + if (nRequired === 0) { + lines.push(` check-runs: ${nChecks}, statuses: ${nStatuses}, required contexts: ${nRequired}`); + lines.push( + '✖ INDETERMINATE: zero required contexts — nothing to verify, so this is not a pass ' + + '(applies ADR-009). Point --required-from at a branch whose protection lists required checks.', + ); + return { pass: false, exitCode: 2, lines }; + } + + // D-PR4: Non-vacuity guard — zero check-runs is the #239 shape (not a pass) + if (nChecks === 0) { + lines.push(` check-runs: ${nChecks}, statuses: ${nStatuses}, required contexts: ${nRequired}`); + lines.push('✖ FAIL: zero check-runs (the #239 shape — not a pass, avoids PF-013)'); + // AC-22: still name every required context that was absent so the caller + // knows exactly what was missing, even though the non-vacuity guard is + // already sufficient to FAIL. This matches the Tier A loop's behavior for + // a partial check-run set and eliminates vacuous "zero check-runs" messages + // that don't say which contexts were expected. + for (const ctx of requiredContexts) { + lines.push(`✖ Tier A (required): "${ctx}" — not found in check-runs (never ran)`); + } + return { pass: false, exitCode: 1, lines }; + } + + // Always print counts (D-PR4 / avoids PF-013) + lines.push(` check-runs: ${nChecks}, statuses: ${nStatuses}, required contexts: ${nRequired}`); + + // Build lookup maps. + // A name maps to EVERY check-run carrying it, not just the last one seen: + // `filter=latest` de-duplicates within a check-suite, but two suites (two + // workflows) can publish the same name, and a required context is satisfied + // by the name. Keeping only the last entry lets a later success mask an + // earlier failure — a fail-open in a merge gate. + const checksByName = new Map(); // name -> CheckRun[] + for (const cr of checkRuns) { + const list = checksByName.get(cr.name); + if (list) list.push(cr); + else checksByName.set(cr.name, [cr]); + } + const statusByContext = new Map(); // context -> CommitStatus + for (const st of statuses) { + statusByContext.set(st.context, st); + } + + // ---- Tier A: required contexts ---- + // D-PR2a: resolved against the UNION of check-runs and commit statuses. + // avoids PF-017: must be status=completed AND conclusion=success. + for (const ctx of requiredContexts) { + const crs = checksByName.get(ctx); + const st = statusByContext.get(ctx); + + if (crs) { + // Found in check-runs namespace — EVERY run under this name must pass. + for (const cr of crs) { + if (cr.status !== 'completed' || cr.conclusion !== 'success') { + failures.push( + `Tier A (required): "${ctx}" — status=${cr.status}, conclusion=${cr.conclusion ?? 'null'}` + + (crs.length > 1 ? ` (1 of ${crs.length} runs sharing this name)` : '') + + ` (avoids PF-017: cancelled/skipped/in_progress are not success)`, + ); + pass = false; + } + } + } else if (st) { + // Found in commit statuses namespace + if (st.state !== 'success') { + failures.push(`Tier A (required): "${ctx}" — status.state=${st.state} (must be "success")`); + pass = false; + } + } else { + // Not found in either namespace + failures.push(`Tier A (required): "${ctx}" — not found in check-runs or statuses (never ran)`); + pass = false; + } + } + + const requiredSet = new Set(requiredContexts); + + // ---- Tier B: non-required check-runs ---- + // failure/cancelled/timed_out/action_required/stale = FAIL + // skipped/neutral = advisory (reported but not fatal) + const TIER_B_FAIL = new Set(['failure', 'timed_out', 'cancelled', 'action_required', 'stale']); + const TIER_B_ADVISORY = new Set(['skipped', 'neutral']); + + for (const cr of checkRuns) { + if (requiredSet.has(cr.name)) continue; // Already handled in Tier A + if (cr.status !== 'completed') continue; // Still running — skip advisory + if (cr.conclusion == null) continue; + + if (TIER_B_FAIL.has(cr.conclusion)) { + failures.push(`Tier B (non-required): "${cr.name}" — conclusion=${cr.conclusion}`); + pass = false; + } else if (TIER_B_ADVISORY.has(cr.conclusion)) { + lines.push(` advisory: "${cr.name}" — conclusion=${cr.conclusion}`); + } + } + + // ---- Tier C: legacy commit statuses ---- + // Advisory unless the context is required (Tier A already handled those). + // Justified by #240 evidence: the sole status was security/snyk (dean0x) + // in state=error due to account-plan limits — not a workflow in this repo. + for (const st of statuses) { + if (requiredSet.has(st.context)) continue; // Already handled in Tier A + if (st.state !== 'success' && st.state !== 'pending') { + lines.push(` advisory (Tier C): "${st.context}" — state=${st.state}`); + } + } + + // ---- Compose result ---- + for (const f of failures) { + lines.push(`✖ ${f}`); + } + + if (pass) { + const cmd = `gh pr merge --squash --match-head-commit ${headSha}`; + lines.push(`✓ PASS — all ${nRequired} required contexts completed+success`); + lines.push(` Verified SHA: ${headSha}`); + lines.push(` Merge command: ${cmd}`); + return { pass: true, exitCode: 0, lines, mergeCommand: cmd }; + } else { + lines.push(`✖ FAIL — ${failures.length} required context(s) not satisfied`); + return { pass: false, exitCode: 1, lines }; + } +} + +// --------------------------------------------------------------------------- +// Main (live path with real gh API calls) +// +// Every step below returns a Result ({ ok: true, ... } | { ok: false, exitCode, +// message }) instead of calling process.exit. Only the CLI wrapper at the +// bottom of this file translates an exit code into a process exit, which is +// what makes the exit-2 paths (404, 403, stale gh, unbounded pagination) +// reachable from an offline test with an injected runner. A tool whose +// failure paths can only be asserted by grepping its own source text is +// exactly the vacuous verification this script exists to eliminate +// (applies ADR-009, avoids PF-013). +// --------------------------------------------------------------------------- + +function ghVersion() { + const r = spawnSync('gh', ['--version'], { encoding: 'utf8' }); + if (r.error || r.status !== 0) return null; + // Output: "gh version 2.88.1 (2026-07-17)" + const m = r.stdout.match(/gh version (\d+)\.(\d+)/); + if (!m) return null; + return { major: parseInt(m[1], 10), minor: parseInt(m[2], 10) }; +} + +/** + * Fetch all pages of check-runs for a given sha, bounded at MAX_PAGES. + * D-PR4a: filter=latest pinned; paginate with hard cap; exit 2 on incomplete. + * + * @returns {{ ok: true, checkRuns: CheckRun[] } | { ok: false, exitCode: 2, message: string }} + */ +export function fetchCheckRuns(headSha, runner) { + const perPage = 100; + let page = 1; + const allCheckRuns = []; + let totalCount = null; + + // Bounded loop (reliability rule): at most MAX_PAGES iterations, always. + while (page <= MAX_PAGES) { + // D-PR4a: filter=latest pinned explicitly to prevent default-change surprises + const url = `/repos/{owner}/{repo}/commits/${headSha}/check-runs?per_page=${perPage}&page=${page}&filter=latest`; + const data = runner(['api', url]); + if (data.__error) { + return { + ok: false, + exitCode: 2, + message: `check-runs API error (page ${page}): ${data.stderr}`, + }; + } + if (totalCount === null) { + totalCount = data.total_count ?? 0; + } + const runs = data.check_runs ?? []; + allCheckRuns.push(...runs); + if (runs.length < perPage || allCheckRuns.length >= totalCount) break; + page++; + } + + if (page > MAX_PAGES) { + return { + ok: false, + exitCode: 2, + message: `pagination exceeded ${MAX_PAGES} pages (D-PR4a) — refusing to evaluate a partial set`, + }; + } + + // D-PR4a: assert we collected everything declared by total_count + if (totalCount !== null && allCheckRuns.length !== totalCount) { + return { + ok: false, + exitCode: 2, + message: `collected ${allCheckRuns.length} check-runs but total_count=${totalCount} — partial page set`, + }; + } + + return { ok: true, checkRuns: allCheckRuns }; +} + +/** + * Fetch commit statuses for a sha. + * @returns {{ ok: true, statuses: CommitStatus[] } | { ok: false, exitCode: 2, message: string }} + */ +export function fetchStatuses(headSha, runner) { + const url = `/repos/{owner}/{repo}/commits/${headSha}/status`; + const data = runner(['api', url]); + if (data.__error) { + return { ok: false, exitCode: 2, message: `commit-status API error: ${data.stderr}` }; + } + return { ok: true, statuses: data.statuses ?? [] }; +} + +/** + * Fetch required contexts from branch protection. + * D-PR2: exit 2 on 403 or when the base has no protection and no fallback. + * AC-29: unprotected base (404) exits 2 unless --required-from is given. + * + * The required set is the UNION of the legacy `contexts` array and the newer + * `checks[].context` array. GitHub populates both today; reading only the + * deprecated `contexts` would silently yield an empty required set — and an + * empty required set is a vacuous pass, not a pass. + * + * @returns {{ ok: true, contexts: string[], resolvedBranch: string, notes: string[] } + * | { ok: false, exitCode: 2, message: string }} + */ +export function fetchRequiredContexts(baseBranch, requiredFrom, runner) { + const branch = requiredFrom ?? baseBranch; + const url = `/repos/{owner}/{repo}/branches/${branch}/protection`; + const data = runner(['api', url]); + + if (data.__error) { + if (data.status === 404) { + const message = requiredFrom + ? `--required-from branch "${requiredFrom}" has no protection (404)` + : `base branch "${baseBranch}" has no protection (404). ` + + `Use --required-from to name a protected branch, e.g. --required-from main. ` + + `(AC-29: an unprotected base is not a pass — D-PR2)`; + return { ok: false, exitCode: 2, message }; + } + if (data.status === 403) { + return { + ok: false, + exitCode: 2, + message: 'branch protection unreadable (403 — insufficient permissions)', + }; + } + return { ok: false, exitCode: 2, message: `protection API error: ${data.stderr}` }; + } + + const rsc = data?.required_status_checks; + const contexts = [...new Set([ + ...(rsc?.contexts ?? []), + ...(rsc?.checks ?? []).map(c => c?.context).filter(c => typeof c === 'string'), + ])]; + + if (contexts.length === 0) { + return { + ok: false, + exitCode: 2, + message: + `branch "${branch}" is protected but lists zero required status checks — ` + + `there is nothing to verify, which is indeterminate, not a pass (applies ADR-009)`, + }; + } + + const notes = []; + if (requiredFrom && requiredFrom !== baseBranch) { + notes.push(` Required contexts read from: ${requiredFrom} (base branch "${baseBranch}" is unprotected)`); + } + return { ok: true, contexts, resolvedBranch: branch, notes }; +} + +const USAGE = + 'Usage: node scripts/verify-pr-checks.mjs [--required-from ]'; + +/** + * Live entry point. Returns an exit code; never calls process.exit, so tests + * can drive it end-to-end with an injected runner. + * + * @param {string[]} argv + * @param {(args: string[]) => any} runner — gh API shim + * @param {() => ({major:number,minor:number}|null)} ghVersionFn — version probe + * @returns {0|1|2} + */ +export function main(argv = process.argv.slice(2), runner = defaultGhRunner, ghVersionFn = ghVersion) { + const fail = (message) => { + console.error(`✖ verify-pr-checks: ${message}`); + }; + + // ---- Parse args ---- + const prArg = argv.find(a => /^\d+$/.test(a)); + if (!prArg) { + console.error(USAGE); + return 2; + } + const prNumber = parseInt(prArg, 10); + + const rfIdx = argv.indexOf('--required-from'); + if (rfIdx !== -1 && !argv[rfIdx + 1]) { + fail(`--required-from requires a branch name\n${USAGE}`); + return 2; + } + const requiredFrom = rfIdx !== -1 ? argv[rfIdx + 1] : null; + + // ---- Check gh version (D-PR5) ---- + const ver = ghVersionFn(); + if (!ver || ver.major < MIN_GH_MAJOR || (ver.major === MIN_GH_MAJOR && ver.minor < MIN_GH_MINOR)) { + const found = ver ? `${ver.major}.${ver.minor}` : 'unknown'; + fail( + `gh >= ${MIN_GH_MAJOR}.${MIN_GH_MINOR} required (found ${found}); ` + + `needed for --match-head-commit (D-PR5)`, + ); + return 2; + } + + // ---- Fetch PR metadata ---- + const prData = runner(['api', `/repos/{owner}/{repo}/pulls/${prNumber}`]); + if (prData.__error) { + fail(`cannot read PR ${prNumber}: ${prData.stderr}`); + return 2; + } + const headSha = prData.head?.sha; + const baseBranch = prData.base?.ref; + if (!headSha || !baseBranch) { + fail(`cannot determine head SHA or base branch for PR ${prNumber}`); + return 2; + } + + console.log(`PR #${prNumber}: base=${baseBranch} head=${headSha.slice(0, 7)}`); + + // ---- Fetch required contexts (D-PR2) ---- + const req = fetchRequiredContexts(baseBranch, requiredFrom, runner); + if (!req.ok) { + fail(req.message); + return req.exitCode; + } + for (const note of req.notes) console.log(note); + console.log(` Required contexts (${req.contexts.length}) from ${req.resolvedBranch}: ${req.contexts.join(', ')}`); + + // ---- Fetch check-runs (D-PR4a) ---- + const cr = fetchCheckRuns(headSha, runner); + if (!cr.ok) { + fail(cr.message); + return cr.exitCode; + } + + // ---- Fetch commit statuses (D-PR2a) ---- + const st = fetchStatuses(headSha, runner); + if (!st.ok) { + fail(st.message); + return st.exitCode; + } + + // ---- Evaluate (D-PR1: pure function) ---- + const result = evaluateChecks({ + requiredContexts: req.contexts, + checkRuns: cr.checkRuns, + statuses: st.statuses, + headSha, + }); + + for (const line of result.lines) { + console.log(line); + } + + return result.exitCode; +} + +// Run only when executed directly (not imported by tests). See isMainModule. +if (isMainModule(import.meta.url)) { + process.exit(main()); +} From cbb11d4cc429163b6bf079583ae2eb30f8e3edd7 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 12:48:14 +0300 Subject: [PATCH 02/34] =?UTF-8?q?feat(lint):=20JSON=20wire=20contract=20?= =?UTF-8?q?=E2=80=94=20sort,=20stdin=20label,=20name=20spans=20(#294)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batches three issues that all mutate the same `--format json` wire object. - #211: uniform `` 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 `` 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 --- .../20260812_0046/pr1-lint-json-plan.md | 580 +++++++++++ .github/workflows/ci.yml | 10 +- CHANGELOG.md | 114 +++ crates/mds-cli/src/build.rs | 21 +- crates/mds-cli/src/fmt.rs | 13 +- crates/mds-cli/src/lint.rs | 397 ++++++-- crates/mds-cli/src/main.rs | 9 +- crates/mds-cli/src/output.rs | 156 +++ crates/mds-cli/tests/cli_build.rs | 116 +++ crates/mds-cli/tests/cli_lint.rs | 910 +++++++++++++++++- crates/mds-cli/tests/print_discipline.rs | 21 + crates/mds-core/src/ast.rs | 11 + crates/mds-core/src/error.rs | 64 ++ crates/mds-core/src/formatter.rs | 44 + crates/mds-core/src/lint/diagnostic.rs | 360 ++++++- crates/mds-core/src/lint/facts.rs | 12 + .../mds-core/src/lint/rules/structural_eq.rs | 55 ++ .../mds-core/src/lint/rules/unused_import.rs | 364 ++++++- crates/mds-core/src/parser_helpers.rs | 61 +- crates/mds-core/src/parser_tests.rs | 25 + crates/mds-core/src/resolver.rs | 12 +- crates/mds-core/src/sourcemap.rs | 7 +- crates/mds-core/tests/api_surface.rs | 114 +++ crates/mds-napi/README.md | 2 +- crates/mds-napi/__test__/index.spec.mjs | 151 +++ crates/mds-napi/src/lib.rs | 6 +- crates/mds-python/README.md | 2 +- crates/mds-python/src/lib.rs | 43 +- crates/mds-python/tests/conftest.py | 24 +- crates/mds-python/tests/test_parity.py | 245 ++++- crates/mds-wasm/src/lib.rs | 6 +- examples/linting/README.md | 52 +- examples/python/README.md | 1 + packages/mds/README.md | 2 +- packages/mds/__test__/lint.spec.mjs | 365 +++++++ spec.md | 7 +- 36 files changed, 4237 insertions(+), 145 deletions(-) create mode 100644 .devflow/docs/design/v040-wave1/20260812_0046/pr1-lint-json-plan.md diff --git a/.devflow/docs/design/v040-wave1/20260812_0046/pr1-lint-json-plan.md b/.devflow/docs/design/v040-wave1/20260812_0046/pr1-lint-json-plan.md new file mode 100644 index 00000000..69f7051b --- /dev/null +++ b/.devflow/docs/design/v040-wave1/20260812_0046/pr1-lint-json-plan.md @@ -0,0 +1,580 @@ +# fix(lint)!: unify stdin label, sort diagnostics by offset, anchor unused-import spans at the unused name + +Issues: #211, #202, #203 + +## Implementation Plan + +# PR1 — Lint JSON wire contract (#211, #202, #203) + +> **Challenge-review amendments are marked `[CR]`.** Every line number below was re-verified against `113f472`; corrections are marked `[CR-fix]`. + +## 0a. USER RULING ON #211 (2026-08-12) — DECIDED, NOT OPEN + +**The stdin label is uniformly ``.** All four CLI diagnostic contexts converge on the +literal string ``: + +| Context | Today | After | Citation status | +|---|---|---|---| +| build / check — **site TBD, see the retraction below** | `` | `` | **RETRACTED** — the cited path does not exist | +| lint — `crates/mds-cli/src/lint.rs:682, :718, :727` **including the JSON `"file"` key, a published wire surface** | `input.mds` | `` | **CONFIRMED at `113f472`** | +| fmt — `crates/mds-cli/src/fmt.rs:133, :136` | `` | `` (no change) | **CONFIRMED at `113f472`** | + +### CITATION RETRACTION — `crates/mds-cli/src/formatter.rs:120` does not exist + +**The ruling's build/check citation is withdrawn.** It is recorded here rather than silently +dropped so no reader re-derives from it. Verified at `113f472`: `crates/mds-cli/src/` contains +exactly `build.rs`, `fmt.rs`, `lint.rs`, `main.rs`, `output.rs`, `watch.rs` — **there is no +`formatter.rs` in the CLI crate.** The only `formatter.rs` in the workspace is +`crates/mds-core/src/formatter.rs`, which is the *source formatter* and is unrelated to +diagnostic labels. **No line 120 of any file was the build/check `` emission site.** + +**The ruling itself is unaffected** — it fixes the user-visible label, and the label for +build/check is still ``. Only the pointer to where that change lands was wrong. Finding +the real site is an explicit implementation task; see §5 step 1a. **Do not substitute a guessed +line number for the retracted one.** + +What *is* verified about the `` value, as a starting point and nothing more: +`crates/mds-core/src/resolver.rs:199` defines `const SOURCE_LABEL: &str = ""` +(crate-private), assigned as `ctx.file_str` at **three** sites — `:508`, `:617`, `:642` — and +locked by a display-label test at `crates/mds-core/src/resolver_tests.rs:2917-2920`. That is +the origin of the value, **not** the CLI boundary where it reaches the user. + +**Rationale, recorded verbatim as the decision rationale:** + +> `input.mds` is ambiguous because a real file can legitimately be named `input.mds`, so a +> JSON consumer reading `"file": "input.mds"` cannot tell stdin from a real file of that +> name. Angle brackets mark a pseudo-source unambiguously. `` also matches what fmt +> already does. The project has effectively zero users and v0.4.0 is already a breaking +> release, so "least breakage" carries little weight while "right contract forever" carries +> a lot. + +### THE MECHANISM IS ALREADY IN THE TREE — extend the boundary relabel, do not touch the constant + +**This is the load-bearing architectural finding, verified at `113f472`. It changes PR1's +implementation approach; it does not change the ruling.** + +**1. `input.mds` is not a CLI literal — it is a shared public constant.** +`crates/mds-core/src/sourcemap.rs:79` defines `pub const STRING_SOURCE_MAP_LABEL: &str = +"input.mds"`, re-exported at `crates/mds-core/src/lib.rs:73`. Verified consumers: + +| Consumer | Role | +|---|---| +| `crates/mds-cli/src/lint.rs:682, :718, :727` | `named_source` name for the miette frame — **the lint sites the ruling changes** | +| `crates/mds-cli/src/build.rs:12` (import), `:992` (compare) | the existing stdin relabel | +| `crates/mds-wasm/src/lib.rs:69` | `DEFAULT_FILENAME` — **the WASM virtual-FS default filename** | +| `crates/mds-core/src/lib.rs:1219` | the string-lint entry point passes it to `lint::lint_source`, so it becomes `diag.file` | +| `crates/mds-core/tests/api_surface.rs:1420-1431` | pins the value; its own message says *"changing it requires updating every surface that uses it"* | + +**Changing the constant is the wrong lever.** It would move the WASM default filename and the +cross-surface `sources[0]` parity that `crates/mds-core/tests/source_map_vfs.rs:1126-1135` +exists to enforce, in service of a CLI display change. That is a regression, not an +implementation. + +**2. The relabel-at-the-output-boundary pattern already exists.** +`crates/mds-cli/src/build.rs` `apply_source_map_file_label` maps `STRING_SOURCE_MAP_LABEL` → +`""` for stdin builds — rustdoc at `:973-974` (*"The `` relabel: maps +`STRING_SOURCE_MAP_LABEL` (`"input.mds"`) → `""` for stdin builds"*), applied at +`:992-993`. Its own doc calls it *"a pure label swap — no path logic."* **PR1 extends this +established pattern to the lint JSON and diagnostic paths. It does not invent one.** + +**3. Tests already enforce the sentinel on the source-map surface — a positive control that is +already in the tree.** `crates/mds-cli/tests/cli_source_map.rs:1048-1059` asserts stdin +`sources[]` **must** contain `` and **must never** contain `"input.mds"` or `""` +(AC-FUNC-12), with companions at `:635-648` and `:1193-1196`. These need no edit; they are +existing proof that the sentinel is the repo's convention and that an assertion of this exact +shape can fail when the value is wrong. + +**Approach, therefore: extend the existing boundary relabel to the lint JSON and diagnostic +paths. Do not change `STRING_SOURCE_MAP_LABEL`.** + +**This reconciles the ruling with this PR's own challenge recommendation.** The disagreement +was only ever about the **user-visible label** — now settled as `` by the user. It was +never about the **mechanism**: relabelling at the CLI output boundary while leaving the core +constant intact is what the tree already does for source maps, and it is what makes the ruling +implementable without collateral damage. The challenge agent's recommendation is superseded as +the *decision of record* on the label; its *mechanism* is the shipped one, and it was right. + +**Consequences for this plan:** + +- **AD-211-5 is DECIDED, not open.** The analysis-failure leg emits `` like every + other stdin diagnostic context. Open decision 3 is closed. AC-P1-07 is concrete. Its own + emission site is verified — `emit_analysis_failure_json_or_stderr` + (`crates/mds-cli/src/lint.rs:1421-1436`) — unlike the retracted build/check citation. +- **Scope:** the ruling names the four CLI diagnostic contexts. It names no `crates/mds-core` + site and no binding surface, so **AC-P1-05, AC-P1-06 and AC-P1-24 stand unchanged** — + `STRING_SOURCE_MAP_LABEL` stays `"input.mds"` and stays public, the WASM virtual-FS entry + key at `mds-wasm/src/lib.rs:69` is untouched, and napi/WASM/Python keep reporting + `input.mds` for string-source input. **AC-P1-28 makes the WASM half explicit**, because it + is the precise regression the wrong lever would cause. +- Open decisions **2** (alias-import span anchoring, AC-P1-17) and **4** (`LintResult::new` + sort placement, AD-202-1b) are unrelated to #211 and **remain open**. + +**Recommendations superseded by this ruling — reasoning preserved, labelled not-taken:** + +- The `preference-auto-resolve` agent recommended **Option B, `input.mds` everywhere** + ("most descriptive, least surprising, aligns with lint JSON semantics"). **NOT TAKEN** — + the user's ambiguity rationale is a direct rebuttal of it. +- This PR's challenge agent recommended **"core keeps `input.mds`; the CLI remaps to + `` at every render boundary"** (its own option C). **NOT TAKEN as the + recommendation of record — superseded.** Its evidence is retained below in AD-211-1 as + supporting evidence for the shipped `` sentinel, not as an option under + consideration. + +## 0. Corrections to the issue text (verified at 113f472) + +1. **`crates/mds-cli/src/formatter.rs` does not exist.** Verified — the CLI crate contains only `build.rs`, `fmt.rs`, `lint.rs`, `main.rs`, `output.rs`, `watch.rs`. Render helpers live in `crates/mds-cli/src/output.rs` **(2,156 lines `[CR-fix]`, not 2,271)**. `crates/mds-core/src/formatter.rs` is the *source formatter*, unrelated to diagnostics. **This same bad citation was carried into the #211 ruling text and is formally retracted in §0a — the build/check emission site is unknown and must be located, not guessed (§5 step 1a).** +2. **#203's premise is inaccurate.** Verified: `unused_import.rs` `make_diag` sets `length: "@import".len()` — 7 bytes on the keyword, not the whole line. The AC still holds; the diff is smaller than the issue implies. +3. **`[CR]` There are FIVE stdin conventions, not three or four.** `input.mds` (core), `` (fmt/check/build), `` (resolver), bare `stdin` (`lint.rs:662,667,706`), and — the one everyone missed — **`` reaching the user through the lint ANALYSIS-FAILURE path**. All five user-visible CLI legs collapse to `` under the §0a ruling; see §3 AD-211-5 (**DECIDED**). + +## 1. Approach overview + +All three issues mutate the same JSON object produced by `LintResult::to_canonical_json()` (`crates/mds-core/src/lint/diagnostic.rs:717`). Land in dependency order inside one PR: **#211 first** (it decides the `file` key that #202/#203 are asserted through), **#202 second**, **#203 third**. + +Unifying insight: every surface reads a `LintResult`. Fix ordering **in core**, fix the label **at the CLI output boundary**, and all surfaces stay in parity by construction. + +**The #211 half is an EXTENSION of an existing mechanism, not new machinery (§0a).** `crates/mds-cli/src/build.rs` `apply_source_map_file_label` (rustdoc `:973-974`, applied `:992-993`) already maps `STRING_SOURCE_MAP_LABEL` → `""` at the output boundary for stdin builds, and `crates/mds-cli/tests/cli_source_map.rs:1048-1059` already forbids `input.mds` and `` on that surface. PR1 extends the same pure-label-swap pattern to (a) the lint JSON `files[].file` key, (b) the lint human miette frames (`lint.rs:682/:718/:727`), (c) the bare-`stdin` fix-path messages (`lint.rs:662/:667/:706`), and (d) the analysis-failure envelope (`lint.rs:1421-1436`). **`STRING_SOURCE_MAP_LABEL` is not modified** — it is a shared public constant whose consumers include the WASM `DEFAULT_FILENAME` (`crates/mds-wasm/src/lib.rs:69`), and changing it is the wrong lever (AC-P1-05, AC-P1-28). + +## 2. Verified call graph (re-confirmed @ 113f472) + +| Location | What it does | Status | +|---|---|---| +| `mds-core/src/lint/diagnostic.rs:717` | `to_canonical_json()` — sole wire producer | ✓ | +| `…:721-725` | groups by `diag.file` into a `BTreeMap`; insertion order preserved within a group — this is #202 | ✓ | +| `…:725` | `` fallback key | ✓ | +| `…:756-764` | emits `rule, severity, message, help, fixable, span, fix_edits` | ✓ **`[CR]` the rustdoc schema at :683-704 omits `fix_edits` — pre-existing drift, fix here** | +| `…:774-782` | `file` key, WIRE-sanitized (#176 / CWE-150) | ✓ | +| `…:820` | `LintResultBuilder::push` — `MAX_DIAGNOSTICS` (=1,000, `limits.rs:94`) truncation | ✓ | +| `…:829` | `LintResultBuilder::build` — choke point for #202 | **`[CR-fix]` :829, plan said :828** | +| `…:659` | `LintResult::new` — public ADR-010 constructor, doctest at :649-656 | ✓ | + +### Surfaces +`mds-cli/src/lint.rs:1398` (`emit_result`) → `to_canonical_json`; dir envelope `lint.rs:1029-1037`; `accumulate_result_json` `lint.rs:1439`. Human: `lint.rs:293/310-312` iterates `result.diagnostics` directly. napi `mds-napi/src/lib.rs`. WASM `mds-wasm/src/lib.rs`. Python `mds-python/src/lib.rs` + hand-written mirror at `:559-600` (alphabetical key order, documented byte-identical to `to_canonical_json`). + +### Stdin label chain — `[CR]` amended with the missing fifth leg +| Location | Value today | +|---|---| +| `mds-core/src/sourcemap.rs:79` | `STRING_SOURCE_MAP_LABEL = "input.mds"` | +| `mds-core/src/lib.rs:1212` | `lint_str_with` → `lint_source(source, STRING_SOURCE_MAP_LABEL, …)` — sets `diag.file` for stdin | +| `mds-wasm/src/lib.rs:69` | `DEFAULT_FILENAME = mds::STRING_SOURCE_MAP_LABEL` — **virtual-FS entry key** | +| `mds-cli/src/lint.rs:682,718,727` | human `named_source` name = `input.mds` | +| `mds-cli/src/lint.rs:662,667,706` | bare `stdin` | +| `mds-cli/src/fmt.rs:133,136,144` | `` | +| `mds-cli/src/main.rs:281` | `OK: ` | +| `mds-cli/src/build.rs:964-993` | already remaps `STRING_SOURCE_MAP_LABEL` → `` | +| **`mds-core/src/resolver.rs:199` `[CR]`** | **`SOURCE_LABEL = ""`, set as `ctx.file_str` at :617/:642 in `resolve_source_intrinsic` — which `lint_str_with` calls as its CHECK GATE. Surfaces to the user via `emit_analysis_failure_json_or_stderr` (`lint.rs:1421-1436`). THE MISSING LEG — remapped to `` at that CLI boundary per the §0a ruling (AD-211-5).** | + +## 3. Key design decisions + +Every decision ships as an `AD-###` rustdoc block **at the call site** (hard AC). + +### AD-211-1: USER RULING (§0a) — every CLI diagnostic context emits the single sentinel ``. +**Decided by the user 2026-08-12.** The rationale of record is the ambiguity argument quoted verbatim in §0a: a real file can legitimately be named `input.mds`, so `"file": "input.mds"` cannot tell a JSON consumer stdin from a real file of that name; angle brackets mark a pseudo-source unambiguously; and `` is already what fmt does. + +Supporting evidence independently re-verified at `113f472`, retained because it tells the implementer *where* the sentinel is applied: `input.mds` is a functional VFS key (`mds-wasm/src/lib.rs:69`) and a pinned public constant (`api_surface.rs::string_source_map_label_is_in_public_api`), so it is not rewritten in core; `cli_source_map.rs:1048-1061` already forbids `input.mds` and `` in stdin `sources[]` and requires ``, making the sentinel the established repo convention; `to_canonical_json` already emits the `` sentinel in the same key (`diagnostic.rs:725`), so a `<…>` value is not a novel shape for machine consumers; `build.rs:964-993` already implements exactly this remap for source maps. + +**Rule:** every user-visible CLI emission of stdin's source identity — human diagnostics, JSON `files[].file`, fix-preview status lines, diff headers, source-map `sources[]`, and the analysis-failure envelope — is the single sentinel ``. The remap is applied at the CLI render boundary; `crates/mds-core` continues to carry `input.mds` as an entry key, which the ruling does not name and does not change. **Core never emits ``.** + +> **Superseded framings, retained not-taken:** the `preference-auto-resolve` agent's Option B (`input.mds` everywhere) and this PR's challenge agent's option C (framed as "core keeps `input.mds`; the CLI remaps"). Neither is the decision of record; the user ruling above is. + +### AD-211-2: fix the bare `stdin` fourth convention here. `lint.rs:662,667,706` → ``. + +### AD-211-3: centralize as `pub(crate) const STDIN_DISPLAY_LABEL: &str = "";` in `crates/mds-cli/src/output.rs`. Replaces 5 existing literals + 5 new. A constant, not a new abstraction. +**Includes the one already in `build.rs`:** the existing relabel hardcodes the string literal `""` at `build.rs:993`. Point it at the new constant so the CLI has exactly one definition of the sentinel. This is the only edit `apply_source_map_file_label` needs — its behaviour is already correct and `cli_source_map.rs:1048-1059` already guards it. + +### AD-211-4: reuse `set_diag_display_path` (`lint.rs:231`), do not add a remap path. +One call in `run_lint_stdin` after the `lint_str_with` at `lint.rs:646`. **`[CR]` Safety proof strengthened:** `crates/mds-core/src/lint/fix.rs` contains **zero** reads of `diag.file`, so relabelling upstream of `preview_fixes`/`plan_and_apply_fixes` cannot perturb fix planning. **`[CR]` Scope correction:** the plan claimed this "fixes the JSON `file` key for all three stdin branches" — but `--fix` + `--format json` + stdin is a **hard usage error, exit 2** (`lint.rs:136-146`, AC-F-22b). Only the report-only branch ever emits stdin JSON. The single call is still correctly placed; the claim was overstated, and the Tester must know the fix-preview assertions are human-mode only. + +### `[CR]` AD-211-5: the analysis-failure label — **DECIDED, ``.** +`lint_str_with` runs `resolve_source_intrinsic` as a check gate before linting; that path sets `ctx.file_str = SOURCE_LABEL = ""`. A stdin lint of a source with a compile error therefore renders `:3:1` today, and `` may appear inside `error.message` in the JSON envelope. + +**Ruling (§0a):** this leg emits `` like every other stdin diagnostic context — it is precisely the `` → `` change the user's first bullet names. Open decision 3 is closed. + +**Write it as a rule about the envelope, not about stdin specifically.** `emit_analysis_failure_json_or_stderr` (`lint.rs:1421-1436`) is reached by every `MdsError::Io` config/analysis failure (existing sites `lint.rs:637, :757, :924`), so the `AD-211-5` rustdoc block at the emitting site must state the general rule — *this envelope labels a stdin source as `` and never as `` or `input.mds`* — so any later error travelling the same path inherits it instead of inventing a second convention. + +### AD-202-1: sort in core at the builder choke point. +One private helper called from `LintResultBuilder::build` (`:829`) **and** (pending open decision 4) `LintResult::new` (`:659`). Sorting only inside `to_canonical_json` would leave the CLI human path (`lint.rs:293`) and the Python mirror unsorted — **avoids PF-007**, whose lesson is that per-surface goldens cannot prove parity. + +**Sort key:** `(diag.file, diag.span.map(|s| s.offset))`, `span: None` **last**. `None` verified reachable: `unused_variable.rs:79` builds the span from `fv.approx_offset.map(...)`. Use `sort_by` on **borrowed** fields — no `String` allocation in the comparator (AC-P1-22). + +**Stability is load-bearing:** the **stable** `sort_by` keeps ties in rule-execution order, deterministic because `run_rules` (`lint/mod.rs:118-131`) is a fixed 10-call sequence (verified). + +### `[CR]` AD-202-1b (NEW): `LintResult::new` is a PUBLIC constructor. +Sorting inside it silently reorders an external caller's deliberately-ordered vec. That is a semantic change to a published ADR-010 construction path and belongs in the CHANGELOG BREAKING entry — or the sort lives only in `build()`. **Open decision 4.** Note the doctest at `:649-656` uses an empty vec and passes either way, so `cargo test --doc` will not catch the difference. + +### AD-202-2: sorting happens AFTER truncation, deliberately. +`push` enforces the 1,000 cap in rule-execution order; `build()` reorders the retained set but never changes *which* are retained. A reader will assume "sorted by offset" implies "the first N by offset" — it does not, and cannot without buffering past the cap. Document at the call site; pin with AC-P1-12. + +### AD-202-3: the fix pipeline is order-independent — verified. +`plan_fixes_with_options` iterates `lint_result.diagnostics` at `fix.rs:338` but re-sorts its own edits at `fix.rs:361` by `(start ASC, end DESC)` before `dedup_contained_or_identical` (`:367`) and `has_overlapping_edits` (`:372`). **`[CR]` Plus: `fix.rs` never reads `diag.file` at all.** Residual: among edits with identical `(start,end)` the stable sort retains input order and dedup keeps the first — reachable only if two rules emit identical ranges with different `new_text`, and only `legacy_interpolation` emits non-empty `new_text`. Pin with T-202-3 rather than asserting it. + +### AD-203-1: per-name offsets come from the parser, not a source re-scan. +Rejected: re-scanning from `imp.offset` breaks on prefix names, on a name occurring in the path, and manufactures the PF-012 failure class (in-bounds, wrong token, plausible caret). Chosen: compute in `parse_import_directive` (`parser_helpers.rs:807-840`), which already receives `(directive, offset)`. + +**Soundness (verified end to end):** `scan_directive` (`lexer.rs:205-219`) has precondition `is_line_start() && chars[pos]=='@'`, emits `Token::Directive(line, byte_pos(pos))` with only a trailing `\r` stripped. `parse_directive` (`parser.rs:346`) does `dir.trim()`; because the token always begins with `@`, the leading half is a no-op. Therefore `trimmed` byte 0 **is** source byte `offset`. + +**`[CR-fix]` The delta formula in the original plan was wrong.** `parser_helpers.rs:808` is `directive.trim_start_matches("@import").trim()` — a **both-ends** trim. `directive.len() - after_kw.len()` over-counts by any TRAILING whitespace, shifting every name offset right. Use **`trim_start` only**: `directive.len() - directive.trim_start_matches("@import").trim_start().len()`. Reproducer: `@import { a } from "./l.mds" `. + +**`[CR]` The stated MOTIVATION was also wrong** (keep the computed delta, fix the reason): `is_directive_token` (`parser_helpers.rs:1459-1463`) admits `@import` only when followed by ` `, `\t`, `{`, or EOL — so `@import@import` never reaches the parser and the repeat-strip path is unreachable. Record the real reason (trailing-whitespace correctness) or a future reader will "simplify" it back to `7`. + +**`[CR]` GUARANTEED INDEX DESYNC — the largest correctness gap.** `parser_helpers.rs:816-821` builds `names` as `split(',').map(trim).filter(non-empty).collect()`. The filter runs AFTER the split, so a naive per-segment offset vector desyncs for `@import { a, , b }` (3 segments, 2 names) and `@import { a, b, }` (3 segments, 2 names) — both parse fine today. Under desync, indices SHIFT and `b` silently anchors at `a`'s offset: in-bounds, plausible, wrong — exactly PF-012, and AD-203-3's fallback does not save it. **The offset vector MUST be produced by the same filter+trim pipeline in a single pass** (push name and offset together, or build `Vec<(String, usize)>` internally and unzip). Pinned by AC-P1-15. + +**`[CR]` Bound on the UTF-8 risk:** `is_valid_identifier` (`parser_helpers.rs:1470-1474`) is ASCII-only and `parse_import_directive:824-828` rejects anything else, so names are always ASCII and `name.len()` is a safe span length. Multi-byte content can only shift the BASE offset — T-203-5 tests the base-offset chain, not the name span. + +### AD-203-2: the AST change is not a public API change. +`crates/mds-core/src/lib.rs:43` declares `pub(crate) mod ast;`, so `ImportDirective` is crate-private and **ADR-010 does not govern it**. **`[CR]` Same for `ImportFact`:** although declared `pub struct`, `crates/mds-core/src/lint/mod.rs:30` declares `pub(crate) mod facts;`, so it is crate-private too — the plan asserted ADR-010 non-applicability without checking this. + +**`[CR-fix]` Consumer list was incomplete.** The plan listed six sites and claimed completeness. It missed **`crates/mds-core/src/lint/rules/structural_eq.rs:175` and `:180`**. Full verified list: `parser_helpers.rs:836` (sole construction site), `resolver.rs:1923`, `resolver/inheritance.rs:57`, `lib.rs:1373`, `parser_tests.rs:89`, `facts.rs:430`, **`structural_eq.rs:175,180`**. All but the construction site use `..`, so the addition is contained — but structural equality returns `n1 == n2 && p1 == p2` and deliberately IGNORES offsets (mirroring the `end_offset` doc at `ast.rs:338-341`). `name_offsets` MUST stay excluded or `duplicate-import` changes behavior. Pinned by AC-P1-18. + +Carry `name_offsets: Vec` index-aligned with `names`, on `ImportDirective::Selective` and `ImportFact`, threaded through `collect_import_fact` (`facts.rs:430-441`). Rejected `Vec` / `Vec<(String,usize)>` as the public shape: cleaner in principle, but it touches every `names` consumer. Document the index-alignment invariant on both fields. + +### AD-203-3: degrade gracefully on desync, never mis-anchor. +`imp.name_offsets.get(i).copied().unwrap_or(imp.offset)` + `debug_assert_eq!` on lengths. Per **PF-005** the `debug_assert` is dev feedback only — the unconditional fallback is the real guard. **`[CR]` This guard is necessary but NOT sufficient for the empty-segment case above, where indices shift rather than run short.** + +### AD-203-4: only the Selective branch changes (pending open decision 2 on aliases). +`make_diag` gains a `length` parameter. **`[CR]`** Its rustdoc currently reads "The span always covers the `@import` keyword (length = 7), so `offset` is the only caller-supplied span parameter" — #203 falsifies both halves. Rewrite to the end-state; no "used to be 7" tombstone (project rule: leave the end-state, not the transition). + +### `[CR]` AD-203-5 (NEW, risk retired with evidence): no `@extends` coordinate-space hazard. +I checked whether inheritance could feed the linter a MERGED module, putting parent-file offsets in a child-file diagnostic — a textbook PF-012 mis-anchor that narrowing the span would worsen. **It cannot.** `lint_source` (`lint/mod.rs:69-81`) re-parses the entry source independently (`lexer::tokenize` → `parse_with_ctx`) and never consults the resolver's merged module. Every offset the linter sees is in the entry source's own coordinate space. Recorded so nobody re-litigates it. + +## 4. Affected files + +**Core (WASM-reachable):** `lint/diagnostic.rs` (sort helper; `build` :829, `new` :659; AD-202-1/1b/2 rustdoc; **`[CR]` fix the `fix_edits`-missing schema rustdoc at :683-704**) · `lint/rules/unused_import.rs` (Selective span, `make_diag` signature + rustdoc; AD-203-4) · `lint/facts.rs` (`ImportFact.name_offsets`; `collect_import_fact` :430-441) · `ast.rs` (`Selective.name_offsets`; AD-203-2) · `parser_helpers.rs` (offset arithmetic :807-840; AD-203-1 soundness rustdoc) + +**CLI:** `output.rs` (`STDIN_DISPLAY_LABEL`; AD-211-1/3) · `lint.rs` (`set_diag_display_path` call; labels :662,:667,:682,:706,:718,:727; AD-211-4 rustdoc on :231; **`[CR]` AD-211-5 at :1421**) · `fmt.rs` :133,:136,:144 · `main.rs` :281 · `build.rs` :993 + +**Tests / docs:** `cli_lint.rs` `stdin_lint_diagnostic_includes_code_frame` :1266-1280 (flip `input.mds` → ``) · `unused_import.rs` unit tests · **`[CR]` canaries that must stay green UNEDITED: `api_surface.rs::string_source_map_label_is_in_public_api`, `cli_source_map.rs:1048-1061`, `mds-python/tests/test_lint.py:161`, `test_parity.py:150,258`** (the last two deliberately assert `input.mds` on binding surfaces — Option C preserves them) · `packages/mds/__test__/lint.spec.mjs` (ordering parity) · `CHANGELOG.md` BREAKING · `README.md` if it documents the lint JSON `file` key · **`[CR]` `structural_eq.rs` — verify-only, no edit expected** + +## 5. Implementation sequence + +0. **Baseline WASM raw bytes** — `npm run build -w @mdscript/mds-wasm`. Budget 850,000 (`ci.yml`); wave/v0.4.0-wave1 CI baseline 821,662 (~3.3% headroom; local build measured 820,305 at the same commit — CI uses Binaryen v129, local uses an older toolchain). **`[CR]` Run in the PRIMARY CHECKOUT — `pkg/` is generated and gitignored, so an isolated worktree has nothing to measure and the check passes vacuously (PF-016).** +1. **#211**: `STDIN_DISPLAY_LABEL`; swap 5 literals **including the hardcoded `""` at `build.rs:993`**; `set_diag_display_path` in `run_lint_stdin`; fix :662/:667/:706; repoint :682/:718/:727; **apply the AD-211-5 ruling** (`` → `` at `emit_analysis_failure_json_or_stderr`, written as an envelope-wide rule). Update `cli_lint.rs:1266-1280`. **Do NOT edit `crates/mds-core/src/sourcemap.rs:79`.** Gate. + + **1a. LOCATE the live build/check `` emission site — an explicit task, not an assumption.** The ruling's `crates/mds-cli/src/formatter.rs:120` citation is **retracted** (§0a); that file does not exist and no replacement line number has been established. **Do not guess one.** Before writing any code for this leg: + - Reproduce it: run `mds check -` and `mds build -` on a stdin source that fails resolution, and capture the exact rendered output that contains ``. + - Trace it from the reproduction back to the boundary where the CLI renders it. Verified starting point, and *only* a starting point: `crates/mds-core/src/resolver.rs:199` `const SOURCE_LABEL: &str = ""` (crate-private), assigned as `ctx.file_str` at `:508`, `:617`, `:642`, and locked by `crates/mds-core/src/resolver_tests.rs:2917-2920`. + - Record the located site, with its verified line number, in the PR body and in the `AD-211-5` rustdoc block. If the reproduction shows build/check never surfaces `` for stdin, say so explicitly and scope the leg out in writing — an unreproducible leg must not be "fixed" speculatively. + - Apply the relabel at that boundary using the same pure-label-swap shape as `apply_source_map_file_label`. **`SOURCE_LABEL` itself must not change** — `resolver_tests.rs:2917-2920` locks it, and it is `ctx.file_str` for non-stdin paths too. +2. **#202**: sort helper + call sites. Gate — this is where a hidden order-dependence surfaces across 590+ tests. +3. **#203**: parser offsets → `ast` → `facts` → rule. Gate. **`[CR]` Build the #202 fixture from rules #203 does NOT touch (`duplicate-export`, `unused-variable`, `legacy-interpolation`) so step 3 does not invalidate step 2's expected offsets.** +4. **Cross-surface differential** (PF-007) — **`[CR]` compare `files[].diagnostics[]` with the `file` key EXCLUDED.** Full byte-identity including `file` is FALSE by construction under Option C (CLI `` vs bindings `input.mds`), as `test_parity.py:150` already documents. Assert the `file` key separately per surface. +5. **Re-measure WASM**, then the full §8 pipeline. + +## 6. Test plan + +See the structured `testPlan` (AC-P1-01 … AC-P1-28). Local IDs use a non-`#` prefix (**PF-010**). New coverage added by this review: the empty-segment/trailing-comma desync (AC-P1-15), trailing directive whitespace (AC-P1-16d), the escaping positive control (AC-P1-20, PF-013/ADR-009), `files[]` array ordering (AC-P1-10), truncation-vs-sort semantics (AC-P1-12), `structural_eq` neutrality (AC-P1-18), the corrected parity scope (AC-P1-24), and an explicit performance bound (AC-P1-22). Added by the 2026-08-12 #211 ruling: the concrete analysis-failure label (AC-P1-07, formerly blocked) and the ruling-pinned wire-key pair (AC-P1-26 positive / AC-P1-27 negative). Added by the citation/architecture correction: **AC-P1-28**, pinning that `STRING_SOURCE_MAP_LABEL` and the WASM `DEFAULT_FILENAME` are untouched — the regression that changing the constant instead of extending the output-boundary relabel would cause. + +## 7. Risks and mitigations + +| # | Risk | Mitigation | +|---|---|---| +| R1 | **WASM budget** — 3.5% headroom, PR touches WASM-reachable core | #202 reuses stable-sort machinery already linked via `fix.rs:361` (no new monomorphization family). #203 adds a `Vec` per selective import. Measure at step 0 and 5, both numbers in the PR body. **Do not raise the guard** — if it trips, shrink the change. | +| R2 | **Breaking wire change** to `files[].file` for stdin | Intended and free: v0.4.0 merged on main but **not tagged**. CHANGELOG `[Unreleased]` BREAKING with a before/after snippet. | +| R3 | Sorting masks a rule-ordering regression | AC-P1-11 (repeat-determinism) + AC-P1-13 (`--fix` byte-identical). | +| R4 | AST field ripples into the resolver | 8 consumers verified (`[CR]` +`structural_eq.rs:175,180`); only `parser_helpers.rs:836` constructs. `clippy --all-targets -D warnings` catches any missed site at compile time. | +| R5 | Python mirror drifts from `to_canonical_json` | Sorting at the builder means the mirror inherits order from the already-sorted `LintResult` — no mirror edit. AC-P1-24 proves it. | +| R6 | Offset arithmetic wrong but in-bounds (**PF-012**) | AC-P1-14 is a positive control: slice the source at the span and compare to the expected name. **`[CR]` AC-P1-15 extends it to the desync case the fallback cannot catch.** | +| **R7 `[CR]`** | **Fifth stdin convention (``) contradicts the shipped rule** | **RETIRED — ruled 2026-08-12 (§0a).** The leg emits ``; AD-211-5 ships the rule as an envelope-wide rustdoc block and AC-P1-07 pins it. | +| **R8 `[CR]`** | **T-PAR-1 as originally written cannot pass** | Corrected in step 4 / AC-P1-24. | +| **R9 `[CR]`** | **`LintResult::new` sort is an unflagged public-API semantic change** | Open decision 4; if kept, name it in the CHANGELOG BREAKING entry. | +| R10 | `mds lint` exits 2 on `examples/` by design | Not a regression. Do not "fix". | +| R11 | `cargo test --workspace` stalls ~20 min locally | `cargo nextest` + repo-local `.cargo/config.toml` (`rustc-wrapper=""`, `jobs=2`). **Never commit it.** nextest skips doctests — `cargo test --doc` is mandatory. | + +**ADR-008 check:** `files[].file` is identifier-shaped and stays WIRE-escaped on every surface. `` contains no character in the hostile class, so the sentinel passes through unchanged. **`[CR]` That is exactly why the sentinel cannot serve as the escaping test — AC-P1-20 supplies a real positive control via a control-character PATH in directory mode. Per PF-018, construct that byte programmatically at runtime; never type a literal `\u` escape into tracked test source.** + +## 8. Verification (all must pass before merge) + +```bash +cargo nextest run --workspace && cargo test --doc # --doc is mandatory, nextest skips it +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings # zero warnings is a hard stop +npm ci && npm run build -w @mdscript/mds-wasm && npm run build --workspaces --if-present +npm test --workspaces --if-present +node scripts/verify-versions.mjs +. .venv/bin/activate && maturin develop -m crates/mds-python/Cargo.toml && pytest crates/mds-python/tests -q +``` +Plus: WASM raw byte count before and after, pasted into the PR body, measured in the primary checkout. + +## 9. Plan self-review + +- **Gaps closed in the original plan:** `formatter.rs` does not exist; #203's "entire line" premise is wrong; `span: None` is reachable; the AST *and* `facts` are `pub(crate)` so ADR-010 does not apply; the fix planner re-sorts and never reads `diag.file`. +- **`[CR]` Gaps closed in THIS review:** a fifth stdin convention (`` via the check gate) that the proposed rule contradicts; T-PAR-1 being unpassable as specified; a guaranteed index desync from post-split filtering; a both-ends-trim delta bug; a wrong justification for computing that delta; two missing `Selective` consumers in a claimed-complete list; `LintResult::new` sorting as an unflagged public-API change; the `fix_edits`-missing wire schema rustdoc; stale `make_diag` rustdoc; missing `files[]`-ordering, truncation-semantics, performance, and escaping-positive-control criteria; PF-016 exposure in the WASM measurement step. +- **`[CR]` Risks retired with evidence:** `@extends` cannot inject foreign-coordinate offsets (`lint_source` re-parses independently); import names are ASCII-only so `name.len()` is always a safe span length; the `trim_start_matches` repeat path is unreachable behind `is_directive_token`. +- **Deliberately in scope, not deferred:** the bare-`stdin` literals (AD-211-2) and the scattered `` literals (AD-211-3). +- **Surfaced as decisions rather than silently dropped:** alias-import span anchoring (**still open**, open decision 2 / AC-P1-17); the `` analysis-failure leg (**RULED 2026-08-12 → ``**, §0a / AD-211-5); `LintResult::new` sort placement (**still open**, open decision 4 / AD-202-1b). +- **No new modules.** One constant, one private sort helper, one widened existing helper. + +## Improvements and Gaps Identified + +- VERIFIED CORRECT (no action): I re-checked the plan's load-bearing citations at 113f472 and they hold — `crates/mds-cli/src/formatter.rs` genuinely does not exist (CLI has build/fmt/lint/main/output/watch only); `unused_import.rs` `make_diag` really does set `length: "@import".len()`, so #203's "entire line" premise is indeed wrong; bare `stdin` really is emitted at `crates/mds-cli/src/lint.rs:662,667,706`; `to_canonical_json` is at `crates/mds-core/src/lint/diagnostic.rs:717` with the `` fallback at :725; `LintResult::new` is at :659; `LintResultBuilder::push` at :820; `set_diag_display_path` at `crates/mds-cli/src/lint.rs:231`; `STRING_SOURCE_MAP_LABEL` at `crates/mds-core/src/sourcemap.rs:79`; `DEFAULT_FILENAME` at `crates/mds-wasm/src/lib.rs:69`; the `build.rs` remap at :964-993; `unused_variable.rs:79` `span: fv.approx_offset.map(...)` proving `span: None` is reachable; `run_rules` at `crates/mds-core/src/lint/mod.rs:118-131` as a fixed 10-call sequence; and all three cited tests (`cli_lint.rs:1266-1280`, `api_surface.rs:~1426`, `cli_source_map.rs:1048-1061`). +- CITATION DRIFT (minor, fix in plan text): `crates/mds-cli/src/output.rs` is 2,156 lines, not 2,271. `LintResultBuilder::build` is at `crates/mds-core/src/lint/diagnostic.rs:829`, not :828. Neither changes the approach, but a plan that cites a number a reviewer cannot confirm burns trust on the numbers that do matter. +- BLOCKER — T-PAR-1 AS WRITTEN CANNOT PASS. The plan's cross-surface differential asserts napi/WASM/Python/CLI JSON produce "byte-identical `files[]`". Under Option C that is false BY CONSTRUCTION: the CLI stdin path emits `file: ""` while every binding emits `file: "input.mds"`. This is already documented in the repo — `crates/mds-python/tests/test_parity.py:150` says surfaces "differ in their file key (\"input.mds\" vs basename) when findings are present", and `test_lint.py:161` repeats it. The differential must compare `files[].diagnostics[]` (ordering, spans, rule, severity, fixable) with the `file` key EXCLUDED, and separately assert the `file` key equals the per-surface expected sentinel. PF-007 is satisfied by comparing surfaces to each other on the fields that are supposed to match — not by pretending a deliberately divergent field matches. +- ~~BLOCKER~~ **RESOLVED 2026-08-12 (§0a): the fifth convention collapses to ``.** The finding below stands as verified analysis and is what forced the ruling; it is no longer blocking, and AD-211-5 / AC-P1-07 now carry a concrete answer instead of a placeholder. Original text: A FIFTH STDIN CONVENTION EXISTS AND THE PLAN'S RULE CONTRADICTS IT. `crates/mds-core/src/resolver.rs:199` defines `const SOURCE_LABEL: &str = ""`, used as `ctx.file_str` in `resolve_source_intrinsic` (:615-624, :640-649). `lint_str_with` (`crates/mds-core/src/lib.rs:1212`) calls `resolve_source_intrinsic` as its check gate BEFORE linting. So `mds lint -` on a source with a compile error takes the `emit_analysis_failure_json_or_stderr` path (`crates/mds-cli/src/lint.rs:1421-1436`) and renders `:3:1` on stderr via miette — and `` can appear inside `error.message` in the JSON envelope for errors that name the file (e.g. circular import). The plan's proposed rule — "every user-visible emission of stdin's source identity uses the single sentinel ``" — is violated by this path on day one. Issue #211 explicitly names `` and cites `resolver.rs:199`; the plan's stdin-label chain table omits it entirely. This must be handled or explicitly scoped out with a written reason (see openDecisions). +- BLOCKER — GUARANTEED INDEX DESYNC IN THE #203 OFFSET VECTOR. `parse_import_directive` (`crates/mds-core/src/parser_helpers.rs:816-821`) builds `names` as `names_str.split(',').map(trim).filter(|n| !n.is_empty()).collect()`. The `filter` runs AFTER the split. A naive per-segment offset vector therefore desyncs from `names` for `@import { a, , b } from "./l.mds"` (3 segments, 2 names) and for the trailing-comma form `@import { a, b, } from "./l.mds"` (3 segments, 2 names). Both parse successfully today. Under desync, AD-203-3's `unwrap_or(imp.offset)` fallback does NOT save you — indices SHIFT, so name `b` silently anchors at name `a`'s offset. That is in-bounds, plausible, and wrong: precisely PF-012. The offset vector must be produced by the SAME filter+trim pipeline in a single pass (push name and offset together, or build `Vec<(String, usize)>` internally and unzip). Needs a dedicated AC and two tests. +- CORRECTNESS — THE PLAN'S DELTA FORMULA IS SUBTLY WRONG. The plan says compute `directive.len() - after_kw.len()`. But `crates/mds-core/src/parser_helpers.rs:808` is `let rest = directive.trim_start_matches("@import").trim();` — a BOTH-ENDS trim. Subtracting the both-ends-trimmed length over-counts the start delta by the length of any TRAILING whitespace on the directive line, which silently shifts every name offset right. The start delta must be computed with `trim_start` only: `directive.len() - directive.trim_start_matches("@import").trim_start().len()`. A source line with trailing spaces (`@import { a } from "./l.mds" `) is the trivial reproducer, and it would pass any assertion that only checks in-bounds-ness. +- The plan's stated MOTIVATION for computing the delta is wrong even though the conclusion is right. It claims `trim_start_matches("@import")` strips repeated occurrences, so `7` is unsafe. But `is_directive_token` (`crates/mds-core/src/parser_helpers.rs:1459-1463`) only admits `@import` followed by ` `, `\t`, `{`, or end-of-string — `@import@import ...` never reaches `parse_import_directive`. The repeat-strip path is unreachable. Keep the computed delta (it is the right defensive choice and it is what makes the trailing-whitespace bug above visible), but fix the justification so a future reader does not "simplify" it back to `7` on discovering the stated reason is bogus. +- INCOMPLETE CONSUMER ENUMERATION. The plan claims it "verified every consumer" of `ImportDirective::Selective` and lists six sites. It missed `crates/mds-core/src/lint/rules/structural_eq.rs:175` and :180, which destructure `Selective { names, path, .. }` in both halves of a comparison. They use `..`, so the addition compiles — but the substantive point is that structural equality returns `n1 == n2 && p1 == p2` and deliberately IGNORES offsets (mirroring the `end_offset` doc at `crates/mds-core/src/ast.rs:338-341`, "intentionally excluded from structural equality"). `name_offsets` MUST stay excluded or `duplicate-import` detection changes behavior. This needs an explicit regression pin, not just a compile check. +- RISK RETIRED WITH EVIDENCE (strengthens the plan, add it to §7): I checked whether `@extends` inheritance could feed the linter a MERGED module, which would put parent-file offsets into a child-file diagnostic — a textbook PF-012 mis-anchor that narrowing the span from 7 bytes to a name would make worse. It cannot. `lint_source` (`crates/mds-core/src/lint/mod.rs:69-81`) re-parses the entry source independently (`lexer::tokenize(source, filename)` then `parse_with_ctx`) and never consults the resolver's merged module. Every offset the linter sees is in the entry source's own coordinate space. State this in the plan so nobody re-litigates it. +- RISK RETIRED WITH EVIDENCE: the plan asserts the fix pipeline is order-independent but only argues it from the re-sort at `fix.rs:361`. Stronger evidence: `crates/mds-core/src/lint/fix.rs` contains ZERO reads of `diag.file` anywhere in the file. So `set_diag_display_path` mutating `diag.file` to `` BEFORE `preview_fixes`/`plan_and_apply_fixes` cannot perturb fix planning at all. This matters because AD-211-4 places the relabel early in `run_lint_stdin`, upstream of both fix paths. +- SCOPE FACT THE PLAN MISSTATES: `--fix` + `--format json` + stdin is a HARD USAGE ERROR that exits 2 (`crates/mds-cli/src/lint.rs:136-146`, AC-F-22b, flagged there as a deliberate exception). AD-211-4 says the single `set_diag_display_path` call "fixes the JSON `file` key for all three stdin branches" — but two of those three branches can never emit JSON. The call is still correctly placed; the claim is just overstated. More importantly the Tester must know this: T-211-3 as written (`mds lint --fix --check -`) is HUMAN-mode only, and anyone who adds `--format json` to it gets exit 2 and a plain stderr message, not a status line. +- STALE-DOC RESIDUE (project rule: "leave the end-state, not the transition"). `crates/mds-core/src/lint/rules/unused_import.rs` documents `make_diag` as: "The span always covers the `@import` keyword (length = 7), so `offset` is the only caller-supplied span parameter." #203 falsifies both halves. `make_diag` needs a `length` parameter and the rustdoc needs rewriting to the new end-state — not a "used to be 7" tombstone. +- PRE-EXISTING DOC DRIFT IN THE EXACT BLOCK THIS PR EDITS. The `to_canonical_json` schema rustdoc (`crates/mds-core/src/lint/diagnostic.rs:683-704`) documents keys `rule, severity, message, help, fixable, span` — but the code at :756-764 also emits `fix_edits`. This PR is the one that declares the lint JSON a pinned wire contract; shipping a contract whose canonical rustdoc omits an emitted key is a defect. Fix it here. +- SORTING IN `LintResult::new` IS A PUBLIC API BEHAVIOR CHANGE THE PLAN DOES NOT FLAG AS BREAKING. `LintResult::new` (`crates/mds-core/src/lint/diagnostic.rs:659`) is the ADR-010 supported construction path for external crates, with a doctest at :649-656. Sorting inside it means a caller who passes a deliberately-ordered `Vec` silently gets it reordered. That is a semantic change to a published constructor and belongs in the CHANGELOG BREAKING entry alongside the wire change — or the sort should live only in `build()`. See openDecisions. +- WIRE-CONTRACT COVERAGE GAP: the plan's AC pins ordering WITHIN a file but never pins the `files[]` array ordering. For a published contract both must be stated. The good news is the behavior is already deterministic and should simply be frozen: directory mode explicitly path-sorts at `crates/mds-cli/src/lint.rs:994-995` ("F1: path-sort explicitly — collect_mds_files does NOT guarantee order"), and single-file/stdin emit one entry. Pin it. +- NO PERFORMANCE THRESHOLD IS STATED ANYWHERE IN THE PLAN. State one and make it trivially satisfiable so it is a real gate rather than theater: `MAX_DIAGNOSTICS = 1_000` (`crates/mds-core/src/limits.rs:94`), so the added sort is bounded at n ≤ 1000 per `LintResult`, O(n log n), executed once per lint. Also pin that the sort key borrows (`&Option` / `&str`) and performs zero `String` allocations, since the plan already commits to that but never makes it checkable. +- PF-013 / ADR-009 POSITIVE CONTROL IS MISSING. This PR rewrites `diag.file` (`set_diag_display_path`) and touches the WIRE sanitization boundary for that exact key (`crates/mds-core/src/lint/diagnostic.rs:774-782`, issue #176 / CWE-150). A test that merely asserts `` appears proves nothing about whether escaping still works. Add a positive control: in DIRECTORY mode, lint a file whose path contains a control character and assert the emitted `files[].file` carries the sanitized `\u00XX` literal and NOT the raw byte — with a companion assertion that the same test detects the raw byte when sanitization is bypassed. Note also that `` itself contains no character in the hostile class, so the sentinel is a no-op for the escape contract — that is the plan's ADR-008 claim and it is correct, but it is exactly why it cannot serve as the control. +- PF-016 APPLIES TO STEP 0/5 OF THE SEQUENCE. The WASM size baseline and re-measure depend on generated, gitignored build output (`pkg/`). If any agent runs that measurement from an isolated worktree the artifact is absent and the check passes on nothing. The measurement must run in the primary checkout, and the AC must require the two raw byte counts be pasted into the PR body, not merely asserted as "passed". +- SEQUENCING IMPROVEMENT: the plan lands #211 → #202 → #203, which is right, but #203 changes span offsets that #202's ordering fixture reads, so the T-202-1 fixture will need its expected offsets updated in step 3. Either (a) build the #202 fixture from rules that #203 does not touch (`duplicate-export`, `unused-variable`, `legacy-interpolation`), or (b) assert non-decreasing order rather than literal offsets. Option (a) is preferable — it keeps T-202-1 a pure ordering test with no coupling to #203. +- ADD A MISSING-FILE CANARY: `crates/mds-cli/tests/cli_source_map.rs:1048-1061` already forbids `input.mds` AND `` in stdin sidecar `sources[]` and requires ``. It needs no edit, but it is the strongest existing proof that Option C's sentinel is the established repo convention, and it should be listed as a must-stay-green canary alongside `api_surface.rs`. Conversely `crates/mds-python/tests/test_lint.py:161` and `test_parity.py:150,258` document `input.mds` as the binding-side key — under Option C those assertions must stay UNCHANGED, and the plan listing those files as "touched for ordering parity" should say explicitly that their `input.mds` expectations are deliberately preserved. +- ASCII-ONLY IDENTIFIERS BOUND THE UTF-8 RISK (tightens T-203-5). `is_valid_identifier` (`crates/mds-core/src/parser_helpers.rs:1470-1474`) requires ASCII letter/underscore start and ASCII alphanumeric/underscore body, and `parse_import_directive:824-828` rejects anything else. So an import name is always ASCII and `name.len()` is always a safe span length. Multi-byte content can therefore only shift the BASE offset, never split a name. T-203-5 is still required, but it is testing the base-offset chain, not the name span — say so, or the test gets written against the wrong hypothesis. + +## Acceptance Criteria + +1. AC-P1-01 (stdin label, CLI JSON): The system MUST emit `files[0].file == ""` when `mds lint - --format json` is run on a source that produces at least one diagnostic, and the string `input.mds` MUST NOT appear anywhere in stdout. +2. AC-P1-02 (stdin label, CLI human): The system MUST render `` as the file reference in the miette code frame for `mds lint -` in human mode, and MUST NOT render `input.mds`. +3. AC-P1-03 (stdin label, fix preview): The system MUST print `Would fix: ` for `mds lint --fix --check -` and MUST emit `` (not bare `stdin`) in the unified-diff header for `mds lint --fix --diff -`. The system MUST print `Partially fixed: (N of M fixes applied)` on the partial-fix path. No bare, unbracketed `stdin` token may remain as a source identity in any of these three messages. +4. AC-P1-04 (cross-subcommand consistency): For stdin input, `mds lint`, `mds fmt`, `mds check`, and `mds build --source-map` MUST all emit the identical sentinel `` as the source identity in their user-visible output (diagnostics, status lines, diff headers, and source-map `sources[]`). +5. AC-P1-05 (library contract preserved — NEGATIVE): The system MUST NOT change `mds::STRING_SOURCE_MAP_LABEL`; it MUST remain exactly `"input.mds"` and remain publicly reachable. `crates/mds-core/tests/api_surface.rs::string_source_map_label_is_in_public_api` MUST pass unmodified. The strings `` and `` MUST NOT be emitted as a `diag.file` value by any function in `crates/mds-core` — the remap is a CLI-boundary concern only. +6. AC-P1-06 (binding surfaces unchanged — NEGATIVE): The napi, WASM, and Python direct lint APIs MUST continue to report `input.mds` as the `file` key for string-source input. `crates/mds-python/tests/test_lint.py` and `test_parity.py` MUST pass with their existing `input.mds` expectations unedited. +7. AC-P1-07 (analysis-failure label — CONCRETE, per the 2026-08-12 ruling): For `mds lint -` on a source that fails the check gate, the source identity rendered on stderr MUST be exactly ``, and any source identity embedded in `error.message` under `--format json` MUST likewise be exactly ``. The strings `` and `input.mds` MUST NOT appear as the stdin source identity on either channel. This rule MUST be written as an `AD-211-5` rustdoc block at `emit_analysis_failure_json_or_stderr` (`crates/mds-cli/src/lint.rs:1421-1436`), phrased as a rule about that envelope generally rather than about stdin specifically, so every `MdsError::Io` failure reaching it (existing sites `lint.rs:637, :757, :924`) inherits the same label. +8. AC-P1-08 (ordering, within file): For any single file, `files[].diagnostics[]` MUST be ordered by non-decreasing `span.offset`. Diagnostics with `span == null` MUST sort last. Two diagnostics with an identical `span.offset` MUST retain the fixed rule-execution order defined by `run_rules` (`crates/mds-core/src/lint/mod.rs:118-131`). +9. AC-P1-09 (ordering, cross-surface): The diagnostic ordering in AC-P1-08 MUST hold identically on the CLI human path, CLI JSON path, napi, WASM, and Python surfaces, because it is established on `LintResult.diagnostics` itself and not per-renderer. +10. AC-P1-10 (ordering, files array): `files[]` MUST be ordered by ascending path for directory-mode runs and MUST contain exactly one entry for single-file and stdin runs. +11. AC-P1-11 (determinism): Linting identical input N times MUST produce byte-identical `--format json` stdout across all N runs (N ≥ 5). +12. AC-P1-12 (truncation semantics — NEGATIVE): Sorting MUST NOT change WHICH diagnostics are retained when `MAX_DIAGNOSTICS` (1,000) is reached. The retained set MUST remain the first 1,000 in rule-execution order; sorting reorders only that retained set. `truncated` MUST remain `true`. The system MUST NOT be documented or tested as returning "the first 1,000 by offset". +13. AC-P1-13 (fix pipeline unaffected — NEGATIVE): Diagnostic ordering MUST NOT alter `--fix` output. For every fixture in the existing fix test corpus, the fixed source bytes and the residual diagnostic set MUST be identical before and after the ordering change. +14. AC-P1-14 (span anchoring, positive control): For a selective import with unused names, each `unused-import` diagnostic's span MUST satisfy `source[span.offset .. span.offset + span.length] == ` exactly. Asserting only that the offset changed, or that it is in bounds, does not satisfy this criterion. +15. AC-P1-15 (span anchoring, index integrity): AC-P1-14 MUST hold for inputs where the comma-split segment count differs from the parsed name count — specifically `@import { a, , b } from "./l.mds"` and the trailing-comma form `@import { a, b, } from "./l.mds"`. A name MUST NOT anchor at another name's offset under any input. +16. AC-P1-16 (span anchoring, robustness): AC-P1-14 MUST hold for irregular interior whitespace, a name that is a strict prefix of another name in the same import, a name that also occurs as a substring of the import path, a directive line with trailing whitespace, CRLF line endings, and multi-byte UTF-8 content preceding the import. +17. AC-P1-17 (span anchoring, scope — NEGATIVE): Alias-import and merge-import diagnostic spans MUST NOT change unless openDecisions #2 rules otherwise. Whatever is ruled, the shipped behavior MUST match the CHANGELOG BREAKING entry. +18. AC-P1-18 (structural equality unaffected — NEGATIVE): Adding per-name offsets MUST NOT participate in structural equality. `duplicate-import` detection results MUST be unchanged for every existing fixture, including imports that differ only in interior whitespace. +19. AC-P1-19 (graceful degradation — NEGATIVE): If the per-name offset vector and the name vector ever differ in length, the diagnostic MUST fall back to the `@import` keyword offset. The system MUST NOT panic, MUST NOT index out of bounds, and MUST NOT emit a span that slices to a value other than the reported name. +20. AC-P1-20 (escaping contract preserved, with positive control): The WIRE sanitization of `files[].file` MUST remain in force after the display-path rewrite. A directory-mode lint of a file whose path contains a control character MUST emit the sanitized `\u00XX` literal and MUST NOT emit the raw byte — and the test MUST demonstrate it detects the raw byte when sanitization is absent (PF-013 / ADR-009). +21. AC-P1-21 (wire schema documented): The `to_canonical_json` rustdoc schema block MUST list every key the function actually emits, including `fix_edits`. The CHANGELOG `[Unreleased]` BREAKING entry MUST show a before/after JSON snippet covering the `file` key change, the diagnostic ordering change, the `unused-import` span change, and the analysis-failure envelope's `` → `` change (AD-211-5), and MUST state that a consumer keying off `files[].file == "input.mds"` for CLI stdin output, or matching `` in `error.message`, or relying on rule-execution array order, or relying on `unused-import` spans having length 7, will break. This CHANGELOG BREAKING block is the wave's **single wire-change ledger** — PR2 and PR4 append to it rather than opening a parallel one. +22. AC-P1-22 (performance, explicit threshold): The added sort MUST be O(n log n) over n ≤ `MAX_DIAGNOSTICS` (1,000) and MUST execute at most once per `LintResult` construction. The sort key MUST borrow (`&str` / `&Option`) and MUST NOT allocate a `String` per comparison. No wall-clock regression threshold is imposed — at n ≤ 1,000 the sort is not a measurable cost — but a lint of the largest fixture in the repo MUST NOT regress by more than 10% wall-clock, measured as the median of 5 runs. +23. AC-P1-23 (WASM budget): The optimized WASM binary raw byte count MUST remain strictly under 850,000. Both the pre-change and post-change raw byte counts MUST be recorded verbatim in the PR body. The guard in `.github/workflows/ci.yml` MUST NOT be raised to accommodate this PR. +24. AC-P1-24 (cross-surface parity, correctly scoped): For an identical fixture linted through napi, WASM, Python, and the CLI, the `files[].diagnostics[]` arrays MUST be byte-identical across all four surfaces when the `file` key is excluded from comparison. The `file` key itself MUST equal `input.mds` on the three binding surfaces and the CLI's own sentinel on the CLI surface. Surfaces MUST be compared to each other, not each to its own golden (PF-007). +25. AC-P1-25 (gates): `cargo nextest run --workspace`, `cargo test --doc`, `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`, the full npm build/test cycle, and `pytest crates/mds-python/tests` MUST all pass. Zero warnings is a hard stop. The `--doc` run is mandatory because nextest skips doctests and both `diagnostic.rs` and `unused_import.rs` carry doc examples. +26. AC-P1-26 (lint JSON `"file"` key for stdin — RULING-PINNED, POSITIVE): Every `--format json` document the CLI emits for stdin input that **carries at least one diagnostic** MUST have `files[0].file` equal exactly `""`. For the analysis-failure envelope emitted when the check gate fails, the source identity carried in `error` MUST likewise be exactly ``. The key is a **published wire surface** and this value is the contract of record per the 2026-08-12 ruling. **Zero-diagnostic carve-out (2026-08-14):** when stdin lint completes cleanly, the implementation emits `{"files":[],...}` — no file entry — consistent with how non-stdin files and all three binding surfaces (napi, WASM, Python) handle zero-diagnostic results. The `` sentinel is therefore present in `files[0].file` only when at least one diagnostic is emitted for stdin. This carve-out is documented in CHANGELOG under the lint JSON wire contract entry. + +**Analysis-failure carve-out (2026-08-14):** when the check gate fails, the CLI emits `{"version":1,"error":{"code":…,"message":…,"help":…,"span":…}}` — no `file` key at the top level and no source identity inside `error`. This is by design: `MdsError::serialize()` emits `code`/`message`/`help`/`span` only, and no `MdsError` Display template interpolates `ctx.file_str`, so the source identity structurally cannot reach `error.message` (AD-211-5 rustdoc at `lint.rs:emit_analysis_failure_json_or_stderr`). A JSON consumer MAY NOT rely on a `file` key in the error envelope; the envelope shape differs from the success envelope (`{"version":1,"files":[…],"truncated":…}`). This carve-out supersedes the "(c)" clause of this AC requiring `error` to carry exactly ``. The human channel is unaffected — stderr still renders `[:L:C]`. The contract is locked in `cli_lint.rs::stdin_analysis_failure_labels_source_as_stdin` with ADR-009 positive controls. +27. AC-P1-27 (lint JSON `"file"` key for stdin — RULING-PINNED, NEGATIVE): The CLI MUST NEVER emit `"file": "input.mds"` for stdin input in any `--format json` output, on any code path, in any mode. The literal `input.mds` MUST NOT appear anywhere in CLI stdout for a stdin lint, and the literal `` MUST NOT appear as a stdin source identity on stdout or stderr. Verification MUST include a positive control demonstrating the assertion detects `input.mds` when it is present (e.g. the same extraction run against a `113f472` build), so an absence assertion cannot pass vacuously (PF-013 / ADR-009). This criterion does NOT constrain the napi, WASM or Python surfaces, which keep `input.mds` per AC-P1-06. +28. AC-P1-28 (WASM `DEFAULT_FILENAME` unchanged — NEGATIVE, the wrong-lever regression guard): `crates/mds-wasm/src/lib.rs:69` binds `const DEFAULT_FILENAME: &str = mds::STRING_SOURCE_MAP_LABEL`, making the shared constant the WASM **virtual-FS default filename**, not a display label. This PR MUST NOT change `crates/mds-core/src/sourcemap.rs:79`, and the WASM default filename MUST remain `input.mds`. Concretely: a WASM string-source compile MUST still emit `sources[0] == "input.mds"`; a WASM string-source lint MUST still emit `files[].file == "input.mds"`; and a relative `@import` in a string source MUST still resolve against the `input.mds` virtual-FS entry key exactly as at `113f472`. `crates/mds-core/tests/api_surface.rs:1420-1431` and `crates/mds-core/tests/source_map_vfs.rs:1126-1135` MUST pass **unmodified**. Any diff touching `sourcemap.rs:79` fails this criterion outright — the relabel belongs at the CLI output boundary, and moving the constant to satisfy a CLI display requirement is the specific regression this criterion exists to prevent. + +## Test Plan + +### 1. AC-P1-01 — stdin lint in JSON mode reports the sentinel and never leaks the library label. + +- **Scenario:** stdin lint in JSON mode reports the sentinel and never leaks the library label. +- **Setup:** In crates/mds-cli/tests/cli_lint.rs, pipe a source that fires at least one rule (e.g. "@define greet(name):\n Hello {{name}}!\n@end\n\n@export greet\n@export greet\n") to `mds lint - --format json`. +- **Expected outcome:** stdout parses as JSON; `files[0].file` is exactly ""; `files` has length 1; the literal substring "input.mds" does not occur anywhere in stdout. +- **Verification method:** integration + +### 2. AC-P1-02 — stdin lint in human mode renders in the code frame. + +- **Scenario:** stdin lint in human mode renders in the code frame. +- **Setup:** Modify the existing test crates/mds-cli/tests/cli_lint.rs::stdin_lint_diagnostic_includes_code_frame (currently asserts stderr contains "input.mds"). Same source as AC-P1-01, no --format flag. +- **Expected outcome:** stderr contains "duplicate-export", contains "", contains "@export" (the code frame is still rendered), and does NOT contain "input.mds". +- **Verification method:** integration + +### 3. AC-P1-03 — All three fix-path status/diff messages use the bracketed sentinel; the bare `stdin` convention is gone. + +- **Scenario:** All three fix-path status/diff messages use the bracketed sentinel; the bare `stdin` convention is gone. +- **Setup:** Three cases in cli_lint.rs against a source with an auto-fixable finding (a Tier A rule on a standalone file, no @import/@extends): (a) `mds lint --fix --check -`; (b) `mds lint --fix --diff -`; (c) a fixture producing a partial fix via `mds lint --fix -`. All in HUMAN mode — do NOT add --format json, which is rejected with exit 2 at crates/mds-cli/src/lint.rs:136-146. +- **Expected outcome:** (a) stderr contains "Would fix: " and the process exits 1. (b) stdout diff header references "". (c) stderr matches "Partially fixed: (\\d+ of \\d+ fixes applied)". In all three, the regex /(^|[^<])\\bstdin\\b([^>]|$)/ does not match any emitted source-identity line. +- **Verification method:** integration + +### 4. AC-P1-04 — Cross-subcommand sentinel consistency for stdin. + +- **Scenario:** Cross-subcommand sentinel consistency for stdin. +- **Setup:** Run all four against stdin with a valid source: `mds lint -` (human), `mds fmt --check -`, `mds check -`, `mds build - --source-map` (sidecar or inline as the existing cli_source_map.rs tests do). +- **Expected outcome:** lint code frame shows ""; fmt prints "Would reformat: " when it would change; check prints "OK: "; build source-map `sources[]` contains "". No surface emits "input.mds" or "". +- **Verification method:** integration + +### 5. AC-P1-05 — The pinned public constant and the core-emits-no-sentinel rule both hold. + +- **Scenario:** The pinned public constant and the core-emits-no-sentinel rule both hold. +- **Setup:** Run crates/mds-core/tests/api_surface.rs::string_source_map_label_is_in_public_api unmodified. Separately, grep crates/mds-core/src for the literals "" and "" assigned to a diagnostic `file` field. +- **Expected outcome:** The api_surface test compiles and passes with `label == "input.mds"`. No core code path assigns "" to `LintDiagnostic.file`. (`` legitimately remains as resolver.rs:199 SOURCE_LABEL — that is the ctx.file_str for errors, not a diag.file, and is governed by AC-P1-07.) +- **Verification method:** unit + +### 6. AC-P1-06 — Binding surfaces are untouched by the CLI remap. + +- **Scenario:** Binding surfaces are untouched by the CLI remap. +- **Setup:** Run `pytest crates/mds-python/tests -q` and `npm test --workspaces --if-present` with NO edits to the `input.mds` assertions in crates/mds-python/tests/test_lint.py:161 and test_parity.py:150,258. +- **Expected outcome:** All pass unmodified. A Python `lint(source)` call returns diagnostics whose file key is "input.mds". +- **Verification method:** integration + +### 7. AC-P1-07 — The analysis-failure path's source identity is ``, and is documented. + +- **Scenario:** The analysis-failure path's source identity is ``, and is documented. +- **Setup:** Pipe a source with a hard syntax error to `mds lint -` (human) and to `mds lint - --format json`. Then grep the emitting site (crates/mds-cli/src/lint.rs:1421 emit_analysis_failure_json_or_stderr) for an `AD-211-5` rustdoc block. +- **Expected outcome:** Human stderr renders `:L:C`, not `:L:C`. The JSON `error.message` carries `` wherever it names the source, and contains neither `` nor `input.mds`. An AD-211-5 block exists at the emitting site stating the envelope-wide rule and its reason (the 2026-08-12 ruling). Include a positive control: the same extraction applied to a build at 113f472 DOES find ``, proving the assertion detects the old value when present. +- **Verification method:** integration + +### 8. AC-P1-08 — Diagnostics within a file are offset-ordered, with span:None last and stable ties. + +- **Scenario:** Diagnostics within a file are offset-ordered, with span:None last and stable ties. +- **Setup:** Build a fixture that fires at least three rules NOT touched by #203 — use duplicate-export, unused-variable, and legacy-interpolation — at known, deliberately out-of-execution-order offsets (i.e. the rule that runs first in run_rules fires at the LARGEST offset). Run `mds lint --format json`. Add a mds-core unit test that also constructs the case via the lint entry point. +- **Expected outcome:** `files[0].diagnostics[].span.offset` is non-decreasing across the array. Any diagnostic with `span == null` appears after every diagnostic with a span. The array order differs from run_rules order, proving the sort ran. +- **Verification method:** integration + +### 9. AC-P1-08 (span:None reachability) — A span-less diagnostic sorts last without panicking. + +- **Scenario:** A span-less diagnostic sorts last without panicking. +- **Setup:** Construct a source with a frontmatter variable whose offset cannot be approximated, so crates/mds-core/src/lint/rules/unused_variable.rs:79 yields `fv.approx_offset == None`, combined with at least one span-bearing diagnostic. Lint via the public entry point. +- **Expected outcome:** No panic. The span-less diagnostic is the last element of `files[0].diagnostics[]` and serializes as `"span": null`. +- **Verification method:** unit + +### 10. AC-P1-09 — Human output ordering matches JSON ordering. + +- **Scenario:** Human output ordering matches JSON ordering. +- **Setup:** Run the AC-P1-08 fixture through `mds lint ` (human, stderr) and `mds lint --format json` (stdout). Extract the rule-name sequence from each. +- **Expected outcome:** The sequence of rule names on stderr is identical to the sequence of `diagnostics[].rule` in the JSON. This proves the sort is on LintResult.diagnostics and not in the JSON renderer. +- **Verification method:** integration + +### 11. AC-P1-10 — files[] array ordering is pinned for directory and single-file/stdin modes. + +- **Scenario:** files[] array ordering is pinned for directory and single-file/stdin modes. +- **Setup:** Create a temp dir with files that sort non-trivially (e.g. b.mds, a.mds, sub/c.mds), each with at least one finding. Run `mds lint --format json`. Separately run single-file and stdin JSON lints. +- **Expected outcome:** Directory mode: `files[].file` is in ascending path order regardless of filesystem enumeration order. Single-file and stdin: `files` has exactly one element. +- **Verification method:** integration + +### 12. AC-P1-11 — Repeated lints of identical input are byte-identical. + +- **Scenario:** Repeated lints of identical input are byte-identical. +- **Setup:** Run `mds lint --format json` five times, capturing stdout each time. +- **Expected outcome:** All five stdout buffers are byte-identical. This pins AD-202-3's stable-tie residual (identical (start,end) edits retaining input order). +- **Verification method:** integration + +### 13. AC-P1-12 — Truncation still selects by rule-execution order, not by offset. + +- **Scenario:** Truncation still selects by rule-execution order, not by offset. +- **Setup:** Extend the existing crates/mds-core/src/lint/diagnostic.rs::builder_truncates_at_max_diagnostics pattern: push MAX_DIAGNOSTICS+1 diagnostics whose offsets DESCEND (so the last-pushed, rejected one has the SMALLEST offset). Build the result. Construct diagnostics via LintDiagnostic::new / with_* builders, never struct literals (ADR-010). +- **Expected outcome:** `diagnostics.len() == 1000`; `truncated == true`; the rejected smallest-offset diagnostic is ABSENT from the result even though sorting would have placed it first. The retained 1000 are sorted ascending among themselves. +- **Verification method:** unit + +### 14. AC-P1-13 — --fix output is byte-identical before and after the ordering change. + +- **Scenario:** --fix output is byte-identical before and after the ordering change. +- **Setup:** Before landing #202, capture `mds lint --fix ` stdout bytes and the residual diagnostic set for every multi-rule fixture in the existing fix corpus (crates/mds-core/src/lint/fix.rs tests plus cli_lint.rs fix tests). Re-capture after. +- **Expected outcome:** Byte-identical fixed source and identical residual rule/severity multiset for every fixture. Supporting static evidence: crates/mds-core/src/lint/fix.rs contains no read of `diag.file`, and re-sorts its own edits at fix.rs:361 before dedup/overlap. +- **Verification method:** integration + +### 15. AC-P1-14 — POSITIVE CONTROL — each unused-import span slices to exactly the unused name. + +- **Scenario:** POSITIVE CONTROL — each unused-import span slices to exactly the unused name. +- **Setup:** Unit test in crates/mds-core/src/lint/rules/unused_import.rs. Source: `@import { used, unused_a, unused_b } from "./l.mds"\n@include used()\n` (adjust so `used` is genuinely referenced). Lint, then for every unused-import diagnostic compute `&source[span.offset .. span.offset + span.length]`. +- **Expected outcome:** Two diagnostics. The slice equals "unused_a" for one and "unused_b" for the other, matching the name quoted in each diagnostic's message. `span.length` equals the name length, not 7. An in-bounds-but-wrong offset fails this assertion (PF-012). +- **Verification method:** unit + +### 16. AC-P1-15 — Empty and trailing comma segments do not desync name-to-offset alignment. + +- **Scenario:** Empty and trailing comma segments do not desync name-to-offset alignment. +- **Setup:** Two unit cases: (a) `@import { a, , b } from "./l.mds"`; (b) `@import { a, b, } from "./l.mds"`. Neither `a` nor `b` referenced in the body. Apply the AC-P1-14 slice assertion to each diagnostic. +- **Expected outcome:** Case (a): exactly two diagnostics; slices are "a" and "b" respectively, matching each message. Case (b): identical. Critically, `b`'s span offset must be `b`'s real source position, NOT `a`'s. Add a companion assertion that the offset vector length equals `names.len()` at the construction site. +- **Verification method:** unit + +### 17. AC-P1-16 — Span anchoring survives whitespace, prefix collisions, path collisions, trailing whitespace, CRLF, and multi-byte prefixes. + +- **Scenario:** Span anchoring survives whitespace, prefix collisions, path collisions, trailing whitespace, CRLF, and multi-byte prefixes. +- **Setup:** Six unit cases, each applying the AC-P1-14 slice assertion: (a) `@import { a , b } from "./l.mds"`; (b) `@import { foo, foobar } from "./l.mds"` with only foo used; (c) `@import { lib } from "./lib.mds"` with lib unused; (d) `@import { a, b } from "./l.mds" ` (three trailing spaces — this is the case that catches the both-ends-trim delta bug); (e) the same source with \r\n line endings; (f) `# héllo wörld\n@import { a, b } from "./l.mds"`. +- **Expected outcome:** Every diagnostic's slice equals its reported name in all six cases. In (b) the `foobar` diagnostic anchors at foobar's own offset, not at foo's. In (c) the span lands inside the braces, not inside the quoted path. In (d) offsets are unshifted by the trailing whitespace. In (f) `source.is_char_boundary(span.offset)` is true and the slice does not panic. +- **Verification method:** unit + +### 18. AC-P1-17 — Alias and merge import spans behave per the ruling. + +- **Scenario:** Alias and merge import spans behave per the ruling. +- **Setup:** Unit test with `@import "./l.mds" as unusedAlias` and a merge `@import "./l.mds"`. Assert the span against whichever behavior openDecisions #2 rules. +- **Expected outcome:** If the ruling is 'exclude': alias span offset == the @import keyword offset and length == 7, unchanged from HEAD; merge is never diagnosed (ImportKind::Merge is skipped at unused_import.rs). If the ruling is 'include': alias span slices to the alias identifier and the CHANGELOG BREAKING entry names it. +- **Verification method:** unit + +### 19. AC-P1-18 — duplicate-import detection is unchanged by the new field. + +- **Scenario:** duplicate-import detection is unchanged by the new field. +- **Setup:** Lint sources containing two structurally identical selective imports written with DIFFERENT interior whitespace, e.g. `@import { a, b } from "./l.mds"` and `@import {a,b} from "./l.mds"` — the offsets differ, the structure does not. Also run the full existing duplicate_import rule test suite. +- **Expected outcome:** duplicate-import fires exactly as it does at HEAD. structural_eq (crates/mds-core/src/lint/rules/structural_eq.rs:175-185) continues to compare only `names` and `path`; name_offsets must not affect the result. All pre-existing duplicate_import tests pass unedited. +- **Verification method:** unit + +### 20. AC-P1-19 — Length desync degrades to the keyword anchor instead of mis-anchoring or panicking. + +- **Scenario:** Length desync degrades to the keyword anchor instead of mis-anchoring or panicking. +- **Setup:** A unit test that constructs an ImportFact whose name_offsets vector is deliberately shorter than names (crate-internal test, legal because lint::facts is pub(crate)), then invokes the rule. +- **Expected outcome:** No panic in release semantics. Each name lacking an offset produces a span at the @import keyword offset. Verify the debug_assert_eq! on lengths exists AND that the unconditional `unwrap_or(imp.offset)` fallback exists — per PF-005 the debug_assert alone is not the guard. +- **Verification method:** unit + +### 21. AC-P1-20 — POSITIVE CONTROL — WIRE sanitization of files[].file still works after the display-path rewrite. + +- **Scenario:** POSITIVE CONTROL — WIRE sanitization of files[].file still works after the display-path rewrite. +- **Setup:** Directory-mode integration test. Create a file whose PATH contains a control character (construct the byte programmatically from a numeric escape at test runtime — do NOT type a literal \u sequence into the test source; per PF-018 the editing tool decodes it into a live control byte in tracked source). Skip on platforms that reject the filename. Run `mds lint --format json`. Then, as the control arm, assert that the same extraction logic applied to the RAW unsanitized path string DOES find the raw byte. +- **Expected outcome:** The emitted `files[].file` contains the escaped literal form and contains no raw control byte. The control arm proves the assertion is capable of detecting a raw byte when one is present — satisfying PF-013 / ADR-009. Absence alone is not accepted. +- **Verification method:** integration + +### 22. AC-P1-21 — The wire schema rustdoc and the CHANGELOG match what ships. + +- **Scenario:** The wire schema rustdoc and the CHANGELOG match what ships. +- **Setup:** Read the to_canonical_json rustdoc schema block in crates/mds-core/src/lint/diagnostic.rs against the keys the function actually inserts. Read the CHANGELOG [Unreleased] BREAKING entry. +- **Expected outcome:** The rustdoc schema lists rule, severity, message, help, fixable, span, AND fix_edits. The CHANGELOG entry contains a before/after JSON snippet and names all three breaks explicitly: CLI stdin `files[].file` changes from "input.mds" to ""; `diagnostics[]` array order changes from rule-execution to offset order; `unused-import` span changes from length 7 at the @import keyword to the name length at the name. +- **Verification method:** manual + +### 23. AC-P1-22 — Sort cost is bounded and allocation-free. + +- **Scenario:** Sort cost is bounded and allocation-free. +- **Setup:** Inspect the sort helper for `sort_by` with a borrowed key (no `.clone()`, no `to_string()` in the comparator). Then time `mds lint` on the largest fixture in the repo, 5 runs, taking the median, before and after the change. +- **Expected outcome:** Comparator borrows `&Option`/`&str` and allocates nothing. n is bounded by MAX_DIAGNOSTICS = 1_000 (crates/mds-core/src/limits.rs:94). Median wall-clock regression is under 10%. Sort is invoked at most once per LintResult construction. +- **Verification method:** load + +### 24. AC-P1-23 — WASM binary stays under the 850,000-byte guard. + +- **Scenario:** WASM binary stays under the 850,000-byte guard. +- **Setup:** IN THE PRIMARY CHECKOUT, NOT AN ISOLATED WORKTREE (PF-016 — pkg/ is generated and gitignored, so an isolated worktree has nothing to measure and the check passes vacuously). Requires Binaryen v129+. Run `npm run build -w @mdscript/mds-wasm` and record the raw .wasm byte count before any source change, then again after all three issues land. +- **Expected outcome:** Both raw byte counts are pasted verbatim into the PR body. The post-change count is strictly less than 850,000. The threshold in .github/workflows/ci.yml is unchanged — if the budget trips, the change shrinks rather than the guard growing. +- **Verification method:** manual + +### 25. AC-P1-24 — Cross-surface differential on the fields that are supposed to match. + +- **Scenario:** Cross-surface differential on the fields that are supposed to match. +- **Setup:** One fixture file. Lint it via CLI `--format json`, napi, WASM, and Python, each through its file-based lint entry point where available. Normalize by deleting the `file` key from each `files[]` entry, then compare the four normalized structures pairwise. Separately assert each surface's `file` key equals its expected value. +- **Expected outcome:** The four normalized `files[].diagnostics[]` arrays are byte-identical — same order, same spans, same rule/severity/fixable. The `file` keys are "input.mds" on napi/WASM/Python string-source paths and the basename (file mode) or "" (stdin mode) on the CLI. Do NOT assert full byte-identity including the file key — that is false by construction under Option C and is already documented at crates/mds-python/tests/test_parity.py:150. +- **Verification method:** integration + +### 26. AC-P1-25 — Full gate suite. + +- **Scenario:** Full gate suite. +- **Setup:** Run, in order: `cargo nextest run --workspace`; `cargo test --doc`; `cargo fmt --all --check`; `cargo clippy --workspace --all-targets -- -D warnings`; `npm ci && npm run build -w @mdscript/mds-wasm && npm run build --workspaces --if-present`; `npm test --workspaces --if-present`; `node scripts/verify-versions.mjs`; `. .venv/bin/activate && maturin develop -m crates/mds-python/Cargo.toml && pytest crates/mds-python/tests -q`. Use the repo-local .cargo/config.toml (rustc-wrapper="", jobs=2) and NEVER commit it. +- **Expected outcome:** All green, zero clippy warnings. `cargo test --doc` is mandatory and separately reported — nextest skips doctests, and both LintResult::new (diagnostic.rs:649-656) and the to_canonical_json schema block carry doc examples this PR edits. `mds lint` exiting 2 on examples/ is by design and is not a failure. +- **Verification method:** integration + +### 27. AC-P1-26 — Every stdin JSON envelope carries `` as the source identity, with a zero-diagnostic carve-out. + +- **Scenario:** Every stdin JSON envelope carries `` as the source identity when diagnostics are present; emits an empty `files[]` array when none are. +- **Setup:** In crates/mds-cli/tests/cli_lint.rs, three stdin cases run with `--format json`: (a) a source producing several diagnostics; (b) a lint-clean source producing zero diagnostics; (c) a source with a hard syntax error that fails the check gate and takes the emit_analysis_failure_json_or_stderr path. +- **Expected outcome:** (a) stdout parses as JSON, `files` has length 1, and `files[0].file` is exactly `""`. (b) stdout parses as JSON and `files` is an empty array — consistent with the zero-diagnostic carve-out in AC-P1-26 (2026-08-14): no file entry is emitted and `""` does NOT appear in `files[]`, matching the behaviour for non-stdin files and all three binding surfaces when zero diagnostics are produced. The test MUST assert `files.len() == 0`, not access `files[0]`. (c) the error envelope's source identity is exactly `""`. Cases (a) and (c) assert exact string equality on the key, never `contains`. +- **Verification method:** integration + +### 28. AC-P1-27 — NEGATIVE with positive control — the CLI never emits `input.mds` or `` for stdin. + +- **Scenario:** NEGATIVE with positive control — the CLI never emits `input.mds` or `` for stdin. +- **Setup:** Re-run every stdin case from AC-P1-26 plus the human-mode and fix-preview stdin cases. Scan full stdout and stderr for the literals `input.mds` and ``. Control arm: run the identical extraction against a binary built at 113f472. +- **Expected outcome:** Post-change: zero occurrences of `input.mds` in CLI stdout for any stdin lint, and zero occurrences of `` as a stdin source identity on either channel. Control arm at 113f472: the SAME extraction finds `input.mds` (lint JSON and human frame) and `` (analysis-failure path) — proving the assertions are capable of failing, per PF-013 / ADR-009. Binding-surface assertions (crates/mds-python/tests/test_lint.py:161, test_parity.py:150,258) stay unedited and keep expecting `input.mds`; this criterion is CLI-scoped. +- **Verification method:** integration + +### 29. AC-P1-28 — NEGATIVE — the shared constant and the WASM virtual-FS default are untouched. + +- **Scenario:** NEGATIVE — the shared constant and the WASM virtual-FS default are untouched. +- **Setup:** (a) `git diff 113f472 -- crates/mds-core/src/sourcemap.rs` and confirm line 79 is unchanged. (b) Run crates/mds-core/tests/api_surface.rs and crates/mds-core/tests/source_map_vfs.rs with zero edits. (c) Through the WASM surface: compile a string source with `source_map: true`; lint a string source; and compile a string source containing a relative `@import` that must resolve against the virtual-FS default entry key. +- **Expected outcome:** (a) `STRING_SOURCE_MAP_LABEL` is still `"input.mds"` and the diff is empty for that line. (b) Both test files pass unmodified, including `string_source_map_label_is_in_public_api` (api_surface.rs:1420-1431) and the D1 cross-surface parity block (source_map_vfs.rs:1126-1135). (c) WASM `sources[0] == "input.mds"`, WASM lint `files[].file == "input.mds"`, and the relative `@import` resolves exactly as at 113f472. Any of these changing means the relabel was applied at the constant instead of at the CLI output boundary. +- **Verification method:** integration + +## Merge Position + +**Position 2 of 6** in the recommended merge order: PR1 — Lint JSON wire contract (#211, #202, #203) + +Reason: Defines the lint JSON envelope, the `files[]` shape and ordering, and the CHANGELOG wire-change ledger that PR2 and PR4 must append to rather than fork; it also settles the source-label rule that PR2's config-error path inherits. + +### Plan Amendments from the Cross-PR Conflict Audit + +**PR1 — Lint JSON wire contract** + +> **Post-ruling status (2026-08-12):** amendment **1 is VOID** — under the #224 warn-but-continue ruling PR2 emits no `files[].error` entries, so AC-P1-10 needs no widening and the `files[]` array keeps a single entry shape. Amendment **3 is SATISFIED** — AD-211-5 is now ruled (``) and is written as an envelope-wide rule, which is exactly what that amendment asked for. Amendments 2, 4, 5 and 6 stand unchanged. + +1) AC-P1-10 must be widened: in directory mode `files[]` may contain BOTH diagnostic entries and PR2's error-only entries (`{"file":…, "error":…}` with no `diagnostics` key, emitted at crates/mds-cli/src/lint.rs:1086-1096); both are path-sorted. As written, AC-P1-10 is silently violated by PR2. 2) The CHANGELOG BREAKING block PR1 creates is the wave's single wire-change ledger — say so explicitly, so PR2 and PR4 append rather than fork. 3) AD-211-5's ruling on how the error envelope labels its source must be written as a rule about `emit_analysis_failure_json_or_stderr` generally, not about stdin specifically, because PR2's config rejection travels the same path (existing MdsError::Io sites at lint.rs:637, :757, :924). 4) Add a line to §8 verification: run `node scripts/verify-no-control-bytes.mjs` before pushing, and construct the AC-P1-20 control byte at runtime — PR6's gate will be live by the time PR1 merges. 5) Record the verified fact that mds-cli calls only `apply_fixes_incremental` (lint.rs:451, :571) and never `apply_fixes`, so PR5's deprecation cannot touch PR1. 6) Note that fix.rs's `make_result` (fix.rs:1031) builds LintResult by struct literal, bypassing both `new()` and `build()` — so PR1's sort does NOT reach the existing fix.rs unit corpus, which strengthens AC-P1-13 and narrows what actually needs re-baselining. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3903b096..d0570d5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,8 +87,8 @@ jobs: run: | for f in crates/mds-wasm/pkg/mds_wasm_bg.wasm crates/mds-wasm/pkg-web/mds_wasm_bg.wasm; do if [ ! -f "$f" ]; then - echo "::warning::WASM file missing: $f" - continue + echo "::error::WASM file missing: $f" + exit 1 fi raw=$(wc -c < "$f" | tr -d ' ') gz=$(gzip -c "$f" | wc -c | tr -d ' ') @@ -109,6 +109,12 @@ jobs: # signature help pushed optimized binary to ~808K locally (wasm-opt v117 # bundled + release profile); +headroom for CI toolchain variance (CI uses # Binaryen v129 which may differ). + # PR #294 (2026-08-14, ticket/pr1-lint-json-wire-contract): sort + stdin-label + + # name-span-anchor added +11,840 bytes; wave/v0.4.0-wave1 baseline 821,662, + # post-change 833,502 (wasm-pack 0.15.0 bundled wasm-opt, measured at HEAD + # after removing redundant to_canonical_json re-sort in 56424f7). + # Guard NOT raised: 16,498 bytes (1.94%) headroom. CI uses + # Binaryen v129 (distinct toolchain from local). (AC-P1-23) # Follow-up: pin the wasm build toolchain to make the size deterministic # and re-tighten this guard. if [ "$raw" -gt 850000 ]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index d6f0e2b1..f680ca88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,109 @@ via struct literals. Use the named constructor or builder listed for each: suitable for miette render boundaries. `mds-cli`'s diagnostic render path now delegates to this method instead of assembling sanitized copies itself, keeping the escape logic co-located with the struct definition (PF-014). +- **`MdsError::source_name() -> Option<&str>`** — a new method that returns the name embedded + in the error's `NamedSource`, or `None` for errors without a source (e.g. `MdsError::Io`). + `source_name()` is domain-neutral; callers that need to detect the string-source analysis + path should use `MdsError::is_string_source()` rather than comparing the returned name + against the sentinel value themselves — the internal sentinel is `pub(crate)` and is not + reachable from downstream crates. +- **`MdsError::is_string_source() -> bool`** — a new predicate that returns `true` when the + error was produced by the string-source analysis path (`resolve_source_intrinsic`). Use this + instead of comparing `source_name()` against a bare string literal: the internal sentinel + (`SOURCE_LABEL`) is `pub(crate)` and is not accessible from downstream crates. + +#### Lint JSON wire contract (#202, #203, #211) + +> This block is the **single wire-change ledger** for the lint JSON envelope. +> Later changes to `mds lint --format json` append here rather than opening a +> parallel section, so a consumer has one place to read. + +**Before / after**, for `mds lint - --format json` on a source with one unused +selective import: + +```jsonc +// abbreviated — see spec.md for the full schema +// before +{ "files": [ { "diagnostics": [ + { "rule": "duplicate-export", "span": { "length": 7, "offset": 59 } }, + { "rule": "unused-import", "span": { "length": 7, "offset": 0 } } + ], "file": "input.mds" } ], "truncated": false, "version": 1 } + +// after +{ "files": [ { "diagnostics": [ + { "rule": "unused-import", "span": { "length": 5, "offset": 10 } }, + { "rule": "duplicate-export", "span": { "length": 7, "offset": 59 } } + ], "file": "" } ], "truncated": false, "version": 1 } +``` + +**A consumer breaks if it** keys off `files[].file == "input.mds"` for CLI stdin +output, matches `` in a rendered diagnostic frame (stderr only — the JSON +`error.message` field cannot carry source identity; no `MdsError` Display template +interpolates `ctx.file_str`, per AD-211-5), relies on +`diagnostics[]` arriving in rule-execution order, assumes `unused-import` +spans have length 7, relies on the `mds lint ` file-group order being +component-wise (`Path::Ord`), or on Windows assumes `files[].file` values use +the native backslash separator. File groups are now ordered by the byte-wise string +of the relative display path (e.g. `api-utils.mds` sorts before `api/x.mds` +because `'-'` (0x2D) < `'/'` (0x2F)). On Windows, `relative_display` normalises +path separators to forward slashes, so a nested path that previously appeared as +`sub\c.mds` in the JSON now appears as `sub/c.mds`; a consumer that string-matches +or splits on `\` in `files[].file` values will silently fail to match. + +**1. Diagnostics are sorted by byte offset (#202).** Within each +`files[].diagnostics` array, diagnostics are ordered by ascending `span.offset` +for results produced by the lint engine; a `LintResult` assembled directly via +`LintResult::new` is emitted in the order the caller supplied. +Previously the order was rule-execution order (implementation-defined). + +- Diagnostics without a span sort to the end of their file group. +- Equal-offset diagnostics preserve rule-execution order (stable sort). +- File groups have a defined order: `mds lint ` sorts `files[]` by the + byte-wise (lexicographic) string comparison of the relative display path — e.g. + `api-utils.mds` sorts before `api/x.mds` because `'-'` (0x2D) < `'/'` (0x2F). + This is a CLI directory-mode contract only: the binding surfaces (napi / WASM / + Python) lint a single entry source, so their `files[]` array never carries more + than one entry. +- Ordering is established on `LintResult.diagnostics` itself, so the CLI human + path and the napi / WASM / Python surfaces observe the same order. +- **Truncation is unchanged and is NOT offset-ranked.** When `truncated` is + `true`, the retained diagnostics are still the first `MAX_DIAGNOSTICS` (1,000) + in rule-execution order, re-sorted afterwards — not the 1,000 smallest offsets. +- **Sort cost (AC-P1-22):** The sort key is a borrowed tuple `(bool, &str, bool, + usize)` — zero per-comparison heap allocations. The sort runs at most once per + `LintResultBuilder::build` call over n <= `MAX_DIAGNOSTICS` (1,000) items. + +**2. The stdin source identity is always `` (#211).** Every CLI context +that names a stdin source now uses the single sentinel ``: + +- the JSON `files[].file` key (previously `"input.mds"`, the internal VFS key); +- human diagnostic frames for `mds lint -` (previously `input.mds`); +- fix-preview status lines and diff headers (previously bare `stdin`); +- the **analysis-failure envelope** — a stdin source that fails the check gate + used to render `:L:C`, the resolver's internal label. `mds check -` and + `mds build -` rendered `` on the same path and now render `` + too, so all four subcommands agree. Note: the analysis-failure JSON envelope + shape is `{"version":1,"error":{"code","message","help","span"}}` — it carries + **no `file` key** (unlike the success envelope which has `files[].file`). A + JSON consumer reading `error` results MUST NOT look for a `file` key there. + +`mds::STRING_SOURCE_MAP_LABEL` is **unchanged** and remains `"input.mds"`: it is a +virtual-FS entry key, not a display label. The napi, WASM and Python lint APIs +continue to report `"input.mds"` for string-source input. The relabel is applied +only at the CLI output boundary. + +**Zero-diagnostic behaviour:** when stdin lint completes with no findings, the +JSON is `{"files":[],"truncated":false,"version":1}` — no file entry. The +`` sentinel appears in `files[0].file` only when at least one diagnostic is +emitted. This matches non-stdin zero-diagnostic behaviour and keeps the JSON +identical across the CLI and binding surfaces (napi, WASM, Python) for the clean +case. + +**3. `unused-import` spans anchor at the unused name (#203).** For selective +imports (`@import { name1, name2 } from "path"`), the span now covers the unused +name rather than the `@import` keyword, and `span.length` is the name's length +instead of a constant 7. Alias imports (`@import "path" as alias`) are unchanged — +their span still covers the `@import` keyword. #### New `fix_edits` field on `LintDiagnostic` @@ -834,6 +937,17 @@ diagnostic messages must update to check for the `\uXXXX` literal form instead. `source_map=true` (from config), two overlapping errors could fire. The messages-mode stdout path now emits exactly one warning. +- **`mds lint --fix --format json ` no longer emits `"file": "input.mds"` + for residual diagnostics.** In single-file mode with both `--fix` and + `--format json`, the `files[].file` key in the JSON output for residual + (post-fix) diagnostics was the internal VFS label `"input.mds"` instead of the + real file basename. The reverify closure inside `plan_and_apply_fixes` calls + `lint_str_with`, which sets `diag.file` to `STRING_SOURCE_MAP_LABEL`; the + resulting residual was not relabeled before `emit_result`. Fixed by calling + `set_diag_display_path(&mut residual, filename)` in the `Fixed` and + `PartiallyFixed` match arms of `run_lint_file`, mirroring the existing relabel + in directory mode (which was already correct). + ## [0.3.0] — 2026-06-28 ### **BREAKING** — Intrinsic output format (removes `--format` flag and `compileMessages` API) diff --git a/crates/mds-cli/src/build.rs b/crates/mds-cli/src/build.rs index 318c6f47..17e6cdb6 100644 --- a/crates/mds-cli/src/build.rs +++ b/crates/mds-cli/src/build.rs @@ -431,7 +431,15 @@ pub(crate) fn parse_cli_value(val: String) -> mds::Value { /// and correctly fall through to exit code 1. Only `MdsError` values converted via /// `.map_err(miette::Error::from)` are categorized. pub(crate) fn exit_code(err: &miette::Error) -> i32 { - if let Some(mds_err) = err.downcast_ref::() { + // AD-211-5: `StdinRelabeledError` is a render-only wrapper around an `MdsError`. + // It must be unwrapped here or wrapping an error to fix its DISPLAY label would + // silently change its EXIT CODE (a wrapped FileNotFound would fall through to 1 + // instead of 2). The label swap is not allowed to have behavioural side effects. + let mds_err = err.downcast_ref::().or_else(|| { + err.downcast_ref::() + .map(crate::output::StdinRelabeledError::inner) + }); + if let Some(mds_err) = mds_err { match mds_err { MdsError::Io { .. } | MdsError::FileNotFound { .. } | MdsError::NotMdsFile { .. } => 2, MdsError::ResourceLimit { .. } => 3, @@ -699,8 +707,11 @@ pub(crate) fn compile_to_content( // Stdin: compile from source string using cwd as base_dir. // read_stdin enforces MAX_FILE_SIZE (PF-004). let (source, cwd) = read_stdin()?; + // AD-211-1 / AD-211-5: a string-source compile labels its errors `` + // (resolver's SOURCE_LABEL). Relabel to the uniform CLI sentinel here, at the + // boundary that knows the input was stdin. mds::compile_str_with_deps_opts(&source, Some(&cwd), runtime_vars, opts) - .map_err(miette::Error::from)? + .map_err(|e| crate::output::relabel_stdin_error(&e, &source))? } else { // File path: compile_with_deps_opts routes through the resolver which enforces // MAX_FILE_SIZE and check_symlink (PF-004 compliance). @@ -990,7 +1001,8 @@ pub(crate) fn apply_source_map_file_label( if stdin_label { for src in &mut sm.sources { if src == STRING_SOURCE_MAP_LABEL { - *src = "".to_string(); + // AD-211-3: use the centralised sentinel from output.rs. + *src = crate::output::STDIN_DISPLAY_LABEL.to_string(); } } } @@ -1159,8 +1171,9 @@ pub(crate) fn run_build(args: BuildArgs) -> Result<()> { .with_source_map_base(source_map_base); let (source, cwd) = read_stdin()?; + // AD-211-1 / AD-211-5: same stdin relabel as `compile_to_content`. let result = mds::compile_str_with_deps_opts(&source, Some(&cwd), runtime_vars, opts) - .map_err(miette::Error::from)?; + .map_err(|e| crate::output::relabel_stdin_error(&e, &source))?; if !quiet { for w in &result.warnings { crate::output::eprint_warning(w); diff --git a/crates/mds-cli/src/fmt.rs b/crates/mds-cli/src/fmt.rs index 4aa7b1cf..c00f2cef 100644 --- a/crates/mds-cli/src/fmt.rs +++ b/crates/mds-cli/src/fmt.rs @@ -128,12 +128,19 @@ fn format_source_named( // ── stdin mode ─────────────────────────────────────────────────────────────── fn run_fmt_stdin(flags: FmtFlags) -> Result<()> { + use crate::output::STDIN_DISPLAY_LABEL; + let FmtFlags { check, diff, quiet } = flags; let (source, cwd) = read_stdin()?; - let result = format_source_named(&source, Some(&cwd), "")?; + // AD-211-3: one definition of the stdin sentinel, shared with lint/check/build. + let result = format_source_named(&source, Some(&cwd), STDIN_DISPLAY_LABEL)?; if diff { - print_diff(&render_unified_diff(&source, &result.formatted, ""))?; + print_diff(&render_unified_diff( + &source, + &result.formatted, + STDIN_DISPLAY_LABEL, + ))?; } else if !check { // Plain filter mode: formatted content is the output. write_stdout(&result.formatted)?; @@ -141,7 +148,7 @@ fn run_fmt_stdin(flags: FmtFlags) -> Result<()> { if check && result.changed { if !quiet { - eprintln!("Would reformat: "); + eprintln!("Would reformat: {STDIN_DISPLAY_LABEL}"); } std::process::exit(1); } diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index 158da378..dafc09bf 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -39,7 +39,8 @@ use crate::build::{ }; use crate::output::{ atomic_write_file, collect_mds_files_detailed, eprint_error, eprint_warning, - render_unified_diff, safe_file_display, safe_inline, safe_path, + relabel_stdin_error, render_unified_diff, safe_file_display, safe_inline, safe_path, + STDIN_DISPLAY_LABEL, }; /// Known lint rule names — used to warn about unknown names in mds.json config. @@ -165,7 +166,7 @@ fn do_lint(args: LintArgs) -> Result<()> { input.display() ), }; - emit_analysis_failure_json_or_stderr(&mds_err, format); + emit_analysis_failure_json_or_stderr(&mds_err, format, None); std::process::exit(2); } return run_lint_directory(&input, flags, runtime_vars); @@ -177,7 +178,7 @@ fn do_lint(args: LintArgs) -> Result<()> { // Route through emit_analysis_failure_json_or_stderr so --format json produces the // correct error envelope (L-CLI-JSON4 / AC-F-14). Do NOT use `?` here. if let Err(mds_err) = ensure_existing_mds_file(&input) { - emit_analysis_failure_json_or_stderr(&mds_err, format); + emit_analysis_failure_json_or_stderr(&mds_err, format, None); std::process::exit(mds_error_exit_code(&mds_err)); } run_lint_file(&input, flags, runtime_vars) @@ -227,13 +228,53 @@ fn load_lint_config(dir: &Path) -> Result { /// This function replaces the field with the caller-supplied relative path so /// the JSON output uses distinct, navigable paths. /// -/// Call this immediately after every `mds::lint` that runs in directory mode. +/// **AD-211-4 (stdin relabel):** also used for stdin mode — called with +/// `STDIN_DISPLAY_LABEL` immediately after every `mds::lint_str_with` call so +/// that `diag.file` in the JSON wire output reads `""` rather than the +/// internal VFS key `"input.mds"` (`STRING_SOURCE_MAP_LABEL`). +/// +/// Call this immediately after every `mds::lint` / `mds::lint_str_with` call. fn set_diag_display_path(result: &mut mds::LintResult, display: &str) { for diag in &mut result.diagnostics { diag.file = Some(display.to_string()); } } +/// Returns the path of `path` relative to `root`, normalised to forward-slash +/// separators by joining path components with `/`. +/// +/// Using `Path::components()` is the correct platform-aware join: on Windows a +/// backslash is a path separator, so each component is a directory or filename +/// segment; on Unix a backslash is an ordinary filename byte (POSIX forbids +/// only `/` and NUL), so a single component carries the whole +/// `\`-containing filename intact. The naive `.replace('\\', "/")` alternative +/// would manufacture a path separator from an ordinary filename byte on Unix, +/// turning `sub/..\..\etc\evil.mds` into `sub/../../../etc/evil.mds` and +/// providing a directory-traversal vector (CWE-22/CWE-41). +/// +/// The directory-mode sort applies `sanitize_control_chars_wire` on top of +/// this function's output so that the sort key matches the sanitized +/// `files[].file` value emitted by `to_canonical_json` for diagnostic entries, +/// keeping array position consistent with the emitted key order (AC-P1-10). +/// 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. +/// +/// Forward slashes (`/`, 0x2F) are used instead of +/// the native backslash separator (`\`, 0x5C) because bytes in the range +/// 0x30–0x5B (digits `0`–`9`, uppercase letters `A`–`Z`, and `[`) sort between +/// `/` and `\`; on Windows a flat file like `sub[abc.mds` would sort BEFORE a +/// 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 { + let rel = path.strip_prefix(root).unwrap_or(path); + rel.components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("/") +} + // ── Read source file ────────────────────────────────────────────────────────── /// Read raw source of `path`: symlink-checked and size-capped (mirrors fmt.rs). @@ -393,6 +434,13 @@ enum FixFileOutcome { /// /// `base_dir` is the file's parent (for reverify recompile). /// +/// `display_label` is the caller-supplied display path (relative, forward-slash-normalised) +/// written into every `diag.file` in residual results via `set_diag_display_path`; it +/// becomes the `files[].file` wire key after `to_canonical_json` applies +/// `sanitize_control_chars_wire`. This is the value that appears in the JSON wire output +/// and must be pre-sanitized by the caller for error-envelope entries that bypass +/// `to_canonical_json`. +/// /// ## Reverify gate (AC-F-20) /// /// The reverify closure checks three conditions: @@ -410,6 +458,7 @@ fn plan_and_apply_fixes( base_dir: &Path, runtime_vars: Option>, config: &mds::LintConfig, + display_label: &str, ) -> FixFileOutcome { let is_standalone = result.is_standalone; let plan = mds::fix::plan_fixes_with_options(&result, source, is_standalone); @@ -485,21 +534,27 @@ fn plan_and_apply_fixes( match outcome { mds::fix::FixOutcome::Fixed { source: new_source, - residual, - } => FixFileOutcome::Fixed { - new_source, - residual, - }, + mut residual, + } => { + set_diag_display_path(&mut residual, display_label); + FixFileOutcome::Fixed { + new_source, + residual, + } + } mds::fix::FixOutcome::PartiallyFixed { source: new_source, - residual, + mut residual, rejected, - } => FixFileOutcome::PartiallyFixed { - new_source, - residual, - applied_count: total_edits - rejected.len(), - total_count: total_edits, - }, + } => { + set_diag_display_path(&mut residual, display_label); + FixFileOutcome::PartiallyFixed { + new_source, + residual, + applied_count: total_edits - rejected.len(), + total_count: total_edits, + } + } mds::fix::FixOutcome::Rejected { source: _, reason } => FixFileOutcome::Rejected { reason, original: result, @@ -637,19 +692,33 @@ fn run_lint_stdin( let mds_err = MdsError::Io { message: format!("{e}"), }; - emit_analysis_failure_json_or_stderr(&mds_err, format); + // AD-211-5: config errors (MdsError::Io) carry no embedded NamedSource, + // so the relabel is a no-op here. Passed anyway so the envelope rule holds + // for EVERY stdin failure path — a future error variant routed here that + // does carry a source inherits the sentinel instead of needing a new call. + emit_analysis_failure_json_or_stderr(&mds_err, format, Some(&source)); std::process::exit(2); } }; - let result = match mds::lint_str_with(&source, Some(&cwd), runtime_vars.clone(), &config) { + let mut result = match mds::lint_str_with(&source, Some(&cwd), runtime_vars.clone(), &config) { Ok(r) => r, Err(e) => { - emit_analysis_failure_json_or_stderr(&e, format); + // AD-211-5: relabel in the rendered failure envelope. + emit_analysis_failure_json_or_stderr(&e, format, Some(&source)); std::process::exit(mds_error_exit_code(&e)); } }; + // AD-211-4 / AD-211-1: relabel diag.file from STRING_SOURCE_MAP_LABEL → + // STDIN_DISPLAY_LABEL at the CLI output boundary. fix.rs never reads diag.file + // (verified: zero reads in fix.rs), so this relabel is safe upstream of both + // preview_fixes and plan_and_apply_fixes. This single call ensures + // "files[].file" emits "" across all code paths (AC-P1-01); for the + // write path, plan_and_apply_fixes relabels its internally produced residual + // before returning. + set_diag_display_path(&mut result, STDIN_DISPLAY_LABEL); + if fix { // ── Preview path: --fix --check and/or --fix --diff (never writes source) ─── // Mirrors run_lint_file's preview path so stdin honours --check / --diff the @@ -659,12 +728,12 @@ fn run_lint_stdin( match preview { PreviewOutcome::WouldFix(ref fixed) => { if diff { - let diff_str = render_unified_diff(&source, fixed, "stdin"); + let diff_str = render_unified_diff(&source, fixed, STDIN_DISPLAY_LABEL); let _ = write_stdout(&diff_str); } if check { if !quiet { - eprintln!("Would fix: stdin"); + eprintln!("Would fix: {STDIN_DISPLAY_LABEL}"); } std::process::exit(1); } @@ -678,8 +747,10 @@ fn run_lint_stdin( } // After diff-only preview, or when nothing would change / fix rejected: // render diagnostics of the original result and exit by severity. + // AD-211-1: pass STDIN_DISPLAY_LABEL so span context renders "", not + // the internal STRING_SOURCE_MAP_LABEL ("input.mds"). let named_source = if format == LintFormat::Human { - Some((mds::STRING_SOURCE_MAP_LABEL, source.as_str())) + Some((STDIN_DISPLAY_LABEL, source.as_str())) } else { None }; @@ -689,7 +760,14 @@ fn run_lint_stdin( } // ── Write path: apply fixes, emit fixed source to stdout ───────────────── - let fix_outcome = plan_and_apply_fixes(result, &source, &cwd, runtime_vars, &config); + let fix_outcome = plan_and_apply_fixes( + result, + &source, + &cwd, + runtime_vars, + &config, + STDIN_DISPLAY_LABEL, + ); let (output_src, diag_result) = match fix_outcome { FixFileOutcome::Fixed { new_source, @@ -703,7 +781,7 @@ fn run_lint_stdin( } => { if !quiet { eprintln!( - "Partially fixed: stdin ({applied_count} of {total_count} fixes applied)" + "Partially fixed: {STDIN_DISPLAY_LABEL} ({applied_count} of {total_count} fixes applied)" ); } (new_source, residual) @@ -715,7 +793,8 @@ fn run_lint_stdin( FixFileOutcome::NothingToFix { original } => (source, original), }; // Stdin diagnostics: pass source text for span context rendering. - let named_source = (mds::STRING_SOURCE_MAP_LABEL, output_src.as_str()); + // AD-211-1: use STDIN_DISPLAY_LABEL so source frame header reads "". + let named_source = (STDIN_DISPLAY_LABEL, output_src.as_str()); render_result_human(&diag_result, quiet, named_source); let _ = write_stdout(&output_src); exit_by_severity(&diag_result); @@ -723,8 +802,9 @@ fn run_lint_stdin( } // Report-only mode: pass stdin source for span context rendering. + // AD-211-1: use STDIN_DISPLAY_LABEL so span source frame reads "". let named_source = if format == LintFormat::Human { - Some((mds::STRING_SOURCE_MAP_LABEL, source.as_str())) + Some((STDIN_DISPLAY_LABEL, source.as_str())) } else { None }; @@ -757,7 +837,7 @@ fn run_lint_file( let mds_err = MdsError::Io { message: format!("{e}"), }; - emit_analysis_failure_json_or_stderr(&mds_err, format); + emit_analysis_failure_json_or_stderr(&mds_err, format, None); std::process::exit(2); } }; @@ -765,7 +845,7 @@ fn run_lint_file( let source = match read_source_file(path) { Ok(s) => s, Err(e) => { - emit_analysis_failure_json_or_stderr(&e, format); + emit_analysis_failure_json_or_stderr(&e, format, None); std::process::exit(mds_error_exit_code(&e)); } }; @@ -774,13 +854,22 @@ fn run_lint_file( .and_then(|n| n.to_str()) .unwrap_or(""); - let result = match mds::lint(path, runtime_vars.clone(), &config) { + let mut result = match mds::lint(path, runtime_vars.clone(), &config) { Ok(r) => r, Err(e) => { - emit_analysis_failure_json_or_stderr(&e, format); + emit_analysis_failure_json_or_stderr(&e, format, None); std::process::exit(mds_error_exit_code(&e)); } }; + // Remap the basename-only `file` label that mds::lint() sets → the display + // filename so all four FixFileOutcome arms carry a consistent label. + // Matches the explicit relabel in lint_one_file_accumulating and + // lint_one_file_human; without this call the Rejected and NothingToFix + // arms rely on mds::lint independently deriving the same basename — correct + // today, but a silent coupling that would break if the two derivations ever + // diverged. Centralising the relabel here is the explicit invariant: + // every FixFileOutcome carries `filename` as its display path. + set_diag_display_path(&mut result, filename); if result.truncated && fix { eprintln!( @@ -795,7 +884,8 @@ fn run_lint_file( // ── Write path: --fix without preview ──────────────────────────────────── if fix && !check && !diff { - let fix_outcome = plan_and_apply_fixes(result, &source, base_dir, runtime_vars, &config); + let fix_outcome = + plan_and_apply_fixes(result, &source, base_dir, runtime_vars, &config, filename); match fix_outcome { FixFileOutcome::Fixed { new_source, @@ -991,8 +1081,40 @@ fn run_lint_directory( return Ok(()); } - // F1: path-sort explicitly — collect_mds_files does NOT guarantee order. - files.sort(); + // F1: sort by (sanitized_display_key, raw_os_path) so that: + // 1. Array position is consistent with the sanitized `files[].file` key + // emitted by `to_canonical_json` for diagnostic entries (AC-P1-10). + // 2. An OsString secondary key breaks any ties when two different non-UTF-8 + // filenames produce the same `to_string_lossy` string — rare in practice, + // but ensures deterministic order regardless of readdir enumeration order. + // + // `Path::Ord` (component-wise) diverges from byte-order when a path-separator + // character appears WITHIN a filename component — e.g. `api-utils.mds` sorts + // AFTER `api/x.mds` under Path::Ord ("api" < "api-utils"), but BEFORE under + // byte-wise string order ('-' = 0x2D < '/' = 0x2F). Sorting on the relative + // display string keeps the CLI wire contract consistent with the BTreeMap + // ordering that `to_canonical_json` applies on the binding surfaces (PF-007). + // + // `relative_display` normalises to forward slashes so byte-wise order is + // identical on Unix and Windows — the sort key and the emitted JSON `file` + // key are the same String by construction, so array position matches key + // order on both platforms. `sanitize_control_chars_wire` is + // then applied so the sort key matches the emitted key produced by + // `to_canonical_json`: POSIX filenames may legally contain control bytes + // (e.g. 0x01), and sorting on the raw (unsanitized) string would place a + // control-byte filename at a position inconsistent with its `\uXXXX`-escaped + // emitted key, violating AC-P1-10. For the vast majority of paths (no control + // bytes), `sanitize_control_chars_wire` returns `Cow::Borrowed` — no extra + // heap allocation beyond the String conversion. + // + // `sort_by_cached_key` computes each key once — O(n) allocations, not O(n log n) + // (AC-P1-22). + files.sort_by_cached_key(|p| { + ( + mds::sanitize_control_chars_wire(&relative_display(p, dir)).into_owned(), + p.as_os_str().to_os_string(), + ) + }); let mut max_tally = FileTally::Clean; let mut json_files: Vec = Vec::new(); @@ -1069,11 +1191,19 @@ fn lint_one_file_accumulating( // Compute a display path relative to the lint root so JSON `file` keys // are navigable and unique across the whole directory tree (not just basenames). - let display_path = file - .strip_prefix(ctx.lint_root) - .unwrap_or(file) - .display() - .to_string(); + // `relative_display` normalises to forward slashes. `to_canonical_json` then + // sanitizes the key via `sanitize_control_chars_wire`; `run_lint_directory` + // sorts on that same sanitized string, so emitted array order and emitted file + // key order are consistent for all inputs including control-byte filenames + // (AC-P1-10). + let display_path = relative_display(file, ctx.lint_root); + // Error-only entries (`{"file": …, "error": …}`) bypass `to_canonical_json` + // and therefore bypass its `sanitize_control_chars_wire` pass. Pre-sanitize + // here so the `file` key in error entries is treated identically to the `file` + // key in diagnostic entries — hostile filenames cannot inject control, bidi, + // or separator characters into either entry type (spec.md §lint-json `file` + // contract; ADR-008). + let file_key = mds::sanitize_control_chars_wire(&display_path).into_owned(); // `source` is only consumed in the fix branch (below); the report-only/JSON // path does not need it — mds::lint() reads the file independently (I-06). @@ -1087,7 +1217,7 @@ fn lint_one_file_accumulating( Ok(c) => c, Err(ref e) => { json_files.push(serde_json::json!({ - "file": display_path, + "file": file_key, "error": e.serialize() })); return FileTally::Error; @@ -1098,7 +1228,7 @@ fn lint_one_file_accumulating( Ok(r) => r, Err(ref e) => { json_files.push(serde_json::json!({ - "file": display_path, + "file": file_key, "error": e.serialize() })); return if matches!(e, MdsError::ResourceLimit { .. }) { @@ -1132,20 +1262,25 @@ fn lint_one_file_accumulating( Err(e) => { // Per-file I/O failure in directory mode: accumulate structured error (AC-F-14). json_files.push(serde_json::json!({ - "file": display_path, + "file": file_key, "error": e.serialize() })); return FileTally::Error; } }; - let fix_outcome = - plan_and_apply_fixes(result, &source, base_dir, ctx.runtime_vars.clone(), &config); + let fix_outcome = plan_and_apply_fixes( + result, + &source, + base_dir, + ctx.runtime_vars.clone(), + &config, + &display_path, + ); match fix_outcome { FixFileOutcome::Fixed { new_source, - mut residual, + residual, } => { - set_diag_display_path(&mut residual, &display_path); 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)); @@ -1155,7 +1290,7 @@ fn lint_one_file_accumulating( } FixFileOutcome::PartiallyFixed { new_source, - mut residual, + residual, applied_count, total_count, } => { @@ -1166,7 +1301,6 @@ fn lint_one_file_accumulating( safe_path(file) ); } - set_diag_display_path(&mut residual, &display_path); 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)); @@ -1194,7 +1328,7 @@ fn lint_one_file_accumulating( Ok(s) => s, Err(e) => { json_files.push(serde_json::json!({ - "file": display_path, + "file": file_key, "error": e.serialize() })); return FileTally::Error; @@ -1244,11 +1378,10 @@ fn lint_one_file_human( } = ctx.flags; // Compute a display path relative to the lint root for human rendering. - let display_path = file - .strip_prefix(ctx.lint_root) - .unwrap_or(file) - .display() - .to_string(); + // `relative_display` normalises to forward slashes, matching the unsanitized + // base used by `run_lint_directory`'s sort (AC-P1-10). Human rendering + // shows the real filename bytes rather than sanitized `\uXXXX` escapes. + let display_path = relative_display(file, ctx.lint_root); let source = match read_source_file(file) { Ok(s) => s, @@ -1302,14 +1435,19 @@ fn lint_one_file_human( } if fix && !check && !diff { - let fix_outcome = - plan_and_apply_fixes(result, &source, base_dir, ctx.runtime_vars.clone(), &config); + let fix_outcome = plan_and_apply_fixes( + result, + &source, + base_dir, + ctx.runtime_vars.clone(), + &config, + &display_path, + ); match fix_outcome { FixFileOutcome::Fixed { new_source, - mut residual, + residual, } => { - set_diag_display_path(&mut residual, &display_path); render_result_human(&residual, quiet, named_source); if let Err(e) = atomic_write_file(file, &new_source) { eprintln!("error writing {}: {}", safe_path(file), safe_inline(&e)); @@ -1322,7 +1460,7 @@ fn lint_one_file_human( } FixFileOutcome::PartiallyFixed { new_source, - mut residual, + residual, applied_count, total_count, } => { @@ -1333,7 +1471,6 @@ fn lint_one_file_human( safe_path(file) ); } - set_diag_display_path(&mut residual, &display_path); render_result_human(&residual, quiet, named_source); if let Err(e) = atomic_write_file(file, &new_source) { eprintln!("error writing {}: {}", safe_path(file), safe_inline(&e)); @@ -1416,9 +1553,34 @@ fn emit_result( } } -/// Emit an `MdsError` analysis failure. +/// AD-211-5 (2026-08-12 ruling): this envelope is the single CLI choke-point for +/// **lint's** analysis failures (config load, IO, resolution, parse). When +/// `stdin_source` is `Some(source_text)` the embedded source identity in the rendered +/// output is replaced with [`STDIN_DISPLAY_LABEL`], so every CLI diagnostic context +/// for stdin input uses the uniform sentinel instead of the core's internal +/// `SOURCE_LABEL` (`""`) that `resolve_source_intrinsic` embeds in `MdsError` +/// spans. +/// +/// **State it as a rule about this envelope, not about stdin:** every +/// `MdsError` reaching this function for a stdin run labels its source ``. +/// Any error later routed here — a config rejection, a new IO failure — inherits +/// that label instead of inventing a second convention. +/// +/// The JSON leg needs no relabel and takes none: `MdsError::serialize()` emits +/// `code` / `message` / `help` / `span`, and no `MdsError` `Display` template +/// interpolates `ctx.file_str`, so the source identity never reaches +/// `error.message`. `cli_lint.rs::stdin_analysis_failure_labels_source_as_stdin` +/// pins that on both channels rather than leaving it as an assumption. +/// +/// For errors from a file source, pass `stdin_source: None`; the error's embedded +/// `NamedSource` (which already carries the correct filename) is used as-is. +/// /// JSON format → stdout envelope; human → stderr via miette. -fn emit_analysis_failure_json_or_stderr(e: &MdsError, format: LintFormat) { +fn emit_analysis_failure_json_or_stderr( + e: &MdsError, + format: LintFormat, + stdin_source: Option<&str>, +) { if format == LintFormat::Json { let envelope = serde_json::json!({ "version": 1, @@ -1431,7 +1593,11 @@ fn emit_analysis_failure_json_or_stderr(e: &MdsError, format: LintFormat) { } else { // Route through the single render choke point (avoids PF-004 / // architecture-6: hand-rolled sanitize_control_chars bypass). - eprint_error(miette::Report::from(e.clone())); + let report = match stdin_source { + Some(src) => relabel_stdin_error(e, src), + None => miette::Report::from(e.clone()), + }; + eprint_error(report); } } @@ -1507,4 +1673,111 @@ mod tests { not NothingToFix or WouldFix (PF-004 — preview must be as honest as apply)" ); } + + /// Regression: `relative_display` must NOT treat a literal backslash in a + /// Unix filename as a path separator. + /// + /// On Unix, POSIX forbids only `/` and NUL in filenames; `\` is an ordinary + /// byte. The old `to_string_lossy().replace('\\', "/")` implementation + /// turned `sub/..\..\etc\evil.mds` into `sub/../../../etc/evil.mds`, + /// providing a directory-traversal vector (CWE-22/CWE-41) and causing key + /// collisions on the published lint JSON wire surface when two distinct files + /// (e.g. `a/b.mds` and `a\b.mds`) were linted together. + /// + /// The new `Path::components()` join preserves `\` as a literal filename + /// byte on Unix and normalises it to a separator on Windows, which is the + /// correct platform-aware behaviour. + /// + /// Path construction uses Rust string literals containing a backslash byte + /// (0x5C) — not a control byte, so the Source hygiene gate does not flag it. + #[cfg(unix)] + #[test] + fn relative_display_preserves_literal_backslash_on_unix() { + use super::relative_display; + use std::path::Path; + + let root = Path::new("/lint-root"); + + // A real subdirectory: /lint-root/a/b.mds (two path components under root) + let real_subdir = Path::new("/lint-root/a/b.mds"); + + // A top-level file whose NAME contains a literal backslash: /lint-root/a\b.mds + // On Unix the backslash is just a filename byte; Path treats this as ONE + // component under root (not two). The string "a\\b.mds" in Rust source + // is the byte sequence a, 0x5C, b, ., m, d, s — no control bytes. + let backslash_name = Path::new("/lint-root/a\\b.mds"); + + let display_subdir = relative_display(real_subdir, root); + let display_backslash = relative_display(backslash_name, root); + + assert_eq!( + display_subdir, "a/b.mds", + "real subdirectory path should emit forward-slash-separated key" + ); + assert_eq!( + display_backslash, "a\\b.mds", + "a literal backslash filename byte must be preserved in the emitted key on Unix" + ); + assert_ne!( + display_subdir, display_backslash, + "a literal-backslash filename must not collide with the same letters \ + separated by a real slash (was broken by the old .replace() approach)" + ); + } + + /// Regression: the directory-mode sort key must be the SANITIZED display path + /// so that sort position matches the sanitized `files[].file` key emitted by + /// `to_canonical_json` for diagnostic entries, including control-byte filenames + /// (AC-P1-10). + /// + /// Without sanitization: a file whose name begins with 0x01 (a C0 control byte) + /// sorts BEFORE "P.mds" in the raw byte order (0x01 < 0x50), but its sanitized + /// emitted key starts with `\` (0x5C, the JSON-escape prefix for byte 0x01), + /// placing it AFTER "P.mds" in emitted-key order — violating AC-P1-10. + /// + /// With the sanitized sort key the two orderings agree: "P.mds" (emitted "P.mds") + /// sorts before the control-byte file (whose JSON-emitted key starts with `\`) in both + /// the array position and the emitted key comparison. + /// + /// The control byte is constructed at runtime via char::from(1u8) so that no + /// literal control byte or \uXXXX escape appears in the source file (PF-018 / + /// Source hygiene gate). + #[cfg(unix)] + #[test] + fn sort_key_sanitizes_control_byte_filenames() { + use super::relative_display; + use std::path::Path; + + let root = Path::new("/lint-root"); + + // Build a filename starting with byte 0x01 at runtime — not as a literal + // control byte in source (PF-018). + let ctrl_char = char::from(1u8); // U+0001, a C0 control character + let ctrl_filename = format!("{ctrl_char}a.mds"); + let ctrl_path_buf = root.join(&ctrl_filename); + let ctrl_path: &Path = &ctrl_path_buf; + let normal_path = Path::new("/lint-root/P.mds"); + + let ctrl_raw = relative_display(ctrl_path, root); + let normal_raw = relative_display(normal_path, root); + + // Raw (unsanitized) order: 0x01 < 'P' (0x50) → control-byte file sorts first. + assert!( + ctrl_raw < normal_raw, + "raw display: control-byte path ({ctrl_raw:?}) must sort before P.mds by \ + unsanitized byte order (confirms old sort would have been wrong)" + ); + + // Sanitized sort keys: byte 0x01 becomes a JSON escape starting with '\' (0x5C). + // 0x5C > 'P' (0x50), so P.mds sorts first — matching the emitted key order. + let ctrl_sort_key = mds::sanitize_control_chars_wire(&ctrl_raw).into_owned(); + let normal_sort_key = mds::sanitize_control_chars_wire(&normal_raw).into_owned(); + + assert!( + normal_sort_key < ctrl_sort_key, + "sanitized sort key: P.mds ({normal_sort_key:?}) must sort before \ + control-byte file ({ctrl_sort_key:?}) — matching the emitted key order \ + (AC-P1-10)" + ); + } } diff --git a/crates/mds-cli/src/main.rs b/crates/mds-cli/src/main.rs index 3a55377a..d28a39bd 100644 --- a/crates/mds-cli/src/main.rs +++ b/crates/mds-cli/src/main.rs @@ -243,6 +243,7 @@ fn run_check( quiet: bool, ) -> Result<()> { use build::read_stdin; + use output::STDIN_DISPLAY_LABEL; let runtime_vars = build_runtime_vars(RuntimeVarArgs { vars, set_vars, @@ -272,13 +273,17 @@ fn run_check( // Single-file / stdin path. if input == std::path::Path::new("-") { let (source, cwd) = read_stdin()?; + // AD-211-1 / AD-211-5: a string-source check labels its errors `` + // (resolver's SOURCE_LABEL). Relabel to the uniform CLI sentinel here, at the + // boundary that knows the input was stdin. let ((), warnings) = mds::check_str_collecting_warnings(&source, Some(&cwd), runtime_vars) - .map_err(miette::Error::from)?; + .map_err(|e| output::relabel_stdin_error(&e, &source))?; if !quiet { for w in &warnings { output::eprint_warning(w); } - eprintln!("OK: "); + // AD-211-3: one definition of the sentinel, shared with lint/build/fmt. + eprintln!("OK: {STDIN_DISPLAY_LABEL}"); } } else { let ((), warnings) = diff --git a/crates/mds-cli/src/output.rs b/crates/mds-cli/src/output.rs index 6bcd46d4..039cfd1f 100644 --- a/crates/mds-cli/src/output.rs +++ b/crates/mds-cli/src/output.rs @@ -24,6 +24,162 @@ use miette::Result; use crate::build::{MdsConfig, OutputKind}; +// ── Stdin display sentinel ──────────────────────────────────────────────────── + +/// AD-211-1 / AD-211-3: the single stdin source-identity sentinel used by every +/// CLI diagnostic context. +/// +/// Every user-visible emission of stdin's source identity — human diagnostics, JSON +/// `files[].file`, fix-preview status lines, diff headers, source-map `sources[]`, +/// and the analysis-failure envelope — uses this exact string. The remap is applied +/// at the CLI output boundary; `crates/mds-core` continues to carry `"input.mds"` +/// (STRING_SOURCE_MAP_LABEL) as the internal VFS entry key, which is NOT changed. +/// +/// Centralised here so the CLI has exactly one definition of the sentinel (AD-211-3), +/// replacing the previously scattered literals — including the hardcoded `""` +/// in `apply_source_map_file_label`, the `OK: ` status line in `main.rs`, and +/// the `fmt` stdin label. +pub(crate) const STDIN_DISPLAY_LABEL: &str = ""; + +// ── Stdin source-identity relabel (AD-211-5) ───────────────────────────────── + +/// Render-boundary wrapper that replaces the source identity embedded in an +/// [`mds::MdsError`] with [`STDIN_DISPLAY_LABEL`]. +/// +/// `resolve_source_intrinsic` sets `ctx.file_str = ""`, so every error a +/// string-source (stdin) analysis produces carries `NamedSource::new("", …)` +/// and renders as `:L:C`. Replacing it here — not in `crates/mds-core` — +/// keeps the core constant intact for the non-stdin paths that legitimately use it +/// (`resolver_tests.rs` locks `SOURCE_LABEL`) and matches the "relabel at the CLI +/// output boundary" discipline of AD-211-1. +/// +/// It also matches PF-014: the swap happens on the miette **input** (the +/// `NamedSource` handed to the renderer), never on already-rendered output. +/// +/// Delegates every `Diagnostic` method to `inner` except `source_code`, which +/// returns the pre-built replacement (or `None` when the inner error carried no +/// embedded source, so miette skips code-frame rendering rather than trying to +/// resolve spans against a source that is not there). +/// +/// # Exit codes +/// +/// This type is transparent to [`crate::build::exit_code`], which unwraps it before +/// classifying the error. A wrapped `MdsError::FileNotFound` must still exit 2, not +/// 1 — see the downcast ladder there. +pub(crate) struct StdinRelabeledError { + inner: mds::MdsError, + /// `Some(named)` when the inner error's embedded source is the stdin sentinel + /// `""` — replaced with a `NamedSource` labelled `""`. + /// + /// `None` in two cases: + /// - The inner error had no embedded source at all (e.g. `MdsError::Io`). + /// - The inner error's embedded source belongs to an **imported file** — its + /// real path must be preserved so miette renders the caret at the correct + /// location. `source_code()` delegates to `inner` in both sub-cases. + source: Option>, +} + +impl StdinRelabeledError { + /// The error this wrapper renders. Used by `exit_code` so wrapping cannot + /// change a process exit status. + pub(crate) fn inner(&self) -> &mds::MdsError { + &self.inner + } +} + +impl std::fmt::Display for StdinRelabeledError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.inner, f) + } +} + +impl std::fmt::Debug for StdinRelabeledError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(&self.inner, f) + } +} + +impl std::error::Error for StdinRelabeledError {} + +impl miette::Diagnostic for StdinRelabeledError { + fn code<'a>(&'a self) -> Option> { + miette::Diagnostic::code(&self.inner) + } + fn severity(&self) -> Option { + miette::Diagnostic::severity(&self.inner) + } + fn help<'a>(&'a self) -> Option> { + miette::Diagnostic::help(&self.inner) + } + fn url<'a>(&'a self) -> Option> { + miette::Diagnostic::url(&self.inner) + } + fn labels<'a>(&'a self) -> Option + 'a>> { + miette::Diagnostic::labels(&self.inner) + } + fn source_code(&self) -> Option<&dyn miette::SourceCode> { + match &self.source { + Some(ns) => Some(ns as &dyn miette::SourceCode), + // When `source` is None the relabel decided NOT to replace: either the + // inner error carries no source at all (MdsError::Io) or it carries a + // real imported-file source that must be preserved intact. Delegate so + // miette still renders the imported-file code frame correctly. + None => miette::Diagnostic::source_code(&self.inner), + } + } + fn related<'a>(&'a self) -> Option + 'a>> { + miette::Diagnostic::related(&self.inner) + } + fn diagnostic_source(&self) -> Option<&dyn miette::Diagnostic> { + miette::Diagnostic::diagnostic_source(&self.inner) + } +} + +/// AD-211-5: build a report whose embedded source identity reads +/// [`STDIN_DISPLAY_LABEL`] instead of the core's `""`. +/// +/// This is a **conditional** label swap: the replacement only happens when the +/// inner error's embedded `NamedSource` carries the stdin sentinel `""` +/// set by `resolve_source_intrinsic`. Errors whose embedded source belongs to an +/// **imported file** carry the real file path and are left untouched — replacing +/// them would render the caret against stdin text at the wrong location, which is +/// exactly the PF-012 in-bounds-but-wrong class this PR set out to avoid. +/// AD-211-5 only authorised relabelling stdin's OWN source identity. +/// +/// The source text used for span rendering, the message, the code, the help and +/// the labels are all untouched. `miette`'s own +/// [`miette::Report::with_source_code`] cannot do this: its `WithSourceCode` +/// wrapper returns `self.error.source_code().or(Some(&self.source_code))`, so an +/// inner diagnostic that already carries a `NamedSource` (which these do) wins +/// and the replacement is ignored. +/// +/// Call sites (verified line numbers, §5 step 1a of the implementation plan): +/// - `mds check -`: `crates/mds-cli/src/main.rs:280` +/// - `mds build -` (single-file path): `crates/mds-cli/src/build.rs:714` +/// - `mds build -` (directory stdin path): `crates/mds-cli/src/build.rs:1176` +/// - `mds lint -`: `crates/mds-cli/src/lint.rs` via +/// `emit_analysis_failure_json_or_stderr` (indirect; symbol cited instead of +/// a line number — avoids stale citations after line insertions) +/// +/// Any new CLI boundary that renders a stdin analysis failure must call this +/// function; skipping it renders `` and breaks the uniform-sentinel rule. +pub(crate) fn relabel_stdin_error(e: &mds::MdsError, source: &str) -> miette::Report { + // Only replace the embedded source when the inner error's source name is the + // stdin sentinel set by `resolve_source_intrinsic` — checked via + // `e.is_string_source()`, which keeps the sentinel comparison inside mds-core. + // Errors from imported files carry the real file path; replacing them would + // render the caret against stdin text at the wrong location (PF-012 / AD-211-5 scope). + miette::Report::new(StdinRelabeledError { + source: if e.is_string_source() { + miette::Diagnostic::source_code(e) + .map(|_| mds::named_source_for_render(STDIN_DISPLAY_LABEL, source)) + } else { + None + }, + inner: e.clone(), + }) +} + // ── Output base for directory mode ──────────────────────────────────────────── /// Describes where directory-mode output files are written. diff --git a/crates/mds-cli/tests/cli_build.rs b/crates/mds-cli/tests/cli_build.rs index cec4c650..99c16e12 100644 --- a/crates/mds-cli/tests/cli_build.rs +++ b/crates/mds-cli/tests/cli_build.rs @@ -977,6 +977,122 @@ fn vars_file_missing_exits_2_with_file_not_found() { ); } +// ── stdin exit-code preservation (AD-211-5 / StdinRelabeledError ladder) ───── + +/// `mds build -` on a stdin source whose `@import` target does not exist must +/// exit 2 (file-system error), not 1. +/// +/// Error path: the resolver returns `MdsError::FileNotFound`; `compile_to_content` +/// wraps it in `StdinRelabeledError` via `relabel_stdin_error`; `build::exit_code` +/// must unwrap the wrapper before classifying. Without the +/// `downcast_ref::().map(inner)` ladder the outer downcast +/// for `MdsError` misses and the process silently exits 1. +#[test] +fn build_stdin_missing_import_exits_2() { + let mut child = mds_bin() + .args(["build", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + + use std::io::Write; + child + .stdin + .take() + .unwrap() + .write_all(b"@import \"./nonexistent_module_xyz_12345.mds\"\nHello!\n") + .unwrap(); + + let output = child.wait_with_output().unwrap(); + assert_eq!( + output.status.code(), + Some(2), + "build stdin with missing @import must exit 2 (file-system error, not 1); got: {:?}", + output.status.code() + ); +} + +/// `mds check -` on a stdin source whose `@import` target does not exist must +/// exit 2 (file-system error), not 1. +/// +/// Same ladder gap as `build_stdin_missing_import_exits_2`: `run_check` calls +/// `relabel_stdin_error` then propagates the wrapped `StdinRelabeledError` +/// to `build::exit_code`. +#[test] +fn check_stdin_missing_import_exits_2() { + let mut child = mds_bin() + .args(["check", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + + use std::io::Write; + child + .stdin + .take() + .unwrap() + .write_all(b"@import \"./nonexistent_module_xyz_12345.mds\"\nHello!\n") + .unwrap(); + + let output = child.wait_with_output().unwrap(); + assert_eq!( + output.status.code(), + Some(2), + "check stdin with missing @import must exit 2 (file-system error, not 1); got: {:?}", + output.status.code() + ); +} + +/// `mds check -` on a stdin source that trips `MAX_TOTAL_ITERATIONS` must exit 3 +/// (resource limit), not 1. +/// +/// Error path: the evaluator returns `MdsError::ResourceLimit`; `run_check` wraps +/// it in `StdinRelabeledError`; `build::exit_code` must unwrap the wrapper to +/// reach the inner `ResourceLimit` variant and return 3. Without the ladder the +/// process exits 1. +/// +/// Source: nested `@for` loops — 1001 outer x 1000 inner = 1_001_000 total +/// iterations, which exceeds `MAX_TOTAL_ITERATIONS` (1_000_000). Both arrays are +/// declared inline in the YAML frontmatter so no `--vars` flag is needed. +#[test] +fn check_stdin_resource_limit_exits_3() { + let outer: Vec = (0..1001usize).map(|i| format!(" - {i}")).collect(); + let inner: Vec = (0..1000usize).map(|i| format!(" - {i}")).collect(); + let source = format!( + "---\nouter:\n{}\ninner:\n{}\n---\n@for o in outer:\n@for i in inner:\n@end\n@end\n", + outer.join("\n"), + inner.join("\n") + ); + + let mut child = mds_bin() + .args(["check", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + + use std::io::Write; + child + .stdin + .take() + .unwrap() + .write_all(source.as_bytes()) + .unwrap(); + + let output = child.wait_with_output().unwrap(); + assert_eq!( + output.status.code(), + Some(3), + "check stdin hitting MAX_TOTAL_ITERATIONS must exit 3 (resource limit, not 1); got: {:?}", + output.status.code() + ); +} + // ── Bare-filename regression (PF-006 / issue #11) ──────────────────────────── /// `mds watch hello.mds` from the directory containing `hello.mds` must start up, diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index 8c62f350..aaae668d 100644 --- a/crates/mds-cli/tests/cli_lint.rs +++ b/crates/mds-cli/tests/cli_lint.rs @@ -1010,6 +1010,64 @@ fn dir_fix_json_residuals_keyed_by_relative_path_not_input_mds() { } } +// ── Test (b2): --fix --format json single-file residuals keyed by basename ──── +// +// Pins that after --fix in SINGLE-FILE mode, residual diagnostics in the JSON +// output are keyed by the file's basename, NOT by "input.mds". +// +// The plan_and_apply_fixes reverify closure calls lint_str_with, which sets +// diag.file to STRING_SOURCE_MAP_LABEL ("input.mds"). Without set_diag_display_path +// in the single-file Fixed/PartiallyFixed arms, `mds lint --fix --format json ` +// emitted "input.mds" instead of the real basename. Directory mode already had the +// correct relabel (lint.rs:1176/1197); this test pins the single-file parity. +// +// Fixture: a file with duplicate-export (Tier A, auto-fixed) + unused-variable +// (Tier C, residual after fix). After --fix, the residual must appear under +// the actual filename, not "input.mds". + +#[test] +fn file_fix_json_residuals_keyed_by_filename_not_input_mds() { + let dir = tempfile::tempdir().unwrap(); + // Use the same fixture shape as dir_fix_json_residuals_keyed_by_relative_path_not_input_mds: + // duplicate-export (fixable) + unused-variable (residual after fix). + let mixed = "---\ngreeting: Hello\nunused_key: not referenced\n---\n\n\ + @define greet(name):\n Hello {{name}}!\n@end\n\n\ + @export greet\n@export greet\n"; + let target = dir.path().join("mixed.mds"); + fs::write(&target, mixed).unwrap(); + + let out = lint_path(&target, &["--fix", "--format", "json"]); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("stdout must be valid JSON; err: {e}; stdout: {stdout}; stderr: {stderr}") + }); + + // After fixing duplicate-export, unused-variable residual remains → exit 1. + assert_eq!( + out.status.code(), + Some(1), + "residual unused-variable must produce exit 1; stderr: {stderr}" + ); + + let files = json["files"].as_array().expect("must have files[]"); + assert!(!files.is_empty(), "files[] must be non-empty after fix"); + + // Every file key must be the real basename, not "input.mds". + for entry in files { + let file_key = entry["file"].as_str().unwrap_or(""); + assert!( + !file_key.contains("input.mds"), + "file key must NOT be 'input.mds'; got: {file_key}" + ); + assert_eq!( + file_key, "mixed.mds", + "file key must be the real filename basename; got: {file_key}" + ); + } +} + // ── Test (c): --fix --check on overlap-fix fixture → "Would fix" after coalescing ── // // Pins bug-5 / PF-004 fix for the check path: preview_fixes returns a @@ -1274,10 +1332,19 @@ fn stdin_lint_diagnostic_includes_code_frame() { "diagnostic must appear in stdin report-only mode; got: {stderr}" ); - // "input.mds" must appear: miette renders it as the file reference in the span header. + // "" must appear: AD-211-1 relabels the span header from the internal + // STRING_SOURCE_MAP_LABEL ("input.mds") to the uniform CLI sentinel. + assert!( + stderr.contains(""), + "stdin mode must show '' in the code frame; got: {stderr}" + ); + + // PF-013 negative half: the internal VFS key must NOT appear in the human output. + // Symmetric with the JSON sibling (stdin_json_wire_file_key_is_stdin_sentinel) which + // also asserts absence of "input.mds" alongside the presence assertion for "". assert!( - stderr.contains("input.mds"), - "stdin mode must show 'input.mds' in the code frame; got: {stderr}" + !stderr.contains("input.mds"), + "stdin human output must not expose the internal VFS key 'input.mds'; got: {stderr}" ); // At least one token from the source must appear in the code frame context. @@ -1288,6 +1355,843 @@ fn stdin_lint_diagnostic_includes_code_frame() { ); } +// ── AC-P1-01: stdin JSON wire `files[].file` emits "" ───────────────── +// +// Pins issue #211: every stdin lint path must emit `""` in the JSON +// `files[].file` key, not the internal VFS sentinel `"input.mds"`. +// +// Covers: run_lint_stdin report-only mode with --format json. + +#[test] +fn stdin_json_wire_file_key_is_stdin_sentinel() { + // Source with a known lint finding that needs no file imports so lint_str_with + // succeeds and emits a regular diagnostic JSON (not an analysis-failure envelope). + // duplicate-export fires without any resolver look-ups. + let source = "@define greet(name):\n Hello {{name}}!\n@end\n\n@export greet\n@export greet\n"; + let out = lint_stdin(source, &["--format", "json"]); + + let stdout = String::from_utf8_lossy(&out.stdout); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("stdout must be valid JSON"); + + let files = v["files"].as_array().expect("JSON must have 'files' array"); + assert!( + !files.is_empty(), + "stdin JSON must have at least one file group (duplicate-export fires); got: {stdout}" + ); + for entry in files { + let file_key = entry["file"] + .as_str() + .expect("each file entry must have a 'file' string"); + assert_eq!( + file_key, "", + "AC-P1-01: JSON files[].file must be '' for stdin input, not '{file_key}'" + ); + } + // AC-P1-01/AC-P1-27, negative half: the internal VFS key must not leak anywhere + // in the document, not just in the key this test read. + assert!( + !stdout.contains("input.mds"), + "AC-P1-27: 'input.mds' must not appear anywhere in CLI stdout for a stdin \ + lint; got: {stdout}" + ); +} + +// ── AC-P1-08/#202: JSON wire diagnostics sorted by byte offset ─────────────── +// +// Pins issue #202: within a file group, diagnostics must appear in ascending +// byte-offset order regardless of the order the rules were applied in. + +/// Fixture whose OFFSET order is the reverse of its RULE-EXECUTION order. +/// +/// `run_rules` (crates/mds-core/src/lint/mod.rs) dispatches `duplicate_export` +/// fifth and `legacy_interpolation` tenth — so without a sort, `duplicate-export` +/// (the LATER offset) is emitted first. `{name}` on line 2 is a legacy +/// single-brace interpolation at a low offset; the repeated `@export greet` at the +/// end is a duplicate export at a high offset. +/// +/// A fixture whose diagnostics are already ascending cannot detect the sort being +/// removed — the assertion would hold either way. +const OUT_OF_ORDER_FIXTURE: &str = + "@define greet(name):\n Hello {name}!\n@end\n\n@export greet\n@export greet\n"; + +fn stdin_json_diagnostics(source: &str) -> Vec { + let out = lint_stdin(source, &["--format", "json"]); + let stdout = String::from_utf8_lossy(&out.stdout); + let v: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("stdout must be JSON: {e}\n{stdout}")); + let files = v["files"].as_array().expect("JSON must have 'files' array"); + assert_eq!( + files.len(), + 1, + "stdin lint must emit exactly one file group" + ); + files[0]["diagnostics"] + .as_array() + .expect("must have 'diagnostics'") + .clone() +} + +#[test] +fn stdin_json_diagnostics_sorted_by_offset() { + let diags = stdin_json_diagnostics(OUT_OF_ORDER_FIXTURE); + + let rules: Vec<&str> = diags.iter().filter_map(|d| d["rule"].as_str()).collect(); + let offsets: Vec = diags + .iter() + .filter_map(|d| d["span"]["offset"].as_i64()) + .collect(); + + // Non-vacuity guard: this assertion is meaningless on a single diagnostic, and + // meaningless if the two land on the same offset. + assert_eq!( + offsets.len(), + diags.len(), + "every diagnostic in this fixture must carry a span; got: {diags:?}" + ); + assert!( + offsets.len() >= 2 && offsets[0] != offsets[offsets.len() - 1], + "AC-P1-08 needs at least two diagnostics at DISTINCT offsets to be a real \ + check; got rules {rules:?} at offsets {offsets:?}" + ); + + let mut sorted = offsets.clone(); + sorted.sort_unstable(); + assert_eq!( + offsets, sorted, + "AC-P1-08: diagnostics must be in ascending byte-offset order; \ + got rules {rules:?} at offsets {offsets:?}" + ); + + // The positive control: `duplicate_export` runs BEFORE `legacy_interpolation` + // in run_rules but fires at the LATER offset, so it must appear LATER in the + // array. Delete the sort in LintResultBuilder::build and this flips. + let legacy = rules + .iter() + .position(|r| *r == "legacy-interpolation") + .expect("fixture must produce a legacy-interpolation diagnostic"); + let dup = rules + .iter() + .position(|r| *r == "duplicate-export") + .expect("fixture must produce a duplicate-export diagnostic"); + assert!( + legacy < dup, + "AC-P1-08: emitted order must follow byte offset, not rule-dispatch order \ + (duplicate_export is dispatched first but fires later in the file); \ + got rules {rules:?} at offsets {offsets:?}" + ); +} + +/// AC-P1-09: the human renderer must present diagnostics in the same order as the +/// JSON renderer — proving the sort lives on `LintResult.diagnostics` and not in +/// one renderer (PF-007: a per-surface assertion could not show this). +#[test] +fn stdin_human_and_json_diagnostic_order_match() { + let json_rules: Vec = stdin_json_diagnostics(OUT_OF_ORDER_FIXTURE) + .iter() + .filter_map(|d| d["rule"].as_str().map(str::to_string)) + .collect(); + // Non-vacuity guard: two empty sequences compare equal and prove nothing. + assert!( + json_rules.len() >= 2, + "AC-P1-09 needs at least two diagnostics to compare an ORDER; got {json_rules:?}" + ); + + let out = lint_stdin(OUT_OF_ORDER_FIXTURE, &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + // Rule names appear in the miette `code` line of each rendered diagnostic. + let human_rules: Vec = stderr + .lines() + .filter_map(|line| { + let t = line.trim(); + json_rules + .iter() + .find(|r| t == format!("mds::lint::{r}") || t == **r) + .cloned() + }) + .collect(); + + assert_eq!( + human_rules, json_rules, + "AC-P1-09: human and JSON surfaces must agree on diagnostic order.\n\ + json: {json_rules:?}\nhuman: {human_rules:?}\nstderr:\n{stderr}" + ); +} + +/// AC-P1-11: repeated lints of identical input are byte-identical. +#[test] +fn stdin_json_output_is_byte_identical_across_runs() { + let first = lint_stdin(OUT_OF_ORDER_FIXTURE, &["--format", "json"]).stdout; + for run in 2..=5 { + let next = lint_stdin(OUT_OF_ORDER_FIXTURE, &["--format", "json"]).stdout; + assert_eq!( + first, + next, + "AC-P1-11: run {run} differed from run 1.\nrun1: {}\nrun{run}: {}", + String::from_utf8_lossy(&first), + String::from_utf8_lossy(&next) + ); + } +} + +// ── AC-P1-07 / AD-211-5: the analysis-failure envelope labels stdin `` ── + +/// Pull the source identity out of a miette code-frame header, e.g. the +/// `` in `[:1:1]`. +/// +/// Returning `Option` and requiring the caller to unwrap keeps this from passing +/// vacuously: a rendering that emitted no frame at all yields `None` and fails, +/// rather than silently satisfying a "does not contain ``" assertion +/// (PF-013). +fn frame_source_identity(rendered: &str) -> Option { + let start = rendered.find('[')?; + let rest = &rendered[start + 1..]; + let end = rest.find(']')?; + let inner = &rest[..end]; + // `