From 87423dd0d6c003ce99879935d8af5bc082924afb Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Thu, 13 Aug 2026 00:32:24 +0200 Subject: [PATCH 01/14] fix: remediate 13 live hazard codepoints injected by edit tooling (PF-018) Seven U+0085 (C1 NEL) bytes were injected into comment text in three test files by the edit tooling decoding backslash-u escapes to live bytes. Six U+2028/U+2029 literals in transform.spec.mjs replace live chars with String.fromCodePoint() so no hazard literal lives in tracked source. - crates/mds-cli/tests/cli_lint.rs (5 occurrences): rewrite comment prose to say U+0085 without embedding the character. Assertions unchanged. - crates/mds-napi/__test__/index.spec.mjs (1): same pattern - crates/mds-wasm/tests/web.rs (1): same pattern - packages/bundler-utils/__test__/transform.spec.mjs (6): replace live U+2028/U+2029 char literals with String.fromCodePoint(0x2028/0x2029) No logic changes; only comment text and string-construction form change. This is the S0 step that must land before the control-byte gate (PR6 S1-S6) so CI is not immediately red on the wave branch. avoids PF-018, applies D-CB4 (fix rather than allowlist) Co-Authored-By: Claude --- crates/mds-cli/tests/cli_lint.rs | 10 +++++----- crates/mds-napi/__test__/index.spec.mjs | 2 +- crates/mds-wasm/tests/web.rs | 2 +- packages/bundler-utils/__test__/transform.spec.mjs | 12 ++++++------ 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index ca302a57..a984e404 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 the 6-char literal `\u{0085}`. /// /// ## 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 → `\u{0085}` (6-char ASCII literal); if that /// sanitization is removed the raw 0xC2 0x85 bytes appear in the JSON wire. /// /// ## Failure mode (regression guard) @@ -1760,7 +1760,7 @@ 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: `\u{0085}` 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,12 +1834,12 @@ 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 literal `\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 `…` + // After JSON deserialisation by serde_json the string value is `\u{0085}` // (6 chars: backslash, u, 0, 0, 8, 5). let has_sanitized_nel = all_diags.iter().any(|d| { d["message"] diff --git a/crates/mds-napi/__test__/index.spec.mjs b/crates/mds-napi/__test__/index.spec.mjs index 21230f21..368a286c 100644 --- a/crates/mds-napi/__test__/index.spec.mjs +++ b/crates/mds-napi/__test__/index.spec.mjs @@ -1323,7 +1323,7 @@ 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 \u{0085} 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/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 { From 2ed7de685a8d6aed041788a755391d95fcc10455 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Thu, 13 Aug 2026 00:32:53 +0200 Subject: [PATCH 02/14] feat: add Code of Conduct, control-byte gate, and pre-merge check verifier (#38, #288, #289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #38 — CODE_OF_CONDUCT.md (Contributor Covenant 2.1): - Added at repo root with deanshrn@gmail.com as enforcement contact - Linked from CONTRIBUTING.md and README.md - Removed satisfied prerequisite from RELEASING.md one-time-prerequisites list - Upstream fixture committed for offline diff verification (D-COC2) #288 — scripts/verify-no-control-bytes.mjs: - Pure Node codepoint scanner; no grep (BSD grep lacks -P, exits 2 silently) - Hazard class: C0 excl. TAB/LF, DEL, C1 at codepoint level (catches U+0085 as 0xC2 0x85), 12 Bidi_Control codepoints (Trojan Source / CVE-2021-42574), U+2028, U+2029, U+FEFF — derived from common/mod.rs with CR/CRLF exception - D-CB5: zero-files-scanned is exit 1, not exit 0 (non-vacuity) - D-CB8: --staged mode reads git index via git cat-file blob, never working tree - D-CB6: two empty allowlists (BINARY_ALLOWLIST, HAZARD_ALLOWLIST); stale entries are self-invalidating (exit 1) - CI: source-hygiene job in ci.yml (pull_request); step in release.yml version-gate (tag pushes); opt-in pre-commit hook at scripts/hooks/pre-commit - 51-test suite covering all ACs including positive controls (ADR-009/PF-013), golden class set (D-CB1a), and staged-vs-working-tree isolation (AC-18) #289 — scripts/verify-pr-checks.mjs: - D-PR1: pure evaluateChecks() function; gh runner injected for offline tests - D-PR2: required contexts read live from branch protection (exit 2 on 404/403); --required-from flag for unprotected wave-branch base - D-PR2a: required contexts resolved against union of check-runs AND statuses - D-PR3: Tier A (required, must be completed+success), Tier B (non-required, failure/cancelled/stale = FAIL), Tier C (legacy statuses, advisory) - D-PR4: zero check-runs = exit 1 (the #239 shape — not a pass) - D-PR4a: filter=latest pinned; pagination bounded at MAX_PAGES=20, exit 2 - D-PR5: PASS emits gh pr merge --squash --match-head-commit - D-PR6: exit 0 PASS, 1 FAIL, 2 indeterminate; never 0 for "cannot tell" - Fixtures from live API: 113f472 (18 checks, PASS), f168944/#239 (0 checks, FAIL), e9dace1/#240 (0 checks + Snyk error, FAIL) - Partial case: 17 of 18 required contexts present → FAIL naming the absent one applies ADR-009, avoids PF-013, avoids PF-016, avoids PF-017, avoids PF-018 Co-Authored-By: Claude --- .github/PULL_REQUEST_TEMPLATE.md | 4 + .github/workflows/ci.yml | 23 + .github/workflows/release.yml | 5 + CHANGELOG.md | 25 + CLAUDE.md | 2 + CODE_OF_CONDUCT.md | 84 +++ CONTRIBUTING.md | 59 +++ README.md | 3 +- RELEASING.md | 6 +- .../fixtures/checks-main-113f472.json | 1 + .../fixtures/checks-pr239-f168944.json | 1 + .../fixtures/checks-pr240-e9dace1.json | 1 + .../fixtures/contributor-covenant-2.1.md | 84 +++ .../__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 | 500 ++++++++++++++++++ scripts/__test__/verify-pr-checks.spec.mjs | 359 +++++++++++++ scripts/hooks/pre-commit | 32 ++ scripts/verify-no-control-bytes.mjs | 486 +++++++++++++++++ scripts/verify-pr-checks.mjs | 422 +++++++++++++++ 21 files changed, 2098 insertions(+), 2 deletions(-) create mode 100644 CODE_OF_CONDUCT.md 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..456b6867 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__/verify-no-control-bytes.spec.mjs scripts/__test__/verify-pr-checks.spec.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e389c2cf..72b98681 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,11 @@ 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 this step ensures the gate is + # enforced at release time. Uses the same Node 22 install above. + - name: "Assert no hazardous codepoints in tracked source" + run: node scripts/verify-no-control-bytes.mjs # --------------------------------------------------------------------------- # A6 — cross-compile the native addon for all 7 targets. diff --git a/CHANGELOG.md b/CHANGELOG.md index 66f954a3..05ebef74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **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 reads required contexts from live branch protection, asserts each + is `status=completed` AND `conclusion=success`, and emits a `gh pr merge + --squash --match-head-commit ` command pinned to the verified SHA. On + success, exit 0; on any required context missing or non-success, exit 1; on + tool/permission errors, exit 2. + ### **BREAKING** — Interpolation syntax: `{x}` → `{{x}}` Interpolation now uses **double braces**: `{{variable}}`, `{{obj.field}}`, 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..9cc5fb57 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,84 @@ +# 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..c618a55a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,6 +62,38 @@ 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) +``` + +**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 (Trojan Source, CVE-2021-42574) +including U+061C, 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 +105,35 @@ 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). + +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..317ce8c7 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,11 @@ 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 +# 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 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..6cffd884 --- /dev/null +++ b/scripts/__test__/fixtures/contributor-covenant-2.1.md @@ -0,0 +1,84 @@ +# 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..88de2929 --- /dev/null +++ b/scripts/__test__/verify-no-control-bytes.spec.mjs @@ -0,0 +1,500 @@ +/** + * 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, mkdirSync, rmSync, readFileSync } 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, + 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('mutation check: removing C1 range (U+0085) would fail the test above', () => { + // D-CB1a: Prove the golden-set test is non-vacuous. + // This test verifies that U+0085 (C1 NEL, the case the baseline missed) IS detected. + // The case that triggered PF-018 three times in this repo. + const nel = 0x85; // U+0085 — C1 NEL; written as hex, not \u escape (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'); + + // Also 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: scanner exits 0 on real repo tree with >= 500 files and >= 4MB', () => { + const r = runScanner([], { cwd: ROOT }); + assert.equal(r.status, 0, `scanner must exit 0 on clean repo tree; stderr: ${r.stderr}`); + // 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); } + }); + +}); + +// --------------------------------------------------------------------------- +// 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 2', () => { + const dir = mkdtempSync(join(tmpdir(), 'mds-nogit-')); + try { + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 2, 'non-git directory must exit 2'); + } 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 grep -P +// --------------------------------------------------------------------------- +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 contains no grep -P invocation', () => { + const src = readFileSync(join(ROOT, 'scripts/verify-no-control-bytes.mjs'), 'utf8'); + assert.ok(!src.includes('grep -P'), 'scanner must not use grep -P (BSD grep lacks -P, exits 2)'); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-17: stale allowlist entries exit 1 +// --------------------------------------------------------------------------- +describe('AC-17: stale allowlist entries are self-invalidating', () => { + + // These tests verify the allowlist behavior using the exported constants. + // The HAZARD_ALLOWLIST is currently empty — an empty allowlist is always valid. + + 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)'); + }); + +}); diff --git a/scripts/__test__/verify-pr-checks.spec.mjs b/scripts/__test__/verify-pr-checks.spec.mjs new file mode 100644 index 00000000..b66d2d5f --- /dev/null +++ b/scripts/__test__/verify-pr-checks.spec.mjs @@ -0,0 +1,359 @@ +/** + * 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, statSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { evaluateChecks } 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'); + const statuses = loadStatuses('status-pr239-f168944.json'); // empty statuses + 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); + // Non-vacuity guard fires: zero check-runs → FAIL immediately + const allLines = result.lines.join('\n'); + assert.ok(allLines.includes('zero check-runs'), `must mention zero check-runs; got: ${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-28: Pagination bounded (tested via the max-page logic in the verifier) +// --------------------------------------------------------------------------- +describe('AC-28: pagination is bounded', () => { + // The pagination logic is in the live path (main()), not evaluateChecks. + // We verify the contract constant is defined at a sane value. + test('MAX_PAGES constant is bounded (not unbounded while-true)', async () => { + // Import the module to check the constant is exported or used + // The MAX_PAGES is defined in the module; the test verifies the concept. + // Since it's a module-internal constant, we verify the pagination logic + // exits 2 by examining the source text. + const src = readFileSync(join(ROOT, 'scripts/verify-pr-checks.mjs'), 'utf8'); + assert.ok(src.includes('MAX_PAGES'), 'verify-pr-checks.mjs must define MAX_PAGES'); + assert.ok(src.includes('process.exit(2)'), 'must call process.exit(2) on page cap'); + // Verify it's used in a conditional: `page > MAX_PAGES` or similar + assert.ok(src.includes('MAX_PAGES') && src.includes('exit(2)'), + 'pagination must be bounded with exit 2 on overflow'); + }); +}); + +// --------------------------------------------------------------------------- +// AC-29: Unprotected base branch exits 2 (tested via the module source) +// --------------------------------------------------------------------------- +describe('AC-29: unprotected base branch exits 2', () => { + test('404 protection endpoint → handled as exit 2 (not exit 0)', () => { + // The fetchRequiredContexts function in the live path handles 404 by + // calling process.exit(2). Verify the source has this logic. + const src = readFileSync(join(ROOT, 'scripts/verify-pr-checks.mjs'), 'utf8'); + assert.ok(src.includes('404'), 'must handle 404 protection response'); + assert.ok( + src.includes('process.exit(2)'), + 'must exit 2 on unprotected base (never 0)' + ); + }); +}); + +// --------------------------------------------------------------------------- +// 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)'); + }); +}); + +// --------------------------------------------------------------------------- +// Code of Conduct fixture verification (AC-1, AC-2) +// --------------------------------------------------------------------------- +describe('AC-1 AC-2: Code of Conduct verification', () => { + test('fixture sha256 and size are recorded', () => { + // The fixture records the upstream CC 2.1 text. + // sha256 and size are the provenance record for offline verification. + 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; + // Record actual sha256 of the committed fixture: + // (fetched from contributor-covenant.org at planning time; exact byte-match + // may vary by LF vs CRLF and trailing newlines — functional check below is authoritative) + assert.ok(hash.length === 64, `sha256 must be 64 hex chars; got: ${hash.length}`); + assert.ok(size > 5000 && size < 6000, `fixture size ${size} bytes should be ~5400-5500 bytes`); + }); + + 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/hooks/pre-commit b/scripts/hooks/pre-commit new file mode 100755 index 00000000..3f38b81c --- /dev/null +++ b/scripts/hooks/pre-commit @@ -0,0 +1,32 @@ +#!/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; fix the file before staging). + +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 — skipping" >&2 + exit 0 +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..57952127 --- /dev/null +++ b/scripts/verify-no-control-bytes.mjs @@ -0,0 +1,486 @@ +#!/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. Zero-files-scanned is exit 1, not exit 0 (avoids + * PF-016 — an empty scan masquerades as clean). + * + * D-CB8: --staged mode reads file content from the git index (git cat-file + * blob :), 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, or zero files scanned, or stale/unmatched allowlist entry + * 2 — tool error: git missing, not a repo, non-UTF-8, pagination error + */ +'use strict'; + +import { execFileSync, spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +// --------------------------------------------------------------------------- +// 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++) { + hex.push((i === offset ? '[' : '') + buf[i].toString(16).padStart(2, '0') + (i === offset ? ']' : '')); + } + return hex.join(' '); +} + +// --------------------------------------------------------------------------- +// UTF-8 decoder (returns array of {cp, byteOffset} objects) +// --------------------------------------------------------------------------- + +/** + * Decode a UTF-8 buffer into an array of {cp, byteOffset}. + * Returns null if the buffer is not valid UTF-8 (after NUL check). + * @param {Buffer} buf + * @returns {{ cp: number, byteOffset: number }[] | null} + */ +function decodeUtf8(buf) { + const codepoints = []; + 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) return null; + const b1 = buf[i + 1]; + if ((b1 & 0xc0) !== 0x80) return null; + cp = ((b0 & 0x1f) << 6) | (b1 & 0x3f); + len = 2; + } else if ((b0 & 0xf0) === 0xe0) { + if (i + 2 >= buf.length) return null; + const b1 = buf[i + 1]; + const b2 = buf[i + 2]; + if ((b1 & 0xc0) !== 0x80 || (b2 & 0xc0) !== 0x80) return null; + cp = ((b0 & 0x0f) << 12) | ((b1 & 0x3f) << 6) | (b2 & 0x3f); + len = 3; + } else if ((b0 & 0xf8) === 0xf0) { + if (i + 3 >= buf.length) return null; + const b1 = buf[i + 1]; + const b2 = buf[i + 2]; + const b3 = buf[i + 3]; + if ((b1 & 0xc0) !== 0x80 || (b2 & 0xc0) !== 0x80 || (b3 & 0xc0) !== 0x80) return null; + cp = ((b0 & 0x07) << 18) | ((b1 & 0x3f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f); + len = 4; + } else { + return null; // Invalid lead byte + } + codepoints.push({ cp, byteOffset: i }); + i += len; + } + return codepoints; +} + +// --------------------------------------------------------------------------- +// 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') { + console.error('✖ verify-no-control-bytes: git is not on PATH'); + process.exit(2); + } + 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 2 if not). */ +function assertGitRepo(cwd) { + const r = gitExec(['rev-parse', '--is-inside-work-tree'], cwd); + if (r.status !== 0) { + console.error('✖ verify-no-control-bytes: not inside a git work tree'); + process.exit(2); + } +} + +/** + * 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 read from git index via `git cat-file blob :`. + */ +function getStagedFiles(cwd) { + const r = gitExec(['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMR'], cwd); + if (r.status !== 0) { + // No staged files is not an error in --staged mode + return []; + } + 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 file content from the git index (staged blob) via `git cat-file blob :`. + * D-CB8: never reads from the working tree in --staged mode. + */ +function readIndexBlob(path, cwd) { + const r = gitExec(['cat-file', 'blob', `:${path}`], cwd); + if (r.status !== 0) { + console.error(`✖ verify-no-control-bytes: cannot read staged blob for ${path}`); + process.exit(2); + } + return r.stdout; // Buffer +} + +// --------------------------------------------------------------------------- +// Scanner core +// --------------------------------------------------------------------------- + +/** + * Scan a single file buffer for hazardous codepoints. + * @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 }[]} — list of hazard hits + */ +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 + } + + const codepoints = decodeUtf8(buf); + if (codepoints === null) { + return [{ codepoint: -1, byteOffset: 0, invalidUtf8: true }]; + } + + const hits = []; + for (let i = 0; i < codepoints.length; i++) { + const { cp, byteOffset } = codepoints[i]; + const nextCp = i + 1 < codepoints.length ? codepoints[i + 1].cp : null; + if (isHazardous(cp, nextCp)) { + if (!allowedCps.has(cp)) { + hits.push({ codepoint: cp, byteOffset }); + } + } + } + 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 ---- + if (fileEntries.length === 0 && !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); + } + + // ---- 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); + } + } + + // ---- Scan each file ---- + let totalBytes = 0; + let scannedFiles = 0; + const exercisedAllowlist = new Set(); // tracks which allowlist entries are hit + const hazardHits = []; // { path, codepoint, byteOffset, buf } + + for (const entry of fileEntries) { + let buf; + try { + if (isStaged || entry.staged) { + buf = readIndexBlob(entry.path, cwd); + } 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 { + hazardHits.push({ path: entry.path, codepoint: hit.codepoint, byteOffset: hit.byteOffset, buf }); + // Track exercised allowlist entries + if (allowedCps.has(hit.codepoint)) { + exercisedAllowlist.add(`${entry.path}:${hit.codepoint}`); + } + } + } + } + + // ---- 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` : ''); + + if (exercisedAllowlist.size > 0 && HAZARD_ALLOWLIST.length > 0) { + for (const entry of HAZARD_ALLOWLIST) { + console.log(` allowlist: ${entry.path} (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')}`; + const ctx = hexContext(hit.buf, hit.byteOffset); + console.error(`✖ ${hit.path}: hazardous codepoint ${cpHex} at byte offset ${hit.byteOffset}`); + console.error(` context: ${ctx}`); + } + console.error(`✖ source-hygiene gate FAILED — ${passStats}`); + process.exit(1); + } + + console.log(`✓ source-hygiene gate: ${passStats}`); + if (HAZARD_ALLOWLIST.length > 0) { + console.log(` (${HAZARD_ALLOWLIST.length} allowlist entr${HAZARD_ALLOWLIST.length === 1 ? 'y' : 'ies'} exercised)`); + } + process.exit(0); +} + +// Run only when executed directly (not imported by tests) +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/scripts/verify-pr-checks.mjs b/scripts/verify-pr-checks.mjs new file mode 100644 index 00000000..2cb919a8 --- /dev/null +++ b/scripts/verify-pr-checks.mjs @@ -0,0 +1,422 @@ +#!/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. + * + * 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 + * node scripts/verify-pr-checks.mjs --head-sha + * + * 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'; + +// 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) { + if (r.error.code === 'ENOENT') { + console.error('✖ verify-pr-checks: gh is not on PATH'); + process.exit(2); + } + console.error(`✖ verify-pr-checks: gh error: ${r.error.message}`); + process.exit(2); + } + 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 — 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)'); + 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 + const checkByName = new Map(); // name -> CheckRun (latest, filter=latest already applied) + for (const cr of checkRuns) { + checkByName.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 cr = checkByName.get(ctx); + const st = statusByContext.get(ctx); + + if (cr) { + // Found in check-runs namespace + if (cr.status !== 'completed' || cr.conclusion !== 'success') { + failures.push( + `Tier A (required): "${ctx}" — status=${cr.status}, conclusion=${cr.conclusion ?? 'null'}` + + ` (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) +// --------------------------------------------------------------------------- + +function checkGhVersion(runner) { + const r = runner(['--version']); + if (r.__error) { + console.error('✖ verify-pr-checks: cannot determine gh version'); + process.exit(2); + } + // gh --version returns an object from gh api; for --version we call gh directly + return; +} + +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. + */ +function fetchCheckRuns(headSha, runner) { + // Use gh api with --paginate to collect all pages + // gh api --paginate emits one JSON object per page (not merged) + const perPage = 100; + let page = 1; + const allCheckRuns = []; + let totalCount = null; + + 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) { + console.error(`✖ verify-pr-checks: check-runs API error (page ${page}): ${data.stderr}`); + process.exit(2); + } + 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) { + console.error(`✖ verify-pr-checks: pagination exceeded ${MAX_PAGES} pages; exiting 2 (D-PR4a)`); + process.exit(2); + } + + // D-PR4a: assert we collected everything declared by total_count + if (totalCount !== null && allCheckRuns.length !== totalCount) { + console.error( + `✖ verify-pr-checks: collected ${allCheckRuns.length} check-runs but total_count=${totalCount}; exiting 2`, + ); + process.exit(2); + } + + return allCheckRuns; +} + +/** + * Fetch commit statuses for a sha. + */ +function fetchStatuses(headSha, runner) { + const url = `/repos/{owner}/{repo}/commits/${headSha}/status`; + const data = runner(['api', url]); + if (data.__error) { + console.error(`✖ verify-pr-checks: commit-status API error: ${data.stderr}`); + process.exit(2); + } + return data.statuses ?? []; +} + +/** + * Fetch required contexts from branch protection. + * D-PR2: exits 2 on 403 or when no protection and no fallback. + * AC-29: unprotected base (404) exits 2 unless --required-from is given. + */ +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) { + if (requiredFrom) { + console.error( + `✖ verify-pr-checks: --required-from branch "${requiredFrom}" has no protection (404); exit 2`, + ); + } else { + console.error( + `✖ verify-pr-checks: base branch "${baseBranch}" has no protection (404). ` + + `Use --required-from to specify a protected branch, e.g. --required-from main. ` + + `(AC-29: unprotected base is not a pass — D-PR2)` + ); + } + process.exit(2); + } + if (data.status === 403) { + console.error( + `✖ verify-pr-checks: branch protection unreadable (403 — insufficient permissions); exit 2`, + ); + process.exit(2); + } + console.error(`✖ verify-pr-checks: protection API error: ${data.stderr}`); + process.exit(2); + } + + const contexts = data?.required_status_checks?.contexts ?? []; + if (requiredFrom && requiredFrom !== baseBranch) { + console.log(` Required contexts read from: ${requiredFrom} (base branch "${baseBranch}" is unprotected)`); + } + return { contexts, resolvedBranch: branch }; +} + +export async function main(argv = process.argv.slice(2), runner = defaultGhRunner) { + // ---- Parse args ---- + const prArg = argv.find(a => /^\d+$/.test(a)); + if (!prArg) { + console.error('Usage: node scripts/verify-pr-checks.mjs [--required-from ] [--head-sha ]'); + process.exit(2); + } + const prNumber = parseInt(prArg, 10); + + const rfIdx = argv.indexOf('--required-from'); + const requiredFrom = rfIdx !== -1 ? argv[rfIdx + 1] : null; + + const hsIdx = argv.indexOf('--head-sha'); + const headShaOverride = hsIdx !== -1 ? argv[hsIdx + 1] : null; + + // ---- Check gh version (D-PR5) ---- + const ver = ghVersion(); + 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'; + console.error( + `✖ verify-pr-checks: gh >= ${MIN_GH_MAJOR}.${MIN_GH_MINOR} required (found ${found}); ` + + `needed for --match-head-commit (D-PR5)` + ); + process.exit(2); + } + + // ---- Fetch PR metadata ---- + const prData = runner(['api', `/repos/{owner}/{repo}/pulls/${prNumber}`]); + if (prData.__error) { + console.error(`✖ verify-pr-checks: cannot read PR ${prNumber}: ${prData.stderr}`); + process.exit(2); + } + const headSha = headShaOverride ?? prData.head?.sha; + const baseBranch = prData.base?.ref; + if (!headSha || !baseBranch) { + console.error(`✖ verify-pr-checks: cannot determine head SHA or base branch for PR ${prNumber}`); + process.exit(2); + } + + console.log(`PR #${prNumber}: base=${baseBranch} head=${headSha.slice(0, 7)}`); + + // ---- Fetch required contexts (D-PR2) ---- + const { contexts: requiredContexts, resolvedBranch } = fetchRequiredContexts(baseBranch, requiredFrom, runner); + console.log(` Required contexts (${requiredContexts.length}) from ${resolvedBranch}: ${requiredContexts.join(', ') || '(none)'}`); + + // ---- Fetch check-runs (D-PR4a) ---- + const checkRuns = fetchCheckRuns(headSha, runner); + + // ---- Fetch commit statuses (D-PR2a) ---- + const statuses = fetchStatuses(headSha, runner); + + // ---- Evaluate (D-PR1: pure function) ---- + const result = evaluateChecks({ requiredContexts, checkRuns, statuses, headSha }); + + for (const line of result.lines) { + console.log(line); + } + + process.exit(result.exitCode); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} From 5b3080de04e891034c9df98d6bcdba07b2be77b8 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Thu, 13 Aug 2026 00:41:08 +0200 Subject: [PATCH 03/14] =?UTF-8?q?chore:=20simplify=20PR6=20scripts=20?= =?UTF-8?q?=E2=80=94=20remove=20dead=20code=20and=20unused=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verify-no-control-bytes.mjs: drop unused execFileSync/join imports; simplify hexContext double-ternary to template literal; drop redundant entry.staged in read-mode branch (isStaged already covers all staged entries set by getStagedFiles) - verify-pr-checks.mjs: remove dead checkGhVersion function (never called; ghVersion() is the real path); remove spurious async from main() (contains no await) - verify-no-control-bytes.spec.mjs: drop unused mkdirSync import - verify-pr-checks.spec.mjs: drop unused dirname import; remove unused loadStatuses call in 113f472 baseline test (result was loaded but [] was passed to evaluateChecks) All 51 tests pass; scanner passes its own gate. --- scripts/__test__/verify-no-control-bytes.spec.mjs | 2 +- scripts/__test__/verify-pr-checks.spec.mjs | 3 +-- scripts/verify-no-control-bytes.mjs | 9 +++++---- scripts/verify-pr-checks.mjs | 12 +----------- 4 files changed, 8 insertions(+), 18 deletions(-) diff --git a/scripts/__test__/verify-no-control-bytes.spec.mjs b/scripts/__test__/verify-no-control-bytes.spec.mjs index 88de2929..96be02e7 100644 --- a/scripts/__test__/verify-no-control-bytes.spec.mjs +++ b/scripts/__test__/verify-no-control-bytes.spec.mjs @@ -13,7 +13,7 @@ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'node:fs'; +import { mkdtempSync, writeFileSync, rmSync, readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync, execFileSync } from 'node:child_process'; diff --git a/scripts/__test__/verify-pr-checks.spec.mjs b/scripts/__test__/verify-pr-checks.spec.mjs index b66d2d5f..3afa1be7 100644 --- a/scripts/__test__/verify-pr-checks.spec.mjs +++ b/scripts/__test__/verify-pr-checks.spec.mjs @@ -14,7 +14,7 @@ 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, dirname } from 'node:path'; +import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { evaluateChecks } from '../verify-pr-checks.mjs'; @@ -60,7 +60,6 @@ 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'); - const statuses = loadStatuses('status-pr239-f168944.json'); // empty statuses 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')}`); diff --git a/scripts/verify-no-control-bytes.mjs b/scripts/verify-no-control-bytes.mjs index 57952127..79e1a354 100644 --- a/scripts/verify-no-control-bytes.mjs +++ b/scripts/verify-no-control-bytes.mjs @@ -40,9 +40,9 @@ */ 'use strict'; -import { execFileSync, spawnSync } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; -import { join, resolve, dirname } from 'node:path'; +import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -148,7 +148,8 @@ function hexContext(buf, offset) { const end = Math.min(buf.length, offset + 10); const hex = []; for (let i = start; i < end; i++) { - hex.push((i === offset ? '[' : '') + buf[i].toString(16).padStart(2, '0') + (i === offset ? ']' : '')); + const byte = buf[i].toString(16).padStart(2, '0'); + hex.push(i === offset ? `[${byte}]` : byte); } return hex.join(' '); } @@ -396,7 +397,7 @@ function main() { for (const entry of fileEntries) { let buf; try { - if (isStaged || entry.staged) { + if (isStaged) { buf = readIndexBlob(entry.path, cwd); } else { buf = readFileSync(entry.absolutePath || resolve(cwd, entry.path)); diff --git a/scripts/verify-pr-checks.mjs b/scripts/verify-pr-checks.mjs index 2cb919a8..c131c7c4 100644 --- a/scripts/verify-pr-checks.mjs +++ b/scripts/verify-pr-checks.mjs @@ -237,16 +237,6 @@ export function evaluateChecks({ requiredContexts, checkRuns, statuses, headSha // Main (live path with real gh API calls) // --------------------------------------------------------------------------- -function checkGhVersion(runner) { - const r = runner(['--version']); - if (r.__error) { - console.error('✖ verify-pr-checks: cannot determine gh version'); - process.exit(2); - } - // gh --version returns an object from gh api; for --version we call gh directly - return; -} - function ghVersion() { const r = spawnSync('gh', ['--version'], { encoding: 'utf8' }); if (r.error || r.status !== 0) return null; @@ -356,7 +346,7 @@ function fetchRequiredContexts(baseBranch, requiredFrom, runner) { return { contexts, resolvedBranch: branch }; } -export async function main(argv = process.argv.slice(2), runner = defaultGhRunner) { +export function main(argv = process.argv.slice(2), runner = defaultGhRunner) { // ---- Parse args ---- const prArg = argv.find(a => /^\d+$/.test(a)); if (!prArg) { From 055ad1bf1b52c36799e96207768a0c1201a6a884 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Thu, 13 Aug 2026 00:59:13 +0200 Subject: [PATCH 04/14] fix: address self-review issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scanner + verifier (#288, #289): - Entry-point guard no longer compares import.meta.url to a hand-built file:// string. That comparison is false for any path with a space and for any symlinked path (macOS /tmp, /var/folders), so main() never ran and both tools exited 0 having scanned/verified nothing — a silent pass. Now compared by realpath, with a regression test that runs each script from a spaced path. - evaluateChecks now exits 2 (indeterminate) instead of 0 when the required context set is empty; fetchRequiredContexts exits 2 when a protected branch lists zero required checks, and reads the UNION of contexts[] and checks[]. 'All 0 required contexts passed' was a vacuous green (applies ADR-009). - A required context is now evaluated across EVERY check-run sharing its name; previously a later success masked an earlier failure. - HAZARD_ALLOWLIST entries can now be exercised: scanBuffer reports allowlisted hits so the caller records them, instead of every valid entry reading stale. - --staged mode exits 2 when 'git diff --cached' fails instead of treating the failure as 'nothing staged' and passing the pre-commit hook. Tests: - Replaced the AC-28/AC-29 source-text greps (asserting a file contains the string 'process.exit(2)' proves nothing about reachability) with 16 tests that drive main() through an injected gh runner: 404, 403, stale gh, page cap, truncated page set, empty required set, API error, call budget, filter=latest. - Added AC-17 allowlist cases, AC-20 symlink skip, AC-16 git-not-on-PATH, and entry-point regressions. Mutation-checked: reverting each fix fails tests. - CoC fixture provenance is pinned to a measured sha256 + exact byte count; 'hash.length === 64' is true of every sha256 and asserted nothing. Fixture normalized to be byte-identical to the upstream 2.1 body. Docs: corrected the U+0085 remediation comments, which described the Rust escape form while the assertions check the 6-character JSON escape. --- CODE_OF_CONDUCT.md | 1 - CONTRIBUTING.md | 10 + crates/mds-cli/tests/cli_lint.rs | 13 +- crates/mds-napi/__test__/index.spec.mjs | 3 +- .../fixtures/contributor-covenant-2.1.md | 1 - .../__test__/verify-no-control-bytes.spec.mjs | 164 +++++++++- scripts/__test__/verify-pr-checks.spec.mjs | 307 ++++++++++++++++-- scripts/verify-no-control-bytes.mjs | 86 +++-- scripts/verify-pr-checks.mjs | 282 +++++++++++----- 9 files changed, 713 insertions(+), 154 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 9cc5fb57..c3c37718 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -81,4 +81,3 @@ For answers to common questions about this code of conduct, see the FAQ at [http [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 c618a55a..c793da03 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -121,6 +121,16 @@ 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` verified, `1` a required context is missing or +not successful, `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. + +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. + If the base branch is unprotected (e.g. a wave branch), supply `--required-from`: ```bash diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index a984e404..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 `\u{0085}`. +/// 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 → `\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: `\u{0085}` 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 `\u{0085}` + // 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 `\u{0085}` - // (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 368a286c..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 \u{0085} 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/scripts/__test__/fixtures/contributor-covenant-2.1.md b/scripts/__test__/fixtures/contributor-covenant-2.1.md index 6cffd884..737de08a 100644 --- a/scripts/__test__/fixtures/contributor-covenant-2.1.md +++ b/scripts/__test__/fixtures/contributor-covenant-2.1.md @@ -81,4 +81,3 @@ For answers to common questions about this code of conduct, see the FAQ at [http [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__/verify-no-control-bytes.spec.mjs b/scripts/__test__/verify-no-control-bytes.spec.mjs index 96be02e7..7cf1fc98 100644 --- a/scripts/__test__/verify-no-control-bytes.spec.mjs +++ b/scripts/__test__/verify-no-control-bytes.spec.mjs @@ -13,7 +13,7 @@ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, writeFileSync, rmSync, readFileSync } from 'node:fs'; +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'; @@ -22,6 +22,7 @@ import { fileURLToPath } from 'node:url'; import { HAZARD_RANGES, isHazardous, + scanBuffer, BINARY_ALLOWLIST, HAZARD_ALLOWLIST, } from '../verify-no-control-bytes.mjs'; @@ -482,12 +483,9 @@ describe('AC-15: scanner source is self-clean', () => { }); // --------------------------------------------------------------------------- -// AC-17: stale allowlist entries exit 1 +// AC-17: allowlist entries are exercised-or-stale // --------------------------------------------------------------------------- -describe('AC-17: stale allowlist entries are self-invalidating', () => { - - // These tests verify the allowlist behavior using the exported constants. - // The HAZARD_ALLOWLIST is currently empty — an empty allowlist is always valid. +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)'); @@ -497,4 +495,158 @@ describe('AC-17: stale allowlist entries are self-invalidating', () => { 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 2 +// --------------------------------------------------------------------------- +describe('AC-16: git not on PATH', () => { + + test('scanner exits 2 when git cannot be found', () => { + // 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, 2, `missing git must exit 2; 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 index 3afa1be7..d084ff9c 100644 --- a/scripts/__test__/verify-pr-checks.spec.mjs +++ b/scripts/__test__/verify-pr-checks.spec.mjs @@ -12,12 +12,14 @@ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync, statSync } from 'node:fs'; +import { readFileSync, statSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { evaluateChecks } from '../verify-pr-checks.mjs'; +import { evaluateChecks, main, fetchRequiredContexts } from '../verify-pr-checks.mjs'; const ROOT = resolve(fileURLToPath(import.meta.url), '../../..'); const FIXTURES = join(ROOT, 'scripts/__test__/fixtures'); @@ -241,39 +243,258 @@ describe('AC-26 AC-27: exit codes and merge command', () => { }); // --------------------------------------------------------------------------- -// AC-28: Pagination bounded (tested via the max-page logic in the verifier) +// 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. // --------------------------------------------------------------------------- -describe('AC-28: pagination is bounded', () => { - // The pagination logic is in the live path (main()), not evaluateChecks. - // We verify the contract constant is defined at a sane value. - test('MAX_PAGES constant is bounded (not unbounded while-true)', async () => { - // Import the module to check the constant is exported or used - // The MAX_PAGES is defined in the module; the test verifies the concept. - // Since it's a module-internal constant, we verify the pagination logic - // exits 2 by examining the source text. - const src = readFileSync(join(ROOT, 'scripts/verify-pr-checks.mjs'), 'utf8'); - assert.ok(src.includes('MAX_PAGES'), 'verify-pr-checks.mjs must define MAX_PAGES'); - assert.ok(src.includes('process.exit(2)'), 'must call process.exit(2) on page cap'); - // Verify it's used in a conditional: `page > MAX_PAGES` or similar - assert.ok(src.includes('MAX_PAGES') && src.includes('exit(2)'), - 'pagination must be bounded with exit 2 on overflow'); + +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); + }); + }); // --------------------------------------------------------------------------- -// AC-29: Unprotected base branch exits 2 (tested via the module source) +// Entry-point guard: the verifier must actually RUN wherever it is checked out // --------------------------------------------------------------------------- -describe('AC-29: unprotected base branch exits 2', () => { - test('404 protection endpoint → handled as exit 2 (not exit 0)', () => { - // The fetchRequiredContexts function in the live path handles 404 by - // calling process.exit(2). Verify the source has this logic. - const src = readFileSync(join(ROOT, 'scripts/verify-pr-checks.mjs'), 'utf8'); - assert.ok(src.includes('404'), 'must handle 404 protection response'); - assert.ok( - src.includes('process.exit(2)'), - 'must exit 2 on unprotected base (never 0)' - ); +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'); + }); + }); // --------------------------------------------------------------------------- @@ -305,18 +526,32 @@ describe('D-PR2a: required context satisfied by commit status', () => { // Code of Conduct fixture verification (AC-1, AC-2) // --------------------------------------------------------------------------- describe('AC-1 AC-2: Code of Conduct verification', () => { - test('fixture sha256 and size are recorded', () => { - // The fixture records the upstream CC 2.1 text. - // sha256 and size are the provenance record for offline 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, re-verified at review time: + // https://raw.githubusercontent.com/EthicalSource/contributor_covenant/ + // release/content/version/2/1/code_of_conduct.md + // The upstream file carries a TOML front-matter block (+++ ... +++) that is + // site metadata, not part of the document. With it stripped, the body is + // byte-identical to this fixture: 5478 bytes, sha256 369bf730...339b. + // (The plan recorded 977d7813.../5480 bytes for a capture that does not + // reproduce against upstream today; the digest below is measured, not copied.) + 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; - // Record actual sha256 of the committed fixture: - // (fetched from contributor-covenant.org at planning time; exact byte-match - // may vary by LF vs CRLF and trailing newlines — functional check below is authoritative) - assert.ok(hash.length === 64, `sha256 must be 64 hex chars; got: ${hash.length}`); - assert.ok(size > 5000 && size < 6000, `fixture size ${size} bytes should be ~5400-5500 bytes`); + 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)', () => { diff --git a/scripts/verify-no-control-bytes.mjs b/scripts/verify-no-control-bytes.mjs index 79e1a354..9eef4b1d 100644 --- a/scripts/verify-no-control-bytes.mjs +++ b/scripts/verify-no-control-bytes.mjs @@ -35,17 +35,43 @@ * * Exit codes: * 0 — no hazards found (prints file count and byte count for non-vacuity) - * 1 — hazard found, or zero files scanned, or stale/unmatched allowlist entry - * 2 — tool error: git missing, not a repo, non-UTF-8, pagination error + * 1 — hazard found, zero files scanned, invalid UTF-8, un-allowlisted NUL, + * an unreadable tracked path, or a stale/unmatched allowlist entry + * 2 — tool error: git missing, not inside a git work tree, or a git + * subcommand (ls-files / diff --cached / cat-file) failed */ 'use strict'; import { spawnSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { readFileSync, realpathSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; -const ROOT = resolve(dirname(fileURLToPath(import.meta.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. @@ -271,8 +297,15 @@ function getTrackedFiles(cwd) { function getStagedFiles(cwd) { const r = gitExec(['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMR'], cwd); if (r.status !== 0) { - // No staged files is not an error in --staged mode - return []; + // 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 })); @@ -300,9 +333,12 @@ function readIndexBlob(path, cwd) { * @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 }[]} — list of hazard hits + * @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). */ -function scanBuffer(buf, relPath, allowedCps) { +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); @@ -322,9 +358,7 @@ function scanBuffer(buf, relPath, allowedCps) { const { cp, byteOffset } = codepoints[i]; const nextCp = i + 1 < codepoints.length ? codepoints[i + 1].cp : null; if (isHazardous(cp, nextCp)) { - if (!allowedCps.has(cp)) { - hits.push({ codepoint: cp, byteOffset }); - } + hits.push({ codepoint: cp, byteOffset, allowed: allowedCps.has(cp) }); } } return hits; @@ -418,12 +452,12 @@ function main() { 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 { hazardHits.push({ path: entry.path, codepoint: hit.codepoint, byteOffset: hit.byteOffset, buf }); - // Track exercised allowlist entries - if (allowedCps.has(hit.codepoint)) { - exercisedAllowlist.add(`${entry.path}:${hit.codepoint}`); - } } } } @@ -454,9 +488,14 @@ function main() { const passStats = `Scanned ${scannedFiles} file(s), ${totalBytes} byte(s)` + (skippedCount > 0 ? `, ${skippedCount} symlink/gitlink skipped` : ''); - if (exercisedAllowlist.size > 0 && HAZARD_ALLOWLIST.length > 0) { - for (const entry of HAZARD_ALLOWLIST) { - console.log(` allowlist: ${entry.path} (reason: ${entry.reason})`); + // 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})`); } } @@ -475,13 +514,10 @@ function main() { } console.log(`✓ source-hygiene gate: ${passStats}`); - if (HAZARD_ALLOWLIST.length > 0) { - console.log(` (${HAZARD_ALLOWLIST.length} allowlist entr${HAZARD_ALLOWLIST.length === 1 ? 'y' : 'ies'} exercised)`); - } process.exit(0); } -// Run only when executed directly (not imported by tests) -if (import.meta.url === `file://${process.argv[1]}`) { +// 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 index c131c7c4..5b2151da 100644 --- a/scripts/verify-pr-checks.mjs +++ b/scripts/verify-pr-checks.mjs @@ -11,7 +11,9 @@ * * 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. + * 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). @@ -48,6 +50,30 @@ '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; @@ -67,12 +93,10 @@ const MIN_GH_MINOR = 31; function defaultGhRunner(args) { const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); if (r.error) { - if (r.error.code === 'ENOENT') { - console.error('✖ verify-pr-checks: gh is not on PATH'); - process.exit(2); - } - console.error(`✖ verify-pr-checks: gh error: ${r.error.message}`); - process.exit(2); + 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 @@ -135,6 +159,20 @@ export function evaluateChecks({ requiredContexts, checkRuns, statuses, headSha 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}`); @@ -145,10 +183,17 @@ export function evaluateChecks({ requiredContexts, checkRuns, statuses, headSha // Always print counts (D-PR4 / avoids PF-013) lines.push(` check-runs: ${nChecks}, statuses: ${nStatuses}, required contexts: ${nRequired}`); - // Build lookup maps - const checkByName = new Map(); // name -> CheckRun (latest, filter=latest already applied) + // 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) { - checkByName.set(cr.name, cr); + 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) { @@ -159,17 +204,20 @@ export function evaluateChecks({ requiredContexts, checkRuns, statuses, headSha // 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 cr = checkByName.get(ctx); + const crs = checksByName.get(ctx); const st = statusByContext.get(ctx); - if (cr) { - // Found in check-runs namespace - if (cr.status !== 'completed' || cr.conclusion !== 'success') { - failures.push( - `Tier A (required): "${ctx}" — status=${cr.status}, conclusion=${cr.conclusion ?? 'null'}` + - ` (avoids PF-017: cancelled/skipped/in_progress are not success)`, - ); - pass = false; + 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 @@ -235,6 +283,15 @@ export function evaluateChecks({ requiredContexts, checkRuns, statuses, headSha // --------------------------------------------------------------------------- // 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() { @@ -249,22 +306,26 @@ function ghVersion() { /** * 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 }} */ -function fetchCheckRuns(headSha, runner) { - // Use gh api with --paginate to collect all pages - // gh api --paginate emits one JSON object per page (not merged) +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) { - console.error(`✖ verify-pr-checks: check-runs API error (page ${page}): ${data.stderr}`); - process.exit(2); + return { + ok: false, + exitCode: 2, + message: `check-runs API error (page ${page}): ${data.stderr}`, + }; } if (totalCount === null) { totalCount = data.total_count ?? 0; @@ -276,137 +337,202 @@ function fetchCheckRuns(headSha, runner) { } if (page > MAX_PAGES) { - console.error(`✖ verify-pr-checks: pagination exceeded ${MAX_PAGES} pages; exiting 2 (D-PR4a)`); - process.exit(2); + 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) { - console.error( - `✖ verify-pr-checks: collected ${allCheckRuns.length} check-runs but total_count=${totalCount}; exiting 2`, - ); - process.exit(2); + return { + ok: false, + exitCode: 2, + message: `collected ${allCheckRuns.length} check-runs but total_count=${totalCount} — partial page set`, + }; } - return allCheckRuns; + return { ok: true, checkRuns: allCheckRuns }; } /** * Fetch commit statuses for a sha. + * @returns {{ ok: true, statuses: CommitStatus[] } | { ok: false, exitCode: 2, message: string }} */ -function fetchStatuses(headSha, runner) { +export function fetchStatuses(headSha, runner) { const url = `/repos/{owner}/{repo}/commits/${headSha}/status`; const data = runner(['api', url]); if (data.__error) { - console.error(`✖ verify-pr-checks: commit-status API error: ${data.stderr}`); - process.exit(2); + return { ok: false, exitCode: 2, message: `commit-status API error: ${data.stderr}` }; } - return data.statuses ?? []; + return { ok: true, statuses: data.statuses ?? [] }; } /** * Fetch required contexts from branch protection. - * D-PR2: exits 2 on 403 or when no protection and no fallback. + * 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 }} */ -function fetchRequiredContexts(baseBranch, requiredFrom, runner) { +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) { - if (requiredFrom) { - console.error( - `✖ verify-pr-checks: --required-from branch "${requiredFrom}" has no protection (404); exit 2`, - ); - } else { - console.error( - `✖ verify-pr-checks: base branch "${baseBranch}" has no protection (404). ` + - `Use --required-from to specify a protected branch, e.g. --required-from main. ` + - `(AC-29: unprotected base is not a pass — D-PR2)` - ); - } - process.exit(2); + 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) { - console.error( - `✖ verify-pr-checks: branch protection unreadable (403 — insufficient permissions); exit 2`, - ); - process.exit(2); + return { + ok: false, + exitCode: 2, + message: 'branch protection unreadable (403 — insufficient permissions)', + }; } - console.error(`✖ verify-pr-checks: protection API error: ${data.stderr}`); - process.exit(2); + 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 contexts = data?.required_status_checks?.contexts ?? []; + const notes = []; if (requiredFrom && requiredFrom !== baseBranch) { - console.log(` Required contexts read from: ${requiredFrom} (base branch "${baseBranch}" is unprotected)`); + notes.push(` Required contexts read from: ${requiredFrom} (base branch "${baseBranch}" is unprotected)`); } - return { contexts, resolvedBranch: branch }; + return { ok: true, contexts, resolvedBranch: branch, notes }; } -export function main(argv = process.argv.slice(2), runner = defaultGhRunner) { +const USAGE = + 'Usage: node scripts/verify-pr-checks.mjs [--required-from ] [--head-sha ]'; + +/** + * 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: node scripts/verify-pr-checks.mjs [--required-from ] [--head-sha ]'); - process.exit(2); + 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; const hsIdx = argv.indexOf('--head-sha'); + if (hsIdx !== -1 && !argv[hsIdx + 1]) { + fail(`--head-sha requires a SHA\n${USAGE}`); + return 2; + } const headShaOverride = hsIdx !== -1 ? argv[hsIdx + 1] : null; // ---- Check gh version (D-PR5) ---- - const ver = ghVersion(); + 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'; - console.error( - `✖ verify-pr-checks: gh >= ${MIN_GH_MAJOR}.${MIN_GH_MINOR} required (found ${found}); ` + - `needed for --match-head-commit (D-PR5)` + fail( + `gh >= ${MIN_GH_MAJOR}.${MIN_GH_MINOR} required (found ${found}); ` + + `needed for --match-head-commit (D-PR5)`, ); - process.exit(2); + return 2; } // ---- Fetch PR metadata ---- const prData = runner(['api', `/repos/{owner}/{repo}/pulls/${prNumber}`]); if (prData.__error) { - console.error(`✖ verify-pr-checks: cannot read PR ${prNumber}: ${prData.stderr}`); - process.exit(2); + fail(`cannot read PR ${prNumber}: ${prData.stderr}`); + return 2; } const headSha = headShaOverride ?? prData.head?.sha; const baseBranch = prData.base?.ref; if (!headSha || !baseBranch) { - console.error(`✖ verify-pr-checks: cannot determine head SHA or base branch for PR ${prNumber}`); - process.exit(2); + 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 { contexts: requiredContexts, resolvedBranch } = fetchRequiredContexts(baseBranch, requiredFrom, runner); - console.log(` Required contexts (${requiredContexts.length}) from ${resolvedBranch}: ${requiredContexts.join(', ') || '(none)'}`); + 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 checkRuns = fetchCheckRuns(headSha, runner); + const cr = fetchCheckRuns(headSha, runner); + if (!cr.ok) { + fail(cr.message); + return cr.exitCode; + } // ---- Fetch commit statuses (D-PR2a) ---- - const statuses = fetchStatuses(headSha, runner); + const st = fetchStatuses(headSha, runner); + if (!st.ok) { + fail(st.message); + return st.exitCode; + } // ---- Evaluate (D-PR1: pure function) ---- - const result = evaluateChecks({ requiredContexts, checkRuns, statuses, headSha }); + const result = evaluateChecks({ + requiredContexts: req.contexts, + checkRuns: cr.checkRuns, + statuses: st.statuses, + headSha, + }); for (const line of result.lines) { console.log(line); } - process.exit(result.exitCode); + return result.exitCode; } -if (import.meta.url === `file://${process.argv[1]}`) { - main(); +// Run only when executed directly (not imported by tests). See isMainModule. +if (isMainModule(import.meta.url)) { + process.exit(main()); } From 0ce459c748798b8783a3ac26d62940c466378834 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Thu, 13 Aug 2026 01:20:57 +0200 Subject: [PATCH 05/14] fix: address four Evaluator alignment failures (AC-6, AC-16, AC-22, AC-30, scope) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC-6: remove `&& !isStaged` guard from non-vacuity check in verify-no-control-bytes.mjs (D-CB5). A --staged scan that finds zero staged files previously exited 0, printing "Scanned 0 file(s)" — indistinguishable from a working scan of a clean index. Now exits 1 unconditionally when fileEntries is empty. Test added. AC-16: change exit 2 → exit 1 for 'git not on PATH' and 'not inside a git work tree' in verify-no-control-bytes.mjs. AC-16 and D-CB5 mandate exit 1 for all four named failure conditions; these two are known, named failures (fail-closed), not indeterminate tool errors (exit 2). Tests updated; header comment revised. AC-22: evaluateChecks in verify-pr-checks.mjs now names every required context as absent when nChecks===0, instead of returning after the single "zero check-runs" line. The test titled "naming all 6 required contexts" now asserts that all six context strings appear in the failure output. AC-30: pre-compute hexCtx inside the hazard-hit scan loop and store it in hazardHits instead of the raw `buf`. The file buffer is released at the end of each file's iteration rather than being retained until all files are scanned. Test added to prove hex context is reported correctly across multiple hazardous files. Scope: remove unplanned --head-sha override from verify-pr-checks.mjs (USAGE string, header comment, argv parsing, and headShaOverride usage in main()). The plan did not include this flag; it had zero test coverage and no provenance note. Self-verification: positive control (planted 0x1B) still exits 1; full-tree scan of 514 files / 4,609,419 bytes exits 0. Co-Authored-By: Claude --- .../__test__/verify-no-control-bytes.spec.mjs | 67 +++++++++++++++++-- scripts/__test__/verify-pr-checks.spec.mjs | 8 ++- scripts/verify-no-control-bytes.mjs | 38 +++++++---- scripts/verify-pr-checks.mjs | 20 +++--- 4 files changed, 104 insertions(+), 29 deletions(-) diff --git a/scripts/__test__/verify-no-control-bytes.spec.mjs b/scripts/__test__/verify-no-control-bytes.spec.mjs index 7cf1fc98..a59ddc2d 100644 --- a/scripts/__test__/verify-no-control-bytes.spec.mjs +++ b/scripts/__test__/verify-no-control-bytes.spec.mjs @@ -361,6 +361,22 @@ describe('AC-5 AC-6: full-tree scan and non-vacuity', () => { } finally { cleanup(dir); } }); + test('AC-6: --staged with nothing staged → exits 1 with non-vacuity message', () => { + // D-CB5 mandates exit 1 when zero files are scanned, unconditionally — including + // in --staged mode. The previous `&& !isStaged` carve-out silently exempted the + // pre-commit hook from the guard it was designed to protect. + const { dir } = mkTempGitRepo(); + try { + // Nothing staged — `git diff --cached` returns empty, yielding zero file entries. + const r = runScanner(['--staged'], { cwd: dir }); + assert.equal(r.status, 1, '--staged with nothing staged 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); } + }); + }); // --------------------------------------------------------------------------- @@ -397,11 +413,14 @@ describe('AC-16 AC-20: error cases', () => { } finally { cleanup(dir); } }); - test('AC-16: not inside a git work tree → exits 2', () => { + 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, 2, 'non-git directory must exit 2'); + assert.equal(r.status, 1, 'non-git directory must exit 1 (fail-closed)'); } finally { cleanup(dir); } }); @@ -482,6 +501,41 @@ describe('AC-15: scanner source is self-clean', () => { }); +// --------------------------------------------------------------------------- +// AC-30: hex context is pre-computed — file buffer not retained beyond each +// individual file scan (avoids accumulating all file contents in memory) +// --------------------------------------------------------------------------- +describe('AC-30: hex context pre-computation', () => { + + test('scanner reports hex context for every hazard across multiple files', () => { + // Verify that hexCtx is computed and stored correctly for each hit. + // When this works, buf is NOT retained in hazardHits — the fix is structural + // (hazardHits stores { hexCtx } not { buf }) and this test proves the + // correct 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 // --------------------------------------------------------------------------- @@ -628,11 +682,14 @@ describe('AC-20: symlinks are skipped and counted, not read', () => { }); // --------------------------------------------------------------------------- -// AC-16: git missing from PATH fails closed with exit 2 +// AC-16: git missing from PATH fails closed with exit 1 // --------------------------------------------------------------------------- describe('AC-16: git not on PATH', () => { - test('scanner exits 2 when git cannot be found', () => { + 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-')); @@ -644,7 +701,7 @@ describe('AC-16: git not on PATH', () => { env: { PATH: binDir }, timeout: 30000, }); - assert.equal(r.status, 2, `missing git must exit 2; stdout: ${r.stdout}, stderr: ${r.stderr}`); + 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 index d084ff9c..1435e16d 100644 --- a/scripts/__test__/verify-pr-checks.spec.mjs +++ b/scripts/__test__/verify-pr-checks.spec.mjs @@ -75,9 +75,15 @@ describe('AC-21 AC-22: historical fixture evaluation', () => { 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); - // Non-vacuity guard fires: zero check-runs → FAIL immediately 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)', () => { diff --git a/scripts/verify-no-control-bytes.mjs b/scripts/verify-no-control-bytes.mjs index 9eef4b1d..b380fcca 100644 --- a/scripts/verify-no-control-bytes.mjs +++ b/scripts/verify-no-control-bytes.mjs @@ -36,9 +36,10 @@ * 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, - * an unreadable tracked path, or a stale/unmatched allowlist entry - * 2 — tool error: git missing, not inside a git work tree, or a git - * subcommand (ls-files / diff --cached / cat-file) failed + * 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'; @@ -237,8 +238,10 @@ 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(2); + process.exit(1); } console.error(`✖ verify-no-control-bytes: git error: ${result.error.message}`); process.exit(2); @@ -246,12 +249,14 @@ function gitExec(args, cwd = process.cwd()) { return result; } -/** Verify we are inside a git work tree (exit 2 if not). */ +/** 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(2); + process.exit(1); } } @@ -403,10 +408,15 @@ function main() { .map(e => ({ ...e, absolutePath: resolve(cwd, e.path) })); } - // ---- D-CB5: Non-vacuity guard ---- - if (fileEntries.length === 0 && !isStaged) { + // ---- D-CB5: Non-vacuity guard (AC-6) ---- + // Zero files scanned — including in --staged mode — is exit 1, never 0. + // "Scanned 0 file(s), 0 byte(s): clean" is indistinguishable from a broken + // path-discovery that never found anything (the exact shape D-CB5 forbids). + if (fileEntries.length === 0) { 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.'); + console.error(isStaged + ? ' No staged files found — stage at least one file before running the pre-commit hook.' + : ' If this is a new repo with no commits, run `git add` first.'); process.exit(1); } @@ -426,7 +436,9 @@ function main() { let totalBytes = 0; let scannedFiles = 0; const exercisedAllowlist = new Set(); // tracks which allowlist entries are hit - const hazardHits = []; // { path, codepoint, byteOffset, buf } + // 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; @@ -457,7 +469,8 @@ function main() { // stale-entry check below does not flag it. exercisedAllowlist.add(`${entry.path}:${hit.codepoint}`); } else { - hazardHits.push({ path: entry.path, codepoint: hit.codepoint, byteOffset: hit.byteOffset, buf }); + // 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) }); } } } @@ -505,9 +518,8 @@ function main() { } for (const hit of hazardHits) { const cpHex = `U+${hit.codepoint.toString(16).toUpperCase().padStart(4, '0')}`; - const ctx = hexContext(hit.buf, hit.byteOffset); console.error(`✖ ${hit.path}: hazardous codepoint ${cpHex} at byte offset ${hit.byteOffset}`); - console.error(` context: ${ctx}`); + console.error(` context: ${hit.hexCtx}`); } console.error(`✖ source-hygiene gate FAILED — ${passStats}`); process.exit(1); diff --git a/scripts/verify-pr-checks.mjs b/scripts/verify-pr-checks.mjs index 5b2151da..36012447 100644 --- a/scripts/verify-pr-checks.mjs +++ b/scripts/verify-pr-checks.mjs @@ -40,7 +40,6 @@ * Usage: * node scripts/verify-pr-checks.mjs * node scripts/verify-pr-checks.mjs --required-from - * node scripts/verify-pr-checks.mjs --head-sha * * Exit codes: * 0 — all required contexts completed+success; prints `gh pr merge` command @@ -177,6 +176,14 @@ export function evaluateChecks({ requiredContexts, checkRuns, statuses, headSha 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 }; } @@ -430,7 +437,7 @@ export function fetchRequiredContexts(baseBranch, requiredFrom, runner) { } const USAGE = - 'Usage: node scripts/verify-pr-checks.mjs [--required-from ] [--head-sha ]'; + 'Usage: node scripts/verify-pr-checks.mjs [--required-from ]'; /** * Live entry point. Returns an exit code; never calls process.exit, so tests @@ -461,13 +468,6 @@ export function main(argv = process.argv.slice(2), runner = defaultGhRunner, ghV } const requiredFrom = rfIdx !== -1 ? argv[rfIdx + 1] : null; - const hsIdx = argv.indexOf('--head-sha'); - if (hsIdx !== -1 && !argv[hsIdx + 1]) { - fail(`--head-sha requires a SHA\n${USAGE}`); - return 2; - } - const headShaOverride = hsIdx !== -1 ? argv[hsIdx + 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)) { @@ -485,7 +485,7 @@ export function main(argv = process.argv.slice(2), runner = defaultGhRunner, ghV fail(`cannot read PR ${prNumber}: ${prData.stderr}`); return 2; } - const headSha = headShaOverride ?? prData.head?.sha; + const headSha = prData.head?.sha; const baseBranch = prData.base?.ref; if (!headSha || !baseBranch) { fail(`cannot determine head SHA or base branch for PR ${prNumber}`); From 2c23d229c86e607394a9528dfeaef25d1e37925e Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Thu, 13 Aug 2026 03:16:48 +0200 Subject: [PATCH 06/14] fix(ci): glob scripts/__test__/*.spec.mjs instead of enumerating files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicit enumeration at ci.yml:331 deviated from plan S2/AC-19 and the established repo convention (7 of 8 node --test call sites use the *.spec.mjs glob). Any spec file added to scripts/__test__/ in the future would be silently skipped — the same "gate that matches nothing" failure mode this PR exists to eliminate, one layer up. Bash on ubuntu-latest expands the glob before passing arguments to node; a non-matching glob (no .spec.mjs files) makes node --test error rather than silently succeed, so the gate remains fail-closed. Co-Authored-By: Claude --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 456b6867..3903b096 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -328,4 +328,4 @@ jobs: - 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__/verify-no-control-bytes.spec.mjs scripts/__test__/verify-pr-checks.spec.mjs + run: node --test scripts/__test__/*.spec.mjs From cbac8617c3381f2da0631f19f17d5e7f8134673f Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Thu, 13 Aug 2026 03:16:53 +0200 Subject: [PATCH 07/14] fix(pre-commit): fail-closed when scanner script is missing and document exit codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-CB5 requires the gate to fail closed. Previously the hook printed a 'skipping' message and exited 0 when scripts/verify-no-control-bytes.mjs was not found — meaning a commit that deletes or renames the scanner silently disabled the local gate. Change to exit 1 with an actionable message directing the developer to restore the script or pass --no-verify deliberately. Also correct the exit-code header: (a) exit 1 now covers both the hazard-byte and missing-scanner cases, and (b) document the exit-2 path that propagates from the scanner's three-value contract but was previously undocumented in the hook header. Co-Authored-By: Claude --- scripts/hooks/pre-commit | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit index 3f38b81c..c81d3f2f 100755 --- a/scripts/hooks/pre-commit +++ b/scripts/hooks/pre-commit @@ -16,7 +16,11 @@ # 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; fix the file before staging). +# 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 @@ -24,8 +28,9 @@ 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 — skipping" >&2 - exit 0 + 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). From 73b06b39eec847302c78583e01be13c6b3995289 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Thu, 13 Aug 2026 03:17:57 +0200 Subject: [PATCH 08/14] docs(changelog): merge duplicate ### Added into existing section, restore BREAKING-first order The PR6 commit introduced a second `### Added` heading at the top of the [Unreleased] block, ahead of all the `### **BREAKING**` sections. This violated the file's established ordering (both [Unreleased] and [0.3.0] lead with BREAKING) and left a duplicate heading (one already existed at line 452). Fix: remove the stray `### Added` block from the top of [Unreleased]; merge its three bullets (Code of Conduct #38, source-hygiene gate #288, pre-merge verifier #289) into the existing `### Added` section at the end of the block, just before `### Changed`. Co-Authored-By: Claude --- CHANGELOG.md | 48 +++++++++++++++++++++++------------------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05ebef74..5698096f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,31 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added - -- **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 reads required contexts from live branch protection, asserts each - is `status=completed` AND `conclusion=success`, and emits a `gh pr merge - --squash --match-head-commit ` command pinned to the verified SHA. On - success, exit 0; on any required context missing or non-success, exit 1; on - tool/permission errors, exit 2. - ### **BREAKING** — Interpolation syntax: `{x}` → `{{x}}` Interpolation now uses **double braces**: `{{variable}}`, `{{obj.field}}`, @@ -649,6 +624,29 @@ 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 reads required contexts from live branch protection, asserts each + is `status=completed` AND `conclusion=success`, and emits a `gh pr merge + --squash --match-head-commit ` command pinned to the verified SHA. On + success, exit 0; on any required context missing or non-success, exit 1; on + tool/permission errors, exit 2. + ### Changed - **napi and Python `compileFile` / `compile_file` now emit root-relative From 30a20b0f115564f4578fa42e16451bf1fa0ee1fb Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Thu, 13 Aug 2026 03:20:00 +0200 Subject: [PATCH 09/14] fix(release): add positive-control suite to version-gate job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version-gate job in release.yml ran the source-hygiene scanner (verify-no-control-bytes.mjs) but not the positive-control and class- completeness suite (scripts/__test__/*.spec.mjs). Because ci.yml does not trigger on tag pushes, the suite that pins HAZARD_RANGES (D-CB1a) never executed on the release path. A regression that silently narrowed the hazard class would exit 0 in the release gate while being caught on PRs and pushes to main. The fix is one additional step — the same glob ci.yml uses — added immediately after the scanner step. Applies ADR-009 / avoids PF-013: a completeness claim must be backed by a positive control that detects the hostile artifact when present. Co-Authored-By: Claude --- .github/workflows/release.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72b98681..5ff47a40 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,10 +31,15 @@ jobs: - 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 this step ensures the gate is - # enforced at release time. Uses the same Node 22 install above. + # 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. From 85997f80a32000fcc24283ad0dd6b3f575985508 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Thu, 13 Aug 2026 03:20:59 +0200 Subject: [PATCH 10/14] docs: fix five review findings in CONTRIBUTING.md and CHANGELOG.md CONTRIBUTING.md hazard-class description (lines 82-86) misplaced U+2028, U+2029, and U+FEFF inside the twelve Bidi_Control=Yes codepoints. They are not Bidi_Control members; U+061C is. Corrected to: '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).' This matches verify-no-control-bytes.mjs and CHANGELOG.md, which both already stated it correctly. CONTRIBUTING.md Merging section exit-code paragraph and CHANGELOG.md entry for #289 both documented exit 1 as only 'a required context is missing or not successful'. This understated the contract: exit 1 also fires for Tier B (any non-required check-run concluded failure/cancelled/timed_out/ action_required/stale) and for zero check-runs. Added Tier A/B/C taxonomy. Added a 'Tier B is load-bearing' note because source-hygiene is NOT a required branch-protection context, making Tier B the only binding mechanism for admin merges. CONTRIBUTING.md Scope paragraph now states two additional limitations: (a) source-hygiene is not a required context so --admin bypasses it outright and Tier B is the binding mechanism, (b) Tier B skips queued/in_progress check-runs, so a verifier pass issued while source-hygiene is still running has verified nothing about source hygiene. CONTRIBUTING.md Source hygiene section now documents the scanner's 0/1/2 exit-code contract, matching the verifier's documented contract in the same file. Co-Authored-By: Claude --- CHANGELOG.md | 13 +++-- CONTRIBUTING.md | 34 +++++++++--- package.json | 5 +- .../__test__/verify-no-control-bytes.spec.mjs | 53 ++++++++++++++----- scripts/__test__/verify-pr-checks.spec.mjs | 8 +-- 5 files changed, 83 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5698096f..d6f0e2b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -641,11 +641,14 @@ diagnostic messages must update to check for the `\uXXXX` literal form instead. - **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 reads required contexts from live branch protection, asserts each - is `status=completed` AND `conclusion=success`, and emits a `gh pr merge - --squash --match-head-commit ` command pinned to the verified SHA. On - success, exit 0; on any required context missing or non-success, exit 1; on - tool/permission errors, exit 2. + --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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c793da03..f151a403 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,8 +70,14 @@ 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): @@ -81,9 +87,9 @@ 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 (Trojan Source, CVE-2021-42574) -including U+061C, U+2028 (LS), U+2029 (PS), and U+FEFF (BOM). CR (U+000D) is -permitted only as the first byte of CRLF. +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. @@ -121,15 +127,27 @@ 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` verified, `1` a required context is missing or -not successful, `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. +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. +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`: 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/scripts/__test__/verify-no-control-bytes.spec.mjs b/scripts/__test__/verify-no-control-bytes.spec.mjs index a59ddc2d..a3d0a1c9 100644 --- a/scripts/__test__/verify-no-control-bytes.spec.mjs +++ b/scripts/__test__/verify-no-control-bytes.spec.mjs @@ -146,15 +146,20 @@ describe('AC-12 AC-13: hazard class golden set', () => { assert.equal(isHazardous(0x0a, null), false, 'LF must NOT be hazardous'); }); - test('mutation check: removing C1 range (U+0085) would fail the test above', () => { - // D-CB1a: Prove the golden-set test is non-vacuous. - // This test verifies that U+0085 (C1 NEL, the case the baseline missed) IS detected. - // The case that triggered PF-018 three times in this repo. - const nel = 0x85; // U+0085 — C1 NEL; written as hex, not \u escape (D-CB2) + 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'); - // Also verify U+0080 (C1 low end) and U+009F (C1 high end) are caught + // 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) @@ -337,9 +342,15 @@ describe('AC-11: git ls-files discovery path', () => { // --------------------------------------------------------------------------- describe('AC-5 AC-6: full-tree scan and non-vacuity', () => { - test('AC-5: scanner exits 0 on real repo tree with >= 500 files and >= 4MB', () => { + 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}`); @@ -502,16 +513,32 @@ describe('AC-15: scanner source is self-clean', () => { }); // --------------------------------------------------------------------------- -// AC-30: hex context is pre-computed — file buffer not retained beyond each -// individual file scan (avoids accumulating all file contents in memory) +// 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 pre-computation', () => { +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. - // When this works, buf is NOT retained in hazardHits — the fix is structural - // (hazardHits stores { hexCtx } not { buf }) and this test proves the - // correct string reaches the output regardless of how many files are scanned. + // 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 diff --git a/scripts/__test__/verify-pr-checks.spec.mjs b/scripts/__test__/verify-pr-checks.spec.mjs index 1435e16d..176454c3 100644 --- a/scripts/__test__/verify-pr-checks.spec.mjs +++ b/scripts/__test__/verify-pr-checks.spec.mjs @@ -543,9 +543,11 @@ describe('AC-1 AC-2: Code of Conduct verification', () => { // release/content/version/2/1/code_of_conduct.md // The upstream file carries a TOML front-matter block (+++ ... +++) that is // site metadata, not part of the document. With it stripped, the body is - // byte-identical to this fixture: 5478 bytes, sha256 369bf730...339b. - // (The plan recorded 977d7813.../5480 bytes for a capture that does not - // reproduce against upstream today; the digest below is measured, not copied.) + // byte-identical to this fixture: 5478 bytes, + // sha256 369bf7301883368fc19203bd0f1233fed2b83f0378ad19c4d0708bf61925339b. + // AC-1 recorded a different capture (977d781349351fd7c1f076e4c7dc7de2a05b40e12c773542c3815dd4ce7f37ba, + // 5480 bytes) that does not reproduce against upstream today; the constants + // below reflect the measured value, not the plan's capture. const FIXTURE_SHA256 = '369bf7301883368fc19203bd0f1233fed2b83f0378ad19c4d0708bf61925339b'; const FIXTURE_BYTES = 5478; From 9fc1425c3d1a426334ff04276ea6a7d8f9c5c426 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Thu, 13 Aug 2026 03:23:05 +0200 Subject: [PATCH 11/14] fix(docs): add test:gates npm script and reference it in RELEASING.md pre-flight Root package.json already has the script (landed in HEAD), CONTRIBUTING.md already references it, but RELEASING.md pre-flight was missing it. Any contributor who edits the gate scripts locally and breaks detection gets zero signal until CI. Now both normative docs list the local invocation path. Also expand AC-15 spec to cover the PF-018 backslash-u-escape vector across all gate scripts and spec files, not just the scanner source. Builds the detection pattern from char codes so the test cannot trip its own new rule. Co-Authored-By: Claude --- RELEASING.md | 19 ++++++++--- .../__test__/verify-no-control-bytes.spec.mjs | 33 +++++++++++++++++-- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 317ce8c7..32ca105c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -59,8 +59,9 @@ 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 +node scripts/verify-pr-checks.mjs # Packaging spot-check (inspect tarball contents) npm pack -w @mdscript/mds --dry-run @@ -92,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/scripts/__test__/verify-no-control-bytes.spec.mjs b/scripts/__test__/verify-no-control-bytes.spec.mjs index a3d0a1c9..04d7e743 100644 --- a/scripts/__test__/verify-no-control-bytes.spec.mjs +++ b/scripts/__test__/verify-no-control-bytes.spec.mjs @@ -505,9 +505,36 @@ describe('AC-15: scanner source is self-clean', () => { assert.equal(r.status, 0, `scanner source files must pass their own gate; stderr: ${r.stderr}`); }); - test('scanner source contains no grep -P invocation', () => { - const src = readFileSync(join(ROOT, 'scripts/verify-no-control-bytes.mjs'), 'utf8'); - assert.ok(!src.includes('grep -P'), 'scanner must not use grep -P (BSD grep lacks -P, exits 2)'); + test('scanner source files contain no grep -P and no backslash-u escape (AC-15, PF-018)', () => { + // AC-15: the scripts, spec files, and hook must not invoke 'grep -P' (BSD grep + // lacks -P, 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). + // + // Build the backslash-u search pattern from numeric char codes so this test + // does not trip its own rule: 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('grep -P'), + `${rel}: must not invoke grep -P (BSD grep lacks -P, 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)` + ); + } }); }); From 89480f8c25b17e992514a91514bcef290150beec Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Thu, 13 Aug 2026 03:24:37 +0200 Subject: [PATCH 12/14] fix(test): avoid self-tripping the AC-15 grep-P check (PF-018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec file includes itself in the AC-15 fileSet (along with the other gate scripts). The HEAD version used src.includes('grep -P') to check for the forbidden grep flag, but that string literal is present in the spec file itself — causing the test to fail its own self-check. Fix: build the forbidden-flag string by concatenation ('grep' + ' -P') and build the test description without the contiguous substring, so the spec file passes its own check. Avoids PF-018 (edit tooling injects live bytes/strings that trip the very gates they guard). Co-Authored-By: Claude --- .../__test__/verify-no-control-bytes.spec.mjs | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/scripts/__test__/verify-no-control-bytes.spec.mjs b/scripts/__test__/verify-no-control-bytes.spec.mjs index 04d7e743..5b72e5eb 100644 --- a/scripts/__test__/verify-no-control-bytes.spec.mjs +++ b/scripts/__test__/verify-no-control-bytes.spec.mjs @@ -492,7 +492,7 @@ describe('AC-18: --staged mode reads index blob, not working tree', () => { }); // --------------------------------------------------------------------------- -// AC-15: scanner source itself has no hazard bytes and no grep -P +// AC-15: scanner source itself has no hazard bytes and no PCRE grep flag // --------------------------------------------------------------------------- describe('AC-15: scanner source is self-clean', () => { @@ -505,14 +505,19 @@ describe('AC-15: scanner source is self-clean', () => { assert.equal(r.status, 0, `scanner source files must pass their own gate; stderr: ${r.stderr}`); }); - test('scanner source files contain no grep -P and no backslash-u escape (AC-15, PF-018)', () => { - // AC-15: the scripts, spec files, and hook must not invoke 'grep -P' (BSD grep - // lacks -P, 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). + 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). // - // Build the backslash-u search pattern from numeric char codes so this test - // does not trip its own rule: 0x5C = backslash, then 'u' and 4 hex digits. + // 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}'); @@ -527,8 +532,8 @@ describe('AC-15: scanner source is self-clean', () => { for (const rel of fileSet) { const src = readFileSync(join(ROOT, rel), 'utf8'); assert.ok( - !src.includes('grep -P'), - `${rel}: must not invoke grep -P (BSD grep lacks -P, exits 2 — AC-15, D-CB7)` + !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), From 2e9482f8a9fa36d4bb3e7eab4b611ba786129bd9 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Thu, 13 Aug 2026 03:25:53 +0200 Subject: [PATCH 13/14] test(coc): split CoC spec into own file and document provenance derivation Move the AC-1/AC-2 Code of Conduct describe block from scripts/__test__/verify-pr-checks.spec.mjs into its own scripts/__test__/code-of-conduct.spec.mjs so the repo's one-spec-per-module convention is upheld and the CI glob (scripts/__test__/*.spec.mjs, already in place) picks it up automatically. Also strengthens the provenance comment: adds the exact upstream URL, the awk front-matter-stripping command, and the unstripped upstream digest at capture time so a reviewer can re-derive FIXTURE_SHA256 independently rather than trusting the self-measured constant (applies ADR-009, avoids PF-013). Verified 2026-08-13: the strip command produces sha256 369bf730.../5478 bytes, matching the committed fixture exactly. Remove now-unused `statSync` and `createHash` imports from verify-pr-checks.spec.mjs. Co-Authored-By: Claude --- scripts/__test__/code-of-conduct.spec.mjs | 109 +++++++++++++++++++++ scripts/__test__/verify-pr-checks.spec.mjs | 75 +------------- 2 files changed, 110 insertions(+), 74 deletions(-) create mode 100644 scripts/__test__/code-of-conduct.spec.mjs 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__/verify-pr-checks.spec.mjs b/scripts/__test__/verify-pr-checks.spec.mjs index 176454c3..4f6a3791 100644 --- a/scripts/__test__/verify-pr-checks.spec.mjs +++ b/scripts/__test__/verify-pr-checks.spec.mjs @@ -12,8 +12,7 @@ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync, statSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; -import { createHash } from 'node:crypto'; +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'; @@ -527,75 +526,3 @@ describe('D-PR2a: required context satisfied by commit status', () => { 'required context satisfied via commit status must pass (D-PR2a)'); }); }); - -// --------------------------------------------------------------------------- -// Code of Conduct fixture verification (AC-1, AC-2) -// --------------------------------------------------------------------------- -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, re-verified at review time: - // https://raw.githubusercontent.com/EthicalSource/contributor_covenant/ - // release/content/version/2/1/code_of_conduct.md - // The upstream file carries a TOML front-matter block (+++ ... +++) that is - // site metadata, not part of the document. With it stripped, the body is - // byte-identical to this fixture: 5478 bytes, - // sha256 369bf7301883368fc19203bd0f1233fed2b83f0378ad19c4d0708bf61925339b. - // AC-1 recorded a different capture (977d781349351fd7c1f076e4c7dc7de2a05b40e12c773542c3815dd4ce7f37ba, - // 5480 bytes) that does not reproduce against upstream today; the constants - // below reflect the measured value, not the plan's capture. - 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'); - }); -}); From fb8befe31e2971d8e7bc7b0f8a5ca684de706517 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Thu, 13 Aug 2026 03:30:23 +0200 Subject: [PATCH 14/14] fix(scanner): fix four review findings in verify-no-control-bytes.mjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding #1/#5 — deletion-only commits rejected by non-vacuity guard: In --staged mode, getStagedFiles() uses --diff-filter=ACMR which excludes deletions. A deletion-only commit yields zero ACMR-filtered paths, tripping the D-CB5 non-vacuity guard (exit 1) and blocking a valid `git rm` commit. Finding #2 (HIGH) — git commit --amend --no-edit rejected: During amend the index equals HEAD, so `git diff --cached --diff-filter=ACMR` is legitimately empty. The guard exited 1, blocking a routine workflow and training contributors to reach for --no-verify. Fix: D-CB5 non-vacuity guard now applies to full-tree mode only. In --staged mode, when the ACMR-filtered set is empty the scanner runs an unfiltered `git diff --cached --name-only -z` check and exits 0 with an explicit message: '0 content-bearing staged paths (N deletion(s)) — nothing to scan' for the deletion-only case, or 'no staged content — nothing to scan' for amend/empty. Exit 1 is retained only for the full-tree scan (broken path discovery) and for genuine tool failures (exit 2). Finding #3 — decodeUtf8 materialised entire file as heap-object array: Removed decodeUtf8(); fused UTF-8 decode + hazard check into a single inline pass in scanBuffer(). Only hit records are allocated. Eliminates the ~53x heap amplification measured on package-lock.json (148 KB file → 7.5 MB heapUsed delta). The nextCp CRLF look-ahead uses buf[nextStart] (lead byte), which is correct because isHazardous() only compares nextCp to 0x0A, and LF is ASCII. Finding #4 — one git cat-file subprocess per staged file: Replaced readIndexBlob() (N per-file spawns) with readAllIndexBlobs() which uses `git cat-file --batch` to read all staged blobs in a single subprocess. Measured: 300 staged files at ~11 ms/spawn → ~3.4 s eliminated. Tests: updated AC-6 --staged test to expect exit 0; added deletion-only commit test; incorporated prior session's AC-15 grep-P / backslash-u test. All 37 tests pass. Full-tree scan: 515 files, 4,629,254 bytes, 0 hits. Co-Authored-By: Claude --- .../__test__/verify-no-control-bytes.spec.mjs | 39 ++- scripts/verify-no-control-bytes.mjs | 255 ++++++++++++------ 2 files changed, 208 insertions(+), 86 deletions(-) diff --git a/scripts/__test__/verify-no-control-bytes.spec.mjs b/scripts/__test__/verify-no-control-bytes.spec.mjs index 5b72e5eb..01c8679e 100644 --- a/scripts/__test__/verify-no-control-bytes.spec.mjs +++ b/scripts/__test__/verify-no-control-bytes.spec.mjs @@ -372,18 +372,41 @@ describe('AC-5 AC-6: full-tree scan and non-vacuity', () => { } finally { cleanup(dir); } }); - test('AC-6: --staged with nothing staged → exits 1 with non-vacuity message', () => { - // D-CB5 mandates exit 1 when zero files are scanned, unconditionally — including - // in --staged mode. The previous `&& !isStaged` carve-out silently exempted the - // pre-commit hook from the guard it was designed to protect. + 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, yielding zero file entries. + // Nothing staged — `git diff --cached` returns empty. const r = runScanner(['--staged'], { cwd: dir }); - assert.equal(r.status, 1, '--staged with nothing staged must exit 1 (non-vacuity guard)'); + assert.equal(r.status, 0, '--staged with nothing staged must exit 0 (no content to scan)'); assert.ok( - r.stderr.includes('zero files scanned') || r.stderr.includes('empty scan'), - `error must mention zero files; got: ${r.stderr}` + 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); } }); diff --git a/scripts/verify-no-control-bytes.mjs b/scripts/verify-no-control-bytes.mjs index b380fcca..85099d62 100644 --- a/scripts/verify-no-control-bytes.mjs +++ b/scripts/verify-no-control-bytes.mjs @@ -21,12 +21,16 @@ * 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. Zero-files-scanned is exit 1, not exit 0 (avoids - * PF-016 — an empty scan masquerades as clean). + * 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 (git cat-file - * blob :), never from the working tree. Staging a clean file then - * modifying the working copy does not bypass the hook. + * 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 @@ -181,54 +185,6 @@ function hexContext(buf, offset) { return hex.join(' '); } -// --------------------------------------------------------------------------- -// UTF-8 decoder (returns array of {cp, byteOffset} objects) -// --------------------------------------------------------------------------- - -/** - * Decode a UTF-8 buffer into an array of {cp, byteOffset}. - * Returns null if the buffer is not valid UTF-8 (after NUL check). - * @param {Buffer} buf - * @returns {{ cp: number, byteOffset: number }[] | null} - */ -function decodeUtf8(buf) { - const codepoints = []; - 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) return null; - const b1 = buf[i + 1]; - if ((b1 & 0xc0) !== 0x80) return null; - cp = ((b0 & 0x1f) << 6) | (b1 & 0x3f); - len = 2; - } else if ((b0 & 0xf0) === 0xe0) { - if (i + 2 >= buf.length) return null; - const b1 = buf[i + 1]; - const b2 = buf[i + 2]; - if ((b1 & 0xc0) !== 0x80 || (b2 & 0xc0) !== 0x80) return null; - cp = ((b0 & 0x0f) << 12) | ((b1 & 0x3f) << 6) | (b2 & 0x3f); - len = 3; - } else if ((b0 & 0xf8) === 0xf0) { - if (i + 3 >= buf.length) return null; - const b1 = buf[i + 1]; - const b2 = buf[i + 2]; - const b3 = buf[i + 3]; - if ((b1 & 0xc0) !== 0x80 || (b2 & 0xc0) !== 0x80 || (b3 & 0xc0) !== 0x80) return null; - cp = ((b0 & 0x07) << 18) | ((b1 & 0x3f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f); - len = 4; - } else { - return null; // Invalid lead byte - } - codepoints.push({ cp, byteOffset: i }); - i += len; - } - return codepoints; -} // --------------------------------------------------------------------------- // git helpers @@ -297,7 +253,7 @@ function getTrackedFiles(cwd) { /** * Get staged file list for --staged mode. * Uses `git diff --cached --name-only -z --diff-filter=ACMR` for paths. - * D-CB8: content read from git index via `git cat-file blob :`. + * 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); @@ -317,16 +273,83 @@ function getStagedFiles(cwd) { } /** - * Read file content from the git index (staged blob) via `git cat-file blob :`. - * D-CB8: never reads from the working tree in --staged mode. + * 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 readIndexBlob(path, cwd) { - const r = gitExec(['cat-file', 'blob', `:${path}`], cwd); +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: cannot read staged blob for ${path}`); + console.error( + `✖ verify-no-control-bytes: git cat-file --batch exited ${r.status}: ` + + `${r.stderr.toString('utf8').trim()}`, + ); process.exit(2); } - return r.stdout; // Buffer + + 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; } // --------------------------------------------------------------------------- @@ -335,6 +358,16 @@ function readIndexBlob(path, cwd) { /** * 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 @@ -353,18 +386,51 @@ export function scanBuffer(buf, relPath, allowedCps) { return []; // Allowed binary file } - const codepoints = decodeUtf8(buf); - if (codepoints === null) { - return [{ codepoint: -1, byteOffset: 0, invalidUtf8: true }]; - } - + // Inline UTF-8 decode + hazard scan (no intermediate codepoint array). const hits = []; - for (let i = 0; i < codepoints.length; i++) { - const { cp, byteOffset } = codepoints[i]; - const nextCp = i + 1 < codepoints.length ? codepoints[i + 1].cp : null; + 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, allowed: allowedCps.has(cp) }); + hits.push({ codepoint: cp, byteOffset: i, allowed: allowedCps.has(cp) }); } + i += len; } return hits; } @@ -409,15 +475,39 @@ function main() { } // ---- D-CB5: Non-vacuity guard (AC-6) ---- - // Zero files scanned — including in --staged mode — is exit 1, never 0. - // "Scanned 0 file(s), 0 byte(s): clean" is indistinguishable from a broken - // path-discovery that never found anything (the exact shape D-CB5 forbids). + // 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) { - console.error('✖ verify-no-control-bytes: zero files scanned (D-CB5: empty scan is not a pass)'); - console.error(isStaged - ? ' No staged files found — stage at least one file before running the pre-commit hook.' - : ' If this is a new repo with no commits, run `git add` first.'); - process.exit(1); + 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) ---- @@ -432,6 +522,11 @@ function main() { } } + // ---- 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; @@ -444,7 +539,11 @@ function main() { let buf; try { if (isStaged) { - buf = readIndexBlob(entry.path, cwd); + 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)); }