From 2e25e995cbcbbaffa44530f79d07c1800ad8fabf Mon Sep 17 00:00:00 2001 From: lusoris Date: Sun, 8 Nov 2026 14:02:29 +0100 Subject: [PATCH 1/6] fix: add a test suite so the Best Practices badge can pass OpenSSF Scorecard reports CII-Best-Practices at in_progress (code scanning alert 9). The badge cannot reach passing while four MUST criteria are Unmet: test, test_invocation, test_policy and tests_are_added. test_continuous_integration (SHOULD) was Unmet too. - tests/: a Python unittest suite (34 tests) for what the repository publishes: labels.yml in the shape EndBug/label-sync reads, the .bestpractices.json answers, and the relative links of the rendered Markdown files. Each check has a passing case, a planted defect it refuses and the boundary between them. - Makefile: make test runs the suite (PYTHON overrides the interpreter); make verify-all runs it with the other checks. - .github/workflows/test.yml: runs make test on pull requests, on main and in the merge queue, with PyYAML pinned by hash in .config/tests/requirements.txt. - README.md: a Tests section with the test policy. - .bestpractices.json: test, test_invocation, test_policy, tests_are_added and test_continuous_integration become Met; release_notes becomes N/A (nothing consumes a release; GitHub serves the files from main). - The engine renders the new "make test" job as a required status check in .github/rulesets/main.json and refreshed the Paperclip harness (.paperclip/, register hash in .standards.yaml). Refs: https://github.com/golusoris/.github/security/code-scanning/9 Signed-off-by: lusoris --- .bestpractices.json | 28 ++++---- .config/tests/requirements.in | 6 ++ .config/tests/requirements.txt | 77 +++++++++++++++++++++ .github/rulesets/main.json | 3 + .github/workflows/test.yml | 50 ++++++++++++++ .paperclip/harness.json | 6 +- .paperclip/rules.md | 8 +-- .standards.yaml | 2 +- Makefile | 7 +- README.md | 25 +++++++ tests/repo_checks.py | 120 +++++++++++++++++++++++++++++++++ tests/test_bestpractices.py | 69 +++++++++++++++++++ tests/test_labels.py | 74 ++++++++++++++++++++ tests/test_markdown_links.py | 66 ++++++++++++++++++ 14 files changed, 516 insertions(+), 25 deletions(-) create mode 100644 .config/tests/requirements.in create mode 100644 .config/tests/requirements.txt create mode 100644 .github/workflows/test.yml create mode 100644 tests/repo_checks.py create mode 100644 tests/test_bestpractices.py create mode 100644 tests/test_labels.py create mode 100644 tests/test_markdown_links.py diff --git a/.bestpractices.json b/.bestpractices.json index e4b4182..1abbdc0 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 -m unittest discover -s tests, 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": "The most recent change to the published files added the suite in tests/ together with a passing, a planted-defect and a boundary test for every check it introduced. Evidence: https://github.com/golusoris/.github/commits/main/tests", "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..fde9f8c --- /dev/null +++ b/.config/tests/requirements.in @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2026 lusoris +# +# SPDX-License-Identifier: EUPL-1.2 + +# Test-suite dependency: tests/ parse labels.yml with PyYAML. +PyYAML diff --git a/.config/tests/requirements.txt b/.config/tests/requirements.txt new file mode 100644 index 0000000..ef1d3c8 --- /dev/null +++ b/.config/tests/requirements.txt @@ -0,0 +1,77 @@ +# 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 +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..b5fde8a --- /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: Tests not run on a draft + MESSAGE: >- + The tests did not run because the pull request is a draft. + They run 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..6311689 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,10 @@ -# 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 (.config/tests/requirements.txt). +PYTHON ?= python3 .PHONY: all verify-all compile-context compile-context-verify caveman-sources audit build test all: build @@ -21,7 +24,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 -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..173507b 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 and PyYAML, 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/repo_checks.py b/tests/repo_checks.py new file mode 100644 index 0000000..8f39c22 --- /dev/null +++ b/tests/repo_checks.py @@ -0,0 +1,120 @@ +"""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 pathlib import Path + +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}") + +# 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") +INLINE_LINK = re.compile(r"!?\[[^\]]*\]\(\s*]+)>?(?:\s+\"[^\"]*\")?\s*\)") +REFERENCE_LINK = re.compile(r"^\s{0,3}\[[^\]]+\]:\s*?(?:\s+.*)?$", re.MULTILINE) +FENCE = re.compile(r"^(```|~~~).*?^\1", re.MULTILINE | re.DOTALL) +CODE_SPAN = re.compile(r"`[^`\n]*`") +EXTERNAL = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*:") + + +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") + 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 and not isinstance(entry["description"], str): + errors.append(f"entry {index}: description must be a string") + aliases = entry.get("aliases", []) + if not isinstance(aliases, list) or not all(isinstance(a, str) and a for a in aliases): + errors.append(f"entry {index}: aliases must be a list of non-empty strings") + 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"] + 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.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"] + 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 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 link_targets(text): + """Return the link and image targets of a Markdown text, code excluded.""" + prose = CODE_SPAN.sub("", FENCE.sub("", text)) + return INLINE_LINK.findall(prose) + REFERENCE_LINK.findall(prose) + + +def check_links(path, root=ROOT): + """Return the relative link targets in path that name no file in root.""" + errors = [] + for target in link_targets(path.read_text(encoding="utf-8")): + if target.startswith("#") or EXTERNAL.match(target): + continue + local = target.split("#", 1)[0].split("?", 1)[0] + base = root if local.startswith("/") else path.parent + if not (base / local.lstrip("/")).exists(): + errors.append(f"{path.relative_to(root)}: {target} names no file") + return errors + + +def markdown_files(root=ROOT): + """Return the rendered Markdown files under root, sorted.""" + return sorted({path for pattern in MARKDOWN_GLOBS for path in root.glob(pattern) if path.is_file()}) diff --git a/tests/test_bestpractices.py b/tests/test_bestpractices.py new file mode 100644 index 0000000..058fb1e --- /dev/null +++ b/tests/test_bestpractices.py @@ -0,0 +1,69 @@ +""".bestpractices.json: the OpenSSF Best Practices answers for project 16160.""" + +import json +import unittest + +from repo_checks import ROOT, check_answers + +# 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 json.loads((ROOT / ".bestpractices.json").read_text(encoding="utf-8")) + + +class AnswerFileTest(unittest.TestCase): + def test_repository_answers_pass(self): + self.assertEqual(check_answers(load_answers()), []) + + def test_test_criteria_are_met_and_point_at_the_suite(self): + 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 = (ROOT / "README.md").read_text(encoding="utf-8") + self.assertIn("https://www.bestpractices.dev/projects/16160/badge", readme) + + +class AnswerDefectTest(unittest.TestCase): + def assertRefused(self, data, fragment): + errors = check_answers(data) + self.assertTrue(any(fragment in error for error in errors), errors) + + def test_empty_document_is_refused(self): + self.assertRefused({}, "non-empty object") + self.assertRefused([], "non-empty object") + + def test_unknown_status_value_is_refused(self): + self.assertRefused({"test_status": "Passing", "test_justification": "x"}, "'Passing' is not one of") + + def test_lower_case_status_value_is_refused(self): + self.assertRefused({"test_status": "met", "test_justification": "x"}, "is not one of") + + def test_status_without_justification_is_refused(self): + self.assertRefused({"test_status": "Met"}, "status without a justification") + + def test_blank_justification_is_refused(self): + self.assertRefused({"test_status": "Met", "test_justification": " "}, "status without a justification") + + def test_justification_without_status_is_refused(self): + self.assertRefused({"test_justification": "x"}, "justification without a status") + + def test_stray_key_is_refused(self): + self.assertRefused({"test_status": "Met", "test_justification": "x", "test_url": "y"}, "test_url") + + +class AnswerBoundaryTest(unittest.TestCase): + def test_every_accepted_status_value_passes(self): + for value in ("Met", "Unmet", "N/A", "?"): + with self.subTest(value=value): + self.assertEqual(check_answers({"test_status": value, "test_justification": "x"}), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_labels.py b/tests/test_labels.py new file mode 100644 index 0000000..4f83907 --- /dev/null +++ b/tests/test_labels.py @@ -0,0 +1,74 @@ +"""labels.yml: the label set every golusoris repository syncs.""" + +import unittest + +import yaml + +from repo_checks import ROOT, check_labels + + +def label(name="type:feat", color="0e8a16", **extra): + 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): + data = yaml.safe_load((ROOT / "labels.yml").read_text(encoding="utf-8")) + self.assertEqual(check_labels(data), []) + + def test_valid_entry_passes(self): + self.assertEqual(check_labels([label(), label("type:fix", "#D73A4A", aliases=["bug"])]), []) + + +class LabelDefectTest(unittest.TestCase): + def assertRefused(self, data, fragment): + errors = check_labels(data) + self.assertTrue(any(fragment in error for error in errors), errors) + + def test_empty_document_is_refused(self): + self.assertRefused([], "non-empty list") + self.assertRefused(None, "non-empty list") + + def test_non_mapping_entry_is_refused(self): + self.assertRefused(["type:feat"], "not a mapping") + + def test_misspelt_key_is_refused(self): + self.assertRefused([label(colour="0e8a16")], "unknown key 'colour'") + + def test_blank_name_is_refused(self): + self.assertRefused([label(name=" ")], "name must be") + + def test_missing_color_is_refused(self): + entry = label() + del entry["color"] + self.assertRefused([entry], "color must be") + + def test_non_string_description_is_refused(self): + self.assertRefused([label(description=7)], "description must be") + + def test_string_aliases_are_refused(self): + self.assertRefused([label(aliases="bug")], "aliases must be") + + def test_duplicate_name_ignores_case(self): + self.assertRefused([label("Type:Feat"), label("type:feat")], "repeats entry 0") + + def test_alias_colliding_with_a_name_is_refused(self): + 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): + 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): + for color in ("", "fff", "12345", "1234567", "##123456", "12345g", 123456): + with self.subTest(color=color): + self.assertNotEqual(check_labels([label(color=color)]), []) + + +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..b224eb0 --- /dev/null +++ b/tests/test_markdown_links.py @@ -0,0 +1,66 @@ +"""Relative links in the rendered Markdown files name files that exist.""" + +import tempfile +import unittest +from pathlib import Path + +from repo_checks import ROOT, check_links, link_targets, markdown_files + + +class RepositoryLinkTest(unittest.TestCase): + def test_profile_and_health_files_are_checked(self): + 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): + for path in markdown_files(): + with self.subTest(path=path.relative_to(ROOT).as_posix()): + self.assertEqual(check_links(path), []) + + +class LinkDefectTest(unittest.TestCase): + def setUp(self): + workdir = tempfile.TemporaryDirectory() + self.addCleanup(workdir.cleanup) + self.root = Path(workdir.name) + (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"): + path = self.root / name + path.write_text(text, encoding="utf-8") + return check_links(path, self.root) + + def test_existing_targets_pass(self): + 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): + 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): + self.assertEqual(self.check("![logo](logo.png)\n"), ["profile/README.md: logo.png names no file"]) + + def test_missing_reference_target_is_refused(self): + self.assertEqual(self.check("[x][ref]\n\n[ref]: missing.md\n"), ["profile/README.md: missing.md names no file"]) + + def test_root_relative_target_resolves_from_the_root(self): + self.assertEqual(self.check("[s](/profile/SECURITY.md)\n"), ["profile/README.md: /profile/SECURITY.md names no file"]) + + +class LinkBoundaryTest(unittest.TestCase): + def test_external_and_anchor_targets_are_skipped(self): + 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): + 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): + self.assertEqual(link_targets('[t]() [u](d.md "title")\n'), ["c.md", "d.md"]) + + +if __name__ == "__main__": + unittest.main() From 0f64c704660e14d4313d99400b0aa4a0aa7e472b Mon Sep 17 00:00:00 2001 From: lusoris Date: Sun, 8 Nov 2026 14:45:28 +0100 Subject: [PATCH 2/6] test: parse Markdown links with markdown-it-py The link check matched links with regular expressions, which missed angle-bracket targets with spaces, titles in parentheses, nested image links and some code spans and fences. It now parses each file with markdown-it-py (CommonMark plus tables) and reads the targets from the link, image and reference-definition tokens, so it sees the links the renderer sees. Each target is checked once, URL-decoded, and refused when it resolves outside the repository. The label check now strips whitespace before it compares names and refuses blank aliases. The tests run as a package (tests/__init__.py, discover -t .), and markdown-it-py is pinned with hashes in .config/tests/requirements.txt. Signed-off-by: lusoris --- .config/tests/requirements.in | 3 ++ .config/tests/requirements.txt | 8 +++++ Makefile | 2 +- README.md | 2 +- tests/__init__.py | 1 + tests/repo_checks.py | 58 ++++++++++++++++++++++-------- tests/test_bestpractices.py | 2 +- tests/test_labels.py | 8 ++++- tests/test_markdown_links.py | 65 ++++++++++++++++++++++++++++++++-- 9 files changed, 128 insertions(+), 21 deletions(-) create mode 100644 tests/__init__.py diff --git a/.config/tests/requirements.in b/.config/tests/requirements.in index fde9f8c..e8bc38c 100644 --- a/.config/tests/requirements.in +++ b/.config/tests/requirements.in @@ -4,3 +4,6 @@ # 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 index ef1d3c8..a2e106c 100644 --- a/.config/tests/requirements.txt +++ b/.config/tests/requirements.txt @@ -1,5 +1,13 @@ # 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 \ diff --git a/Makefile b/Makefile index 6311689..ec0d5f2 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ audit: @$(PRAETORCTL) audit test: build - @$(PYTHON) -B -m unittest discover -s tests -v + @$(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 173507b..42a7e03 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Local checks: `make verify-all` runs the full gate; `make audit` and ## Tests `make test` runs the suite under [`tests/`](tests/) with Python's `unittest`. -It needs Python 3.10 or later and PyYAML, pinned with hashes in +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. 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 index 8f39c22..7de3cfc 100644 --- a/tests/repo_checks.py +++ b/tests/repo_checks.py @@ -7,6 +7,9 @@ import re from pathlib import Path +from urllib.parse import unquote + +from markdown_it import MarkdownIt ROOT = Path(__file__).resolve().parent.parent @@ -26,10 +29,6 @@ # Markdown files the organisation renders: the profile, the community health # files and the decision records. MARKDOWN_GLOBS = ("*.md", "profile/*.md", "docs/**/*.md") -INLINE_LINK = re.compile(r"!?\[[^\]]*\]\(\s*]+)>?(?:\s+\"[^\"]*\")?\s*\)") -REFERENCE_LINK = re.compile(r"^\s{0,3}\[[^\]]+\]:\s*?(?:\s+.*)?$", re.MULTILINE) -FENCE = re.compile(r"^(```|~~~).*?^\1", re.MULTILINE | re.DOTALL) -CODE_SPAN = re.compile(r"`[^`\n]*`") EXTERNAL = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*:") @@ -47,7 +46,7 @@ def check_label(entry, index): if "description" in entry and not isinstance(entry["description"], str): errors.append(f"entry {index}: description must be a string") aliases = entry.get("aliases", []) - if not isinstance(aliases, list) or not all(isinstance(a, str) and a for a in 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") return errors @@ -71,7 +70,7 @@ def check_labels(data): for name in label_names(entry): if not isinstance(name, str): continue - key = name.casefold() + key = name.strip().casefold() if key in seen: errors.append(f"entry {index}: {name!r} repeats entry {seen[key]}") seen.setdefault(key, index) @@ -97,20 +96,51 @@ def check_answers(data): def link_targets(text): - """Return the link and image targets of a Markdown text, code excluded.""" - prose = CODE_SPAN.sub("", FENCE.sub("", text)) - return INLINE_LINK.findall(prose) + REFERENCE_LINK.findall(prose) + """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 is left unchecked. + """ + env = {} + tokens = MarkdownIt("commonmark").enable("table").parse(text, env) + targets = [] + # Links and images sit only in the children of inline tokens; an image's + # own children are its alt text, which renders as plain text. + for block in tokens: + for token in block.children or []: + if token.type == "link_open": + targets.append(token.attrGet("href")) + elif token.type == "image": + targets.append(token.attrGet("src")) + references = env.get("references", {}) + targets.extend(references[label].get("href") for label in sorted(references)) + return [target for target in dict.fromkeys(targets) if target is not None] + + +def relative_targets(path): + """Return the relative link targets in the Markdown file at path.""" + targets = [] + for target in link_targets(path.read_text(encoding="utf-8")): + if target.startswith("//") or EXTERNAL.match(target): + continue + local = target.split("#", 1)[0].split("?", 1)[0] + if not local: + continue + targets.append(target) + return targets def check_links(path, root=ROOT): """Return the relative link targets in path that name no file in root.""" errors = [] - for target in link_targets(path.read_text(encoding="utf-8")): - if target.startswith("#") or EXTERNAL.match(target): - continue + for target in relative_targets(path): local = target.split("#", 1)[0].split("?", 1)[0] - base = root if local.startswith("/") else path.parent - if not (base / local.lstrip("/")).exists(): + 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"{path.relative_to(root)}: {target} leaves the repository") + elif not target_path.exists(): errors.append(f"{path.relative_to(root)}: {target} names no file") return errors diff --git a/tests/test_bestpractices.py b/tests/test_bestpractices.py index 058fb1e..be8f285 100644 --- a/tests/test_bestpractices.py +++ b/tests/test_bestpractices.py @@ -3,7 +3,7 @@ import json import unittest -from repo_checks import ROOT, check_answers +from .repo_checks import ROOT, check_answers # 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. diff --git a/tests/test_labels.py b/tests/test_labels.py index 4f83907..c30eba1 100644 --- a/tests/test_labels.py +++ b/tests/test_labels.py @@ -4,7 +4,7 @@ import yaml -from repo_checks import ROOT, check_labels +from .repo_checks import ROOT, check_labels def label(name="type:feat", color="0e8a16", **extra): @@ -51,9 +51,15 @@ def test_non_string_description_is_refused(self): def test_string_aliases_are_refused(self): self.assertRefused([label(aliases="bug")], "aliases must be") + def test_blank_alias_is_refused(self): + self.assertRefused([label(aliases=[" "])], "aliases must be") + def test_duplicate_name_ignores_case(self): self.assertRefused([label("Type:Feat"), label("type:feat")], "repeats entry 0") + def test_duplicate_name_with_trailing_whitespace_is_refused(self): + self.assertRefused([label("a "), label("a")], "repeats entry 0") + def test_alias_colliding_with_a_name_is_refused(self): self.assertRefused([label("type:fix"), label("type:bug", aliases=["TYPE:FIX"])], "repeats entry 0") diff --git a/tests/test_markdown_links.py b/tests/test_markdown_links.py index b224eb0..5b49380 100644 --- a/tests/test_markdown_links.py +++ b/tests/test_markdown_links.py @@ -4,7 +4,13 @@ import unittest from pathlib import Path -from repo_checks import ROOT, check_links, link_targets, markdown_files +from .repo_checks import ( + ROOT, + check_links, + link_targets, + markdown_files, + relative_targets, +) class RepositoryLinkTest(unittest.TestCase): @@ -13,9 +19,14 @@ def test_profile_and_health_files_are_checked(self): self.assertTrue({"README.md", "CONTRIBUTING.md", "SECURITY.md", "profile/README.md"} <= names, names) def test_every_relative_link_resolves(self): - for path in markdown_files(): + 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): @@ -42,12 +53,48 @@ def test_missing_inline_target_is_refused(self): def test_missing_image_is_refused(self): self.assertEqual(self.check("![logo](logo.png)\n"), ["profile/README.md: logo.png names no file"]) - def test_missing_reference_target_is_refused(self): + def test_missing_reference_target_is_refused_once(self): 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): + 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): + self.assertEqual(link_targets("![a [b](missing.md)](banner.svg)\n"), ["banner.svg"]) + def test_root_relative_target_resolves_from_the_root(self): 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): + 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): + 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): + 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): + (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): + 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): + (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): + (self.root / "profile" / "a b.md").write_text("# A B\n", encoding="utf-8") + self.assertEqual(self.check("[s](a%20b.md)\n"), []) + class LinkBoundaryTest(unittest.TestCase): def test_external_and_anchor_targets_are_skipped(self): @@ -61,6 +108,18 @@ def test_links_inside_code_are_ignored(self): def test_link_title_and_angle_brackets_are_stripped(self): self.assertEqual(link_targets('[t]() [u](d.md "title")\n'), ["c.md", "d.md"]) + def test_not_a_reference_definition(self): + self.assertEqual(link_targets("[note]:\nNext paragraph.\n"), []) + + def test_code_fences_and_spans_contain_no_targets(self): + 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), []) + if __name__ == "__main__": unittest.main() From 5f51f1b81d8214d6fbc35f3a05c9b92bc5b65d61 Mon Sep 17 00:00:00 2001 From: lusoris Date: Sun, 8 Nov 2026 14:46:37 +0100 Subject: [PATCH 3/6] ci(tests): use the canonical draft step text The engine's workflow trigger check (HISS-18) accepts a draft step only with the hosted gate's exact title and message. The Tests workflow used its own wording, so the check reported that the job runs on a draft. It now carries the same step as the REUSE workflow. Signed-off-by: lusoris --- .github/workflows/test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b5fde8a..f4a90cd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,10 +27,10 @@ jobs: if: github.event.pull_request.draft == true shell: bash env: - TITLE: Tests not run on a draft + TITLE: Gate not run on a draft MESSAGE: >- - The tests did not run because the pull request is a draft. - They run when the pull request is marked ready for review. + 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" From 20005604bee89eeed09058ce57ad8a7b163f3c72 Mon Sep 17 00:00:00 2001 From: lusoris Date: Sun, 8 Nov 2026 15:10:07 +0100 Subject: [PATCH 4/6] build: name both test dependencies in the Makefile comment The comment above PYTHON named PyYAML alone. The suite also parses Markdown links with markdown-it-py since the link checks moved to it, so the comment now names both, as README.md already does. Signed-off-by: lusoris --- Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ec0d5f2..223dea9 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,8 @@ -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 (.config/tests/requirements.txt). +# 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 From 4705c216f364a73927b05d17ee6437ff5497b234 Mon Sep 17 00:00:00 2001 From: lusoris Date: Sun, 8 Nov 2026 15:27:40 +0100 Subject: [PATCH 5/6] test: document what each test proves The pre-push docstrings gate needs 80 % coverage (.coderabbit.yaml); the suite had 8 of 61. Each test and helper now names the case it covers: the input shape and the outcome it expects. Coverage is 61 of 61 and no test body changed. Signed-off-by: lusoris --- tests/test_bestpractices.py | 13 +++++++++++++ tests/test_labels.py | 17 +++++++++++++++++ tests/test_markdown_links.py | 23 +++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/tests/test_bestpractices.py b/tests/test_bestpractices.py index be8f285..efafffc 100644 --- a/tests/test_bestpractices.py +++ b/tests/test_bestpractices.py @@ -11,14 +11,17 @@ 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): @@ -26,40 +29,50 @@ def test_test_criteria_are_met_and_point_at_the_suite(self): 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) 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_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") 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"}), []) diff --git a/tests/test_labels.py b/tests/test_labels.py index c30eba1..228aaaf 100644 --- a/tests/test_labels.py +++ b/tests/test_labels.py @@ -8,6 +8,7 @@ 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 @@ -15,62 +16,78 @@ def label(name="type:feat", color="0e8a16", **extra): 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)]), []) diff --git a/tests/test_markdown_links.py b/tests/test_markdown_links.py index 5b49380..0587951 100644 --- a/tests/test_markdown_links.py +++ b/tests/test_markdown_links.py @@ -15,10 +15,12 @@ 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 @@ -31,6 +33,7 @@ def test_every_relative_link_resolves(self): 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) @@ -39,79 +42,99 @@ def setUp(self): (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"), []) 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" From 7e4ed3905163380d6f263c0dd1f3172b7a06b7ed Mon Sep 17 00:00:00 2001 From: lusoris Date: Sun, 8 Nov 2026 15:56:05 +0100 Subject: [PATCH 6/6] fix(tests): close the gaps the review of the suite found The review of pull request 12 found checks that crashed, missed cases or claimed more than they tested. Each fix comes with a test that fails without it (26 planted defects, all refused). - A status given as a list or mapping is refused, not a TypeError. - Label names, aliases and descriptions are held to the GitHub limits of 50 and 100 characters. - Every collection the checks walk has a scalar cap (HISS-02). - Nested profile Markdown files are found, an image that names a directory is refused, and raw HTML a, img and source tags are read. - A required status check in .github/rulesets/main.json must name a job of a workflow that runs on every pull request, with no path filter that could skip it. - The README test policy and the make test command are checked against the Best Practices answers that cite them. - The link tests keep their temporary files inside their own directory. - Two answer texts now quote the real test command and name the pull request that added the suite. Signed-off-by: lusoris --- .bestpractices.json | 4 +- tests/repo_checks.py | 250 +++++++++++++++++++++++++++++++---- tests/test_bestpractices.py | 123 ++++++++++++++++- tests/test_labels.py | 35 ++++- tests/test_markdown_links.py | 99 +++++++++++++- 5 files changed, 483 insertions(+), 28 deletions(-) diff --git a/.bestpractices.json b/.bestpractices.json index 1abbdc0..0e31100 100644 --- a/.bestpractices.json +++ b/.bestpractices.json @@ -68,7 +68,7 @@ "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 -m unittest discover -s tests, and make verify-all runs it with the other checks. Evidence: https://github.com/golusoris/.github/blob/main/Makefile", + "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": "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", @@ -76,7 +76,7 @@ "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": "The most recent change to the published files added the suite in tests/ together with a passing, a planted-defect and a boundary test for every check it introduced. Evidence: https://github.com/golusoris/.github/commits/main/tests", + "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", diff --git a/tests/repo_checks.py b/tests/repo_checks.py index 7de3cfc..4f7cfc8 100644 --- a/tests/repo_checks.py +++ b/tests/repo_checks.py @@ -6,6 +6,7 @@ """ import re +from html.parser import HTMLParser from pathlib import Path from urllib.parse import unquote @@ -22,16 +23,79 @@ # '#'; 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") +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): @@ -40,14 +104,24 @@ def check_label(entry, index): 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 and not isinstance(entry["description"], str): - errors.append(f"entry {index}: description must be a string") + 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 @@ -63,6 +137,8 @@ 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): @@ -81,12 +157,14 @@ 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 value not in STATUS_VALUES: + 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(): @@ -95,56 +173,182 @@ def check_answers(data): return errors -def link_targets(text): - """Return the link and image targets of a Markdown text, code excluded. +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 - Each target appears once, in document order, followed by the targets of - reference definitions no link uses. Raw HTML is left unchecked. + +# 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 = [] - # Links and images sit only in the children of inline tokens; an image's - # own children are its alt text, which renders as plain text. + 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": - targets.append(token.attrGet("href")) + href = token.attrGet("href") + if href: + targets.append(href) elif token.type == "image": - targets.append(token.attrGet("src")) + 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", {}) - targets.extend(references[label].get("href") for label in sorted(references)) - return [target for target in dict.fromkeys(targets) if target is not None] + 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 relative_targets(path): - """Return the relative link targets in the Markdown file at path.""" - targets = [] - for target in link_targets(path.read_text(encoding="utf-8")): +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 - targets.append(target) + 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 relative_targets(path): + 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"{path.relative_to(root)}: {target} leaves the repository") + errors.append(f"{rel}: {target} leaves the repository") elif not target_path.exists(): - errors.append(f"{path.relative_to(root)}: {target} names no file") + 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.""" - return sorted({path for pattern in MARKDOWN_GLOBS for path in root.glob(pattern) if path.is_file()}) + 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 index efafffc..a6d8983 100644 --- a/tests/test_bestpractices.py +++ b/tests/test_bestpractices.py @@ -3,7 +3,27 @@ import json import unittest -from .repo_checks import ROOT, check_answers +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. @@ -33,6 +53,41 @@ def test_badge_names_this_project(self): 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): @@ -53,6 +108,14 @@ 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") @@ -69,6 +132,35 @@ 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): @@ -77,6 +169,35 @@ def test_every_accepted_status_value_passes(self): 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 index 228aaaf..539cb62 100644 --- a/tests/test_labels.py +++ b/tests/test_labels.py @@ -4,7 +4,13 @@ import yaml -from .repo_checks import ROOT, check_labels +from .repo_checks import ( + LABEL_DESCRIPTION_MAX, + LABEL_NAME_MAX, + MAX_LABELS, + ROOT, + check_labels, +) def label(name="type:feat", color="0e8a16", **extra): @@ -93,5 +99,32 @@ def test_wrong_length_or_digit_is_refused(self): 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 index 0587951..e944b21 100644 --- a/tests/test_markdown_links.py +++ b/tests/test_markdown_links.py @@ -3,8 +3,11 @@ 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, @@ -36,7 +39,8 @@ def setUp(self): """Create a temporary repository layout with fixture files.""" workdir = tempfile.TemporaryDirectory() self.addCleanup(workdir.cleanup) - self.root = Path(workdir.name) + 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") @@ -113,6 +117,65 @@ def test_url_encoded_target_passes_when_file_exists(self): (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): @@ -143,6 +206,40 @@ def test_code_fences_and_spans_contain_no_targets(self): ) 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()