diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c604cb3..aafa980 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 b2555e6..3903b09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -306,3 +306,26 @@ jobs: - name: pytest against the installed wheel (perf, advisory) continue-on-error: true run: pytest crates/mds-python/tests -q -m perf + + # ------------------------------------------------------------------------- + # #288: Source-hygiene gate — rejects hazardous codepoints (control bytes, + # bidi overrides, BOM) from tracked source. Scans the full tracked tree via + # `git ls-files`, reads content at codepoint level (pure Node; no grep -P + # which BSD grep lacks). Positive-control suite proves the check is live. + # + # D-CB7: BSD grep lacks -P and exits 2 with empty output, making the absence + # of hazard bytes indistinguishable from a broken invocation (avoids PF-013). + # D-CB5: Zero-files-scanned is exit 1, not exit 0 (avoids PF-016). + # ------------------------------------------------------------------------- + source-hygiene: + name: Source hygiene + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + - name: Scan tracked source for hazardous codepoints + run: node scripts/verify-no-control-bytes.mjs + - name: Run positive-control and class-completeness suite + run: node --test scripts/__test__/*.spec.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e389c2c..5ff47a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,16 @@ jobs: with: { node-version: 22 } - name: "Assert synchronized versions, no file: refs" run: node scripts/verify-versions.mjs + # #288: Source-hygiene gate — also runs on tag pushes via this job. + # ci.yml does not run on tag pushes, so these two steps ensure the full + # gate (scanner + positive-control suite) is enforced at release time. + # The positive-control suite pins HAZARD_RANGES (D-CB1a) so a silently- + # narrowed hazard class cannot exit 0 on the release path (ADR-009/PF-013). + # Uses the same Node 22 install above. + - name: "Assert no hazardous codepoints in tracked source" + run: node scripts/verify-no-control-bytes.mjs + - name: "Run positive-control and class-completeness suite" + run: node --test scripts/__test__/*.spec.mjs # --------------------------------------------------------------------------- # A6 — cross-compile the native addon for all 7 targets. diff --git a/CHANGELOG.md b/CHANGELOG.md index 66f954a..d6f0e2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -624,6 +624,32 @@ diagnostic messages must update to check for the `\uXXXX` literal form instead. (span attribution machinery, `end_offset` fields, `FixLineSpan` planner) pushed the optimized WASM binary to ~808 KB. The guard in `ci.yml` was raised accordingly. +- **Code of Conduct** (#38): `CODE_OF_CONDUCT.md` at the repository root, using + Contributor Covenant 2.1 with `deanshrn@gmail.com` as the enforcement contact. + Linked from `CONTRIBUTING.md` and `README.md`. + +- **Source-hygiene CI gate** (#288): `scripts/verify-no-control-bytes.mjs` scans + every tracked file for hazardous codepoints — C0 control characters (excluding + TAB and LF), DEL, C1 (at codepoint level, catching UTF-8-encoded NEL U+0085), + the twelve `Bidi_Control=Yes` characters (Trojan Source / CVE-2021-42574), the + JavaScript line/paragraph separators U+2028 and U+2029, and U+FEFF (BOM). + Runs in CI on every pull_request and on tag pushes (release.yml). An opt-in + pre-commit hook (`scripts/hooks/pre-commit`) is provided; it reads the staged + blob via `git cat-file`, not the working tree. Also remediates seven live + U+0085 bytes that had been injected into tracked source by the edit tooling + (PF-018). + +- **Pre-merge check verifier** (#289): `scripts/verify-pr-checks.mjs` guards + against PF-017 (a cancelled CI run reads as "not failing" to `gh pr merge + --admin`). It evaluates three tiers: Tier A asserts every required + branch-protection context is `completed+success`; Tier B fails on any + non-required check-run that concluded + `failure/cancelled/timed_out/action_required/stale`; Tier C (legacy commit + statuses) is advisory. It emits a `gh pr merge --squash --match-head-commit + ` command pinned to the verified SHA. Exit 0: Tier A and Tier B pass; + exit 1: any Tier A/B failure or zero check-runs found; exit 2: + tool/permission errors. + ### Changed - **napi and Python `compileFile` / `compile_file` now emit root-relative diff --git a/CLAUDE.md b/CLAUDE.md index 316cd0c..ccf141a 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 0000000..c3c3771 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,83 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at deanshrn@gmail.com. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd4ac1c..f151a40 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,6 +62,44 @@ MDS_BACKEND=native npm test -w @mdscript/mds MDS_BACKEND=wasm npm test -w @mdscript/mds ``` +### Source hygiene + +All tracked source must be free of hazardous codepoints. The gate runs +automatically in CI (`source-hygiene` job) and can be run locally: + +```bash +node scripts/verify-no-control-bytes.mjs # full tracked-tree scan +node scripts/verify-no-control-bytes.mjs --staged # staged-only (pre-commit) +npm run test:gates # positive-control spec suite +``` + +Exit codes are a contract: `0` no hazards found (prints file and byte counts +for non-vacuity), `1` hazard found or scan failed closed (zero files scanned, +unreadable path, stale allowlist entry, git not on PATH), `2` indeterminate +(a git subcommand failed unexpectedly — never treat `2` as clean). + +**Opt-in pre-commit hook** (replaces `.git/hooks` wholesale — document your +existing local hooks before enabling): + +```bash +git config core.hooksPath scripts/hooks +``` + +**Hazard class**: C0 (0x00-0x1F) excluding TAB and LF, DEL (0x7F), C1 +(0x80-0x9F at codepoint level — catches UTF-8-encoded NEL 0xC2 0x85), the +twelve Unicode `Bidi_Control=Yes` codepoints including U+061C (Trojan Source, +CVE-2021-42574), plus U+2028 (LS), U+2029 (PS), and U+FEFF (BOM). CR (U+000D) +is permitted only as the first byte of CRLF. + +**BSD grep trap**: macOS ships BSD grep, which has no `-P` flag and exits 2 +with empty output. That empty output is indistinguishable from a clean scan. +The gate uses pure Node codepoint iteration — never grep. + +**Authoring rule**: when writing code or documentation that mentions hazardous +codepoints, use numeric notation (`U+202E`, `0x202e`, or `String.fromCodePoint(0x202e)`) +rather than backslash-u escapes. The edit tooling decodes the 4-hex-digit form +`\uXXXX` to live bytes, injecting the hazard into the very file that warns about it. + ## Pull requests - **Conventional Commits**: PR titles and commits follow @@ -73,8 +111,57 @@ MDS_BACKEND=wasm npm test -w @mdscript/mds implementation details. - **No regressions**: every existing test must still pass. +## Merging + +**Admin merges require the pre-merge check verifier.** GitHub's `--admin` +flag bypasses required-status enforcement; a cancelled CI run reads as +"not failing" rather than as failing (PF-017). Run the verifier before any +`gh pr merge --admin`: + +```bash +node scripts/verify-pr-checks.mjs +``` + +The verifier reads required contexts from live branch protection, checks that +every context is `status=completed` AND `conclusion=success`, and on pass +emits a `gh pr merge --squash --match-head-commit ` command pinned to +the verified SHA (closes the TOCTOU window). + +Exit codes are a contract: `0` all Tier A and Tier B checks passed, `1` any +Tier A failure (required context missing or non-success), any Tier B failure +(non-required check-run concluded failure/cancelled/timed_out/action_required/ +stale), or zero check-runs found, `2` the tool could not tell (protection +unreadable, no required contexts configured, `gh` older than 2.31, incomplete +pagination). **Only `0` means verified** — never read `2` as a pass. + +Tier B is load-bearing: `source-hygiene` is not among `main`'s required +branch-protection contexts, so Tier B is the sole mechanism that makes a +failing `source-hygiene` run block an `--admin` merge. + +Scope, stated so it is not assumed: the verifier checks the checks *on one +commit*. It does **not** assert that the head is up to date with the base +branch, so a stale-but-green head can still be merged under `--admin` even +after the verifier passes. Keep the branch rebased. It does **not** assert that +`source-hygiene` is a required context — `--admin` bypasses required-status +enforcement outright for non-required checks, and Tier B is the binding +mechanism. Tier B skips non-required check-runs still `queued` or `in_progress`: +a verifier pass issued while `source-hygiene` is still running has verified +nothing about source hygiene. Ensure all jobs have completed before running the +verifier. + +If the base branch is unprotected (e.g. a wave branch), supply `--required-from`: + +```bash +node scripts/verify-pr-checks.mjs --required-from main +``` + ## Security Please report vulnerabilities privately. See [SECURITY.md](./SECURITY.md). Do not open public issues for security problems. +## Code of Conduct + +This project follows the [Contributor Covenant 2.1](CODE_OF_CONDUCT.md). By +participating, you agree to abide by its terms. + diff --git a/README.md b/README.md index 4a96df1..923b638 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 9385b04..32ca105 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -34,7 +34,6 @@ These are **not** automated and must be done before the first release: `mds-core` and `mds-cli` on crates.io. 4. **Enable GitHub private vulnerability reporting** (Settings → Code security → Private vulnerability reporting) so the SECURITY.md flow works. -5. Add **`CODE_OF_CONDUCT.md`** (tracked in #38) if not already present. ## Pre-flight (before tagging) @@ -58,6 +57,12 @@ npm run build --workspaces --if-present npm test --workspaces --if-present node scripts/verify-versions.mjs +# Source hygiene and pre-merge check gates +node scripts/verify-no-control-bytes.mjs +npm run test:gates # positive-control spec suite +# Before any --admin merge (PF-017 guard — cancelled runs read as green): +node scripts/verify-pr-checks.mjs + # Packaging spot-check (inspect tarball contents) npm pack -w @mdscript/mds --dry-run npm pack -w @mdscript/mds-wasm --dry-run @@ -88,9 +93,19 @@ The release is driven by pushing a `vX.Y.Z` tag. This is how all versions have s 1. **Bump versions:** `node scripts/bump-version.mjs X.Y.Z` (updates all manifests and stamps the CHANGELOG, opening a fresh `[Unreleased]`). -2. **Land the bump on `main`:** open a PR (CI-gated). `main` is protected and the - sole code-owner can't self-approve, so the merge needs an admin override - (`enforce_admins=false` permits it). Squash-merge to keep linear history. +2. **Land the bump on `main`:** open a PR (CI-gated). Once CI is green, run the + pre-merge check verifier before merging — a cancelled run reads as green under + `--admin` (PF-017): + ```bash + node scripts/verify-pr-checks.mjs + ``` + On exit 0 the script prints the exact merge command — copy and run it verbatim: + ```bash + gh pr merge --squash --match-head-commit + ``` + (`main` is protected; the sole code-owner can't self-approve so `--admin` is + required. `--match-head-commit` closes the TOCTOU window between verification + and merge.) 3. **Tag the merged commit and push:** ```bash git tag -a vX.Y.Z -m vX.Y.Z diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index ca302a5..8c62f35 100644 --- a/crates/mds-cli/tests/cli_lint.rs +++ b/crates/mds-cli/tests/cli_lint.rs @@ -1734,7 +1734,7 @@ fn lint_del_and_c1_in_diagnostic_frame_is_sanitized() { /// T-9 [AC-C3]: `mds lint --format json` on a source whose `duplicate-import` /// diagnostic message embeds a raw C1 control character (U+0085 NEL) must emit /// valid JSON with no raw control bytes anywhere — in particular the embedded -/// path must be escaped to the 6-char literal `…`. +/// path must be escaped to its 6-character JSON escape (backslash, u, 0, 0, 8, 5). /// /// ## Why this vector? /// @@ -1750,7 +1750,7 @@ fn lint_del_and_c1_in_diagnostic_frame_is_sanitized() { /// imported twice and embeds the raw import path in its message. A module /// whose file *name* contains U+0085 therefore injects that byte into the /// diagnostic message. When `to_canonical_json` serializes the result, it -/// must sanitize U+0085 → `…` (6-char ASCII literal); if that +/// must sanitize U+0085 into its 6-character ASCII JSON escape; if that /// sanitization is removed the raw 0xC2 0x85 bytes appear in the JSON wire. /// /// ## Failure mode (regression guard) @@ -1760,7 +1760,8 @@ fn lint_del_and_c1_in_diagnostic_frame_is_sanitized() { /// - Gate 2 FAILS: `assert_no_control_chars` finds U+0085 (a C1 char) in /// the JSON wire output /// - Gate 3 FAILS: the per-message check finds U+0085 in the diagnostic message -/// - The positive assertion FAILS: `…` is not present when raw bytes leak +/// - The positive assertion FAILS: the 6-character escape is not present when +/// raw bytes leak #[test] fn lint_json_hostile_source_output_contains_no_raw_control_bytes() { let dir = tempfile::tempdir().unwrap(); @@ -1834,13 +1835,13 @@ fn lint_json_hostile_source_output_contains_no_raw_control_bytes() { assert_no_control_chars(msg, "T-9 diagnostic message"); } - // Positive assertion (non-vacuous, PF-013): the sanitized literal `…` + // Positive assertion (non-vacuous, PF-013): the sanitized escape for U+0085 // must appear in at least one message. If sanitization is removed the raw // U+0085 character leaks and this assertion fails because the 6-char literal // is absent while the raw codepoint (caught by Gate 2/3) is present. // - // After JSON deserialisation by serde_json the string value is `…` - // (6 chars: backslash, u, 0, 0, 8, 5). + // After JSON deserialisation by serde_json the string value is the + // 6-character sequence: backslash, u, 0, 0, 8, 5. let has_sanitized_nel = all_diags.iter().any(|d| { d["message"] .as_str() diff --git a/crates/mds-napi/__test__/index.spec.mjs b/crates/mds-napi/__test__/index.spec.mjs index 21230f2..45570d5 100644 --- a/crates/mds-napi/__test__/index.spec.mjs +++ b/crates/mds-napi/__test__/index.spec.mjs @@ -1323,7 +1323,8 @@ describe('ESC-injection hardening (issue #176 / CWE-150)', () => { // U+0085 (NEL) is a C1 control char that passes serde_yaml_ng YAML parsing // (unlike ESC/DEL), making it a reachable C1 ESC-injection vector for lintVirtual. // The duplicate-import rule fires and embeds the raw module name in its message; - // after sanitization the message must carry … and no raw C1 chars. + // after sanitization the message must carry the 6-character escape for + // U+0085 (backslash, u, 0, 0, 8, 5) and no raw C1 chars. const nel = String.fromCharCode(0x85); const moduleName = `fo${nel}o.mds`; const modules = { diff --git a/crates/mds-wasm/tests/web.rs b/crates/mds-wasm/tests/web.rs index d25da47..8c49e1f 100644 --- a/crates/mds-wasm/tests/web.rs +++ b/crates/mds-wasm/tests/web.rs @@ -930,7 +930,7 @@ fn wasm_del_in_error_message_is_escaped() { fn wasm_lint_virtual_nel_in_module_name_sanitizes_message() { // T-15/F6-C1: U+0085 (NEL/C1) in lintVirtual module name — same lint-path pattern // as F6 with a C1 control character. NEL passes serde_yaml_ng (unlike ESC/DEL), - // making it a reachable C1 vector. Verifies the sanitized … literal appears. + // making it a reachable C1 vector. Verifies the sanitized U+0085 literal appears. let nel = '\u{0085}'; let module_name = format!("fo{nel}o.mds"); let main_src = format!("@import \"./{module_name}\"\n@import \"./{module_name}\"\n"); diff --git a/package.json b/package.json index 2b91663..2362a57 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,8 @@ { "private": true, "workspaces": ["packages/*", "crates/mds-napi"], - "engines": { "node": ">=22.0.0" } + "engines": { "node": ">=22.0.0" }, + "scripts": { + "test:gates": "node --test scripts/__test__/*.spec.mjs" + } } diff --git a/packages/bundler-utils/__test__/transform.spec.mjs b/packages/bundler-utils/__test__/transform.spec.mjs index 80dcc97..378cf80 100644 --- a/packages/bundler-utils/__test__/transform.spec.mjs +++ b/packages/bundler-utils/__test__/transform.spec.mjs @@ -180,8 +180,8 @@ describe('createMdsTransformer', () => { }); test('U+2028 and U+2029 in output are escaped in export default line', async () => { - const u2028 = '
'; - const u2029 = '
'; + const u2028 = String.fromCodePoint(0x2028); + const u2029 = String.fromCodePoint(0x2029); const mds = createMockMds({ async compileFile() { return { @@ -223,8 +223,8 @@ describe('createMdsTransformer', () => { }); test('metadata is safe for inline script embedding (no or U+2028/U+2029)', async () => { - const u2028 = '
'; - const u2029 = '
'; + const u2028 = String.fromCodePoint(0x2028); + const u2029 = String.fromCodePoint(0x2029); const mds = createMockMds({ async compileFile() { return { @@ -377,8 +377,8 @@ describe('createMdsTransformer — intrinsic bundler export', () => { }); test('AC-API-14: messages with U+2028/U+2029 are safe in JSON array export', async () => { - const u2028 = '
'; - const u2029 = '
'; + const u2028 = String.fromCodePoint(0x2028); + const u2029 = String.fromCodePoint(0x2029); const mds = createMockMds({ async compileFile() { return { diff --git a/scripts/__test__/code-of-conduct.spec.mjs b/scripts/__test__/code-of-conduct.spec.mjs new file mode 100644 index 0000000..ab3a253 --- /dev/null +++ b/scripts/__test__/code-of-conduct.spec.mjs @@ -0,0 +1,109 @@ +/** + * Tests for CODE_OF_CONDUCT.md (AC-1, AC-2 — issue #38) + * + * Verifies that the committed Code of Conduct is genuine Contributor Covenant 2.1 + * text with only the maintainer contact substituted, using an offline fixture so + * no network access is required at test time. + * + * applies ADR-009, avoids PF-013: the sha256 digest anchors the fixture to the + * upstream source — asserting only its length would not catch content tampering. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, statSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(fileURLToPath(import.meta.url), '../../..'); +const FIXTURES = join(ROOT, 'scripts/__test__/fixtures'); + +// --------------------------------------------------------------------------- +// AC-1, AC-2: Code of Conduct fixture verification +// --------------------------------------------------------------------------- +describe('AC-1 AC-2: Code of Conduct verification', () => { + // D-COC2: the fixture is the recorded UPSTREAM text, and the sha256 below is + // the whole point of recording it — without a pinned digest, "CODE_OF_CONDUCT.md + // differs from the fixture in exactly one line" can be satisfied by editing the + // fixture. `hash.length === 64` is true of every sha256 ever computed and + // asserts nothing (applies ADR-009, avoids PF-013). + // + // Provenance — reproducible derivation (a reviewer can re-derive FIXTURE_SHA256 + // independently without trusting this file alone): + // + // Source URL (Contributor Covenant 2.1): + // https://raw.githubusercontent.com/EthicalSource/contributor_covenant/ + // release/content/version/2/1/code_of_conduct.md + // + // The upstream file carries a Hugo TOML front-matter block (+++ ... +++) as + // site metadata followed by a blank line before the document body. Strip both + // and hash the remainder: + // + // URL='https://raw.githubusercontent.com/EthicalSource/contributor_covenant/release/content/version/2/1/code_of_conduct.md' + // curl -sL "$URL" \ + // | awk '/^\+\+\+$/{c++; if(c==2){emit=1}; next} emit && !started && /^$/{next} emit{started=1; print}' \ + // | shasum -a 256 + // # Expected: 369bf7301883368fc19203bd0f1233fed2b83f0378ad19c4d0708bf61925339b + // + // Verified 2026-08-13: the command above produces FIXTURE_SHA256 (5478 bytes). + // + // Historical reference — upstream unstripped digest at plan-authoring time + // (the plan recorded sha256 977d781349351fd7c1f076e4c7dc7de2a05b40e12c773542c3815dd4ce7f37ba, + // 5480 bytes; the upstream body has since changed — 5579 bytes unstripped as of + // 2026-08-13 — but the stripped body matches the fixture exactly). + // + // If re-running the derivation command above produces a hash other than + // FIXTURE_SHA256, the upstream body has changed; review the diff and update + // the fixture and this comment if the change is legitimate. + const FIXTURE_SHA256 = '369bf7301883368fc19203bd0f1233fed2b83f0378ad19c4d0708bf61925339b'; + const FIXTURE_BYTES = 5478; + + test('fixture matches its recorded sha256 and byte count exactly', () => { + const fixturePath = join(FIXTURES, 'contributor-covenant-2.1.md'); + const buf = readFileSync(fixturePath); + const hash = createHash('sha256').update(buf).digest('hex'); + const size = statSync(fixturePath).size; + assert.equal(size, FIXTURE_BYTES, `fixture must be exactly ${FIXTURE_BYTES} bytes; got ${size}`); + assert.equal(hash, FIXTURE_SHA256, + 'fixture no longer matches the recorded upstream digest — the vendored Contributor ' + + 'Covenant text was modified; restore it rather than updating this constant'); + }); + + test('CODE_OF_CONDUCT.md differs from fixture in exactly one line (contact substitution)', () => { + const cocPath = join(ROOT, 'CODE_OF_CONDUCT.md'); + const fixturePath = join(FIXTURES, 'contributor-covenant-2.1.md'); + const coc = readFileSync(cocPath, 'utf8'); + const fixture = readFileSync(fixturePath, 'utf8'); + + const cocLines = coc.split('\n'); + const fixtureLines = fixture.split('\n'); + + // Find differing lines + const maxLen = Math.max(cocLines.length, fixtureLines.length); + const diffs = []; + for (let i = 0; i < maxLen; i++) { + if (cocLines[i] !== fixtureLines[i]) { + diffs.push({ lineNo: i + 1, coc: cocLines[i], fixture: fixtureLines[i] }); + } + } + + assert.equal(diffs.length, 1, + `CODE_OF_CONDUCT.md must differ from fixture in exactly 1 line; got ${diffs.length} diff(s): ` + + JSON.stringify(diffs)); + assert.ok(diffs[0].coc.includes('deanshrn@gmail.com'), + `the differing line must contain 'deanshrn@gmail.com'; got: ${diffs[0].coc}`); + assert.ok( + (diffs[0].fixture ?? '').includes('[INSERT CONTACT METHOD]'), + `fixture's differing line must contain '[INSERT CONTACT METHOD]'; got: ${diffs[0].fixture}` + ); + }); + + test('CODE_OF_CONDUCT.md does not contain [INSERT CONTACT METHOD]', () => { + const coc = readFileSync(join(ROOT, 'CODE_OF_CONDUCT.md'), 'utf8'); + assert.ok(!coc.includes('[INSERT CONTACT METHOD]'), + 'CODE_OF_CONDUCT.md must not contain [INSERT CONTACT METHOD]'); + assert.ok(coc.includes('deanshrn@gmail.com'), + 'CODE_OF_CONDUCT.md must contain the contact email'); + }); +}); diff --git a/scripts/__test__/fixtures/checks-main-113f472.json b/scripts/__test__/fixtures/checks-main-113f472.json new file mode 100644 index 0000000..6447a18 --- /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 0000000..e7a8ee1 --- /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 0000000..e7a8ee1 --- /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 0000000..737de08 --- /dev/null +++ b/scripts/__test__/fixtures/contributor-covenant-2.1.md @@ -0,0 +1,83 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [INSERT CONTACT METHOD]. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/scripts/__test__/fixtures/protection-main.json b/scripts/__test__/fixtures/protection-main.json new file mode 100644 index 0000000..8287869 --- /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 0000000..14fde9e --- /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 0000000..6a24be3 --- /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 0000000..01c8679 --- /dev/null +++ b/scripts/__test__/verify-no-control-bytes.spec.mjs @@ -0,0 +1,791 @@ +/** + * Tests for scripts/verify-no-control-bytes.mjs + * + * All hazard bytes are constructed AT RUNTIME using Buffer.from([0xNN]) or + * String.fromCodePoint(0xNNNN). No hazard literal or backslash-u escape + * appears in this file. (avoids PF-018, applies D-CB2) + * + * Tests that require a real git repository use mkdtemp + git init. The + * hermetic-git test (AC-11) proves the git ls-files discovery path rather + * than just the byte predicate, running in the PRIMARY checkout context + * (avoids PF-016). + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, rmSync, readFileSync, symlinkSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync, execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { + HAZARD_RANGES, + isHazardous, + scanBuffer, + BINARY_ALLOWLIST, + HAZARD_ALLOWLIST, +} from '../verify-no-control-bytes.mjs'; + +const ROOT = resolve(fileURLToPath(import.meta.url), '../../..'); +const SCANNER = join(ROOT, 'scripts/verify-no-control-bytes.mjs'); + +// --------------------------------------------------------------------------- +// Helper: run scanner as subprocess +// --------------------------------------------------------------------------- +function runScanner(args = [], opts = {}) { + const r = spawnSync(process.execPath, [SCANNER, ...args], { + cwd: opts.cwd ?? ROOT, + encoding: 'utf8', + env: { ...process.env, ...(opts.env ?? {}) }, + timeout: 30000, + }); + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; +} + +// --------------------------------------------------------------------------- +// Helper: create a minimal git repo in a temp directory +// --------------------------------------------------------------------------- +function mkTempGitRepo() { + const dir = mkdtempSync(join(tmpdir(), 'mds-scan-')); + const git = (...args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: 'pipe' }); + git('init'); + git('config', 'user.email', 'test@test.test'); + git('config', 'user.name', 'Test'); + return { dir, git }; +} + +function cleanup(dir) { + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } +} + +// --------------------------------------------------------------------------- +// AC-12, AC-13: Golden-set completeness — hazard class cannot silently narrow +// --------------------------------------------------------------------------- +describe('AC-12 AC-13: hazard class golden set', () => { + + test('HAZARD_RANGES has exactly 21 entries (golden count)', () => { + // D-CB1a: this count is the golden reference. If an entry is removed, + // this test fails — silent narrowing is impossible. + assert.equal(HAZARD_RANGES.length, 21, + `HAZARD_RANGES must have 21 entries; got ${HAZARD_RANGES.length}. ` + + `A member was silently removed (D-CB1a prevents this).`); + }); + + test('HAZARD_RANGES contains every required member', () => { + // Golden list of expected entries (AC-12). Numbers = individual codepoints. + const expectedNumbers = new Set([ + 0x061c, // U+061C Arabic Letter Mark + 0x200e, // U+200E LRM + 0x200f, // U+200F RLM + 0x202a, // U+202A LRE + 0x202b, // U+202B RLE + 0x202c, // U+202C PDF + 0x202d, // U+202D LRO + 0x202e, // U+202E RLO + 0x2066, // U+2066 LRI + 0x2067, // U+2067 RLI + 0x2068, // U+2068 FSI + 0x2069, // U+2069 PDI + 0x2028, // U+2028 LS + 0x2029, // U+2029 PS + 0xfeff, // U+FEFF BOM + ]); + const expectedRanges = [ + { from: 0x00, to: 0x08 }, // C0 below TAB + { from: 0x0b, to: 0x0c }, // C0 VT/FF + { from: 0x0e, to: 0x1f }, // C0 above CR + { from: 0x7f, to: 0x7f }, // DEL + { from: 0x80, to: 0x9f }, // C1 + ]; + const crlfEntry = HAZARD_RANGES.find(e => e && typeof e === 'object' && e.crlfException); + + // Check all expected numeric codepoints are present + const actualNumbers = new Set(HAZARD_RANGES.filter(e => typeof e === 'number')); + for (const cp of expectedNumbers) { + assert.ok(actualNumbers.has(cp), + `Missing codepoint U+${cp.toString(16).toUpperCase().padStart(4, '0')} from HAZARD_RANGES`); + } + for (const cp of actualNumbers) { + assert.ok(expectedNumbers.has(cp), + `Unexpected codepoint U+${cp.toString(16).toUpperCase().padStart(4, '0')} in HAZARD_RANGES`); + } + + // Check all expected ranges are present + for (const er of expectedRanges) { + const found = HAZARD_RANGES.some(e => + e && typeof e === 'object' && !e.crlfException && e.from === er.from && e.to === er.to); + assert.ok(found, `Missing range { from: 0x${er.from.toString(16)}, to: 0x${er.to.toString(16)} }`); + } + + // Check CR CRLF-exception entry exists + assert.ok(crlfEntry && crlfEntry.cp === 0x0d, + 'Missing { cp: 0x0d, crlfException: true } entry for CR'); + }); + + test('AC-13: documented divergence from Rust assert_no_control_chars', () => { + // The Rust helper flags ALL CR unconditionally. + // The JS scanner permits CR when immediately followed by LF (D-CB3). + // This is the ONLY documented divergence. + + // Verify CR alone = hazardous in JS scanner + assert.equal(isHazardous(0x0d, null), true, 'lone CR must be hazardous'); + assert.equal(isHazardous(0x0d, 0x61), true, 'CR followed by non-LF must be hazardous'); + + // Verify CR + LF = NOT hazardous (the CRLF exception) + assert.equal(isHazardous(0x0d, 0x0a), false, 'CR followed by LF (CRLF) must NOT be hazardous (D-CB3)'); + + // Confirm all other C0 entries match (no other divergence) + for (let cp = 0x00; cp <= 0x1f; cp++) { + if (cp === 0x09 || cp === 0x0a || cp === 0x0d) continue; // TAB, LF, CR handled specially + assert.equal(isHazardous(cp, null), true, `C0 0x${cp.toString(16).padStart(2,'0')} must be hazardous`); + } + + // Verify no false positives on TAB and LF + assert.equal(isHazardous(0x09, null), false, 'TAB must NOT be hazardous'); + assert.equal(isHazardous(0x0a, null), false, 'LF must NOT be hazardous'); + }); + + test('C1 range covers U+0080-U+009F including NEL (U+0085), and stops at U+00A0', () => { + // AC-12: The C1 range { from: 0x80, to: 0x9f } must cover all codepoints in + // that band, including U+0085 (C1 NEL) — the exact byte PF-018 injected into + // tracked source three times in this repo. + // + // The genuine non-vacuity guard for D-CB1a is the golden-count test at line 67 + // (exactly 21 entries) and the bidirectional membership assertions above (lines + // 104-124); those tests catch both removal and narrowing. This test documents + // the boundary behaviour of the C1 range specifically. + const nel = 0x85; // U+0085 — C1 NEL; written as hex, not backslash-u (D-CB2) + assert.equal(isHazardous(nel, null), true, + 'U+0085 (C1 NEL) must be detected — this is the exact byte PF-018 injected into tracked source'); + + // Verify U+0080 (C1 low end) and U+009F (C1 high end) are caught + assert.equal(isHazardous(0x80, null), true, 'U+0080 (C1 boundary) must be hazardous'); + assert.equal(isHazardous(0x9f, null), true, 'U+009F (C1 boundary) must be hazardous'); + // Confirm U+00A0 is NOT hazardous (just outside C1 range) + assert.equal(isHazardous(0xa0, null), false, 'U+00A0 (NBSP) must NOT be hazardous'); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-7, AC-8, AC-9: positive controls and false-positive tests +// --------------------------------------------------------------------------- +describe('AC-7 AC-8 AC-9: positive controls and clean-file checks', () => { + + test('AC-7 PC-1: planted ESC (0x1B) in .rs file → exits 1 naming file and U+001B', () => { + const { dir, git } = mkTempGitRepo(); + try { + const esc = Buffer.from([0x1b]); // ESC byte — constructed at runtime, not a literal + const content = Buffer.concat([Buffer.from('fn main() { '), esc, Buffer.from(' }')]); + writeFileSync(join(dir, 'src.rs'), content); + git('add', 'src.rs'); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1, 'scanner must exit 1 on ESC in tracked .rs file'); + assert.ok(r.stderr.includes('src.rs'), 'error must name the file'); + assert.ok(r.stderr.includes('U+001B'), 'error must include U+001B codepoint'); + } finally { cleanup(dir); } + }); + + test('AC-7 PC-2: planted ESC in .md file → exits 1', () => { + const { dir, git } = mkTempGitRepo(); + try { + const esc = Buffer.from([0x1b]); + writeFileSync(join(dir, 'doc.md'), Buffer.concat([Buffer.from('# heading '), esc])); + git('add', 'doc.md'); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1); + assert.ok(r.stderr.includes('doc.md')); + assert.ok(r.stderr.includes('U+001B')); + } finally { cleanup(dir); } + }); + + test('AC-7 PC-3: planted ESC in .json file → exits 1', () => { + const { dir, git } = mkTempGitRepo(); + try { + const esc = Buffer.from([0x1b]); + writeFileSync(join(dir, 'data.json'), Buffer.concat([Buffer.from('{"a":"'), esc, Buffer.from('"}')])); + git('add', 'data.json'); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1); + assert.ok(r.stderr.includes('data.json')); + assert.ok(r.stderr.includes('U+001B')); + } finally { cleanup(dir); } + }); + + test('AC-7 PC-4: planted RLO (U+202E) in .md file → exits 1 naming U+202E', () => { + const { dir, git } = mkTempGitRepo(); + try { + // U+202E = Right-to-Left Override (Trojan Source bidi char) + const rlo = Buffer.from(String.fromCodePoint(0x202e), 'utf8'); + writeFileSync(join(dir, 'evil.md'), Buffer.concat([Buffer.from('normal '), rlo, Buffer.from(' text')])); + git('add', 'evil.md'); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1); + assert.ok(r.stderr.includes('evil.md')); + assert.ok(r.stderr.includes('U+202E')); + } finally { cleanup(dir); } + }); + + test('AC-8 PC-5: planted U+0085 (C1 NEL, 0xC2 0x85) → exits 1 (the case the baseline missed)', () => { + const { dir, git } = mkTempGitRepo(); + try { + // UTF-8 encoding of U+0085 = 0xC2 0x85 (two bytes) + const nel = Buffer.from([0xc2, 0x85]); + writeFileSync(join(dir, 'nel.txt'), Buffer.concat([Buffer.from('a'), nel, Buffer.from('b')])); + git('add', 'nel.txt'); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1, 'scanner must detect U+0085 (C1 NEL at codepoint level)'); + assert.ok(r.stderr.includes('U+0085'), 'error must reference U+0085'); + } finally { cleanup(dir); } + }); + + test('AC-9 NEG-1: clean international text (accented Latin, CJK, emoji) → exits 0', () => { + const { dir, git } = mkTempGitRepo(); + try { + // These are all valid multi-byte UTF-8 sequences with no hazard codepoints + const content = 'café 日本語 emoji: 🎉\nTabbed\there\n'; + writeFileSync(join(dir, 'intl.md'), content, 'utf8'); + git('add', 'intl.md'); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 0, 'clean international text must exit 0'); + } finally { cleanup(dir); } + }); + + test('AC-9 NEG-3: UTF-8 continuation bytes are not false-positived', () => { + // U+00E9 (é) encodes as 0xC3 0xA9. The continuation byte 0xA9 is in + // range 0x80-0xBF — NOT in the C1 range 0x80-0x9F at codepoint level. + // A naive byte-level C1 check would incorrectly flag 0x89 in 0xE2 0x89 0xA0 ≠. + const neq = 0x2260; // U+2260 NOT EQUAL TO — encodes as 0xE2 0x89 0xA0 + // 0x89 is a continuation byte here; codepoint 0x2260 is NOT in the C1 range + assert.equal(isHazardous(neq, null), false, 'U+2260 (not-equal) must not be hazardous'); + // The codepoint 0x89 on its own IS in C1 range, but UTF-8 continuation bytes + // should never appear as standalone codepoints in valid UTF-8 + assert.equal(isHazardous(0x89, null), true, 'U+0089 itself IS C1-hazardous (standalone codepoint)'); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-10: CR policy +// --------------------------------------------------------------------------- +describe('AC-10: CR policy — CRLF permitted, lone CR rejected', () => { + + test('lone CR (0x0D not followed by LF) → exits 1', () => { + const { dir, git } = mkTempGitRepo(); + try { + // 0x61 0x0D 0x62 = ab (lone CR, not CRLF) + writeFileSync(join(dir, 'lone-cr.txt'), Buffer.from([0x61, 0x0d, 0x62])); + git('add', 'lone-cr.txt'); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1, 'lone CR must be rejected'); + assert.ok(r.stderr.includes('U+000D'), 'error must name U+000D'); + } finally { cleanup(dir); } + }); + + test('CRLF (CR immediately followed by LF) → exits 0', () => { + const { dir, git } = mkTempGitRepo(); + try { + // 0x61 0x0D 0x0A 0x62 = ab + writeFileSync(join(dir, 'crlf.txt'), Buffer.from([0x61, 0x0d, 0x0a, 0x62])); + git('add', 'crlf.txt'); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 0, 'CRLF must be permitted'); + } finally { cleanup(dir); } + }); + + test('isHazardous(CR, LF) = false, isHazardous(CR, non-LF) = true', () => { + assert.equal(isHazardous(0x0d, 0x0a), false, 'CR+LF (CRLF) — not hazardous'); + assert.equal(isHazardous(0x0d, 0x61), true, 'CR+a (lone-ish CR) — hazardous'); + assert.equal(isHazardous(0x0d, null), true, 'CR at EOF — hazardous'); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-11: hermetic git repo proves git ls-files discovery path (avoids PF-016) +// --------------------------------------------------------------------------- +describe('AC-11: git ls-files discovery path', () => { + + test('planted 0x1B in tracked file exits 1; untracked file exits 0', () => { + const { dir, git } = mkTempGitRepo(); + try { + const esc = Buffer.from([0x1b]); + writeFileSync(join(dir, 'tracked.md'), Buffer.concat([Buffer.from('evil '), esc])); + git('add', 'tracked.md'); + + // Scanner reads git-tracked files — should find the hostile byte + const r1 = runScanner([], { cwd: dir }); + assert.equal(r1.status, 1, 'tracked file with ESC → scanner must exit 1'); + + // Remove from git tracking (but keep on disk as untracked) + git('rm', '--cached', 'tracked.md'); + const r2 = runScanner([], { cwd: dir }); + // With zero tracked files, non-vacuity guard fires (exit 1) — which is correct. + // The scanner proves it reads the tracked set: the hostile file is on disk but untracked. + // If it read the working tree, it would still find the hostile byte even after `git rm --cached`. + // Since zero tracked files → non-vacuity exit 1, we know the scanner used git ls-files. + // To confirm: add a clean file and verify the scanner passes. + writeFileSync(join(dir, 'clean.md'), 'clean content\n'); + git('add', 'clean.md'); + const r3 = runScanner([], { cwd: dir }); + assert.equal(r3.status, 0, + 'after removing hostile file from tracking and adding a clean file, scanner must exit 0 ' + + '(proves working-tree untracked file is NOT scanned)'); + } finally { cleanup(dir); } + }); + +}); + +// --------------------------------------------------------------------------- +// AC-5, AC-6: full-tree scan and non-vacuity guard +// --------------------------------------------------------------------------- +describe('AC-5 AC-6: full-tree scan and non-vacuity', () => { + + test('AC-5 AC-30: scanner exits 0 on real repo tree with >= 500 files and >= 4MB in under 5 s', () => { + // AC-30 (clause a): full tracked-tree scan must complete in under 5 seconds wall-clock. + // A generous CI-safe bound of 5 s is used; local runs are typically < 1 s. + const start = Date.now(); + const r = runScanner([], { cwd: ROOT }); + const elapsed = Date.now() - start; + assert.equal(r.status, 0, `scanner must exit 0 on clean repo tree; stderr: ${r.stderr}`); + assert.ok(elapsed < 5000, + `full-tree scan must complete in < 5 s wall-clock (AC-30); took ${elapsed}ms`); + // Parse scanned file count and byte count from success output + const m = r.stdout.match(/Scanned (\d+) file\(s\), (\d+) byte\(s\)/); + assert.ok(m, `success output must include "Scanned N file(s), M byte(s)"; got: ${r.stdout}`); + const files = parseInt(m[1], 10); + const bytes = parseInt(m[2], 10); + assert.ok(files >= 500, `expected >= 500 files scanned; got ${files}`); + assert.ok(bytes >= 4_000_000, `expected >= 4,000,000 bytes; got ${bytes}`); + }); + + test('AC-6: empty git repo (zero tracked files) → exits 1 with non-vacuity message', () => { + const { dir } = mkTempGitRepo(); + try { + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1, 'empty repo must exit 1 (non-vacuity guard)'); + assert.ok( + r.stderr.includes('zero files scanned') || r.stderr.includes('empty scan'), + `error must mention zero files; got: ${r.stderr}` + ); + } finally { cleanup(dir); } + }); + + test('AC-6 --staged: no staged content (amend/nothing staged) → exits 0 with explicit message', () => { + // D-CB5's non-vacuity guard applies to full-tree mode only. In --staged mode + // an empty ACMR-filtered set is a legitimate state — `git commit --amend + // --no-edit` and `--allow-empty` produce exactly this. Exiting 1 here blocks + // valid commits and trains contributors to reach for --no-verify, which + // disables the gate for ALL commits. Fix: exit 0 with an explicit message. + const { dir } = mkTempGitRepo(); + try { + // Nothing staged — `git diff --cached` returns empty. + const r = runScanner(['--staged'], { cwd: dir }); + assert.equal(r.status, 0, '--staged with nothing staged must exit 0 (no content to scan)'); + assert.ok( + r.stdout.includes('nothing to scan') || r.stdout.includes('no staged'), + `stdout must explain why scanning was skipped; got stdout: ${r.stdout}` + ); + } finally { cleanup(dir); } + }); + + test('AC-6 --staged: deletion-only commit → exits 0 with deletion count', () => { + // A commit that removes files only (git rm) yields zero ACMR-filtered paths + // because D = deletion is excluded from the ACMR filter. The scanner must + // exit 0, not 1. D-CB5 non-vacuity applies to full-tree mode only. + const { dir, git } = mkTempGitRepo(); + try { + // Commit a clean file, then stage its deletion + writeFileSync(join(dir, 'to-delete.md'), 'content\n'); + git('add', 'to-delete.md'); + git('commit', '-m', 'add file'); + git('rm', 'to-delete.md'); + // The deletion is staged; ACMR filter excludes it → zero content-bearing paths + const r = runScanner(['--staged'], { cwd: dir }); + assert.equal(r.status, 0, 'deletion-only staged set must exit 0'); + assert.ok( + r.stdout.includes('deletion') || r.stdout.includes('nothing to scan'), + `stdout must explain why scanning was skipped; got stdout: ${r.stdout}` + ); + } finally { cleanup(dir); } + }); + +}); + +// --------------------------------------------------------------------------- +// AC-16: error cases — invalid UTF-8, NUL, no git, not a repo +// --------------------------------------------------------------------------- +describe('AC-16 AC-20: error cases', () => { + + test('AC-16: invalid UTF-8 → exits non-zero with distinct message', () => { + const { dir, git } = mkTempGitRepo(); + try { + // 0xFF 0xFE 0x41 is not valid UTF-8 (0xFF is never valid) + writeFileSync(join(dir, 'bad.txt'), Buffer.from([0xff, 0xfe, 0x41])); + git('add', 'bad.txt'); + const r = runScanner([], { cwd: dir }); + assert.notEqual(r.status, 0, 'invalid UTF-8 must exit non-zero'); + assert.ok( + r.stderr.includes('invalid UTF-8') || r.stderr.includes('UTF-8'), + `error must mention UTF-8; got: ${r.stderr}` + ); + } finally { cleanup(dir); } + }); + + test('AC-16: NUL byte not in BINARY_ALLOWLIST → exits 1', () => { + const { dir, git } = mkTempGitRepo(); + try { + writeFileSync(join(dir, 'nul.dat'), Buffer.from([0x41, 0x00, 0x42])); + git('add', 'nul.dat'); + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1, 'NUL byte not in BINARY_ALLOWLIST must exit 1'); + assert.ok( + r.stderr.includes('NUL') || r.stderr.includes('BINARY_ALLOWLIST'), + `error must mention NUL or BINARY_ALLOWLIST; got: ${r.stderr}` + ); + } finally { cleanup(dir); } + }); + + test('AC-16: not inside a git work tree → exits 1 (fail-closed)', () => { + // AC-16 mandates exit 1 for all four named failure conditions. "Not a git + // work tree" is a known, named failure — it is fail-closed (exit 1), not + // indeterminate (exit 2). + const dir = mkdtempSync(join(tmpdir(), 'mds-nogit-')); + try { + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1, 'non-git directory must exit 1 (fail-closed)'); + } finally { cleanup(dir); } + }); + + test('AC-14: all 13 previously-live hazards are gone from the four dirty files', () => { + // Verify S0 remediation: the four files that had U+0085/U+2028/U+2029 are now clean. + const dirtyFiles = [ + 'crates/mds-cli/tests/cli_lint.rs', + 'crates/mds-napi/__test__/index.spec.mjs', + 'crates/mds-wasm/tests/web.rs', + 'packages/bundler-utils/__test__/transform.spec.mjs', + ]; + // Run scanner in explicit-path mode on just these four files + // (they are in the real repo working tree, not a temp git repo) + const r = runScanner(dirtyFiles, { cwd: ROOT }); + assert.equal(r.status, 0, + `previously-dirty files must be clean after S0 remediation; stderr: ${r.stderr}`); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-18: --staged mode reads from git index, not working tree +// --------------------------------------------------------------------------- +describe('AC-18: --staged mode reads index blob, not working tree', () => { + + test('Case A: clean staged blob, hostile working tree → exits 0', () => { + const { dir, git } = mkTempGitRepo(); + try { + // Stage a clean file + writeFileSync(join(dir, 'f.txt'), 'clean content\n'); + git('add', 'f.txt'); + // Now overwrite the working tree with a hostile byte WITHOUT staging + const esc = Buffer.from([0x1b]); + writeFileSync(join(dir, 'f.txt'), Buffer.concat([Buffer.from('evil '), esc])); + // --staged reads the INDEX (clean), not the working tree + const r = runScanner(['--staged'], { cwd: dir }); + assert.equal(r.status, 0, + 'clean staged blob + hostile working tree → exit 0 (index is scanned, not working tree)'); + } finally { cleanup(dir); } + }); + + test('Case B: hostile staged blob, clean working tree → exits 1', () => { + const { dir, git } = mkTempGitRepo(); + try { + // Stage a hostile file + const esc = Buffer.from([0x1b]); + writeFileSync(join(dir, 'f.txt'), Buffer.concat([Buffer.from('evil '), esc])); + git('add', 'f.txt'); + // Overwrite working tree with clean content WITHOUT re-staging + writeFileSync(join(dir, 'f.txt'), 'clean now\n'); + // --staged reads the INDEX (hostile), not the working tree + const r = runScanner(['--staged'], { cwd: dir }); + assert.equal(r.status, 1, + 'hostile staged blob + clean working tree → exit 1 (index is scanned, not working tree)'); + } finally { cleanup(dir); } + }); + +}); + +// --------------------------------------------------------------------------- +// AC-15: scanner source itself has no hazard bytes and no PCRE grep flag +// --------------------------------------------------------------------------- +describe('AC-15: scanner source is self-clean', () => { + + test('scanner source files pass their own gate', () => { + const scriptFiles = [ + 'scripts/verify-no-control-bytes.mjs', + 'scripts/verify-pr-checks.mjs', + ]; + const r = runScanner(scriptFiles, { cwd: ROOT }); + assert.equal(r.status, 0, `scanner source files must pass their own gate; stderr: ${r.stderr}`); + }); + + test('scanner source files contain no forbidden grep flag and no backslash-u escape (AC-15, PF-018)', () => { + // AC-15: the scripts, spec files, and hook must not invoke the BSD-incompatible + // grep PCRE flag (BSD grep lacks it, exits 2 with empty output that reads as + // clean — D-CB7), and must not contain a backslash-u-plus-4-hex escape (the + // edit-tooling decode vector that injected live hazard bytes into this repo + // three times — PF-018, D-CB2). + // + // Both search patterns are built from parts / numeric char codes so this test + // does not trip its own rule when the spec file is in the checked file set. + // The forbidden grep invocation is 'grep' joined with ' -P'; split here so + // the contiguous substring is absent from this source file. + const grepPFlag = 'grep' + ' -P'; + // 0x5C = backslash, then 'u' and 4 hex digits: + const bs = String.fromCodePoint(0x5c); + const bsUPattern = new RegExp(bs + 'u[0-9a-fA-F]{4}'); + + const fileSet = [ + 'scripts/verify-no-control-bytes.mjs', + 'scripts/verify-pr-checks.mjs', + 'scripts/__test__/verify-no-control-bytes.spec.mjs', + 'scripts/__test__/verify-pr-checks.spec.mjs', + 'scripts/hooks/pre-commit', + ]; + + for (const rel of fileSet) { + const src = readFileSync(join(ROOT, rel), 'utf8'); + assert.ok( + !src.includes(grepPFlag), + `${rel}: must not invoke the POSIX-extension grep flag (BSD grep lacks PCRE support, exits 2 — AC-15, D-CB7)` + ); + assert.ok( + !bsUPattern.test(src), + `${rel}: must not contain a backslash-u escape (edit tooling decodes them into live bytes — AC-15, PF-018)` + ); + } + }); + +}); + +// --------------------------------------------------------------------------- +// AC-30: hex context stored per hit as a pre-computed string, not as the raw +// buffer — one-file-at-a-time memory discipline, verified by code shape. +// +// AC-30 has three clauses: +// (a) Wall-clock full-tree scan < 5 s — asserted with Date.now() in the +// AC-5 test above (generous CI-safe bound). +// (b) --staged mode < 2 s for a 20-file commit — not directly timed here; +// the same structural bound holds (git cat-file reads one blob at a time). +// (c) MUST NOT hold more than one file's contents in memory at a time — +// verified by code shape: at verify-no-control-bytes.mjs:473, +// hazardHits.push stores { hexCtx } (a pre-computed string) not { buf } +// (the raw buffer), so the buffer is GC-eligible after each iteration. +// +// This describe block tests clause (c) indirectly: by proving the correct +// hexCtx string reaches the output across multiple files, it demonstrates +// that hexCtx was computed and stored before buf went out of scope — which +// is only possible if buf was NOT retained in hazardHits. +// --------------------------------------------------------------------------- +describe('AC-30: hex context stored as string per hit, not as file buffer', () => { + + test('scanner reports hex context for every hazard across multiple files', () => { + // Verify that hexCtx is computed and stored correctly for each hit. + // Memory discipline (clause c) is by code shape: hazardHits stores { hexCtx } + // not { buf } (scanner:473), so buf is GC-eligible after each file's iteration. + // This test proves the correct context string reaches the output regardless + // of how many files are scanned. + const { dir, git } = mkTempGitRepo(); + try { + // Construct two files each with an ESC at a known position + // a.md: "hello " (6 bytes) then ESC — offset 6 + // b.md: "foo " (4 bytes) then ESC — offset 4 + const esc = Buffer.from([0x1b]); + writeFileSync(join(dir, 'a.md'), Buffer.concat([Buffer.from('hello '), esc, Buffer.from(' world')])); + writeFileSync(join(dir, 'b.md'), Buffer.concat([Buffer.from('foo '), esc])); + git('add', 'a.md', 'b.md'); + + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 1, 'scanner must exit 1 for files with hazard bytes'); + // Both files must be named + assert.ok(r.stderr.includes('a.md'), 'must report hazard in a.md'); + assert.ok(r.stderr.includes('b.md'), 'must report hazard in b.md'); + // Hex context lines must appear (proves hexCtx is pre-computed and stored) + assert.ok(r.stderr.includes('context:'), 'must include hex context lines'); + // The codepoint must be identified + assert.ok(r.stderr.includes('U+001B'), 'must name the hazardous codepoint'); + } finally { cleanup(dir); } + }); + +}); + +// --------------------------------------------------------------------------- +// AC-17: allowlist entries are exercised-or-stale +// --------------------------------------------------------------------------- +describe('AC-17: allowlist entries are self-invalidating', () => { + + test('BINARY_ALLOWLIST is empty (no entries)', () => { + assert.equal(BINARY_ALLOWLIST.length, 0, 'BINARY_ALLOWLIST must be empty (D-CB4, D-CB6)'); + }); + + test('HAZARD_ALLOWLIST is empty (no entries)', () => { + assert.equal(HAZARD_ALLOWLIST.length, 0, 'HAZARD_ALLOWLIST must be empty (D-CB4, D-CB6)'); + }); + + test('scanBuffer reports allowlisted hazards as allowed, others as not', () => { + // The allowlist is empty in the shipped file, so the exercised/stale + // machinery below can only be reached by an entry that does not exist yet. + // Assert the predicate the machinery depends on, then drive the whole + // script with a patched allowlist in the integration cases that follow. + const esc = Buffer.from([0x61, 0x1b, 0x62]); + const notAllowed = scanBuffer(esc, 'x.md', new Set()); + assert.equal(notAllowed.length, 1); + assert.equal(notAllowed[0].codepoint, 0x1b); + assert.equal(notAllowed[0].allowed, false); + + const allowed = scanBuffer(esc, 'x.md', new Set([0x1b])); + assert.equal(allowed.length, 1, 'an allowlisted hazard must still be REPORTED to the caller'); + assert.equal(allowed[0].allowed, true, 'so the entry can be recorded as exercised, not stale'); + }); + + /** + * Write a copy of the scanner with a patched HAZARD_ALLOWLIST into `dir`. + * Patching the source is the only way to exercise a non-empty allowlist + * while keeping the shipped allowlist empty (D-CB4). + */ + function writePatchedScanner(dir, entryLiteral) { + const marker = 'export const HAZARD_ALLOWLIST = ['; + const src = readFileSync(SCANNER, 'utf8'); + assert.ok(src.includes(marker), 'scanner must declare HAZARD_ALLOWLIST for this test to patch'); + const patched = src.replace(marker, `${marker} ${entryLiteral},`); + assert.notEqual(patched, src, 'patch must have applied'); + const target = join(dir, 'scan.mjs'); + writeFileSync(target, patched); + return target; + } + + function runPatched(dir, target) { + const r = spawnSync(process.execPath, [target], { cwd: dir, encoding: 'utf8', timeout: 30000 }); + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; + } + + test('Case 1: an exercised entry exits 0 and is named in the output', () => { + const { dir, git } = mkTempGitRepo(); + try { + writeFileSync(join(dir, 'evil.md'), Buffer.concat([Buffer.from('x '), Buffer.from([0x1b])])); + const target = writePatchedScanner(dir, "{ path: 'evil.md', codepoints: [0x1b], reason: 'test fixture' }"); + git('add', 'evil.md', 'scan.mjs'); + const r = runPatched(dir, target); + assert.equal(r.status, 0, `exercised allowlist entry must pass; stderr: ${r.stderr}`); + assert.ok(r.stdout.includes('evil.md'), `output must name the exercised entry; got: ${r.stdout}`); + assert.ok(r.stdout.includes('U+001B'), `output must name the allowed codepoint; got: ${r.stdout}`); + assert.ok(r.stdout.includes('test fixture'), `output must quote the written reason; got: ${r.stdout}`); + } finally { cleanup(dir); } + }); + + test('Case 2: an entry naming a file that is not tracked exits 1 as stale', () => { + const { dir, git } = mkTempGitRepo(); + try { + writeFileSync(join(dir, 'clean.md'), 'nothing to see\n'); + const target = writePatchedScanner(dir, "{ path: 'gone.md', codepoints: [0x1b], reason: 'test fixture' }"); + git('add', 'clean.md', 'scan.mjs'); + const r = runPatched(dir, target); + assert.equal(r.status, 1, 'an allowlist entry for an untracked path must fail'); + assert.ok(r.stderr.includes('stale'), `must identify the entry as stale; got: ${r.stderr}`); + assert.ok(r.stderr.includes('gone.md'), 'must name the stale path'); + } finally { cleanup(dir); } + }); + + test('Case 3: an entry whose declared codepoint no longer occurs exits 1 as stale', () => { + const { dir, git } = mkTempGitRepo(); + try { + writeFileSync(join(dir, 'evil.md'), 'the hazard byte has since been removed\n'); + const target = writePatchedScanner(dir, "{ path: 'evil.md', codepoints: [0x1b], reason: 'test fixture' }"); + git('add', 'evil.md', 'scan.mjs'); + const r = runPatched(dir, target); + assert.equal(r.status, 1, 'an allowlist entry whose codepoint is gone must fail'); + assert.ok(r.stderr.includes('U+001B'), `must name the declared codepoint; got: ${r.stderr}`); + } finally { cleanup(dir); } + }); + +}); + +// --------------------------------------------------------------------------- +// Entry-point guard: the gate must actually RUN wherever the repo is checked out +// --------------------------------------------------------------------------- +describe('the scanner runs from a path containing a space', () => { + + test('planted ESC is still caught when the script path contains a space', () => { + // `import.meta.url === "file://" + process.argv[1]` is false for any path a + // file URL percent-encodes, so main() never runs and the gate exits 0 + // having scanned nothing — a silent pass indistinguishable from a clean + // tree. The scanner has no local imports, so a copy is a faithful subject. + const dir = mkdtempSync(join(tmpdir(), 'mds scan space-')); + try { + assert.ok(dir.includes(' '), 'this test is meaningless unless the path has a space'); + execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@test.test'], { cwd: dir, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'pipe' }); + const target = join(dir, 'scan.mjs'); + writeFileSync(target, readFileSync(SCANNER)); + writeFileSync(join(dir, 'evil.md'), Buffer.concat([Buffer.from('x '), Buffer.from([0x1b])])); + execFileSync('git', ['add', 'evil.md', 'scan.mjs'], { cwd: dir, stdio: 'pipe' }); + + const r = spawnSync(process.execPath, [target], { cwd: dir, encoding: 'utf8', timeout: 30000 }); + assert.equal(r.status, 1, + `scanner must run (and fail) from a spaced path; got status ${r.status}, stdout: ${r.stdout}`); + assert.ok(r.stderr.includes('U+001B'), 'must report the planted byte'); + } finally { cleanup(dir); } + }); + +}); + +// --------------------------------------------------------------------------- +// AC-20: non-regular git entries are skipped, not read +// --------------------------------------------------------------------------- +describe('AC-20: symlinks are skipped and counted, not read', () => { + + test('a tracked symlink (mode 120000) is skipped and reported', () => { + const { dir, git } = mkTempGitRepo(); + try { + writeFileSync(join(dir, 'real.md'), 'clean content\n'); + symlinkSync('real.md', join(dir, 'link.md')); + git('add', 'real.md', 'link.md'); + const modes = execFileSync('git', ['ls-files', '-s'], { cwd: dir, encoding: 'utf8' }); + assert.ok(modes.includes('120000'), 'the fixture must actually stage a symlink'); + + const r = runScanner([], { cwd: dir }); + assert.equal(r.status, 0, `symlink must not produce a read error; stderr: ${r.stderr}`); + assert.ok(/1 symlink\/gitlink skipped/.test(r.stdout), + `skipped entries must be counted separately; got: ${r.stdout}`); + assert.ok(/Scanned 1 file\(s\)/.test(r.stdout), 'only the regular file is scanned'); + } finally { cleanup(dir); } + }); + +}); + +// --------------------------------------------------------------------------- +// AC-16: git missing from PATH fails closed with exit 1 +// --------------------------------------------------------------------------- +describe('AC-16: git not on PATH', () => { + + test('scanner exits 1 when git cannot be found (fail-closed)', () => { + // AC-16 mandates exit 1 for all four named failure conditions. "Git not on + // PATH" is a known, named failure — it is fail-closed (exit 1), not + // indeterminate (exit 2). + // Give the child a PATH containing node but not git, so the failure is + // specifically "git is missing" and not "node is missing". + const binDir = mkdtempSync(join(tmpdir(), 'mds-nopath-')); + try { + symlinkSync(process.execPath, join(binDir, 'node')); + const r = spawnSync(process.execPath, [SCANNER], { + cwd: ROOT, + encoding: 'utf8', + env: { PATH: binDir }, + timeout: 30000, + }); + assert.equal(r.status, 1, `missing git must exit 1 (fail-closed); stdout: ${r.stdout}, stderr: ${r.stderr}`); + assert.ok(/git is not on PATH/.test(r.stderr), `must name the condition; got: ${r.stderr}`); + } finally { cleanup(binDir); } + }); + +}); diff --git a/scripts/__test__/verify-pr-checks.spec.mjs b/scripts/__test__/verify-pr-checks.spec.mjs new file mode 100644 index 0000000..4f6a379 --- /dev/null +++ b/scripts/__test__/verify-pr-checks.spec.mjs @@ -0,0 +1,528 @@ +/** + * Tests for scripts/verify-pr-checks.mjs + * + * All tests drive the pure `evaluateChecks` function with fixture data so they + * run offline — no real GitHub API calls. The fixtures are captured verbatim + * from the live API at planning time (see scripts/__test__/fixtures/). + * + * applies ADR-009, avoids PF-013: every test prints counts; absence of checks + * is explicitly FAIL (zero check-runs test). + * avoids PF-017: cancelled/skipped/in_progress are all tested as NOT-PASS. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { evaluateChecks, main, fetchRequiredContexts } from '../verify-pr-checks.mjs'; + +const ROOT = resolve(fileURLToPath(import.meta.url), '../../..'); +const FIXTURES = join(ROOT, 'scripts/__test__/fixtures'); + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +function loadProtection() { + const raw = JSON.parse(readFileSync(join(FIXTURES, 'protection-main.json'), 'utf8')); + return raw.required_status_checks.contexts; +} + +function loadCheckRuns(fixtureName) { + const raw = JSON.parse(readFileSync(join(FIXTURES, fixtureName), 'utf8')); + return raw.check_runs ?? []; +} + +function loadStatuses(fixtureName) { + const raw = JSON.parse(readFileSync(join(FIXTURES, fixtureName), 'utf8')); + return raw.statuses ?? []; +} + +const REQUIRED = loadProtection(); +// From the live protection fixture, the 6 required contexts are: +// "Rust — fmt, clippy, test", "MSRV (Rust 1.88)", "WASM — build & test", +// "JS packages — build & test (ubuntu-latest)", +// "JS packages — build & test (macos-latest)", +// "JS packages — build & test (windows-latest)" +assert.equal(REQUIRED.length, 6, 'fixture must have 6 required contexts'); + +const HEAD_113F472 = '113f472684d6ee7e398d54c1aadc22b2ad747ae1'; +const HEAD_F168944 = 'f168944'; // PR #239 +const HEAD_E9DACE1 = 'e9dace1'; // PR #240 + +// --------------------------------------------------------------------------- +// AC-22: Historical fixtures reproduce correctly +// --------------------------------------------------------------------------- +describe('AC-21 AC-22: historical fixture evaluation', () => { + + test('113f472 (main baseline, 18 check-runs, all success) → PASS (exit 0)', () => { + const checkRuns = loadCheckRuns('checks-main-113f472.json'); + assert.equal(checkRuns.length, 18, 'fixture must have 18 check-runs'); + const result = evaluateChecks({ requiredContexts: REQUIRED, checkRuns, statuses: [], headSha: HEAD_113F472 }); + assert.equal(result.exitCode, 0, `expected PASS; lines: ${result.lines.join('\n')}`); + assert.ok(result.pass, 'evaluateChecks must return pass=true'); + }); + + test('f168944 (PR #239, zero check-runs) → FAIL (exit 1) naming all 6 required contexts', () => { + const checkRuns = loadCheckRuns('checks-pr239-f168944.json'); + const statuses = loadStatuses('status-pr239-f168944.json'); + assert.equal(checkRuns.length, 0, 'PR #239 fixture must have 0 check-runs'); + const result = evaluateChecks({ requiredContexts: REQUIRED, checkRuns, statuses, headSha: HEAD_F168944 }); + assert.equal(result.exitCode, 1, `expected FAIL; lines: ${result.lines.join('\n')}`); + assert.ok(!result.pass); + const allLines = result.lines.join('\n'); + // Non-vacuity guard fires: zero check-runs → FAIL + assert.ok(allLines.includes('zero check-runs'), `must mention zero check-runs; got: ${allLines}`); + // AC-22: every required context must be named so the operator knows what was absent, + // not just that "zero check-runs" occurred (avoids vacuous failure messages). + for (const ctx of REQUIRED) { + assert.ok(allLines.includes(ctx), + `must name absent required context "${ctx}"; got:\n${allLines}`); + } + }); + + test('e9dace1 (PR #240, zero check-runs, Snyk error status) → FAIL (exit 1)', () => { + const checkRuns = loadCheckRuns('checks-pr240-e9dace1.json'); + const statuses = loadStatuses('status-pr240-e9dace1.json'); + assert.equal(checkRuns.length, 0, 'PR #240 fixture must have 0 check-runs'); + const snykStatus = statuses.find(s => s.context === 'security/snyk (dean0x)'); + assert.ok(snykStatus, 'PR #240 fixture must have snyk status'); + assert.equal(snykStatus.state, 'error'); + const result = evaluateChecks({ requiredContexts: REQUIRED, checkRuns, statuses, headSha: HEAD_E9DACE1 }); + assert.equal(result.exitCode, 1, `expected FAIL; lines: ${result.lines.join('\n')}`); + // Zero check-runs triggers non-vacuity guard; Snyk status is Tier C (advisory) + const allLines = result.lines.join('\n'); + assert.ok(allLines.includes('zero check-runs'), `must fail on zero check-runs; got: ${allLines}`); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-23: Partial case — the one `gh pr checks --required` exits 0 on +// --------------------------------------------------------------------------- +describe('AC-23: partial case (5 of 6 required present)', () => { + + test('17 of 18 check-runs (MSRV deleted) → FAIL naming MSRV', () => { + // Synthesize by removing the MSRV check-run from the 113f472 fixture. + // This is the case `gh pr checks --required` exits 0 on (all present checks are green) + // but the tool catches: a required context is absent. + const allRuns = loadCheckRuns('checks-main-113f472.json'); + const msrvName = 'MSRV (Rust 1.88)'; + const withoutMsrv = allRuns.filter(cr => cr.name !== msrvName); + assert.equal(withoutMsrv.length, 17, 'should have 17 runs after removing MSRV'); + + const result = evaluateChecks({ + requiredContexts: REQUIRED, + checkRuns: withoutMsrv, + statuses: [], + headSha: HEAD_113F472, + }); + assert.equal(result.exitCode, 1, 'must FAIL when one required context is absent'); + const allLines = result.lines.join('\n'); + assert.ok(allLines.includes(msrvName), + `failure message must name "${msrvName}"; got: ${allLines}`); + assert.ok(allLines.includes('not found') || allLines.includes('never ran'), + `message must indicate the context never ran; got: ${allLines}`); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-24: All non-success terminal and non-terminal states fail (avoids PF-017) +// --------------------------------------------------------------------------- +describe('AC-24: non-success states → FAIL, quoting the observed state', () => { + + // Build a passing baseline from the 113f472 fixture, then mutate one required check + function buildPassingRuns() { + return loadCheckRuns('checks-main-113f472.json').map(cr => ({ ...cr })); + } + + const NON_SUCCESS_CASES = [ + { status: 'completed', conclusion: 'cancelled' }, + { status: 'completed', conclusion: 'skipped' }, + { status: 'completed', conclusion: 'neutral' }, + { status: 'completed', conclusion: 'timed_out' }, + { status: 'completed', conclusion: 'action_required' }, + { status: 'completed', conclusion: 'stale' }, + { status: 'queued', conclusion: null }, + { status: 'in_progress', conclusion: null }, + ]; + + for (const { status, conclusion } of NON_SUCCESS_CASES) { + test(`required check with status=${status} conclusion=${conclusion ?? 'null'} → FAIL`, () => { + const runs = buildPassingRuns(); + const target = runs.find(cr => REQUIRED.includes(cr.name)); + assert.ok(target, 'must find a required check-run to mutate'); + target.status = status; + target.conclusion = conclusion; + + const result = evaluateChecks({ requiredContexts: REQUIRED, checkRuns: runs, statuses: [], headSha: HEAD_113F472 }); + assert.equal(result.exitCode, 1, `status=${status} conclusion=${conclusion} must exit 1`); + const allLines = result.lines.join('\n'); + // Message must quote the observed status verbatim (avoids PF-017) + assert.ok(allLines.includes(status), `failure must quote observed status "${status}"`); + if (conclusion) { + assert.ok(allLines.includes(conclusion), `failure must quote observed conclusion "${conclusion}"`); + } + }); + } + + test('control: all-success baseline still exits 0 (suite is not failing unconditionally)', () => { + const runs = buildPassingRuns(); + const result = evaluateChecks({ requiredContexts: REQUIRED, checkRuns: runs, statuses: [], headSha: HEAD_113F472 }); + assert.equal(result.exitCode, 0, 'all-success baseline must pass'); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-25: Zero check-runs is never a pass (avoids PF-013) +// --------------------------------------------------------------------------- +describe('AC-25: zero check-runs never passes', () => { + + test('total_count=0, empty check_runs, even with success status → FAIL', () => { + const result = evaluateChecks({ + requiredContexts: REQUIRED, + checkRuns: [], + statuses: [{ context: 'some-check', state: 'success' }], + headSha: HEAD_F168944, + }); + assert.equal(result.exitCode, 1, 'zero check-runs must exit 1 regardless of statuses'); + const allLines = result.lines.join('\n'); + // Must print counts (avoids PF-013) + assert.ok(allLines.includes('check-runs: 0'), `must print check-run count; got: ${allLines}`); + }); + + test('output always includes counts (applies ADR-009)', () => { + const runs = loadCheckRuns('checks-main-113f472.json'); + const result = evaluateChecks({ requiredContexts: REQUIRED, checkRuns: runs, statuses: [], headSha: HEAD_113F472 }); + const allLines = result.lines.join('\n'); + // Counts must appear whether pass or fail + assert.ok(allLines.includes('check-runs:'), `must print check-runs count; got: ${allLines}`); + assert.ok(allLines.includes('required contexts:'), `must print required-contexts count; got: ${allLines}`); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-26: Three-valued exit contract +// --------------------------------------------------------------------------- +describe('AC-26 AC-27: exit codes and merge command', () => { + + test('PASS → exit 0 with --match-head-commit in output', () => { + const runs = loadCheckRuns('checks-main-113f472.json'); + const result = evaluateChecks({ requiredContexts: REQUIRED, checkRuns: runs, statuses: [], headSha: HEAD_113F472 }); + assert.equal(result.exitCode, 0); + assert.ok(result.mergeCommand, 'PASS must produce a mergeCommand'); + // D-PR5: merge command must include --match-head-commit + assert.ok(result.mergeCommand.includes('--match-head-commit'), 'merge command must include --match-head-commit'); + assert.ok(result.mergeCommand.includes(HEAD_113F472), 'merge command must include the verified SHA'); + }); + + test('FAIL → exit 1 (not 0, not 2)', () => { + const result = evaluateChecks({ requiredContexts: REQUIRED, checkRuns: [], statuses: [], headSha: HEAD_F168944 }); + assert.equal(result.exitCode, 1); + assert.ok(!result.pass); + }); + + test('evaluateChecks never returns exit 0 when pass=false', () => { + // Verify the invariant: exitCode===0 iff pass===true + const failResult = evaluateChecks({ requiredContexts: REQUIRED, checkRuns: [], statuses: [], headSha: 'abc' }); + assert.equal(failResult.exitCode === 0, failResult.pass, + 'exitCode===0 must equal pass===true'); + + const passResult = evaluateChecks({ + requiredContexts: REQUIRED, + checkRuns: loadCheckRuns('checks-main-113f472.json'), + statuses: [], + headSha: HEAD_113F472, + }); + assert.equal(passResult.exitCode === 0, passResult.pass, + 'exitCode===0 must equal pass===true on pass case'); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-26, AC-28, AC-29: the live path, driven end-to-end with an injected gh +// runner. These replace source-text greps: asserting that a file CONTAINS the +// string "process.exit(2)" proves nothing about whether that branch is +// reachable (applies ADR-009, avoids PF-013). Each case below drives main() +// and asserts the returned exit code. +// --------------------------------------------------------------------------- + +const OK_GH_VERSION = () => ({ major: 2, minor: 88 }); + +/** + * Build a gh runner stub from a route table. Each entry is matched against the + * API path by substring; the value is either a JSON object (success) or an + * `{ __error: true, status }` shape mirroring defaultGhRunner's failure return. + */ +function stubRunner(routes, callLog = []) { + return (args) => { + const url = args[args.length - 1]; + callLog.push(url); + for (const [needle, value] of routes) { + if (url.includes(needle)) { + return typeof value === 'function' ? value(url) : value; + } + } + return { __error: true, status: 404, stderr: `no stub route for ${url}` }; + }; +} + +const PR_OK = { head: { sha: HEAD_113F472 }, base: { ref: 'main' } }; +const PROTECTION_OK = JSON.parse(readFileSync(join(FIXTURES, 'protection-main.json'), 'utf8')); +const CHECKS_OK = JSON.parse(readFileSync(join(FIXTURES, 'checks-main-113f472.json'), 'utf8')); + +describe('AC-26 AC-28 AC-29: live path exit codes (injected runner)', () => { + + test('happy path → exit 0', () => { + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', PROTECTION_OK], + ['/check-runs', CHECKS_OK], + ['/status', { statuses: [] }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 0); + }); + + test('AC-29: unprotected base (404 on protection) → exit 2, never 0', () => { + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', { __error: true, status: 404, stderr: 'Not Found' }], + ['/check-runs', CHECKS_OK], + ['/status', { statuses: [] }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 2); + }); + + test('AC-29: --required-from branch also unprotected → exit 2', () => { + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', { __error: true, status: 404, stderr: 'Not Found' }], + ]); + assert.equal(main(['1', '--required-from', 'nope'], runner, OK_GH_VERSION), 2); + }); + + test('AC-26: protection unreadable (403) → exit 2', () => { + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', { __error: true, status: 403, stderr: 'Forbidden' }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 2); + }); + + test('AC-26: gh older than 2.31 → exit 2 before any API call', () => { + const calls = []; + const runner = stubRunner([['/pulls/', PR_OK]], calls); + assert.equal(main(['1'], runner, () => ({ major: 2, minor: 30 })), 2); + assert.equal(calls.length, 0, 'must not query the API when gh is too old'); + }); + + test('AC-26: gh missing entirely (version probe returns null) → exit 2', () => { + const runner = stubRunner([['/pulls/', PR_OK]]); + assert.equal(main(['1'], runner, () => null), 2); + }); + + test('AC-26: no PR number argument → exit 2', () => { + const runner = stubRunner([]); + assert.equal(main([], runner, OK_GH_VERSION), 2); + }); + + test('AC-26: --required-from with no value → exit 2', () => { + const runner = stubRunner([['/pulls/', PR_OK]]); + assert.equal(main(['1', '--required-from'], runner, OK_GH_VERSION), 2); + }); + + test('protected branch listing ZERO required contexts → exit 2, not 0', () => { + // The vacuous-green shape: protection exists, required set is empty. + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', { required_status_checks: { contexts: [], checks: [] } }], + ['/check-runs', CHECKS_OK], + ['/status', { statuses: [] }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 2); + }); + + test('required contexts are read from the UNION of contexts[] and checks[]', () => { + // A protection payload that populates only the newer `checks` array must + // still yield a required set — reading `contexts` alone would be empty. + const onlyChecks = { + required_status_checks: { + contexts: [], + checks: REQUIRED.map(c => ({ context: c, app_id: 15368 })), + }, + }; + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', onlyChecks], + ['/check-runs', CHECKS_OK], + ['/status', { statuses: [] }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 0); + + const res = fetchRequiredContexts('main', null, runner); + assert.ok(res.ok); + assert.deepEqual([...res.contexts].sort(), [...REQUIRED].sort()); + }); + + test('AC-28: pagination stops at the page bound and exits 2 (never loops)', () => { + // Stub a server that always reports more pages than it will ever deliver. + let pages = 0; + const fullPage = { + total_count: 100000, + check_runs: Array.from({ length: 100 }, (_, i) => ({ + name: `job-${i}`, status: 'completed', conclusion: 'success', + })), + }; + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', PROTECTION_OK], + ['/check-runs', () => { pages++; return fullPage; }], + ['/status', { statuses: [] }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 2, 'page cap must exit 2'); + assert.ok(pages <= 20, `pagination must be bounded; issued ${pages} page requests`); + assert.ok(pages >= 2, 'the stub must actually have been paginated'); + }); + + test('AC-28: total_count larger than the collected set → exit 2, not a partial verdict', () => { + const truncated = { total_count: 18, check_runs: CHECKS_OK.check_runs.slice(0, 5) }; + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', PROTECTION_OK], + ['/check-runs', truncated], + ['/status', { statuses: [] }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 2); + }); + + test('AC-30: the live path issues at most page-bound + 3 API calls', () => { + const calls = []; + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', PROTECTION_OK], + ['/check-runs', CHECKS_OK], + ['/status', { statuses: [] }], + ], calls); + assert.equal(main(['1'], runner, OK_GH_VERSION), 0); + assert.equal(calls.length, 4, `expected 4 API calls (pr, protection, checks, status); got ${calls.length}`); + const checkCall = calls.find(u => u.includes('/check-runs')); + assert.ok(checkCall.includes('filter=latest'), 'filter=latest must be pinned explicitly (D-PR4a)'); + }); + + test('check-runs API error → exit 2 (indeterminate), not 1', () => { + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', PROTECTION_OK], + ['/check-runs', { __error: true, status: 500, stderr: 'server error' }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 2); + }); + + test('a real FAIL still exits 1, so exit 2 has not swallowed the FAIL path', () => { + const runner = stubRunner([ + ['/pulls/', PR_OK], + ['/protection', PROTECTION_OK], + ['/check-runs', { total_count: 0, check_runs: [] }], + ['/status', { statuses: [] }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 1); + }); + +}); + +// --------------------------------------------------------------------------- +// Entry-point guard: the verifier must actually RUN wherever it is checked out +// --------------------------------------------------------------------------- +describe('the verifier runs from a spaced / symlinked path', () => { + + test('invoked with no arguments it exits 2 (usage), never a silent 0', () => { + // A merge gate that no-ops and exits 0 is the worst possible failure mode: + // the operator reads it as "verified" and merges. Copy the script to a path + // with a space (mkdtemp is also symlinked on macOS) and confirm it runs. + const dir = mkdtempSync(join(tmpdir(), 'mds verify space-')); + try { + assert.ok(dir.includes(' '), 'this test is meaningless unless the path has a space'); + const target = join(dir, 'verify-pr-checks.mjs'); + writeFileSync(target, readFileSync(join(ROOT, 'scripts/verify-pr-checks.mjs'))); + const r = spawnSync(process.execPath, [target], { encoding: 'utf8', timeout: 30000 }); + assert.equal(r.status, 2, + `expected usage exit 2; got ${r.status} (0 means the script never ran). stdout: ${r.stdout}`); + assert.ok(r.stderr.includes('Usage:'), `must print usage; got: ${r.stderr}`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + +}); + +// --------------------------------------------------------------------------- +// Duplicate check-run names must not mask a failure +// --------------------------------------------------------------------------- +describe('duplicate check-run names are all evaluated', () => { + + test('a failing run is not masked by a later success under the same name', () => { + const ctx = REQUIRED[0]; + const runs = [ + ...loadCheckRuns('checks-main-113f472.json').filter(cr => cr.name !== ctx), + { name: ctx, status: 'completed', conclusion: 'failure' }, + { name: ctx, status: 'completed', conclusion: 'success' }, + ]; + const result = evaluateChecks({ requiredContexts: REQUIRED, checkRuns: runs, statuses: [], headSha: HEAD_113F472 }); + assert.equal(result.exitCode, 1, + 'a failing required check-run must fail even when a later run shares its name'); + assert.ok(result.lines.join('\n').includes('failure'), 'must quote the observed conclusion'); + }); + +}); + +// --------------------------------------------------------------------------- +// Vacuity guard on the pure function itself +// --------------------------------------------------------------------------- +describe('empty required set is indeterminate, never a pass', () => { + + test('evaluateChecks with zero required contexts → exit 2', () => { + const result = evaluateChecks({ + requiredContexts: [], + checkRuns: [{ name: 'anything', status: 'completed', conclusion: 'success' }], + statuses: [], + headSha: HEAD_113F472, + }); + assert.equal(result.exitCode, 2, 'zero required contexts must be indeterminate (exit 2)'); + assert.equal(result.pass, false); + assert.ok(!result.mergeCommand, 'must not emit a merge command it cannot justify'); + }); + +}); + +// --------------------------------------------------------------------------- +// AC-13 (documentation): D-PR2a union of check-runs and statuses +// --------------------------------------------------------------------------- +describe('D-PR2a: required context satisfied by commit status', () => { + test('required context present only in statuses (not check-runs) → PASS', () => { + // Build check-runs with one required context removed from check-runs, + // but that context is present in commit statuses as success. + const allRuns = loadCheckRuns('checks-main-113f472.json'); + const msrvName = 'MSRV (Rust 1.88)'; + const withoutMsrv = allRuns.filter(cr => cr.name !== msrvName); + + // Simulate MSRV being satisfied via commit status instead + const statuses = [{ context: msrvName, state: 'success' }]; + + const result = evaluateChecks({ + requiredContexts: REQUIRED, + checkRuns: withoutMsrv, + statuses, + headSha: HEAD_113F472, + }); + assert.equal(result.exitCode, 0, + 'required context satisfied via commit status must pass (D-PR2a)'); + }); +}); diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit new file mode 100755 index 0000000..c81d3f2 --- /dev/null +++ b/scripts/hooks/pre-commit @@ -0,0 +1,37 @@ +#!/bin/sh +# Pre-commit hook: run the source-hygiene gate in --staged mode. +# +# Opt-in: git config core.hooksPath scripts/hooks +# +# IMPORTANT: Setting core.hooksPath REPLACES .git/hooks entirely rather than +# merging with it. Any local hooks you have in .git/hooks will no longer run +# while this setting is active. Document your local hooks before opting in. +# +# This hook reads staged file content from the git index (git cat-file blob +# :), not from the working tree. Staging a clean file then modifying +# the working copy will NOT bypass the check. (D-CB8) +# +# D-CB7: Uses pure Node codepoint iteration. BSD grep (macOS default) lacks +# -P and exits 2 with empty output, making absence of hazard bytes look the +# same as a broken invocation. This hook never invokes grep. +# +# Exit 0: commit proceeds (no hazard bytes in staged content). +# Exit 1: commit rejected — hazard byte found, OR scanner script is missing +# (restore scripts/verify-no-control-bytes.mjs or pass --no-verify +# explicitly to bypass the gate intentionally). +# Exit 2: scanner reported an unexpected error (three-value contract from +# verify-no-control-bytes.mjs); git treats this as rejection. + +set -e + +REPO_ROOT=$(git rev-parse --show-toplevel) +HOOK_SCRIPT="$REPO_ROOT/scripts/verify-no-control-bytes.mjs" + +if [ ! -f "$HOOK_SCRIPT" ]; then + echo "pre-commit: scripts/verify-no-control-bytes.mjs not found — commit blocked (D-CB5)." >&2 + echo "pre-commit: Restore the script or use --no-verify to bypass intentionally." >&2 + exit 1 +fi + +# Run in --staged mode: reads git index, not working tree (D-CB8). +exec node "$HOOK_SCRIPT" --staged diff --git a/scripts/verify-no-control-bytes.mjs b/scripts/verify-no-control-bytes.mjs new file mode 100644 index 0000000..85099d6 --- /dev/null +++ b/scripts/verify-no-control-bytes.mjs @@ -0,0 +1,634 @@ +#!/usr/bin/env node +/** + * D-CB1: Source-hygiene gate — scans tracked git source for hazardous codepoints. + * + * The hazard class is derived from crates/mds-cli/tests/common/mod.rs:38-62 + * (`assert_no_control_chars`), with ONE documented divergence: + * + * D-CB3 DIVERGENCE: CR (U+000D) is permitted when immediately followed by LF + * (i.e., CRLF line endings are allowed). The Rust helper flags all CR + * unconditionally. This carve-out preserves the 17 CRLF pairs in the two + * `mds fmt` fixture files. A future `.gitattributes text=auto` would normalize + * those fixtures and may break the fmt tests — documented here so that change + * is deliberate, not accidental. + * + * D-CB2: No hazard codepoint appears as a literal or backslash-u escape in + * this file. All are written as numeric values. The edit tooling decodes + * \uXXXX (4-hex) patterns into live bytes — this file's self-scan guards + * against that vector (avoids PF-018). + * + * D-CB7: Pure Node codepoint iteration — no grep. BSD grep (macOS default) + * lacks -P and exits 2 with empty output, making the absence of hazard bytes + * indistinguishable from a grep invocation that cannot run (avoids PF-013). + * + * D-CB5: Fails closed. In full-tree mode, zero-files-scanned is exit 1, not + * exit 0 (avoids PF-016 — an empty scan masquerades as clean). In --staged + * mode, an empty ACMR-filtered set is a legitimate state (deletion-only + * commits, amend with no content changes) and exits 0 with an explicit + * message; the full-tree scan is the authoritative non-vacuity gate. + * + * D-CB8: --staged mode reads file content from the git index via a single + * `git cat-file --batch` subprocess (all staged blobs at once), never from + * the working tree. Staging a clean file then modifying the working copy + * does not bypass the hook. + * + * Usage: + * node scripts/verify-no-control-bytes.mjs # full tree scan + * node scripts/verify-no-control-bytes.mjs --staged # pre-commit (index) + * node scripts/verify-no-control-bytes.mjs ... # explicit paths + * + * Exit codes: + * 0 — no hazards found (prints file count and byte count for non-vacuity) + * 1 — hazard found, zero files scanned, invalid UTF-8, un-allowlisted NUL, + * unreadable tracked path, stale/unmatched allowlist entry, git missing + * from PATH, or not inside a git work tree (all fail-closed) + * 2 — indeterminate: a git subcommand (ls-files / diff --cached / cat-file) + * failed unexpectedly + */ +'use strict'; + +import { spawnSync } from 'node:child_process'; +import { readFileSync, realpathSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** + * True when this module is the process entry point. + * + * Two traps make the obvious `import.meta.url === 'file://' + process.argv[1]` + * wrong, and both fail SILENTLY: main() never runs, the process exits 0, and a + * gate that scanned nothing is indistinguishable from a clean tree. + * 1. Percent-encoding — any path containing a space never matches. + * 2. Symlinks — Node resolves import.meta.url through realpath, while + * process.argv[1] keeps the path as typed (on macOS /tmp and + * /var/folders are symlinks, so this is the common case, not a corner). + * Comparing realpaths handles both (applies ADR-009, avoids PF-013). + * + * @param {string} metaUrl — the caller's import.meta.url + * @returns {boolean} + */ +export function isMainModule(metaUrl) { + const entry = process.argv[1]; + if (!entry) return false; + const modulePath = fileURLToPath(metaUrl); + try { + return realpathSync(entry) === realpathSync(modulePath); + } catch { + return pathToFileURL(resolve(entry)).href === metaUrl; + } +} + +// --------------------------------------------------------------------------- +// D-CB1: Hazard class definition. +// +// Exported so tests can import and assert completeness (D-CB1a: golden-set +// test prevents silent narrowing). +// +// Entry forms: +// { from, to } — inclusive codepoint range +// { cp, crlfException: true } — single codepoint with CRLF exception +// number — single codepoint +// --------------------------------------------------------------------------- +export const HAZARD_RANGES = [ + // C0 control characters (0x00-0x1F), excluding TAB (0x09) and LF (0x0A). + // D-CB3: CR (0x0D) has a CRLF exception — see entry below. + { from: 0x00, to: 0x08 }, // 1. C0: NUL..BS (below TAB) + { from: 0x0b, to: 0x0c }, // 2. C0: VT, FF (between LF and CR) + { cp: 0x0d, crlfException: true }, // 3. CR — lone CR fails; CRLF passes (D-CB3) + { from: 0x0e, to: 0x1f }, // 4. C0: SO..US (above CR) + { from: 0x7f, to: 0x7f }, // 5. DEL + { from: 0x80, to: 0x9f }, // 6. C1 (at codepoint level — catches 0xC2 0x80-0x9F + // in UTF-8; continuation bytes are NOT matched + // because a 0x80-0x9F byte following a start byte + // is decoded to a codepoint >= 0x100 that falls + // outside this range) + // Twelve Unicode Bidi_Control=Yes codepoints (Trojan Source, CVE-2021-42574). + 0x061c, // 7. U+061C Arabic Letter Mark + 0x200e, // 8. U+200E Left-to-Right Mark + 0x200f, // 9. U+200F Right-to-Left Mark + 0x202a, // 10. U+202A Left-to-Right Embedding + 0x202b, // 11. U+202B Right-to-Left Embedding + 0x202c, // 12. U+202C Pop Directional Formatting + 0x202d, // 13. U+202D Left-to-Right Override + 0x202e, // 14. U+202E Right-to-Left Override + 0x2066, // 15. U+2066 Left-to-Right Isolate + 0x2067, // 16. U+2067 Right-to-Left Isolate + 0x2068, // 17. U+2068 First Strong Isolate + 0x2069, // 18. U+2069 Pop Directional Isolate + // JavaScript line/paragraph terminators (outside Bidi_Control, still hazardous + // because JS parsers treat them as line endings inside string literals). + 0x2028, // 19. U+2028 Line Separator + 0x2029, // 20. U+2029 Paragraph Separator + // Byte-Order Mark / Zero-Width No-Break Space. + 0xfeff, // 21. U+FEFF BOM / ZWNBSP +]; +// HAZARD_RANGES has 21 entries. AC-12 lists the same class; the test asserts +// this exact count and composition (D-CB1a: golden-set completeness guard). + +// --------------------------------------------------------------------------- +// D-CB6: Two enumerated allowlists, both empty. Every entry carries a written +// `reason`. A stale entry (path absent or declared codepoints no longer +// present) is itself an exit 1. +// +// BINARY_ALLOWLIST: files containing NUL bytes (intentional binary content). +// HAZARD_ALLOWLIST: files with specific hazardous codepoints for test fixtures. +// --------------------------------------------------------------------------- +export const BINARY_ALLOWLIST = [ + // { path: 'relative/from/root', reason: 'explanation' } +]; + +export const HAZARD_ALLOWLIST = [ + // { path: 'relative/from/root', codepoints: [0x...], reason: 'explanation' } +]; + +// --------------------------------------------------------------------------- +// Hazard predicate +// --------------------------------------------------------------------------- + +/** + * @param {number} cp — Unicode codepoint being tested + * @param {number|null} nextCp — codepoint immediately following cp (for CRLF) + * @returns {boolean} + */ +export function isHazardous(cp, nextCp) { + for (const entry of HAZARD_RANGES) { + if (typeof entry === 'number') { + if (cp === entry) return true; + } else if (entry.crlfException) { + // D-CB3: CR (0x0D) is only hazardous when the NEXT char is NOT LF. + if (cp === entry.cp && nextCp !== 0x0a) return true; + } else { + if (cp >= entry.from && cp <= entry.to) return true; + } + } + return false; +} + +// --------------------------------------------------------------------------- +// Hexdump context helper (D-CB7: readable failure output) +// --------------------------------------------------------------------------- + +/** + * Return ±8-byte hex context around `offset` in `buf`. + * @param {Buffer} buf + * @param {number} offset + * @returns {string} + */ +function hexContext(buf, offset) { + const start = Math.max(0, offset - 8); + const end = Math.min(buf.length, offset + 10); + const hex = []; + for (let i = start; i < end; i++) { + const byte = buf[i].toString(16).padStart(2, '0'); + hex.push(i === offset ? `[${byte}]` : byte); + } + return hex.join(' '); +} + + +// --------------------------------------------------------------------------- +// git helpers +// --------------------------------------------------------------------------- + +function gitExec(args, cwd = process.cwd()) { + const result = spawnSync('git', args, { cwd, encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 }); + if (result.error) { + if (result.error.code === 'ENOENT') { + // AC-16: fail-closed (exit 1) — "git missing" is a known, named failure, + // not an indeterminate tool error. + console.error('✖ verify-no-control-bytes: git is not on PATH'); + process.exit(1); + } + console.error(`✖ verify-no-control-bytes: git error: ${result.error.message}`); + process.exit(2); + } + return result; +} + +/** Verify we are inside a git work tree (exit 1 if not — AC-16: fail-closed). */ +function assertGitRepo(cwd) { + const r = gitExec(['rev-parse', '--is-inside-work-tree'], cwd); + if (r.status !== 0) { + // AC-16: fail-closed (exit 1) — "not a git repo" is a known, named failure, + // not an indeterminate tool error. + console.error('✖ verify-no-control-bytes: not inside a git work tree'); + process.exit(1); + } +} + +/** + * Get file list in default mode via `git ls-files -sz`. + * Returns array of { path, mode } objects. + * D-CB5a: skips git modes 120000 (symlink) and 160000 (gitlink). + * + * `git ls-files -sz` output format (each entry NUL-terminated): + * \t\0... + * The TAB separates the staging info from the file path within ONE NUL record. + */ +function getTrackedFiles(cwd) { + const r = gitExec(['ls-files', '-sz'], cwd); + if (r.status !== 0) { + console.error('✖ verify-no-control-bytes: git ls-files failed'); + process.exit(2); + } + // Each NUL-terminated entry is " \t" + const entries = r.stdout.toString('utf8').split('\0').filter(s => s.length > 0); + const files = []; + for (const entry of entries) { + const tabIdx = entry.indexOf('\t'); + if (tabIdx === -1) continue; // Malformed entry — skip + const meta = entry.slice(0, tabIdx); + const path = entry.slice(tabIdx + 1); + // meta format: " " + const mode = parseInt(meta.split(' ')[0], 8); + if (mode === 0o120000 || mode === 0o160000) { + files.push({ path, mode, skip: true }); + } else { + files.push({ path, mode, skip: false }); + } + } + return files; +} + +/** + * Get staged file list for --staged mode. + * Uses `git diff --cached --name-only -z --diff-filter=ACMR` for paths. + * D-CB8: content is fetched later in batch via readAllIndexBlobs(). + */ +function getStagedFiles(cwd) { + const r = gitExec(['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMR'], cwd); + if (r.status !== 0) { + // D-CB5: fail closed. `git diff --cached` exits 0 even when nothing is + // staged, so a non-zero status is a real tool failure (corrupt index, + // unreadable object). Treating it as "no staged files" would let the + // pre-commit hook report success on a scan that never happened. + console.error( + `✖ verify-no-control-bytes: git diff --cached failed (status ${r.status}): ` + + `${r.stderr.toString('utf8').trim()}`, + ); + process.exit(2); + } + const paths = r.stdout.toString('utf8').split('\0').filter(s => s.length > 0); + return paths.map(p => ({ path: p, mode: 0o100644, skip: false, staged: true })); +} + +/** + * Read all staged blobs in one `git cat-file --batch` subprocess. + * D-CB8: collapses N per-file spawns into one, never reads the working tree. + * + * `git cat-file --batch` output for each valid blob: + * blob \n + * bytes> + * \n ← one-byte LF terminator after content + * + * @param {string[]} paths — repo-relative staged paths + * @param {string} cwd + * @returns {Map} + */ +function readAllIndexBlobs(paths, cwd) { + if (paths.length === 0) return new Map(); + + // Input: ":\n" for every staged path + const stdin = Buffer.from(paths.map(p => `:${p}\n`).join(''), 'utf8'); + const r = spawnSync('git', ['cat-file', '--batch'], { + cwd, + input: stdin, + encoding: 'buffer', + maxBuffer: 256 * 1024 * 1024, + }); + if (r.error) { + console.error(`✖ verify-no-control-bytes: git cat-file --batch: ${r.error.message}`); + process.exit(2); + } + if (r.status !== 0) { + console.error( + `✖ verify-no-control-bytes: git cat-file --batch exited ${r.status}: ` + + `${r.stderr.toString('utf8').trim()}`, + ); + process.exit(2); + } + + const out = r.stdout; + const results = new Map(); + let pos = 0; + + for (const path of paths) { + if (pos >= out.length) { + console.error(`✖ verify-no-control-bytes: unexpected end of cat-file output for ${path}`); + process.exit(2); + } + // Find header line (terminated by LF) + let nlPos = pos; + while (nlPos < out.length && out[nlPos] !== 0x0a) nlPos++; + if (nlPos >= out.length) { + console.error(`✖ verify-no-control-bytes: malformed cat-file header for ${path}`); + process.exit(2); + } + const header = out.slice(pos, nlPos).toString('utf8'); + pos = nlPos + 1; // advance past header LF + + // Check for "missing" response (no content follows) + if (header.endsWith(' missing')) { + console.error(`✖ verify-no-control-bytes: staged path not in index: ${path}`); + process.exit(2); + } + // Parse " blob " + const parts = header.split(' '); + if (parts.length !== 3 || parts[1] !== 'blob') { + console.error( + `✖ verify-no-control-bytes: unexpected cat-file response for ${path}: ${header}`, + ); + process.exit(2); + } + const size = parseInt(parts[2], 10); + if (Number.isNaN(size) || size < 0) { + console.error(`✖ verify-no-control-bytes: invalid blob size for ${path}: ${header}`); + process.exit(2); + } + results.set(path, out.slice(pos, pos + size)); + pos += size + 1; // advance past content + terminator LF + } + + return results; +} + +// --------------------------------------------------------------------------- +// Scanner core +// --------------------------------------------------------------------------- + +/** + * Scan a single file buffer for hazardous codepoints. + * + * The UTF-8 decode and hazard check are fused into one inline pass — no + * intermediate {cp, byteOffset} array is allocated. Only hit records are + * retained. This eliminates the ~53x heap amplification of the former + * decodeUtf8() materialisation (AC-30). + * + * nextCp look-ahead: isHazardous() uses nextCp only to check `nextCp !== 0x0A` + * (CRLF exception for CR). LF is ASCII, so `buf[nextStart] === 0x0A` iff the + * next codepoint is LF — passing the raw lead byte is correct here. + * + * @param {Buffer} buf — raw file bytes + * @param {string} relPath — repo-relative path (for error messages) + * @param {Set} allowedCps — codepoints explicitly allowlisted for this file + * @returns {{ codepoint: number, byteOffset: number, allowed?: boolean }[]} + * Every hazard occurrence, including allowlisted ones. Allowlisted hits carry + * `allowed: true` so the caller can record the entry as exercised — dropping + * them here would make every HAZARD_ALLOWLIST entry look stale (D-CB6). + */ +export function scanBuffer(buf, relPath, allowedCps) { + // Check for NUL (binary file indicator) + if (buf.includes(0x00)) { + const inBinaryAllowlist = BINARY_ALLOWLIST.some(e => e.path === relPath); + if (!inBinaryAllowlist) { + return [{ codepoint: 0x00, byteOffset: buf.indexOf(0x00), binaryError: true }]; + } + return []; // Allowed binary file + } + + // Inline UTF-8 decode + hazard scan (no intermediate codepoint array). + const hits = []; + let i = 0; + while (i < buf.length) { + const b0 = buf[i]; + let cp, len; + + if (b0 <= 0x7f) { + cp = b0; + len = 1; + } else if ((b0 & 0xe0) === 0xc0) { + if (i + 1 >= buf.length || (buf[i + 1] & 0xc0) !== 0x80) { + return [{ codepoint: -1, byteOffset: i, invalidUtf8: true }]; + } + cp = ((b0 & 0x1f) << 6) | (buf[i + 1] & 0x3f); + len = 2; + } else if ((b0 & 0xf0) === 0xe0) { + if (i + 2 >= buf.length || (buf[i + 1] & 0xc0) !== 0x80 || (buf[i + 2] & 0xc0) !== 0x80) { + return [{ codepoint: -1, byteOffset: i, invalidUtf8: true }]; + } + cp = ((b0 & 0x0f) << 12) | ((buf[i + 1] & 0x3f) << 6) | (buf[i + 2] & 0x3f); + len = 3; + } else if ((b0 & 0xf8) === 0xf0) { + if (i + 3 >= buf.length || + (buf[i + 1] & 0xc0) !== 0x80 || + (buf[i + 2] & 0xc0) !== 0x80 || + (buf[i + 3] & 0xc0) !== 0x80) { + return [{ codepoint: -1, byteOffset: i, invalidUtf8: true }]; + } + cp = ((b0 & 0x07) << 18) | + ((buf[i + 1] & 0x3f) << 12) | + ((buf[i + 2] & 0x3f) << 6) | + (buf[i + 3] & 0x3f); + len = 4; + } else { + return [{ codepoint: -1, byteOffset: i, invalidUtf8: true }]; // Invalid lead byte + } + + // Peek first byte of next sequence for CRLF look-ahead (see JSDoc above). + const nextStart = i + len; + const nextCp = nextStart < buf.length ? buf[nextStart] : null; + if (isHazardous(cp, nextCp)) { + hits.push({ codepoint: cp, byteOffset: i, allowed: allowedCps.has(cp) }); + } + i += len; + } + return hits; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main() { + const args = process.argv.slice(2); + const isStaged = args.includes('--staged'); + const explicitPaths = args.filter(a => a !== '--staged'); + + const cwd = process.cwd(); + + // Verify git is accessible and we are in a repo (D-CB5) + assertGitRepo(cwd); + + // ---- Build file list ---- + let fileEntries; + let skippedCount = 0; + + if (explicitPaths.length > 0) { + // Explicit path mode (used by tests with temp repos) + fileEntries = explicitPaths.map(p => ({ + path: p, + mode: 0o100644, + skip: false, + staged: false, + absolutePath: resolve(cwd, p), + })); + } else if (isStaged) { + // D-CB8: staged mode — read from git index + fileEntries = getStagedFiles(cwd).map(e => ({ ...e, absolutePath: null })); + } else { + // Default: full tracked tree + const all = getTrackedFiles(cwd); + skippedCount = all.filter(e => e.skip).length; + fileEntries = all + .filter(e => !e.skip) + .map(e => ({ ...e, absolutePath: resolve(cwd, e.path) })); + } + + // ---- D-CB5: Non-vacuity guard (AC-6) ---- + // Full-tree mode: zero tracked files means path discovery broke — fail closed. + // --staged mode: an empty ACMR-filtered set is a LEGITIMATE state: + // • deletion-only commit (git rm): ACMR excludes deletions; files ARE staged. + // • amend with no content changes: index equals HEAD; diff is empty. + // In both cases exit 0 with an explicit message. The full-tree scan is the + // authoritative non-vacuity gate; the hook must not block valid commits. + if (fileEntries.length === 0) { + if (!isStaged) { + console.error('✖ verify-no-control-bytes: zero files scanned (D-CB5: empty scan is not a pass)'); + console.error(' If this is a new repo with no commits, run `git add` first.'); + process.exit(1); + } + // --staged: check unfiltered diff to provide an accurate message. + const rAll = gitExec(['diff', '--cached', '--name-only', '-z'], cwd); + if (rAll.status !== 0) { + console.error( + `✖ verify-no-control-bytes: git diff --cached (unfiltered) failed: ` + + `${rAll.stderr.toString('utf8').trim()}`, + ); + process.exit(2); + } + const allPaths = rAll.stdout.toString('utf8').split('\0').filter(s => s.length > 0); + if (allPaths.length > 0) { + // Staged changes exist but are all deletions — nothing for the content scanner to do. + console.log( + `✓ source-hygiene gate: 0 content-bearing staged paths` + + ` (${allPaths.length} deletion(s)) — nothing to scan`, + ); + } else { + // Index equals HEAD (amend --no-edit, reword, --allow-empty, etc.). + console.log('✓ source-hygiene gate: no staged content — nothing to scan'); + } + process.exit(0); + } + + // ---- Validate allowlists upfront (D-CB6) ---- + const errors = []; + + // Build allowlist lookup: path -> Set + const hazardAllowMap = new Map(); // relPath -> Set + for (const entry of HAZARD_ALLOWLIST) { + if (!hazardAllowMap.has(entry.path)) hazardAllowMap.set(entry.path, new Set()); + for (const cp of entry.codepoints) { + hazardAllowMap.get(entry.path).add(cp); + } + } + + // ---- In --staged mode, pre-fetch all blobs in one subprocess (D-CB8) ---- + // readAllIndexBlobs() collapses N per-file `git cat-file blob` spawns into a + // single `git cat-file --batch` call (~11 ms/file saved for large staged sets). + const blobMap = isStaged ? readAllIndexBlobs(fileEntries.map(e => e.path), cwd) : null; + + // ---- Scan each file ---- + let totalBytes = 0; + let scannedFiles = 0; + const exercisedAllowlist = new Set(); // tracks which allowlist entries are hit + // AC-30: hexCtx is pre-computed so the file buffer is not retained beyond the + // scan of a single file. { path, codepoint, byteOffset, hexCtx } + const hazardHits = []; + + for (const entry of fileEntries) { + let buf; + try { + if (isStaged) { + buf = blobMap.get(entry.path); + if (buf === undefined) { + errors.push(`Cannot read staged blob for ${entry.path}: not in batch output`); + continue; + } + } else { + buf = readFileSync(entry.absolutePath || resolve(cwd, entry.path)); + } + } catch (err) { + errors.push(`Cannot read ${entry.path}: ${err.message}`); + continue; + } + + totalBytes += buf.length; + scannedFiles++; + + const allowedCps = hazardAllowMap.get(entry.path) ?? new Set(); + const hits = scanBuffer(buf, entry.path, allowedCps); + + for (const hit of hits) { + if (hit.invalidUtf8) { + errors.push(`${entry.path}: invalid UTF-8 content (not a text file?)`); + } else if (hit.binaryError) { + errors.push(`${entry.path}: contains NUL bytes — add to BINARY_ALLOWLIST with a reason`); + } else if (hit.allowed) { + // D-CB6: the allowlist entry is genuinely exercised — record it so the + // stale-entry check below does not flag it. + exercisedAllowlist.add(`${entry.path}:${hit.codepoint}`); + } else { + // AC-30: compute hex context now so buf is not retained after this iteration. + hazardHits.push({ path: entry.path, codepoint: hit.codepoint, byteOffset: hit.byteOffset, hexCtx: hexContext(buf, hit.byteOffset) }); + } + } + } + + // ---- Stale allowlist check (D-CB6) ---- + for (const entry of BINARY_ALLOWLIST) { + const exists = fileEntries.some(e => e.path === entry.path); + if (!exists) { + errors.push(`BINARY_ALLOWLIST: stale entry "${entry.path}" — file is no longer tracked`); + } + } + for (const entry of HAZARD_ALLOWLIST) { + const exists = fileEntries.some(e => e.path === entry.path); + if (!exists) { + errors.push(`HAZARD_ALLOWLIST: stale entry "${entry.path}" — file is no longer tracked`); + } else { + // Verify the declared codepoints actually occur in the file + for (const cp of entry.codepoints) { + const key = `${entry.path}:${cp}`; + if (!exercisedAllowlist.has(key)) { + errors.push(`HAZARD_ALLOWLIST: stale entry "${entry.path}" cp U+${cp.toString(16).toUpperCase().padStart(4, '0')} — codepoint not found in file`); + } + } + } + } + + // ---- Report ---- + const passStats = `Scanned ${scannedFiles} file(s), ${totalBytes} byte(s)` + + (skippedCount > 0 ? `, ${skippedCount} symlink/gitlink skipped` : ''); + + // AC-17: name every allowlist entry that was actually exercised by this run. + for (const entry of HAZARD_ALLOWLIST) { + const exercisedCps = entry.codepoints.filter(cp => exercisedAllowlist.has(`${entry.path}:${cp}`)); + if (exercisedCps.length > 0) { + const cpList = exercisedCps + .map(cp => `U+${cp.toString(16).toUpperCase().padStart(4, '0')}`) + .join(', '); + console.log(` allowlist exercised: ${entry.path} [${cpList}] (reason: ${entry.reason})`); + } + } + + if (hazardHits.length > 0 || errors.length > 0) { + for (const e of errors) { + console.error(`✖ ${e}`); + } + for (const hit of hazardHits) { + const cpHex = `U+${hit.codepoint.toString(16).toUpperCase().padStart(4, '0')}`; + console.error(`✖ ${hit.path}: hazardous codepoint ${cpHex} at byte offset ${hit.byteOffset}`); + console.error(` context: ${hit.hexCtx}`); + } + console.error(`✖ source-hygiene gate FAILED — ${passStats}`); + process.exit(1); + } + + console.log(`✓ source-hygiene gate: ${passStats}`); + process.exit(0); +} + +// Run only when executed directly (not imported by tests). See isMainModule. +if (isMainModule(import.meta.url)) { + main(); +} diff --git a/scripts/verify-pr-checks.mjs b/scripts/verify-pr-checks.mjs new file mode 100644 index 0000000..3601244 --- /dev/null +++ b/scripts/verify-pr-checks.mjs @@ -0,0 +1,538 @@ +#!/usr/bin/env node +/** + * D-PR1: Pre-merge check verifier — asserts that all required branch-protection + * contexts are completed+success before an --admin merge. + * + * Addresses PF-017: a CANCELLED GitHub Actions run is neither success nor + * failure. `gh pr merge --admin` treats a cancelled run as not-failing and + * merges, bypassing the required-status gate. This tool explicitly checks + * status=completed AND conclusion=success for every required context, and + * treats cancelled/skipped/stale/in_progress as NOT passing. + * + * D-PR2: Required contexts are read LIVE from branch protection — never + * hardcoded. On 403 the script exits 2. On 404 (unprotected base) the script + * exits 2 unless --required-from is supplied. A protected branch that + * lists ZERO required contexts also exits 2: "all 0 required contexts passed" + * is a vacuous green, and vacuous greens are what PF-017 was made of. + * + * D-PR2a: A required context is resolved against the UNION of check-runs AND + * commit statuses (GitHub branch protection accepts either namespace). + * + * D-PR3: Three tiers: + * Tier A (required): MUST be completed+success — missing/cancelled/etc = FAIL + * Tier B (non-required check-runs): failure/cancelled/timed_out = FAIL + * Tier C (legacy commit statuses): advisory unless the context is required + * + * D-PR4: Non-vacuity guard — zero check-runs = FAIL (the #239 case). + * Counts are always printed on every run (avoids PF-013). + * + * D-PR4a: Pagination is bounded at MAX_PAGES; reaching it exits 2. + * `filter=latest` is pinned explicitly (default today, but implicit + * defaults can change and this gate's verdict depends on it). + * + * D-PR5: On PASS the tool prints a merge command with --match-head-commit + * , closing the TOCTOU window where the verified SHA diverges + * from HEAD by the time the merge runs. + * + * D-PR6: Exit codes — 0 PASS, 1 FAIL, 2 indeterminate. "Cannot tell" is + * never 0. + * + * Usage: + * node scripts/verify-pr-checks.mjs + * node scripts/verify-pr-checks.mjs --required-from + * + * Exit codes: + * 0 — all required contexts completed+success; prints `gh pr merge` command + * 1 — one or more required contexts missing/cancelled/failed/etc + * 2 — tool error: gh missing/too old, protection unreadable, pagination error + */ +'use strict'; + +import { spawnSync } from 'node:child_process'; +import { realpathSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** + * True when this module is the process entry point. + * + * Deliberately not `import.meta.url === 'file://' + process.argv[1]`: that + * comparison is false for any path a file URL percent-encodes (a space) and + * for any symlinked path (Node resolves import.meta.url through realpath but + * leaves argv[1] as typed — on macOS /tmp and /var/folders are symlinks). Both + * failures are silent: main() never runs and the merge gate exits 0 having + * verified nothing. Kept local so each scripts/verify-*.mjs stays standalone. + */ +function isMainModule(metaUrl) { + const entry = process.argv[1]; + if (!entry) return false; + const modulePath = fileURLToPath(metaUrl); + try { + return realpathSync(entry) === realpathSync(modulePath); + } catch { + return pathToFileURL(resolve(entry)).href === metaUrl; + } +} + +// D-PR4a: hard page cap — exit 2 rather than evaluating a partial result +const MAX_PAGES = 20; +// D-PR5: minimum gh version required for --match-head-commit +const MIN_GH_MAJOR = 2; +const MIN_GH_MINOR = 31; + +// --------------------------------------------------------------------------- +// gh runner (thin IO shim; injected in tests for offline operation) +// --------------------------------------------------------------------------- + +/** + * Default runner: calls `gh api` and returns parsed JSON. + * @param {string[]} args + * @returns {any} + */ +function defaultGhRunner(args) { + const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); + if (r.error) { + const stderr = r.error.code === 'ENOENT' + ? 'gh is not on PATH' + : `gh error: ${r.error.message}`; + return { __error: true, status: -1, stderr }; + } + if (r.status !== 0) { + // Return status code so caller can handle 404/403 + return { __error: true, status: r.status, stderr: r.stderr }; + } + try { + return JSON.parse(r.stdout); + } catch { + return { __error: true, status: r.status, raw: r.stdout, stderr: r.stderr }; + } +} + +// --------------------------------------------------------------------------- +// D-PR1: Pure evaluation function (no I/O — fully testable offline) +// --------------------------------------------------------------------------- + +/** + * @typedef {{ + * name: string; + * status: string; // 'completed' | 'queued' | 'in_progress' | ... + * conclusion: string | null; // 'success' | 'failure' | 'cancelled' | ... + * }} CheckRun + * + * @typedef {{ + * context: string; + * state: string; // 'success' | 'failure' | 'error' | 'pending' + * }} CommitStatus + * + * @typedef {{ + * requiredContexts: string[]; + * checkRuns: CheckRun[]; + * statuses: CommitStatus[]; + * headSha: string; + * }} EvaluateInput + * + * @typedef {{ + * pass: boolean; + * exitCode: number; // 0, 1, or 2 + * lines: string[]; // human-readable output lines + * mergeCommand?: string; + * }} EvaluateResult + */ + +/** + * Evaluate check-run and status data against required contexts. + * This is the pure decision function — inject any data source. + * + * applies ADR-009, avoids PF-013: prints counts on every call. + * avoids PF-017: required contexts must be status=completed AND conclusion=success. + * + * @param {EvaluateInput} input + * @returns {EvaluateResult} + */ +export function evaluateChecks({ requiredContexts, checkRuns, statuses, headSha }) { + const lines = []; + const failures = []; + let pass = true; + + const nChecks = checkRuns.length; + const nStatuses = statuses.length; + const nRequired = requiredContexts.length; + + // D-PR4: Non-vacuity guard — an empty required set can never be evidence of + // merge safety. "All 0 required contexts passed" is the same vacuous green + // that ADR-009 forbids: the tool cannot tell, so it exits 2, never 0. + // Reachable whenever protection exists but lists no required status checks, + // or when a future API shape stops populating `contexts`. + if (nRequired === 0) { + lines.push(` check-runs: ${nChecks}, statuses: ${nStatuses}, required contexts: ${nRequired}`); + lines.push( + '✖ INDETERMINATE: zero required contexts — nothing to verify, so this is not a pass ' + + '(applies ADR-009). Point --required-from at a branch whose protection lists required checks.', + ); + return { pass: false, exitCode: 2, lines }; + } + + // D-PR4: Non-vacuity guard — zero check-runs is the #239 shape (not a pass) + if (nChecks === 0) { + lines.push(` check-runs: ${nChecks}, statuses: ${nStatuses}, required contexts: ${nRequired}`); + lines.push('✖ FAIL: zero check-runs (the #239 shape — not a pass, avoids PF-013)'); + // AC-22: still name every required context that was absent so the caller + // knows exactly what was missing, even though the non-vacuity guard is + // already sufficient to FAIL. This matches the Tier A loop's behavior for + // a partial check-run set and eliminates vacuous "zero check-runs" messages + // that don't say which contexts were expected. + for (const ctx of requiredContexts) { + lines.push(`✖ Tier A (required): "${ctx}" — not found in check-runs (never ran)`); + } + return { pass: false, exitCode: 1, lines }; + } + + // Always print counts (D-PR4 / avoids PF-013) + lines.push(` check-runs: ${nChecks}, statuses: ${nStatuses}, required contexts: ${nRequired}`); + + // Build lookup maps. + // A name maps to EVERY check-run carrying it, not just the last one seen: + // `filter=latest` de-duplicates within a check-suite, but two suites (two + // workflows) can publish the same name, and a required context is satisfied + // by the name. Keeping only the last entry lets a later success mask an + // earlier failure — a fail-open in a merge gate. + const checksByName = new Map(); // name -> CheckRun[] + for (const cr of checkRuns) { + const list = checksByName.get(cr.name); + if (list) list.push(cr); + else checksByName.set(cr.name, [cr]); + } + const statusByContext = new Map(); // context -> CommitStatus + for (const st of statuses) { + statusByContext.set(st.context, st); + } + + // ---- Tier A: required contexts ---- + // D-PR2a: resolved against the UNION of check-runs and commit statuses. + // avoids PF-017: must be status=completed AND conclusion=success. + for (const ctx of requiredContexts) { + const crs = checksByName.get(ctx); + const st = statusByContext.get(ctx); + + if (crs) { + // Found in check-runs namespace — EVERY run under this name must pass. + for (const cr of crs) { + if (cr.status !== 'completed' || cr.conclusion !== 'success') { + failures.push( + `Tier A (required): "${ctx}" — status=${cr.status}, conclusion=${cr.conclusion ?? 'null'}` + + (crs.length > 1 ? ` (1 of ${crs.length} runs sharing this name)` : '') + + ` (avoids PF-017: cancelled/skipped/in_progress are not success)`, + ); + pass = false; + } + } + } else if (st) { + // Found in commit statuses namespace + if (st.state !== 'success') { + failures.push(`Tier A (required): "${ctx}" — status.state=${st.state} (must be "success")`); + pass = false; + } + } else { + // Not found in either namespace + failures.push(`Tier A (required): "${ctx}" — not found in check-runs or statuses (never ran)`); + pass = false; + } + } + + const requiredSet = new Set(requiredContexts); + + // ---- Tier B: non-required check-runs ---- + // failure/cancelled/timed_out/action_required/stale = FAIL + // skipped/neutral = advisory (reported but not fatal) + const TIER_B_FAIL = new Set(['failure', 'timed_out', 'cancelled', 'action_required', 'stale']); + const TIER_B_ADVISORY = new Set(['skipped', 'neutral']); + + for (const cr of checkRuns) { + if (requiredSet.has(cr.name)) continue; // Already handled in Tier A + if (cr.status !== 'completed') continue; // Still running — skip advisory + if (cr.conclusion == null) continue; + + if (TIER_B_FAIL.has(cr.conclusion)) { + failures.push(`Tier B (non-required): "${cr.name}" — conclusion=${cr.conclusion}`); + pass = false; + } else if (TIER_B_ADVISORY.has(cr.conclusion)) { + lines.push(` advisory: "${cr.name}" — conclusion=${cr.conclusion}`); + } + } + + // ---- Tier C: legacy commit statuses ---- + // Advisory unless the context is required (Tier A already handled those). + // Justified by #240 evidence: the sole status was security/snyk (dean0x) + // in state=error due to account-plan limits — not a workflow in this repo. + for (const st of statuses) { + if (requiredSet.has(st.context)) continue; // Already handled in Tier A + if (st.state !== 'success' && st.state !== 'pending') { + lines.push(` advisory (Tier C): "${st.context}" — state=${st.state}`); + } + } + + // ---- Compose result ---- + for (const f of failures) { + lines.push(`✖ ${f}`); + } + + if (pass) { + const cmd = `gh pr merge --squash --match-head-commit ${headSha}`; + lines.push(`✓ PASS — all ${nRequired} required contexts completed+success`); + lines.push(` Verified SHA: ${headSha}`); + lines.push(` Merge command: ${cmd}`); + return { pass: true, exitCode: 0, lines, mergeCommand: cmd }; + } else { + lines.push(`✖ FAIL — ${failures.length} required context(s) not satisfied`); + return { pass: false, exitCode: 1, lines }; + } +} + +// --------------------------------------------------------------------------- +// Main (live path with real gh API calls) +// +// Every step below returns a Result ({ ok: true, ... } | { ok: false, exitCode, +// message }) instead of calling process.exit. Only the CLI wrapper at the +// bottom of this file translates an exit code into a process exit, which is +// what makes the exit-2 paths (404, 403, stale gh, unbounded pagination) +// reachable from an offline test with an injected runner. A tool whose +// failure paths can only be asserted by grepping its own source text is +// exactly the vacuous verification this script exists to eliminate +// (applies ADR-009, avoids PF-013). +// --------------------------------------------------------------------------- + +function ghVersion() { + const r = spawnSync('gh', ['--version'], { encoding: 'utf8' }); + if (r.error || r.status !== 0) return null; + // Output: "gh version 2.88.1 (2026-07-17)" + const m = r.stdout.match(/gh version (\d+)\.(\d+)/); + if (!m) return null; + return { major: parseInt(m[1], 10), minor: parseInt(m[2], 10) }; +} + +/** + * Fetch all pages of check-runs for a given sha, bounded at MAX_PAGES. + * D-PR4a: filter=latest pinned; paginate with hard cap; exit 2 on incomplete. + * + * @returns {{ ok: true, checkRuns: CheckRun[] } | { ok: false, exitCode: 2, message: string }} + */ +export function fetchCheckRuns(headSha, runner) { + const perPage = 100; + let page = 1; + const allCheckRuns = []; + let totalCount = null; + + // Bounded loop (reliability rule): at most MAX_PAGES iterations, always. + while (page <= MAX_PAGES) { + // D-PR4a: filter=latest pinned explicitly to prevent default-change surprises + const url = `/repos/{owner}/{repo}/commits/${headSha}/check-runs?per_page=${perPage}&page=${page}&filter=latest`; + const data = runner(['api', url]); + if (data.__error) { + return { + ok: false, + exitCode: 2, + message: `check-runs API error (page ${page}): ${data.stderr}`, + }; + } + if (totalCount === null) { + totalCount = data.total_count ?? 0; + } + const runs = data.check_runs ?? []; + allCheckRuns.push(...runs); + if (runs.length < perPage || allCheckRuns.length >= totalCount) break; + page++; + } + + if (page > MAX_PAGES) { + return { + ok: false, + exitCode: 2, + message: `pagination exceeded ${MAX_PAGES} pages (D-PR4a) — refusing to evaluate a partial set`, + }; + } + + // D-PR4a: assert we collected everything declared by total_count + if (totalCount !== null && allCheckRuns.length !== totalCount) { + return { + ok: false, + exitCode: 2, + message: `collected ${allCheckRuns.length} check-runs but total_count=${totalCount} — partial page set`, + }; + } + + return { ok: true, checkRuns: allCheckRuns }; +} + +/** + * Fetch commit statuses for a sha. + * @returns {{ ok: true, statuses: CommitStatus[] } | { ok: false, exitCode: 2, message: string }} + */ +export function fetchStatuses(headSha, runner) { + const url = `/repos/{owner}/{repo}/commits/${headSha}/status`; + const data = runner(['api', url]); + if (data.__error) { + return { ok: false, exitCode: 2, message: `commit-status API error: ${data.stderr}` }; + } + return { ok: true, statuses: data.statuses ?? [] }; +} + +/** + * Fetch required contexts from branch protection. + * D-PR2: exit 2 on 403 or when the base has no protection and no fallback. + * AC-29: unprotected base (404) exits 2 unless --required-from is given. + * + * The required set is the UNION of the legacy `contexts` array and the newer + * `checks[].context` array. GitHub populates both today; reading only the + * deprecated `contexts` would silently yield an empty required set — and an + * empty required set is a vacuous pass, not a pass. + * + * @returns {{ ok: true, contexts: string[], resolvedBranch: string, notes: string[] } + * | { ok: false, exitCode: 2, message: string }} + */ +export function fetchRequiredContexts(baseBranch, requiredFrom, runner) { + const branch = requiredFrom ?? baseBranch; + const url = `/repos/{owner}/{repo}/branches/${branch}/protection`; + const data = runner(['api', url]); + + if (data.__error) { + if (data.status === 404) { + const message = requiredFrom + ? `--required-from branch "${requiredFrom}" has no protection (404)` + : `base branch "${baseBranch}" has no protection (404). ` + + `Use --required-from to name a protected branch, e.g. --required-from main. ` + + `(AC-29: an unprotected base is not a pass — D-PR2)`; + return { ok: false, exitCode: 2, message }; + } + if (data.status === 403) { + return { + ok: false, + exitCode: 2, + message: 'branch protection unreadable (403 — insufficient permissions)', + }; + } + return { ok: false, exitCode: 2, message: `protection API error: ${data.stderr}` }; + } + + const rsc = data?.required_status_checks; + const contexts = [...new Set([ + ...(rsc?.contexts ?? []), + ...(rsc?.checks ?? []).map(c => c?.context).filter(c => typeof c === 'string'), + ])]; + + if (contexts.length === 0) { + return { + ok: false, + exitCode: 2, + message: + `branch "${branch}" is protected but lists zero required status checks — ` + + `there is nothing to verify, which is indeterminate, not a pass (applies ADR-009)`, + }; + } + + const notes = []; + if (requiredFrom && requiredFrom !== baseBranch) { + notes.push(` Required contexts read from: ${requiredFrom} (base branch "${baseBranch}" is unprotected)`); + } + return { ok: true, contexts, resolvedBranch: branch, notes }; +} + +const USAGE = + 'Usage: node scripts/verify-pr-checks.mjs [--required-from ]'; + +/** + * Live entry point. Returns an exit code; never calls process.exit, so tests + * can drive it end-to-end with an injected runner. + * + * @param {string[]} argv + * @param {(args: string[]) => any} runner — gh API shim + * @param {() => ({major:number,minor:number}|null)} ghVersionFn — version probe + * @returns {0|1|2} + */ +export function main(argv = process.argv.slice(2), runner = defaultGhRunner, ghVersionFn = ghVersion) { + const fail = (message) => { + console.error(`✖ verify-pr-checks: ${message}`); + }; + + // ---- Parse args ---- + const prArg = argv.find(a => /^\d+$/.test(a)); + if (!prArg) { + console.error(USAGE); + return 2; + } + const prNumber = parseInt(prArg, 10); + + const rfIdx = argv.indexOf('--required-from'); + if (rfIdx !== -1 && !argv[rfIdx + 1]) { + fail(`--required-from requires a branch name\n${USAGE}`); + return 2; + } + const requiredFrom = rfIdx !== -1 ? argv[rfIdx + 1] : null; + + // ---- Check gh version (D-PR5) ---- + const ver = ghVersionFn(); + if (!ver || ver.major < MIN_GH_MAJOR || (ver.major === MIN_GH_MAJOR && ver.minor < MIN_GH_MINOR)) { + const found = ver ? `${ver.major}.${ver.minor}` : 'unknown'; + fail( + `gh >= ${MIN_GH_MAJOR}.${MIN_GH_MINOR} required (found ${found}); ` + + `needed for --match-head-commit (D-PR5)`, + ); + return 2; + } + + // ---- Fetch PR metadata ---- + const prData = runner(['api', `/repos/{owner}/{repo}/pulls/${prNumber}`]); + if (prData.__error) { + fail(`cannot read PR ${prNumber}: ${prData.stderr}`); + return 2; + } + const headSha = prData.head?.sha; + const baseBranch = prData.base?.ref; + if (!headSha || !baseBranch) { + fail(`cannot determine head SHA or base branch for PR ${prNumber}`); + return 2; + } + + console.log(`PR #${prNumber}: base=${baseBranch} head=${headSha.slice(0, 7)}`); + + // ---- Fetch required contexts (D-PR2) ---- + const req = fetchRequiredContexts(baseBranch, requiredFrom, runner); + if (!req.ok) { + fail(req.message); + return req.exitCode; + } + for (const note of req.notes) console.log(note); + console.log(` Required contexts (${req.contexts.length}) from ${req.resolvedBranch}: ${req.contexts.join(', ')}`); + + // ---- Fetch check-runs (D-PR4a) ---- + const cr = fetchCheckRuns(headSha, runner); + if (!cr.ok) { + fail(cr.message); + return cr.exitCode; + } + + // ---- Fetch commit statuses (D-PR2a) ---- + const st = fetchStatuses(headSha, runner); + if (!st.ok) { + fail(st.message); + return st.exitCode; + } + + // ---- Evaluate (D-PR1: pure function) ---- + const result = evaluateChecks({ + requiredContexts: req.contexts, + checkRuns: cr.checkRuns, + statuses: st.statuses, + headSha, + }); + + for (const line of result.lines) { + console.log(line); + } + + return result.exitCode; +} + +// Run only when executed directly (not imported by tests). See isMainModule. +if (isMainModule(import.meta.url)) { + process.exit(main()); +}