diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2bf9d0c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + strategy: + # A failure on one Python version must not hide the result on the other. + fail-fast: false + matrix: + # The oldest Python that the scripts support, and the newest release. + python-version: ["3.11", "3.x"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + # The repo has no lock file to key a cache, and the tools are small. + enable-cache: false + - run: make check + + secrets: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # gitleaks must read the whole history. + fetch-depth: 0 + persist-credentials: false + - run: | + image=ghcr.io/gitleaks/gitleaks # v8.30.1 + digest=sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f + docker run --rm -v "$PWD:/repo" "$image@$digest" git /repo diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..6af6488 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +name: pull request + +# A squash merge puts the title and the body of the pull request on main as the commit message. +# An edit of the title or the body does not push a commit, so it must start these jobs again. +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: pull-request-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + sign-off: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The check reads each commit from the base to the head. + fetch-depth: 0 + persist-credentials: false + # Only the message that reaches main gets the other message rules, so a commit needs a + # sign-off and nothing more. + - name: Check that each commit has a sign-off + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: python3 scripts/check-tells --signed-off "$BASE..$HEAD" + + squash-message: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # The title and the body come through env, so that no text of the pull request ends up + # inside the script. + - name: Check the message that the squash merge puts on main + env: + TITLE: ${{ github.event.pull_request.title }} + NUMBER: ${{ github.event.pull_request.number }} + BODY: ${{ github.event.pull_request.body }} + run: | + printf '%s (#%s)\n\n%s\n' "$TITLE" "$NUMBER" "$BODY" > "$RUNNER_TEMP/message" + python3 scripts/check-tells --squash-msg "$RUNNER_TEMP/message" --no-scope diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0eb32b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +observed-raw/ +__pycache__/ +.mypy_cache/ +.ruff_cache/ diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..64a0f59 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,162 @@ +# Gatepost + +Unofficial, open-source developer tools for Nigeria's National Digital Postcode. This glossary fixes one name for each concept, in every language and every repo. + +## Language + +### The postcode + +**Postcode**: +The 11-character code that NIPOST gives to one addressable building or location. +_Avoid_: digital address, address code, zip code, postal code + +**Legacy postcode**: +An old 6-digit NIPOST postcode. It names an area, not a building. +_Avoid_: old code, zip + +**Segment**: +One of the five parts of a postcode: state, LGA, district, area and unit. +_Avoid_: part, field, component, chunk + +**State**: +The first segment. Two letters that name one of the 36 states or the Federal Capital Territory. +_Avoid_: region, province + +**LGA**: +The second segment. Two digits that name a local government area within a state. +_Avoid_: council, local government, municipality + +**District**: +The third segment. Three characters that name a district within an LGA. +_Avoid_: sorting code, sector + +**Area**: +The fourth segment. Two letters that name the smallest polygon in the system. An area holds at most 99 units. +_Avoid_: zone, block, neighbourhood + +**Unit**: +The fifth segment. Two digits that name one building or delivery point within an area. +_Avoid_: building number, house number, delivery unit + +**Partial postcode**: +A postcode that stops after the state, LGA, district or area segment. +_Avoid_: prefix, incomplete code +Exception: `prefix` is the name of the first parameter of `contains`. + +**Precision**: +The most specific segment that a postcode or a result contains. +_Avoid_: level, granularity, resolution + +### Forms of a postcode + +**Compact form**: +The postcode with no separators, for example `EK01A03FK01`. +_Avoid_: raw, plain + +**Canonical form**: +The postcode with hyphens between segments, for example `EK-01-A03-FK-01`. +_Avoid_: formatted, standard form + +**Display form**: +The postcode with spaces between segments, for example `EK 01 A03 FK 01`. +_Avoid_: pretty, human form + +**Normalise**: +To change input in three steps, in this order: apply NFKC, remove the separators, and change the ASCII letters to upper case. `grammar.md` defines the steps. +_Avoid_: sanitise, canonicalise + +**Code point**: +One Unicode code point. It is not a UTF-16 unit, a byte or a grapheme cluster. For example, U+1D404 is one code point and two UTF-16 units. +_Avoid_: character (when you count input), char + +**Input limit**: +The most code points that `parse` and `isLegacy` read. It is `maxInputCodePoints` in `data/format.json`. Over the limit, `parse` gives `bad_length` and `isLegacy` gives false. Both functions apply the limit before they normalise. +_Avoid_: max length, length limit, size limit + +**Suggestion**: +The canonical form that `parse` offers with `unknown_state` or `bad_segment`, when a fix of look-alike characters makes the code parse. It is a hint, never a success. The word means this hint from `parse`. It does not mean the suggestions that NIPOST's autocomplete API returns. +_Avoid_: correction, autocorrect + +**Parse result**: +What `parse` returns: a postcode, or a parse error. The Interface section of `grammar.md` defines its fields. +_Avoid_: outcome, response + +**Parse error**: +The reason that `parse` rejected its input. It has a code, the failing segment and a suggestion. It is a value that `parse` returns, not an exception. +_Avoid_: validation error, exception + +### NIPOST's API + +**Gateway**: +NIPOST's public API at `api.postcode.gov.ng`. +_Avoid_: server, backend, endpoint (for the whole API) + +**Lookup**: +A request to the gateway for the facts about one postcode. +_Avoid_: query, fetch, resolve + +**Lookup level**: +How much data a lookup returns, from 1 for validity only to 5 for point geometry. +_Avoid_: tier, access level, depth + +**Reverse geocode**: +A request for the nearest unit to a coordinate. +_Avoid_: reverse lookup, locate + +**Accuracy**: +The radius in metres within which a GPS fix is likely correct. +_Avoid_: precision, error margin + +**Secret key**: +An API key for servers. It starts with `nipost_test_` or `nipost_live_`. +_Avoid_: private key, API secret + +**Publishable key**: +An API key that can ship inside an app or a web page. It starts with `nipost_pk_`. +_Avoid_: public key, client key + +**Widget**: +NIPOST's own postcode picker. Gatepost does not build widgets. +_Avoid_: using this word for a Gatepost component + +### Gatepost + +**Spec**: +The `spec` repo. It holds the grammar, the data, the vectors and the standards. The API fixtures and the completed OpenAPI file come later. +_Avoid_: schema, contract (for the repo) + +**Vector**: +One shared test case in `spec/vectors/` that every SDK must pass. +_Avoid_: fixture, example, golden file + +**Fixture**: +Synthetic data that the mock server returns. +_Avoid_: vector, stub, sample + +**Mock server**: +The local server that imitates the gateway with fixtures. +_Avoid_: fake API, stub server, sandbox + +**SDK**: +The core and the client for one language. +_Avoid_: library (for the pair), wrapper, binding + +**Core**: +The part of an SDK that parses and formats postcodes without network access. +_Avoid_: utils, base, common + +**Client**: +The part of an SDK that calls the gateway. +_Avoid_: API wrapper, service, fetcher + +**Field**: +A Gatepost UI component in which a user enters or picks a postcode. +_Avoid_: input, widget, picker + +**Confirm**: +To show the user what a postcode points to before the app stores it. +_Avoid_: verify, validate (for this step) + +**Tell**: +A habit that makes code look machine-written, such as very long lines. +_Avoid_: smell (smells are design problems), anti-pattern diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5130d37 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +.PHONY: check test vectors tells lint types licences actions + +# Each tool has an exact version. A new release can add rules and turn CI red on a change that +# has nothing to do with it. Renovate opens the pull requests that move these versions. +# renovate: datasource=pypi depName=ruff +RUFF_VERSION := 0.16.10 +# renovate: datasource=pypi depName=mypy +MYPY_VERSION := 2.4.0 +# renovate: datasource=pypi depName=reuse +REUSE_VERSION := 6.2.0 +# renovate: datasource=pypi depName=charset-normalizer +CHARSET_NORMALIZER_VERSION := 3.5.2 +# renovate: datasource=pypi depName=zizmor +ZIZMOR_VERSION := 1.30.1 + +# scripts/check-tells has no .py suffix, so the tools need its name. +SCRIPTS := scripts scripts/check-tells + +check: test vectors tells lint types licences actions + +test: + python3 -m unittest discover -s scripts/tests -t scripts + +vectors: + python3 scripts/build_vectors.py --check + +tells: + python3 scripts/check-tells + +lint: + uvx ruff@$(RUFF_VERSION) check $(SCRIPTS) + uvx ruff@$(RUFF_VERSION) format --check $(SCRIPTS) + +types: + uvx mypy@$(MYPY_VERSION) + +# reuse needs an encoding detector, and uvx installs reuse without one. +licences: + uvx --with charset-normalizer==$(CHARSET_NORMALIZER_VERSION) reuse@$(REUSE_VERSION) lint + +# SEC-6: zizmor finds an action that is not pinned to a commit, and text of a pull request that +# reaches a script. Its other checks need a GitHub token, so this run is offline. +actions: + uvx zizmor@$(ZIZMOR_VERSION) --offline . diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..bc94510 --- /dev/null +++ b/NOTICE @@ -0,0 +1,5 @@ +Gatepost +Copyright 2026 The Gatepost authors + +This product is unofficial. The Nigerian Postal Service (NIPOST) did not make it and does not endorse it. +Gatepost uses the name NIPOST only to describe compatibility with NIPOST's National Digital Postcode. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8efd4e6 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Gatepost spec + +The shared contract for every Gatepost SDK: the postcode grammar, the data files, the test vectors, the coding standards and the repo templates. + +> Unofficial. Not made or endorsed by NIPOST. + +## Contents + +| Path | Holds | +|---|---| +| `grammar.md` | how every SDK reads and writes postcodes, and the public interface | +| `data/` | state codes, GPS accuracy limits and the postcode format | +| `vectors/` | shared test cases that every SDK must pass | +| `standards/` | the coding standards and one file for each language | +| `CONTEXT.md` | the glossary | +| `templates/` | files for new repos | +| `scripts/` | `check-tells`, the vector builder, and a plain reading of the grammar that checks each vector | + +## Check + +Run `make check`. It needs Python 3.11 or later and `uv`. + +## Licence + +Apache-2.0. See `LICENSE` and `NOTICE`. diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000..a074a60 --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,7 @@ +version = 1 + +[[annotations]] +path = ["**/*.md", "**/*.json", "**/*.yml", "VERSION", "NOTICE", "templates/NOTICE"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 The Gatepost authors" +SPDX-License-Identifier = "Apache-2.0" diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/data/format.json b/data/format.json new file mode 100644 index 0000000..5804e6a --- /dev/null +++ b/data/format.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "maxInputCodePoints": 64, + "segments": [ + { "name": "state", "length": 2, "characters": "letters" }, + { "name": "lga", "length": 2, "characters": "digits", "minimum": 1 }, + { "name": "district", "length": 3, "characters": "letters-or-digits" }, + { "name": "area", "length": 2, "characters": "letters" }, + { "name": "unit", "length": 2, "characters": "digits", "minimum": 1 } + ], + "legacyPattern": "^[0-9]{6}$", + "separators": [ + "U+0009", "U+000A", "U+000B", "U+000C", "U+000D", "U+0020", "U+002D", "U+002E", + "U+0085", "U+00A0", "U+00AD", "U+1680", "U+2000", "U+2001", "U+2002", "U+2003", + "U+2004", "U+2005", "U+2006", "U+2007", "U+2008", "U+2009", "U+200A", "U+200B", + "U+200C", "U+200D", "U+200E", "U+200F", "U+2010", "U+2011", "U+2012", "U+2013", + "U+2014", "U+2015", "U+2028", "U+2029", "U+202F", "U+205F", "U+2060", "U+2212", + "U+3000", "U+FEFF" + ], + "suggestions": { + "digits": { "O": "0", "I": "1", "L": "1" }, + "letters": { "0": "O", "1": "I" } + } +} diff --git a/data/precision.json b/data/precision.json new file mode 100644 index 0000000..7411fbc --- /dev/null +++ b/data/precision.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "source": "The GPS rule in NIPOST's postcode widget docs.", + "thresholds": [ + { "maxAccuracyM": 8, "precision": "unit" }, + { "maxAccuracyM": 20, "precision": "area" }, + { "maxAccuracyM": 50, "precision": "district" } + ], + "fallback": "lga" +} diff --git a/data/states.json b/data/states.json new file mode 100644 index 0000000..16a49e3 --- /dev/null +++ b/data/states.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "source": "NIPOST's autocomplete API returned these 37 codes on 2 Oct 2026. 32 equal ISO 3166-2:NG. NIPOST uses BR, GM, KG, SK and YB for Borno, Gombe, Kogi, Sokoto and Yobe, where ISO uses BO, GO, KO, SO and YO. The API returns no names. The names of these five come from the first-letter groups: each group lacks one ISO code and has one NIPOST code.", + "states": [ + { "code": "AB", "name": "Abia", "iso": "NG-AB" }, + { "code": "AD", "name": "Adamawa", "iso": "NG-AD" }, + { "code": "AK", "name": "Akwa Ibom", "iso": "NG-AK" }, + { "code": "AN", "name": "Anambra", "iso": "NG-AN" }, + { "code": "BA", "name": "Bauchi", "iso": "NG-BA" }, + { "code": "BE", "name": "Benue", "iso": "NG-BE" }, + { "code": "BR", "name": "Borno", "iso": "NG-BO" }, + { "code": "BY", "name": "Bayelsa", "iso": "NG-BY" }, + { "code": "CR", "name": "Cross River", "iso": "NG-CR" }, + { "code": "DE", "name": "Delta", "iso": "NG-DE" }, + { "code": "EB", "name": "Ebonyi", "iso": "NG-EB" }, + { "code": "ED", "name": "Edo", "iso": "NG-ED" }, + { "code": "EK", "name": "Ekiti", "iso": "NG-EK" }, + { "code": "EN", "name": "Enugu", "iso": "NG-EN" }, + { "code": "FC", "name": "Federal Capital Territory", "iso": "NG-FC" }, + { "code": "GM", "name": "Gombe", "iso": "NG-GO" }, + { "code": "IM", "name": "Imo", "iso": "NG-IM" }, + { "code": "JI", "name": "Jigawa", "iso": "NG-JI" }, + { "code": "KD", "name": "Kaduna", "iso": "NG-KD" }, + { "code": "KE", "name": "Kebbi", "iso": "NG-KE" }, + { "code": "KG", "name": "Kogi", "iso": "NG-KO" }, + { "code": "KN", "name": "Kano", "iso": "NG-KN" }, + { "code": "KT", "name": "Katsina", "iso": "NG-KT" }, + { "code": "KW", "name": "Kwara", "iso": "NG-KW" }, + { "code": "LA", "name": "Lagos", "iso": "NG-LA" }, + { "code": "NA", "name": "Nasarawa", "iso": "NG-NA" }, + { "code": "NI", "name": "Niger", "iso": "NG-NI" }, + { "code": "OG", "name": "Ogun", "iso": "NG-OG" }, + { "code": "ON", "name": "Ondo", "iso": "NG-ON" }, + { "code": "OS", "name": "Osun", "iso": "NG-OS" }, + { "code": "OY", "name": "Oyo", "iso": "NG-OY" }, + { "code": "PL", "name": "Plateau", "iso": "NG-PL" }, + { "code": "RI", "name": "Rivers", "iso": "NG-RI" }, + { "code": "SK", "name": "Sokoto", "iso": "NG-SO" }, + { "code": "TA", "name": "Taraba", "iso": "NG-TA" }, + { "code": "YB", "name": "Yobe", "iso": "NG-YO" }, + { "code": "ZA", "name": "Zamfara", "iso": "NG-ZA" } + ] +} diff --git a/grammar.md b/grammar.md new file mode 100644 index 0000000..8dabf3a --- /dev/null +++ b/grammar.md @@ -0,0 +1,125 @@ +# Postcode grammar + +Spec version 0.1.0. This file defines how every Gatepost SDK reads and writes Nigeria's digital postcodes. The values live in `data/`. The vectors in `vectors/` test every rule, except the rules in the list below. If this file and a vector disagree, this file wins. Such a disagreement is a bug in the spec. + +No shared vector tests these rules. Each SDK tests them itself. + +- The Unicode version of NFKC (see Normalise). +- Text that is not well-formed (see Parse). JSON cannot carry invalid UTF-8, and PHP rejects an escape for a lone surrogate. +- That `truncate` fails for a value that is not a precision (see Hierarchy). +- That each SDK exposes `SPEC_VERSION` (see Versions). + +## Forms + +| Form | Example | Use | +|---|---|---| +| Compact | `EK01A03FK01` | storage and comparison | +| Canonical | `EK-01-A03-FK-01` | APIs and logs | +| Display | `EK 01 A03 FK 01` | screens and print | + +## Segments + +`segments` in `data/format.json` holds the length, the characters and the minimum of each segment. + +| Segment | Length | Characters | Rule | +|---|---|---|---| +| state | 2 | letters | one of the 37 codes in `data/states.json` | +| lga | 2 | digits | 01 to 99 | +| district | 3 | letters or digits | any combination | +| area | 2 | letters | | +| unit | 2 | digits | 01 to 99 | + +A partial postcode stops after the state, LGA, district or area segment. Its length is 2, 4, 7 or 9 characters. Its precision is the last segment that it contains. + +## Normalise + +1. Apply Unicode NFKC. This changes full-width letters and digits to ASCII. +2. Remove each separator in `data/format.json`: white space, hyphens and dashes, the full stop, and zero-width characters. +3. Change the ASCII letters a to z to upper case. Keep every other character as it is. + +An SDK without a full NFKC can apply NFKC through a table. The table lists only the characters whose NFKC form holds only ASCII characters and separators. The SDK's `parse` and `isLegacy` must then give the same results as with full NFKC, for every input. The SDK's `normalize` can keep a character that full NFKC changes, such as U+00B5, in text that `parse` rejects. Each `normalize` vector gives the same result either way. + +Unicode 17.0 is the baseline for NFKC. A table must come from that version. A platform can have a Unicode version older than 17.0. An SDK on that platform can give other results for a character that a later version added. Unicode's stability policy freezes the NFKC form of an assigned character, so only an added character can differ. For example, NFKC in Unicode 17.0 changes U+A7F1 to S, but a platform with Unicode 16.0 or older keeps it. No vector holds such a character. + +A format character that is not in the list stays. For example, the right-to-left override U+202E can change the order in which a code shows on screen, so `parse` gives `bad_character` for it. + +`normalize` has no length limit. Bound untrusted text before you call it, or call `parse`, which checks the limit first. + +## Parse + +First count the Unicode code points of the input. Do not count UTF-16 units, UTF-8 bytes or grapheme clusters. A lone surrogate, in a language that can hold one, counts as one code point. The last paragraph of this section gives the rule for input that is not well-formed. An SDK can stop counting at 65. If the input has more than `maxInputCodePoints` code points (64 in `data/format.json`), the error code is `bad_length`, and `parse` does not normalise the input. The limit bounds the cost of NFKC, which can take seconds on a long run of combining marks. Otherwise, normalise the input. Then apply these checks in order. The first check that fails gives the error code. + +1. `empty`: no character is left. +2. `legacy_code`: exactly 6 ASCII digits are left (`legacyPattern` in `data/format.json`). These are old NIPOST postcodes. +3. `bad_character`: a character other than A to Z and 0 to 9 is left. +4. `bad_length`: the length is not 11. With `allowPartial`, the lengths 2, 4, 7 and 9 also pass. +5. `unknown_state`: the first two characters are not a code in `data/states.json`. +6. `bad_segment`: a segment breaks its rule in the table above. Check the segments in order, and report the first one that fails. + +For `unknown_state` and `bad_segment`, the error names the segment. For the other codes, the segment is null. + +Input that is not well-formed Unicode is never a postcode. In a language whose strings hold bytes, the SDK does not count the code points of input that is not valid UTF-8. `parse` gives `bad_length` for such input when it has more than 4 x `maxInputCodePoints` bytes, and `bad_character` otherwise. In a language whose strings can hold a lone surrogate, the surrogate counts as one code point. The input limit applies as usual. Within the limit, `parse` gives `bad_character` for input that holds a lone surrogate. `isLegacy` gives false for input that is not well-formed. `normalize` keeps each byte that is not part of a valid UTF-8 sequence, and each lone surrogate, in place. It normalises the text on each side of such a byte or surrogate on its own. No function throws for input that is not well-formed. + +## Suggestions + +For `unknown_state` and `bad_segment`, build one corrected code. `suggestions` in `data/format.json` holds the fixes: + +- In the letter segments, state and area, change `0` to `O` and `1` to `I`. +- In the digit segments, LGA and unit, change `O` to `0`, `I` to `1` and `L` to `1`. +- Never change the district. NIPOST has not confirmed which characters it uses there. + +Parse the corrected code with the same options. If it parses, the suggestion is its canonical form. If it does not parse, or if no character changed, the suggestion is null. A suggestion is a hint only. `parse` never returns a corrected code as a success. + +## Legacy postcodes + +`isLegacy` is false for input with more than `maxInputCodePoints` code points, and it does not normalise such input. Otherwise, it is true when the normalised input is exactly 6 ASCII digits. + +## Hierarchy + +- `truncate(code, to)` keeps the segments up to `to`. It fails when `to` is more precise than the code, and when `to` is not a precision. Both are programmer mistakes. Each language uses its standard error for them, for example `RangeError` in TypeScript. +- `parent(code)` returns the code with one segment fewer. A postcode with only the state segment has no parent, so `parent` returns null for it. +- `contains(prefix, code)` is true when the code has every segment of the prefix, in the same places. +- `redact(code)` replaces the unit with `**` in the canonical form. A code without a unit stays the same. + +## State names + +`stateName(code)` returns the name in `data/states.json`, and it ignores the case of the ASCII letters a to z only. It does not remove spaces or other characters. It does not apply NFKC. An unknown code gives null. The codes are NIPOST's. They equal ISO 3166-2:NG, except BR, GM, KG, SK and YB, where ISO uses BO, GO, KO, SO and YO. Each state in `data/states.json` also keeps its ISO code in the `iso` field. + +## GPS precision + +`precisionForAccuracy(metres)` returns the most precise segment that a GPS fix of that accuracy supports, from `data/precision.json`. An unknown, negative, infinite or NaN accuracy gives `lga`. The value -0 counts as 0, so it is not negative. A fix with an accuracy equal to a limit gets the precision of that limit. + +## Versions + +`VERSION` holds the spec version. Each SDK exposes it as `SPEC_VERSION`. Each vector file carries a format version, which is 1. + +## Interface + +An SDK exposes the symbols in this table and the types that they need. A language file gives the idiomatic form of each name and lists the additions that its language needs, such as an options type. The function names are the names in the vector files. This section covers the core. The interface of the client comes later, with the completed OpenAPI file. + +| Symbol | Takes | Returns | Rules | +|---|---|---|---| +| `normalize(text)` | text | text | Normalise | +| `parse(text, allowPartial)` | text, and `allowPartial`, which is false by default | a parse result | Parse | +| `isLegacy(text)` | text | true or false | Legacy postcodes | +| `truncate(code, to)` | a postcode and a precision | a postcode | Hierarchy | +| `parent(code)` | a postcode | a postcode, or null | Hierarchy | +| `contains(prefix, code)` | two postcodes | true or false | Hierarchy | +| `redact(code)` | a postcode | text | Hierarchy | +| `stateName(code)` | text | the name of a state, or null | State names | +| `precisionForAccuracy(metres)` | a number, or no value | a precision | GPS precision | +| `SPEC_VERSION` (a constant) | none | the spec version, as text | Versions | + +A parse result is a success or a failure. A success holds a postcode. A failure holds a parse error. A language can shape the result in the way that it expects, if it keeps the fields below. In the vectors, a success has the field `ok` with the value true, beside the fields of the postcode. A failure has `ok` with the value false, and an `error` field that holds the parse error. + +A postcode that `parse` accepted has these fields: + +- `compact`, `canonical` and `display`: the three forms of the code. +- `precision`: the last segment that the code contains. It is one of `state`, `lga`, `district`, `area` and `unit`. +- `segments`: the text of each of `state`, `lga`, `district`, `area` and `unit`. Each segment after the precision is null. For example, the segments of `EK-01` are `EK` and `01`, and null for the district, the area and the unit. + +A parse error has these fields: + +- `code`: one of `empty`, `legacy_code`, `bad_character`, `bad_length`, `unknown_state` and `bad_segment`. +- `segment`: the name of the failing segment for `unknown_state` and `bad_segment`, and null for the other codes. +- `suggestion`: the canonical form of one corrected code, or null. diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..725bca9 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +[mypy] +strict = True +mypy_path = scripts +# scripts/check-tells has no .py suffix, so the files list names it. +files = scripts, scripts/check-tells diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..60fb5d1 --- /dev/null +++ b/renovate.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "description": [ + "Each pull request body ends with the sign-off line that the pull request job needs.", + "prHeader holds the line because a JSON string cannot wrap, and the template ends with it." + ], + "extends": [ + "config:recommended", + "helpers:pinGitHubActionDigests", + "customManagers:makefileVersions", + "schedule:weekly" + ], + "semanticCommits": "enabled", + "semanticCommitScope": "", + "commitTrailers": ["Signed-off-by: {{{gitAuthor}}}"], + "prHeader": "Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>", + "prBodyTemplate": "Update a pinned tool or action.\n\n{{{header}}}", + "packageRules": [ + { + "matchManagers": ["github-actions"], + "semanticCommitType": "ci" + } + ] +} diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..fe1955b --- /dev/null +++ b/ruff.toml @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +target-version = "py311" +line-length = 100 + +[lint] +preview = true +explicit-preview-rules = true +select = [ + "E", "F", "W", "I", "B", "UP", "SIM", "RUF", "D", "ANN", "S", "PT", + "PL", "C4", "PIE", "RET", "ERA", "T20", "T10", "C90", +] +extend-select = ["PLR1702", "PLR0917"] +# S603 and S607: check-tells runs git with a fixed argument list. +# ANN401: JSON values in the vector builder are Any. +# PLR0913: TELL-3 counts only positional parameters. +ignore = ["S603", "S607", "ANN401", "PLR0913"] + +[lint.mccabe] +max-complexity = 10 + +[lint.pylint] +max-nested-blocks = 3 +max-positional-args = 4 + +[lint.pydocstyle] +convention = "google" + +[lint.per-file-ignores] +# PT: these tests use unittest, so the pytest-style rules do not apply. +"scripts/tests/**" = ["S101", "D", "ANN", "PLR2004", "PT"] diff --git a/scripts/build_vectors.py b/scripts/build_vectors.py new file mode 100755 index 0000000..9376f19 --- /dev/null +++ b/scripts/build_vectors.py @@ -0,0 +1,846 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""Build the shared test vectors in vectors/. + +This file is the source of truth for the vectors. Edit the cases here, then run +python3 scripts/build_vectors.py. CI runs it with --check, which fails when a vector +file differs from what this script builds. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from collections.abc import Callable +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent.parent +VECTORS = ROOT / "vectors" +FORMAT_VERSION = 1 +STATE_COUNT = 37 + +EN_DASH = chr(0x2013) +EM_DASH = chr(0x2014) +MINUS_SIGN = chr(0x2212) +NO_BREAK_SPACE = chr(0x00A0) +NARROW_NO_BREAK_SPACE = chr(0x202F) +ZERO_WIDTH_SPACE = chr(0x200B) +BYTE_ORDER_MARK = chr(0xFEFF) +SMALL_E_ACUTE = chr(0x00E9) +FI_LIGATURE = chr(0xFB01) +TAB = chr(0x09) +LINE_FEED = chr(0x0A) +SOFT_HYPHEN = chr(0x00AD) +LEFT_TO_RIGHT_MARK = chr(0x200E) +RIGHT_TO_LEFT_MARK = chr(0x200F) +RIGHT_TO_LEFT_OVERRIDE = chr(0x202E) +FULL_WIDTH_HYPHEN = chr(0xFF0D) +COMBINING_ACUTE = chr(0x0301) +HORIZONTAL_ELLIPSIS = chr(0x2026) +ZERO_WIDTH_JOINER = chr(0x200D) +LONG_S = chr(0x017F) +DOTLESS_I = chr(0x0131) +ARABIC_INDIC_LEGACY = "".join( + chr(code_point) for code_point in (0x0669, 0x0660, 0x0660, 0x0661, 0x0660, 0x0668) +) + +# The characters, other than the separators themselves, whose NFKC form holds a separator that +# is not ASCII. A table of ASCII forms only keeps them. A test computes the full set. +NFKC_SEPARATOR_SOURCES = ( + ("a superscript minus", chr(0x207B)), + ("a subscript minus", chr(0x208B)), + ("a vertical em dash", chr(0xFE31)), + ("a vertical en dash", chr(0xFE32)), + ("a small em dash", chr(0xFE58)), +) + +WIDTHS = (("state", 2), ("lga", 2), ("district", 3), ("area", 2), ("unit", 2)) +PRECISION_BY_LENGTH = {2: "state", 4: "lga", 7: "district", 9: "area", 11: "unit"} +PARTIAL = {"allowPartial": True} +CODE = "EK01A03FK01" +# Mathematical bold capitals start at U+1D400 and digits at U+1D7CE. Each one is 1 code point +# and 2 UTF-16 units, and NFKC maps it to its ASCII form. +MATH_BOLD_CODE = "".join( + chr(0x1D7CE + int(c)) if c.isdigit() else chr(0x1D400 + ord(c) - ord("A")) for c in CODE +) + +Row = tuple[str, Any, dict[str, Any], dict[str, Any]] + + +def row( + description: str, input_: Any, expect: dict[str, Any], options: dict[str, Any] | None = None +) -> Row: + """Make one case. Options default to none.""" + return (description, input_, expect, options or {}) + + +def full_width(text: str) -> str: + """Change the ASCII letters and digits in text to their full-width forms.""" + return "".join(chr(ord(c) + 0xFEE0) if c.isascii() and c.isalnum() else c for c in text) + + +def joined(separator: str) -> str: + """Return the code EK 01 A03 FK 01 with this separator between segments.""" + return separator.join(("EK", "01", "A03", "FK", "01")) + + +def segments_of(compact: str) -> dict[str, str | None]: + """Split a compact code into named segments. A missing segment is None.""" + segments: dict[str, str | None] = {} + start = 0 + for name, width in WIDTHS: + segments[name] = compact[start : start + width] or None + start += width + return segments + + +def ok(compact: str) -> dict[str, Any]: + """The expected result of a successful parse.""" + parts = [part for part in segments_of(compact).values() if part] + return { + "ok": True, + "compact": compact, + "canonical": "-".join(parts), + "display": " ".join(parts), + "precision": PRECISION_BY_LENGTH[len(compact)], + "segments": segments_of(compact), + } + + +def failed(code: str, segment: str | None = None, suggestion: str | None = None) -> dict[str, Any]: + """The expected result of a failed parse.""" + return {"ok": False, "error": {"code": code, "segment": segment, "suggestion": suggestion}} + + +def value(returned: Any) -> dict[str, Any]: + """The expected result of a function that returns one value.""" + return {"value": returned} + + +def load_states() -> list[dict[str, str]]: + """Read data/states.json and check that it lists 37 different states.""" + states: list[dict[str, str]] = json.loads( + (ROOT / "data" / "states.json").read_text(encoding="utf-8") + )["states"] + if len({state["code"] for state in states}) != STATE_COUNT: + raise ValueError(f"data/states.json must list {STATE_COUNT} different state codes.") + return states + + +def load_unused_iso_codes() -> list[tuple[str, str]]: + """Return the name and ISO code of each state where NIPOST's code is not the ISO code.""" + return [ + (state["name"], state["iso"].removeprefix("NG-")) + for state in load_states() + if state["iso"] != "NG-" + state["code"] + ] + + +def load_format() -> dict[str, Any]: + """Read data/format.json.""" + postcode_format: dict[str, Any] = json.loads( + (ROOT / "data" / "format.json").read_text(encoding="utf-8") + ) + return postcode_format + + +def load_separators() -> list[int]: + """Read data/format.json and return its separators as code points.""" + return [int(label.removeprefix("U+"), 16) for label in load_format()["separators"]] + + +def load_input_limit() -> int: + """Read data/format.json and return the input limit in code points.""" + limit: int = load_format()["maxInputCodePoints"] + return limit + + +def normalize_rows() -> list[Row]: + """Cases for normalize.""" + limit = load_input_limit() + want = value(CODE) + separator_rows = [ + row(f"removes the separator U+{code_point:04X}", joined(chr(code_point)), want) + for code_point in load_separators() + ] + nfkc_separator_rows = [ + row(f"removes {name}", "EK" + character + "01A03FK01", want) + for name, character in NFKC_SEPARATOR_SOURCES + ] + return [ + row("removes hyphens", "EK-01-A03-FK-01", want), + row("removes spaces and makes letters upper case", "ek 01 a03 fk 01", want), + row("removes leading and trailing spaces", " EK01A03FK01 ", want), + row("removes dots", "EK.01.A03.FK.01", want), + row("removes en dashes", joined(EN_DASH), want), + row("removes em dashes", joined(EM_DASH), want), + row("removes minus signs", joined(MINUS_SIGN), want), + row("removes no-break spaces", joined(NO_BREAK_SPACE), want), + row("removes narrow no-break spaces", joined(NARROW_NO_BREAK_SPACE), want), + row("removes a zero-width space", CODE + ZERO_WIDTH_SPACE, want), + row("removes a byte order mark", BYTE_ORDER_MARK + CODE, want), + row("removes soft hyphens", joined(SOFT_HYPHEN), want), + row("removes direction marks", LEFT_TO_RIGHT_MARK + CODE + RIGHT_TO_LEFT_MARK, want), + row( + "keeps a right-to-left override", + RIGHT_TO_LEFT_OVERRIDE + CODE, + value(RIGHT_TO_LEFT_OVERRIDE + CODE), + ), + row("changes full-width letters and digits to ASCII", full_width(CODE), want), + # Each character is above U+FFFF, so a table that skips the 4-byte entries keeps it. + row("changes mathematical bold letters and digits to ASCII", MATH_BOLD_CODE, want), + # An SDK that makes ASCII letters upper case before NFKC leaves these in lower case. + row( + "changes full-width lower-case letters to ASCII upper case", + full_width("ek01a03fk01"), + want, + ), + row("removes full-width hyphens", joined(FULL_WIDTH_HYPHEN), want), + *nfkc_separator_rows, + row("removes tabs and line feeds", "EK01" + TAB + "A03" + LINE_FEED + "FK01", want), + row( + "changes a ligature to its letters", + "EK01A03" + FI_LIGATURE + "01", + value("EK01A03FI01"), + ), + row("makes mixed-case letters upper case", "eK01a03Fk01", want), + row("keeps a legacy code", "900 108", value("900108")), + row("keeps characters that are not separators", "EK01A03FK0!", value("EK01A03FK0!")), + row( + "makes only ASCII letters upper case", + "ek01a03f" + SMALL_E_ACUTE + "01", + value("EK01A03F" + SMALL_E_ACUTE + "01"), + ), + # Unicode upper case changes U+0131 to I, and NFKC leaves it alone. + row( + "keeps a dotless i", + "EK01A03F" + DOTLESS_I + "01", + value("EK01A03F" + DOTLESS_I + "01"), + ), + row("returns an empty string for empty input", "", value("")), + row("returns an empty string for separators only", " - . ", value("")), + *separator_rows, + # normalize has no input limit. The code starts after the first `limit` code points, so a + # reading that refuses the input, or that cuts it at the limit, fails here. + row( + "removes spaces from input over the input limit", " " * limit + CODE + " " * limit, want + ), + # A case table that lacks one letter still passes every postcode that does not hold it. + row( + "makes each ASCII letter upper case", + "abcdefghijklmnopqrstuvwxyz", + value("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), + ), + ] + + +def parse_rows() -> list[Row]: + """Cases for forms, lengths, characters and legacy codes.""" + limit = load_input_limit() + return [ + row("parses the canonical form", "EK-01-A03-FK-01", ok(CODE)), + row("parses the compact form", CODE, ok(CODE)), + row("parses the display form in lower case", "ek 01 a03 fk 01", ok(CODE)), + row("parses a code with en dashes", joined(EN_DASH), ok(CODE)), + row("parses a code with no-break spaces", joined(NO_BREAK_SPACE), ok(CODE)), + row("parses a code with a zero-width space", CODE + ZERO_WIDTH_SPACE, ok(CODE)), + row("parses a code with a byte order mark", BYTE_ORDER_MARK + CODE, ok(CODE)), + row("parses full-width letters and digits", full_width("EK-01-A03-FK-01"), ok(CODE)), + row("parses the example on NIPOST's homepage", "FC-02-A09-DB-09", ok("FC02A09DB09")), + row("parses the example in NIPOST's blog", "LA 12 K23 IV 95", ok("LA12K23IV95")), + row("parses a NIPOST test code", "AK-11-I61-ZF-12", ok("AK11I61ZF12")), + row( + "accepts a letter O in the district of a NIPOST test code", + "JI-24-O18-JP-23", + ok("JI24O18JP23"), + ), + row("rejects empty input", "", failed("empty")), + row("rejects spaces only", " ", failed("empty")), + row("rejects separators only", " - . ", failed("empty")), + row("names a legacy code", "900108", failed("legacy_code")), + row("names a legacy code with a space", "900 108", failed("legacy_code")), + row( + "names a legacy code in full-width digits", full_width("900108"), failed("legacy_code") + ), + row( + "names a legacy code when partial codes are allowed", + "123456", + failed("legacy_code"), + PARTIAL, + ), + row("rejects five digits", "90010", failed("bad_length")), + row("rejects an exclamation mark", "EK01A03FK0!", failed("bad_character")), + row("rejects an underscore", "EK01A03FK0_", failed("bad_character")), + row("rejects slashes", "EK01/A03/FK01", failed("bad_character")), + row( + "rejects a letter outside ASCII", + "EK01A03F" + SMALL_E_ACUTE + "01", + failed("bad_character"), + ), + row( + "rejects a right-to-left override", + RIGHT_TO_LEFT_OVERRIDE + CODE, + failed("bad_character"), + ), + row("rejects Arabic-Indic digits", ARABIC_INDIC_LEGACY, failed("bad_character")), + row("rejects 10 characters", "EK01A03FK1", failed("bad_length")), + row("rejects 12 characters", "EK01A03FK01X", failed("bad_length")), + row("rejects a unit that lost its zero", "EK-01-A03-FK-1", failed("bad_length")), + row("rejects a state alone by default", "EK", failed("bad_length")), + row("rejects a district code by default", "EK01A03", failed("bad_length")), + # The length check comes before the state and segment checks and before suggestions. + row("checks the length before the state", "XX", failed("bad_length")), + row("checks the length before the segments", "EK00", failed("bad_length")), + row("checks the length before a fixable segment", "EKO1A03FK1", failed("bad_length")), + row("parses a state when partial codes are allowed", "EK", ok("EK"), PARTIAL), + row("parses an LGA code when partial codes are allowed", "EK01", ok("EK01"), PARTIAL), + row( + "parses a district code when partial codes are allowed", + "ek-01-a03", + ok("EK01A03"), + PARTIAL, + ), + row( + "parses an area code when partial codes are allowed", + "EK01A03FK", + ok("EK01A03FK"), + PARTIAL, + ), + row("parses a full code when partial codes are allowed", CODE, ok(CODE), PARTIAL), + row( + "rejects 3 characters when partial codes are allowed", + "EK0", + failed("bad_length"), + PARTIAL, + ), + row( + "rejects 5 characters when partial codes are allowed", + "EK01A", + failed("bad_length"), + PARTIAL, + ), + row( + "rejects 6 characters when partial codes are allowed", + "EK01A0", + failed("bad_length"), + PARTIAL, + ), + row( + "rejects 8 characters when partial codes are allowed", + "EK01A03F", + failed("bad_length"), + PARTIAL, + ), + row( + "rejects 10 characters when partial codes are allowed", + "EK01A03FK0", + failed("bad_length"), + PARTIAL, + ), + row("parses a code padded to the input limit", CODE + " " * (limit - len(CODE)), ok(CODE)), + row( + "rejects input over the input limit", + CODE + " " * (limit - len(CODE) + 1), + failed("bad_length"), + ), + # 54 code points and 65 UTF-16 units. A runner that counts UTF-16 units rejects it. + row( + "counts code points, not UTF-16 units", + MATH_BOLD_CODE + " " * (limit - 2 * len(CODE) + 1), + ok(CODE), + ), + # 64 code points and 75 UTF-16 units. A runner that counts UTF-16 units rejects it. + row( + "parses an astral code padded to the input limit", + MATH_BOLD_CODE + " " * (limit - len(CODE)), + ok(CODE), + ), + # 65 code points, but 1 grapheme cluster and 64 code points after NFKC. + row( + "counts combining marks as code points", + "E" + COMBINING_ACUTE * limit, + failed("bad_length"), + ), + # 33 code points, but 77 after NFKC, because NFKC changes U+2026 to 3 full stops. + row("counts the input before it normalises", CODE + HORIZONTAL_ELLIPSIS * 22, ok(CODE)), + # 65 code points. In many regex engines, $ also matches before a final line feed. + row( + "rejects input over the limit that ends in a line feed", + CODE + " " * (limit - len(CODE)) + LINE_FEED, + failed("bad_length"), + ), + ] + + +def segment_rows() -> list[Row]: + """Cases for segment rules and typo suggestions.""" + unused_iso = [ + row( + f"rejects {iso_code}, the ISO code of {name}, as a state", + iso_code + "01Z99ZZ01", + failed("unknown_state", "state"), + ) + for name, iso_code in load_unused_iso_codes() + ] + return [ + row("rejects an unknown state", "XX01A03FK01", failed("unknown_state", "state")), + row("rejects the synthetic state ZZ", "ZZ01Z99ZZ01", failed("unknown_state", "state")), + *unused_iso, + row( + "suggests O for a zero in the state", + "0G01A03FK01", + failed("unknown_state", "state", "OG-01-A03-FK-01"), + ), + row( + "suggests I for a one in the state", + "1M01A03FK01", + failed("unknown_state", "state", "IM-01-A03-FK-01"), + ), + row( + "gives no suggestion when the fixed state is unknown", + "E101A03FK01", + failed("unknown_state", "state"), + ), + row( + "gives no suggestion when the state cannot be fixed", + "XXO1A03FK01", + failed("unknown_state", "state"), + ), + row( + "fixes later segments in a state suggestion", + "0GO1A03FK01", + failed("unknown_state", "state", "OG-01-A03-FK-01"), + ), + row("rejects an LGA of 00", "EK00A03FK01", failed("bad_segment", "lga")), + row("rejects a unit of 00", "EK01A03FK00", failed("bad_segment", "unit")), + # A digit check that has no end anchor reads the first digit of the segment and accepts + # these codes. The letter X has no fix, so neither code gets a suggestion. + row( + "rejects a letter after a digit in the LGA", + "JI9XCHKLS18", + failed("bad_segment", "lga"), + ), + row( + "rejects a letter after a digit in the unit", + "JI99CHKLS1X", + failed("bad_segment", "unit"), + ), + row( + "suggests 0 for a letter O in the LGA", + "EKO1A03FK01", + failed("bad_segment", "lga", "EK-01-A03-FK-01"), + ), + row( + "suggests 1 for a letter I in the LGA", + "EKI1A03FK01", + failed("bad_segment", "lga", "EK-11-A03-FK-01"), + ), + row( + "suggests 1 for a letter L in the LGA", + "EKL1A03FK01", + failed("bad_segment", "lga", "EK-11-A03-FK-01"), + ), + row( + "suggests I for a one in the area", + "EK01A03F101", + failed("bad_segment", "area", "EK-01-A03-FI-01"), + ), + row( + "suggests O for a zero in the area", + "EK01A03F001", + failed("bad_segment", "area", "EK-01-A03-FO-01"), + ), + # A letter check that looks only at the last character of the area accepts this code. + row( + "rejects a digit before a letter in the area", + "EK01A031K01", + failed("bad_segment", "area", "EK-01-A03-IK-01"), + ), + row( + "suggests 0 for a letter O in the unit", + "EK01A03FKO1", + failed("bad_segment", "unit", "EK-01-A03-FK-01"), + ), + row( + "suggests 1 for a letter I in the unit", + "EK01A03FKI2", + failed("bad_segment", "unit", "EK-01-A03-FK-12"), + ), + row( + "suggests 1 for a letter L in the unit", + "EK01A03FKL1", + failed("bad_segment", "unit", "EK-01-A03-FK-11"), + ), + row( + "suggests 1 for a letter I after a zero in the unit", + "EK01A03FK0I", + failed("bad_segment", "unit", "EK-01-A03-FK-01"), + ), + row( + "gives no suggestion when the fix makes a unit of 00", + "EK01A03FK0O", + failed("bad_segment", "unit"), + ), + row( + "gives no suggestion when a segment cannot be fixed", + "EK01A03F2O1", + failed("bad_segment", "area"), + ), + row( + "reports the first bad segment and fixes them all", + "EKO1A03F1O1", + failed("bad_segment", "lga", "EK-01-A03-FI-01"), + ), + row( + "suggests a fix after making letters upper case", + "eko1a03fk01", + failed("bad_segment", "lga", "EK-01-A03-FK-01"), + ), + row( + "rejects an LGA of 00 in a partial code", "EK00", failed("bad_segment", "lga"), PARTIAL + ), + row("suggests a partial code", "EKO1", failed("bad_segment", "lga", "EK-01"), PARTIAL), + row( + "suggests a partial area code", + "EK01A03F1", + failed("bad_segment", "area", "EK-01-A03-FI"), + PARTIAL, + ), + row( + "rejects an unknown state when partial codes are allowed", + "XX", + failed("unknown_state", "state"), + PARTIAL, + ), + row( + "suggests a state for a partial code", + "0G", + failed("unknown_state", "state", "OG"), + PARTIAL, + ), + row( + "suggests an LGA fix for a district code", + "EKO1A03", + failed("bad_segment", "lga", "EK-01-A03"), + PARTIAL, + ), + row( + "rejects an LGA of 00 in a district code", + "EK00A03", + failed("bad_segment", "lga"), + PARTIAL, + ), + row( + "keeps a letter O in the district when it fixes the LGA", + "EKO1AO3FK01", + failed("bad_segment", "lga", "EK-01-AO3-FK-01"), + ), + row( + "keeps letters I and L in the district when it fixes the LGA", + "EKO1AILFK01", + failed("bad_segment", "lga", "EK-01-AIL-FK-01"), + ), + row( + "accepts letters and digits in any district position", "EK01A0OFK01", ok("EK01A0OFK01") + ), + ] + + +def state_parse_rows() -> list[Row]: + """One valid synthetic code for each state.""" + return [ + row( + f"parses a code in {state['name']}", + f"{state['code']}-01-Z99-ZZ-01", + ok(f"{state['code']}01Z99ZZ01"), + ) + for state in load_states() + ] + + +def legacy_rows() -> list[Row]: + """Cases for isLegacy.""" + limit = load_input_limit() + return [ + row("accepts six digits", "900108", value(True)), + row("accepts six digits with a space", "900 108", value(True)), + row("accepts six full-width digits", full_width("900108"), value(True)), + row("rejects five digits as a legacy code", "90010", value(False)), + row("rejects seven digits", "9001080", value(False)), + row("rejects a new postcode", CODE, value(False)), + row("rejects empty input as a legacy code", "", value(False)), + row("rejects a letter O among digits", "90O108", value(False)), + row("rejects six Arabic-Indic digits as a legacy code", ARABIC_INDIC_LEGACY, value(False)), + row( + "finds a legacy code padded to the input limit", + "900108" + " " * (limit - 6), + value(True), + ), + row( + "rejects a legacy code over the input limit", + "900108" + " " * (limit - 5), + value(False), + ), + # 65 code points, but 6 grapheme clusters. A grapheme counter gives true, because + # normalize removes the joiners. + row( + "counts zero-width joiners as code points for legacy codes", + "900108" + ZERO_WIDTH_JOINER * (limit - 5), + value(False), + ), + # 26 code points, but 66 after NFKC, which changes U+2026 to 3 full stops. A count + # after NFKC gives false. + row( + "counts the input before it normalises for legacy codes", + "900108" + HORIZONTAL_ELLIPSIS * 20, + value(True), + ), + # 65 code points. In many regex engines, $ also matches before a final line feed. + row( + "rejects a legacy code over the limit that ends in a line feed", + "900108" + " " * (limit - 6) + LINE_FEED, + value(False), + ), + ] + + +def truncate_rows() -> list[Row]: + """Cases for truncate. Inputs are canonical codes.""" + full = "EK-01-A03-FK-01" + return [ + row( + "keeps a full code at unit precision", {"code": full, "to": "unit"}, {"canonical": full} + ), + row( + "cuts a full code to its area", + {"code": full, "to": "area"}, + {"canonical": "EK-01-A03-FK"}, + ), + row( + "cuts a full code to its district", + {"code": full, "to": "district"}, + {"canonical": "EK-01-A03"}, + ), + row("cuts a full code to its LGA", {"code": full, "to": "lga"}, {"canonical": "EK-01"}), + row("cuts a full code to its state", {"code": full, "to": "state"}, {"canonical": "EK"}), + row( + "keeps a district code at district precision", + {"code": "EK-01-A03", "to": "district"}, + {"canonical": "EK-01-A03"}, + ), + row( + "refuses to add precision to a district code", + {"code": "EK-01-A03", "to": "area"}, + {"rejects": True}, + ), + row( + "refuses to add precision to a state code", + {"code": "EK", "to": "lga"}, + {"rejects": True}, + ), + ] + + +def parent_rows() -> list[Row]: + """Cases for parent.""" + return [ + row( + "gives the area of a full code", + {"code": "EK-01-A03-FK-01"}, + {"canonical": "EK-01-A03-FK"}, + ), + row( + "gives the district of an area code", + {"code": "EK-01-A03-FK"}, + {"canonical": "EK-01-A03"}, + ), + row("gives the LGA of a district code", {"code": "EK-01-A03"}, {"canonical": "EK-01"}), + row("gives the state of an LGA code", {"code": "EK-01"}, {"canonical": "EK"}), + row("gives nothing for a state code", {"code": "EK"}, {"canonical": None}), + ] + + +def contains_rows() -> list[Row]: + """Cases for contains.""" + full = "EK-01-A03-FK-01" + return [ + row( + "finds a full code in its district", {"prefix": "EK-01-A03", "code": full}, value(True) + ), + row("finds a full code in its state", {"prefix": "EK", "code": full}, value(True)), + row("finds a code in itself", {"prefix": full, "code": full}, value(True)), + row( + "finds a unit in its area", + {"prefix": "EK-01-A03-FK", "code": "EK-01-A03-FK-02"}, + value(True), + ), + row( + "rejects a code from another district", + {"prefix": "EK-01-A04", "code": full}, + value(False), + ), + row("rejects a code from another state", {"prefix": "FC", "code": full}, value(False)), + row( + "rejects a less precise code inside a more precise one", + {"prefix": full, "code": "EK-01-A03"}, + value(False), + ), + row("rejects a different unit", {"prefix": full, "code": "EK-01-A03-FK-02"}, value(False)), + # The district FC0 holds the text FC. A check that looks for the prefix anywhere in the + # code finds it there. + row( + "rejects a code that holds the prefix after its start", + {"prefix": "FC", "code": "EK-01-FC0-FK-01"}, + value(False), + ), + # A check that skips one segment of the prefix finds these codes inside it. + row("rejects a code from another LGA", {"prefix": "EK-02", "code": full}, value(False)), + row( + "rejects a code from another area", + {"prefix": "EK-01-A03-FA", "code": full}, + value(False), + ), + ] + + +def redact_rows() -> list[Row]: + """Cases for redact.""" + return [ + row("hides the unit of a full code", {"code": "EK-01-A03-FK-01"}, value("EK-01-A03-FK-**")), + row("keeps an area code as it is", {"code": "EK-01-A03-FK"}, value("EK-01-A03-FK")), + row("keeps a state code as it is", {"code": "EK"}, value("EK")), + ] + + +def state_name_rows() -> list[Row]: + """Cases for stateName: one for each state, then the edge cases.""" + named = [ + row(f"names {state['code']}", state["code"], value(state["name"])) + for state in load_states() + ] + unused_iso = [ + row(f"returns nothing for {iso_code}, the ISO code of {name}", iso_code, value(None)) + for name, iso_code in load_unused_iso_codes() + ] + return [ + *named, + row("accepts lower case", "ek", value("Ekiti")), + row("accepts mixed case", "Fc", value("Federal Capital Territory")), + row("returns nothing for an unknown code", "XX", value(None)), + row("returns nothing for empty input", "", value(None)), + row("returns nothing for three letters", "EKI", value(None)), + row("returns nothing for a code with spaces", " EK ", value(None)), + row("returns nothing for a code with a leading space", " EK", value(None)), + row("returns nothing for a code with a trailing space", "EK ", value(None)), + row("returns nothing for full-width letters", full_width("EK"), value(None)), + # Python's upper() changes U+017F to S and U+0131 to I. These two would give Osun and Imo. + row("returns nothing for a long s", "O" + LONG_S, value(None)), + row("returns nothing for a dotless i", DOTLESS_I + "M", value(None)), + # Where NIPOST's code is not the ISO code, the ISO code is an unknown state. + *unused_iso, + ] + + +def accuracy_rows() -> list[Row]: + """Cases for precisionForAccuracy. + + Runners turn the strings NaN, Infinity and -Infinity into numbers. + """ + return [ + row("gives the unit at 0 m", 0, value("unit")), + row("gives the unit at 5 m", 5, value("unit")), + row("gives the unit at exactly 8 m", 8, value("unit")), + row("gives the area just above 8 m", 8.1, value("area")), + row("gives the area at exactly 20 m", 20, value("area")), + row("gives the district above 20 m", 20.5, value("district")), + row("gives the district at exactly 50 m", 50, value("district")), + row("gives the LGA just above 50 m", 50.1, value("lga")), + row("gives the LGA at 1000 m", 1000, value("lga")), + row("gives the LGA when the accuracy is unknown", None, value("lga")), + row("gives the LGA for a negative accuracy", -1, value("lga")), + # An accuracy of -0.0 is not negative. A sign test such as double.IsNegative says it is. + row("gives the unit for negative zero", -0.0, value("unit")), + row("gives the LGA for NaN", "NaN", value("lga")), + row("gives the LGA for infinity", "Infinity", value("lga")), + row("gives the LGA for negative infinity", "-Infinity", value("lga")), + ] + + +FILES: dict[str, tuple[str, str, Callable[[], list[Row]]]] = { + "normalize": ("normalize", "Cleans input before parsing.", normalize_rows), + "parse": ("parse", "Forms, lengths, characters and legacy codes.", parse_rows), + "parse-segments": ("parse", "Segment rules and typo suggestions.", segment_rows), + "parse-states": ("parse", "One valid code for each state.", state_parse_rows), + "is-legacy": ("isLegacy", "Finds old 6-digit postcodes.", legacy_rows), + "truncate": ("truncate", "Cuts a code to a less precise segment.", truncate_rows), + "parent": ("parent", "Gives the code one segment shorter.", parent_rows), + "contains": ("contains", "Checks that one code lies inside another.", contains_rows), + "redact": ("redact", "Hides the unit for logs.", redact_rows), + "state-name": ("stateName", "Names the state for a state code.", state_name_rows), + "precision-for-accuracy": ( + "precisionForAccuracy", + "Maps GPS accuracy to precision.", + accuracy_rows, + ), +} + + +def render(stem: str) -> str: + """Build the JSON text of one vector file.""" + function, description, rows = FILES[stem] + cases = [ + { + "id": f"{stem}-{index:03d}", + "description": text, + "input": input_, + "options": options, + "expect": expect, + } + for index, (text, input_, expect, options) in enumerate(rows(), start=1) + ] + document = { + "version": FORMAT_VERSION, + "function": function, + "description": description, + "cases": cases, + } + return json.dumps(document, ensure_ascii=True, indent=2) + "\n" + + +def main(argv: list[str] | None = None) -> int: + """Write the vector files, or with --check, report the ones that are out of date. + + In both modes, report each JSON file in vectors/ that the builder does not write. + """ + parser = argparse.ArgumentParser(description="Build the shared test vectors.") + parser.add_argument( + "--check", action="store_true", help="Fail if a vector file is out of date." + ) + args = parser.parse_args(argv) + if not args.check: + VECTORS.mkdir(exist_ok=True) + stale: list[str] = [] + for stem in FILES: + path = VECTORS / f"{stem}.json" + text = render(stem) + if not args.check: + path.write_text(text, encoding="utf-8") + elif not path.exists() or path.read_text(encoding="utf-8") != text: + stale.append(path.name) + strays = sorted(path.name for path in VECTORS.glob("*.json") if path.stem not in FILES) + if stale: + sys.stderr.write( + f"Out of date: {', '.join(stale)}. Run python3 scripts/build_vectors.py.\n" + ) + for name in strays: + sys.stderr.write(f"vectors/{name} is not a builder output. Remove it.\n") + if stale or strays: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-tells b/scripts/check-tells new file mode 100755 index 0000000..277fa02 --- /dev/null +++ b/scripts/check-tells @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""Run the check_tells package. The rules are in that package.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from check_tells import main + +raise SystemExit(main()) diff --git a/scripts/check_tells/__init__.py b/scripts/check_tells/__init__.py new file mode 100644 index 0000000..0edff59 --- /dev/null +++ b/scripts/check_tells/__init__.py @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""Find the agent tells that language tools miss. + +The rules come from standards/CODING_STANDARDS.md: TELL-1, TELL-7, TELL-13, TELL-14 +and TELL-18, and CS-6 for the form of to-do comments. For the message that a squash merge +puts on main, it also checks GIT-1 and GIT-2. + +Each module has one job: + +- violation: the result of a check. +- text: the lines of a text, and the characters that TELL-14 rejects. +- file_kinds: the kind and the language of a file. +- debug_calls: the debug calls of TELL-13. +- file_checks: the checks of the name and the lines of one file. +- message_checks: the checks of commit messages and squash messages. +- scan: the reading of files, message files and commits. +- command: the command line. +""" + +from check_tells.command import main +from check_tells.file_checks import check_text +from check_tells.file_kinds import FileInfo, Kind, classify +from check_tells.message_checks import ( + PLACEHOLDER_ADDRESS, + TEMPLATE_PARAGRAPH, + check_commit_message, + check_squash_message, +) +from check_tells.scan import InputError, check_files, check_signed_off, tracked_files +from check_tells.violation import Violation + +__all__ = [ + "PLACEHOLDER_ADDRESS", + "TEMPLATE_PARAGRAPH", + "FileInfo", + "InputError", + "Kind", + "Violation", + "check_commit_message", + "check_files", + "check_signed_off", + "check_squash_message", + "check_text", + "classify", + "main", + "tracked_files", +] diff --git a/scripts/check_tells/command.py b/scripts/check_tells/command.py new file mode 100644 index 0000000..edf901c --- /dev/null +++ b/scripts/check_tells/command.py @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""The command line: the options, the exit codes and the output.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from check_tells.message_checks import check_commit_message, check_squash_message +from check_tells.scan import ( + InputError, + check_files, + check_message_file, + check_signed_off, + tracked_files, +) +from check_tells.violation import Violation + +EXIT_BAD_INPUT = 2 + + +def _run(args: argparse.Namespace) -> list[Violation]: + if args.commit_msg is not None: + return check_message_file(args.commit_msg, check_commit_message) + if args.squash_msg is not None: + return check_message_file( + args.squash_msg, lambda text: check_squash_message(text, no_scope=args.no_scope) + ) + root = args.root.resolve() + if args.signed_off is not None: + return check_signed_off(root, args.signed_off) + files = [(root / path).resolve() for path in args.paths] or tracked_files(root) + return check_files(root, files) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="check-tells", description="Find the agent tells that language tools miss." + ) + parser.add_argument("--root", type=Path, default=Path.cwd(), help="The repo root.") + messages = parser.add_mutually_exclusive_group() + messages.add_argument( + "--commit-msg", type=Path, help="Check this commit message file (TELL-14 and TELL-18)." + ) + messages.add_argument( + "--squash-msg", + type=Path, + help="Check this file as the message that a squash merge puts on main " + "(TELL-14, TELL-18, GIT-1, GIT-2 and the text of the pull request template).", + ) + messages.add_argument( + "--signed-off", + metavar="RANGE", + help="Check that each commit in this git revision range has a sign-off (GIT-2).", + ) + parser.add_argument( + "--no-scope", + action="store_true", + help="With --squash-msg, reject a scope in the subject " + "(GIT-1 for a repo without packages).", + ) + parser.add_argument("paths", nargs="*", type=Path, help="Files to check. Default: all.") + return parser + + +def main(argv: list[str] | None = None) -> int: + """Run the checks and print each violation. + + Return 0 if all is well, 1 if a rule broke and 2 if the input cannot be read. + """ + parser = _parser() + args = parser.parse_args(argv) + if args.no_scope and args.squash_msg is None: + parser.error("--no-scope needs --squash-msg.") + try: + violations = _run(args) + except InputError as error: + sys.stderr.write(f"check-tells: {error}\n") + return EXIT_BAD_INPUT + for violation in sorted(violations): + sys.stdout.write(f"{violation}\n") + return 1 if violations else 0 diff --git a/scripts/check_tells/debug_calls.py b/scripts/check_tells/debug_calls.py new file mode 100644 index 0000000..6350842 --- /dev/null +++ b/scripts/check_tells/debug_calls.py @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""TELL-13: the debug calls that each language file lists, and the check of one line.""" + +from __future__ import annotations + +import re + +from check_tells.file_kinds import FileInfo +from check_tells.violation import Violation + +DEBUG_CALLS: dict[str, tuple[re.Pattern[str], ...]] = { + "javascript": (re.compile(r"\bconsole\.(log|debug|dir)\s*\("), re.compile(r"\bdebugger\b")), + "php": ( + re.compile( + r"(?$:])(var_dump|print_r|var_export|dd|dump|error_log|debug_print_backtrace)" + r"\s*\(" + ), + ), + "python": ( + re.compile(r"(?>> and ...) start the examples in Python docstrings. +DOCTEST_LINE = re.compile(r"^\s*(>>>|\.\.\.)") + + +def _is_comment(info: FileInfo, line: str) -> bool: + if COMMENT_LINE.match(line): + return True + return info.language == "python" and bool(DOCTEST_LINE.match(line)) + + +def check_debug_calls(info: FileInfo, number: int, line: str) -> list[Violation]: + """Find the debug calls of the file's language in one line (TELL-13). + + A test file and a comment line have no debug calls. + """ + if info.kind != "code" or info.is_test or info.language is None or _is_comment(info, line): + return [] + patterns = DEBUG_CALLS.get(info.language, ()) + return [ + Violation(info.path, number, match.start() + 1, "TELL-13", f"Remove {match.group(0)!r}.") + for pattern in patterns + for match in pattern.finditer(line) + ] diff --git a/scripts/check_tells/file_checks.py b/scripts/check_tells/file_checks.py new file mode 100644 index 0000000..aa7c29e --- /dev/null +++ b/scripts/check_tells/file_checks.py @@ -0,0 +1,112 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""The checks of the name and the lines of one file. + +They cover TELL-1, TELL-7, TELL-13, TELL-14 and CS-6, and the comment that skips one of +them for one line. +""" + +from __future__ import annotations + +import re +from pathlib import PurePosixPath + +from check_tells.debug_calls import check_debug_calls +from check_tells.file_kinds import FileInfo, classify, is_data_json +from check_tells.text import LAST_ASCII, is_emoji, split_lines +from check_tells.violation import Violation + +MAX_LINE = 100 +TAB_WIDTH = 4 +FORBIDDEN_STEMS = frozenset({"utils", "helpers", "common", "misc"}) +SKIPPABLE_RULES = frozenset({"TELL-1", "TELL-13", "TELL-14", "CS-6"}) +URL = re.compile(r"https?://") +# The words are split, so that this line does not match its own pattern. +TODO_FORM = re.compile(r"\b(?:TO" r"DO|FIX" r"ME)\b(?!\(#\d+\))", re.IGNORECASE) +SKIP = re.compile(r"check-tells: allow ([A-Z]+-\d+)(?: because (\S.*))?") + + +def check_text(path: str, text: str) -> list[Violation]: + """Check the name and every line of one file.""" + info = classify(path, text) + violations = _check_name(info) + for number, line in enumerate(split_lines(text), start=1): + violations.extend(_check_line(info, number, line)) + return violations + + +def _check_name(info: FileInfo) -> list[Violation]: + stem = PurePosixPath(info.path).name.split(".")[0].lower() + if stem not in FORBIDDEN_STEMS: + return [] + message = "Name the file after the concept that it holds, not utils, helpers, common or misc." + return [Violation(info.path, 1, 1, "TELL-7", message)] + + +def _check_line(info: FileInfo, number: int, line: str) -> list[Violation]: + skipped: set[str] = set() + problems: list[Violation] = [] + if info.kind in ("code", "config"): + skipped, problems = _skips(info.path, number, line) + found = [ + violation + for check in (_line_length, _todo_form, check_debug_calls, _characters) + for violation in check(info, number, line) + if violation.rule not in skipped + ] + return problems + found + + +def _skips(path: str, number: int, line: str) -> tuple[set[str], list[Violation]]: + skipped: set[str] = set() + problems: list[Violation] = [] + for match in SKIP.finditer(line): + rule, reason = match.group(1), match.group(2) + if rule in SKIPPABLE_RULES and reason: + skipped.add(rule) + continue + message = ( + f"Skip only {', '.join(sorted(SKIPPABLE_RULES))}, and give a reason after because." + ) + problems.append(Violation(path, number, match.start() + 1, "SKIP", message)) + return skipped, problems + + +def _line_length(info: FileInfo, number: int, line: str) -> list[Violation]: + if info.kind not in ("code", "config") or info.is_generated or is_data_json(info.path): + return [] + width = len(line.expandtabs(TAB_WIDTH)) + if width <= MAX_LINE or URL.search(line): + return [] + message = f"The line is {width} characters wide. Keep lines at {MAX_LINE} or fewer." + return [Violation(info.path, number, MAX_LINE + 1, "TELL-1", message)] + + +def _todo_form(info: FileInfo, number: int, line: str) -> list[Violation]: + if info.kind not in ("code", "config"): + return [] + return [ + Violation( + info.path, + number, + match.start() + 1, + "CS-6", + f"Link an issue in the form {match.group(0).upper()}(#123).", + ) + for match in TODO_FORM.finditer(line) + ] + + +def _characters(info: FileInfo, number: int, line: str) -> list[Violation]: + found = [ + Violation(info.path, number, index + 1, "TELL-14", f"Remove the emoji U+{ord(char):04X}.") + for index, char in enumerate(line) + if is_emoji(char) + ] + if found or info.kind not in ("code", "config"): + return found + for index, char in enumerate(line): + if ord(char) > LAST_ASCII: + message = f"Write U+{ord(char):04X} as an escape sequence. Code and config use ASCII." + return [Violation(info.path, number, index + 1, "TELL-14", message)] + return [] diff --git a/scripts/check_tells/file_kinds.py b/scripts/check_tells/file_kinds.py new file mode 100644 index 0000000..a039333 --- /dev/null +++ b/scripts/check_tells/file_kinds.py @@ -0,0 +1,185 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""The kind and the language of a file, which decide the rules that apply to it.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import PurePosixPath +from typing import Literal + +from check_tells.text import split_lines + +# Code and config files get the strict rules. The other kinds can use any script, so they get +# only the emoji check: prose (Markdown), catalogue (translations), exempt (lockfiles, data and +# images) and other (a file type that no rule names). +Kind = Literal["code", "config", "prose", "catalogue", "exempt", "other"] + +CODE_EXTENSIONS = frozenset( + { + ".ts", + ".tsx", + ".mts", + ".cts", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".php", + ".py", + ".go", + ".kt", + ".kts", + ".java", + ".cs", + ".dart", + ".swift", + ".sql", + ".sh", + ".bash", + ".css", + ".scss", + ".html", + ".astro", + ".vue", + ".svelte", + } +) +CONFIG_EXTENSIONS = frozenset( + { + ".json", + ".yml", + ".yaml", + ".toml", + ".xml", + ".ini", + ".cfg", + ".properties", + ".gradle", + ".neon", + ".json5", + ".jsonc", + } +) +CONFIG_NAMES = frozenset( + {"Makefile", "Dockerfile", ".editorconfig", ".gitignore", ".gitattributes", ".npmrc"} +) +PROSE_EXTENSIONS = frozenset({".md", ".mdx"}) +EXEMPT_EXTENSIONS = frozenset( + {".svg", ".lock", ".csv", ".txt", ".api", ".snap", ".png", ".jpg", ".ico"} +) +# A lockfile with the suffix .lock is exempt through EXEMPT_EXTENSIONS, so this names the others. +LOCKFILES = frozenset( + { + "pnpm-lock.yaml", + "package-lock.json", + "go.sum", + "Package.resolved", + "gradle.lockfile", + "packages.lock.json", + } +) +CATALOGUE_FOLDERS = frozenset({"locales", "i18n", "l10n", "messages"}) +DATA_FOLDERS = frozenset({"data", "vectors"}) +CATALOGUE_EXTENSIONS = frozenset({".arb", ".po", ".xlf", ".xliff", ".strings", ".stringsdict"}) + +LANGUAGE_BY_EXTENSION = { + ".ts": "javascript", + ".tsx": "javascript", + ".mts": "javascript", + ".cts": "javascript", + ".js": "javascript", + ".jsx": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", + ".astro": "javascript", + ".vue": "javascript", + ".svelte": "javascript", + ".php": "php", + ".py": "python", + ".go": "go", + ".kt": "jvm", + ".kts": "jvm", + ".java": "jvm", + ".cs": "dotnet", + ".dart": "dart", + ".swift": "swift", +} +LANGUAGE_BY_INTERPRETER = {"python3": "python", "python": "python", "node": "javascript"} + +GENERATED_MARKERS = ("@generated", "DO NOT EDIT", " FileInfo: + """Decide the kind and the language of one file.""" + repo_path = _without_dist(PurePosixPath(path)) + head = "\n".join(split_lines(text)[:5]) + return FileInfo( + path=path, + kind=_kind(repo_path, text), + language=_language(repo_path, text), + is_test=bool(TEST_PATH.search(path)), + is_generated=any(marker in head for marker in GENERATED_MARKERS), + ) + + +def _without_dist(repo_path: PurePosixPath) -> PurePosixPath: + """Read a .dist file as the file that it copies, such as phpunit.xml.dist as phpunit.xml.""" + return repo_path.with_suffix("") if repo_path.suffix.lower() == ".dist" else repo_path + + +def _kind(repo_path: PurePosixPath, text: str) -> Kind: + suffix = repo_path.suffix.lower() + if repo_path.name in LOCKFILES or suffix in EXEMPT_EXTENSIONS: + return "exempt" + if suffix in CODE_EXTENSIONS or (not suffix and text.startswith("#!")): + return "code" + if _is_catalogue(repo_path): + return "catalogue" + if suffix in PROSE_EXTENSIONS: + return "prose" + if suffix in CONFIG_EXTENSIONS or repo_path.name in CONFIG_NAMES: + return "config" + return "other" + + +def _is_catalogue(repo_path: PurePosixPath) -> bool: + in_folder = any(part in CATALOGUE_FOLDERS for part in repo_path.parts[:-1]) + android = repo_path.name == "strings.xml" and repo_path.parent.name.startswith("values") + return in_folder or android or repo_path.suffix.lower() in CATALOGUE_EXTENSIONS + + +def _language(repo_path: PurePosixPath, text: str) -> str | None: + if repo_path.suffix: + return LANGUAGE_BY_EXTENSION.get(repo_path.suffix.lower()) + first_line = split_lines(text)[0] + if not first_line.startswith("#!"): + return None + interpreter = first_line.split()[-1].rsplit("/", 1)[-1] + return LANGUAGE_BY_INTERPRETER.get(interpreter) + + +def is_data_json(path: str) -> bool: + """Tell whether the path is a JSON file in a data or vectors folder, where TELL-1 is off.""" + repo_path = PurePosixPath(path) + in_folder = any(part in DATA_FOLDERS for part in repo_path.parts[:-1]) + return in_folder and repo_path.suffix.lower() == ".json" diff --git a/scripts/check_tells/message_checks.py b/scripts/check_tells/message_checks.py new file mode 100644 index 0000000..ee30589 --- /dev/null +++ b/scripts/check_tells/message_checks.py @@ -0,0 +1,184 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""The checks of a commit message and of the message that a squash merge puts on main. + +TELL-14 and TELL-18 apply to both. GIT-1, GIT-2 and the text of the pull request template +apply to the squash message. +""" + +from __future__ import annotations + +import re + +from check_tells.text import LAST_ASCII, is_emoji, split_lines +from check_tells.violation import Violation + +NumberedLine = tuple[int, str] + +MAX_SUBJECT = 72 +# In lower case, because git reads the keys of trailer lines without regard to case. +SIGN_OFF_PREFIXES = ("signed-off-by:", "co-authored-by:") +COMMIT_TYPES = ( + "build", + "chore", + "ci", + "docs", + "feat", + "fix", + "perf", + "refactor", + "revert", + "style", + "test", +) +COMMIT_TYPE_LIST = f"{', '.join(COMMIT_TYPES[:-1])} or {COMMIT_TYPES[-1]}" +# A type, a scope that can be missing, a mark for a breaking change that can be missing, a colon, +# a space and text. +SUBJECT_FORM = re.compile(r"(?P[a-z]+)(?P\([^()\s]+\))?!?: \S") +SIGN_OFF = re.compile(r"signed-off-by:[ \t]+[^<>\s][^<>]*<[^<>\s]+@[^<>\s]+>[ \t]*", re.IGNORECASE) +# The address in the sign-off line of templates/PULL_REQUEST_TEMPLATE.md. +PLACEHOLDER_ADDRESS = "you@example.com" +# The start of the paragraph in that template that asks the author for prose. +TEMPLATE_PARAGRAPH = "Write two or three sentences of plain prose" +EMPTY_CLOSES_LINE = re.compile(r"closes #\s*", re.IGNORECASE) +# Renovate adds this comment to the very end of each pull request body. The author did not +# write it, and its payload is base64. Only ASCII white space may follow it, because \s also +# matches characters such as U+3000, and TELL-14 must see those. +RENOVATE_COMMENT = re.compile(r"\n?[ \t\r\n]*\Z") +SQUASH_NOTE = " The subject is the title plus the ' (#N)' that the squash merge adds." + + +def _message_lines(text: str) -> list[NumberedLine]: + """Pair each line with its number. Drop git's comment lines, and stop at its scissors line.""" + numbered: list[NumberedLine] = [] + for number, line in enumerate(split_lines(text), start=1): + if line.startswith("# ") and ">8" in line: + break + if not line.startswith("#"): + numbered.append((number, line)) + return numbered + + +def _subject(lines: list[NumberedLine]) -> NumberedLine | None: + """Find the subject, which is the first line that has text.""" + return next(((number, line) for number, line in lines if line.strip()), None) + + +def _check_subject(lines: list[NumberedLine], note: str = "") -> list[Violation]: + """Check the length of the subject. The note adds advice that fits the message.""" + subject = _subject(lines) + if subject is None or len(subject[1]) <= MAX_SUBJECT: + return [] + number, text = subject + message = f"The subject has {len(text)} characters. Use {MAX_SUBJECT} or fewer.{note}" + return [Violation("commit message", number, MAX_SUBJECT + 1, "TELL-18", message)] + + +def _first_bad_character(number: int, line: str) -> Violation | None: + """Find the first emoji, or the first non-ASCII character outside a sign-off line.""" + allow_non_ascii = line.lower().startswith(SIGN_OFF_PREFIXES) + for index, char in enumerate(line): + if is_emoji(char) or (ord(char) > LAST_ASCII and not allow_non_ascii): + message = f"Remove U+{ord(char):04X}. Commit messages use ASCII." + return Violation("commit message", number, index + 1, "TELL-14", message) + return None + + +def _check_message(lines: list[NumberedLine], note: str = "") -> list[Violation]: + violations = _check_subject(lines, note) + for number, line in lines: + violation = _first_bad_character(number, line) + if violation is not None: + violations.append(violation) + return violations + + +def _check_subject_form(lines: list[NumberedLine], *, no_scope: bool) -> list[Violation]: + """Check GIT-1: the subject has a type that Conventional Commits lists, and maybe a scope.""" + subject = _subject(lines) + if subject is None: + return [Violation("commit message", 1, 1, "GIT-1", "The message has no subject.")] + number, text = subject + match = SUBJECT_FORM.match(text) + if match is None: + message = ( + "The subject does not follow Conventional Commits. " + f"Write 'type: description', where type is {COMMIT_TYPE_LIST}." + ) + return [Violation("commit message", number, 1, "GIT-1", message)] + if match["type"] not in COMMIT_TYPES: + message = f"'{match['type']}' is not a Conventional Commits type. Use {COMMIT_TYPE_LIST}." + return [Violation("commit message", number, 1, "GIT-1", message)] + if no_scope and match["scope"]: + message = ( + f"Remove the scope {match['scope']}. " + "This repo has no packages, so a subject has no scope." + ) + column = match.start("scope") + 1 + return [Violation("commit message", number, column, "GIT-1", message)] + return [] + + +def check_sign_off(lines: list[NumberedLine], path: str) -> list[Violation]: + """Check GIT-2: the message has a line 'Signed-off-by: Name
', not the template's.""" + sign_offs = [(number, line) for number, line in lines if SIGN_OFF.fullmatch(line)] + if not sign_offs: + message = ( + "The message has no Signed-off-by line. Add the line 'Signed-off-by: Name
'. " + "git commit --signoff adds it to a commit, and git rebase --signoff adds it to each " + "commit of a branch." + ) + return [Violation(path, 1, 1, "GIT-2", message)] + message = ( + f"The sign-off still holds the placeholder address {PLACEHOLDER_ADDRESS}. " + "Write your own name and address." + ) + return [ + Violation(path, number, 1, "GIT-2", message) + for number, line in sign_offs + if PLACEHOLDER_ADDRESS in line.lower() + ] + + +def _check_template_text(lines: list[NumberedLine]) -> list[Violation]: + """Find the text of the pull request template that the author did not replace.""" + violations: list[Violation] = [] + for number, line in lines: + text = line.strip() + if text.startswith(TEMPLATE_PARAGRAPH): + message = ( + "The body still holds the placeholder paragraph of the pull request template. " + "Replace it with two or three sentences that say what the change does and why." + ) + elif EMPTY_CLOSES_LINE.fullmatch(text): + message = ( + "The body still holds the line 'Closes #'. Add the issue number after it. " + "If the change closes no issue, remove the line." + ) + else: + continue + violations.append(Violation("commit message", number, 1, "TELL-18", message)) + return violations + + +def check_commit_message(text: str) -> list[Violation]: + """Check the subject length and the characters of a commit message.""" + return _check_message(_message_lines(text)) + + +def check_squash_message(text: str, *, no_scope: bool = False) -> list[Violation]: + """Check the message that a squash merge puts on main: the pull request title and body. + + The body is not a git message, so a line that starts with # is text and not a comment. GitHub + keeps the raw body, so the text of an HTML comment reaches main like any other text. The + check leaves out only the comment that Renovate adds at the very end of its body. + """ + subject, line_feed, body = text.partition("\n") + checked = subject + line_feed + RENOVATE_COMMENT.sub("", body) + lines = list(enumerate(split_lines(checked), start=1)) + return [ + *_check_message(lines, SQUASH_NOTE), + *_check_subject_form(lines, no_scope=no_scope), + *check_sign_off(lines, "commit message"), + *_check_template_text(lines), + ] diff --git a/scripts/check_tells/scan.py b/scripts/check_tells/scan.py new file mode 100644 index 0000000..d9753a4 --- /dev/null +++ b/scripts/check_tells/scan.py @@ -0,0 +1,121 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""Read the input of a run and check it: the files of a repo, a message file or a range of commits. + +Input that cannot be read raises InputError. +""" + +from __future__ import annotations + +import subprocess +from collections.abc import Callable +from pathlib import Path + +from check_tells.file_checks import check_text +from check_tells.file_kinds import classify +from check_tells.message_checks import check_sign_off +from check_tells.text import split_lines +from check_tells.violation import Violation + + +class InputError(Exception): + """The command line names something that check-tells cannot read.""" + + +def _git(root: Path, *arguments: str) -> bytes: + """Run git in root and give its output. A failure is bad input, such as no repo.""" + try: + run = subprocess.run(["git", "-C", str(root), *arguments], check=True, capture_output=True) + except subprocess.CalledProcessError as error: + reason = " ".join(error.stderr.decode(errors="replace").split()) + raise InputError(f"Git failed in {root}: {reason}") from error + return run.stdout + + +def tracked_files(root: Path) -> list[Path]: + """List the files that git tracks, or would track, under root.""" + output = _git(root, "ls-files", "-z", "--cached", "--others", "--exclude-standard") + names = output.decode().split("\0") + return [root / name for name in names if name and (root / name).is_file()] + + +def _commits(root: Path, revision_range: str) -> list[tuple[str, str]]: + """Give the hash and the message of each commit in a range. Merge commits are left out.""" + output = _git( + root, + "log", + "--no-merges", + "--no-show-signature", + "--format=%H%n%B%x00", + "--end-of-options", + revision_range, + ) + records = [record.lstrip("\n") for record in output.decode(errors="replace").split("\0")] + pairs = (record.partition("\n") for record in records if record) + return [(commit, message) for commit, _, message in pairs] + + +def check_signed_off(root: Path, revision_range: str) -> list[Violation]: + """Check that each commit in a revision range has a sign-off. Merge commits are left out.""" + violations: list[Violation] = [] + for commit, message in _commits(root, revision_range): + lines = list(enumerate(split_lines(message), start=1)) + violations.extend(check_sign_off(lines, f"commit {commit[:12]}")) + return violations + + +def _read(path: Path) -> bytes: + try: + return path.read_bytes() + except OSError as error: + raise InputError(f"Cannot read {path}: {error.strerror}.") from error + + +def _not_utf8(path: str, data: bytes, error: UnicodeDecodeError, advice: str) -> Violation: + """Point at the first byte that is not UTF-8. All the bytes before it are valid.""" + line_start = data.rfind(b"\n", 0, error.start) + 1 + line = data.count(b"\n", 0, error.start) + 1 + column = len(data[line_start : error.start].decode("utf-8")) + 1 + message = f"Byte 0x{data[error.start]:02X} is not valid UTF-8. {advice}" + return Violation(path, line, column, "TELL-14", message) + + +def _check_undecodable(path: str, data: bytes, error: UnicodeDecodeError) -> list[Violation]: + """Report a code or config file that is not UTF-8. Other files can be binary, so skip them.""" + # Only the suffix and the first bytes decide the kind, and bad bytes would stop the decoder. + kind = classify(path, data[:512].decode("utf-8", errors="replace")).kind + if kind not in ("code", "config"): + return [] + advice = "Save the file as UTF-8, and use ASCII in code and config." + return [_not_utf8(path, data, error, advice)] + + +def check_files(root: Path, files: list[Path]) -> list[Violation]: + """Check each file. Report a code or config file that is not UTF-8, and skip other files.""" + violations: list[Violation] = [] + for file in files: + if not file.is_relative_to(root): + raise InputError(f"{file} is outside the root {root}.") + path = file.relative_to(root).as_posix() + data = _read(file) + try: + text = data.decode("utf-8") + except UnicodeDecodeError as error: + violations.extend(_check_undecodable(path, data, error)) + else: + violations.extend(check_text(path, text)) + return violations + + +def check_message_file(path: Path, check: Callable[[str], list[Violation]]) -> list[Violation]: + """Read a message file and run a check on its text. + + A file that is not UTF-8 gives a TELL-14 violation at the first bad byte. + """ + data = _read(path) + try: + text = data.decode("utf-8") + except UnicodeDecodeError as error: + advice = "Save the message as UTF-8, and use ASCII." + return [_not_utf8("commit message", data, error, advice)] + return check(text) diff --git a/scripts/check_tells/text.py b/scripts/check_tells/text.py new file mode 100644 index 0000000..bd828b2 --- /dev/null +++ b/scripts/check_tells/text.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""How the checks read text: its lines, and the characters that TELL-14 rejects.""" + +from __future__ import annotations + +LAST_ASCII = 0x7F +EMOJI_RANGES = ( + (0x1F000, 0x1FAFF), + (0x231A, 0x231B), + (0x23E9, 0x23F3), + (0x2600, 0x27BF), + (0x2B00, 0x2BFF), + (0xFE0F, 0xFE0F), +) + + +def split_lines(text: str) -> list[str]: + """Split text at line feeds only, and remove one carriage return from each line. + + str.splitlines also splits at U+2028, U+2029, U+0085 and the form feed. That hides the + characters that TELL-14 looks for, and it moves the line numbers. + """ + return [line.removesuffix("\r") for line in text.split("\n")] + + +def is_emoji(character: str) -> bool: + """Tell whether the character is in an emoji range. The ranges include U+FE0F.""" + return any(low <= ord(character) <= high for low, high in EMOJI_RANGES) diff --git a/scripts/check_tells/violation.py b/scripts/check_tells/violation.py new file mode 100644 index 0000000..3cfe59d --- /dev/null +++ b/scripts/check_tells/violation.py @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""The result of a check: one broken rule at one place in a file or a message.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, order=True) +class Violation: + """One broken rule at one place in a file.""" + + path: str + line: int + column: int + rule: str + message: str + + def __str__(self) -> str: + """Format the violation like a compiler message.""" + return f"{self.path}:{self.line}:{self.column}: {self.rule} {self.message}" diff --git a/scripts/reference.py b/scripts/reference.py new file mode 100644 index 0000000..05dab5e --- /dev/null +++ b/scripts/reference.py @@ -0,0 +1,328 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""A plain reading of grammar.md, with every value taken from data/. + +The builder holds its expected results as typed literals, so nothing else ties the vectors to +the grammar. This module is a second reading. It does not import the builder, and it keeps no +number, code or table that data/ holds. The tests run every vector through it, so a vector or a +data file that contradicts grammar.md fails there. + +No vector can carry text that is not well-formed, so this module leaves out the rules for such +text (grammar.md, Parse). +""" + +from __future__ import annotations + +import json +import math +import re +import string +import unicodedata +from collections.abc import Callable, Mapping +from dataclasses import dataclass, replace +from itertools import accumulate, pairwise, zip_longest +from pathlib import Path +from typing import Any + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" + +LETTERS = frozenset(string.ascii_uppercase) +DIGITS = frozenset(string.digits) +# The words that data/format.json uses for the characters of a segment. +CHARACTER_SETS = { + "letters": LETTERS, + "digits": DIGITS, + "letters-or-digits": LETTERS | DIGITS, +} +# str.upper() would also change letters such as U+0131, which grammar.md keeps. +ASCII_UPPER_CASE = str.maketrans(string.ascii_lowercase, string.ascii_uppercase) +# Only these two codes name a segment and offer a suggestion. +CODES_WITH_SUGGESTION = frozenset({"unknown_state", "bad_segment"}) +UNIT_MASK = "**" + +# The segments of an accepted postcode, from the state down to its precision. +Postcode = tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class SegmentRule: + """The rule for one segment, as data/format.json gives it. A minimum applies to digits.""" + + name: str + length: int + characters: str + minimum: int | None + + +@dataclass(frozen=True, slots=True) +class Failure: + """A parse error. The segment and the suggestion are None where grammar.md says null.""" + + code: str + segment: str | None = None + suggestion: str | None = None + + +@dataclass(frozen=True, slots=True) +class Grammar: + """The rules of grammar.md, filled with the values of data/.""" + + input_limit: int + segments: tuple[SegmentRule, ...] + legacy: re.Pattern[str] + separators: frozenset[str] + fixes: Mapping[str, Mapping[str, str]] + states: Mapping[str, str] + limits: tuple[tuple[float, str], ...] + fallback: str + + def normalize(self, text: str) -> str: + """Apply NFKC, remove the separators, then make the ASCII letters upper case.""" + folded = unicodedata.normalize("NFKC", text) + kept = "".join(character for character in folded if character not in self.separators) + return kept.translate(ASCII_UPPER_CASE) + + def parse(self, text: str, *, allow_partial: bool = False) -> Postcode | Failure: + """Read text as a postcode. Over the input limit, give bad_length before normalising.""" + if len(text) > self.input_limit: + return Failure("bad_length") + compact = self.normalize(text) + outcome = self._read_compact(compact, allow_partial) + if isinstance(outcome, Failure) and outcome.code in CODES_WITH_SUGGESTION: + return replace(outcome, suggestion=self._suggest(compact, allow_partial)) + return outcome + + def is_legacy(self, text: str) -> bool: + """Tell whether the text is an old postcode. Over the input limit, the answer is no.""" + if len(text) > self.input_limit: + return False + # fullmatch, because a pattern that ends in $ also matches before a final line feed. + return self.legacy.fullmatch(self.normalize(text)) is not None + + def precision(self, code: Postcode) -> str: + """Name the last segment of the code.""" + return self.segments[len(code) - 1].name + + def truncate(self, code: Postcode, to: str) -> Postcode: + """Keep the segments up to a precision. A precision that the code lacks is an error.""" + ranks = {rule.name: rank for rank, rule in enumerate(self.segments, start=1)} + if to not in ranks or ranks[to] > len(code): + raise ValueError(f"The code {'-'.join(code)} has no {to!r} precision.") + return code[: ranks[to]] + + def parent(self, code: Postcode) -> Postcode | None: + """Drop the last segment. A code with only the state has no parent.""" + return code[:-1] if len(code) > 1 else None + + def contains(self, prefix: Postcode, code: Postcode) -> bool: + """Tell whether the code has every segment of the prefix, in the same places.""" + return code[: len(prefix)] == prefix + + def redact(self, code: Postcode) -> str: + """Write the canonical form with the unit hidden. A code without a unit is unchanged.""" + if len(code) == len(self.segments): + code = (*code[:-1], UNIT_MASK) + return "-".join(code) + + def state_name(self, code: str) -> str | None: + """Look up a state code. Only the ASCII letters a to z change case, and NFKC is not used.""" + return self.states.get(code.translate(ASCII_UPPER_CASE)) + + def precision_for_accuracy(self, metres: float | None) -> str: + """Give the most precise segment that a GPS fix of this accuracy supports.""" + # -0.0 is not below 0, so it counts as 0 here. Testing the sign bit would give lga. + if metres is None or not math.isfinite(metres) or metres < 0: + return self.fallback + for limit, precision in self.limits: + if metres <= limit: + return precision + return self.fallback + + def _lengths(self, allow_partial: bool) -> frozenset[int]: + """Give the lengths that pass: the full length, and each segment end for a partial code.""" + ends = list(accumulate(rule.length for rule in self.segments)) + return frozenset(ends if allow_partial else ends[-1:]) + + def _split(self, compact: str) -> Postcode: + """Cut text of a valid length into its segments.""" + bounds = [0, *accumulate(rule.length for rule in self.segments)] + return tuple(compact[start:end] for start, end in pairwise(bounds) if start < len(compact)) + + def _read_compact(self, compact: str, allow_partial: bool) -> Postcode | Failure: + """Run the checks of grammar.md on normalised text. Offer no suggestion.""" + failure = self._reject_text(compact, allow_partial) + if failure is not None: + return failure + segments = self._split(compact) + failure = self._reject_segments(segments) + return segments if failure is None else failure + + def _reject_text(self, compact: str, allow_partial: bool) -> Failure | None: + """Run the checks on the whole text: empty, legacy_code, bad_character and bad_length.""" + if not compact: + return Failure("empty") + if self.legacy.fullmatch(compact): + return Failure("legacy_code") + if not set(compact) <= LETTERS | DIGITS: + return Failure("bad_character") + if len(compact) not in self._lengths(allow_partial): + return Failure("bad_length") + return None + + def _reject_segments(self, segments: Postcode) -> Failure | None: + """Run the checks on the segments, in order: unknown_state, then bad_segment.""" + if segments[0] not in self.states: + return Failure("unknown_state", self.segments[0].name) + # A partial code has fewer segments than rules, so zip stops at the last segment. + for rule, segment in zip(self.segments, segments, strict=False): + if not _fits(rule, segment): + return Failure("bad_segment", rule.name) + return None + + def _suggest(self, compact: str, allow_partial: bool) -> str | None: + """Parse the code with its look-alike characters fixed. Give its canonical form.""" + fixed = self._fix(compact) + # An unchanged code fails the same way, so it has no suggestion. + if fixed == compact: + return None + # The fixed code has only A to Z and 0 to 9, so it needs no normalising. + outcome = self._read_compact(fixed, allow_partial) + return None if isinstance(outcome, Failure) else "-".join(outcome) + + def _fix(self, compact: str) -> str: + """Change the characters that data/format.json lists, in the kinds of segment it names.""" + fixed: list[str] = [] + # A kind with no table, such as the district, keeps its characters. + for rule, segment in zip(self.segments, self._split(compact), strict=False): + table = self.fixes.get(rule.characters, {}) + fixed.append("".join(table.get(character, character) for character in segment)) + return "".join(fixed) + + +def _fits(rule: SegmentRule, segment: str) -> bool: + """Tell whether a segment has the characters of its rule and reaches its minimum.""" + allowed = set(segment) <= CHARACTER_SETS[rule.characters] + return allowed and (rule.minimum is None or int(segment) >= rule.minimum) + + +def load(data_dir: Path = DATA_DIR) -> Grammar: + """Read format.json, states.json and precision.json from a folder.""" + layout = _read_json(data_dir / "format.json") + states = _read_json(data_dir / "states.json")["states"] + precision = _read_json(data_dir / "precision.json") + return Grammar( + input_limit=layout["maxInputCodePoints"], + segments=tuple( + SegmentRule( + segment["name"], segment["length"], segment["characters"], segment.get("minimum") + ) + for segment in layout["segments"] + ), + legacy=re.compile(layout["legacyPattern"]), + separators=frozenset( + chr(int(label.removeprefix("U+"), 16)) for label in layout["separators"] + ), + fixes=layout["suggestions"], + states={state["code"]: state["name"] for state in states}, + limits=tuple((row["maxAccuracyM"], row["precision"]) for row in precision["thresholds"]), + fallback=precision["fallback"], + ) + + +def _read_json(path: Path) -> dict[str, Any]: + document: dict[str, Any] = json.loads(path.read_text(encoding="utf-8")) + return document + + +Case = Mapping[str, Any] +Expected = dict[str, Any] +Reader = Callable[[Grammar, Case], Expected] + + +def _postcode(grammar: Grammar, canonical: str) -> Postcode: + """Read an input of a hierarchy vector. The runner rules say to parse it with allowPartial.""" + outcome = grammar.parse(canonical, allow_partial=True) + if isinstance(outcome, Failure): + raise ValueError( + f"The vector input {canonical!r} does not parse. Check the vector and data/." + ) + return outcome + + +def _success(grammar: Grammar, code: Postcode) -> Expected: + """Write an accepted code the way a vector does.""" + names = [rule.name for rule in grammar.segments] + return { + "ok": True, + "compact": "".join(code), + "canonical": "-".join(code), + "display": " ".join(code), + "precision": grammar.precision(code), + # zip_longest gives null for each segment after the precision. + "segments": dict(zip_longest(names, code)), + } + + +def _read_normalize(grammar: Grammar, case: Case) -> Expected: + return {"value": grammar.normalize(case["input"])} + + +def _read_parse(grammar: Grammar, case: Case) -> Expected: + outcome = grammar.parse(case["input"], allow_partial=bool(case["options"].get("allowPartial"))) + if isinstance(outcome, Failure): + error = {"code": outcome.code, "segment": outcome.segment, "suggestion": outcome.suggestion} + return {"ok": False, "error": error} + return _success(grammar, outcome) + + +def _read_is_legacy(grammar: Grammar, case: Case) -> Expected: + return {"value": grammar.is_legacy(case["input"])} + + +def _read_truncate(grammar: Grammar, case: Case) -> Expected: + code = _postcode(grammar, case["input"]["code"]) + try: + shorter = grammar.truncate(code, case["input"]["to"]) + except ValueError: + return {"rejects": True} + return {"canonical": "-".join(shorter)} + + +def _read_parent(grammar: Grammar, case: Case) -> Expected: + above = grammar.parent(_postcode(grammar, case["input"]["code"])) + return {"canonical": None if above is None else "-".join(above)} + + +def _read_contains(grammar: Grammar, case: Case) -> Expected: + prefix = _postcode(grammar, case["input"]["prefix"]) + return {"value": grammar.contains(prefix, _postcode(grammar, case["input"]["code"]))} + + +def _read_redact(grammar: Grammar, case: Case) -> Expected: + return {"value": grammar.redact(_postcode(grammar, case["input"]["code"]))} + + +def _read_state_name(grammar: Grammar, case: Case) -> Expected: + return {"value": grammar.state_name(case["input"])} + + +def _read_precision_for_accuracy(grammar: Grammar, case: Case) -> Expected: + metres = case["input"] + # The runner rules turn the strings NaN, Infinity and -Infinity into numbers. + if isinstance(metres, str): + metres = float(metres) + return {"value": grammar.precision_for_accuracy(metres)} + + +# One reader for each function that a vector file tests, by the name in its function field. +READERS: dict[str, Reader] = { + "normalize": _read_normalize, + "parse": _read_parse, + "isLegacy": _read_is_legacy, + "truncate": _read_truncate, + "parent": _read_parent, + "contains": _read_contains, + "redact": _read_redact, + "stateName": _read_state_name, + "precisionForAccuracy": _read_precision_for_accuracy, +} diff --git a/scripts/tests/__init__.py b/scripts/tests/__init__.py new file mode 100644 index 0000000..7cb53b7 --- /dev/null +++ b/scripts/tests/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 diff --git a/scripts/tests/check_tells_driver.py b/scripts/tests/check_tells_driver.py new file mode 100644 index 0000000..9d98f22 --- /dev/null +++ b/scripts/tests/check_tells_driver.py @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""Run check_tells in a test: make git repos, run the command and read the rules.""" + +import contextlib +import io +import os +import subprocess +import tempfile +from collections.abc import Iterator +from pathlib import Path + +import check_tells +from check_tells import Violation + +# A host with a signing key in its git config must not sign the commits of these tests. +GIT_ENVIRONMENT = { + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_SYSTEM": os.devnull, + "GIT_AUTHOR_NAME": "Ada Bello", + "GIT_AUTHOR_EMAIL": "ada@example.org", + "GIT_COMMITTER_NAME": "Ada Bello", + "GIT_COMMITTER_EMAIL": "ada@example.org", +} + + +def rules(violations: list[Violation]) -> list[str]: + return [violation.rule for violation in violations] + + +@contextlib.contextmanager +def git_repo() -> Iterator[Path]: + """Make an empty git repo in a temporary folder and give its resolved path.""" + with tempfile.TemporaryDirectory() as folder: + root = Path(folder).resolve() + subprocess.run(["git", "init", "-q", str(root)], check=True) + yield root + + +def git(root: Path, *args: str) -> str: + """Run git in a test repo and give its standard output.""" + run = subprocess.run( + ["git", "-C", str(root), *args], + check=True, + capture_output=True, + text=True, + env={**os.environ, **GIT_ENVIRONMENT}, + ) + return run.stdout.strip() + + +def commit(root: Path, message: str) -> str: + """Make an empty commit and give its hash.""" + git(root, "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-q", "-m", message) + return git(root, "rev-parse", "HEAD") + + +def run_main(*args: str) -> tuple[int, str, str]: + """Run the command line and give the exit code, the standard output and the error output.""" + output, errors = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors): + status = check_tells.main(list(args)) + return status, output.getvalue(), errors.getvalue() diff --git a/scripts/tests/check_tells_samples.py b/scripts/tests/check_tells_samples.py new file mode 100644 index 0000000..c29a2e2 --- /dev/null +++ b/scripts/tests/check_tells_samples.py @@ -0,0 +1,132 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""Sample text, file names and messages that the check_tells tests share.""" + +import string + +EMOJI = chr(0x1F600) +NO_BREAK_SPACE = chr(0x00A0) +E_ACUTE = chr(0x00E9) +EM_DASH = chr(0x2014) +LINE_SEPARATOR = chr(0x2028) +O_DOT_BELOW = chr(0x1ECD) +LONG_LINE = "x" * 150 +# Built from two parts, so that this file passes its own to-do check. +TO_DO = "TO" + "DO" +FIX_ME = "FIX" + "ME" +# Built from two parts, so that this file passes its own skip check. +SKIP_MARKER = "check-tells: " + "allow" + +KIND_BY_PATH = { + "code": [ + "a.ts", + "a.tsx", + "a.mts", + "a.cts", + "a.js", + "a.jsx", + "a.mjs", + "a.cjs", + "a.php", + "a.py", + "a.go", + "a.kt", + "a.kts", + "a.java", + "a.cs", + "a.dart", + "a.swift", + "a.sql", + "a.sh", + "a.bash", + "a.css", + "a.scss", + "a.html", + "a.astro", + "a.vue", + "a.svelte", + "A.TS", + "config.php.dist", + ], + "config": [ + "a.json", + "a.yml", + "a.yaml", + "a.toml", + "a.xml", + "a.ini", + "a.cfg", + "a.properties", + "a.gradle", + "a.neon", + "a.json5", + "a.jsonc", + "A.JSON", + "phpunit.xml.dist", + "phpstan.neon.dist", + "phpstan.dist.neon", + "Makefile", + "Dockerfile", + ".editorconfig", + ".gitignore", + ".gitattributes", + ".npmrc", + ], + "prose": ["a.md", "a.mdx", "A.MD"], + "catalogue": [ + "a.arb", + "a.po", + "a.xlf", + "a.xliff", + "a.strings", + "a.stringsdict", + "src/locales/en.json", + "src/i18n/en.json", + "src/l10n/en.json", + "src/messages/en.json", + "src/locales/yo/home.json", + "res/values/strings.xml", + "res/values-yo/strings.xml", + ], + "exempt": [ + "pnpm-lock.yaml", + "package-lock.json", + "yarn.lock", + "composer.lock", + "go.sum", + "uv.lock", + "poetry.lock", + "Cargo.lock", + "Package.resolved", + "pubspec.lock", + "gradle.lockfile", + "packages.lock.json", + "a.svg", + "a.lock", + "a.csv", + "a.txt", + "a.api", + "a.snap", + "a.png", + "a.jpg", + "a.ico", + "src/locales/yo.txt", + ], + "other": ["LICENSE", "CODEOWNERS", ".gitmodules", "a.woff", "res/layout/strings.xml.bak"], +} + +SIGN_OFF_LINE = "Signed-off-by: Ada Bello " +PLACEHOLDER_PARAGRAPH = ( + "Write two or three sentences of plain prose that say what this change does and why." +) +# The comment that Renovate adds at the very end of the body of its pull requests. The payload +# holds each character of base64. +BASE64 = string.ascii_letters + string.digits + "+/" +RENOVATE_COMMENT = f"" + + +def squash( + subject: str, body: str = "Why the change is needed.", sign_off: str = SIGN_OFF_LINE +) -> str: + """Build a squash merge message from a title with its suffix, a body and a sign-off.""" + return f"{subject}\n\n{body}\n\n{sign_off}\n" diff --git a/scripts/tests/test_build_vectors.py b/scripts/tests/test_build_vectors.py new file mode 100644 index 0000000..f56f72e --- /dev/null +++ b/scripts/tests/test_build_vectors.py @@ -0,0 +1,441 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for build_vectors.""" + +import contextlib +import io +import json +import math +import shutil +import string +import sys +import tempfile +import unicodedata +import unittest +from collections import Counter +from pathlib import Path +from typing import Any +from unittest import mock + +import build_vectors + +ROOT = Path(__file__).resolve().parents[2] +ZERO_WIDTH_JOINER = chr(0x200D) +ASCII_UPPER_CASE = str.maketrans(string.ascii_lowercase, string.ascii_uppercase) + + +def load(stem: str) -> dict[str, Any]: + value: object = json.loads((ROOT / "vectors" / f"{stem}.json").read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise TypeError(f"{stem}.json must hold a JSON object.") + return value + + +def input_strings(value: Any) -> list[str]: + """Return each string in a vector input: the input itself, or the strings in an object.""" + if isinstance(value, str): + return [value] + if isinstance(value, dict): + return [text for item in value.values() for text in input_strings(item)] + return [] + + +def utf16_units(text: str) -> int: + return len(text.encode("utf-16-le", errors="surrogatepass")) // 2 + + +def separator_characters() -> frozenset[str]: + return frozenset(chr(code_point) for code_point in build_vectors.load_separators()) + + +def holds_only_ascii_and_separators(text: str, separators: frozenset[str]) -> bool: + return all(character.isascii() or character in separators for character in text) + + +def table_form(character: str, separators: frozenset[str]) -> str: + """Return the NFKC form of a character if the form holds only ASCII characters and separators. + + An SDK that applies NFKC through a table has entries for such characters only. It keeps + every other character as it is. + """ + form = unicodedata.normalize("NFKC", character) + if holds_only_ascii_and_separators(form, separators): + return form + return character + + +def remove_separators_and_upper_case(text: str, separators: frozenset[str]) -> str: + """Apply steps 2 and 3 of the Normalise section in grammar.md.""" + kept = "".join(character for character in text if character not in separators) + return kept.translate(ASCII_UPPER_CASE) + + +def full_normalize(text: str, separators: frozenset[str]) -> str: + """Normalise as grammar.md says, with a full NFKC.""" + return remove_separators_and_upper_case(unicodedata.normalize("NFKC", text), separators) + + +def reduced_normalize(text: str, separators: frozenset[str]) -> str: + """Normalise as an SDK without a full NFKC does (grammar.md, section Normalise).""" + mapped = "".join(table_form(character, separators) for character in text) + return remove_separators_and_upper_case(mapped, separators) + + +class BuildVectorsTest(unittest.TestCase): + def assert_same_text(self, actual: str, expected: str) -> None: + # ASCII forms keep a failure readable: U+00B5 and U+03BC look alike. + self.assertEqual(ascii(actual), ascii(expected)) + + def test_vector_files_match_the_builder(self) -> None: + self.assertEqual(build_vectors.main(["--check"]), 0) + + def test_vector_files_are_ascii(self) -> None: + for path in sorted((ROOT / "vectors").glob("*.json")): + with self.subTest(file=path.name): + self.assertTrue(path.read_text(encoding="utf-8").isascii()) + + def test_each_case_has_a_unique_id_and_a_description(self) -> None: + ids: list[str] = [] + for stem in build_vectors.FILES: + for case in load(stem)["cases"]: + ids.append(case["id"]) + self.assertTrue(case["description"]) + self.assertEqual(len(ids), len(set(ids))) + self.assertGreaterEqual(len(ids), 200) + + def test_each_input_character_is_assigned_in_the_running_unicode_version(self) -> None: + # NFKC differs between Unicode versions only for a character that a later version added. + # CI runs the oldest Python that the scripts support, so it rejects a newer character. + unassigned = sorted( + { + f"U+{ord(character):04X}" + for stem in build_vectors.FILES + for case in load(stem)["cases"] + for text in input_strings(case["input"]) + for character in text + if unicodedata.category(character) == "Cn" + } + ) + self.assertEqual(unassigned, []) + + def test_each_description_is_unique_across_all_vector_files(self) -> None: + counts = Counter( + case["description"] for stem in build_vectors.FILES for case in load(stem)["cases"] + ) + repeated = sorted(text for text, count in counts.items() if count > 1) + self.assertEqual(repeated, []) + + def test_each_state_has_a_parse_case_and_a_name_case(self) -> None: + states = json.loads((ROOT / "data" / "states.json").read_text(encoding="utf-8")) + codes = {state["code"] for state in states["states"]} + parsed = {case["input"][:2] for case in load("parse-states")["cases"]} + named = {case["input"] for case in load("state-name")["cases"]} + self.assertEqual(parsed, codes) + self.assertLessEqual(codes, named) + + def test_each_unused_iso_code_has_a_state_name_case_and_a_parse_case(self) -> None: + states = json.loads((ROOT / "data" / "states.json").read_text(encoding="utf-8"))["states"] + iso_codes = [ + state["iso"].removeprefix("NG-") + for state in states + if state["iso"] != "NG-" + state["code"] + ] + self.assertTrue(iso_codes) + unknown_state = { + "ok": False, + "error": {"code": "unknown_state", "segment": "state", "suggestion": None}, + } + nameless = { + case["input"] for case in load("state-name")["cases"] if case["expect"]["value"] is None + } + rejected = { + case["input"][:2] + for case in load("parse-segments")["cases"] + if case["expect"] == unknown_state and not case["options"] + } + missing: list[str] = [] + for iso_code in iso_codes: + if iso_code not in nameless: + missing.append(f"state-name: {iso_code}") + if iso_code not in rejected: + missing.append(f"parse-segments: {iso_code}") + self.assertEqual(missing, []) + + def test_each_separator_has_a_normalize_case(self) -> None: + postcode_format = json.loads((ROOT / "data" / "format.json").read_text(encoding="utf-8")) + expected = {"value": "EK01A03FK01"} + inputs = { + case["input"] for case in load("normalize")["cases"] if case["expect"] == expected + } + missing: list[str] = [] + for label in postcode_format["separators"]: + separator = chr(int(label.removeprefix("U+"), 16)) + if separator.join(("EK", "01", "A03", "FK", "01")) not in inputs: + missing.append(label) + self.assertEqual(missing, []) + + def test_each_separator_has_an_nfkc_form_of_ascii_characters_and_separators(self) -> None: + separators = separator_characters() + # Otherwise full NFKC leaves a character where a table removes the separator. + outside = [ + f"U+{ord(separator):04X}" + for separator in sorted(separators) + if not holds_only_ascii_and_separators( + unicodedata.normalize("NFKC", separator), separators + ) + ] + self.assertEqual(outside, []) + + def test_each_character_that_nfkc_changes_to_a_non_ascii_separator_has_a_normalize_case( + self, + ) -> None: + separators = separator_characters() + inputs = {character for case in load("normalize")["cases"] for character in case["input"]} + sources = { + character + for character in map(chr, range(sys.maxunicode + 1)) + if character not in separators + and any( + not form.isascii() and form in separators + for form in unicodedata.normalize("NFKC", character) + ) + } + # A table of ASCII forms only keeps these characters. Each separator has its own case. + self.assertTrue(sources) + self.assertEqual([f"U+{ord(c):04X}" for c in sorted(sources - inputs)], []) + + def test_normalize_has_cases_for_letters_and_digits_above_u_ffff(self) -> None: + astral = { + character + for case in load("normalize")["cases"] + for character in case["input"] + if ord(character) > 0xFFFF + } + forms = [unicodedata.normalize("NFKC", character) for character in sorted(astral)] + # A table that skips each 4-byte entry passes every other normalize case. + with self.subTest("a letter"): + self.assertTrue(any(form.isascii() and form.isalpha() for form in forms)) + with self.subTest("a digit"): + self.assertTrue(any(form.isascii() and form.isdigit() for form in forms)) + + def test_parse_segments_rejects_a_digit_before_a_letter_in_the_area(self) -> None: + rejected = [ + case + for case in load("parse-segments")["cases"] + if len(case["input"]) == 11 + and case["input"][7] in string.digits + and case["input"][8] in string.ascii_letters + and case["expect"].get("error", {}).get("segment") == "area" + ] + # A letter check that is anchored only at the end accepts such an area. + self.assertTrue(rejected) + + def test_parse_segments_rejects_a_letter_after_a_digit_in_the_lga_and_the_unit(self) -> None: + # A digit check that has no end anchor reads the first digit and accepts such a segment. + # A digit of 0 fails the minimum alone, so the digit here is 1 to 9. + for segment, start in (("lga", 2), ("unit", 9)): + with self.subTest(segment=segment): + rejected = [ + case + for case in load("parse-segments")["cases"] + if len(case["input"]) == 11 + and case["input"][start] in string.digits[1:] + and case["input"][start + 1] in string.ascii_letters + and case["expect"].get("error", {}).get("code") == "bad_segment" + and case["expect"]["error"]["segment"] == segment + ] + self.assertTrue(rejected) + + def test_precision_for_accuracy_has_a_case_for_negative_zero(self) -> None: + # The JSON literal -0 loads as the integer 0, so only -0.0 keeps the sign. + negative_zero = [ + case["expect"] + for case in load("precision-for-accuracy")["cases"] + if isinstance(case["input"], float) + and case["input"] == 0 + and math.copysign(1.0, case["input"]) < 0 + ] + self.assertEqual(negative_zero, [{"value": "unit"}]) + + def test_each_normalize_case_holds_with_full_nfkc_and_with_a_table(self) -> None: + separators = separator_characters() + normalizers = {"full NFKC": full_normalize, "NFKC through a table": reduced_normalize} + for case in load("normalize")["cases"]: + for name, normalizer in normalizers.items(): + with self.subTest(case["id"], normalizer=name): + self.assert_same_text( + normalizer(case["input"], separators), case["expect"]["value"] + ) + + def test_a_table_keeps_a_micro_sign_but_changes_a_full_width_letter(self) -> None: + separators = separator_characters() + micro_sign = chr(0x00B5) + full_width_a = chr(0xFF21) + with self.subTest("full NFKC changes the micro sign"): + self.assert_same_text(full_normalize(micro_sign, separators), chr(0x03BC)) + with self.subTest("a table keeps the micro sign"): + self.assert_same_text(reduced_normalize(micro_sign, separators), micro_sign) + with self.subTest("a table changes the full-width letter"): + self.assert_same_text(reduced_normalize(full_width_a, separators), "A") + + def test_each_gps_limit_has_a_case_at_the_limit_and_a_case_above_it(self) -> None: + rules = json.loads((ROOT / "data" / "precision.json").read_text(encoding="utf-8")) + limits = [threshold["maxAccuracyM"] for threshold in rules["thresholds"]] + at_limit = [threshold["precision"] for threshold in rules["thresholds"]] + above_limit = [*at_limit[1:], rules["fallback"]] + ceilings = [*limits[1:], math.inf] + measured = [ + (case["input"], case["expect"]["value"]) + for case in load("precision-for-accuracy")["cases"] + if isinstance(case["input"], int | float) + ] + for limit, ceiling, precision, next_precision in zip( + limits, ceilings, at_limit, above_limit, strict=True + ): + with self.subTest(limit=limit): + self.assertIn((limit, precision), measured) + above = [expected for metres, expected in measured if limit < metres < ceiling] + self.assertIn(next_precision, above) + + def test_parse_has_cases_at_the_limit_above_it_and_for_each_counting_mistake(self) -> None: + postcode_format = json.loads((ROOT / "data" / "format.json").read_text(encoding="utf-8")) + limit = postcode_format["maxInputCodePoints"] + parsed = [ + (case["input"], "ok" if case["expect"]["ok"] else case["expect"]["error"]["code"]) + for case in load("parse")["cases"] + ] + outcomes = [(len(text), outcome) for text, outcome in parsed] + self.assertIn((limit, "ok"), outcomes) + self.assertIn((limit + 1, "bad_length"), outcomes) + accepted = [text for text, outcome in parsed if outcome == "ok" and len(text) <= limit] + rejected = [ + text for text, outcome in parsed if outcome == "bad_length" and len(text) > limit + ] + pins = { + "a success with more UTF-16 units than the limit": [ + text for text in accepted if utf16_units(text) > limit + ], + "a success with the limit in code points and more UTF-16 units": [ + text for text in accepted if len(text) == limit and utf16_units(text) > limit + ], + "a success that grows past the limit under NFKC": [ + text for text in accepted if len(unicodedata.normalize("NFKC", text)) > limit + ], + "a rejection over the limit with a combining mark": [ + text + for text in rejected + if any(unicodedata.combining(character) for character in text) + ], + "a rejection over the limit that ends in a line feed": [ + text for text in rejected if text.endswith("\n") + ], + } + for requirement, texts in pins.items(): + with self.subTest(requirement): + self.assertTrue(texts) + # A check that ends in $ instead of \z skips a final line feed. Only an input with + # exactly one code point over the limit shows it. + line_feed_lengths = {len(text) for text in rejected if text.endswith("\n")} + with self.subTest("each rejection that ends in a line feed is one code point over"): + self.assertEqual(line_feed_lengths, {limit + 1}) + + def test_is_legacy_has_cases_at_the_limit_above_it_and_for_each_counting_mistake(self) -> None: + postcode_format = json.loads((ROOT / "data" / "format.json").read_text(encoding="utf-8")) + limit = postcode_format["maxInputCodePoints"] + legacy = [(case["input"], case["expect"]["value"]) for case in load("is-legacy")["cases"]] + outcomes = [(len(text), outcome) for text, outcome in legacy] + self.assertIn((limit, True), outcomes) + self.assertIn((limit + 1, False), outcomes) + accepted = [text for text, outcome in legacy if outcome is True and len(text) <= limit] + rejected = [text for text, outcome in legacy if outcome is False and len(text) > limit] + pins = { + "an accepted legacy code that grows past the limit under NFKC": [ + text for text in accepted if len(unicodedata.normalize("NFKC", text)) > limit + ], + "a rejected legacy code that is within the limit without its zero-width joiners": [ + text for text in rejected if len(text.replace(ZERO_WIDTH_JOINER, "")) <= limit + ], + "a rejected legacy code over the limit that ends in a line feed": [ + text for text in rejected if text.endswith("\n") + ], + } + for requirement, texts in pins.items(): + with self.subTest(requirement): + self.assertTrue(texts) + # The same one-over rule as for parse: a check that ends in $ lets such an input through. + line_feed_lengths = {len(text) for text in rejected if text.endswith("\n")} + with self.subTest("each rejection that ends in a line feed is one code point over"): + self.assertEqual(line_feed_lengths, {limit + 1}) + + +class BuilderModesTest(unittest.TestCase): + """Run the builder on a temporary copy of the vector folder.""" + + def setUp(self) -> None: + folder = tempfile.TemporaryDirectory() + self.addCleanup(folder.cleanup) + self.vectors = Path(folder.name) + for stem in build_vectors.FILES: + shutil.copy(ROOT / "vectors" / f"{stem}.json", self.vectors) + patch = mock.patch.object(build_vectors, "VECTORS", self.vectors) + patch.start() + self.addCleanup(patch.stop) + + def snapshot(self) -> dict[str, str]: + return { + path.name: path.read_text(encoding="utf-8") for path in sorted(self.vectors.glob("*")) + } + + def run_builder(self, *arguments: str) -> tuple[int, str]: + errors = io.StringIO() + with contextlib.redirect_stderr(errors): + exit_code = build_vectors.main(list(arguments)) + return exit_code, errors.getvalue() + + def test_check_passes_files_that_match_the_builder(self) -> None: + before = self.snapshot() + self.assertEqual(self.run_builder("--check"), (0, "")) + self.assertEqual(self.snapshot(), before) + + def test_check_reports_a_changed_file_and_a_stray_file_and_writes_nothing(self) -> None: + changed = self.vectors / "redact.json" + changed.write_text(changed.read_text(encoding="utf-8") + " ", encoding="utf-8") + (self.vectors / "extra.json").write_text("{}\n", encoding="utf-8") + before = self.snapshot() + exit_code, errors = self.run_builder("--check") + self.assertEqual(exit_code, 1) + self.assertIn("Out of date: redact.json.", errors) + self.assertIn("vectors/extra.json is not a builder output. Remove it.", errors) + self.assertEqual(self.snapshot(), before) + + def test_check_fails_for_a_stray_file_alone(self) -> None: + (self.vectors / "extra.json").write_text("{}\n", encoding="utf-8") + exit_code, errors = self.run_builder("--check") + self.assertEqual(exit_code, 1) + self.assertNotIn("Out of date", errors) + + def test_check_reports_a_missing_file_and_does_not_write_it(self) -> None: + (self.vectors / "parent.json").unlink() + before = self.snapshot() + exit_code, errors = self.run_builder("--check") + self.assertEqual(exit_code, 1) + self.assertIn("Out of date: parent.json.", errors) + self.assertEqual(self.snapshot(), before) + + def test_writing_makes_a_missing_folder_and_rewrites_a_changed_file(self) -> None: + expected = self.snapshot() + fresh = self.vectors / "fresh" + with mock.patch.object(build_vectors, "VECTORS", fresh): + self.assertEqual(self.run_builder(), (0, "")) + changed = fresh / "redact.json" + changed.write_text("{}\n", encoding="utf-8") + self.assertEqual(self.run_builder(), (0, "")) + self.assertEqual({path.name: path.read_text("utf-8") for path in fresh.glob("*")}, expected) + + def test_writing_reports_a_stray_file_and_keeps_it(self) -> None: + stray = self.vectors / "extra.json" + stray.write_text("{}\n", encoding="utf-8") + exit_code, errors = self.run_builder() + self.assertEqual(exit_code, 1) + self.assertIn("vectors/extra.json is not a builder output. Remove it.", errors) + self.assertEqual(stray.read_text(encoding="utf-8"), "{}\n") diff --git a/scripts/tests/test_check_tells_command.py b/scripts/tests/test_check_tells_command.py new file mode 100644 index 0000000..5688d98 --- /dev/null +++ b/scripts/tests/test_check_tells_command.py @@ -0,0 +1,219 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for check_tells.command: the options, the exit codes and the output.""" + +import contextlib +import io +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import check_tells +from check_tells import Violation + +from tests.check_tells_driver import git_repo, run_main +from tests.check_tells_samples import SIGN_OFF_LINE, squash + + +class CommandTest(unittest.TestCase): + def test_checks_new_files_and_skips_ignored_ones(self) -> None: + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + subprocess.run(["git", "init", "-q", str(root)], check=True) + (root / ".gitignore").write_text("ignored.ts\n", encoding="utf-8") + (root / "ignored.ts").write_text("console.log(1);\n", encoding="utf-8") + (root / "new.ts").write_text("console.log(1);\n", encoding="utf-8") + output = io.StringIO() + with contextlib.redirect_stdout(output): + status = check_tells.main(["--root", str(root)]) + self.assertEqual(status, 1) + self.assertIn("new.ts:1:1: TELL-13", output.getvalue()) + self.assertNotIn("ignored.ts", output.getvalue()) + + def test_exits_with_0_for_a_clean_repo(self) -> None: + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + subprocess.run(["git", "init", "-q", str(root)], check=True) + (root / "clean.ts").write_text("export const a = 1;\n", encoding="utf-8") + command = [str(Path(__file__).resolve().parents[1] / "check-tells"), "--root", folder] + self.assertEqual(subprocess.run(command, check=False).returncode, 0) + + def test_prints_a_violation_like_a_compiler_message(self) -> None: + violation = Violation("src/a.ts", 3, 101, "TELL-1", "The line is too wide.") + self.assertEqual(str(violation), "src/a.ts:3:101: TELL-1 The line is too wide.") + + def test_prints_the_violations_in_the_order_of_the_files_and_lines(self) -> None: + with git_repo() as root: + (root / "b.ts").write_text("console.log(1);\n\nconsole.log(2);\n", encoding="utf-8") + (root / "a.ts").write_text("export const a = 1;\nconsole.log(1);\n", encoding="utf-8") + status, output, errors = run_main("--root", str(root)) + self.assertEqual(status, 1) + self.assertEqual(errors, "") + self.assertEqual( + [line.split(": ")[0] for line in output.splitlines()], + ["a.ts:2:1", "b.ts:1:1", "b.ts:3:1"], + ) + + def test_skips_a_tracked_file_that_is_missing_from_the_work_tree(self) -> None: + with git_repo() as root: + (root / "gone.ts").write_text("console.log(1);\n", encoding="utf-8") + subprocess.run(["git", "-C", str(root), "add", "gone.ts"], check=True) + (root / "gone.ts").unlink() + status, output, errors = run_main("--root", str(root)) + self.assertEqual((status, output, errors), (0, "", "")) + + def test_checks_only_the_paths_that_it_is_given(self) -> None: + with git_repo() as root: + (root / "a.ts").write_text("console.log(1);\n", encoding="utf-8") + (root / "b.ts").write_text("console.log(1);\n", encoding="utf-8") + status, output, _ = run_main("--root", str(root), "b.ts") + self.assertEqual(status, 1) + self.assertEqual(output.splitlines(), ["b.ts:1:1: TELL-13 Remove 'console.log('."]) + + def test_exits_with_1_and_names_a_code_file_that_is_not_utf_8(self) -> None: + with git_repo() as root: + (root / "a.ts").write_bytes(b"const a = '\x93';\n") + (root / "logo.png").write_bytes(b"\x89PNG\r\n\x1a\n\xff") + status, output, errors = run_main("--root", str(root)) + self.assertEqual(status, 1) + self.assertEqual(errors, "") + self.assertEqual(len(output.splitlines()), 1) + self.assertIn("a.ts:1:12: TELL-14 Byte 0x93 is not valid UTF-8.", output) + + +class BadInputTest(unittest.TestCase): + def assert_one_line_on_stderr(self, result: tuple[int, str, str], *words: str) -> None: + status, output, errors = result + self.assertEqual(status, 2) + self.assertEqual(output, "") + self.assertEqual(len(errors.splitlines()), 1, errors) + self.assertTrue(errors.startswith("check-tells: "), errors) + for word in words: + self.assertIn(word, errors) + + def test_reports_a_root_that_is_not_a_git_repo(self) -> None: + with tempfile.TemporaryDirectory() as folder: + root = Path(folder).resolve() + # Stop git from finding a repo above the temporary folder. + with mock.patch.dict(os.environ, {"GIT_CEILING_DIRECTORIES": str(root.parent)}): + result = run_main("--root", str(root)) + self.assert_one_line_on_stderr(result, str(root), "not a git repository") + + def test_reports_a_root_that_does_not_exist(self) -> None: + with tempfile.TemporaryDirectory() as folder: + missing = Path(folder).resolve() / "missing" + result = run_main("--root", str(missing)) + self.assert_one_line_on_stderr(result, str(missing)) + + def test_reports_a_path_that_does_not_exist(self) -> None: + with git_repo() as root: + result = run_main("--root", str(root), "missing.ts") + self.assert_one_line_on_stderr(result, "missing.ts", "No such file") + + def test_reports_a_path_that_is_a_folder(self) -> None: + with git_repo() as root: + (root / "src").mkdir() + result = run_main("--root", str(root), "src") + self.assert_one_line_on_stderr(result, "src", "directory") + + def test_reports_a_path_outside_the_root(self) -> None: + with git_repo() as root, tempfile.TemporaryDirectory() as other: + outside = Path(other).resolve() / "a.ts" + outside.write_text("console.log(1);\n", encoding="utf-8") + absolute = run_main("--root", str(root), str(outside)) + relative = run_main("--root", str(root), "../" + outside.parent.name + "/a.ts") + self.assert_one_line_on_stderr(absolute, str(outside), "outside the root") + self.assert_one_line_on_stderr(relative, "outside the root") + + def test_reports_a_commit_message_file_that_does_not_exist(self) -> None: + with tempfile.TemporaryDirectory() as folder: + missing = Path(folder) / "message" + result = run_main("--commit-msg", str(missing)) + self.assert_one_line_on_stderr(result, str(missing), "No such file") + + def test_reports_a_commit_message_that_is_not_utf_8(self) -> None: + with tempfile.TemporaryDirectory() as folder: + message = Path(folder) / "message" + message.write_bytes(b"fix: parse caf\xe9\n") + status, output, errors = run_main("--commit-msg", str(message)) + self.assertEqual((status, errors), (1, "")) + self.assertIn("commit message:1:15: TELL-14 Byte 0xE9 is not valid UTF-8.", output) + + def test_prints_no_traceback_from_the_command(self) -> None: + command = [str(Path(__file__).resolve().parents[1] / "check-tells")] + with tempfile.TemporaryDirectory() as folder: + root = Path(folder).resolve() + environment = {**os.environ, "GIT_CEILING_DIRECTORIES": str(root.parent)} + run = subprocess.run( + [*command, "--root", str(root)], + check=False, + capture_output=True, + text=True, + env=environment, + ) + self.assertEqual(run.returncode, 2) + self.assertEqual(run.stdout, "") + self.assertNotIn("Traceback", run.stderr) + self.assertEqual(len(run.stderr.splitlines()), 1) + + +class SquashCommandTest(unittest.TestCase): + def run_with_message(self, text: str, *options: str) -> tuple[int, str, str]: + with tempfile.TemporaryDirectory() as folder: + message = Path(folder) / "message" + message.write_text(text, encoding="utf-8") + return run_main("--squash-msg", str(message), *options) + + def test_exits_with_0_for_a_good_message(self) -> None: + result = self.run_with_message(squash("fix: reject a unit (#7)"), "--no-scope") + self.assertEqual(result, (0, "", "")) + + def test_exits_with_1_and_prints_each_problem(self) -> None: + status, output, errors = self.run_with_message("Update files\n\nBody\n") + self.assertEqual((status, errors), (1, "")) + self.assertEqual( + [line.split(" ")[:3] for line in output.splitlines()], + [["commit", "message:1:1:", "GIT-1"], ["commit", "message:1:1:", "GIT-2"]], + ) + + def test_rejects_a_scope_only_with_the_no_scope_option(self) -> None: + message = squash("fix(core): reject a unit (#7)") + self.assertEqual(self.run_with_message(message), (0, "", "")) + status, output, _ = self.run_with_message(message, "--no-scope") + self.assertEqual(status, 1) + self.assertIn("commit message:1:4: GIT-1 Remove the scope (core).", output) + + def test_reads_a_squash_message_that_is_not_utf_8(self) -> None: + with tempfile.TemporaryDirectory() as folder: + message = Path(folder) / "message" + message.write_bytes(b"fix: parse caf\xe9\n\n" + SIGN_OFF_LINE.encode() + b"\n") + status, output, _ = run_main("--squash-msg", str(message)) + self.assertEqual(status, 1) + self.assertIn("commit message:1:15: TELL-14 Byte 0xE9 is not valid UTF-8.", output) + + def test_reports_a_squash_message_file_that_does_not_exist(self) -> None: + status, output, errors = run_main("--squash-msg", "no-such-file") + self.assertEqual((status, output), (2, "")) + self.assertEqual(len(errors.splitlines()), 1) + + def test_asks_for_a_squash_message_when_it_gets_the_no_scope_option(self) -> None: + errors = io.StringIO() + with self.assertRaises(SystemExit) as caught, contextlib.redirect_stderr(errors): + check_tells.main(["--no-scope"]) + self.assertEqual(caught.exception.code, 2) + self.assertIn("--no-scope needs --squash-msg", errors.getvalue()) + + def test_reads_one_kind_of_message_at_a_time(self) -> None: + for options in ( + ["--commit-msg", "a", "--squash-msg", "b"], + ["--commit-msg", "a", "--signed-off", "b..c"], + ["--squash-msg", "a", "--signed-off", "b..c"], + ): + with self.subTest(options=options): + errors = io.StringIO() + with self.assertRaises(SystemExit) as caught, contextlib.redirect_stderr(errors): + check_tells.main(options) + self.assertEqual(caught.exception.code, 2) diff --git a/scripts/tests/test_check_tells_debug_calls.py b/scripts/tests/test_check_tells_debug_calls.py new file mode 100644 index 0000000..0d83360 --- /dev/null +++ b/scripts/tests/test_check_tells_debug_calls.py @@ -0,0 +1,257 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for check_tells.debug_calls: TELL-13.""" + +import unittest + +from check_tells import check_text + +from tests.check_tells_driver import rules + +# The debug calls that each language file lists under TELL-13, one sample line for each call. +DEBUG_LINES = { + "src/a.ts": ["console.log(x);", "console.debug(x);", "console.dir(x);", "debugger;"], + "src/a.php": [ + "var_dump($x);", + "print_r($x);", + "var_export($x);", + "dd($x);", + "dump($x);", + "error_log($x);", + "debug_print_backtrace();", + ], + "pkg/a.py": ["print(x)", "pprint(x)", "breakpoint()", "pdb.set_trace()"], + "a.go": [ + "fmt.Print(x)", + "fmt.Printf(x)", + "fmt.Println(x)", + "log.Print(x)", + "log.Printf(x)", + "log.Println(x)", + "log.Fatal(x)", + "log.Fatalf(x)", + "log.Fatalln(x)", + "log.Panic(x)", + "log.Panicf(x)", + "log.Panicln(x)", + "print(x)", + "println(x)", + ], + "src/A.kt": [ + "println(x)", + "print(x)", + "System.out.println(x)", + "System.err.println(x)", + "error.printStackTrace()", + "Log.v(TAG, x)", + "Log.d(TAG, x)", + "Log.i(TAG, x)", + "Log.w(TAG, x)", + "Log.e(TAG, x)", + "Log.wtf(TAG, x)", + ], + "src/A.java": ["System.out.println(x);", "System.err.println(x);", "error.printStackTrace();"], + "src/A.cs": [ + "Console.Write(x);", + "Console.WriteLine(x);", + "Debug.WriteLine(x);", + "Trace.WriteLine(x);", + ], + "lib/a.dart": ["print(x);", "debugPrint(x);"], + "Sources/A.swift": ["print(x)", "debugPrint(x)", "dump(x)", "NSLog(x)"], +} +# One debug call for each file extension that has a language. +DEBUG_LINE_BY_EXTENSION = { + ".ts": "console.log(x);", + ".tsx": "console.log(x);", + ".mts": "console.log(x);", + ".cts": "console.log(x);", + ".js": "console.log(x);", + ".jsx": "console.log(x);", + ".mjs": "console.log(x);", + ".cjs": "console.log(x);", + ".vue": "console.log(x);", + ".svelte": "console.log(x);", + ".astro": "console.log(x);", + ".php": "var_dump($x);", + ".py": "print(x)", + ".go": "fmt.Println(x)", + ".kt": "println(x)", + ".kts": "println(x)", + ".java": "System.out.println(x);", + ".cs": "Console.WriteLine(x);", + ".dart": "debugPrint(x);", + ".swift": "NSLog(x)", +} +# Calls that look like a debug call but are not one. +HARMLESS_LINES = { + "pkg/a.py": ["report.print(x)", "pprint_table(x)", "self.breakpoint(x)", "sprint(x)"], + "src/a.ts": ["myconsole.log(x);", "console.logs(x);", "console.warn(x);"], + "src/a.php": ["$this->dump($x);", "Debug::dump($x);", "my_dump($x);", "$dd($x);"], + "a.go": ["report.Print(x)", "sprint(x)", "s.println(x)"], + "src/A.kt": ["report.print(x)", "logPrint(x)"], + "src/A.cs": ["Logger.WriteLine(x);"], + "lib/a.dart": ["report.print(x);", "sprint(x);"], + "Sources/A.swift": ["report.print(x)", "sprint(x)"], +} +# Every file name that check-tells must read as a test file, each with the language's debug call. +TEST_FILES = { + "test/a.ts": "console.log(x);", + "tests/a.ts": "console.log(x);", + "src/__tests__/a.ts": "console.log(x);", + "src/testdata/a.ts": "console.log(x);", + "testdata/a.go": "fmt.Println(x)", + "pkg/testdata/a.go": "fmt.Println(x)", + "Tests/A.cs": "Console.WriteLine(x);", + "a.test.ts": "console.log(x);", + "a.spec.ts": "console.log(x);", + "a.test.tsx": "console.log(x);", + "a.test.js": "console.log(x);", + "a.test.jsx": "console.log(x);", + "a.test.mjs": "console.log(x);", + "a.test.cjs": "console.log(x);", + "a.test.mts": "console.log(x);", + "a.test.cts": "console.log(x);", + "a_test.go": "fmt.Println(x)", + "a_test.dart": "debugPrint(x);", + "test_a.py": "print(x)", + "src/ATest.kt": "println(x)", + "src/ATests.kt": "println(x)", + "src/ATest.java": "System.out.println(x);", + "src/ATests.java": "System.out.println(x);", + "src/ATest.cs": "Console.WriteLine(x);", + "src/ATests.cs": "Console.WriteLine(x);", + "Sources/ATest.swift": "dump(x)", + "Sources/ATests.swift": "dump(x)", +} +# File names that look like test files but are not. +NON_TEST_FILES = { + "src/latest/a.ts": "console.log(x);", + "src/contest.ts": "console.log(x);", + "src/a_test.ts": "console.log(x);", + "src/a.testing.ts": "console.log(x);", + "src/Latest.kt": "println(x)", + "src/test.py": "print(x)", + "src/testdata.py": "print(x)", +} + + +class DebugCallTest(unittest.TestCase): + def test_rejects_a_debug_call_in_each_language(self) -> None: + cases = { + "src/a.ts": "console.log(code);", + "src/b.ts": "debugger;", + "src/a.php": "var_dump($code);", + "pkg/a.py": "print(code)", + "pkg/b.py": "pdb.set_trace()", + "pkg/c.py": "breakpoint()", + "a.go": "fmt.Println(code)", + "b.go": 'log.Printf("x")', + "src/A.kt": "println(code)", + "src/B.kt": 'Log.d(TAG, "x")', + "src/A.java": "System.out.println(x);", + "src/B.java": "e.printStackTrace();", + "src/A.cs": "Console.WriteLine(code);", + "lib/a.dart": "debugPrint(code);", + "Sources/A.swift": "dump(code)", + "Sources/B.swift": 'NSLog("x")', + } + for path, line in cases.items(): + with self.subTest(path=path): + self.assertEqual(rules(check_text(path, line)), ["TELL-13"]) + + def test_rejects_each_debug_call_that_a_language_file_lists(self) -> None: + for path, lines in DEBUG_LINES.items(): + for line in lines: + with self.subTest(path=path, line=line): + self.assertEqual(rules(check_text(path, line)), ["TELL-13"]) + + def test_rejects_a_debug_call_in_each_file_extension_with_a_language(self) -> None: + for extension, line in DEBUG_LINE_BY_EXTENSION.items(): + with self.subTest(extension=extension): + self.assertEqual(rules(check_text("src/a" + extension, line)), ["TELL-13"]) + + def test_names_the_call_and_its_place(self) -> None: + found = check_text("src/a.ts", " console.log(x);") + self.assertEqual((found[0].line, found[0].column), (1, 3)) + self.assertEqual(found[0].message, "Remove 'console.log('.") + + def test_checks_a_code_file_in_a_catalogue_folder(self) -> None: + found = check_text("src/messages/format.ts", "console.log(1);") + self.assertEqual(rules(found), ["TELL-13"]) + + def test_accepts_methods_with_similar_names(self) -> None: + self.assertEqual(check_text("pkg/a.py", "report.print(code)"), []) + self.assertEqual(check_text("pkg/a.py", "pprint_table(code)"), []) + + def test_accepts_each_call_that_only_looks_like_a_debug_call(self) -> None: + for path, lines in HARMLESS_LINES.items(): + for line in lines: + with self.subTest(path=path, line=line): + self.assertEqual(check_text(path, line), []) + + def test_ignores_test_files_and_comment_lines(self) -> None: + self.assertEqual(check_text("example_test.go", 'fmt.Println("EK-01")'), []) + self.assertEqual(check_text("src/a.ts", " * console.log(parse(code));"), []) + + def test_ignores_each_kind_of_test_path(self) -> None: + cases = { + "test/parse.ts": "console.log(code);", + "src/__tests__/parse.ts": "console.log(code);", + "lib/parse_test.dart": "debugPrint(code);", + "src/parse.test.ts": "console.log(code);", + "src/parse.spec.mjs": "console.log(code);", + "test_parse.py": "print(code)", + "src/ParseTest.kt": "println(code)", + "Sources/ParseTests.swift": "dump(code)", + } + for path, line in cases.items(): + with self.subTest(path=path): + self.assertEqual(check_text(path, line), []) + + def test_ignores_every_test_file_name_that_the_standards_allow(self) -> None: + for path, line in TEST_FILES.items(): + with self.subTest(path=path): + self.assertEqual(check_text(path, line), []) + + def test_checks_a_file_name_that_only_looks_like_a_test_file(self) -> None: + for path, line in NON_TEST_FILES.items(): + with self.subTest(path=path): + self.assertEqual(rules(check_text(path, line)), ["TELL-13"]) + + def test_ignores_each_kind_of_comment_start(self) -> None: + cases = [ + ("src/a.ts", "// console.log(x)"), + ("src/a.ts", "/* console.log(x)"), + ("src/a.ts", " * console.log(x)"), + ("pkg/a.py", "# print(x)"), + ] + for path, line in cases: + with self.subTest(line=line): + self.assertEqual(check_text(path, line), []) + + def test_ignores_doctest_examples(self) -> None: + self.assertEqual(check_text("pkg/a.py", ' >>> print(parse("EK01A03FK01"))'), []) + self.assertEqual(check_text("pkg/a.py", " ... print(code)"), []) + + def test_checks_a_spread_line_outside_python(self) -> None: + line = " ...items.map((item) => console.log(item))," + self.assertEqual(rules(check_text("src/a.ts", line)), ["TELL-13"]) + + def test_reads_the_language_from_a_shebang(self) -> None: + cases = { + "#!/usr/bin/env python3\nprint(1)": ["TELL-13"], + "#!/usr/bin/env python\nprint(1)": ["TELL-13"], + "#!/usr/bin/python3\nprint(1)": ["TELL-13"], + "#!/usr/bin/env node\nconsole.log(1)": ["TELL-13"], + "#!/usr/bin/node\nconsole.log(1)": ["TELL-13"], + "#!/bin/sh\nprint(1)": [], + } + for text, expected in cases.items(): + with self.subTest(text=text): + self.assertEqual(rules(check_text("scripts/run", text)), expected) + + def test_does_not_check_markup_or_style_files_for_debug_calls(self) -> None: + for path in ("site/a.html", "src/a.css", "src/a.scss"): + with self.subTest(path=path): + self.assertEqual(check_text(path, "console.log(x);"), []) diff --git a/scripts/tests/test_check_tells_file_checks.py b/scripts/tests/test_check_tells_file_checks.py new file mode 100644 index 0000000..c90fc04 --- /dev/null +++ b/scripts/tests/test_check_tells_file_checks.py @@ -0,0 +1,220 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for check_tells.file_checks: the checks of the name and the lines of a file.""" + +import unittest + +from check_tells import check_text + +from tests.check_tells_driver import rules +from tests.check_tells_samples import ( + FIX_ME, + KIND_BY_PATH, + LONG_LINE, + NO_BREAK_SPACE, + SKIP_MARKER, + TO_DO, +) + +# A line that breaks each rule that a skip comment can skip. +SKIP_SAMPLES = { + "TELL-1": ("src/a.ts", LONG_LINE), + "TELL-13": ("src/a.ts", "console.log(x);"), + "TELL-14": ("src/a.ts", f"const a = '{NO_BREAK_SPACE}';"), + "CS-6": ("src/a.ts", f"// {TO_DO}: x"), +} + + +class LineLengthTest(unittest.TestCase): + def test_accepts_a_line_of_100_characters(self) -> None: + self.assertEqual(check_text("src/a.ts", "x" * 100), []) + + def test_rejects_a_line_of_101_characters(self) -> None: + found = check_text("src/a.ts", "x" * 101) + self.assertEqual(rules(found), ["TELL-1"]) + self.assertEqual(found[0].column, 101) + + def test_counts_a_tab_as_4_columns(self) -> None: + self.assertEqual(check_text("Makefile", "\t" + "x" * 96), []) + self.assertEqual(rules(check_text("Makefile", "\t" + "x" * 97)), ["TELL-1"]) + self.assertEqual(check_text("Makefile", "\t\t" + "x" * 92), []) + self.assertEqual(rules(check_text("Makefile", "\t\t" + "x" * 93)), ["TELL-1"]) + + def test_skips_a_line_with_a_url(self) -> None: + for scheme in ("https", "http"): + with self.subTest(scheme=scheme): + line = f"// {scheme}://example.com/" + "a" * 100 + self.assertEqual(check_text("src/a.ts", line), []) + + def test_skips_markdown_lockfiles_and_vectors(self) -> None: + self.assertEqual(check_text("README.md", "word " * 40), []) + self.assertEqual(check_text("pnpm-lock.yaml", "x" * 150), []) + self.assertEqual(check_text("vectors/parse.json", "x" * 150), []) + + def test_skips_json_in_a_data_folder(self) -> None: + self.assertEqual(check_text("data/states.json", "x" * 150), []) + self.assertEqual(check_text("vectors/parse.json", "x" * 150), []) + self.assertEqual(check_text("spec/vectors/parse.json", "x" * 150), []) + self.assertEqual(check_text("spec/data/format.json", "x" * 150), []) + + def test_checks_other_files_in_a_data_folder(self) -> None: + self.assertEqual(rules(check_text("data/rules.yml", "x" * 150)), ["TELL-1"]) + self.assertEqual(rules(check_text("vectors/build.py", "x" * 150)), ["TELL-1"]) + self.assertEqual(rules(check_text("package.json", "x" * 150)), ["TELL-1"]) + self.assertEqual(rules(check_text("database/rules.json", "x" * 150)), ["TELL-1"]) + + def test_skips_a_message_catalogue(self) -> None: + line = '{"title": "' + "x" * 150 + '"}' + self.assertEqual(check_text("src/locales/en.json", line), []) + + def test_skips_a_generated_file(self) -> None: + text = "// @generated by a script\n" + "x" * 150 + self.assertEqual(check_text("src/spec-data.ts", text), []) + + def test_skips_files_that_other_tools_generate(self) -> None: + cases = { + "src/A.cs": "// \n", + "lib/a.g.dart": "// GENERATED CODE - DO NOT MODIFY BY HAND\n", + } + for path, header in cases.items(): + with self.subTest(path=path): + self.assertEqual(check_text(path, header + "x" * 150), []) + + def test_honours_a_skip_with_a_reason(self) -> None: + line = "x" * 120 + " // check-tells: allow TELL-1 because the regex cannot be split" + self.assertEqual(check_text("src/a.ts", line), []) + + def test_checks_each_code_and_config_kind(self) -> None: + for kind in ("code", "config"): + for path in KIND_BY_PATH[kind]: + with self.subTest(path=path): + self.assertEqual(rules(check_text(path, LONG_LINE)), ["TELL-1"]) + + def test_leaves_every_other_kind_alone(self) -> None: + for kind in ("prose", "catalogue", "exempt", "other"): + for path in KIND_BY_PATH[kind]: + with self.subTest(path=path): + self.assertEqual(check_text(path, LONG_LINE), []) + + def test_reads_a_marker_in_the_first_5_lines_of_a_file(self) -> None: + for marker in ("@generated", "DO NOT EDIT", " None: + for marker in ("@generated", "DO NOT EDIT", " None: + text = "// @generated\nconsole.log(1);\n// " + TO_DO + ": x" + self.assertEqual(rules(check_text("src/a.ts", text)), ["TELL-13", "CS-6"]) + + +class FileNameTest(unittest.TestCase): + def test_rejects_utils_helpers_common_and_misc(self) -> None: + for name in ("src/utils.ts", "lib/helpers.py", "common.go", "pkg/Misc.kt"): + with self.subTest(name=name): + self.assertEqual(rules(check_text(name, "")), ["TELL-7"]) + + def test_rejects_the_name_with_any_extension_or_none(self) -> None: + for name in ("utils", "helpers.test.ts", "common.d.ts", "src/MISC.md", "Utils.json"): + with self.subTest(name=name): + self.assertEqual(rules(check_text(name, "")), ["TELL-7"]) + + def test_accepts_a_folder_named_common(self) -> None: + self.assertEqual(check_text("common/parse.ts", ""), []) + + def test_accepts_a_name_that_only_starts_with_one_of_the_words(self) -> None: + self.assertEqual(check_text("src/utilities.ts", ""), []) + self.assertEqual(check_text("src/commonly.ts", ""), []) + + +class TodoFormTest(unittest.TestCase): + def test_rejects_a_to_do_without_an_issue(self) -> None: + self.assertEqual(rules(check_text("src/a.ts", f"// {TO_DO}: handle 00")), ["CS-6"]) + + def test_rejects_a_to_do_in_a_config_file(self) -> None: + found = check_text("Makefile", "# " + TO_DO + ": pin the version") + self.assertEqual(rules(found), ["CS-6"]) + + def test_rejects_a_to_do_or_a_fix_me_in_any_letter_case(self) -> None: + for word in (TO_DO, TO_DO.lower(), TO_DO.title(), FIX_ME, FIX_ME.lower(), FIX_ME.title()): + with self.subTest(word=word): + found = check_text("src/a.ts", f"// {word}: handle 00") + self.assertEqual(rules(found), ["CS-6"]) + self.assertEqual(found[0].column, 4) + self.assertEqual( + found[0].message, f"Link an issue in the form {word.upper()}(#123)." + ) + + def test_accepts_a_to_do_with_an_issue(self) -> None: + self.assertEqual(check_text("src/a.ts", f"// {TO_DO}(#12): handle 00"), []) + + def test_accepts_a_to_do_or_a_fix_me_with_an_issue_in_any_letter_case(self) -> None: + for word in (TO_DO, TO_DO.lower(), FIX_ME, FIX_ME.title()): + with self.subTest(word=word): + self.assertEqual(check_text("src/a.ts", f"// {word}(#12): handle 00"), []) + + def test_rejects_a_to_do_with_a_bad_issue_reference(self) -> None: + for reference in ("(12)", "(#)", "(#x)", " (#12)"): + with self.subTest(reference=reference): + found = check_text("src/a.ts", f"// {TO_DO}{reference}: handle 00") + self.assertEqual(rules(found), ["CS-6"]) + + def test_accepts_a_word_that_only_contains_to_do(self) -> None: + line = "const todos = 1; const mastodon = 2; const fixmeLater = 3; // _todo_form" + self.assertEqual(check_text("src/a.ts", line), []) + + def test_ignores_prose(self) -> None: + self.assertEqual(check_text("docs/notes.md", f"{TO_DO}: write this"), []) + + def test_checks_a_config_file_that_php_tools_use(self) -> None: + found = check_text("phpstan.dist.neon", "# " + TO_DO + ": raise the level") + self.assertEqual(rules(found), ["CS-6"]) + + +class SkipTest(unittest.TestCase): + def test_rejects_a_skip_without_a_reason(self) -> None: + line = "x" * 120 + f" // {SKIP_MARKER} TELL-1" + self.assertEqual(sorted(rules(check_text("src/a.ts", line))), ["SKIP", "TELL-1"]) + + def test_rejects_a_skip_of_a_rule_that_cannot_be_skipped(self) -> None: + line = f"const a = 1; // {SKIP_MARKER} TELL-7 because it is short" + self.assertEqual(rules(check_text("src/a.ts", line)), ["SKIP"]) + + def test_skips_each_rule_that_a_skip_comment_can_skip(self) -> None: + for rule, (path, line) in SKIP_SAMPLES.items(): + with self.subTest(rule=rule): + self.assertEqual(rules(check_text(path, line)), [rule]) + skipped = f"{line} // {SKIP_MARKER} {rule} because the line is a sample" + self.assertEqual(check_text(path, skipped), []) + + def test_rejects_a_skip_of_each_rule_that_cannot_be_skipped(self) -> None: + for rule in ("TELL-2", "TELL-7", "TELL-18", "CS-5", "API-3", "GIT-1"): + with self.subTest(rule=rule): + line = f"const a = 1; // {SKIP_MARKER} {rule} because it is short" + found = check_text("src/a.ts", line) + self.assertEqual(rules(found), ["SKIP"]) + self.assertEqual(found[0].column, 17) + + def test_skips_only_the_rule_that_the_comment_names(self) -> None: + line = f"console.log('{LONG_LINE}'); // {SKIP_MARKER} TELL-1 because the string is data" + self.assertEqual(rules(check_text("src/a.ts", line)), ["TELL-13"]) + + def test_skips_only_the_line_that_holds_the_comment(self) -> None: + text = f"console.log(1); // {SKIP_MARKER} TELL-13 because it is a sample\nconsole.log(2);" + found = check_text("src/a.ts", text) + self.assertEqual([(v.rule, v.line) for v in found], [("TELL-13", 2)]) + + def test_honours_a_skip_in_a_config_file(self) -> None: + line = f"{LONG_LINE} # {SKIP_MARKER} TELL-1 because the value is a hash" + self.assertEqual(check_text("Makefile", line), []) + + def test_ignores_a_skip_marker_in_prose(self) -> None: + line = "A line such as `" + SKIP_MARKER + " TELL-1` has no reason." + self.assertEqual(check_text("README.md", line), []) diff --git a/scripts/tests/test_check_tells_file_kinds.py b/scripts/tests/test_check_tells_file_kinds.py new file mode 100644 index 0000000..d81a1d0 --- /dev/null +++ b/scripts/tests/test_check_tells_file_kinds.py @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for check_tells.file_kinds: the kind and the language of a file.""" + +import unittest + +from check_tells import check_text, classify + +from tests.check_tells_driver import rules +from tests.check_tells_samples import KIND_BY_PATH, LONG_LINE + +LANGUAGE_BY_PATH = { + "a.ts": "javascript", + "a.vue": "javascript", + "a.astro": "javascript", + "a.svelte": "javascript", + "a.php": "php", + "config.php.dist": "php", + "a.py": "python", + "a.go": "go", + "a.kt": "jvm", + "a.java": "jvm", + "a.cs": "dotnet", + "a.dart": "dart", + "a.swift": "swift", + "a.css": None, + "a.html": None, + "a.sh": None, + "a.md": None, +} + + +class ClassifyTest(unittest.TestCase): + def test_reads_the_kind_of_each_file_name(self) -> None: + for kind, paths in KIND_BY_PATH.items(): + for path in paths: + with self.subTest(path=path): + self.assertEqual(classify(path, "").kind, kind) + + def test_reads_the_language_of_each_file_name(self) -> None: + for path, language in LANGUAGE_BY_PATH.items(): + with self.subTest(path=path): + self.assertEqual(classify(path, "").language, language) + + def test_reads_a_file_without_a_suffix_from_its_first_line(self) -> None: + self.assertEqual(classify("scripts/run", "#!/usr/bin/env python3\n").kind, "code") + self.assertEqual(classify("scripts/run", "plain text\n").kind, "other") + self.assertEqual(classify("scripts/run", "").kind, "other") + + def test_reads_the_suffix_before_a_shebang(self) -> None: + cases = { + "notes.txt": "exempt", + "README.md": "prose", + "package.json": "config", + "font.woff": "other", + "src/locales/en.json": "catalogue", + } + for path, kind in cases.items(): + with self.subTest(path=path): + self.assertEqual(classify(path, "#!/usr/bin/env python3\n").kind, kind) + + def test_reads_the_interpreter_of_a_shebang(self) -> None: + cases = { + "#!/usr/bin/env python3": "python", + "#!/usr/bin/env python": "python", + "#!/usr/bin/python3": "python", + "#!/usr/bin/env node": "javascript", + "#!/usr/bin/node": "javascript", + "#!/bin/sh": None, + } + for first_line, language in cases.items(): + with self.subTest(first_line=first_line): + self.assertEqual(classify("scripts/run", first_line).language, language) + + def test_reads_a_dist_file_like_the_file_that_it_copies(self) -> None: + self.assertEqual(rules(check_text("phpunit.xml.dist", LONG_LINE)), ["TELL-1"]) + self.assertEqual(rules(check_text("config.php.dist", "var_dump($x);")), ["TELL-13"]) + self.assertEqual(rules(check_text("PHPUNIT.XML.DIST", LONG_LINE)), ["TELL-1"]) + + def test_keeps_the_folder_rules_for_a_dist_file(self) -> None: + self.assertEqual(check_text("src/locales/en.json.dist", LONG_LINE), []) diff --git a/scripts/tests/test_check_tells_message_checks.py b/scripts/tests/test_check_tells_message_checks.py new file mode 100644 index 0000000..8dd3dca --- /dev/null +++ b/scripts/tests/test_check_tells_message_checks.py @@ -0,0 +1,323 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for check_tells.message_checks: commit messages and squash messages.""" + +import unittest + +from check_tells import check_commit_message, check_squash_message + +from tests.check_tells_driver import rules +from tests.check_tells_samples import ( + E_ACUTE, + EM_DASH, + EMOJI, + LINE_SEPARATOR, + PLACEHOLDER_PARAGRAPH, + RENOVATE_COMMENT, + SIGN_OFF_LINE, + squash, +) + +COMMIT_TYPES = ( + "build", + "chore", + "ci", + "docs", + "feat", + "fix", + "perf", + "refactor", + "revert", + "style", + "test", +) + + +class CommitMessageTest(unittest.TestCase): + def test_accepts_a_good_message(self) -> None: + message = "fix(core): reject a unit of 00\n\nThe spec forbids 00.\n" + self.assertEqual(check_commit_message(message), []) + + def test_rejects_a_long_subject(self) -> None: + self.assertEqual(rules(check_commit_message("fix: " + "a" * 70)), ["TELL-18"]) + + def test_accepts_a_subject_of_72_characters(self) -> None: + self.assertEqual(check_commit_message("a" * 72), []) + + def test_rejects_a_subject_of_73_characters(self) -> None: + found = check_commit_message("a" * 73) + self.assertEqual(rules(found), ["TELL-18"]) + self.assertEqual(found[0].column, 73) + self.assertEqual(found[0].message, "The subject has 73 characters. Use 72 or fewer.") + + def test_reads_the_subject_after_blank_lines(self) -> None: + found = check_commit_message("\n\n" + "a" * 73 + "\n\n" + "b" * 80) + self.assertEqual([(v.rule, v.line) for v in found], [("TELL-18", 3)]) + + def test_rejects_an_emoji(self) -> None: + self.assertEqual(rules(check_commit_message(f"fix: parse codes {EMOJI}")), ["TELL-14"]) + + def test_accepts_an_accented_name_in_a_sign_off(self) -> None: + message = f"fix: parse codes\n\nSigned-off-by: Ad{E_ACUTE} Bello \n" + self.assertEqual(check_commit_message(message), []) + + def test_ignores_git_comment_lines(self) -> None: + self.assertEqual(check_commit_message(f"fix: parse codes\n# {EMOJI} from git\n"), []) + + def test_accepts_the_last_ascii_character_in_a_commit_message(self) -> None: + self.assertEqual(check_commit_message("fix: a" + chr(0x7F)), []) + self.assertEqual(rules(check_commit_message("fix: a" + chr(0x80))), ["TELL-14"]) + + def test_rejects_non_ascii_in_a_subject(self) -> None: + self.assertEqual(rules(check_commit_message("fix: parse caf" + E_ACUTE)), ["TELL-14"]) + + def test_rejects_non_ascii_in_a_body_but_not_in_a_sign_off(self) -> None: + message = ( + "fix: parse codes\n\n" + f"Use a dash {EM_DASH} here.\n\n" + f"Signed-off-by: Ad{E_ACUTE} Bello \n" + ) + found = check_commit_message(message) + self.assertEqual(rules(found), ["TELL-14"]) + self.assertEqual(found[0].line, 3) + + def test_reads_a_sign_off_without_regard_to_case(self) -> None: + message = f"fix: parse codes\n\nCo-Authored-By: Ad{E_ACUTE} Bello " + self.assertEqual(check_commit_message(message), []) + + def test_stops_at_the_scissors_line(self) -> None: + scissors = "# ------------------------ >8 ------------------------" + message = f"fix: parse codes\n{scissors}\n+ caf{E_ACUTE}" + self.assertEqual(check_commit_message(message), []) + + def test_reports_the_real_line_of_a_character(self) -> None: + found = check_commit_message(f"# c1\n# c2\nfix: caf{E_ACUTE}") + self.assertEqual(rules(found), ["TELL-14"]) + self.assertEqual(found[0].line, 3) + + def test_reports_the_real_line_of_a_long_subject(self) -> None: + found = check_commit_message("# note\nfix: " + "a" * 70) + self.assertEqual(rules(found), ["TELL-18"]) + self.assertEqual(found[0].line, 2) + + def test_keeps_a_line_separator_in_a_commit_message(self) -> None: + found = check_commit_message("fix: a\n\nb" + LINE_SEPARATOR + "c") + self.assertEqual([(v.rule, v.line) for v in found], [("TELL-14", 3)]) + + +class ConventionalSubjectTest(unittest.TestCase): + def test_accepts_each_commit_type(self) -> None: + for kind in COMMIT_TYPES: + with self.subTest(kind=kind): + self.assertEqual(check_squash_message(squash(f"{kind}: reject a unit")), []) + + def test_accepts_a_mark_for_a_breaking_change(self) -> None: + self.assertEqual(check_squash_message(squash("feat!: drop the old name (#4)")), []) + + def test_rejects_a_subject_that_does_not_follow_the_form(self) -> None: + subjects = [ + "Foundation: standards and grammar (#1)", + "Fix: reject a unit", + "fix:reject a unit", + "fix : reject a unit", + "fix(): reject a unit", + "fix", + "fix: ", + "Update files", + ": reject a unit", + "fix(core:) reject a unit", + ] + for subject in subjects: + with self.subTest(subject=subject): + found = check_squash_message(squash(subject)) + self.assertEqual(rules(found), ["GIT-1"]) + self.assertEqual((found[0].line, found[0].column), (1, 1)) + self.assertIn("does not follow Conventional Commits", found[0].message) + + def test_rejects_a_type_that_conventional_commits_does_not_list(self) -> None: + for kind in ("update", "feature", "bugfix", "wip", "chores"): + with self.subTest(kind=kind): + found = check_squash_message(squash(f"{kind}: reject a unit")) + self.assertEqual(rules(found), ["GIT-1"]) + self.assertIn(f"'{kind}' is not a Conventional Commits type", found[0].message) + + def test_names_each_type_that_it_accepts(self) -> None: + found = check_squash_message(squash("update: reject a unit")) + self.assertIn( + "build, chore, ci, docs, feat, fix, perf, refactor, revert, style or test", + found[0].message, + ) + + def test_accepts_a_scope_unless_the_repo_has_none(self) -> None: + self.assertEqual(check_squash_message(squash("fix(core): reject a unit")), []) + self.assertEqual(check_squash_message(squash("fix(core)!: reject a unit")), []) + + def test_rejects_a_scope_in_a_repo_that_has_none(self) -> None: + for subject in ("fix(core): reject a unit", "feat(deps)!: drop a name"): + with self.subTest(subject=subject): + found = check_squash_message(squash(subject), no_scope=True) + self.assertEqual(rules(found), ["GIT-1"]) + self.assertEqual((found[0].line, found[0].column), (1, subject.index("(") + 1)) + self.assertIn("Remove the scope", found[0].message) + + def test_accepts_a_subject_without_a_scope_in_a_repo_that_has_none(self) -> None: + self.assertEqual(check_squash_message(squash("fix: reject a unit"), no_scope=True), []) + + def test_rejects_a_message_that_has_no_subject(self) -> None: + found = check_squash_message("\n \n") + self.assertEqual(sorted(rules(found)), ["GIT-1", "GIT-2"]) + self.assertEqual(found[0].message, "The message has no subject.") + + def test_rejects_the_default_squash_subject_of_a_pull_request_with_no_type(self) -> None: + title = "Foundation: standards, grammar, data, shared vectors and check-tells (#1)" + found = check_squash_message(squash(title), no_scope=True) + self.assertEqual(sorted(rules(found)), ["GIT-1", "TELL-18"]) + + def test_reads_the_first_line_that_has_text_as_the_subject(self) -> None: + found = check_squash_message("\n\nupdate: x\n\nbody\n\n" + SIGN_OFF_LINE) + self.assertEqual([(v.rule, v.line) for v in found], [("GIT-1", 3)]) + + +class SquashMessageTest(unittest.TestCase): + def test_accepts_a_good_message(self) -> None: + message = ( + "fix: reject a unit of 00 (#7)\n\n" + "The parser accepted a unit of 00. It now returns bad_segment.\n\n" + "Closes #5\n\n" + SIGN_OFF_LINE + "\n" + ) + self.assertEqual(check_squash_message(message, no_scope=True), []) + + def test_counts_the_suffix_that_the_merge_adds_to_the_title(self) -> None: + self.assertEqual(check_squash_message(squash("fix: " + "a" * 62 + " (#7)")), []) + found = check_squash_message(squash("fix: " + "a" * 63 + " (#7)")) + self.assertEqual(rules(found), ["TELL-18"]) + self.assertEqual(found[0].column, 73) + self.assertIn("The subject has 73 characters. Use 72 or fewer.", found[0].message) + self.assertIn("(#N)", found[0].message) + + def test_reads_a_line_that_starts_with_a_hash_as_text(self) -> None: + body = f"## Notes {EMOJI}\n# {EM_DASH} here" + found = check_squash_message(squash("fix: reject a unit", body=body)) + self.assertEqual([(v.rule, v.line) for v in found], [("TELL-14", 3), ("TELL-14", 4)]) + + def test_reads_a_line_that_starts_with_a_hash_as_a_comment_in_a_commit_file(self) -> None: + message = f"fix: reject a unit\n\n## Notes {EMOJI}\n" + self.assertEqual(check_commit_message(message), []) + + def test_rejects_non_ascii_in_the_body_but_not_in_a_sign_off(self) -> None: + found = check_squash_message(squash("fix: a unit", body=f"Use a dash {EM_DASH} here.")) + self.assertEqual([(v.rule, v.line) for v in found], [("TELL-14", 3)]) + sign_off = f"Signed-off-by: Ad{E_ACUTE} Bello " + self.assertEqual(check_squash_message(squash("fix: a unit", sign_off=sign_off)), []) + + def test_treats_crlf_like_lf(self) -> None: + message = squash("fix: reject a unit (#7)", body="Closes #5").replace("\n", "\r\n") + self.assertEqual(check_squash_message(message), []) + + def test_reports_every_problem_once(self) -> None: + message = "Update files\n\n" + PLACEHOLDER_PARAGRAPH + "\n\nCloses #\n" + found = check_squash_message(message) + self.assertEqual(sorted(rules(found)), ["GIT-1", "GIT-2", "TELL-18", "TELL-18"]) + + +class HtmlCommentTest(unittest.TestCase): + def test_leaves_out_the_comment_that_renovate_ends_a_body_with(self) -> None: + message = squash("ci: update an action (#4)", body="Update a pinned tool or action.") + self.assertEqual(check_squash_message(message + RENOVATE_COMMENT + "\n"), []) + + def test_reads_the_comment_of_renovate_when_text_follows_it(self) -> None: + body = f"{RENOVATE_COMMENT} {EM_DASH} here." + found = check_squash_message(squash("ci: update an action (#4)", body=body)) + self.assertEqual( + [(v.rule, v.line, v.column) for v in found], + [("TELL-14", 3, len(RENOVATE_COMMENT) + 2)], + ) + + def test_reads_a_comment_that_starts_like_the_one_of_renovate_but_holds_other_text( + self, + ) -> None: + payloads = ((EMOJI, EMOJI), (f"a dash {EM_DASH}", EM_DASH), (E_ACUTE, E_ACUTE)) + for payload, character in payloads: + with self.subTest(payload=payload): + comment = f"" + message = squash("ci: update an action (#4)") + comment + "\n" + found = check_squash_message(message) + self.assertEqual( + [(v.rule, v.line, v.column) for v in found], + [("TELL-14", 6, comment.index(character) + 1)], + ) + + def test_leaves_out_the_comment_of_renovate_so_that_no_text_is_left(self) -> None: + # The title line is empty, so the first line with text would be the comment. + found = check_squash_message("\n" + RENOVATE_COMMENT + "\n") + self.assertEqual(found[0].message, "The message has no subject.") + + def test_leaves_out_the_comment_of_renovate_when_ascii_white_space_follows_it(self) -> None: + for tail in ("", " ", "\t", "\n", "\r\n", " \t\r\n\n"): + with self.subTest(tail=tail): + found = check_squash_message("\n" + RENOVATE_COMMENT + tail) + self.assertEqual(found[0].message, "The message has no subject.") + + def test_reads_the_comment_of_renovate_when_non_ascii_white_space_follows_it(self) -> None: + # \s matches each of these, so a pattern that ends in \s* hides them from TELL-14. + for code_point in (0x3000, 0x2028, 0x00A0, 0x0085, 0x2003): + with self.subTest(code_point=f"U+{code_point:04X}"): + message = squash("ci: update an action (#4)") + found = check_squash_message(message + RENOVATE_COMMENT + chr(code_point) + "\n") + self.assertEqual( + [(v.rule, v.line, v.column) for v in found], + [("TELL-14", 6, len(RENOVATE_COMMENT) + 1)], + ) + + def test_reads_the_text_that_a_comment_holds(self) -> None: + for text, character in ((EMOJI, EMOJI), (f"a dash {EM_DASH} here", EM_DASH)): + with self.subTest(text=text): + comment = f"" + body = f"Why the change is needed.\n\n{comment}" + found = check_squash_message(squash("fix: a unit", body=body)) + self.assertEqual( + [(v.rule, v.line, v.column) for v in found], + [("TELL-14", 5, comment.index(character) + 1)], + ) + + def test_reads_a_comment_that_spans_lines(self) -> None: + body = f"Why the change is needed.\n\n" + found = check_squash_message(squash("fix: a unit", body=body)) + self.assertEqual([(v.rule, v.line) for v in found], [("TELL-14", 7), ("TELL-18", 8)]) + + def test_keeps_the_place_of_the_text_around_a_comment(self) -> None: + body = f"Use a dash {EM_DASH} here." + found = check_squash_message(squash("fix: a unit", body=body)) + self.assertEqual([(v.rule, v.line, v.column) for v in found], [("TELL-14", 4, 7)]) + + def test_reports_the_first_bad_character_of_a_line_with_comments(self) -> None: + body = f"Fix the {EM_DASH} unit rule." + found = check_squash_message(squash("fix: a unit", body=body)) + self.assertEqual( + [(v.rule, v.line, v.column) for v in found], [("TELL-14", 3, body.index(EMOJI) + 1)] + ) + + def test_does_not_count_a_sign_off_that_a_comment_holds(self) -> None: + body = f"Why the change is needed.\n\n" + found = check_squash_message(f"fix: a unit\n\n{body}\n") + self.assertEqual(rules(found), ["GIT-2"]) + self.assertIn("The message has no Signed-off-by line.", found[0].message) + + def test_reads_a_comment_that_does_not_end_as_text(self) -> None: + body = f"Why the change is needed.\n\n")) + self.assertEqual([(v.rule, v.line) for v in found], [("TELL-14", 1)]) + + def test_reads_a_comment_in_a_message_with_crlf(self) -> None: + body = f"Why the change is needed.\n\n" + message = squash("fix: a unit", body=body).replace("\n", "\r\n") + found = check_squash_message(message) + self.assertEqual([(v.rule, v.line, v.column) for v in found], [("TELL-14", 6, 1)]) + + def test_leaves_out_nothing_in_a_commit_message_file(self) -> None: + message = f"fix: a unit\n\n\n" + self.assertEqual(rules(check_commit_message(message)), ["TELL-14"]) diff --git a/scripts/tests/test_check_tells_pull_request.py b/scripts/tests/test_check_tells_pull_request.py new file mode 100644 index 0000000..b2cd3d0 --- /dev/null +++ b/scripts/tests/test_check_tells_pull_request.py @@ -0,0 +1,239 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for check_tells.message_checks: the sign-off, the template text and the templates.""" + +import json +import unittest +from pathlib import Path +from typing import Any + +import check_tells +from check_tells import check_squash_message + +from tests.check_tells_driver import rules +from tests.check_tells_samples import E_ACUTE, PLACEHOLDER_PARAGRAPH, RENOVATE_COMMENT, squash + + +class SignOffTest(unittest.TestCase): + def test_requires_a_sign_off(self) -> None: + found = check_squash_message("fix: reject a unit\n\nWhy the change is needed.\n") + self.assertEqual(rules(found), ["GIT-2"]) + self.assertEqual((found[0].line, found[0].column), (1, 1)) + self.assertIn("The message has no Signed-off-by line.", found[0].message) + + def test_reads_each_form_of_a_sign_off(self) -> None: + forms = [ + "Signed-off-by: Ada Bello ", + "signed-off-by: Ada Bello ", + "SIGNED-OFF-BY: Ada Bello ", + "Signed-off-by: Ada Bello ", + "Signed-off-by: A ", + f"Signed-off-by: Ad{E_ACUTE} Bello ", + "Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>", + ] + for form in forms: + with self.subTest(form=form): + self.assertEqual(check_squash_message(squash("fix: a unit", sign_off=form)), []) + + def test_rejects_each_line_that_only_looks_like_a_sign_off(self) -> None: + forms = [ + "Signed-off-by:", + "Signed-off-by: Ada Bello", + "Signed-off-by: ", + "Signed-off-by: Ada Bello ", + "Signed-off-by: Ada Bello ada@example.org", + "Signed-off-by Ada Bello ", + "Reviewed-by: Ada Bello ", + "Co-authored-by: Ada Bello ", + "- Signed-off-by: Ada Bello ", + "Signed-off-by: Ada ", + ] + for form in forms: + with self.subTest(form=form): + found = check_squash_message(squash("fix: a unit", sign_off=form)) + self.assertEqual(rules(found), ["GIT-2"]) + + def test_rejects_the_placeholder_address_of_the_template(self) -> None: + sign_off = "Signed-off-by: Your Name " + found = check_squash_message(squash("fix: a unit", sign_off=sign_off)) + self.assertEqual(rules(found), ["GIT-2"]) + self.assertEqual((found[0].line, found[0].column), (5, 1)) + self.assertIn("placeholder address", found[0].message) + + def test_rejects_the_placeholder_address_in_any_letter_case(self) -> None: + sign_off = "Signed-off-by: Ada Bello " + found = check_squash_message(squash("fix: a unit", sign_off=sign_off)) + self.assertEqual(rules(found), ["GIT-2"]) + + def test_rejects_the_placeholder_address_next_to_a_real_sign_off(self) -> None: + body = "Signed-off-by: Your Name " + found = check_squash_message(squash("fix: a unit", body=body)) + self.assertEqual([(v.rule, v.line) for v in found], [("GIT-2", 3)]) + + def test_accepts_the_placeholder_address_in_prose(self) -> None: + body = "The template shows you@example.com in its sign-off line." + self.assertEqual(check_squash_message(squash("docs: fix a template", body=body)), []) + + +class TemplateTextTest(unittest.TestCase): + def test_rejects_the_placeholder_paragraph_of_the_template(self) -> None: + found = check_squash_message(squash("fix: a unit", body=PLACEHOLDER_PARAGRAPH)) + self.assertEqual(rules(found), ["TELL-18"]) + self.assertEqual((found[0].line, found[0].column), (3, 1)) + self.assertIn("placeholder paragraph", found[0].message) + + def test_rejects_the_paragraph_after_an_edit_of_its_end_or_its_layout(self) -> None: + bodies = [ + PLACEHOLDER_PARAGRAPH + " This change fixes a unit.", + " " + PLACEHOLDER_PARAGRAPH, + PLACEHOLDER_PARAGRAPH.removesuffix("."), + "Write two or three sentences of plain prose\nthat say what this change does.", + ] + for body in bodies: + with self.subTest(body=body): + found = check_squash_message(squash("fix: a unit", body=body)) + self.assertEqual(rules(found), ["TELL-18"]) + + def test_accepts_a_body_that_only_mentions_the_words(self) -> None: + bodies = [ + "The template asks the author to write two or three sentences of plain prose.", + "The template says: Write two or three sentences of plain prose.", + ] + for body in bodies: + with self.subTest(body=body): + found = check_squash_message(squash("docs: fix a template", body=body)) + self.assertEqual(found, []) + + def test_rejects_an_empty_closes_line(self) -> None: + for body in ("Closes #", "closes #", "Closes # ", " Closes #"): + with self.subTest(body=body): + found = check_squash_message(squash("fix: a unit", body=body)) + self.assertEqual(rules(found), ["TELL-18"]) + self.assertEqual(found[0].line, 3) + self.assertIn("Closes #", found[0].message) + + def test_accepts_a_closes_line_with_an_issue(self) -> None: + for body in ("Closes #5", "Closes #5, closes #6", "Fixes #12"): + with self.subTest(body=body): + self.assertEqual(check_squash_message(squash("fix: a unit", body=body)), []) + + +def read_template(name: str) -> str: + """Read a file of templates/, which the pull request check and the contributors share.""" + return (Path(__file__).resolve().parents[2] / "templates" / name).read_text(encoding="utf-8") + + +class PullRequestTemplateTest(unittest.TestCase): + TITLE = "fix: reject a unit of 00 (#7)" + + def test_holds_the_paragraph_the_closes_line_and_the_sign_off_line(self) -> None: + lines = read_template("PULL_REQUEST_TEMPLATE.md").split("\n") + self.assertEqual( + lines, + [ + PLACEHOLDER_PARAGRAPH, + "", + "Closes #", + "", + "Signed-off-by: Your Name ", + "", + ], + ) + + def test_has_no_heading_checklist_or_html_comment(self) -> None: + text = read_template("PULL_REQUEST_TEMPLATE.md") + starts = ("#", "- [", "* [", "`. With `--no-scope`, the subject must also have no scope, as GIT-1 asks of a repo without packages. +- **GIT-2.** The squash message must have a line `Signed-off-by: Name
`, and the address must not be the placeholder of the pull request template. With `--signed-off `, the tool checks that each commit in a git revision range has a `Signed-off-by` line. It skips merge commits, and it applies no other message rule to a commit, because only the squash message reaches `main`. + +A line in a code or config file can skip one check with a comment that names the rule and gives a reason, for example `check-tells: allow TELL-1 because the regex cannot be split`. Reviewers check each skip. + +The command exits with 0 when all files pass, with 1 when a rule is broken, and with 2 when its input is bad, for example a path that does not exist or a folder that is not a git repo. + +## Dependencies + +- **DEP-1 MUST.** A `core` package has no runtime dependencies. +- **DEP-2 MUST.** A client uses the platform's standard HTTP and JSON libraries, or the ones that its language file names. +- **DEP-3 MUST.** A new runtime dependency needs an ADR in `docs/adr/`. +- **DEP-4 MUST.** Each dependency uses a permissive licence, such as MIT, BSD, ISC or Apache-2.0. **(tool)** +- **DEP-5 MUST.** Lockfiles are in the repo. Renovate opens update pull requests each week. **(tool)** + +## Runtime versions + +- **VER-1 MUST.** Each language file states its runtime floors and the end-of-life date of each floor. A floor that is past its end of life needs an ADR that explains the trade-off. +- **VER-2 MUST.** Raise a floor only in a minor release. Announce the change in the changelog of the release before it. + +## Errors and logging + +- **ERR-1 MUST.** Each HTTP status maps to exactly one error code. The spec does not define the map yet. It will define the map with the contract scenarios, before the first client release. +- **ERR-2 MUST.** A library does not log. It returns or raises errors. A caller can attach a debug hook. +- **ERR-3 MUST.** No error, log or exception message contains an API key, a NIN, an email address, or a full postcode next to personal data. Use `redact()` for postcodes. +- **ERR-4 MUST.** An error message says what happened and what to do, in plain English. Example: "The postcode has 10 characters. A postcode has 11 characters." + +## Security and privacy + +- **SEC-1 MUST.** Client-side code refuses secret keys, which start with `nipost_test_` or `nipost_live_`. +- **SEC-2 MUST.** There is no telemetry. An SDK sends only the requests that the caller makes. +- **SEC-3 MUST.** The cache is off by default. When on, it has a time limit and a clear command. +- **SEC-4 MUST.** Call only the documented gateway endpoints. +- **SEC-5 MUST.** CI scans each pull request and each push to `main` for secrets. **(tool)** +- **SEC-6 MUST.** Each GitHub Action is pinned to a full commit SHA. **(tool)** +- **SEC-7 MUST.** Releases come only from CI. They carry provenance or a signature where the registry supports it. +- **SEC-8 MUST.** Each repo has `SECURITY.md`. A reporter gets a reply within 3 working days. + +## Tests + +- **T-1 MUST.** Write the failing test first for each change in behaviour. +- **T-2 MUST.** Every SDK runs every vector in `spec/vectors/` for the spec version that it implements. **(tool)** +- **T-3 MUST.** Every client passes every contract scenario in `spec/contract/` against the mock server in CI. The scenarios fix the shared client behaviour: error codes, retries, waits, caching and the limit on parallel requests. **(tool)** +- **T-4 MUST.** A test can fail. It asserts a specific value, and it does not repeat the logic under test. +- **T-5 MUST.** Unit tests use no network, no real clock and no sleep. +- **T-6 MUST.** A test name states the behaviour in domain words, for example "rejects a unit of 00". +- **T-7 MUST.** Coverage floors are 95 % of branches for core, 90 % for clients and 80 % for UI. UI coverage includes browser tests. **(tool)** +- **T-8 SHOULD.** Core runs mutation tests each week, with a target score of 85 %. **(tool)** +- **T-9 SHOULD.** The parser has property-based or fuzz tests, where the language has a standard tool for them. +- **T-10 MUST.** UI tests include keyboard-only use and an automated accessibility check. + +## Documentation + +- **DOC-1 MUST.** Each public symbol has a doc comment. It gives a one-line summary, each parameter, each error and one example. +- **DOC-2 MUST.** Each README follows `templates/README.md`. +- **DOC-3 MUST.** Each code example in a README or on the docs site runs in CI. +- **DOC-4 MUST.** Prose uses plain English. Write sentences of 25 words or fewer, with one idea in each. Use the active voice, and name who does the action. Use the plainest word, and use one word for one meaning. Keep every article and connector. Write a verb, not a noun that is made from it, and do not put more than three nouns in a row. Do not use phrasal verbs, semicolons, stacked hedges, em dashes or marketing words such as "powerful" and "seamless". Do not open with filler or close with a summary. Use British spelling. +- **DOC-5 MUST.** Each package has a changelog that change files generate. It follows the Keep a Changelog format, unless the release tool writes its own format. Changesets, the tool for npm packages, writes its own format. +- **DOC-6 SHOULD.** Record a decision as an ADR when it is hard to reverse, surprising, and the result of a real trade-off. An ADR is a short file in `docs/adr/` that gives the context, the decision and the reason. +- **DOC-7 MUST.** A new domain term goes into `CONTEXT.md` before it goes into code. + +## Git, pull requests and releases + +- **GIT-1 MUST.** Commits follow Conventional Commits. The scope names the package, or is repo, deps or release for a change outside a package. The spec repo has no packages. Its commits use no scope. **(tool)** +- **GIT-2 MUST.** Each commit has a DCO sign-off: `git commit -s`. **(tool)** +- **GIT-3 MUST.** Commits do not carry co-author lines for AI tools. The person who opens the pull request owns the change. +- **GIT-4 MUST.** Nobody force-pushes to `main` or to a shared branch. +- **GIT-5 SHOULD.** A pull request changes at most about 400 lines. Tests, vectors and generated files do not count. +- **GIT-6 MUST.** A pull request needs one approval and green CI. Merges use squash. The title of the pull request becomes the subject of the commit on `main`, and its body becomes the body, so both follow GIT-1, GIT-2 and TELL-18. +- **GIT-7 MUST.** A pull request that changes the public interface also updates the API report, the docs and a change file. **(tool)** +- **GIT-8 MUST.** Each package exposes a `SPEC_VERSION` constant. It names the spec version that the package implements. + +## UI + +These rules apply to the field, the docs site and every app screen. + +- **UI-1 MUST.** Meet WCAG 2.2 AA. +- **UI-2 MUST.** Each string comes from a [message catalogue](#the-check-tells-script), which is a data file. A code module of strings is code, so it uses ASCII (TELL-14). Do not build sentences by joining strings. A UI shows the catalogue string for an error code, not the library's error message. +- **UI-3 MUST.** Colours, radii and fonts come from theme tokens. Components contain no fixed colours. +- **UI-4 MUST.** Respect the user's reduced-motion setting and text size. +- **UI-5 SHOULD.** Design each new screen on purpose. Choose its layout, type and colour for the task, with the theme tokens of UI-3, and do not keep the defaults of a UI kit or a template. + +## Performance + +- **PERF-1 MUST.** Each package stays inside its size limit. **(tool)** +- **PERF-2 MUST.** Importing a package does no work: no network calls, no timers and no global registration. The field's custom element is the one exception. +- **PERF-3 SHOULD.** Web packages are tree-shakeable. + +## Licence and notices + +- **LIC-1 MUST.** Each file has licence information that `reuse lint` accepts. A file whose format has comments starts with two SPDX lines, one for the copyright and one for the licence `Apache-2.0`, unless `REUSE.toml` covers it. `REUSE.toml` covers Markdown, JSON and YAML files, and a few plain files such as `NOTICE`. **(tool)** +- **LIC-2 MUST.** Each repo has `LICENSE` and `NOTICE`. +- **LIC-3 MUST.** Each README and each package description says "Unofficial. Not made or endorsed by NIPOST." + +## Cross-language parity + +- **PAR-1 MUST.** An SDK that claims a spec version passes every vector and every contract scenario of that version. +- **PAR-2 MUST.** Error codes, defaults and option meanings are identical in every SDK. +- **PAR-3 MUST.** The docs site has a parity table. It shows each SDK, its version, its spec version and its status. + +## Gates + +### New package gate + +A new package can publish its first release only when all of these are true: + +- **NPG-1.** The repo has the community files from `templates/`. +- **NPG-2.** The language file's tools run in CI, and they pass. +- **NPG-3.** All vectors pass. +- **NPG-4.** The client passes every contract scenario against the mock server. +- **NPG-5.** The repo holds the API report or API dump. +- **NPG-6.** The README quickstart runs in CI. +- **NPG-7.** The coverage floors pass. +- **NPG-8.** A second person reviewed the whole package twice, once against these standards and once against the spec. Neither review has an open finding. + +### Release gate + +Each release meets all of these: + +- **REL-1.** CI is green on the release commit. +- **REL-2.** The change files describe each user-visible change. +- **REL-3.** Each breaking change has a migration note. +- **REL-4.** The release runs from CI, with provenance or a signature. + +### 1.0 gate + +A package can release 1.0 only when all of these are true: + +- **V1-1.** The spec is at 1.0. +- **V1-2.** The public interface did not change for 4 weeks. +- **V1-3.** At least two production users run the package. +- **V1-4.** The docs cover every public symbol and every error code. + +## Review checklist + +Tools check the **(tool)** rules. Reviewers check these by hand: + +1. The interface: API-1, API-3, API-7 and API-12. +2. The structure: CS-1, CS-7 and CS-8. +3. The errors: ERR-3 and ERR-4. +4. The tests: T-4 and T-6. +5. The docs: DOC-1, DOC-4 and DOC-7. +6. The tells that need a person: TELL-4, TELL-5, TELL-6, TELL-8, TELL-11, TELL-15, TELL-19 and TELL-20. +7. Code smells that no rule names, such as a function that does several jobs, or the same logic in two places. Treat each smell as a judgement call. diff --git a/standards/languages/csharp.md b/standards/languages/csharp.md new file mode 100644 index 0000000..0141c1a --- /dev/null +++ b/standards/languages/csharp.md @@ -0,0 +1,105 @@ +# C# standards + +Version 1.0, 1 Oct 2026. + +This file and `standards/CODING_STANDARDS.md` together are the standard for C# code. They apply to the `dotnet` repo. The repo publishes one package, `Gatepost.Postcode`, with the core types and the client. + +## Versions + +- The package targets `netstandard2.0` and `net10.0`. +- CI tests `net10.0` on Linux. CI also tests .NET Framework 4.8 on a Windows runner. +- .NET 10 has support until 14 Nov 2028. .NET Framework 4.8 has support for as long as the Windows version that it runs on. `netstandard2.0` is a specification, not a runtime, so it has no end-of-life date. +- On `netstandard2.0`, the package depends on `System.Text.Json`. An ADR records this under DEP-3. On `net10.0`, the package has no dependencies. +- The C# language version is `latest`. On `netstandard2.0`, `init` accessors need an internal `IsExternalInit` polyfill. + +## Tools + +`make check` runs every tool below. CI runs `make check` on each pull request and each push to `main`. + +| Tool | Job | Settings | +|---|---|---| +| `dotnet format --verify-no-changes` | format | `.editorconfig` | +| Roslyn analyzers | lint | `AnalysisLevel` `latest-recommended`, `TreatWarningsAsErrors`, `Nullable` `enable` | +| `GenerateDocumentationFile` | doc check | a missing XML doc comment fails the build | +| Microsoft.CodeAnalysis.PublicApiAnalyzers | API tracking | `PublicAPI.Shipped.txt` and `PublicAPI.Unshipped.txt` | +| Microsoft.CodeAnalysis.BannedApiAnalyzers | banned calls | `BannedSymbols.txt` | +| Microsoft.VisualStudio.Threading.Analyzers | async rules | the `Async` suffix and no `async void` | +| xUnit | unit, vector and contract tests | | +| coverlet | coverage | floors from T-7 | +| CsCheck | property tests | `Parse`, `Normalize` and the hierarchy methods | +| Stryker.NET | mutation tests | core types, weekly scheduled job | +| SourceLink, deterministic builds and symbol packages | debugging support | `ContinuousIntegrationBuild` in CI, `.snupkg` files | +| changie | change files and changelog | one change file per user-visible change | +| `spec/scripts/check-tells` | agent-tell checks | line length, emojis, other non-ASCII in code, the file names `utils`, `helpers`, `common` and `misc`, the `TODO(#123)` form and debug output | +| REUSE | licence headers | `reuse lint` | +| gitleaks | secret scan | each pull request and each push to `main` | + +### Agent-tell checks + +| Rule | Check in C# | +|---|---| +| TELL-1 | `max_line_length = 100` in `.editorconfig`. `dotnet format` does not enforce it, so `check-tells` does. | +| TELL-2 | CA1502 at error severity, with `CA1502: 10` in `CodeMetricsConfig.txt`. Nesting depth: review. | +| TELL-3 | review. Parameters with default values do not count. Options classes carry the rest, for example `LookupOptions`. | +| TELL-13 | BannedApiAnalyzers and `check-tells`. They ban `Console.Write`, `Console.WriteLine`, `Debug.WriteLine` and `Trace.WriteLine`. | +| TELL-14 | `check-tells` | + +## Rules + +- **NET-1 MUST.** Nullable reference types are on. The build treats warnings as errors. **(tool)** +- **NET-2 MUST.** Each public type and member has an XML doc comment with ``, ``, `` and ``. The build checks that the comment exists. Reviewers check its content. +- **NET-3 MUST.** Async methods end in `Async`. They take a `CancellationToken` as the last parameter. They call `ConfigureAwait(false)`. **(tool)** +- **NET-4 MUST.** When the caller's token is cancelled, the method throws `OperationCanceledException`. When the client's own timeout ends the call, the method throws `PostcodeException` with `ErrorCode.Timeout`. The client checks the caller's token to tell the two apart. +- **NET-5 MUST.** The caller can pass an `HttpClient`. The client never disposes an `HttpClient` that it did not create. +- **NET-6 MUST.** Validated value types are sealed classes with get-only properties. Do not use records for them, because `with` can bypass validation. +- **NET-7 MUST.** Options classes, such as `LookupOptions`, use `init` accessors. +- **NET-8 MUST.** `ErrorCode` is an enum. One internal table maps each member to its wire value, for example `RateLimited` to `rate_limited`. +- **NET-9 MUST.** Do not add idiom aliases, such as `TryParse`, unless the spec adds them first. This follows API-1. +- **NET-10 MUST.** Durations use `TimeSpan`. +- **NET-11 MUST.** A change to the public API updates `PublicAPI.Unshipped.txt`. A release moves the entries to `PublicAPI.Shipped.txt`. **(tool)** +- **NET-12 MUST.** Each `#pragma warning disable` and each `SuppressMessage` attribute gives a justification. +- **NET-13 MUST.** Code writes each non-ASCII or invisible character as an escape sequence, for example `"\u00A0"` and `"\u200B"`. **(tool)** +- **NET-14 SHOULD.** Prefer sealed classes and static methods. Do not add an interface that has one implementation. +- **NET-15 MUST.** Tests replace the clock through an internal seam, with `InternalsVisibleTo`, until the spec defines a clock option for API-10. +- **NET-16 MUST.** The client decodes JSON with `System.Text.Json`, which ignores unknown members by default. Do not set `UnmappedMemberHandling.Disallow`. A custom converter maps an unknown enum string to the fallback that the spec defines. This follows API-14. +- **NET-17 MUST.** A `SemaphoreSlim` with 4 slots limits each client to 4 requests at a time, as API-8 requires. A waiting call still ends when its `CancellationToken` is cancelled. + +## Names + +| Element | Style | Example | +|---|---|---| +| type, method, property | PascalCase | `PostcodeClient`, `LookupAsync` | +| parameter, local variable | camelCase | `allowPartial` | +| private field | underscore and camelCase | `_httpClient` | +| enum member | PascalCase | `ErrorCode.RateLimited` | +| constant | PascalCase | `Postcode.SpecVersion` | +| error code value | snake_case string | `"rate_limited"` | +| namespace | PascalCase | `Gatepost.Postcode` | +| file | one type per file, named after the type | `PostcodeClient.cs` | + +## Package layout + +``` +src/Gatepost.Postcode/ + Gatepost.Postcode.csproj + Postcode.cs + ParseResult.cs + PostcodeClient.cs + PublicAPI.Shipped.txt + PublicAPI.Unshipped.txt + BannedSymbols.txt + CodeMetricsConfig.txt +tests/Gatepost.Postcode.Tests/ unit, vector and contract tests +Directory.Build.props shared build and analyzer settings +.editorconfig +.changes/ changie fragments +Makefile +README.md from templates/README.md +CHANGELOG.md generated by changie +``` + +## Publishing + +- CI packs the package with `dotnet pack` and publishes it to NuGet.org with trusted publishing. +- Each package carries SourceLink data and a symbol package. +- A signed tag `vX.Y.Z` starts each release. diff --git a/standards/languages/dart.md b/standards/languages/dart.md new file mode 100644 index 0000000..89e858b --- /dev/null +++ b/standards/languages/dart.md @@ -0,0 +1,115 @@ +# Dart standards + +Version 1.0, 1 Oct 2026. + +This file and `standards/CODING_STANDARDS.md` together are the standard for Dart and Flutter code. They apply to the `dart` repo. The repo publishes two packages: `gatepost_postcode` is pure Dart and holds the core and the client. `gatepost_postcode_field` is the Flutter widget. + +## Versions + +- Dart 3.7 or later. `pubspec.yaml` sets `sdk: ^3.7.0`, because the formatter reads `page_width` only from language version 3.7. +- The widget package needs a Flutter release that ships Dart 3.7 or later. +- Dart and Flutter support only their newest stable release. No floor has a published end-of-life date. +- package:http is the one runtime dependency of `gatepost_postcode`. The core code does not import it. + +## Tools + +`make check` runs every tool below. CI runs `make check` on each pull request and each push to `main`. + +| Tool | Job | Settings | +|---|---|---| +| `dart format --set-exit-if-changed` | format | `page_width: 100` in `analysis_options.yaml` | +| `dart analyze --fatal-infos` | lint | very_good_analysis and the settings below | +| `flutter analyze --fatal-infos` | lint for the widget package | the same settings | +| `dart test` with the coverage package | unit, vector and contract tests | coverage floors from T-7 | +| `flutter test --coverage` | widget tests | semantics, text scaling and reduced motion | +| dart_apitool | API check against the last release | `diff`, with `--old` set to the last release and `--new` set to the working copy | +| pana | pub.dev score | 160 of 160 points | +| changie | change files and changelog | one change file per user-visible change | +| `spec/scripts/check-tells` | agent-tell checks | line length, emojis, other non-ASCII in code, the file names `utils`, `helpers`, `common` and `misc`, the `TODO(#123)` form and debug output | +| REUSE | licence headers | `reuse lint` | +| gitleaks | secret scan | each pull request and each push to `main` | + +### `analysis_options.yaml` + +```yaml +include: package:very_good_analysis/analysis_options.yaml + +formatter: + page_width: 100 + +linter: + rules: + lines_longer_than_80_chars: false + document_ignores: true +``` + +very_good_analysis turns on `lines_longer_than_80_chars`. This file turns it off, because TELL-1 sets the limit at 100. + +### Agent-tell checks + +| Rule | Check in Dart | +|---|---| +| TELL-1 | the formatter's `page_width: 100`, and `check-tells` for lines that the formatter cannot split, such as long strings | +| TELL-2 | review. The Dart analyzer has no complexity lint and no nesting lint. | +| TELL-3 | review. Named parameters do not count. The `avoid_positional_boolean_parameters` lint is on. | +| TELL-13 | the `avoid_print` lint, and `check-tells`. They ban `print` and `debugPrint`. | +| TELL-14 | `check-tells` | + +## Rules + +T-8 and T-9 do not apply to Dart. The shared vectors cover the parser. + +- **DART-1 MUST.** Results are sealed classes. `ParseResult` has one subclass for success and one for failure. Callers use an exhaustive `switch`. +- **DART-2 MUST.** Value types are `final class` types with private constructors and `final` fields. +- **DART-3 MUST.** `ErrorCode` is an enhanced enum with a `wireValue` field, for example `ErrorCode.rateLimited` has the value `'rate_limited'`. +- **DART-4 MUST.** The caller can pass an `http.Client`. The client closes only an `http.Client` that it created. +- **DART-5 MUST.** Each client call takes an optional `abortTrigger` future. The client sends an `AbortableRequest`. When the future completes, the call ends with the `RequestAbortedException` from package:http. `IOClient`, `BrowserClient` and `RetryClient` support this. If the caller's `http.Client` cannot abort, the timeout still ends the call. +- **DART-6 MUST.** Durations use `Duration`. +- **DART-7 MUST.** Each public member has a doc comment with an example. The analyzer checks that the comment exists. **(tool)** +- **DART-8 MUST.** Code lives in `lib/src/`. The file `lib/gatepost_postcode.dart` exports only the public API. +- **DART-9 MUST.** Each `// ignore:` comment has a comment that gives the reason. **(tool)** +- **DART-10 MUST.** Code writes each non-ASCII or invisible character as an escape sequence, for example `'\u00A0'` and `'\u200B'`. **(tool)** +- **DART-11 MUST.** The client parses JSON with `dart:convert` and hand-written `fromJson` functions. They read only the keys that they need. An unknown value of a known field maps to the fallback that the spec defines. This follows API-14. +- **DART-12 MUST.** An internal queue limits each client to 4 requests at a time, as API-8 requires. A fifth call waits until one of the first four ends. +- **DART-13 MUST.** Tests replace the clock through an internal seam until the spec defines a clock option for API-10. +- **DART-14 MUST.** Widgets use `const` constructors where possible. **(tool)** +- **DART-15 MUST.** Widgets read colours, radii and fonts from a `ThemeExtension`. They contain no fixed colours. +- **DART-16 MUST.** Each control has a semantics label. Widgets follow `MediaQuery.textScalerOf` and `MediaQuery.disableAnimationsOf`. + +## Names + +| Element | Style | Example | +|---|---|---| +| class, enum, typedef, extension | UpperCamelCase | `PostcodeClient` | +| function, method, variable, parameter | lowerCamelCase | `precisionForAccuracy` | +| constant | lowerCamelCase | `specVersion` | +| enum value | lowerCamelCase | `ErrorCode.rateLimited` | +| error code value | snake_case string | `'rate_limited'` | +| file and library | lowercase_with_underscores | `postcode_client.dart` | +| package | lowercase_with_underscores | `gatepost_postcode` | + +## Package layout + +``` +packages/gatepost_postcode/ + lib/gatepost_postcode.dart public exports only + lib/src/ one concept per file + test/ unit, vector and contract tests + analysis_options.yaml + pubspec.yaml + README.md from templates/README.md + CHANGELOG.md generated by changie +packages/gatepost_postcode_field/ + lib/gatepost_postcode_field.dart + lib/src/ + test/ widget tests + example/ a runnable example app +.changes/ changie fragments +Makefile +``` + +## Publishing + +- A GitHub Actions job publishes to pub.dev with automated publishing when a version tag reaches the repo. +- A verified publisher owns both packages. The publisher uses the team's domain. +- `dart pub publish --dry-run` runs on each pull request. diff --git a/standards/languages/go.md b/standards/languages/go.md new file mode 100644 index 0000000..e877262 --- /dev/null +++ b/standards/languages/go.md @@ -0,0 +1,155 @@ +# Go standards + +Version 1.0, 1 Oct 2026. + +This file and `standards/CODING_STANDARDS.md` together are the standard for Go code. They apply to the `go` repo. The repo publishes one module, `github.com/gatepost-dev/postcode-go`, with the package `postcode`. The package holds the core functions and the client. + +## Versions + +- The two newest Go releases. CI tests both. The `go` line in `go.mod` names the older one. +- Go supports each release until two newer releases exist. So the older floor loses support when the next Go release ships, in February or August. +- The module has no dependencies outside the standard library. +- Tools are not dependencies. The Makefile pins each tool version and runs it with `go run`. + +## Tools + +`make check` runs every tool below. CI runs `make check` on each pull request and each push to `main`. + +| Tool | Job | Settings | +|---|---|---| +| gofmt and goimports | format | `goimports -local github.com/gatepost-dev/postcode-go` | +| go vet | static checks | default analyzers | +| staticcheck | static analysis | default checks | +| golangci-lint | lint | the settings below | +| `go test` | unit, vector, contract and example tests | `-race -cover`, coverage floors from T-7 | +| `go test -fuzz` | fuzz tests | `FuzzParse` for 30 seconds on each pull request | +| govulncheck | vulnerability scan | each pull request and each push to `main` | +| gorelease | API check against the last tag | enforces GIT-7 and API-13 | +| changie | change files and changelog | one change file per user-visible change | +| `spec/scripts/check-tells` | agent-tell checks | line length, emojis, other non-ASCII in code, the file names `utils`, `helpers`, `common` and `misc`, the `TODO(#123)` form and debug output | +| REUSE | licence headers | `reuse lint` | +| gitleaks | secret scan | each pull request and each push to `main` | + +### golangci-lint settings + +```yaml +version: "2" +linters: + enable: + - errcheck + - errorlint + - exhaustive + - forbidigo + - gochecknoglobals + - gochecknoinits + - godot + - gosec + - lll + - misspell + - nolintlint + - revive + settings: + lll: + line-length: 100 + revive: + rules: + - name: argument-limit + arguments: [4] + - name: max-control-nesting + arguments: [3] + - name: cyclomatic + arguments: [10] + - name: exported + - name: var-naming + forbidigo: + forbid: + - pattern: ^fmt\.Print.*$ + - pattern: ^log\.(Print|Fatal|Panic).*$ + - pattern: ^print(ln)?$ + nolintlint: + require-explanation: true + require-specific: true + exclusions: + rules: + - path: _test\.go + linters: + - forbidigo +``` + +Example functions print their output, so `forbidigo` skips test files. + +### Agent-tell checks + +| Rule | Check in Go | +|---|---| +| TELL-1 | golangci-lint `lll` with `line-length: 100`, and `check-tells`. gofmt has no line limit. | +| TELL-2 | revive `max-control-nesting` 3 and `cyclomatic` 10 | +| TELL-3 | revive `argument-limit` 4. Functional options carry the rest. | +| TELL-13 | `forbidigo` and `check-tells`. They ban `fmt.Print`, `fmt.Printf`, `fmt.Println`, `log.Print`, `log.Fatal`, `log.Panic`, `print` and `println` in library code. | +| TELL-14 | `check-tells` | + +## Rules + +T-8 does not apply to Go. Fuzz tests cover T-9. + +- **GO-1 MUST.** `Parse` and `ParsePartial` return `(Postcode, error)`. A parse failure is a `*ParseError` with `Code`, `Segment` and `Suggestion`. +- **GO-2 MUST.** A client failure is a `*Error` with `Code`, `Status`, `APICode` and `RetryAfter`. Callers read it with `errors.As`. +- **GO-3 MUST.** Each I/O method takes `context.Context` as its first parameter. +- **GO-4 MUST.** If the caller's context ends, the method returns an error that wraps `ctx.Err()`. Then `errors.Is(err, context.Canceled)` works. The client's own 8-second timeout returns a `*Error` with `CodeTimeout`. +- **GO-5 MUST.** `NewClient` takes functional options: `NewClient(opts ...Option)`. Per-call options use the type `LookupOption`, for example `WithLevel(2)`. +- **GO-6 MUST.** The caller can pass an `*http.Client` with `WithHTTPClient`. The client never changes or closes it. +- **GO-7 MUST.** Library code does not panic. The package has no `init` functions and no package-level variables. **(tool)** +- **GO-8 MUST.** Value types keep their fields unexported. Accessor methods expose them, for example `p.Canonical()`. +- **GO-9 MUST.** Each exported symbol has a doc comment that starts with its name and ends with a period. **(tool)** +- **GO-10 MUST.** Each exported function and method has an `Example` function with an `// Output:` comment. +- **GO-11 MUST.** Initialisms keep one case, for example `LGA`, `API`, `URL` and `HTTP`. **(tool)** +- **GO-12 MUST.** Durations use `time.Duration`. +- **GO-13 MUST.** Error strings use lower case and end without punctuation. Wrapped errors use `%w`. **(tool)** +- **GO-14 MUST.** Each `//nolint` comment names the linter and gives a reason. **(tool)** +- **GO-15 MUST.** Names do not repeat the package name. Use `postcode.Client`, not `postcode.PostcodeClient`. **(tool)** +- **GO-16 MUST.** Code writes each non-ASCII or invisible character as an escape sequence, for example `"\u00a0"` and `"\u200b"`. **(tool)** +- **GO-17 MUST.** Contract tests carry the build tag `contract`. CI runs them against the mock server. +- **GO-18 MUST.** Tests replace the clock through an unexported field until the spec defines a clock option for API-10. +- **GO-19 MUST.** Each error string starts with `postcode:`, so that a caller can see which package made it. +- **GO-20 MUST.** The client decodes JSON with `encoding/json`, which ignores unknown fields. Do not call `DisallowUnknownFields`. An unknown value of a known field maps to the fallback that the spec defines. This follows API-14. +- **GO-21 MUST.** A buffered channel of size 4 limits each client to 4 requests at a time, as API-8 requires. A waiting call still ends when its context ends. + +## Names + +| Element | Style | Example | +|---|---|---| +| exported type, function, method | PascalCase | `ParsePartial`, `Client` | +| unexported identifier | camelCase | `normalize` | +| initialism | one case | `LGA`, `apiKey` | +| client error code | `Code` prefix | `CodeRateLimited` | +| parse error code | `ParseCode` prefix | `ParseCodeBadLength` | +| error code value | snake_case string | `"rate_limited"` | +| option | `With` prefix | `WithLevel`, `WithHTTPClient` | +| spec version | exported constant | `SpecVersion` | +| file | lower case, underscores | `parse.go`, `parse_test.go` | + +## Package layout + +``` +go.mod module github.com/gatepost-dev/postcode-go +doc.go package documentation +postcode.go the Postcode type +parse.go +client.go +errors.go +parse_test.go +example_test.go Example functions +fuzz_test.go +vectors_test.go runs spec/vectors/ +contract_test.go build tag contract, runs against the mock server +.changes/ changie fragments +Makefile +README.md from templates/README.md +CHANGELOG.md generated by changie +``` + +## Publishing + +- CI runs `gorelease` and then creates a signed tag `vX.Y.Z`. +- The Go module proxy serves each tagged version. +- After 1.0, a breaking change needs a new major version path, for example `/v2`. diff --git a/standards/languages/kotlin-java.md b/standards/languages/kotlin-java.md new file mode 100644 index 0000000..711f045 --- /dev/null +++ b/standards/languages/kotlin-java.md @@ -0,0 +1,116 @@ +# Kotlin and Java standards + +Version 1.0, 1 Oct 2026. + +This file and `standards/CODING_STANDARDS.md` together are the standard for Kotlin code that Kotlin and Java callers use. They apply to the `kotlin` repo. The repo publishes two artifacts: `postcode-core` and `postcode-client`. Later Android work follows them too, such as the Jetpack Compose field. + +## Versions + +- The newest stable Kotlin. The build emits JVM bytecode 11. +- Java callers need Java 11 or later. Eclipse Temurin 11 has support until at least 31 Oct 2027. +- Android code supports minSdk 21. No module depends on Google Play services. +- Android 5.0, which is API 21, gets no security updates. An ADR records why the floor stays at API 21: agent-banking POS terminals run old Android versions. +- `postcode-core` depends on the Kotlin standard library only. +- `postcode-client` depends on OkHttp, `kotlinx-coroutines-core` and `kotlinx-serialization-json`. An ADR records the two kotlinx dependencies under DEP-3. A `suspend` call can be cancelled only through `suspendCancellableCoroutine`. The JVM has no standard JSON library. + +## Tools + +`./gradlew check` runs every tool below. CI runs `./gradlew check` on each pull request and each push to `main`. + +| Tool | Job | Settings | +|---|---|---| +| Gradle Kotlin DSL with a version catalog | build | `gradle/libs.versions.toml` | +| Kotlin explicit API mode | public API discipline | `kotlin { explicitApi() }` | +| ktlint | format | `max_line_length = 100` in `.editorconfig` | +| detekt | lint | the settings below | +| binary-compatibility-validator | API dump | `apiCheck` runs in `check`. The `.api` files are in the repo. | +| JUnit 5 | unit, vector and contract tests | | +| kotest-property | property tests | `parse`, `normalize` and the hierarchy functions | +| Kover | coverage | floors from T-7 | +| PIT | mutation tests | `postcode-core`, weekly scheduled job | +| Dokka | API docs | every public symbol | +| Android Lint | Android checks | Android modules only, warnings as errors | +| changie | change files and changelog | one change file per user-visible change | +| `spec/scripts/check-tells` | agent-tell checks | line length, emojis, other non-ASCII in code, the file names `utils`, `helpers`, `common` and `misc`, the `TODO(#123)` form and debug output | +| REUSE | licence headers | `reuse lint` | +| gitleaks | secret scan | each pull request and each push to `main` | + +### detekt settings + +| Rule | Setting | Enforces | +|---|---|---| +| `style>MaxLineLength` | `maxLineLength: 100` | TELL-1 | +| `complexity>NestedBlockDepth` | `threshold: 4`, which allows 3 levels | TELL-2 | +| `complexity>CyclomaticComplexMethod` | the limit of 10 from TELL-2 | TELL-2 | +| `complexity>LongParameterList` | `functionThreshold: 5`, `constructorThreshold: 5`, `ignoreDefaultParameters: true` | TELL-3 | +| `style>ForbiddenMethodCall` | the debug calls listed under Agent-tell checks | TELL-13 | +| `potential-bugs>UnsafeCallOnNullableType` | active | KT-9 | + +### Agent-tell checks + +| Rule | Check in Kotlin | +|---|---| +| TELL-1 | ktlint `max_line_length = 100`, detekt `MaxLineLength`, and `check-tells` | +| TELL-2 | detekt `NestedBlockDepth` and `CyclomaticComplexMethod` | +| TELL-3 | detekt `LongParameterList`. Parameters with default values do not count, because Kotlin callers name them. | +| TELL-13 | detekt `ForbiddenMethodCall` and `check-tells`. They ban `println`, `print`, `System.out`, `System.err`, `printStackTrace` and the Android `Log` methods. | +| TELL-14 | `check-tells` | + +## Rules + +- **KT-1 MUST.** Explicit API mode is on. Each public declaration states its visibility and its return type. **(tool)** +- **KT-2 MUST.** Factory functions live in the companion object of their type, with `@JvmStatic`, for example `Postcode.parse`. Functions with default arguments carry `@JvmOverloads`. Blocking methods carry `@Throws(PostcodeException::class)`. +- **KT-3 MUST.** The public API uses no Kotlin-only types. Do not expose `kotlin.Result`, value classes or `kotlin.time.Duration`. Java callers can call each operation. +- **KT-4 MUST.** Each client operation has three forms. Kotlin callers use `suspend fun lookup()`. Java callers use `lookupBlocking()` or `lookupAsync(..., callback)`. +- **KT-5 MUST.** `lookupAsync` returns a `Cancellable` handle. After a call to `cancel()`, the callback never runs. +- **KT-6 MUST.** The client does not use `CompletableFuture`, because it needs Android API 24. +- **KT-7 MUST.** Callbacks run on a background thread. The docs tell Android callers to move to the main thread. +- **KT-8 MUST.** The client rethrows each `CancellationException`. It never wraps one in `PostcodeException`. +- **KT-9 MUST.** Do not use `!!` outside tests. **(tool)** +- **KT-10 MUST.** Validated value types have a private constructor. A data class that is a value type carries `@ConsistentCopyVisibility`, so its `copy` is private too. +- **KT-11 MUST.** Error codes use `enum class ErrorCode(val wireValue: String)`. +- **KT-12 MUST.** Durations are `Long` milliseconds with the suffix `Millis`, for example `timeoutMillis`. `java.time` needs Android API 26 or desugaring. +- **KT-13 MUST.** The caller can pass an `OkHttpClient`. The client never shuts down an `OkHttpClient` that it did not create. +- **KT-14 MUST.** Each public symbol has a KDoc comment with an example. Dokka renders it. +- **KT-15 MUST.** Each `@Suppress` annotation names the rule and has a comment that gives the reason. +- **KT-16 MUST.** Code writes each non-ASCII or invisible character as an escape sequence, for example `"\u00A0"` and `"\u200B"`. **(tool)** +- **KT-17 MUST.** Tests replace the clock through an internal seam until the spec defines a clock option for API-10. +- **KT-18 MUST.** The client decodes JSON with `kotlinx-serialization-json`, configured with `ignoreUnknownKeys = true` and `coerceInputValues = true`. An unknown enum value then takes the default value of its property, which is the fallback that the spec defines. This follows API-14. +- **KT-19 MUST.** A kotlinx.coroutines `Semaphore` with 4 permits limits each client to 4 requests at a time, as API-8 requires. The blocking and callback forms go through the same semaphore. + +## Names + +| Element | Style | Example | +|---|---|---| +| class, interface, enum | PascalCase | `PostcodeClient` | +| function, property, parameter | camelCase | `precisionForAccuracy` | +| enum entry | UPPER_SNAKE_CASE | `ErrorCode.RATE_LIMITED` | +| constant | UPPER_SNAKE_CASE | `Postcode.SPEC_VERSION` | +| error code value | snake_case string | `"rate_limited"` | +| package | lower case, no hyphens | `dev.gatepost.postcode` | +| file | PascalCase, named after its main type | `PostcodeClient.kt` | + +## Package layout + +``` +postcode-core/ + src/main/kotlin/dev/gatepost/postcode/ + src/test/kotlin/dev/gatepost/postcode/ unit, vector and property tests + api/postcode-core.api API dump +postcode-client/ + src/main/kotlin/dev/gatepost/postcode/client/ + src/test/kotlin/dev/gatepost/postcode/client/ unit and contract tests + api/postcode-client.api +gradle/libs.versions.toml +build.gradle.kts +settings.gradle.kts +.changes/ changie fragments +README.md from templates/README.md +CHANGELOG.md generated by changie +``` + +## Publishing + +- CI publishes both artifacts to Maven Central through the Central Portal. CI signs each artifact. +- The Maven group is `dev.gatepost` if the team owns `gatepost.dev`. Otherwise the group is `io.github.gatepost-dev`, and the package root is `io.github.gatepostdev.postcode`. +- A signed tag `vX.Y.Z` starts each release. diff --git a/standards/languages/php.md b/standards/languages/php.md new file mode 100644 index 0000000..1d13801 --- /dev/null +++ b/standards/languages/php.md @@ -0,0 +1,123 @@ +# PHP standards + +Version 1.0, 1 Oct 2026. + +This file and `standards/CODING_STANDARDS.md` together are the standard for PHP code. Part A applies to the SDK in the `php` repo. Part B applies to WordPress plugins, such as the WooCommerce plugin. Each part names its own code style, because the two ecosystems use different conventions. + +## Versions + +- PHP 8.1 or later. CI tests PHP 8.1 and the newest PHP release. +- PHP 8.1 reached end of life on 31 Dec 2025, and PHP 8.2 reaches it on 31 Dec 2026. The 8.1 floor serves WordPress stores on older hosting. Under VER-1, ADR 0001 must record this trade-off with current WordPress usage data before the first release. If the data does not support 8.1, raise the floor. +- PHPUnit 11 and later need PHP 8.2. CI runs PHPUnit 10 on PHP 8.1 and the newest PHPUnit on newer PHP versions. +- WordPress and WooCommerce: the floors are not defined yet. The team sets them after it tests the Additional Checkout Fields API, and before the first release of a plugin. + +## Part A: the SDK + +### Tools + +`composer check` runs every tool below. CI runs `composer check` on each pull request and each push to `main`. + +| Tool | Job | Settings | +|---|---|---| +| PHP-CS-Fixer | format and style | `@PER-CS`, `@PER-CS:risky`, `declare_strict_types`, `strict_comparison`, `strict_param`, `final_class`, `no_unused_imports`, `ordered_imports` | +| PHPStan | static analysis | level `max`, with `phpstan-strict-rules`, `phpstan-deprecation-rules` and `phpstan-phpunit` | +| PHPUnit 10 | unit and contract tests | coverage floors from T-7 | +| Infection | mutation tests | the core namespace, weekly scheduled job | +| Roave BackwardCompatibilityCheck | API check against the last tag | enforces GIT-7 and API-13 | +| `composer validate --strict` and `composer-normalize` | package checks | each pull request and each push to `main` | +| PHPMD | size and complexity | `CyclomaticComplexity` 10, `ExcessiveParameterList` minimum 5, enforces TELL-2 and TELL-3 | +| changie | change files and changelog | one change file per user-visible change, enforces DOC-5 | +| REUSE | licence headers | `reuse lint` | +| gitleaks | secret scan | each pull request and each push to `main` | +| `spec/scripts/check-tells` | agent tells | line length 100, file names, TODO form, debug calls, emojis and non-ASCII | + +PHP-CS-Fixer does not limit line length, so `check-tells` enforces TELL-1. PHPMD has no nesting rule, so reviewers check nesting for TELL-2. + +`check-tells` bans these debug calls in PHP: `var_dump`, `print_r`, `var_export`, `dd`, `dump`, `error_log` and `debug_print_backtrace`. + +### Rules + +- **PHP-1 MUST.** Each file declares `declare(strict_types=1);`. **(tool)** +- **PHP-2 MUST.** Namespaces follow PSR-4 under `Gatepost\Postcode\`. **(tool)** +- **PHP-3 MUST.** Classes are `final`, unless the spec names an extension point. **(tool)** +- **PHP-4 MUST.** Value objects have `readonly` properties. Their constructors are private, and named constructors create them, for example `Postcode::parse()`. +- **PHP-5 MUST.** Error codes are string-backed enums. Each case's value is the shared wire value, for example `ErrorCode::RateLimited` is `'rate_limited'`. +- **PHP-6 MUST.** The client depends only on the PSR-18, PSR-17 and PSR-16 interfaces. A concrete HTTP client, such as Guzzle, is a dev dependency only. +- **PHP-7 MUST.** There is no static mutable state. +- **PHP-8 MUST.** Each array has a PHPDoc shape or generic type, for example `list`. **(tool)** +- **PHP-9 MUST.** The package does not depend on `ext-intl` or `ext-mbstring`. Use PCRE patterns with the `u` flag, and static lookup tables with `strtr`. +- **PHP-10 MUST.** The package throws only `PostcodeException`. For programmer errors, it throws `InvalidArgumentException` or `LogicException`. +- **PHP-11 MUST.** Do not use the `@` operator or `eval`. **(tool)** +- **PHP-12 MUST.** Each method declares its parameter types and its return type. **(tool)** +- **PHP-13 SHOULD.** Examples and docs use named arguments, for example `$client->lookup($code, level: 2)`. +- **PHP-14 MUST.** The client decodes JSON into arrays and reads only the keys that it knows (API-14). +- **PHP-15 MUST.** The client is synchronous, so it sends one request at a time. This meets the limit in API-8. + +### Idiomatic additions (API-1) + +The PHP SDK follows the Interface section of `spec/grammar.md`. Its client constructor takes PSR-18, PSR-17 and PSR-16 objects. These are parameters, not new public symbols. + +### Names + +| Element | Style | Example | +|---|---|---| +| class, enum, interface | PascalCase | `PostcodeClient` | +| method, variable | camelCase | `precisionForAccuracy` | +| enum case | PascalCase | `ErrorCode::BadLength` | +| constant | UPPER_SNAKE_CASE | `SPEC_VERSION` | +| error code value | snake_case string | `'bad_length'` | + +### Package layout + +``` +src/ PSR-4 root for Gatepost\Postcode\ +src/Client/ the client and its errors +tests/Unit/ +tests/Contract/ runs against the mock server +tests/Vectors/ runs spec/vectors/ +README.md from templates/README.md +CHANGELOG.md +``` + +### Publishing + +- A Git tag `vX.Y.Z` on `main` publishes the version on Packagist. +- CI creates the tag. Tags are signed. + +## Part B: WordPress plugins + +### Tools + +`composer check` runs every tool below. CI runs it on each pull request and each push to `main`. + +| Tool | Job | Settings | +|---|---|---| +| PHPCS with WordPress Coding Standards 3 | style and security sniffs | `WordPress-Extra` and `WordPress-Docs` | +| PHPCompatibilityWP | PHP version check | `testVersion` 8.1 and later | +| PHPStan with `szepeviktor/phpstan-wordpress` | static analysis | level 8 | +| Plugin Check | WordPress.org review rules | `wp plugin check` on the built zip | +| PHPUnit with the WordPress test suite | integration tests | WooCommerce installed | +| Playwright | checkout tests | classic checkout and block checkout | +| Strauss | namespace prefixing for bundled packages | one prefix per plugin, `Gatepost\\Vendor\`, for example `Gatepost\WooCommerce\Vendor\` | +| PHPCS `Generic.Metrics` and `Generic.Files.LineLength` | agent tells | nesting level 3, cyclomatic complexity 10, line length 100 | +| changie | change files and changelog | one change file per user-visible change, enforces DOC-5 | +| `spec/scripts/check-tells` | agent tells | file names, TODO form, debug calls, emojis and non-ASCII | + +### Rules + +- **WP-1 MUST.** Plugin code follows WordPress Coding Standards 3. PER-CS does not apply to plugin code. **(tool)** +- **WP-2 MUST.** Each global function, class, hook, option, meta key and script handle starts with `gatepost_`, `Gatepost_` or the namespace `Gatepost\`. **(tool)** +- **WP-3 MUST.** Escape output late. Sanitize input early. Check a nonce and a capability before each write. **(tool)** +- **WP-4 MUST.** Each plugin bundles the PHP SDK under its own prefixed namespace with Strauss, for example `Gatepost\WooCommerce\Vendor\`. Two plugins that bundle different SDK versions, including two Gatepost plugins, must not clash. +- **WP-5 MUST.** Plugin Check reports no errors. **(tool)** +- **WP-6 MUST.** The plugin declares compatibility with HPOS and with the cart and checkout blocks. +- **WP-7 MUST.** Each user-facing string uses the plugin's text domain. A string with a placeholder has a translator comment. **(tool)** +- **WP-8 MUST.** The plugin stores only the postcode, the check result and the time of the check. It stores no other data from NIPOST's responses. +- **WP-9 MUST.** Uninstalling the plugin deletes its options. Order meta stays, because it belongs to the store's order records. +- **WP-10 MUST.** The plugin logs only through `wc_get_logger()`, with the source `gatepost`. The logs follow ERR-3. + +### Publishing + +- CI builds the zip with Strauss and runs Plugin Check on it. +- A GitHub release carries the zip. +- After WordPress.org approves the plugin, CI deploys each release to the WordPress.org SVN repository. diff --git a/standards/languages/python.md b/standards/languages/python.md new file mode 100644 index 0000000..50b8508 --- /dev/null +++ b/standards/languages/python.md @@ -0,0 +1,133 @@ +# Python standards + +Version 1.0, 1 Oct 2026. + +This file and `standards/CODING_STANDARDS.md` together are the standard for Python code. They apply to the `python` repo. The repo publishes one distribution, `gatepost-postcode`, with the import name `gatepost_postcode`. It holds the core functions, `PostcodeClient` and `AsyncPostcodeClient`. Later Python work follows them too, such as the QGIS plugin. + +## Versions + +- Python 3.11 or later. CI tests Python 3.11 and the newest Python release. +- Python 3.11 reaches end of life on 31 Oct 2027. +- httpx is the one runtime dependency of the client. The core has none. + +## Tools + +`make check` runs every tool below. CI runs `make check` on each pull request and each push to `main`. + +| Tool | Job | Settings | +|---|---|---| +| uv | environments, lockfile and builds | `uv.lock` is in the repo | +| hatchling | build backend | `pyproject.toml` | +| Ruff format | format | `line-length = 100` | +| Ruff lint | lint | the settings below | +| mypy | type check | `--strict` on `src/` and `tests/` | +| pytest with pytest-cov | unit, vector and contract tests | coverage floors from T-7 | +| Hypothesis | property tests | `parse`, `normalize` and the hierarchy functions | +| mutmut | mutation tests | the core modules, weekly scheduled job | +| griffe | API check against the last tag | `griffe check gatepost_postcode --against ` | +| towncrier | change files and changelog | one fragment in `changes/` per user-visible change | +| `spec/scripts/check-tells` | agent-tell checks | line length, emojis, other non-ASCII in code, the file names `utils`, `helpers`, `common` and `misc`, the `TODO(#123)` form and debug output | +| REUSE | licence headers | `reuse lint` | +| gitleaks | secret scan | each pull request and each push to `main` | + +### Ruff settings + +```toml +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +preview = true +explicit-preview-rules = true +select = [ + "E", "F", "W", "I", "B", "UP", "SIM", "RUF", "D", "ANN", "S", "PT", + "PL", "C4", "PIE", "RET", "ERA", "T20", "T10", "C90", +] +extend-select = ["PLR1702", "PLR0917"] +ignore = ["PLR0913"] + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.ruff.lint.pylint] +max-nested-blocks = 3 +max-positional-args = 4 + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101", "D", "ANN", "PLR2004"] +``` + +`PLR1702` is a preview rule. `explicit-preview-rules` turns on only the preview rules that the file names. `PLR0913` is off, because TELL-3 counts only positional parameters. `PLR0917` counts only positional parameters. + +### Agent-tell checks + +| Rule | Check in Python | +|---|---| +| TELL-1 | Ruff `E501` with `line-length = 100`, and `check-tells` | +| TELL-2 | Ruff `C901` with `max-complexity = 10`, and `PLR1702` with `max-nested-blocks = 3` | +| TELL-3 | Ruff `PLR0917` with `max-positional-args = 4`. Keyword-only parameters do not count. | +| TELL-13 | Ruff `T20` and `T10`, and `check-tells`. They ban `print`, `pprint`, `breakpoint` and `pdb.set_trace`. | +| TELL-14 | `check-tells` | + +## Rules + +- **PY-1 MUST.** The package ships a `py.typed` marker. Every public function and method has full type hints. **(tool)** +- **PY-2 MUST.** Value types are frozen dataclasses with slots: `@dataclass(frozen=True, slots=True)`. +- **PY-3 MUST.** Options are keyword-only. The text argument of `parse` is positional-only: `parse(text, /, *, allow_partial=False)`. +- **PY-4 MUST.** Error codes use `class ErrorCode(StrEnum)`. Each member's value is the shared wire value, for example `ErrorCode.RATE_LIMITED` is `"rate_limited"`. +- **PY-5 MUST.** `PostcodeClient` and `AsyncPostcodeClient` have the same methods, the same options and the same errors. Only `async` and `await` differ. +- **PY-6 MUST.** The caller can pass an `httpx.Client` or an `httpx.AsyncClient` through the keyword `http_client`. The client closes only an HTTP client that it created. +- **PY-7 MUST.** Each client is a context manager. `with` closes the sync client. `async with` closes the async client. +- **PY-8 MUST.** Durations are seconds as `float`, as in httpx. Names have no unit suffix: `timeout`, `cache_ttl` and `retry_after`. +- **PY-9 MUST.** The async client lets `asyncio.CancelledError` pass through. It never catches it and never wraps it. +- **PY-10 MUST.** Errors subclass `PostcodeError`, which subclasses `Exception`. A raised error keeps its cause with `raise ... from ...`. +- **PY-11 MUST.** Each public symbol has a Google-style docstring with an `Example:` section. Write the example with doctest prompts (`>>>`). **(tool)** +- **PY-12 MUST.** The package does not call `print`, `pprint`, `breakpoint` or `pdb.set_trace`. Importing a module has no side effects. **(tool)** +- **PY-13 MUST.** `__all__` in `gatepost_postcode/__init__.py` lists every public symbol. Other modules start with an underscore. +- **PY-14 MUST.** Each `noqa` and each `type: ignore` comment names the rule and gives a reason. +- **PY-15 SHOULD.** Prefer functions and dataclasses. The allowed classes with behaviour are the two clients and the errors. +- **PY-16 MUST.** Code writes each non-ASCII or invisible character as an escape sequence, for example `"\u00a0"` and `"\u200b"`. **(tool)** +- **PY-17 MUST.** Tests replace the clock through an internal seam until the spec defines a clock option for API-10. +- **PY-18 MUST.** The client reads JSON with the `json` module and reads each field that it needs by name. It ignores unknown fields. An unknown value of a known field maps to the fallback that the spec defines. This follows API-14. +- **PY-19 MUST.** A semaphore limits each client to 4 requests at a time, as API-8 requires. The async client uses `asyncio.Semaphore`. The sync client uses `threading.BoundedSemaphore`. + +## Names + +| Element | Style | Example | +|---|---|---| +| function, method, variable | snake_case | `precision_for_accuracy` | +| class, dataclass, enum | PascalCase | `ParseResult` | +| enum member | UPPER_SNAKE_CASE | `ErrorCode.BAD_LENGTH` | +| constant | UPPER_SNAKE_CASE | `SPEC_VERSION` | +| error code value | snake_case string | `"bad_length"` | +| private module | leading underscore | `_normalize.py` | +| keyword option | snake_case | `allow_partial`, `http_client` | + +## Package layout + +``` +src/gatepost_postcode/ + __init__.py public API, __all__ only + _parse.py one private module per concept + _client.py + _async_client.py + py.typed +tests/unit/ +tests/vectors/ runs spec/vectors/ +tests/contract/ runs against the mock server +changes/ towncrier fragments +pyproject.toml +uv.lock +README.md from templates/README.md +CHANGELOG.md generated by towncrier +``` + +## Publishing + +- A GitHub Actions job builds with `uv build`. +- The job publishes to PyPI with trusted publishing and attestations. +- Pre-releases use PEP 440 suffixes, for example `0.2.0a1`. diff --git a/standards/languages/swift.md b/standards/languages/swift.md new file mode 100644 index 0000000..7f3abdb --- /dev/null +++ b/standards/languages/swift.md @@ -0,0 +1,109 @@ +# Swift standards + +Version 1.0, 1 Oct 2026. + +This file and `standards/CODING_STANDARDS.md` together are the standard for Swift code. They apply to the `swift` repo. The repo publishes one Swift package. Its product `GatepostPostcode` holds the core and the client. A later product, `GatepostPostcodeUI`, holds the SwiftUI field. + +## Versions + +- Swift 6 language mode with complete strict concurrency checking. +- iOS 15 and macOS 12 or later. +- Apple publishes no end-of-life dates. iOS 15 and macOS 12 no longer get regular updates. An ADR records why the floors stay: NIPOST's own iOS widget supports iOS 15. +- The package has no dependencies outside Apple's swiftlang projects. `swift-docc-plugin` is the one package dependency, and only the docs build uses it. + +## Tools + +`make check` runs every tool below. CI runs `make check` on each pull request and each push to `main`. + +| Tool | Job | Settings | +|---|---|---| +| swift-format | format and lint | `lint --strict`, `lineLength` 100 in `.swift-format` | +| SwiftLint | lint | `--strict`, the settings below | +| `swift test` with Swift Testing | unit, vector and contract tests | `--enable-code-coverage` | +| llvm-cov | coverage | floors from T-7 | +| `swift package diagnose-api-breaking-changes` | API check against the last tag | runs on a macOS runner | +| DocC | API docs | `swift-docc-plugin` | +| changie | change files and changelog | one change file per user-visible change | +| `spec/scripts/check-tells` | agent-tell checks | line length, emojis, other non-ASCII in code, the file names `utils`, `helpers`, `common` and `misc`, the `TODO(#123)` form and debug output | +| REUSE | licence headers | `reuse lint` | +| gitleaks | secret scan | each pull request and each push to `main` | + +### SwiftLint settings + +```yaml +line_length: 100 +cyclomatic_complexity: 10 +function_parameter_count: + warning: 4 + ignores_default_parameters: true +opt_in_rules: + - force_unwrapping + - implicitly_unwrapped_optional +``` + +`--strict` turns each warning into a failure. + +### Agent-tell checks + +| Rule | Check in Swift | +|---|---| +| TELL-1 | swift-format `lineLength` 100, and SwiftLint `line_length` 100 | +| TELL-2 | SwiftLint `cyclomatic_complexity` 10. Nesting of control flow: review. | +| TELL-3 | SwiftLint `function_parameter_count` 4 with `ignores_default_parameters: true`. Parameters with default values do not count. | +| TELL-13 | `check-tells`. It bans `print`, `debugPrint`, `dump` and `NSLog`. | +| TELL-14 | `check-tells` | + +## Rules + +T-8 and T-9 do not apply to Swift. Swift has no standard tool for mutation tests or property tests, and the shared vectors cover the parser. + +- **SW-1 MUST.** Follow the Swift API Design Guidelines. Argument labels make each call read as a phrase, for example `client.lookup(code, level: 2)`. +- **SW-2 MUST.** Each public type conforms to `Sendable`. **(tool)** +- **SW-3 MUST.** Value types are structs with `let` properties. The static `Postcode.parse(_:allowPartial:)` creates them and returns `Result`. +- **SW-4 MUST.** Client methods are `async throws`. They throw `PostcodeError`, or `CancellationError` when the task is cancelled. Do not use typed throws for them, because `throws(PostcodeError)` cannot pass on `CancellationError`. +- **SW-5 MUST.** Cancellation follows the task. A cancelled task ends its request, and the method throws `CancellationError`. +- **SW-6 MUST.** `URLSession` reaches the client through a small public protocol with one method. Tests pass a fake that conforms to it. The caller's session stays open after the client ends. +- **SW-7 MUST.** Durations use `TimeInterval` seconds. The Swift `Duration` type needs iOS 16. +- **SW-8 MUST.** Do not use force unwraps, `try!` or implicitly unwrapped optionals outside tests. **(tool)** +- **SW-9 MUST.** `ErrorCode` is a `String` enum. Each raw value is the shared wire value, for example `case rateLimited = "rate_limited"`. +- **SW-10 MUST.** Each public symbol has a DocC comment with a summary, `- Parameters:`, `- Throws:` and a code example. +- **SW-11 MUST.** Each `swiftlint:disable` comment names the rule and gives a reason. Prefer `swiftlint:disable:next` for one line. +- **SW-12 MUST.** Code writes each non-ASCII or invisible character as an escape sequence, for example `"\u{00A0}"` and `"\u{200B}"`. **(tool)** +- **SW-13 MUST.** Tests replace the clock through an internal seam until the spec defines a clock option for API-10. +- **SW-14 MUST.** The client decodes JSON with `Codable`, which ignores unknown keys. A custom `init(from:)` maps an unknown raw value of a known enum to the fallback that the spec defines. This follows API-14. +- **SW-15 MUST.** An internal actor limits each client to 4 requests at a time, as API-8 requires. A waiting call still ends when its task is cancelled. + +## Names + +| Element | Style | Example | +|---|---|---| +| type, protocol | UpperCamelCase | `PostcodeClient` | +| function, method, property, variable | lowerCamelCase | `precisionForAccuracy` | +| enum case | lowerCamelCase | `ErrorCode.rateLimited` | +| constant | lowerCamelCase static property | `Postcode.specVersion` | +| error code value | snake_case raw value | `"rate_limited"` | +| module and product | UpperCamelCase | `GatepostPostcode` | +| file | named after its main type | `PostcodeClient.swift` | + +## Package layout + +``` +Package.swift +Sources/GatepostPostcode/ + Postcode.swift + ParseError.swift + PostcodeClient.swift + GatepostPostcode.docc/ DocC catalogue +Tests/GatepostPostcodeTests/ unit, vector and contract tests +.swift-format +.swiftlint.yml +.changes/ changie fragments +Makefile +README.md from templates/README.md +CHANGELOG.md generated by changie +``` + +## Publishing + +- CI runs the API check and then creates a signed tag without a `v` prefix, for example `1.2.0`. Swift Package Manager reads the tags. +- Do not publish to CocoaPods. Swift Package Manager is the one channel. diff --git a/standards/languages/typescript.md b/standards/languages/typescript.md new file mode 100644 index 0000000..acf58ac --- /dev/null +++ b/standards/languages/typescript.md @@ -0,0 +1,154 @@ +# TypeScript standards + +Version 1.0, 1 Oct 2026. + +This file and `standards/CODING_STANDARDS.md` together are the standard for TypeScript code. They apply to every package and app in the `js` repo: core, client, field, React wrapper, docs site and demos. Later TypeScript work follows them too, such as the MCP server, the n8n node and the spreadsheet add-ins. + +## Versions + +- Published packages run on Node 22 or later. Node 22 reaches end of life on 30 Apr 2027. Raise the floor to Node 24 before then (VER-2). +- The development tools need Node 22.22.2 or a later 22.x release, or Node 24.15 or later. CI runs the tests on Node 22 and Node 24, and the other tools on Node 24. +- TypeScript 6.0. typescript-eslint supports only TypeScript versions below 6.1, so TypeScript 7 waits until typescript-eslint supports it. This was checked on 1 Oct 2026. +- React 18 and React 19, for `@gatepost/react`. +- Current evergreen browsers and Safari 16.4 or later, for browser code. +- Packages ship ESM only. + +## Tools + +`pnpm check` runs every tool below. CI runs `pnpm check` on each pull request and each push to `main`. + +| Tool | Job | Settings | +|---|---|---| +| `tsc --noEmit` | type check | `tsconfig.base.json`, see below | +| ESLint with `typescript-eslint` | lint | `strictTypeChecked` and `stylisticTypeChecked`, plus the rules below | +| Prettier | format | `singleQuote: true`, `printWidth: 100`, other options at default | +| Vitest with V8 coverage | unit tests | coverage floors from T-7 | +| fast-check | property tests | `parse`, `normalize` and the hierarchy functions | +| Playwright with `@axe-core/playwright` | browser and accessibility tests | field and docs site | +| monocart-coverage-reports | merged coverage | merges Vitest and Playwright V8 coverage for the field, so T-7 counts browser tests | +| StrykerJS | mutation tests | `@gatepost/core`, weekly scheduled job | +| size-limit | size limits | core 4 KB, client 5 KB, field 20 KB, compressed, as size-limit measures them | +| publint and `@arethetypeswrong/cli` | package checks | every published package | +| API Extractor | API report | `etc/.api.md`, checked in CI | +| Changesets | versions and changelogs | one change file per user-visible change | +| commitlint | commit messages | `@commitlint/config-conventional`, `scope-enum` with the scopes `core`, `client`, `field`, `react`, `repo`, `deps` and `release`, `scope-empty` set to `never` so each commit needs a scope (GIT-1), and `header-max-length` set to 72 characters (TELL-18) | +| REUSE | licence headers | `reuse lint` | +| gitleaks | secret scan | each pull request and each push to `main` | +| `spec/scripts/check-tells` | agent tells | line length 100, file names, TODO form, debug calls, emojis and non-ASCII | +| TypeDoc | API pages | the docs site renders them | + +### `tsconfig.base.json` + +```json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "verbatimModuleSyntax": true, + "isolatedDeclarations": true, + "declaration": true, + "sourceMap": true, + "skipLibCheck": false, + "noEmit": true + } +} +``` + +tsdown builds the packages, so `tsc` only checks types. Each package's `tsconfig.json` includes `src` and `test`. Config files, such as `vitest.config.ts`, stay outside the TypeScript project, because `isolatedDeclarations` rejects their default exports. ESLint checks config files without type information. + +### Extra ESLint rules + +| Rule | Setting | Enforces | +|---|---|---| +| `no-restricted-syntax` for `TSEnumDeclaration` and `TSModuleDeclaration` | error | TS-5 | +| `no-restricted-exports` with `restrictDefaultExports` | error | TS-1 | +| `no-console` | error in packages | TS-12 | +| `@eslint-community/eslint-comments/require-description` | error | TS-13 | +| `eslint --max-warnings 0` | a warning fails the check, such as an unused `eslint-disable` comment | TS-13 | +| `jsdoc/require-jsdoc` with `publicOnly: true`, and `jsdoc/require-example` for exported functions, with `exemptedBy: ['internal']` | error in packages | TS-9 | +| `jsdoc/tag-lines` with `startLines: 1` | error in packages | one blank line between a description and its tags | +| `@typescript-eslint/explicit-module-boundary-types` | error in `packages/*/src` | TS-8 | +| `@typescript-eslint/no-non-null-assertion` | off in `packages/*/test` | TS-4 | +| `no-restricted-imports` for every Node built-in module, by bare name and as `node:*` | error in `packages/*/src` | CS-2 | +| `no-restricted-globals` for `process`, `Buffer`, `__dirname`, `__filename`, `global` and `require` | error in `packages/*/src` | CS-2 | +| `max-depth` | 3 | TELL-2 | +| `complexity` | 10 | TELL-2 | +| `max-params` | 4 | TELL-3 | +| `no-empty` with `allowEmptyCatch: false` | error | TELL-9 | +| `@typescript-eslint/no-unnecessary-condition` | error | TELL-10 | +| `@typescript-eslint/require-await` | error | TELL-16 | +| `no-nested-ternary` | error | TELL-17 | + +Prettier's `printWidth: 100` matches TELL-1. Prettier does not split long strings or comments, so `check-tells` also checks line length. + +`check-tells` bans these debug calls in TypeScript: `console.log`, `console.debug`, `console.dir` and `debugger`. + +## Rules + +- **TS-1 MUST.** Use named exports only. **(tool)** +- **TS-2 MUST.** Each package has one public entry, `src/index.ts`. The `exports` map in `package.json` has only `.`, plus the field's custom element entry. **(tool)** +- **TS-3 MUST.** Do not use `any`. Narrow `unknown` instead. **(tool)** +- **TS-4 MUST.** Do not use non-null assertions outside tests. **(tool)** +- **TS-5 MUST.** Do not use `enum` or `namespace`. Use string literal unions and modules. **(tool)** +- **TS-6 MUST.** Public types use `readonly` properties and `readonly` arrays, written `readonly T[]`. +- **TS-7 MUST.** A result type is a discriminated union with an `ok` field. +- **TS-8 MUST.** Each exported function and method declares its return type. **(tool)** +- **TS-9 MUST.** Each exported function has a TSDoc comment with `@example`. Each exported type and constant has a TSDoc comment. A helper that other modules use, but that `src/index.ts` does not export, carries the tag `@internal`. **(tool)** +- **TS-22 MUST.** Write an options object type inline as `Readonly<{ name?: Type }>`, and document it with one `@param options` line. The literal `readonly` form fails `eslint-plugin-jsdoc`, and a dotted `@param` name fails the TSDoc parser that API Extractor uses. +- **TS-10 MUST.** An error class extends `Error`, sets `name` and passes `cause`. +- **TS-11 MUST.** A `catch` block treats its value as `unknown` and narrows it. **(tool)** +- **TS-12 MUST.** Packages do not call `console`. **(tool)** +- **TS-13 MUST.** Each `eslint-disable` comment gives a reason. **(tool)** +- **TS-14 MUST.** Each package ships ESM with an `exports` map and type declarations. **(tool)** +- **TS-15 MUST.** Browser code does not read `window`, `document` or `customElements` at import time. The field's guarded registration is the one exception. +- **TS-16 SHOULD.** Prefer functions and plain objects. The allowed classes are `PostcodeClient`, `PostcodeError` and the custom element. +- **TS-17 MUST.** File names use kebab-case. Tests sit in `test/` and end in `.test.ts`. +- **TS-18 MUST.** Type-only imports use `import type`. **(tool)** +- **TS-19 MUST.** `@gatepost/react` supports React 18 and React 19. It uses `forwardRef` while it supports React 18. Each component starts with `'use client'`. +- **TS-20 MUST.** The client reads only the response fields that it knows and ignores the rest (API-14). It parses each response with a narrowing function, not with a type assertion. +- **TS-21 MUST.** The client limits parallel requests with an internal queue, set by API-8. Batch tools, such as spreadsheet add-ins, call the client and add no parallelism of their own. + +### Idiomatic additions (API-1) + +The TypeScript SDK follows the Interface section of `spec/grammar.md` exactly. It adds no idiomatic symbols. + +## Names + +| Element | Style | Example | +|---|---|---| +| function, variable, method | camelCase | `precisionForAccuracy` | +| type, class, interface | PascalCase | `ParseResult` | +| exported constant | UPPER_SNAKE_CASE | `SPEC_VERSION` | +| error code value | snake_case string | `'rate_limited'` | +| custom element | kebab-case with prefix | `gatepost-postcode-field` | +| DOM event | kebab-case with prefix | `gatepost-change` | +| CSS custom property | kebab-case with prefix | `--gatepost-accent` | +| file | kebab-case | `precision-for-accuracy.ts` | + +## Package layout + +``` +packages// + src/index.ts public entry, exports only + src/.ts one module per file + test/.test.ts + etc/.api.md API Extractor report + package.json + README.md from templates/README.md + CHANGELOG.md generated by Changesets +``` + +## Publishing + +- Changesets opens the release pull request. +- A GitHub Actions job publishes with npm trusted publishing and provenance. +- Pre-releases use the dist-tag `alpha` or `beta`. Stable releases use `latest`. diff --git a/templates/AGENTS.md b/templates/AGENTS.md new file mode 100644 index 0000000..570be8a --- /dev/null +++ b/templates/AGENTS.md @@ -0,0 +1,30 @@ +# AGENTS.md + +This repo is part of Gatepost, unofficial open-source developer tools for Nigeria's National Digital Postcode. {{REPO_PURPOSE}} + +## Read first + +- `CODING_STANDARDS.md` and the two files it names hold the rules. Cite rule IDs, such as `API-3`, in reviews and commit bodies. +- `CONTEXT.md` is the glossary. Name things with its terms in code, tests, docs and commits. +- `docs/adr/` holds the decisions. When a change contradicts an ADR, say so in the pull request. +- `spec/` holds the grammar, with its Interface section, and the vectors. The spec wins over code and over these docs. + +## Rules that no config file shows + +- Call only the documented NIPOST gateway endpoints. +- Commit only NIPOST's published test codes and synthetic values, such as `FC-01-Z99-ZZ-01`. +- Sign off each commit with `git commit -s`. The person who opens the pull request is the only author. Leave out co-author lines for AI tools. +- Change `main` only through pull requests. Keep the history of `main` and of other people's branches as it is: never force-push to them. +- If a commit lacks its sign-off, run `git rebase --signoff` with the base branch. Then push to your own branch with `--force-with-lease`. +- Send every call to the gateway through the client. The client limits how many calls run at once. +- Write prose in plain British English, with short sentences in the active voice. +- Some agent tools change a typed backslash-u escape, such as the one for an en dash, into the character itself. Build such characters with code, for example `chr(0x2013)` in Python or `String.fromCodePoint(0x2013)` in TypeScript. In data and prose, write `U+2013`. + +## Done means all of these + +1. A failing test came first, and it passes now. +2. `{{CHECK_COMMAND}}` passes. It runs the formatter, linters, tests, vectors, size limits and `check-tells`. +3. Each user-visible change has a change file. +4. A change to the public interface also updates the API report and the docs. +5. Each new domain term is in `CONTEXT.md`. +6. The diff touches only the lines that the task needs. diff --git a/templates/CLAUDE.md b/templates/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/templates/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/templates/CODING_STANDARDS.md b/templates/CODING_STANDARDS.md new file mode 100644 index 0000000..3baa029 --- /dev/null +++ b/templates/CODING_STANDARDS.md @@ -0,0 +1,8 @@ +# Coding standards + +This repo follows two files. Both are part of its standard: + +1. `spec/standards/CODING_STANDARDS.md` holds the rules for every Gatepost repo. +2. `spec/standards/languages/{{LANGUAGE_FILE}}` holds the rules for {{LANGUAGE_NAME}}. + +Rules for this repo only: {{REPO_RULES}} diff --git a/templates/CONTRIBUTING.md b/templates/CONTRIBUTING.md new file mode 100644 index 0000000..d99a0fc --- /dev/null +++ b/templates/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# Contributing to Gatepost + +Thank you for your interest in Gatepost. This guide explains how to make a change that we can merge. + +Gatepost is unofficial. For problems with NIPOST's own app, data or API, contact NIPOST through the contact details on its official website. + +## Before you start + +1. Read `CODING_STANDARDS.md` and the files it names. Reviews cite their rule IDs. +2. Read `CONTEXT.md`. Use its terms in code, tests and docs. +3. For a change bigger than a small fix, open an issue first. Agree on the approach before you write code. +4. A change to the public interface starts in the `spec` repo (API-1). + +## Make a change + +1. Create a branch named `/`, for example `fix/unit-zero-message`. +2. Write a failing test first (T-1). +3. Make the test pass. +4. Run the repo's check command. The README of the repo names it. +5. Add a change file for each user-visible change (DOC-5). +6. If you changed the public interface, update the API report and the docs (GIT-7). + +## Commit + +- Use Conventional Commits, for example `fix(core): reject a unit of 00` (GIT-1). The `spec` repo has no packages, so its commits use no scope, as in `fix: reject a unit of 00`. +- Keep the subject at 72 characters or fewer. Explain why in the body (TELL-18). +- Sign off each commit with `git commit -s` (GIT-2). The sign-off states that you accept the Developer Certificate of Origin at https://developercertificate.org. +- If you forget a sign-off, run `git rebase --signoff` with the base branch of your pull request, for example `git rebase --signoff origin/main`. Then push to your own branch with `git push --force-with-lease`. GIT-4 forbids a force-push only to `main` and to shared branches. +- You are the only author of your commits. Do not add co-author lines for AI tools (GIT-3). + +## AI-assisted contributions + +You can use AI tools. You are responsible for each line that you submit. If an AI tool wrote a large part of the change, say which parts in the pull request. The same standards apply to every line, whoever wrote it. + +## Open a pull request + +The title and the body of the pull request become the commit message on `main`, and CI checks both. + +1. Keep it to one topic. Aim for 400 changed lines or fewer, not counting tests, vectors and generated files (GIT-5). +2. Write the title as a Conventional Commits subject, for example `fix(core): reject a unit of 00` (GIT-1). GitHub adds ` (#N)` to it, and the whole subject has 72 characters or fewer (TELL-18). The `spec` repo uses no scope, so its titles look like `fix: reject a unit of 00`. +3. Fill in the pull request template. + - Replace the first paragraph with two or three sentences of plain prose that say what the change does and why. + - Replace `Closes #` with the number of the issue that the change closes, or remove the line. + - Replace the sign-off line with your own name and address (GIT-2). +4. Before you ask for a review, check these points. + - A failing test came first, and it passes now (T-1). + - The repo's check command passes on your machine. + - Each user-visible change has a change file (DOC-5). + - If the public interface changed, you updated the API report and the docs (GIT-7). + - Each new domain term is in `CONTEXT.md` (DOC-7). + - The diff touches only the lines that the task needs (TELL-15). + - Each commit has a sign-off from `git commit -s` (GIT-2). +5. Wait for green CI and one approval. We merge with squash (GIT-6). + +## Rules from NIPOST + +Gatepost follows NIPOST's terms and acceptable use policy: + +- Call only the documented gateway endpoints. +- Commit only NIPOST's published test codes and synthetic values. Do not commit real API responses, NINs, email addresses or the postcodes of private homes. +- Do not use NIPOST's name or logo as branding. + +## Licence + +You submit each contribution under the Apache-2.0 licence. diff --git a/templates/ISSUE_TEMPLATE/bug_report.yml b/templates/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..49504cb --- /dev/null +++ b/templates/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,47 @@ +name: Bug report +description: A Gatepost package does not work as its docs say. +labels: [bug, needs-triage] +body: + - type: markdown + attributes: + value: > + Gatepost is unofficial. For problems with NIPOST's own app, data or API, + contact NIPOST through the contact details on its official website. + + Do not paste API keys, NINs, email addresses or the postcodes of private homes. + Use synthetic postcodes, such as FC-01-Z99-ZZ-01. + - type: input + id: package + attributes: + label: Package and version + placeholder: "@gatepost/client 0.2.1" + validations: + required: true + - type: input + id: runtime + attributes: + label: Runtime and version + placeholder: "Node 22.11, Chrome 131, PHP 8.3 or iOS 18" + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: The smallest code that shows the problem. + render: text + validations: + required: true + - type: textarea + id: expected + attributes: + label: What you expected + validations: + required: true + - type: textarea + id: actual + attributes: + label: What happened + description: Include the error code, if there is one. + validations: + required: true diff --git a/templates/ISSUE_TEMPLATE/config.yml b/templates/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..4a72d6f --- /dev/null +++ b/templates/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Question + url: https://github.com/orgs/gatepost-dev/discussions + about: Ask how to use a Gatepost package. + - name: Security problem + url: https://github.com/gatepost-dev/.github/blob/main/SECURITY.md + about: Report a vulnerability privately. + - name: NIPOST's own app or data + url: https://postcode.gov.ng/faq + about: Gatepost is unofficial. Contact NIPOST about NIPOST's services. diff --git a/templates/ISSUE_TEMPLATE/feature_request.yml b/templates/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..6b3e7fa --- /dev/null +++ b/templates/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,29 @@ +name: Feature request +description: Suggest a change to a Gatepost package. +labels: [enhancement, needs-triage] +body: + - type: textarea + id: problem + attributes: + label: The problem + description: What do you want to do? What stops you now? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Your proposal + description: A change to the public interface starts in the spec repo (API-1). + - type: dropdown + id: packages + attributes: + label: Packages + multiple: true + options: + - core + - client + - field + - React wrapper + - PHP SDK + - WooCommerce plugin + - another package diff --git a/templates/NOTICE b/templates/NOTICE new file mode 100644 index 0000000..bc94510 --- /dev/null +++ b/templates/NOTICE @@ -0,0 +1,5 @@ +Gatepost +Copyright 2026 The Gatepost authors + +This product is unofficial. The Nigerian Postal Service (NIPOST) did not make it and does not endorse it. +Gatepost uses the name NIPOST only to describe compatibility with NIPOST's National Digital Postcode. diff --git a/templates/PULL_REQUEST_TEMPLATE.md b/templates/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..497a866 --- /dev/null +++ b/templates/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,5 @@ +Write two or three sentences of plain prose that say what this change does and why. + +Closes # + +Signed-off-by: Your Name diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 0000000..de22839 --- /dev/null +++ b/templates/README.md @@ -0,0 +1,59 @@ +# {{PACKAGE_NAME}} + +{{ONE_SENTENCE_SUMMARY}} + +> Unofficial. Not made or endorsed by NIPOST. + +[![CI]({{CI_BADGE_URL}})]({{CI_URL}}) +[![Version]({{VERSION_BADGE_URL}})]({{REGISTRY_URL}}) +[![Licence](https://img.shields.io/badge/licence-Apache--2.0-blue)](LICENSE) + +## Install + +```{{LANGUAGE_ID}} +{{INSTALL_COMMAND}} +``` + +## Quickstart + +```{{LANGUAGE_ID}} +{{QUICKSTART_CODE}} +``` + +{{ONE_SENTENCE_ABOUT_THE_OUTPUT}} + +## What it does + +- {{CAPABILITY}} + +## Requirements + +| Requirement | Version | +|---|---| +| {{RUNTIME}} | {{MINIMUM_VERSION}} or later | +| Gatepost spec | {{SPEC_VERSION}} | + +## Docs + +The guide and the API reference are at {{DOCS_URL}}. + +## Support + +Ask questions in GitHub Discussions. Report bugs in GitHub Issues. Report security problems privately, as `SECURITY.md` describes. + +## Contributing + +Read `CONTRIBUTING.md` before you open a pull request. + +## Licence + +Apache-2.0. See `LICENSE` and `NOTICE`. + + diff --git a/templates/SECURITY.md b/templates/SECURITY.md new file mode 100644 index 0000000..4a06087 --- /dev/null +++ b/templates/SECURITY.md @@ -0,0 +1,26 @@ +# Security policy + +## Supported versions + +We fix security problems in the newest minor version of each package. + +## Report a problem + +Report each problem privately. Do not open a public issue. + +1. Open the repo's "Security" tab on GitHub. +2. Select "Report a vulnerability". +3. Describe the problem, the affected package and version, and the steps to reproduce it. + +You can also email {{SECURITY_EMAIL}}. + +## What happens next + +- We reply within 3 working days (SEC-8). +- We confirm the problem and agree on a disclosure date with you. +- We aim to release a fix within 30 days for a high or critical problem. +- We credit you in the release notes, unless you ask us not to. + +## Problems in NIPOST's systems + +This policy covers Gatepost code only. Report a problem in NIPOST's systems to NIPOST, through the contact details on its official website. Do not test NIPOST's systems. NIPOST's acceptable use policy forbids probing them. diff --git a/vectors/README.md b/vectors/README.md new file mode 100644 index 0000000..b88f01b --- /dev/null +++ b/vectors/README.md @@ -0,0 +1,38 @@ +# Vectors + +Each JSON file here holds shared test cases for one core function. Every SDK runs every case. + +`scripts/build_vectors.py` builds these files. Do not edit them by hand. Edit the builder, then run `python3 scripts/build_vectors.py`. + +`scripts/reference.py` is a plain reading of `grammar.md` and `data/`. `make check` runs every case through it. A case fails the check when the reading disagrees with it. The grammar wins over both the case and the reading. + +## Format + + { "version": 1, "function": "parse", "description": "...", "cases": [ ... ] } + +Each case has these fields: + +| Field | Meaning | +|---|---| +| `id` | an id that is unique within one spec version, such as `parse-001`. A new case can change the ids after it. | +| `description` | the behaviour in domain words. Use it in the test name. | +| `input` | the argument, or an object of named arguments | +| `options` | options for the function, such as `{ "allowPartial": true }` | +| `expect` | the expected result | + +## Expected results + +| Function | `expect` | +|---|---| +| `normalize`, `isLegacy`, `contains`, `redact`, `stateName`, `precisionForAccuracy` | `{ "value": ... }` | +| `parse` | `{ "ok": true, "compact", "canonical", "display", "precision", "segments" }`, or `{ "ok": false, "error": { "code", "segment", "suggestion" } }` | +| `truncate` | `{ "canonical": ... }`, or `{ "rejects": true }` when the call must fail with the language's programmer error | +| `parent` | `{ "canonical": ... }`, where null means no parent | + +The Interface section of `grammar.md` defines the fields of a parse result and of a parse error. + +## Runner rules + +- For `truncate`, `parent`, `contains` and `redact`, the inputs are canonical codes. Parse them with `allowPartial` before the call. +- For `precisionForAccuracy`, change the strings `NaN`, `Infinity` and `-Infinity` into the language's numbers. Change null into the language's "no value". +- Non-ASCII inputs are JSON escapes. Every file is ASCII. diff --git a/vectors/contains.json b/vectors/contains.json new file mode 100644 index 0000000..8e290b2 --- /dev/null +++ b/vectors/contains.json @@ -0,0 +1,139 @@ +{ + "version": 1, + "function": "contains", + "description": "Checks that one code lies inside another.", + "cases": [ + { + "id": "contains-001", + "description": "finds a full code in its district", + "input": { + "prefix": "EK-01-A03", + "code": "EK-01-A03-FK-01" + }, + "options": {}, + "expect": { + "value": true + } + }, + { + "id": "contains-002", + "description": "finds a full code in its state", + "input": { + "prefix": "EK", + "code": "EK-01-A03-FK-01" + }, + "options": {}, + "expect": { + "value": true + } + }, + { + "id": "contains-003", + "description": "finds a code in itself", + "input": { + "prefix": "EK-01-A03-FK-01", + "code": "EK-01-A03-FK-01" + }, + "options": {}, + "expect": { + "value": true + } + }, + { + "id": "contains-004", + "description": "finds a unit in its area", + "input": { + "prefix": "EK-01-A03-FK", + "code": "EK-01-A03-FK-02" + }, + "options": {}, + "expect": { + "value": true + } + }, + { + "id": "contains-005", + "description": "rejects a code from another district", + "input": { + "prefix": "EK-01-A04", + "code": "EK-01-A03-FK-01" + }, + "options": {}, + "expect": { + "value": false + } + }, + { + "id": "contains-006", + "description": "rejects a code from another state", + "input": { + "prefix": "FC", + "code": "EK-01-A03-FK-01" + }, + "options": {}, + "expect": { + "value": false + } + }, + { + "id": "contains-007", + "description": "rejects a less precise code inside a more precise one", + "input": { + "prefix": "EK-01-A03-FK-01", + "code": "EK-01-A03" + }, + "options": {}, + "expect": { + "value": false + } + }, + { + "id": "contains-008", + "description": "rejects a different unit", + "input": { + "prefix": "EK-01-A03-FK-01", + "code": "EK-01-A03-FK-02" + }, + "options": {}, + "expect": { + "value": false + } + }, + { + "id": "contains-009", + "description": "rejects a code that holds the prefix after its start", + "input": { + "prefix": "FC", + "code": "EK-01-FC0-FK-01" + }, + "options": {}, + "expect": { + "value": false + } + }, + { + "id": "contains-010", + "description": "rejects a code from another LGA", + "input": { + "prefix": "EK-02", + "code": "EK-01-A03-FK-01" + }, + "options": {}, + "expect": { + "value": false + } + }, + { + "id": "contains-011", + "description": "rejects a code from another area", + "input": { + "prefix": "EK-01-A03-FA", + "code": "EK-01-A03-FK-01" + }, + "options": {}, + "expect": { + "value": false + } + } + ] +} diff --git a/vectors/is-legacy.json b/vectors/is-legacy.json new file mode 100644 index 0000000..c0e6b4d --- /dev/null +++ b/vectors/is-legacy.json @@ -0,0 +1,133 @@ +{ + "version": 1, + "function": "isLegacy", + "description": "Finds old 6-digit postcodes.", + "cases": [ + { + "id": "is-legacy-001", + "description": "accepts six digits", + "input": "900108", + "options": {}, + "expect": { + "value": true + } + }, + { + "id": "is-legacy-002", + "description": "accepts six digits with a space", + "input": "900 108", + "options": {}, + "expect": { + "value": true + } + }, + { + "id": "is-legacy-003", + "description": "accepts six full-width digits", + "input": "\uff19\uff10\uff10\uff11\uff10\uff18", + "options": {}, + "expect": { + "value": true + } + }, + { + "id": "is-legacy-004", + "description": "rejects five digits as a legacy code", + "input": "90010", + "options": {}, + "expect": { + "value": false + } + }, + { + "id": "is-legacy-005", + "description": "rejects seven digits", + "input": "9001080", + "options": {}, + "expect": { + "value": false + } + }, + { + "id": "is-legacy-006", + "description": "rejects a new postcode", + "input": "EK01A03FK01", + "options": {}, + "expect": { + "value": false + } + }, + { + "id": "is-legacy-007", + "description": "rejects empty input as a legacy code", + "input": "", + "options": {}, + "expect": { + "value": false + } + }, + { + "id": "is-legacy-008", + "description": "rejects a letter O among digits", + "input": "90O108", + "options": {}, + "expect": { + "value": false + } + }, + { + "id": "is-legacy-009", + "description": "rejects six Arabic-Indic digits as a legacy code", + "input": "\u0669\u0660\u0660\u0661\u0660\u0668", + "options": {}, + "expect": { + "value": false + } + }, + { + "id": "is-legacy-010", + "description": "finds a legacy code padded to the input limit", + "input": "900108 ", + "options": {}, + "expect": { + "value": true + } + }, + { + "id": "is-legacy-011", + "description": "rejects a legacy code over the input limit", + "input": "900108 ", + "options": {}, + "expect": { + "value": false + } + }, + { + "id": "is-legacy-012", + "description": "counts zero-width joiners as code points for legacy codes", + "input": "900108\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d\u200d", + "options": {}, + "expect": { + "value": false + } + }, + { + "id": "is-legacy-013", + "description": "counts the input before it normalises for legacy codes", + "input": "900108\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026", + "options": {}, + "expect": { + "value": true + } + }, + { + "id": "is-legacy-014", + "description": "rejects a legacy code over the limit that ends in a line feed", + "input": "900108 \n", + "options": {}, + "expect": { + "value": false + } + } + ] +} diff --git a/vectors/normalize.json b/vectors/normalize.json new file mode 100644 index 0000000..6dba5aa --- /dev/null +++ b/vectors/normalize.json @@ -0,0 +1,691 @@ +{ + "version": 1, + "function": "normalize", + "description": "Cleans input before parsing.", + "cases": [ + { + "id": "normalize-001", + "description": "removes hyphens", + "input": "EK-01-A03-FK-01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-002", + "description": "removes spaces and makes letters upper case", + "input": "ek 01 a03 fk 01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-003", + "description": "removes leading and trailing spaces", + "input": " EK01A03FK01 ", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-004", + "description": "removes dots", + "input": "EK.01.A03.FK.01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-005", + "description": "removes en dashes", + "input": "EK\u201301\u2013A03\u2013FK\u201301", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-006", + "description": "removes em dashes", + "input": "EK\u201401\u2014A03\u2014FK\u201401", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-007", + "description": "removes minus signs", + "input": "EK\u221201\u2212A03\u2212FK\u221201", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-008", + "description": "removes no-break spaces", + "input": "EK\u00a001\u00a0A03\u00a0FK\u00a001", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-009", + "description": "removes narrow no-break spaces", + "input": "EK\u202f01\u202fA03\u202fFK\u202f01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-010", + "description": "removes a zero-width space", + "input": "EK01A03FK01\u200b", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-011", + "description": "removes a byte order mark", + "input": "\ufeffEK01A03FK01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-012", + "description": "removes soft hyphens", + "input": "EK\u00ad01\u00adA03\u00adFK\u00ad01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-013", + "description": "removes direction marks", + "input": "\u200eEK01A03FK01\u200f", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-014", + "description": "keeps a right-to-left override", + "input": "\u202eEK01A03FK01", + "options": {}, + "expect": { + "value": "\u202eEK01A03FK01" + } + }, + { + "id": "normalize-015", + "description": "changes full-width letters and digits to ASCII", + "input": "\uff25\uff2b\uff10\uff11\uff21\uff10\uff13\uff26\uff2b\uff10\uff11", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-016", + "description": "changes mathematical bold letters and digits to ASCII", + "input": "\ud835\udc04\ud835\udc0a\ud835\udfce\ud835\udfcf\ud835\udc00\ud835\udfce\ud835\udfd1\ud835\udc05\ud835\udc0a\ud835\udfce\ud835\udfcf", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-017", + "description": "changes full-width lower-case letters to ASCII upper case", + "input": "\uff45\uff4b\uff10\uff11\uff41\uff10\uff13\uff46\uff4b\uff10\uff11", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-018", + "description": "removes full-width hyphens", + "input": "EK\uff0d01\uff0dA03\uff0dFK\uff0d01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-019", + "description": "removes a superscript minus", + "input": "EK\u207b01A03FK01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-020", + "description": "removes a subscript minus", + "input": "EK\u208b01A03FK01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-021", + "description": "removes a vertical em dash", + "input": "EK\ufe3101A03FK01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-022", + "description": "removes a vertical en dash", + "input": "EK\ufe3201A03FK01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-023", + "description": "removes a small em dash", + "input": "EK\ufe5801A03FK01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-024", + "description": "removes tabs and line feeds", + "input": "EK01\tA03\nFK01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-025", + "description": "changes a ligature to its letters", + "input": "EK01A03\ufb0101", + "options": {}, + "expect": { + "value": "EK01A03FI01" + } + }, + { + "id": "normalize-026", + "description": "makes mixed-case letters upper case", + "input": "eK01a03Fk01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-027", + "description": "keeps a legacy code", + "input": "900 108", + "options": {}, + "expect": { + "value": "900108" + } + }, + { + "id": "normalize-028", + "description": "keeps characters that are not separators", + "input": "EK01A03FK0!", + "options": {}, + "expect": { + "value": "EK01A03FK0!" + } + }, + { + "id": "normalize-029", + "description": "makes only ASCII letters upper case", + "input": "ek01a03f\u00e901", + "options": {}, + "expect": { + "value": "EK01A03F\u00e901" + } + }, + { + "id": "normalize-030", + "description": "keeps a dotless i", + "input": "EK01A03F\u013101", + "options": {}, + "expect": { + "value": "EK01A03F\u013101" + } + }, + { + "id": "normalize-031", + "description": "returns an empty string for empty input", + "input": "", + "options": {}, + "expect": { + "value": "" + } + }, + { + "id": "normalize-032", + "description": "returns an empty string for separators only", + "input": " - . ", + "options": {}, + "expect": { + "value": "" + } + }, + { + "id": "normalize-033", + "description": "removes the separator U+0009", + "input": "EK\t01\tA03\tFK\t01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-034", + "description": "removes the separator U+000A", + "input": "EK\n01\nA03\nFK\n01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-035", + "description": "removes the separator U+000B", + "input": "EK\u000b01\u000bA03\u000bFK\u000b01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-036", + "description": "removes the separator U+000C", + "input": "EK\f01\fA03\fFK\f01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-037", + "description": "removes the separator U+000D", + "input": "EK\r01\rA03\rFK\r01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-038", + "description": "removes the separator U+0020", + "input": "EK 01 A03 FK 01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-039", + "description": "removes the separator U+002D", + "input": "EK-01-A03-FK-01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-040", + "description": "removes the separator U+002E", + "input": "EK.01.A03.FK.01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-041", + "description": "removes the separator U+0085", + "input": "EK\u008501\u0085A03\u0085FK\u008501", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-042", + "description": "removes the separator U+00A0", + "input": "EK\u00a001\u00a0A03\u00a0FK\u00a001", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-043", + "description": "removes the separator U+00AD", + "input": "EK\u00ad01\u00adA03\u00adFK\u00ad01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-044", + "description": "removes the separator U+1680", + "input": "EK\u168001\u1680A03\u1680FK\u168001", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-045", + "description": "removes the separator U+2000", + "input": "EK\u200001\u2000A03\u2000FK\u200001", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-046", + "description": "removes the separator U+2001", + "input": "EK\u200101\u2001A03\u2001FK\u200101", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-047", + "description": "removes the separator U+2002", + "input": "EK\u200201\u2002A03\u2002FK\u200201", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-048", + "description": "removes the separator U+2003", + "input": "EK\u200301\u2003A03\u2003FK\u200301", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-049", + "description": "removes the separator U+2004", + "input": "EK\u200401\u2004A03\u2004FK\u200401", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-050", + "description": "removes the separator U+2005", + "input": "EK\u200501\u2005A03\u2005FK\u200501", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-051", + "description": "removes the separator U+2006", + "input": "EK\u200601\u2006A03\u2006FK\u200601", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-052", + "description": "removes the separator U+2007", + "input": "EK\u200701\u2007A03\u2007FK\u200701", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-053", + "description": "removes the separator U+2008", + "input": "EK\u200801\u2008A03\u2008FK\u200801", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-054", + "description": "removes the separator U+2009", + "input": "EK\u200901\u2009A03\u2009FK\u200901", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-055", + "description": "removes the separator U+200A", + "input": "EK\u200a01\u200aA03\u200aFK\u200a01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-056", + "description": "removes the separator U+200B", + "input": "EK\u200b01\u200bA03\u200bFK\u200b01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-057", + "description": "removes the separator U+200C", + "input": "EK\u200c01\u200cA03\u200cFK\u200c01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-058", + "description": "removes the separator U+200D", + "input": "EK\u200d01\u200dA03\u200dFK\u200d01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-059", + "description": "removes the separator U+200E", + "input": "EK\u200e01\u200eA03\u200eFK\u200e01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-060", + "description": "removes the separator U+200F", + "input": "EK\u200f01\u200fA03\u200fFK\u200f01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-061", + "description": "removes the separator U+2010", + "input": "EK\u201001\u2010A03\u2010FK\u201001", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-062", + "description": "removes the separator U+2011", + "input": "EK\u201101\u2011A03\u2011FK\u201101", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-063", + "description": "removes the separator U+2012", + "input": "EK\u201201\u2012A03\u2012FK\u201201", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-064", + "description": "removes the separator U+2013", + "input": "EK\u201301\u2013A03\u2013FK\u201301", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-065", + "description": "removes the separator U+2014", + "input": "EK\u201401\u2014A03\u2014FK\u201401", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-066", + "description": "removes the separator U+2015", + "input": "EK\u201501\u2015A03\u2015FK\u201501", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-067", + "description": "removes the separator U+2028", + "input": "EK\u202801\u2028A03\u2028FK\u202801", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-068", + "description": "removes the separator U+2029", + "input": "EK\u202901\u2029A03\u2029FK\u202901", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-069", + "description": "removes the separator U+202F", + "input": "EK\u202f01\u202fA03\u202fFK\u202f01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-070", + "description": "removes the separator U+205F", + "input": "EK\u205f01\u205fA03\u205fFK\u205f01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-071", + "description": "removes the separator U+2060", + "input": "EK\u206001\u2060A03\u2060FK\u206001", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-072", + "description": "removes the separator U+2212", + "input": "EK\u221201\u2212A03\u2212FK\u221201", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-073", + "description": "removes the separator U+3000", + "input": "EK\u300001\u3000A03\u3000FK\u300001", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-074", + "description": "removes the separator U+FEFF", + "input": "EK\ufeff01\ufeffA03\ufeffFK\ufeff01", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-075", + "description": "removes spaces from input over the input limit", + "input": " EK01A03FK01 ", + "options": {}, + "expect": { + "value": "EK01A03FK01" + } + }, + { + "id": "normalize-076", + "description": "makes each ASCII letter upper case", + "input": "abcdefghijklmnopqrstuvwxyz", + "options": {}, + "expect": { + "value": "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + } + } + ] +} diff --git a/vectors/parent.json b/vectors/parent.json new file mode 100644 index 0000000..5858306 --- /dev/null +++ b/vectors/parent.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "function": "parent", + "description": "Gives the code one segment shorter.", + "cases": [ + { + "id": "parent-001", + "description": "gives the area of a full code", + "input": { + "code": "EK-01-A03-FK-01" + }, + "options": {}, + "expect": { + "canonical": "EK-01-A03-FK" + } + }, + { + "id": "parent-002", + "description": "gives the district of an area code", + "input": { + "code": "EK-01-A03-FK" + }, + "options": {}, + "expect": { + "canonical": "EK-01-A03" + } + }, + { + "id": "parent-003", + "description": "gives the LGA of a district code", + "input": { + "code": "EK-01-A03" + }, + "options": {}, + "expect": { + "canonical": "EK-01" + } + }, + { + "id": "parent-004", + "description": "gives the state of an LGA code", + "input": { + "code": "EK-01" + }, + "options": {}, + "expect": { + "canonical": "EK" + } + }, + { + "id": "parent-005", + "description": "gives nothing for a state code", + "input": { + "code": "EK" + }, + "options": {}, + "expect": { + "canonical": null + } + } + ] +} diff --git a/vectors/parse-segments.json b/vectors/parse-segments.json new file mode 100644 index 0000000..938063c --- /dev/null +++ b/vectors/parse-segments.json @@ -0,0 +1,587 @@ +{ + "version": 1, + "function": "parse", + "description": "Segment rules and typo suggestions.", + "cases": [ + { + "id": "parse-segments-001", + "description": "rejects an unknown state", + "input": "XX01A03FK01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "unknown_state", + "segment": "state", + "suggestion": null + } + } + }, + { + "id": "parse-segments-002", + "description": "rejects the synthetic state ZZ", + "input": "ZZ01Z99ZZ01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "unknown_state", + "segment": "state", + "suggestion": null + } + } + }, + { + "id": "parse-segments-003", + "description": "rejects BO, the ISO code of Borno, as a state", + "input": "BO01Z99ZZ01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "unknown_state", + "segment": "state", + "suggestion": null + } + } + }, + { + "id": "parse-segments-004", + "description": "rejects GO, the ISO code of Gombe, as a state", + "input": "GO01Z99ZZ01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "unknown_state", + "segment": "state", + "suggestion": null + } + } + }, + { + "id": "parse-segments-005", + "description": "rejects KO, the ISO code of Kogi, as a state", + "input": "KO01Z99ZZ01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "unknown_state", + "segment": "state", + "suggestion": null + } + } + }, + { + "id": "parse-segments-006", + "description": "rejects SO, the ISO code of Sokoto, as a state", + "input": "SO01Z99ZZ01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "unknown_state", + "segment": "state", + "suggestion": null + } + } + }, + { + "id": "parse-segments-007", + "description": "rejects YO, the ISO code of Yobe, as a state", + "input": "YO01Z99ZZ01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "unknown_state", + "segment": "state", + "suggestion": null + } + } + }, + { + "id": "parse-segments-008", + "description": "suggests O for a zero in the state", + "input": "0G01A03FK01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "unknown_state", + "segment": "state", + "suggestion": "OG-01-A03-FK-01" + } + } + }, + { + "id": "parse-segments-009", + "description": "suggests I for a one in the state", + "input": "1M01A03FK01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "unknown_state", + "segment": "state", + "suggestion": "IM-01-A03-FK-01" + } + } + }, + { + "id": "parse-segments-010", + "description": "gives no suggestion when the fixed state is unknown", + "input": "E101A03FK01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "unknown_state", + "segment": "state", + "suggestion": null + } + } + }, + { + "id": "parse-segments-011", + "description": "gives no suggestion when the state cannot be fixed", + "input": "XXO1A03FK01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "unknown_state", + "segment": "state", + "suggestion": null + } + } + }, + { + "id": "parse-segments-012", + "description": "fixes later segments in a state suggestion", + "input": "0GO1A03FK01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "unknown_state", + "segment": "state", + "suggestion": "OG-01-A03-FK-01" + } + } + }, + { + "id": "parse-segments-013", + "description": "rejects an LGA of 00", + "input": "EK00A03FK01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "lga", + "suggestion": null + } + } + }, + { + "id": "parse-segments-014", + "description": "rejects a unit of 00", + "input": "EK01A03FK00", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "unit", + "suggestion": null + } + } + }, + { + "id": "parse-segments-015", + "description": "rejects a letter after a digit in the LGA", + "input": "JI9XCHKLS18", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "lga", + "suggestion": null + } + } + }, + { + "id": "parse-segments-016", + "description": "rejects a letter after a digit in the unit", + "input": "JI99CHKLS1X", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "unit", + "suggestion": null + } + } + }, + { + "id": "parse-segments-017", + "description": "suggests 0 for a letter O in the LGA", + "input": "EKO1A03FK01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "lga", + "suggestion": "EK-01-A03-FK-01" + } + } + }, + { + "id": "parse-segments-018", + "description": "suggests 1 for a letter I in the LGA", + "input": "EKI1A03FK01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "lga", + "suggestion": "EK-11-A03-FK-01" + } + } + }, + { + "id": "parse-segments-019", + "description": "suggests 1 for a letter L in the LGA", + "input": "EKL1A03FK01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "lga", + "suggestion": "EK-11-A03-FK-01" + } + } + }, + { + "id": "parse-segments-020", + "description": "suggests I for a one in the area", + "input": "EK01A03F101", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "area", + "suggestion": "EK-01-A03-FI-01" + } + } + }, + { + "id": "parse-segments-021", + "description": "suggests O for a zero in the area", + "input": "EK01A03F001", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "area", + "suggestion": "EK-01-A03-FO-01" + } + } + }, + { + "id": "parse-segments-022", + "description": "rejects a digit before a letter in the area", + "input": "EK01A031K01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "area", + "suggestion": "EK-01-A03-IK-01" + } + } + }, + { + "id": "parse-segments-023", + "description": "suggests 0 for a letter O in the unit", + "input": "EK01A03FKO1", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "unit", + "suggestion": "EK-01-A03-FK-01" + } + } + }, + { + "id": "parse-segments-024", + "description": "suggests 1 for a letter I in the unit", + "input": "EK01A03FKI2", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "unit", + "suggestion": "EK-01-A03-FK-12" + } + } + }, + { + "id": "parse-segments-025", + "description": "suggests 1 for a letter L in the unit", + "input": "EK01A03FKL1", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "unit", + "suggestion": "EK-01-A03-FK-11" + } + } + }, + { + "id": "parse-segments-026", + "description": "suggests 1 for a letter I after a zero in the unit", + "input": "EK01A03FK0I", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "unit", + "suggestion": "EK-01-A03-FK-01" + } + } + }, + { + "id": "parse-segments-027", + "description": "gives no suggestion when the fix makes a unit of 00", + "input": "EK01A03FK0O", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "unit", + "suggestion": null + } + } + }, + { + "id": "parse-segments-028", + "description": "gives no suggestion when a segment cannot be fixed", + "input": "EK01A03F2O1", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "area", + "suggestion": null + } + } + }, + { + "id": "parse-segments-029", + "description": "reports the first bad segment and fixes them all", + "input": "EKO1A03F1O1", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "lga", + "suggestion": "EK-01-A03-FI-01" + } + } + }, + { + "id": "parse-segments-030", + "description": "suggests a fix after making letters upper case", + "input": "eko1a03fk01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "lga", + "suggestion": "EK-01-A03-FK-01" + } + } + }, + { + "id": "parse-segments-031", + "description": "rejects an LGA of 00 in a partial code", + "input": "EK00", + "options": { + "allowPartial": true + }, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "lga", + "suggestion": null + } + } + }, + { + "id": "parse-segments-032", + "description": "suggests a partial code", + "input": "EKO1", + "options": { + "allowPartial": true + }, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "lga", + "suggestion": "EK-01" + } + } + }, + { + "id": "parse-segments-033", + "description": "suggests a partial area code", + "input": "EK01A03F1", + "options": { + "allowPartial": true + }, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "area", + "suggestion": "EK-01-A03-FI" + } + } + }, + { + "id": "parse-segments-034", + "description": "rejects an unknown state when partial codes are allowed", + "input": "XX", + "options": { + "allowPartial": true + }, + "expect": { + "ok": false, + "error": { + "code": "unknown_state", + "segment": "state", + "suggestion": null + } + } + }, + { + "id": "parse-segments-035", + "description": "suggests a state for a partial code", + "input": "0G", + "options": { + "allowPartial": true + }, + "expect": { + "ok": false, + "error": { + "code": "unknown_state", + "segment": "state", + "suggestion": "OG" + } + } + }, + { + "id": "parse-segments-036", + "description": "suggests an LGA fix for a district code", + "input": "EKO1A03", + "options": { + "allowPartial": true + }, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "lga", + "suggestion": "EK-01-A03" + } + } + }, + { + "id": "parse-segments-037", + "description": "rejects an LGA of 00 in a district code", + "input": "EK00A03", + "options": { + "allowPartial": true + }, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "lga", + "suggestion": null + } + } + }, + { + "id": "parse-segments-038", + "description": "keeps a letter O in the district when it fixes the LGA", + "input": "EKO1AO3FK01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "lga", + "suggestion": "EK-01-AO3-FK-01" + } + } + }, + { + "id": "parse-segments-039", + "description": "keeps letters I and L in the district when it fixes the LGA", + "input": "EKO1AILFK01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_segment", + "segment": "lga", + "suggestion": "EK-01-AIL-FK-01" + } + } + }, + { + "id": "parse-segments-040", + "description": "accepts letters and digits in any district position", + "input": "EK01A0OFK01", + "options": {}, + "expect": { + "ok": true, + "compact": "EK01A0OFK01", + "canonical": "EK-01-A0O-FK-01", + "display": "EK 01 A0O FK 01", + "precision": "unit", + "segments": { + "state": "EK", + "lga": "01", + "district": "A0O", + "area": "FK", + "unit": "01" + } + } + } + ] +} diff --git a/vectors/parse-states.json b/vectors/parse-states.json new file mode 100644 index 0000000..e109cbc --- /dev/null +++ b/vectors/parse-states.json @@ -0,0 +1,747 @@ +{ + "version": 1, + "function": "parse", + "description": "One valid code for each state.", + "cases": [ + { + "id": "parse-states-001", + "description": "parses a code in Abia", + "input": "AB-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "AB01Z99ZZ01", + "canonical": "AB-01-Z99-ZZ-01", + "display": "AB 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "AB", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-002", + "description": "parses a code in Adamawa", + "input": "AD-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "AD01Z99ZZ01", + "canonical": "AD-01-Z99-ZZ-01", + "display": "AD 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "AD", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-003", + "description": "parses a code in Akwa Ibom", + "input": "AK-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "AK01Z99ZZ01", + "canonical": "AK-01-Z99-ZZ-01", + "display": "AK 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "AK", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-004", + "description": "parses a code in Anambra", + "input": "AN-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "AN01Z99ZZ01", + "canonical": "AN-01-Z99-ZZ-01", + "display": "AN 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "AN", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-005", + "description": "parses a code in Bauchi", + "input": "BA-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "BA01Z99ZZ01", + "canonical": "BA-01-Z99-ZZ-01", + "display": "BA 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "BA", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-006", + "description": "parses a code in Benue", + "input": "BE-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "BE01Z99ZZ01", + "canonical": "BE-01-Z99-ZZ-01", + "display": "BE 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "BE", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-007", + "description": "parses a code in Borno", + "input": "BR-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "BR01Z99ZZ01", + "canonical": "BR-01-Z99-ZZ-01", + "display": "BR 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "BR", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-008", + "description": "parses a code in Bayelsa", + "input": "BY-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "BY01Z99ZZ01", + "canonical": "BY-01-Z99-ZZ-01", + "display": "BY 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "BY", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-009", + "description": "parses a code in Cross River", + "input": "CR-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "CR01Z99ZZ01", + "canonical": "CR-01-Z99-ZZ-01", + "display": "CR 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "CR", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-010", + "description": "parses a code in Delta", + "input": "DE-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "DE01Z99ZZ01", + "canonical": "DE-01-Z99-ZZ-01", + "display": "DE 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "DE", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-011", + "description": "parses a code in Ebonyi", + "input": "EB-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "EB01Z99ZZ01", + "canonical": "EB-01-Z99-ZZ-01", + "display": "EB 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "EB", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-012", + "description": "parses a code in Edo", + "input": "ED-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "ED01Z99ZZ01", + "canonical": "ED-01-Z99-ZZ-01", + "display": "ED 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "ED", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-013", + "description": "parses a code in Ekiti", + "input": "EK-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "EK01Z99ZZ01", + "canonical": "EK-01-Z99-ZZ-01", + "display": "EK 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "EK", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-014", + "description": "parses a code in Enugu", + "input": "EN-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "EN01Z99ZZ01", + "canonical": "EN-01-Z99-ZZ-01", + "display": "EN 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "EN", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-015", + "description": "parses a code in Federal Capital Territory", + "input": "FC-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "FC01Z99ZZ01", + "canonical": "FC-01-Z99-ZZ-01", + "display": "FC 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "FC", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-016", + "description": "parses a code in Gombe", + "input": "GM-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "GM01Z99ZZ01", + "canonical": "GM-01-Z99-ZZ-01", + "display": "GM 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "GM", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-017", + "description": "parses a code in Imo", + "input": "IM-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "IM01Z99ZZ01", + "canonical": "IM-01-Z99-ZZ-01", + "display": "IM 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "IM", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-018", + "description": "parses a code in Jigawa", + "input": "JI-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "JI01Z99ZZ01", + "canonical": "JI-01-Z99-ZZ-01", + "display": "JI 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "JI", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-019", + "description": "parses a code in Kaduna", + "input": "KD-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "KD01Z99ZZ01", + "canonical": "KD-01-Z99-ZZ-01", + "display": "KD 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "KD", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-020", + "description": "parses a code in Kebbi", + "input": "KE-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "KE01Z99ZZ01", + "canonical": "KE-01-Z99-ZZ-01", + "display": "KE 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "KE", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-021", + "description": "parses a code in Kogi", + "input": "KG-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "KG01Z99ZZ01", + "canonical": "KG-01-Z99-ZZ-01", + "display": "KG 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "KG", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-022", + "description": "parses a code in Kano", + "input": "KN-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "KN01Z99ZZ01", + "canonical": "KN-01-Z99-ZZ-01", + "display": "KN 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "KN", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-023", + "description": "parses a code in Katsina", + "input": "KT-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "KT01Z99ZZ01", + "canonical": "KT-01-Z99-ZZ-01", + "display": "KT 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "KT", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-024", + "description": "parses a code in Kwara", + "input": "KW-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "KW01Z99ZZ01", + "canonical": "KW-01-Z99-ZZ-01", + "display": "KW 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "KW", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-025", + "description": "parses a code in Lagos", + "input": "LA-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "LA01Z99ZZ01", + "canonical": "LA-01-Z99-ZZ-01", + "display": "LA 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "LA", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-026", + "description": "parses a code in Nasarawa", + "input": "NA-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "NA01Z99ZZ01", + "canonical": "NA-01-Z99-ZZ-01", + "display": "NA 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "NA", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-027", + "description": "parses a code in Niger", + "input": "NI-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "NI01Z99ZZ01", + "canonical": "NI-01-Z99-ZZ-01", + "display": "NI 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "NI", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-028", + "description": "parses a code in Ogun", + "input": "OG-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "OG01Z99ZZ01", + "canonical": "OG-01-Z99-ZZ-01", + "display": "OG 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "OG", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-029", + "description": "parses a code in Ondo", + "input": "ON-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "ON01Z99ZZ01", + "canonical": "ON-01-Z99-ZZ-01", + "display": "ON 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "ON", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-030", + "description": "parses a code in Osun", + "input": "OS-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "OS01Z99ZZ01", + "canonical": "OS-01-Z99-ZZ-01", + "display": "OS 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "OS", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-031", + "description": "parses a code in Oyo", + "input": "OY-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "OY01Z99ZZ01", + "canonical": "OY-01-Z99-ZZ-01", + "display": "OY 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "OY", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-032", + "description": "parses a code in Plateau", + "input": "PL-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "PL01Z99ZZ01", + "canonical": "PL-01-Z99-ZZ-01", + "display": "PL 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "PL", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-033", + "description": "parses a code in Rivers", + "input": "RI-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "RI01Z99ZZ01", + "canonical": "RI-01-Z99-ZZ-01", + "display": "RI 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "RI", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-034", + "description": "parses a code in Sokoto", + "input": "SK-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "SK01Z99ZZ01", + "canonical": "SK-01-Z99-ZZ-01", + "display": "SK 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "SK", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-035", + "description": "parses a code in Taraba", + "input": "TA-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "TA01Z99ZZ01", + "canonical": "TA-01-Z99-ZZ-01", + "display": "TA 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "TA", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-036", + "description": "parses a code in Yobe", + "input": "YB-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "YB01Z99ZZ01", + "canonical": "YB-01-Z99-ZZ-01", + "display": "YB 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "YB", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + }, + { + "id": "parse-states-037", + "description": "parses a code in Zamfara", + "input": "ZA-01-Z99-ZZ-01", + "options": {}, + "expect": { + "ok": true, + "compact": "ZA01Z99ZZ01", + "canonical": "ZA-01-Z99-ZZ-01", + "display": "ZA 01 Z99 ZZ 01", + "precision": "unit", + "segments": { + "state": "ZA", + "lga": "01", + "district": "Z99", + "area": "ZZ", + "unit": "01" + } + } + } + ] +} diff --git a/vectors/parse.json b/vectors/parse.json new file mode 100644 index 0000000..9057115 --- /dev/null +++ b/vectors/parse.json @@ -0,0 +1,869 @@ +{ + "version": 1, + "function": "parse", + "description": "Forms, lengths, characters and legacy codes.", + "cases": [ + { + "id": "parse-001", + "description": "parses the canonical form", + "input": "EK-01-A03-FK-01", + "options": {}, + "expect": { + "ok": true, + "compact": "EK01A03FK01", + "canonical": "EK-01-A03-FK-01", + "display": "EK 01 A03 FK 01", + "precision": "unit", + "segments": { + "state": "EK", + "lga": "01", + "district": "A03", + "area": "FK", + "unit": "01" + } + } + }, + { + "id": "parse-002", + "description": "parses the compact form", + "input": "EK01A03FK01", + "options": {}, + "expect": { + "ok": true, + "compact": "EK01A03FK01", + "canonical": "EK-01-A03-FK-01", + "display": "EK 01 A03 FK 01", + "precision": "unit", + "segments": { + "state": "EK", + "lga": "01", + "district": "A03", + "area": "FK", + "unit": "01" + } + } + }, + { + "id": "parse-003", + "description": "parses the display form in lower case", + "input": "ek 01 a03 fk 01", + "options": {}, + "expect": { + "ok": true, + "compact": "EK01A03FK01", + "canonical": "EK-01-A03-FK-01", + "display": "EK 01 A03 FK 01", + "precision": "unit", + "segments": { + "state": "EK", + "lga": "01", + "district": "A03", + "area": "FK", + "unit": "01" + } + } + }, + { + "id": "parse-004", + "description": "parses a code with en dashes", + "input": "EK\u201301\u2013A03\u2013FK\u201301", + "options": {}, + "expect": { + "ok": true, + "compact": "EK01A03FK01", + "canonical": "EK-01-A03-FK-01", + "display": "EK 01 A03 FK 01", + "precision": "unit", + "segments": { + "state": "EK", + "lga": "01", + "district": "A03", + "area": "FK", + "unit": "01" + } + } + }, + { + "id": "parse-005", + "description": "parses a code with no-break spaces", + "input": "EK\u00a001\u00a0A03\u00a0FK\u00a001", + "options": {}, + "expect": { + "ok": true, + "compact": "EK01A03FK01", + "canonical": "EK-01-A03-FK-01", + "display": "EK 01 A03 FK 01", + "precision": "unit", + "segments": { + "state": "EK", + "lga": "01", + "district": "A03", + "area": "FK", + "unit": "01" + } + } + }, + { + "id": "parse-006", + "description": "parses a code with a zero-width space", + "input": "EK01A03FK01\u200b", + "options": {}, + "expect": { + "ok": true, + "compact": "EK01A03FK01", + "canonical": "EK-01-A03-FK-01", + "display": "EK 01 A03 FK 01", + "precision": "unit", + "segments": { + "state": "EK", + "lga": "01", + "district": "A03", + "area": "FK", + "unit": "01" + } + } + }, + { + "id": "parse-007", + "description": "parses a code with a byte order mark", + "input": "\ufeffEK01A03FK01", + "options": {}, + "expect": { + "ok": true, + "compact": "EK01A03FK01", + "canonical": "EK-01-A03-FK-01", + "display": "EK 01 A03 FK 01", + "precision": "unit", + "segments": { + "state": "EK", + "lga": "01", + "district": "A03", + "area": "FK", + "unit": "01" + } + } + }, + { + "id": "parse-008", + "description": "parses full-width letters and digits", + "input": "\uff25\uff2b-\uff10\uff11-\uff21\uff10\uff13-\uff26\uff2b-\uff10\uff11", + "options": {}, + "expect": { + "ok": true, + "compact": "EK01A03FK01", + "canonical": "EK-01-A03-FK-01", + "display": "EK 01 A03 FK 01", + "precision": "unit", + "segments": { + "state": "EK", + "lga": "01", + "district": "A03", + "area": "FK", + "unit": "01" + } + } + }, + { + "id": "parse-009", + "description": "parses the example on NIPOST's homepage", + "input": "FC-02-A09-DB-09", + "options": {}, + "expect": { + "ok": true, + "compact": "FC02A09DB09", + "canonical": "FC-02-A09-DB-09", + "display": "FC 02 A09 DB 09", + "precision": "unit", + "segments": { + "state": "FC", + "lga": "02", + "district": "A09", + "area": "DB", + "unit": "09" + } + } + }, + { + "id": "parse-010", + "description": "parses the example in NIPOST's blog", + "input": "LA 12 K23 IV 95", + "options": {}, + "expect": { + "ok": true, + "compact": "LA12K23IV95", + "canonical": "LA-12-K23-IV-95", + "display": "LA 12 K23 IV 95", + "precision": "unit", + "segments": { + "state": "LA", + "lga": "12", + "district": "K23", + "area": "IV", + "unit": "95" + } + } + }, + { + "id": "parse-011", + "description": "parses a NIPOST test code", + "input": "AK-11-I61-ZF-12", + "options": {}, + "expect": { + "ok": true, + "compact": "AK11I61ZF12", + "canonical": "AK-11-I61-ZF-12", + "display": "AK 11 I61 ZF 12", + "precision": "unit", + "segments": { + "state": "AK", + "lga": "11", + "district": "I61", + "area": "ZF", + "unit": "12" + } + } + }, + { + "id": "parse-012", + "description": "accepts a letter O in the district of a NIPOST test code", + "input": "JI-24-O18-JP-23", + "options": {}, + "expect": { + "ok": true, + "compact": "JI24O18JP23", + "canonical": "JI-24-O18-JP-23", + "display": "JI 24 O18 JP 23", + "precision": "unit", + "segments": { + "state": "JI", + "lga": "24", + "district": "O18", + "area": "JP", + "unit": "23" + } + } + }, + { + "id": "parse-013", + "description": "rejects empty input", + "input": "", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "empty", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-014", + "description": "rejects spaces only", + "input": " ", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "empty", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-015", + "description": "rejects separators only", + "input": " - . ", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "empty", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-016", + "description": "names a legacy code", + "input": "900108", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "legacy_code", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-017", + "description": "names a legacy code with a space", + "input": "900 108", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "legacy_code", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-018", + "description": "names a legacy code in full-width digits", + "input": "\uff19\uff10\uff10\uff11\uff10\uff18", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "legacy_code", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-019", + "description": "names a legacy code when partial codes are allowed", + "input": "123456", + "options": { + "allowPartial": true + }, + "expect": { + "ok": false, + "error": { + "code": "legacy_code", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-020", + "description": "rejects five digits", + "input": "90010", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_length", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-021", + "description": "rejects an exclamation mark", + "input": "EK01A03FK0!", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_character", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-022", + "description": "rejects an underscore", + "input": "EK01A03FK0_", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_character", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-023", + "description": "rejects slashes", + "input": "EK01/A03/FK01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_character", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-024", + "description": "rejects a letter outside ASCII", + "input": "EK01A03F\u00e901", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_character", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-025", + "description": "rejects a right-to-left override", + "input": "\u202eEK01A03FK01", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_character", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-026", + "description": "rejects Arabic-Indic digits", + "input": "\u0669\u0660\u0660\u0661\u0660\u0668", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_character", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-027", + "description": "rejects 10 characters", + "input": "EK01A03FK1", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_length", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-028", + "description": "rejects 12 characters", + "input": "EK01A03FK01X", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_length", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-029", + "description": "rejects a unit that lost its zero", + "input": "EK-01-A03-FK-1", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_length", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-030", + "description": "rejects a state alone by default", + "input": "EK", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_length", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-031", + "description": "rejects a district code by default", + "input": "EK01A03", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_length", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-032", + "description": "checks the length before the state", + "input": "XX", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_length", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-033", + "description": "checks the length before the segments", + "input": "EK00", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_length", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-034", + "description": "checks the length before a fixable segment", + "input": "EKO1A03FK1", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_length", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-035", + "description": "parses a state when partial codes are allowed", + "input": "EK", + "options": { + "allowPartial": true + }, + "expect": { + "ok": true, + "compact": "EK", + "canonical": "EK", + "display": "EK", + "precision": "state", + "segments": { + "state": "EK", + "lga": null, + "district": null, + "area": null, + "unit": null + } + } + }, + { + "id": "parse-036", + "description": "parses an LGA code when partial codes are allowed", + "input": "EK01", + "options": { + "allowPartial": true + }, + "expect": { + "ok": true, + "compact": "EK01", + "canonical": "EK-01", + "display": "EK 01", + "precision": "lga", + "segments": { + "state": "EK", + "lga": "01", + "district": null, + "area": null, + "unit": null + } + } + }, + { + "id": "parse-037", + "description": "parses a district code when partial codes are allowed", + "input": "ek-01-a03", + "options": { + "allowPartial": true + }, + "expect": { + "ok": true, + "compact": "EK01A03", + "canonical": "EK-01-A03", + "display": "EK 01 A03", + "precision": "district", + "segments": { + "state": "EK", + "lga": "01", + "district": "A03", + "area": null, + "unit": null + } + } + }, + { + "id": "parse-038", + "description": "parses an area code when partial codes are allowed", + "input": "EK01A03FK", + "options": { + "allowPartial": true + }, + "expect": { + "ok": true, + "compact": "EK01A03FK", + "canonical": "EK-01-A03-FK", + "display": "EK 01 A03 FK", + "precision": "area", + "segments": { + "state": "EK", + "lga": "01", + "district": "A03", + "area": "FK", + "unit": null + } + } + }, + { + "id": "parse-039", + "description": "parses a full code when partial codes are allowed", + "input": "EK01A03FK01", + "options": { + "allowPartial": true + }, + "expect": { + "ok": true, + "compact": "EK01A03FK01", + "canonical": "EK-01-A03-FK-01", + "display": "EK 01 A03 FK 01", + "precision": "unit", + "segments": { + "state": "EK", + "lga": "01", + "district": "A03", + "area": "FK", + "unit": "01" + } + } + }, + { + "id": "parse-040", + "description": "rejects 3 characters when partial codes are allowed", + "input": "EK0", + "options": { + "allowPartial": true + }, + "expect": { + "ok": false, + "error": { + "code": "bad_length", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-041", + "description": "rejects 5 characters when partial codes are allowed", + "input": "EK01A", + "options": { + "allowPartial": true + }, + "expect": { + "ok": false, + "error": { + "code": "bad_length", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-042", + "description": "rejects 6 characters when partial codes are allowed", + "input": "EK01A0", + "options": { + "allowPartial": true + }, + "expect": { + "ok": false, + "error": { + "code": "bad_length", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-043", + "description": "rejects 8 characters when partial codes are allowed", + "input": "EK01A03F", + "options": { + "allowPartial": true + }, + "expect": { + "ok": false, + "error": { + "code": "bad_length", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-044", + "description": "rejects 10 characters when partial codes are allowed", + "input": "EK01A03FK0", + "options": { + "allowPartial": true + }, + "expect": { + "ok": false, + "error": { + "code": "bad_length", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-045", + "description": "parses a code padded to the input limit", + "input": "EK01A03FK01 ", + "options": {}, + "expect": { + "ok": true, + "compact": "EK01A03FK01", + "canonical": "EK-01-A03-FK-01", + "display": "EK 01 A03 FK 01", + "precision": "unit", + "segments": { + "state": "EK", + "lga": "01", + "district": "A03", + "area": "FK", + "unit": "01" + } + } + }, + { + "id": "parse-046", + "description": "rejects input over the input limit", + "input": "EK01A03FK01 ", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_length", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-047", + "description": "counts code points, not UTF-16 units", + "input": "\ud835\udc04\ud835\udc0a\ud835\udfce\ud835\udfcf\ud835\udc00\ud835\udfce\ud835\udfd1\ud835\udc05\ud835\udc0a\ud835\udfce\ud835\udfcf ", + "options": {}, + "expect": { + "ok": true, + "compact": "EK01A03FK01", + "canonical": "EK-01-A03-FK-01", + "display": "EK 01 A03 FK 01", + "precision": "unit", + "segments": { + "state": "EK", + "lga": "01", + "district": "A03", + "area": "FK", + "unit": "01" + } + } + }, + { + "id": "parse-048", + "description": "parses an astral code padded to the input limit", + "input": "\ud835\udc04\ud835\udc0a\ud835\udfce\ud835\udfcf\ud835\udc00\ud835\udfce\ud835\udfd1\ud835\udc05\ud835\udc0a\ud835\udfce\ud835\udfcf ", + "options": {}, + "expect": { + "ok": true, + "compact": "EK01A03FK01", + "canonical": "EK-01-A03-FK-01", + "display": "EK 01 A03 FK 01", + "precision": "unit", + "segments": { + "state": "EK", + "lga": "01", + "district": "A03", + "area": "FK", + "unit": "01" + } + } + }, + { + "id": "parse-049", + "description": "counts combining marks as code points", + "input": "E\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301\u0301", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_length", + "segment": null, + "suggestion": null + } + } + }, + { + "id": "parse-050", + "description": "counts the input before it normalises", + "input": "EK01A03FK01\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026", + "options": {}, + "expect": { + "ok": true, + "compact": "EK01A03FK01", + "canonical": "EK-01-A03-FK-01", + "display": "EK 01 A03 FK 01", + "precision": "unit", + "segments": { + "state": "EK", + "lga": "01", + "district": "A03", + "area": "FK", + "unit": "01" + } + } + }, + { + "id": "parse-051", + "description": "rejects input over the limit that ends in a line feed", + "input": "EK01A03FK01 \n", + "options": {}, + "expect": { + "ok": false, + "error": { + "code": "bad_length", + "segment": null, + "suggestion": null + } + } + } + ] +} diff --git a/vectors/precision-for-accuracy.json b/vectors/precision-for-accuracy.json new file mode 100644 index 0000000..9e6a48f --- /dev/null +++ b/vectors/precision-for-accuracy.json @@ -0,0 +1,142 @@ +{ + "version": 1, + "function": "precisionForAccuracy", + "description": "Maps GPS accuracy to precision.", + "cases": [ + { + "id": "precision-for-accuracy-001", + "description": "gives the unit at 0 m", + "input": 0, + "options": {}, + "expect": { + "value": "unit" + } + }, + { + "id": "precision-for-accuracy-002", + "description": "gives the unit at 5 m", + "input": 5, + "options": {}, + "expect": { + "value": "unit" + } + }, + { + "id": "precision-for-accuracy-003", + "description": "gives the unit at exactly 8 m", + "input": 8, + "options": {}, + "expect": { + "value": "unit" + } + }, + { + "id": "precision-for-accuracy-004", + "description": "gives the area just above 8 m", + "input": 8.1, + "options": {}, + "expect": { + "value": "area" + } + }, + { + "id": "precision-for-accuracy-005", + "description": "gives the area at exactly 20 m", + "input": 20, + "options": {}, + "expect": { + "value": "area" + } + }, + { + "id": "precision-for-accuracy-006", + "description": "gives the district above 20 m", + "input": 20.5, + "options": {}, + "expect": { + "value": "district" + } + }, + { + "id": "precision-for-accuracy-007", + "description": "gives the district at exactly 50 m", + "input": 50, + "options": {}, + "expect": { + "value": "district" + } + }, + { + "id": "precision-for-accuracy-008", + "description": "gives the LGA just above 50 m", + "input": 50.1, + "options": {}, + "expect": { + "value": "lga" + } + }, + { + "id": "precision-for-accuracy-009", + "description": "gives the LGA at 1000 m", + "input": 1000, + "options": {}, + "expect": { + "value": "lga" + } + }, + { + "id": "precision-for-accuracy-010", + "description": "gives the LGA when the accuracy is unknown", + "input": null, + "options": {}, + "expect": { + "value": "lga" + } + }, + { + "id": "precision-for-accuracy-011", + "description": "gives the LGA for a negative accuracy", + "input": -1, + "options": {}, + "expect": { + "value": "lga" + } + }, + { + "id": "precision-for-accuracy-012", + "description": "gives the unit for negative zero", + "input": -0.0, + "options": {}, + "expect": { + "value": "unit" + } + }, + { + "id": "precision-for-accuracy-013", + "description": "gives the LGA for NaN", + "input": "NaN", + "options": {}, + "expect": { + "value": "lga" + } + }, + { + "id": "precision-for-accuracy-014", + "description": "gives the LGA for infinity", + "input": "Infinity", + "options": {}, + "expect": { + "value": "lga" + } + }, + { + "id": "precision-for-accuracy-015", + "description": "gives the LGA for negative infinity", + "input": "-Infinity", + "options": {}, + "expect": { + "value": "lga" + } + } + ] +} diff --git a/vectors/redact.json b/vectors/redact.json new file mode 100644 index 0000000..3f06884 --- /dev/null +++ b/vectors/redact.json @@ -0,0 +1,40 @@ +{ + "version": 1, + "function": "redact", + "description": "Hides the unit for logs.", + "cases": [ + { + "id": "redact-001", + "description": "hides the unit of a full code", + "input": { + "code": "EK-01-A03-FK-01" + }, + "options": {}, + "expect": { + "value": "EK-01-A03-FK-**" + } + }, + { + "id": "redact-002", + "description": "keeps an area code as it is", + "input": { + "code": "EK-01-A03-FK" + }, + "options": {}, + "expect": { + "value": "EK-01-A03-FK" + } + }, + { + "id": "redact-003", + "description": "keeps a state code as it is", + "input": { + "code": "EK" + }, + "options": {}, + "expect": { + "value": "EK" + } + } + ] +} diff --git a/vectors/state-name.json b/vectors/state-name.json new file mode 100644 index 0000000..f76b006 --- /dev/null +++ b/vectors/state-name.json @@ -0,0 +1,484 @@ +{ + "version": 1, + "function": "stateName", + "description": "Names the state for a state code.", + "cases": [ + { + "id": "state-name-001", + "description": "names AB", + "input": "AB", + "options": {}, + "expect": { + "value": "Abia" + } + }, + { + "id": "state-name-002", + "description": "names AD", + "input": "AD", + "options": {}, + "expect": { + "value": "Adamawa" + } + }, + { + "id": "state-name-003", + "description": "names AK", + "input": "AK", + "options": {}, + "expect": { + "value": "Akwa Ibom" + } + }, + { + "id": "state-name-004", + "description": "names AN", + "input": "AN", + "options": {}, + "expect": { + "value": "Anambra" + } + }, + { + "id": "state-name-005", + "description": "names BA", + "input": "BA", + "options": {}, + "expect": { + "value": "Bauchi" + } + }, + { + "id": "state-name-006", + "description": "names BE", + "input": "BE", + "options": {}, + "expect": { + "value": "Benue" + } + }, + { + "id": "state-name-007", + "description": "names BR", + "input": "BR", + "options": {}, + "expect": { + "value": "Borno" + } + }, + { + "id": "state-name-008", + "description": "names BY", + "input": "BY", + "options": {}, + "expect": { + "value": "Bayelsa" + } + }, + { + "id": "state-name-009", + "description": "names CR", + "input": "CR", + "options": {}, + "expect": { + "value": "Cross River" + } + }, + { + "id": "state-name-010", + "description": "names DE", + "input": "DE", + "options": {}, + "expect": { + "value": "Delta" + } + }, + { + "id": "state-name-011", + "description": "names EB", + "input": "EB", + "options": {}, + "expect": { + "value": "Ebonyi" + } + }, + { + "id": "state-name-012", + "description": "names ED", + "input": "ED", + "options": {}, + "expect": { + "value": "Edo" + } + }, + { + "id": "state-name-013", + "description": "names EK", + "input": "EK", + "options": {}, + "expect": { + "value": "Ekiti" + } + }, + { + "id": "state-name-014", + "description": "names EN", + "input": "EN", + "options": {}, + "expect": { + "value": "Enugu" + } + }, + { + "id": "state-name-015", + "description": "names FC", + "input": "FC", + "options": {}, + "expect": { + "value": "Federal Capital Territory" + } + }, + { + "id": "state-name-016", + "description": "names GM", + "input": "GM", + "options": {}, + "expect": { + "value": "Gombe" + } + }, + { + "id": "state-name-017", + "description": "names IM", + "input": "IM", + "options": {}, + "expect": { + "value": "Imo" + } + }, + { + "id": "state-name-018", + "description": "names JI", + "input": "JI", + "options": {}, + "expect": { + "value": "Jigawa" + } + }, + { + "id": "state-name-019", + "description": "names KD", + "input": "KD", + "options": {}, + "expect": { + "value": "Kaduna" + } + }, + { + "id": "state-name-020", + "description": "names KE", + "input": "KE", + "options": {}, + "expect": { + "value": "Kebbi" + } + }, + { + "id": "state-name-021", + "description": "names KG", + "input": "KG", + "options": {}, + "expect": { + "value": "Kogi" + } + }, + { + "id": "state-name-022", + "description": "names KN", + "input": "KN", + "options": {}, + "expect": { + "value": "Kano" + } + }, + { + "id": "state-name-023", + "description": "names KT", + "input": "KT", + "options": {}, + "expect": { + "value": "Katsina" + } + }, + { + "id": "state-name-024", + "description": "names KW", + "input": "KW", + "options": {}, + "expect": { + "value": "Kwara" + } + }, + { + "id": "state-name-025", + "description": "names LA", + "input": "LA", + "options": {}, + "expect": { + "value": "Lagos" + } + }, + { + "id": "state-name-026", + "description": "names NA", + "input": "NA", + "options": {}, + "expect": { + "value": "Nasarawa" + } + }, + { + "id": "state-name-027", + "description": "names NI", + "input": "NI", + "options": {}, + "expect": { + "value": "Niger" + } + }, + { + "id": "state-name-028", + "description": "names OG", + "input": "OG", + "options": {}, + "expect": { + "value": "Ogun" + } + }, + { + "id": "state-name-029", + "description": "names ON", + "input": "ON", + "options": {}, + "expect": { + "value": "Ondo" + } + }, + { + "id": "state-name-030", + "description": "names OS", + "input": "OS", + "options": {}, + "expect": { + "value": "Osun" + } + }, + { + "id": "state-name-031", + "description": "names OY", + "input": "OY", + "options": {}, + "expect": { + "value": "Oyo" + } + }, + { + "id": "state-name-032", + "description": "names PL", + "input": "PL", + "options": {}, + "expect": { + "value": "Plateau" + } + }, + { + "id": "state-name-033", + "description": "names RI", + "input": "RI", + "options": {}, + "expect": { + "value": "Rivers" + } + }, + { + "id": "state-name-034", + "description": "names SK", + "input": "SK", + "options": {}, + "expect": { + "value": "Sokoto" + } + }, + { + "id": "state-name-035", + "description": "names TA", + "input": "TA", + "options": {}, + "expect": { + "value": "Taraba" + } + }, + { + "id": "state-name-036", + "description": "names YB", + "input": "YB", + "options": {}, + "expect": { + "value": "Yobe" + } + }, + { + "id": "state-name-037", + "description": "names ZA", + "input": "ZA", + "options": {}, + "expect": { + "value": "Zamfara" + } + }, + { + "id": "state-name-038", + "description": "accepts lower case", + "input": "ek", + "options": {}, + "expect": { + "value": "Ekiti" + } + }, + { + "id": "state-name-039", + "description": "accepts mixed case", + "input": "Fc", + "options": {}, + "expect": { + "value": "Federal Capital Territory" + } + }, + { + "id": "state-name-040", + "description": "returns nothing for an unknown code", + "input": "XX", + "options": {}, + "expect": { + "value": null + } + }, + { + "id": "state-name-041", + "description": "returns nothing for empty input", + "input": "", + "options": {}, + "expect": { + "value": null + } + }, + { + "id": "state-name-042", + "description": "returns nothing for three letters", + "input": "EKI", + "options": {}, + "expect": { + "value": null + } + }, + { + "id": "state-name-043", + "description": "returns nothing for a code with spaces", + "input": " EK ", + "options": {}, + "expect": { + "value": null + } + }, + { + "id": "state-name-044", + "description": "returns nothing for a code with a leading space", + "input": " EK", + "options": {}, + "expect": { + "value": null + } + }, + { + "id": "state-name-045", + "description": "returns nothing for a code with a trailing space", + "input": "EK ", + "options": {}, + "expect": { + "value": null + } + }, + { + "id": "state-name-046", + "description": "returns nothing for full-width letters", + "input": "\uff25\uff2b", + "options": {}, + "expect": { + "value": null + } + }, + { + "id": "state-name-047", + "description": "returns nothing for a long s", + "input": "O\u017f", + "options": {}, + "expect": { + "value": null + } + }, + { + "id": "state-name-048", + "description": "returns nothing for a dotless i", + "input": "\u0131M", + "options": {}, + "expect": { + "value": null + } + }, + { + "id": "state-name-049", + "description": "returns nothing for BO, the ISO code of Borno", + "input": "BO", + "options": {}, + "expect": { + "value": null + } + }, + { + "id": "state-name-050", + "description": "returns nothing for GO, the ISO code of Gombe", + "input": "GO", + "options": {}, + "expect": { + "value": null + } + }, + { + "id": "state-name-051", + "description": "returns nothing for KO, the ISO code of Kogi", + "input": "KO", + "options": {}, + "expect": { + "value": null + } + }, + { + "id": "state-name-052", + "description": "returns nothing for SO, the ISO code of Sokoto", + "input": "SO", + "options": {}, + "expect": { + "value": null + } + }, + { + "id": "state-name-053", + "description": "returns nothing for YO, the ISO code of Yobe", + "input": "YO", + "options": {}, + "expect": { + "value": null + } + } + ] +} diff --git a/vectors/truncate.json b/vectors/truncate.json new file mode 100644 index 0000000..1161a4e --- /dev/null +++ b/vectors/truncate.json @@ -0,0 +1,103 @@ +{ + "version": 1, + "function": "truncate", + "description": "Cuts a code to a less precise segment.", + "cases": [ + { + "id": "truncate-001", + "description": "keeps a full code at unit precision", + "input": { + "code": "EK-01-A03-FK-01", + "to": "unit" + }, + "options": {}, + "expect": { + "canonical": "EK-01-A03-FK-01" + } + }, + { + "id": "truncate-002", + "description": "cuts a full code to its area", + "input": { + "code": "EK-01-A03-FK-01", + "to": "area" + }, + "options": {}, + "expect": { + "canonical": "EK-01-A03-FK" + } + }, + { + "id": "truncate-003", + "description": "cuts a full code to its district", + "input": { + "code": "EK-01-A03-FK-01", + "to": "district" + }, + "options": {}, + "expect": { + "canonical": "EK-01-A03" + } + }, + { + "id": "truncate-004", + "description": "cuts a full code to its LGA", + "input": { + "code": "EK-01-A03-FK-01", + "to": "lga" + }, + "options": {}, + "expect": { + "canonical": "EK-01" + } + }, + { + "id": "truncate-005", + "description": "cuts a full code to its state", + "input": { + "code": "EK-01-A03-FK-01", + "to": "state" + }, + "options": {}, + "expect": { + "canonical": "EK" + } + }, + { + "id": "truncate-006", + "description": "keeps a district code at district precision", + "input": { + "code": "EK-01-A03", + "to": "district" + }, + "options": {}, + "expect": { + "canonical": "EK-01-A03" + } + }, + { + "id": "truncate-007", + "description": "refuses to add precision to a district code", + "input": { + "code": "EK-01-A03", + "to": "area" + }, + "options": {}, + "expect": { + "rejects": true + } + }, + { + "id": "truncate-008", + "description": "refuses to add precision to a state code", + "input": { + "code": "EK", + "to": "lga" + }, + "options": {}, + "expect": { + "rejects": true + } + } + ] +}