diff --git a/.bestpractices.json b/.bestpractices.json index e4b4182..0e31100 100644 --- a/.bestpractices.json +++ b/.bestpractices.json @@ -39,8 +39,8 @@ "version_semver_justification": "The repository does not publish versioned releases or use SemVer/CalVer version numbers, relying solely on Git commit identifiers (GitHub repository settings read 2026-11-01: 0 releases and 0 tags).", "version_tags_status": "Unmet", "version_tags_justification": "The repository does not contain Git tags identifying releases (GitHub repository settings read 2026-11-01: 0 tags; git tag produces no output).", - "release_notes_status": "Unmet", - "release_notes_justification": "No release has been published, so no release notes exist (repository releases: 0, tags: 0, read 2026-11-01). N/A does not apply: the community health files are reused by every repository in the organisation.", + "release_notes_status": "N/A", + "release_notes_justification": "N/A: the repository publishes no releases (0 releases, 0 tags, read 2026-11-08) because nothing consumes a release. GitHub serves the organisation profile and community health files from the default branch, and the reusable label workflow is called at @main and fetches labels.yml from main, so the files are continuously updated and deployed to one place, the case this criterion allows N/A for. Evidence: https://github.com/golusoris/.github/blob/main/.github/workflows/sync-labels.yml", "release_notes_vulns_status": "N/A", "release_notes_vulns_justification": "No release notes exist and no publicly known run-time vulnerabilities have been reported for this repository (GitHub repository settings read 2026-11-01: dependabot open: 0, releases: 0).", "report_process_status": "Met", @@ -65,18 +65,18 @@ "build_common_tools_justification": "No build toolchain is required to use the files in this repository (Makefile: build target is not applicable).", "build_floss_tools_status": "N/A", "build_floss_tools_justification": "No build tools are needed to use this repository's profile and configuration files (Makefile: build target is not applicable).", - "test_status": "Unmet", - "test_justification": "The repository contains no automated software test suite, as it holds organisation profile and community health files only (Makefile test target prints 'test: not applicable: no project code').", - "test_invocation_status": "Unmet", - "test_invocation_justification": "No software test suite is present to invoke in this repository (Makefile declares test not applicable).", + "test_status": "Met", + "test_justification": "An automated test suite under tests/ (Python unittest) checks the files the repository publishes: labels.yml against the format EndBug/label-sync reads, the .bestpractices.json answers, and the relative links of the rendered Markdown files, each with planted-defect and boundary cases. Evidence: https://github.com/golusoris/.github/tree/main/tests", + "test_invocation_status": "Met", + "test_invocation_justification": "make test runs the suite in tests/ with the standard command python3 -B -m unittest discover -s tests -t . -v, and make verify-all runs it with the other checks. Evidence: https://github.com/golusoris/.github/blob/main/Makefile", "test_most_status": "Unmet", - "test_most_justification": "No automated test suite or code coverage measurement exists for this repository (Makefile declares test not applicable).", - "test_continuous_integration_status": "Unmet", - "test_continuous_integration_justification": "CI runs REUSE lint (.github/workflows/reuse.yml) and OpenSSF Scorecard (.github/workflows/scorecard.yml) on pushes and pull requests to main, but neither runs an automated test suite, and the repository has none.", - "test_policy_status": "Unmet", - "test_policy_justification": "No policy requiring automated tests for new functionality is defined in CONTRIBUTING.md or repository documentation, as the repository holds community files and profile documentation rather than executable software.", - "tests_are_added_status": "Unmet", - "tests_are_added_justification": "No automated tests are added with changes in the repository (git log shows commits modifying configuration and documentation without accompanying tests).", + "test_most_justification": "The suite in tests/ checks every published data file and every rendered Markdown file, but coverage is not measured: no coverage tool runs, so no figure is claimed. Evidence: https://github.com/golusoris/.github/blob/main/Makefile", + "test_continuous_integration_status": "Met", + "test_continuous_integration_justification": "The Tests workflow runs make test (tests/) on every pull request, on main and in the merge queue. Evidence: https://github.com/golusoris/.github/blob/main/.github/workflows/test.yml", + "test_policy_status": "Met", + "test_policy_justification": "README.md states the test policy: a change that adds a published file, or a rule for one, adds a test to tests/ in the same pull request with a passing case, a refused planted defect and the boundary between them, and a fix adds a test that fails without it. Evidence: https://github.com/golusoris/.github/blob/main/README.md#tests", + "tests_are_added_status": "Met", + "tests_are_added_justification": "Pull request 12 added the suite in tests/ together with a passing, a planted-defect and a boundary test for every check it introduced, and the README test policy requires the same of every later change. Evidence: https://github.com/golusoris/.github/pull/12", "tests_documented_added_status": "Unmet", "tests_documented_added_justification": "Instructions for change proposals in CONTRIBUTING.md do not document a policy requiring automated tests.", "warnings_status": "Met", @@ -130,7 +130,7 @@ "dynamic_analysis_unsafe_status": "N/A", "dynamic_analysis_unsafe_justification": "The repository does not produce software written in a memory-unsafe language, containing only Markdown documentation, YAML configuration, and shell/python governance scripts.", "dynamic_analysis_enable_assertions_status": "Unmet", - "dynamic_analysis_enable_assertions_justification": "No dynamic analysis or automated test suite is run for this repository (Makefile declares test not applicable).", + "dynamic_analysis_enable_assertions_justification": "No dynamic analysis tool runs; the suite in tests/ checks data and Markdown files, not executable software, so no run-time assertions apply. Evidence: https://github.com/golusoris/.github/blob/main/Makefile", "dynamic_analysis_fixed_status": "N/A", "dynamic_analysis_fixed_justification": "No dynamic analysis is run for this repository and consequently no vulnerabilities have been discovered via dynamic analysis (Makefile)." } diff --git a/.config/tests/requirements.in b/.config/tests/requirements.in new file mode 100644 index 0000000..e8bc38c --- /dev/null +++ b/.config/tests/requirements.in @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2026 lusoris +# +# SPDX-License-Identifier: EUPL-1.2 + +# Test-suite dependency: tests/ parse labels.yml with PyYAML. +PyYAML + +# Test-suite dependency: tests/ parse Markdown links with markdown-it-py. +markdown-it-py diff --git a/.config/tests/requirements.txt b/.config/tests/requirements.txt new file mode 100644 index 0000000..a2e106c --- /dev/null +++ b/.config/tests/requirements.txt @@ -0,0 +1,85 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile --universal --generate-hashes --python-version=3.10 .config/tests/requirements.in --output-file=.config/tests/requirements.txt +markdown-it-py==4.2.0 \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a + # via -r .config/tests/requirements.in +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba + # via markdown-it-py +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via -r .config/tests/requirements.in diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json index f77b7f8..6398740 100644 --- a/.github/rulesets/main.json +++ b/.github/rulesets/main.json @@ -55,6 +55,9 @@ }, { "context": "Scorecard Analysis" + }, + { + "context": "make test" } ], "strict_required_status_checks_policy": true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f4a90cd --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,50 @@ +--- +# Test suite: runs make test (tests/) on every pull request, on main and in +# the merge queue. The suite checks labels.yml, .bestpractices.json and the +# relative links of the rendered Markdown files. +name: Tests + +'on': + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + push: + branches: ['main'] + merge_group: +concurrency: + group: ${{ github.workflow }}-${{ github.event.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + test: + name: make test + runs-on: ubuntu-26.04 + timeout-minutes: 10 + steps: + - name: Stop on a draft pull request + if: github.event.pull_request.draft == true + shell: bash + env: + TITLE: Gate not run on a draft + MESSAGE: >- + The gate did not run because the pull request is a draft. + It runs when the pull request is marked ready for review. + run: | + echo "::error title=$TITLE::$MESSAGE" + echo "$MESSAGE" >> "$GITHUB_STEP_SUMMARY" + exit 1 + - name: Check out the source + if: github.event.pull_request.draft != true + # yamllint disable-line rule:line-length + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: make test + if: github.event.pull_request.draft != true + run: | + python3 -m venv "$RUNNER_TEMP/tests" + "$RUNNER_TEMP/tests/bin/python" -m pip install \ + --require-hashes -r .config/tests/requirements.txt + make test PYTHON="$RUNNER_TEMP/tests/bin/python" diff --git a/.paperclip/harness.json b/.paperclip/harness.json index 82ddc3d..fb214de 100644 --- a/.paperclip/harness.json +++ b/.paperclip/harness.json @@ -11,10 +11,10 @@ ], "push_format": "git push origin HEAD:refs/heads/paperclip/\u003cissue-id\u003e", "invariants": [ - "HISS-01: recursion prohibited; call graph = DAG; Go, C/C++: zero `goto`", - "HISS-02: scalar upper bound on every loop; explicit deadline on every I/O call; Go: I/O takes `context.Context` deadline", + "HISS-01: recursion prohibited; call graph = DAG", + "HISS-02: scalar upper bound on every loop; explicit deadline on every I/O call", "HISS-04: McCabe cyclomatic \u003c= 15, cognitive \u003c= 20, statements \u003c= 75; func LOC \u003c= 60 (audit ceiling)", - "HISS-07: every error handled or wrapped with context; Go: zero unchecked `error` return; Rust: zero `.unwrap()` / `.expect()` outside tests", + "HISS-07: every error handled or wrapped with context", "HISS-10: zero warnings: compiler, linter, format sweeps", "HISS-15: positive + negative + boundary tests, every public interface", "HISS-16: single canonical `AGENTS.md`; vendor files compiled via `praetorctl compile-context`" diff --git a/.paperclip/rules.md b/.paperclip/rules.md index fa584be..db76308 100644 --- a/.paperclip/rules.md +++ b/.paperclip/rules.md @@ -22,13 +22,11 @@ git push origin HEAD:refs/heads/paperclip/ ## High-Integrity Invariants -- HISS-01: recursion prohibited; call graph = DAG; Go, C/C++: zero `goto` -- HISS-02: scalar upper bound on every loop; explicit deadline on every I/O - call; Go: I/O takes `context.Context` deadline +- HISS-01: recursion prohibited; call graph = DAG +- HISS-02: scalar upper bound on every loop; explicit deadline on every I/O call - HISS-04: McCabe cyclomatic <= 15, cognitive <= 20, statements <= 75; func LOC <= 60 (audit ceiling) -- HISS-07: every error handled or wrapped with context; Go: zero unchecked - `error` return; Rust: zero `.unwrap()` / `.expect()` outside tests +- HISS-07: every error handled or wrapped with context - HISS-10: zero warnings: compiler, linter, format sweeps - HISS-15: positive + negative + boundary tests, every public interface - HISS-16: single canonical `AGENTS.md`; vendor files compiled via `praetorctl diff --git a/.standards.yaml b/.standards.yaml index 34fae7a..d100396 100644 --- a/.standards.yaml +++ b/.standards.yaml @@ -30,7 +30,7 @@ register: sources: expected: 13 sha256: - sha256:bae7c17912f7d873769ddeae9dce1cf267e94eedd838228a219463bf5cb35efa + sha256:ac76ac78e354aa8628cf74916271fef35ec9de2af6244ce254ee449ffabd121e inputs: - path: .paperclip/harness.json surface: prompts diff --git a/Makefile b/Makefile index 50abf2c..223dea9 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,11 @@ -# Generated by Praetor from declared project commands; execution is unverified. +# Generated by Praetor from declared project commands. The test target runs the +# repository's own suite under tests/; praetorctl adopt keeps a custom recipe. -include .config/praetor/engine.mk # Praetor ships one binary under two names; resolve whichever is installed. PRAETORCTL ?= $(shell command -v praetorctl 2>/dev/null || command -v standardsctl 2>/dev/null || echo praetorctl) +# The suite needs Python 3.10 or later with PyYAML and markdown-it-py +# (.config/tests/requirements.txt). +PYTHON ?= python3 .PHONY: all verify-all compile-context compile-context-verify caveman-sources audit build test all: build @@ -21,7 +25,7 @@ audit: @$(PRAETORCTL) audit test: build - @printf '%s\n' 'test: not applicable: no project code; verify-all checks the agent context (compile-context-verify), the caveman sources and the audit.' + @$(PYTHON) -B -m unittest discover -s tests -t . -v build: @printf '%s\n' 'build: not applicable: this repository holds organisation profile and community files only; nothing compiles.' diff --git a/README.md b/README.md index ad84832..42a7e03 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,31 @@ Local checks: `make verify-all` runs the full gate; `make audit` and `make compile-context-verify` run its parts. The Makefile uses `praetorctl` or `standardsctl`, whichever is installed. +## Tests + +`make test` runs the suite under [`tests/`](tests/) with Python's `unittest`. +It needs Python 3.10 or later, PyYAML and markdown-it-py, pinned with hashes in +[`.config/tests/requirements.txt`](.config/tests/requirements.txt). The +[Tests workflow](.github/workflows/test.yml) runs it on every pull request, on +`main` and in the merge queue. + +The suite checks what this repository publishes to the organisation: + +- [`labels.yml`](labels.yml) has the shape EndBug/label-sync reads, a + six-digit colour on every label, and no name or alias twice + ([`tests/test_labels.py`](tests/test_labels.py)). +- [`.bestpractices.json`](.bestpractices.json) gives every criterion a valid + status and a justification + ([`tests/test_bestpractices.py`](tests/test_bestpractices.py)). +- Every relative link in the profile, the community health files and the + decision records names a file that exists + ([`tests/test_markdown_links.py`](tests/test_markdown_links.py)). + +Test policy: a change that adds a published file, or a rule for one, adds a +test to `tests/` in the same pull request, with a passing case, a refused +planted defect and the boundary between them. A fix adds a test that fails +without it. + [![HISS Adopted][praetor-hiss-badge]][praetor-hiss-agents] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..6ab13da --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the files this repository publishes; `make test` runs them.""" diff --git a/tests/repo_checks.py b/tests/repo_checks.py new file mode 100644 index 0000000..4f7cfc8 --- /dev/null +++ b/tests/repo_checks.py @@ -0,0 +1,354 @@ +"""Checks for the files this repository publishes to the organisation. + +Each check takes parsed data (or text) and returns a list of error strings; +an empty list means the input passes. The test modules run every check +against the real repository files and against planted defects. +""" + +import re +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import unquote + +from markdown_it import MarkdownIt + +ROOT = Path(__file__).resolve().parent.parent + +# Keys EndBug/label-sync reads from a label entry (src/index.ts at the +# commit .github/workflows/sync-labels.yml pins). Any other key is a typo +# the action would silently ignore, so the check refuses it. +LABEL_KEYS = frozenset({"name", "color", "description", "aliases"}) + +# The GitHub REST API takes a hexadecimal colour code without the leading +# '#'; label-sync strips one leading '#' before sending it. +LABEL_COLOR = re.compile(r"#?[0-9a-fA-F]{6}") + +# GitHub REST API limits: 422 'name is too long (maximum is 50 characters)' +# and 'Description is too long (maximum is 100 characters)'. +LABEL_NAME_MAX = 50 +LABEL_DESCRIPTION_MAX = 100 + +# Bounded collection limits to satisfy HISS-02 scalar loop bounds. +MAX_LABELS = 1000 +MAX_ANSWER_KEYS = 2000 +MAX_MARKDOWN_BYTES = 1_000_000 +MAX_MARKDOWN_FILES = 1000 + +# Answer values the bestpractices.dev autofill file accepts per criterion. +STATUS_VALUES = frozenset({"Met", "Unmet", "N/A", "?"}) +ANSWER_KEY = re.compile(r"[a-z0-9_]+_(status|justification)") + +# Markdown files the organisation renders: the profile, the community health +# files and the decision records. +MARKDOWN_GLOBS = ("*.md", "profile/**/*.md", "docs/**/*.md") +EXTERNAL = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*:") + + +class _LinkHTMLParser(HTMLParser): + """Extract link href and image src/srcset targets from HTML tokens.""" + + def __init__(self): + """Initialize the parser with an empty target buffer.""" + super().__init__() + self.drained = [] + + def handle_starttag(self, tag, attrs): + """Collect targets from an HTML start or empty tag.""" + attr_dict = dict(attrs) + if tag == "a" and "href" in attr_dict: + href = attr_dict["href"] + if href: + self.drained.append((href, False)) + elif tag == "img" and "src" in attr_dict: + src = attr_dict["src"] + if src: + self.drained.append((src, True)) + elif tag == "source" and "srcset" in attr_dict: + srcset = attr_dict["srcset"] + if srcset: + for part in srcset.split(","): + candidate = part.strip() + if candidate: + url = candidate.split()[0] + if url: + self.drained.append((url, True)) + + def drain(self): + """Return the collected targets and clear the buffer.""" + collected = self.drained + self.drained = [] + return collected + + +class MarkdownTooLarge(ValueError): + """A Markdown file is larger than MAX_MARKDOWN_BYTES.""" + + +def _html_targets(fragment): + """Return the (target, is_image) pairs of one raw HTML fragment. + + Each fragment gets its own parser, closed at the end, so an unfinished + tag in one fragment cannot join the text of the next. + """ + parser = _LinkHTMLParser() + parser.feed(fragment) + parser.close() + return parser.drain() + + +def check_label(entry, index): + """Return the errors of one labels.yml entry at position index.""" + if not isinstance(entry, dict): + return [f"entry {index}: not a mapping"] + errors = [f"entry {index}: unknown key {key!r}" for key in sorted(set(entry) - LABEL_KEYS)] + name = entry.get("name") + if not isinstance(name, str) or not name.strip(): + errors.append(f"entry {index}: name must be a non-empty string") + elif len(name) > LABEL_NAME_MAX: + errors.append(f"entry {index}: name exceeds {LABEL_NAME_MAX} characters") + color = entry.get("color") + if not isinstance(color, str) or not LABEL_COLOR.fullmatch(color): + errors.append(f"entry {index}: color must be six hexadecimal digits") + if "description" in entry: + desc = entry["description"] + if not isinstance(desc, str): + errors.append(f"entry {index}: description must be a string") + elif len(desc) > LABEL_DESCRIPTION_MAX: + errors.append(f"entry {index}: description exceeds {LABEL_DESCRIPTION_MAX} characters") + aliases = entry.get("aliases", []) + if not isinstance(aliases, list) or not all(isinstance(a, str) and a.strip() for a in aliases): + errors.append(f"entry {index}: aliases must be a list of non-empty strings") + else: + for alias in aliases: + if len(alias) > LABEL_NAME_MAX: + errors.append(f"entry {index}: alias {alias!r} exceeds {LABEL_NAME_MAX} characters") + return errors + + +def label_names(entry): + """Return the name and aliases one labels.yml entry claims.""" + if not isinstance(entry, dict): + return [] + aliases = entry.get("aliases") + return [entry.get("name")] + (aliases if isinstance(aliases, list) else []) + + +def check_labels(data): + """Return the errors of a parsed labels.yml document.""" + if not isinstance(data, list) or not data: + return ["labels.yml must be a non-empty list"] + if len(data) > MAX_LABELS: + return [f"labels.yml: more than {MAX_LABELS} entries"] + errors = [] + seen = {} + for index, entry in enumerate(data): + errors.extend(check_label(entry, index)) + for name in label_names(entry): + if not isinstance(name, str): + continue + key = name.strip().casefold() + if key in seen: + errors.append(f"entry {index}: {name!r} repeats entry {seen[key]}") + seen.setdefault(key, index) + return errors + + +def check_answers(data): + """Return the errors of a parsed .bestpractices.json document.""" + if not isinstance(data, dict) or not data: + return [".bestpractices.json must be a non-empty object"] + if len(data) > MAX_ANSWER_KEYS: + return [f".bestpractices.json: more than {MAX_ANSWER_KEYS} keys"] + errors = [f"{key}: not a _status or _justification key" for key in sorted(data) if not ANSWER_KEY.fullmatch(key)] + statuses = {key[: -len("_status")] for key in data if key.endswith("_status")} + justified = {key[: -len("_justification")] for key in data if key.endswith("_justification")} + for criterion in sorted(statuses): + value = data[criterion + "_status"] + if not isinstance(value, str) or value not in STATUS_VALUES: + errors.append(f"{criterion}_status: {value!r} is not one of {sorted(STATUS_VALUES)}") + text = data.get(criterion + "_justification") + if not isinstance(text, str) or not text.strip(): + errors.append(f"{criterion}: status without a justification") + errors.extend(f"{criterion}: justification without a status" for criterion in sorted(justified - statuses)) + return errors + + +def required_contexts(ruleset): + """Return the status check contexts a parsed ruleset requires.""" + rules = ruleset.get("rules") if isinstance(ruleset, dict) else None + if not isinstance(rules, list): + return [] + contexts = [] + for rule in rules: + if not isinstance(rule, dict) or rule.get("type") != "required_status_checks": + continue + parameters = rule.get("parameters") + checks = parameters.get("required_status_checks") if isinstance(parameters, dict) else None + for check in checks if isinstance(checks, list) else []: + if isinstance(check, dict) and isinstance(check.get("context"), str): + contexts.append(check["context"]) + return contexts + + +# A pull_request filter that can skip the workflow. A skipped workflow leaves +# its required check pending, which blocks the merge (GitHub Docs, "Skipping +# workflow runs"). +SKIPPING_FILTERS = ("paths", "paths-ignore", "branches-ignore") + + +def runs_on_every_pull_request(workflow): + """Return whether a parsed workflow runs on every pull request.""" + # PyYAML reads the bare key `on` as the boolean True. + trigger = workflow.get("on", workflow.get(True)) + if isinstance(trigger, str): + return trigger == "pull_request" + if isinstance(trigger, list): + return "pull_request" in [name for name in trigger if isinstance(name, str)] + if not isinstance(trigger, dict) or "pull_request" not in trigger: + return False + config = trigger["pull_request"] + return not (isinstance(config, dict) and any(key in config for key in SKIPPING_FILTERS)) + + +def check_required_checks(ruleset, workflows): + """Return the required status checks no pull request workflow job reports.""" + if not isinstance(ruleset, dict): + return ["ruleset must be a mapping"] + reported = set() + for workflow in workflows: + if not isinstance(workflow, dict) or not runs_on_every_pull_request(workflow): + continue + jobs = workflow.get("jobs") + for job_id, job in jobs.items() if isinstance(jobs, dict) else []: + reported.add(job.get("name", job_id) if isinstance(job, dict) else job_id) + return [ + f"required check {context!r} not found in pull_request workflows" + for context in required_contexts(ruleset) + if context not in reported + ] + + +# The commitments of the README test policy (README.md, "Test policy"). +TEST_POLICY_FRAGMENTS = ( + "Test policy:", + "adds a test to `tests/` in the same pull request", + "a passing case", + "a refused planted defect", + "the boundary between them", + "A fix adds a test that fails without it", +) + + +def check_test_policy(text): + """Return the test policy commitments a README text does not state. + + Line breaks and runs of spaces count as one space, so rewrapping the + paragraph does not change the result. + """ + collapsed = " ".join(text.split()) + return [f"README.md: test policy lacks {fragment!r}" for fragment in TEST_POLICY_FRAGMENTS if fragment not in collapsed] + + +def _extract_targets(text): + """Return all targets in document order and the set of image targets.""" + env = {} + tokens = MarkdownIt("commonmark").enable("table").parse(text, env) + targets = [] + image_targets = set() + # Token loops are bounded by MAX_MARKDOWN_BYTES since MarkdownIt parses + # a finite input string whose length is capped prior to parsing. + for block in tokens: + if block.type == "html_block" and block.content: + for target, is_image in _html_targets(block.content): + targets.append(target) + if is_image: + image_targets.add(target) + for token in block.children or []: + if token.type == "link_open": + href = token.attrGet("href") + if href: + targets.append(href) + elif token.type == "image": + src = token.attrGet("src") + if src: + targets.append(src) + image_targets.add(src) + elif token.type == "html_inline" and token.content: + for target, is_image in _html_targets(token.content): + targets.append(target) + if is_image: + image_targets.add(target) + references = env.get("references", {}) + for label in sorted(references): + href = references[label].get("href") + if href is not None: + targets.append(href) + deduped = [target for target in dict.fromkeys(targets) if target is not None] + return deduped, image_targets + + +def link_targets(text): + """Return the link and image targets of a Markdown text, code excluded. + + Each target appears once, in document order, followed by the targets of + reference definitions no link uses. Raw HTML a, img, and source tags are + checked; comments and other tags are ignored. + """ + targets, _ = _extract_targets(text) + return targets + + +def _relative_targets(path): + """Return relative targets and the set of image targets for path.""" + if path.stat().st_size > MAX_MARKDOWN_BYTES: + raise MarkdownTooLarge(f"{path}: more than {MAX_MARKDOWN_BYTES} bytes") + targets, image_targets = _extract_targets(path.read_text(encoding="utf-8")) + rel_targets = [] + for target in targets: + if target.startswith("//") or EXTERNAL.match(target): + continue + local = target.split("#", 1)[0].split("?", 1)[0] + if not local: + continue + rel_targets.append(target) + return rel_targets, image_targets + + +def relative_targets(path): + """Return the relative link targets in the Markdown file at path.""" + targets, _ = _relative_targets(path) + return targets + + +def check_links(path, root=ROOT): + """Return the relative link targets in path that name no file in root.""" + rel = path.relative_to(root) + try: + targets, image_targets = _relative_targets(path) + except MarkdownTooLarge: + return [f"{rel}: more than {MAX_MARKDOWN_BYTES} bytes"] + errors = [] + for target in targets: + local = target.split("#", 1)[0].split("?", 1)[0] + unquoted = unquote(local) + base = root if unquoted.startswith("/") else path.parent + target_path = (base / unquoted.lstrip("/")).resolve() + if not target_path.is_relative_to(root.resolve()): + errors.append(f"{rel}: {target} leaves the repository") + elif not target_path.exists(): + errors.append(f"{rel}: {target} names no file") + elif target in image_targets and not target_path.is_file(): + errors.append(f"{rel}: {target} names a directory, not an image file") + return errors + + +def markdown_files(root=ROOT): + """Return the rendered Markdown files under root, sorted.""" + discovered = set() + for pattern in MARKDOWN_GLOBS: + for path in root.glob(pattern): + if path.is_file(): + discovered.add(path) + if len(discovered) > MAX_MARKDOWN_FILES: + raise ValueError(f"{root}: more than {MAX_MARKDOWN_FILES} markdown files") + return sorted(discovered) diff --git a/tests/test_bestpractices.py b/tests/test_bestpractices.py new file mode 100644 index 0000000..a6d8983 --- /dev/null +++ b/tests/test_bestpractices.py @@ -0,0 +1,203 @@ +""".bestpractices.json: the OpenSSF Best Practices answers for project 16160.""" + +import json +import unittest + +import yaml + +from .repo_checks import ( + MAX_ANSWER_KEYS, + ROOT, + TEST_POLICY_FRAGMENTS, + check_answers, + check_required_checks, + check_test_policy, +) + + +def ruleset(*contexts): + """Return a minimal ruleset that requires the given status checks.""" + checks = [{"context": context} for context in contexts] + return {"rules": [{"type": "required_status_checks", "parameters": {"required_status_checks": checks}}]} + + +def workflow(on, job="make test"): + """Return a minimal parsed workflow with one job named job.""" + return {True: on, "jobs": {"test": {"name": job}}} + +# Criteria a passing badge needs Met (or N/A where the criterion allows it) +# that this repository answers with its own test suite and policy. +TEST_CRITERIA = ("test", "test_invocation", "test_policy", "tests_are_added") + + +def load_answers(): + """Return the parsed .bestpractices.json of the repository.""" + return json.loads((ROOT / ".bestpractices.json").read_text(encoding="utf-8")) + + +class AnswerFileTest(unittest.TestCase): + def test_repository_answers_pass(self): + """The repository .bestpractices.json file passes every answer check.""" + self.assertEqual(check_answers(load_answers()), []) + + def test_test_criteria_are_met_and_point_at_the_suite(self): + """Every test criterion is Met and cites the tests/ directory.""" + answers = load_answers() + for criterion in TEST_CRITERIA: + with self.subTest(criterion=criterion): + self.assertEqual(answers[criterion + "_status"], "Met") + self.assertIn("tests/", answers[criterion + "_justification"]) + + def test_badge_names_this_project(self): + """README.md contains the OpenSSF badge URL for project 16160.""" + readme = (ROOT / "README.md").read_text(encoding="utf-8") + self.assertIn("https://www.bestpractices.dev/projects/16160/badge", readme) + + def test_test_invocation_matches_makefile(self): + """The test invocation justification contains the exact make test command.""" + makefile = (ROOT / "Makefile").read_text(encoding="utf-8") + recipe = "" + lines = makefile.splitlines() + for idx, line in enumerate(lines): + if line.startswith("test:"): + recipe = lines[idx + 1].strip() + break + prefix = "@$(PYTHON) " + self.assertTrue(recipe.startswith(prefix), recipe) + rest = recipe.removeprefix(prefix) + answers = load_answers() + self.assertIn("python3 " + rest, answers["test_invocation_justification"]) + + def test_ci_and_release_notes_criteria(self): + """Continuous integration and release notes criteria have valid statuses and justifications.""" + answers = load_answers() + self.assertEqual(answers["test_continuous_integration_status"], "Met") + self.assertIn(".github/workflows/test.yml", answers["test_continuous_integration_justification"]) + self.assertIn(answers["release_notes_status"], ("Met", "N/A")) + + def test_required_status_checks_match_workflows(self): + """Every required status check corresponds to a pull request workflow job.""" + ruleset = json.loads((ROOT / ".github/rulesets/main.json").read_text(encoding="utf-8")) + workflows = [] + for path in sorted((ROOT / ".github/workflows").glob("*.yml")): + workflows.append(yaml.safe_load(path.read_text(encoding="utf-8"))) + self.assertEqual(check_required_checks(ruleset, workflows), []) + + def test_readme_declares_test_policy(self): + """README.md states every commitment of the test policy.""" + readme = (ROOT / "README.md").read_text(encoding="utf-8") + self.assertEqual(check_test_policy(readme), []) + + +class AnswerDefectTest(unittest.TestCase): + def assertRefused(self, data, fragment): + """Assert check_answers reports an error containing the expected fragment.""" + errors = check_answers(data) + self.assertTrue(any(fragment in error for error in errors), errors) + + def test_empty_document_is_refused(self): + """An empty mapping or list document is refused.""" + self.assertRefused({}, "non-empty object") + self.assertRefused([], "non-empty object") + + def test_unknown_status_value_is_refused(self): + """A criterion status outside the accepted set is refused.""" + self.assertRefused({"test_status": "Passing", "test_justification": "x"}, "'Passing' is not one of") + + def test_lower_case_status_value_is_refused(self): + """A lower-case status value like 'met' is refused.""" + self.assertRefused({"test_status": "met", "test_justification": "x"}, "is not one of") + + def test_list_status_is_refused(self): + """A list status value is refused as unrecognized without raising.""" + self.assertRefused({"test_status": [], "test_justification": "x"}, "is not one of") + + def test_dict_status_is_refused(self): + """A dictionary status value is refused as unrecognized without raising.""" + self.assertRefused({"test_status": {}, "test_justification": "x"}, "is not one of") + + def test_status_without_justification_is_refused(self): + """A status entry missing a justification key is refused.""" + self.assertRefused({"test_status": "Met"}, "status without a justification") + + def test_blank_justification_is_refused(self): + """A whitespace-only justification string is refused.""" + self.assertRefused({"test_status": "Met", "test_justification": " "}, "status without a justification") + + def test_justification_without_status_is_refused(self): + """A justification entry missing a status key is refused.""" + self.assertRefused({"test_justification": "x"}, "justification without a status") + + def test_stray_key_is_refused(self): + """Keys not matching the status or justification pattern are refused.""" + self.assertRefused({"test_status": "Met", "test_justification": "x", "test_url": "y"}, "test_url") + + def test_missing_required_check_is_refused(self): + """A required status check no workflow job reports is refused.""" + self.assertEqual( + check_required_checks(ruleset("gone"), [workflow(["pull_request"])]), + ["required check 'gone' not found in pull_request workflows"], + ) + + def test_required_check_behind_a_path_filter_is_refused(self): + """A required check from a workflow that path filters can skip is refused.""" + on = {"pull_request": {"paths": ["tests/**"]}} + self.assertNotEqual(check_required_checks(ruleset("make test"), [workflow(on)]), []) + + def test_required_check_from_a_push_only_workflow_is_refused(self): + """A required check from a workflow that never runs on pull requests is refused.""" + self.assertNotEqual(check_required_checks(ruleset("make test"), [workflow("push")]), []) + + def test_malformed_ruleset_and_workflows_do_not_raise(self): + """Rules, parameters and jobs of the wrong type are skipped without an exception.""" + rules = {"rules": [None, {"type": "required_status_checks", "parameters": []}]} + broken = [None, {True: ["pull_request", {"x": 1}], "jobs": []}] + self.assertEqual(check_required_checks(rules, broken), []) + self.assertEqual(check_required_checks({"rules": {}}, broken), []) + + def test_incomplete_test_policy_is_refused(self): + """A README text without the planted-defect commitment is refused.""" + readme = (ROOT / "README.md").read_text(encoding="utf-8") + planted = " ".join(readme.split()).replace("a refused planted defect", "a test") + self.assertEqual(check_test_policy(planted), ["README.md: test policy lacks 'a refused planted defect'"]) + + +class AnswerBoundaryTest(unittest.TestCase): + def test_every_accepted_status_value_passes(self): + """Each accepted status value passes when accompanied by justification.""" + for value in ("Met", "Unmet", "N/A", "?"): + with self.subTest(value=value): + self.assertEqual(check_answers({"test_status": value, "test_justification": "x"}), []) + + def test_required_check_trigger_forms_pass(self): + """A pull_request trigger as a string, a list, a mapping or a branch filter passes.""" + for on in ("pull_request", ["push", "pull_request"], {"pull_request": None}, {"pull_request": {"branches": ["main"]}}): + with self.subTest(on=on): + self.assertEqual(check_required_checks(ruleset("make test"), [workflow(on)]), []) + + def test_job_context_is_its_name_or_else_its_id(self): + """A named job reports only its name; a job without a name reports its id.""" + unnamed = {True: "pull_request", "jobs": {"test": {}}} + self.assertEqual(check_required_checks(ruleset("test"), [unnamed]), []) + self.assertNotEqual(check_required_checks(ruleset("make test"), [unnamed]), []) + self.assertNotEqual(check_required_checks(ruleset("test"), [workflow("pull_request")]), []) + + def test_wrapped_test_policy_passes(self): + """A test policy whose sentences wrap at every space passes.""" + text = "\n".join(fragment.replace(" ", "\n ") for fragment in TEST_POLICY_FRAGMENTS) + self.assertEqual(check_test_policy(text), []) + + def test_answer_keys_count_boundary(self): + """An answers mapping with 2000 keys passes while 2001 keys is refused.""" + answers_2000 = {} + for i in range(MAX_ANSWER_KEYS // 2): + answers_2000[f"c{i}_status"] = "Met" + answers_2000[f"c{i}_justification"] = "x" + self.assertEqual(check_answers(answers_2000), []) + answers_2001 = dict(answers_2000) + answers_2001["c1000_status"] = "Met" + self.assertEqual(check_answers(answers_2001), [f".bestpractices.json: more than {MAX_ANSWER_KEYS} keys"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_labels.py b/tests/test_labels.py new file mode 100644 index 0000000..539cb62 --- /dev/null +++ b/tests/test_labels.py @@ -0,0 +1,130 @@ +"""labels.yml: the label set every golusoris repository syncs.""" + +import unittest + +import yaml + +from .repo_checks import ( + LABEL_DESCRIPTION_MAX, + LABEL_NAME_MAX, + MAX_LABELS, + ROOT, + check_labels, +) + + +def label(name="type:feat", color="0e8a16", **extra): + """Construct a sample label mapping with valid defaults.""" + entry = {"name": name, "color": color, "description": "New user-facing feature"} + entry.update(extra) + return entry + + +class LabelFileTest(unittest.TestCase): + def test_repository_label_set_passes(self): + """The repository labels.yml file passes all validation checks.""" + data = yaml.safe_load((ROOT / "labels.yml").read_text(encoding="utf-8")) + self.assertEqual(check_labels(data), []) + + def test_valid_entry_passes(self): + """Valid label entries with standard colors and aliases produce no errors.""" + self.assertEqual(check_labels([label(), label("type:fix", "#D73A4A", aliases=["bug"])]), []) + + +class LabelDefectTest(unittest.TestCase): + def assertRefused(self, data, fragment): + """Assert check_labels reports an error containing the expected fragment.""" + errors = check_labels(data) + self.assertTrue(any(fragment in error for error in errors), errors) + + def test_empty_document_is_refused(self): + """An empty list or empty YAML document is refused.""" + self.assertRefused([], "non-empty list") + self.assertRefused(None, "non-empty list") + + def test_non_mapping_entry_is_refused(self): + """A list entry that is not a dictionary is refused.""" + self.assertRefused(["type:feat"], "not a mapping") + + def test_misspelt_key_is_refused(self): + """An unrecognized key such as 'colour' is refused.""" + self.assertRefused([label(colour="0e8a16")], "unknown key 'colour'") + + def test_blank_name_is_refused(self): + """A label name consisting only of whitespace is refused.""" + self.assertRefused([label(name=" ")], "name must be") + + def test_missing_color_is_refused(self): + """A label entry missing the required color field is refused.""" + entry = label() + del entry["color"] + self.assertRefused([entry], "color must be") + + def test_non_string_description_is_refused(self): + """A description that is not a string is refused.""" + self.assertRefused([label(description=7)], "description must be") + + def test_string_aliases_are_refused(self): + """An aliases value that is a string instead of a list is refused.""" + self.assertRefused([label(aliases="bug")], "aliases must be") + + def test_blank_alias_is_refused(self): + """A label alias of only spaces is refused.""" + self.assertRefused([label(aliases=[" "])], "aliases must be") + + def test_duplicate_name_ignores_case(self): + """Label names differing only in case are detected as duplicates.""" + self.assertRefused([label("Type:Feat"), label("type:feat")], "repeats entry 0") + + def test_duplicate_name_with_trailing_whitespace_is_refused(self): + """Label names differing only in trailing whitespace are refused.""" + self.assertRefused([label("a "), label("a")], "repeats entry 0") + + def test_alias_colliding_with_a_name_is_refused(self): + """An alias matching an existing label name is refused as a collision.""" + self.assertRefused([label("type:fix"), label("type:bug", aliases=["TYPE:FIX"])], "repeats entry 0") + + +class LabelColorBoundaryTest(unittest.TestCase): + def test_six_digits_with_or_without_hash_pass(self): + """Six-character hexadecimal color strings pass with or without a hash.""" + for color in ("000000", "FFFFFF", "#a1b2c3"): + with self.subTest(color=color): + self.assertEqual(check_labels([label(color=color)]), []) + + def test_wrong_length_or_digit_is_refused(self): + """Bad lengths, a doubled hash, non-hex or non-string colors are refused.""" + for color in ("", "fff", "12345", "1234567", "##123456", "12345g", 123456): + with self.subTest(color=color): + self.assertNotEqual(check_labels([label(color=color)]), []) + + +class LabelBoundaryTest(unittest.TestCase): + def test_name_length_boundary(self): + """A label name of 50 characters passes while 51 characters is refused.""" + self.assertEqual(check_labels([label(name="a" * LABEL_NAME_MAX)]), []) + errors = check_labels([label(name="a" * (LABEL_NAME_MAX + 1))]) + self.assertTrue(any("name exceeds" in err for err in errors), errors) + + def test_description_length_boundary(self): + """A description of 100 characters passes while 101 characters is refused.""" + self.assertEqual(check_labels([label(description="d" * LABEL_DESCRIPTION_MAX)]), []) + errors = check_labels([label(description="d" * (LABEL_DESCRIPTION_MAX + 1))]) + self.assertTrue(any("description exceeds" in err for err in errors), errors) + + def test_alias_length_boundary(self): + """A label alias of 50 characters passes while 51 characters is refused.""" + self.assertEqual(check_labels([label(aliases=["a" * LABEL_NAME_MAX])]), []) + errors = check_labels([label(aliases=["a" * (LABEL_NAME_MAX + 1)])]) + self.assertTrue(any("alias" in err and "exceeds" in err for err in errors), errors) + + def test_label_count_boundary(self): + """A document with 1000 labels passes while 1001 entries is refused.""" + labels_1000 = [label(name=f"l{i}") for i in range(MAX_LABELS)] + self.assertEqual(check_labels(labels_1000), []) + labels_1001 = [label(name=f"l{i}") for i in range(MAX_LABELS + 1)] + self.assertEqual(check_labels(labels_1001), [f"labels.yml: more than {MAX_LABELS} entries"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_markdown_links.py b/tests/test_markdown_links.py new file mode 100644 index 0000000..e944b21 --- /dev/null +++ b/tests/test_markdown_links.py @@ -0,0 +1,245 @@ +"""Relative links in the rendered Markdown files name files that exist.""" + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from . import repo_checks +from .repo_checks import ( + MAX_MARKDOWN_BYTES, + ROOT, + check_links, + link_targets, + markdown_files, + relative_targets, +) + + +class RepositoryLinkTest(unittest.TestCase): + def test_profile_and_health_files_are_checked(self): + """Discovered Markdown files include repository profile and health docs.""" + names = {path.relative_to(ROOT).as_posix() for path in markdown_files()} + self.assertTrue({"README.md", "CONTRIBUTING.md", "SECURITY.md", "profile/README.md"} <= names, names) + + def test_every_relative_link_resolves(self): + """Every relative Markdown link across the repository resolves to a file.""" + files = markdown_files() + self.assertTrue(files) + total_targets = 0 + for path in files: + total_targets += len(relative_targets(path)) + with self.subTest(path=path.relative_to(ROOT).as_posix()): + self.assertEqual(check_links(path), []) + self.assertGreater(total_targets, 0) + + +class LinkDefectTest(unittest.TestCase): + def setUp(self): + """Create a temporary repository layout with fixture files.""" + workdir = tempfile.TemporaryDirectory() + self.addCleanup(workdir.cleanup) + self.root = Path(workdir.name) / "repo" + self.root.mkdir() + (self.root / "profile").mkdir() + (self.root / "profile" / "banner.svg").write_text("", encoding="utf-8") + (self.root / "SECURITY.md").write_text("# Security\n", encoding="utf-8") + + def check(self, text, name="profile/README.md"): + """Write text to a file under the temporary root and check its links.""" + path = self.root / name + path.write_text(text, encoding="utf-8") + return check_links(path, self.root) + + def test_existing_targets_pass(self): + """Links pointing to existing files, anchors, and reference targets pass.""" + text = "![b](banner.svg) [s](../SECURITY.md#report) [r](/SECURITY.md) [a](#top)\n[ref]: ../SECURITY.md\n" + self.assertEqual(self.check(text), []) + + def test_missing_inline_target_is_refused(self): + """An inline link pointing to a nonexistent relative file is flagged.""" + self.assertEqual(self.check("[gone](../CODE_OF_CONDUCT.md)\n"), ["profile/README.md: ../CODE_OF_CONDUCT.md names no file"]) + + def test_missing_image_is_refused(self): + """An image link referencing a missing file produces an error.""" + self.assertEqual(self.check("![logo](logo.png)\n"), ["profile/README.md: logo.png names no file"]) + + def test_missing_reference_target_is_refused_once(self): + """A missing target in a reference link produces exactly one error.""" + self.assertEqual(self.check("[x][ref]\n\n[ref]: missing.md\n"), ["profile/README.md: missing.md names no file"]) + + def test_unused_reference_definition_is_checked(self): + """An unused reference definition pointing to a missing file is flagged.""" + self.assertEqual(self.check("[ref]: missing.md\n"), ["profile/README.md: missing.md names no file"]) + + def test_link_in_image_alt_text_is_not_a_target(self): + """Link syntax inside image alt text is ignored during target extraction.""" + self.assertEqual(link_targets("![a [b](missing.md)](banner.svg)\n"), ["banner.svg"]) + + def test_root_relative_target_resolves_from_the_root(self): + """A leading-slash path resolves relative to the repository root.""" + self.assertEqual(self.check("[s](/profile/SECURITY.md)\n"), ["profile/README.md: /profile/SECURITY.md names no file"]) + + def test_nested_image_link_checks_both_targets(self): + """The link wrapping an image has its destination checked.""" + self.assertEqual(self.check("[![b](banner.svg)](missing.md)\n"), ["profile/README.md: missing.md names no file"]) + + def test_angle_bracket_target_with_space(self): + """Angle-bracketed paths containing spaces resolve to files on disk.""" + self.assertEqual(self.check("[x]()\n"), ["profile/README.md: a%20b.md names no file"]) + (self.root / "profile" / "a b.md").write_text("# A B\n", encoding="utf-8") + self.assertEqual(self.check("[x]()\n"), []) + + def test_link_titles_in_quotes_or_parentheses_are_refused_when_missing(self): + """Missing link targets with quoted or parenthesized titles are flagged.""" + self.assertEqual(self.check("[x](missing.md 'title')\n"), ["profile/README.md: missing.md names no file"]) + self.assertEqual(self.check("[x](missing.md (title))\n"), ["profile/README.md: missing.md names no file"]) + + def test_reference_definition_with_angle_bracket_space(self): + """Reference definitions using angle brackets with spaces resolve to disk.""" + (self.root / "profile" / "a b.md").write_text("# A B\n", encoding="utf-8") + self.assertEqual(self.check("[ref]: \n"), []) + + def test_inline_code_followed_by_real_fence(self): + """Links outside inline code spans are validated even when fences follow.""" + text = "```code``` and [x](missing.md)\n\n```\nfence\n```\n" + self.assertEqual(self.check(text), ["profile/README.md: missing.md names no file"]) + + def test_target_resolution_and_escape(self): + """Query, root-anchor and // targets pass; a path out of the repo is refused.""" + (self.root.parent / "outside.md").write_text("# Outside\n", encoding="utf-8") + self.assertEqual(self.check("[x](?query) [y](/#anchor)\n"), []) + self.assertEqual(self.check("[p](//example.org/x)\n"), []) + self.assertEqual(self.check("[u](../../outside.md)\n"), ["profile/README.md: ../../outside.md leaves the repository"]) + + def test_url_encoded_target_passes_when_file_exists(self): + """A URL-encoded path like '%20' resolves to the matching file on disk.""" + (self.root / "profile" / "a b.md").write_text("# A B\n", encoding="utf-8") + self.assertEqual(self.check("[s](a%20b.md)\n"), []) + + def test_nested_profile_markdown_files_are_checked(self): + """Nested profile Markdown files are discovered and checked for missing targets.""" + sub_dir = self.root / "profile" / "sub" + sub_dir.mkdir(parents=True, exist_ok=True) + doc = sub_dir / "x.md" + doc.write_text("[x](missing.md)\n", encoding="utf-8") + discovered = [p.relative_to(self.root).as_posix() for p in markdown_files(self.root)] + self.assertIn("profile/sub/x.md", discovered) + self.assertEqual(check_links(doc, self.root), ["profile/sub/x.md: missing.md names no file"]) + + def test_image_naming_a_directory_is_refused(self): + """An image referencing an existing directory is refused as not an image file.""" + self.assertEqual( + self.check("![b](profile)\n", name="README.md"), + ["README.md: profile names a directory, not an image file"], + ) + + def test_link_to_directory_passes(self): + """A plain link pointing to an existing directory produces no error.""" + (self.root / "profile" / "sub").mkdir(exist_ok=True) + self.assertEqual(self.check("[d](sub/)\n"), []) + + def test_target_used_as_link_and_image_is_treated_as_image(self): + """A target used as both a link and an image is validated with image constraints.""" + text = "[d](profile) and ![d](profile)\n" + self.assertEqual( + self.check(text, name="README.md"), + ["README.md: profile names a directory, not an image file"], + ) + + def test_html_image_block_missing_target_is_refused(self): + """An HTML image block pointing to a nonexistent file is refused.""" + self.assertEqual( + self.check('\n'), + ["profile/README.md: gone.svg names no file"], + ) + + def test_inline_html_image_missing_target_is_refused(self): + """An inline HTML image inside a paragraph pointing to a nonexistent file is refused.""" + self.assertEqual( + self.check('text text\n'), + ["profile/README.md: gone.svg names no file"], + ) + + def test_html_link_and_inline_image_pass(self): + """HTML paragraph links and inline images pointing to existing targets pass.""" + text = '

s

\ntext text\n' + self.assertEqual(self.check(text), []) + + def test_html_comments_yield_no_targets(self): + """Markdown links and HTML tags inside HTML comments are ignored.""" + text = '\n' + self.assertEqual(link_targets(text), []) + self.assertEqual(self.check(text), []) + + def test_external_html_image_is_skipped(self): + """External HTML images with scheme prefixes produce no relative target errors.""" + self.assertEqual(self.check('\n'), []) + + +class LinkBoundaryTest(unittest.TestCase): + def test_external_and_anchor_targets_are_skipped(self): + """External URI schemes and local fragment anchors are extracted as targets.""" + text = "[w](https://example.org/x) [m](mailto:a@example.org) [a](#x)\n" + self.assertEqual(link_targets(text), ["https://example.org/x", "mailto:a@example.org", "#x"]) + + def test_links_inside_code_are_ignored(self): + """Link syntax inside inline code spans or fenced blocks is ignored.""" + text = "`[x](missing.md)`\n\n```md\n[y](missing.md)\n```\n" + self.assertEqual(link_targets(text), []) + + def test_link_title_and_angle_brackets_are_stripped(self): + """Angle brackets and trailing titles are stripped from extracted targets.""" + self.assertEqual(link_targets('[t]() [u](d.md "title")\n'), ["c.md", "d.md"]) + + def test_not_a_reference_definition(self): + """Bracketed keys lacking a destination URI are not treated as definitions.""" + self.assertEqual(link_targets("[note]:\nNext paragraph.\n"), []) + + def test_code_fences_and_spans_contain_no_targets(self): + """Indented and nested fences, double and multi-line spans yield no targets.""" + text = ( + " ```\n [x](missing.md)\n ```\n\n" + "````\n```\n[y](missing.md)\n```\n````\n\n" + "`` `[z](missing.md)` ``\n\n" + "`multi\n[w](missing.md)\nline`\n" + ) + self.assertEqual(link_targets(text), []) + + def test_markdown_byte_cap_boundary(self): + """A Markdown file of 1000000 bytes passes while 1000001 bytes is refused.""" + workdir = tempfile.TemporaryDirectory() + self.addCleanup(workdir.cleanup) + root = Path(workdir.name) / "repo" + root.mkdir() + file_exact = root / "exact.md" + file_exact.write_text("a" * MAX_MARKDOWN_BYTES, encoding="utf-8") + self.assertEqual(check_links(file_exact, root), []) + self.assertEqual(relative_targets(file_exact), []) + + file_over = root / "over.md" + file_over.write_text("a" * (MAX_MARKDOWN_BYTES + 1), encoding="utf-8") + with self.assertRaises(ValueError): + relative_targets(file_over) + self.assertEqual( + check_links(file_over, root), + [f"over.md: more than {MAX_MARKDOWN_BYTES} bytes"], + ) + + def test_markdown_file_cap_boundary(self): + """Collecting Markdown files stops and raises when the file count exceeds the cap.""" + workdir = tempfile.TemporaryDirectory() + self.addCleanup(workdir.cleanup) + root = Path(workdir.name) / "repo" + root.mkdir() + (root / "a.md").write_text("# A\n", encoding="utf-8") + (root / "b.md").write_text("# B\n", encoding="utf-8") + with patch.object(repo_checks, "MAX_MARKDOWN_FILES", 2): + self.assertEqual(len(markdown_files(root)), 2) + (root / "c.md").write_text("# C\n", encoding="utf-8") + with self.assertRaises(ValueError): + markdown_files(root) + + +if __name__ == "__main__": + unittest.main()