diff --git a/.agents/skills/README.md b/.agents/skills/README.md index 17a55b2..a623f8f 100644 --- a/.agents/skills/README.md +++ b/.agents/skills/README.md @@ -3,12 +3,12 @@ This directory contains repo-local copies of canonical skills from `The-Interdependency/skill-lib`. -Source commit: `c14ee9d500579a4b5d6821f62c9d82ca96e73608` +Source commit: `5e7239b52dc5403d8d01fbc63341fe397719ec3f` Repo-local copies are not the source of truth. Edit `skill-lib` first, then propagate from the canonical source. -Installed skills: +Skills refreshed from the source commit above: - `canon/` - `domain-claims/` diff --git a/.agents/skills/gonol-build/SKILL.md b/.agents/skills/gonol-build/SKILL.md index 86747c8..6e0e97d 100644 --- a/.agents/skills/gonol-build/SKILL.md +++ b/.agents/skills/gonol-build/SKILL.md @@ -1,6 +1,6 @@ --- name: gonol-build -description: Construction, closure, and replay contract for gonols across UCNS and EDCM. Load this when building or reviewing UCNS geometry used by gonols, or building EDCM character, word, definition, or recursive-relation gonols. UCNS owns geometry; EDCM owns text construction. The required EDCM order is characters -> words -> definitions -> recursive gonol relations. Pronunciation is not required unless an explicitly declared later experiment makes it part of the construction. Do not load for unrelated geometry, ordinary prose editing, or measurement over already-closed gonols. +description: Construction, closure, and replay contract for gonols across UCNS and EDCM. Load this when building or reviewing UCNS geometry used by gonols, or building EDCM character, word, definition, or recursive-relation gonols. UCNS owns geometry; EDCM owns text construction and admissible scale options. Closed gonols participate atomically at any admissible consuming scale; no universal adjacent-scale ladder is required. Pronunciation is not required unless an explicitly declared later experiment makes it part of the construction. Do not load for unrelated geometry, ordinary prose editing, or measurement over already-closed gonols. --- # gonol-build @@ -11,8 +11,8 @@ Use this skill to keep gonol construction on the declared architecture and nothi 1. Resolve the current UCNS and EDCM authorities before building. 2. Before launching construction or replay whose completion materially depends on scarce resources, preflight the resources required to finish the declared scope. -3. Keep the EDCM order load-bearing: characters -> words -> definitions -> recursive gonol relations. -4. Close each completed gonol before it participates atomically at the next declared scale. +3. Resolve the EDCM constructor's declared scale option set and participant eligibility; do not impose a universal adjacent-scale ladder. +4. Close each completed gonol before it participates atomically at an admissible consuming scale. 5. If required UCNS geometry is unresolved, preserve that boundary as `hmmm`. 6. Replay the complete declared scope only where replay is required by the governing protocol. @@ -29,18 +29,25 @@ Resolve the current UCNS and EDCM authorities before building. Do not move text ## EDCM construction contract ```text -characters -> words -> definitions -> recursive gonol relations +declared scale option set + eligible already-closed participants -> construction -> closure ``` -This order is load-bearing. +EDCM owns the admissible scale options. Consult its exact current +`docs/GONOL_LANGUAGE_BOUNDARY.md` and `edcm/gonol.py` contract before selecting +participants. The sequence `characters -> words -> definitions -> recursive +gonol relations` may describe a particular construction, but is not a mandatory +ladder for all EDCM constructions. - Every admitted character is a gonol. -- Ordered character gonols close into a word gonol. -- A closed word gonol is atomic at the consuming scale while its constituent identities, order, multiplicity, source positions, and provenance remain recoverable. -- Definition gonols are constructed from the applicable closed word gonols and exact source definition evidence. +- Ordered character gonols may close into a word gonol under its declared constructor. +- Any closed gonol is atomic at an admissible consuming scale while its constituent identities, order, multiplicity, source positions, and provenance remain recoverable. +- Definition gonols use eligible closed participants and exact source definition evidence; do not require word intermediates when EDCM admits another scale option. - Recursive relations are constructed from already-closed gonols without reopening or erasing their internal structure. -Do not insert another required stage into this sequence unless the governing contract is explicitly changed. +Do not invent participant eligibility or another required stage. A permitted +character-to-definition construction must not be rejected solely because it +omits a word intermediate. Nor does this permit every scale combination: the +owning constructor's option set remains load-bearing. ## Pronunciation boundary @@ -57,7 +64,7 @@ ordered eligible gonols -> authorized UCNS geometric relation/application -> closure -> deterministic identity + provenance receipt --> atomic participation at the next declared scale +-> atomic participation at an admissible consuming scale ``` Preserve exact source identity, occurrence order, multiplicity, and provenance. Do not normalize, deduplicate, infer relations, or substitute tokens, embeddings, hashes, or another representation for gonol identity unless the active contract explicitly authorizes it. @@ -83,21 +90,36 @@ Replay establishes reproducibility of that construction only. It does not by its ## Usage guidance +The executable cross-source witness is owned by skill-lib at +`tools/check_edcm_boundary.py`, outside the propagated skill directory. In a +skill-lib checkout, run it against a clean EDCM checkout at the script's exact +`EDCM_COMMIT`: + +```bash +python tools/check_edcm_boundary.py /path/to/edcm +``` + +It checks direct character-to-definition construction, replay, candidate standing, +and refusal of an undeclared scale. It is deliberately not vendored into UCNS or +other geometry consumers. This pinned witness is not a universal scale registry; +current construction must still resolve its owning EDCM option set. + For text construction, start in EDCM and consume current UCNS geometry. ```text UCNS: geometry -EDCM: characters -> words -> definitions -> recursive gonol relations +EDCM: declared scale options, participant eligibility, text construction ``` -When a word closes, use that word gonol atomically at the next scale. Ignore pronunciation unless a future explicit construction says otherwise. +When a gonol closes, use it atomically at any admissible consuming scale. Ignore pronunciation unless a future explicit construction says otherwise. ## Anti-patterns - Moving text semantics into UCNS or inventing geometry in EDCM. -- Inserting another required EDCM stage without an explicit contract change. +- Vendoring the EDCM executable witness into a UCNS geometry repository. +- Imposing an adjacent-scale ladder or inventing an eligible scale option without EDCM authority. - Letting pronunciation alter gonol identity, closure, ordering, or relations unless a later explicitly declared experiment makes phonology part of its construction. -- Normalizing, deduplicating, inferring relations, or substituting tokens, embeddings, or hashes for gonol identity unless the active contract explicitly authorizes it. +- Normalizing, deduplicating, inferring relations, or substituting tokens, embeddings, hashes for gonol identity unless the active contract explicitly authorizes it. - Adding arbitrary wall-clock limits to a healthy admitted run. - Treating replay as semantic quality, measurement validity, cognition, or extra-scope canon. @@ -105,4 +127,4 @@ When a word closes, use that word gonol atomically at the next scale. Ignore pro - exact UCNS geometric operations that remain unresolved in current implementation; - any future construction that explicitly adds phonology or another stage; -- any recursive relation whose governing source or geometry is not yet established. +- any recursive relation whose governing source or geometry is not yet established. \ No newline at end of file diff --git a/.agents/skills/msdmd/SKILL.md b/.agents/skills/msdmd/SKILL.md index ffa10c4..9a6a036 100644 --- a/.agents/skills/msdmd/SKILL.md +++ b/.agents/skills/msdmd/SKILL.md @@ -63,7 +63,9 @@ claims to prove those obligations. See referenced from external tooling). - **Field lines**: indented one level beneath the id (two spaces of visible indent inside the comment). Field names are lowercase - snake_case followed by `:` and a value. + snake_case followed by `:` and a value. Digits are allowed after the first + character, so `evidence_sha256` is valid; the first character must be a + lowercase letter or underscore. - **Multiple blocks per file**: a module may declare more than one block, of the same or different types. The parser concatenates entries. @@ -144,6 +146,13 @@ A reference implementation in pure stdlib Python lives at Both commit to zero non-stdlib dependencies so you can copy them into any project. +The Python and TypeScript reference helpers each own `MODULE_BUILD` and +field-preservation `CONTRACTS` beside their implementations. A consumer that +executes or updates a vendored helper should reconcile its exact dependency +declarations and provide a local resolving `CHECKS` witness. The dependency's canonical owner +and exact source identity remain explicit; local execution does not transfer +parser ownership to the consumer. + Extension detection refuses ambiguous suffixes rather than sniffing content. For example, `.m` can mean Objective-C or MATLAB/Octave and therefore has no automatic marker. A caller that already knows the language may still call diff --git a/.agents/skills/msdmd/parsers/universal.py b/.agents/skills/msdmd/parsers/universal.py index 204f533..e25feee 100644 --- a/.agents/skills/msdmd/parsers/universal.py +++ b/.agents/skills/msdmd/parsers/universal.py @@ -1,4 +1,27 @@ -# ratios: loc_comments=161:57 imports_exports=4:7 calls_definitions=55:10 +# ratios: loc_comments=161:80 imports_exports=4:7 calls_definitions=55:10 +# === MODULE_BUILD === +# id: msdmd_python_reference_parser +# module_name: universal +# module_kind: instrument +# summary: parses canonical line-comment metadata without executing inspected source +# owner: The Interdependency skill-lib +# public_surface: COMMENT_MARKERS, RATIO_IDS, marker_for, parse_text, parse_file, walk_tree, parse_ratios, parse_ratios_file, ratios_placement +# internal_surface: marker and block matching helpers +# auth_boundary: none +# storage_boundary: read +# network_boundary: none +# user_data_boundary: read +# admin_only: false +# tests: tests/test_universal_parser.py +# rollout: exact-pinned reference parser propagation +# rollback: restore a previously accepted exact parser identity +# === END MODULE_BUILD === +# === CONTRACTS === +# id: msdmd_python_parser_preserves_field_names +# given: a valid metadata entry uses lowercase snake-case field names containing digits +# then: parsed entries retain those field names and string values without executing the inspected source +# class: evidence +# === END CONTRACTS === """Universal msdmd parser — pure stdlib. Implements the parser contract from ``msdmd/SKILL.md``: extracts every @@ -100,7 +123,7 @@ def parse_text(text: str, block_name: str, marker: str = "#") -> list[dict]: block_re = _block_regex(block_name, marker) m = re.escape(marker) id_re = re.compile(rf"^\s*{m}\s*id:\s*(?P\S+)\s*$") - field_re = re.compile(rf"^\s*{m}\s+(?P[a-z_]+):\s*(?P.+?)\s*$") + field_re = re.compile(rf"^\s*{m}\s+(?P[a-z_][a-z0-9_]*):\s*(?P.+?)\s*$") entries: list[dict] = [] for block in block_re.finditer(text): @@ -189,7 +212,7 @@ def iter_source_files(path: Path) -> Iterable[Path]: # boundary to literal line 2: # ratios: loc_comments=N:M imports_exports=N:M calls_definitions=N:M RATIO_IDS = ("loc_comments", "imports_exports", "calls_definitions") -_RATIOS_TOKEN_RE = re.compile(r"(?P[a-z_]+)=(?P\S+)") +_RATIOS_TOKEN_RE = re.compile(r"(?P[a-z_][a-z0-9_]*)=(?P\S+)") def _ratios_line_re(marker: str) -> re.Pattern[str]: @@ -253,4 +276,4 @@ def ratios_placement(text: str, marker: str = "#") -> tuple[bool, bool]: last_ok = bool(line_re.match(raw.rstrip())) break return (opening_ok, last_ok) -# ratios: loc_comments=161:57 imports_exports=4:7 calls_definitions=55:10 +# ratios: loc_comments=161:80 imports_exports=4:7 calls_definitions=55:10 diff --git a/.agents/skills/msdmd/parsers/universal.ts b/.agents/skills/msdmd/parsers/universal.ts index 141f46c..6b4dc72 100644 --- a/.agents/skills/msdmd/parsers/universal.ts +++ b/.agents/skills/msdmd/parsers/universal.ts @@ -1,4 +1,27 @@ // ratios: loc_comments=hmmm imports_exports=hmmm calls_definitions=hmmm +// === MODULE_BUILD === +// id: msdmd_typescript_reference_parser +// module_name: universal +// module_kind: instrument +// summary: parses canonical line-comment metadata without executing inspected source +// owner: The Interdependency skill-lib +// public_surface: Entry, WalkOptions, COMMENT_MARKERS, RATIO_IDS, markerFor, parseText, parseFile, walkTree, parseRatios, parseRatiosFile, ratiosPlacement +// internal_surface: marker and block matching helpers +// auth_boundary: none +// storage_boundary: read +// network_boundary: none +// user_data_boundary: read +// admin_only: false +// tests: tests/test_universal_parser.py::test_typescript_parser_field_contract +// rollout: exact-pinned reference parser propagation +// rollback: restore a previously accepted exact parser identity +// === END MODULE_BUILD === +// === CONTRACTS === +// id: msdmd_typescript_parser_preserves_field_names +// given: a valid metadata entry uses lowercase snake-case field names containing digits +// then: parsed entries retain those field names and string values without executing the inspected source +// class: evidence +// === END CONTRACTS === /** * Universal msdmd parser — pure Node stdlib (fs, path). * @@ -86,7 +109,7 @@ export function parseText( "gm", ); const idRe = new RegExp(`^\\s*${m}\\s*id:\\s*(\\S+)\\s*$`); - const fieldRe = new RegExp(`^\\s*${m}\\s+([a-z_]+):\\s*(.+?)\\s*$`); + const fieldRe = new RegExp(`^\\s*${m}\\s+([a-z_][a-z0-9_]*):\\s*(.+?)\\s*$`); const entries: Entry[] = []; let match: RegExpExecArray | null; @@ -185,7 +208,7 @@ function ratiosLineRe(marker: string): RegExp { export function parseRatios(text: string, marker: string = "#"): Entry[] { const lineRe = ratiosLineRe(marker); - const tokenRe = /([a-z_]+)=(\S+)/g; + const tokenRe = /([a-z_][a-z0-9_]*)=(\S+)/g; const out: Entry[] = []; for (const raw of text.split("\n")) { const lm = lineRe.exec(raw.replace(/\s+$/, "")); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b0bf8d..e54b5f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v7.0.1 with: repository: The-Interdependency/skill-lib - ref: c14ee9d500579a4b5d6821f62c9d82ca96e73608 + ref: 5e7239b52dc5403d8d01fbc63341fe397719ec3f path: .skill-lib-canon sparse-checkout: | msdmd @@ -40,22 +40,40 @@ jobs: - uses: actions/setup-python@v7.0.0 with: python-version: ${{ matrix.python-version }} - - name: Install verification dependencies - run: python -m pip install --upgrade pip pytest build twine - - name: Install package - run: python -m pip install -e ".[test]" + - uses: actions/setup-node@v6 + with: + node-version: "24.15.0" + - name: Install verification prerequisites + run: | + sudo apt-get update + sudo apt-get install -y libmpfr6 + python -m pip install uv==0.11.18 + - name: Check lock and install verification dependencies + run: | + uv lock --check + uv sync --locked --python python --extra test --extra build - name: Check vendored skill-lib drift run: >- python .skill-lib-canon/tools/check_consumer_drift.py . --canon-root .skill-lib-canon - --sha c14ee9d500579a4b5d6821f62c9d82ca96e73608 + --sha 5e7239b52dc5403d8d01fbc63341fe397719ec3f --strict-sha --require-vendored - name: Audit skill-lib contract graph - run: python tools/verify_skill_lib_contracts.py . + run: .venv/bin/python tools/verify_skill_lib_contracts.py . - name: Run geometry tests - run: python -m pytest + env: + PYTEST_DISABLE_PLUGIN_AUTOLOAD: '1' + PYTHONDONTWRITEBYTECODE: '1' + run: | + .venv/bin/python - <<'PY' + from pathlib import Path + from tools._boundary_pytest import run_suite + raise SystemExit(run_suite(["tests", "-c", "pyproject.toml", "--noconftest", "--strict-config"], Path.cwd())) + PY - name: Build package - run: python -m build + run: .venv/bin/python -m build - name: Check distributions - run: python -m twine check dist/* + run: | + .venv/bin/python -m twine check dist/* + .venv/bin/python tools/verify_distributions.py . dist diff --git a/AGENTS.md b/AGENTS.md index 1b8cc5e..26b2f2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,4 +12,4 @@ - Preserve exact arithmetic where exactness is load-bearing; lossy renderings remain explicitly secondary. - Use `hmmm` for unresolved geometric constraints. Never fill a missing geometric law with semantic machinery or implementation convenience. - Every behavior-bearing source module owns skill-lib `MODULE_BUILD` and `CONTRACTS`; executable tests own resolving `CHECKS`. -- Before claiming test-backed status run the complete geometry test suite, `python tools/verify_skill_lib_contracts.py .`, build, and Twine checks. +- Before claiming test-backed status run the complete geometry suite through the outcome gate documented in README, `.venv/bin/python tools/verify_skill_lib_contracts.py .`, build, and Twine checks. Use the project interpreter populated by `uv sync --locked --extra test --extra build` for these checks. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..93bc3cb --- /dev/null +++ b/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..226f2a9 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,9 @@ +include AGENTS.md CANON.md CLAUDE.md uv.lock +recursive-include src/ucns * +recursive-include tools * +recursive-include tests * +recursive-include docs * +recursive-include generated * +recursive-include .agents/skills * +recursive-include .github/workflows * +global-exclude *.py[cod] diff --git a/README.md b/README.md index 784b445..dca6b4b 100644 --- a/README.md +++ b/README.md @@ -144,11 +144,119 @@ assert s720 == s0 ## Build ```bash -python -m pip install -e ".[test,build]" -python -m pytest -q -python tools/verify_skill_lib_contracts.py . -python -m build -python -m twine check dist/* +python -m pip install uv==0.11.18 +uv lock --check +uv sync --locked --python python --extra test --extra build +.venv/bin/python tools/verify_skill_lib_contracts.py . +env -u PYTHONPATH -u PYTHONHOME -u PYTEST_ADDOPTS -u PYTEST_PLUGINS \ + PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python - <<'PY' +from pathlib import Path +from tools._boundary_pytest import run_suite +raise SystemExit(run_suite(["tests", "-c", "pyproject.toml", "--noconftest", "--strict-config"], Path.cwd())) +PY +.venv/bin/python -m build +.venv/bin/python -m twine check dist/* +.venv/bin/python tools/verify_distributions.py . dist ``` +The independent interval checks also require system MPFR (for example, +`libmpfr6` on Ubuntu). Verification dependencies are locked; the isolated build +backend is pinned in `[build-system].requires`. Use a clean `dist` directory. + +The wheel supplies the geometry package. Repository-context research replay and +the full tests require the source archive or checkout: run from its root, where +the archived preregistrations, generated evidence, and vendored parser live. +The distribution gate checks these inputs byte-for-byte; it does not recertify +their mathematical claims. It also binds wheel filename tags, dependencies, and flags to +`pyproject.toml`, verifies every RECORD digest, and requires the exact generated +sdist configuration. Both source PKG-INFO records and generated egg-info +dependency and file lists must agree with that same source configuration. Directory +entries are validated too; duplicate names and file/directory collisions fail. + +Exact modular and trace records require immutable tuples with non-Boolean integer +residues; prefer the public builders. MPFR rational constructors accept only +integers or `Fraction`, and NaN cannot participate in interval ordering. + +The no-exec graph also reconciles both exact vendored reference parsers, with +local Python and TypeScript numeric-field/no-execution witnesses. The complete +repository evidence suite requires Node 24.15.0 for that TypeScript check; UCNS +library use remains Python-only. Parser ownership remains in skill-lib. +Unused skill helper tools remain canonical dependency material. + +Receipt inventories hash every file under the bound `src`, `tools`, `tests`, +`docs`, `generated`, `.agents/skills`, and `.github/workflows` directories, +excluding `__pycache__`, plus the runner's declared root inputs. Text, binary, +and extensionless fixtures are included. Symlinks in bound inputs are unsupported +and fail before execution. Source archives retain the complete +package, test, tool, documentation, generated-evidence, skill, and CI trees; +the distribution gate rejects missing or altered inputs. Declared test-function +bindings must be unique. Test-helper modules receive the same collection-surface +audit, and the observer compares the actual test function code with its declared +source before and after the call. An imported replacement cannot stand in for +the original witness. Bound Python imports compile the inventoried source, and +pytest runs use fresh bytecode-cache locations; pre-existing caches cannot replace +those source imports. Snapshot failures during execution become explicit ERROR +outcomes, retain mutation evidence, and produce a not-passed receipt. + +For a source-bound, selected-check receipt: + +```bash +.venv/bin/python tools/run_skill_lib_boundaries.py . \ + --check check_boundary_runner_nonactivation --receipt /tmp/ucns-receipt.json +``` + +Receipt schema 2.1 rejects skips, expected failures, both forms of XPASS, absent +reports, and source changes during execution (including write-and-restore +through external hardlinks). +Receipt execution requires Linux inotify and readable procfs process identities; +unavailable instrumentation is an error before check execution. Descendant discovery +uses the task-children interface when available and otherwise reads procfs parent IDs. +Potential test definitions inside module or class control flow fail the declaration +audit closed; executable witnesses use direct module-level definitions. +The graph requires explicit `testpaths = ["tests"]` and +`collect_imported_tests = false`, with pytest 9.1 or later, and supports default pytest +file/class/function naming. Unrecognized configuration keys fail closed. +Repository-root helper imports outside the declared source layout fail the audit. +The complete-suite gate also requires every statically declared top-level test to +execute, so hiding a failing witness during collection cannot leave a passing gate. +Both full and selected execution also reconcile every collected pytest item, +including separate parameter invocations, against its actual call outcome. +Conftest files and local plugin registration are +outside this bounded collection contract. Changed settings, alternative pytest configuration +files, and collection-changing `addopts` fail closed. Root `pyproject.toml` is +parsed without executing tests; Python 3.10 uses the declared `tomli` test dependency. +Nested pytest configuration is rejected, and selected execution explicitly uses +the audited root config with conftest loading disabled. Collection-time execution +is limited to declarations, literal data, pytest decorators with literal arguments, +and read-only `Path(__file__)` source constants. Callable decorator options and +string skip/xfail conditions are unsupported. Compound annotations require +`from __future__ import annotations`. Indirect namespace mutation and unresolved calls, decorators, +or namespace protocols fail closed; setup work belongs in fixtures or checks. +Module/class pytest marks and pytest/xunit hooks are unsupported implicit execution. Class +data bindings must be literal values; imported descriptors, unresolved bases, nested classes, and compound class +namespace construction fail closed. Fixture helpers remain supported, including +test-prefixed helper names, but fixture-decorated functions cannot resolve CHECKS. +Imported or destructured `__test__` bindings are unresolved collection opt-outs and fail closed. +Class and base names must have unambiguous bindings; later rebinding cannot +stand in for the class used during construction. +CI runs the complete suite through the outcome observer: collection skips/errors, +runtime skips, xfail, XPASS, and empty execution fail the suite gate. +Source observation starts before capability probing. Executable Node version +probes use the same descendant supervisor as checks; a timeout or leaked child +cannot satisfy the capability. Source archives require owner-readable/writable +files and owner-readable/writable/searchable directories, without special mode bits. +Explicit archive directories must belong to the hierarchy of allowed files. +Receipt output must be outside the bound source tree and is written by atomic +replacement so an output hardlink cannot modify a bound input. +Checks import the bound checkout with ambient pytest plugins disabled and +PYTHONPATH replaced. An inherited startup hook makes ordinary Python descendants +prefer bound packages over their working directory. A separate supervisor uses Linux +child subreaping to terminate remaining descendants before observation ends; +leaked background work prevents acceptance. Timeout cleanup is outside the +pytest process, so replacing its signal handler cannot bypass descendant cleanup. Origin receipts observe the selected +pytest process. Explicit isolated/no-site interpreters and replaced child +environments do not inherit the import protocol. The observer instruments trusted +checks; it is not a sandbox for hostile test code. A `passed` receipt covers only its selected +checks; it does not select geometry, ratify candidates, or establish freshness. + `hmmm`: ratification of the modular-orbit / continuum-boundary-trace candidates, the complete higher-dimensional UCNS construction, the exact visible-circle wave-trace lift into the native Möbius carrier, any law selecting one continuum covering lift from a finite modular congruence class, and the exact geometric operation of every Public Gonol function position remain unresolved. Unresolved geometry stays unresolved; semantic machinery is not used to fill it. diff --git a/docs/work-graphs/repository-plan-report.json b/docs/work-graphs/repository-plan-report.json index 9a8163e..772552c 100644 --- a/docs/work-graphs/repository-plan-report.json +++ b/docs/work-graphs/repository-plan-report.json @@ -1 +1,167 @@ -{"schema":"the-interdependency.repository-plan-report","version":"1.0.0","repository":"The-Interdependency/ucns","contract":{"repository":"The-Interdependency/skill-lib","path":"interdependent-work-graph/repository-plan-report.schema.json","version":"1.0.0","blob_sha":"9b347b2dff7692054b571602f30ee6d00c2e7265"},"source":{"branch":"main","commit":"c4b8d6225262168942a456ebc6f34f42de8dccf0","generated_at":"2026-09-06","note":"This report describes the exact repository state immediately beneath the report commit; the report commit is coordination metadata and does not acquire or transfer substantive authority."},"authority":{"owns":["geometry constructions and exact geometric carriers implemented in UCNS","UCNS-local mathematical representation candidates and their proof/status evidence","Public Gonol carrier geometry and native Möbius geometric state"],"does_not_own":["lexical, semantic, morphological, NLP, EDCM measurement, PTCNA runtime, PCEA security, or external-physics truth claims"],"non_transfer":["executability does not ratify a candidate geometry","downstream use does not transfer downstream semantic or empirical status into UCNS","finite modular traces do not select a privileged continuum lift or external physical meaning"]},"portfolio_role":{"summary":"Own the geometry and mathematical-representation layer consumed by other Interdependency projects without absorbing their semantics or empirical claims.","reports_to":{"repository":"The-Interdependency/skill-lib","skill":"interdependent-work-graph","relation":"repo-owned report consumed by the deterministic portfolio projection"}},"status":{"state":"active geometry repository with exact constructions and explicitly candidate-scoped continuum/modular relations","current_claim":"UCNS provides exact Public Gonol, Möbius, modular-orbit, continuum wave-trace, and related topological geometry while leaving unresolved geometry and candidate selection explicit."},"delivered":[{"surface":"Public Gonol carrier","status":"implemented exact 157-position carrier","boundary":"glyph function meaning is not inferred from Unicode or lexical semantics"},{"surface":"native Möbius geometry","status":"implemented exact framed 360° visible / 720° complete return","boundary":"display equivalence does not collapse complete-state identity"},{"surface":"modular orbit and continuum boundary trace","status":"implemented candidate exact relation","boundary":"candidate status; no privileged lift, modulus, carrier, or physical meaning is selected"}],"active_frontier":["ratify or reject modular-orbit / continuum-boundary-trace candidates under explicit falsifiers","derive the complete visible-circle wave trace into the native Möbius carrier","resolve exact geometric operations for unresolved Public Gonol positions"],"next_actions":[{"action":"add and maintain this repo-owned portfolio report","owner":"The-Interdependency/ucns","dependency":"skill-lib repository-plan-report v1 contract"},{"action":"keep unresolved geometric operations as hmmm rather than filling them semantically","owner":"The-Interdependency/ucns","dependency":"geometry evidence sufficient to select operations"}],"blocked":[],"cross_repository_relations":[{"repository":"The-Interdependency/skill-lib","relation":"consumer of canonical build/evidence and work-graph doctrine","authority_transfer":false},{"repository":"The-Interdependency/stack","relation":"canonical source for stack-pinned UCNS views and stack-local UCNS research","authority_transfer":false},{"repository":"The-Interdependency/edcm","relation":"geometry producer for downstream text-domain gonol construction; EDCM retains text/measurement authority","authority_transfer":false},{"repository":"The-Interdependency/pcea","relation":"forward arithmetic/geometric substrate may be consumed by PCEA without transferring security claims","authority_transfer":false},{"repository":"The-Interdependency/epac","relation":"geometry dependency for EPAC candidate constructions; external physics/chemistry standing does not transfer","authority_transfer":false}],"machine_entrypoints":{"repo_report":"docs/work-graphs/repository-plan-report.json","system_overview":"README.md","package_source":"src/ucns/","public_gonol":"src/ucns/public_gonol.py","continuum_trace":"src/ucns/gonal_boundary_trace.py","continuum_trace_docs":"docs/modular-orbit-wave-trace.md"},"hmmm":["ratification of modular-orbit / continuum-boundary-trace candidates remains unresolved","complete native Möbius lift for visible-circle wave traces remains unresolved","exact geometric operation of every Public Gonol position remains unresolved"]} +{ + "schema": "the-interdependency.repository-plan-report", + "version": "1.0.0", + "repository": "The-Interdependency/ucns", + "contract": { + "repository": "The-Interdependency/skill-lib", + "path": "interdependent-work-graph/repository-plan-report.schema.json", + "version": "1.0.0", + "blob_sha": "9b347b2dff7692054b571602f30ee6d00c2e7265" + }, + "source": { + "branch": "repair/ucns-audit-20260911", + "commit": "af02fa61c225b2ada4cb36d1c8016fc81f94f1c0", + "generated_at": "2026-09-12", + "note": "Describes the exact implementation commit preceding this report-only refresh. Maintenance PR #226 is in final exact-head review; merge remains a separate acceptance observation. No scientific or deployment authority is transferred." + }, + "authority": { + "owns": [ + "geometry constructions and exact geometric carriers implemented in UCNS", + "UCNS-local mathematical representation candidates and their proof/status evidence", + "Public Gonol carrier geometry and native M\u00f6bius geometric state" + ], + "does_not_own": [ + "lexical, semantic, morphological, NLP, EDCM measurement, PTCNA runtime, PCEA security, or external-physics truth claims" + ], + "non_transfer": [ + "executability does not ratify a candidate geometry", + "downstream use does not transfer downstream semantic or empirical status into UCNS", + "finite modular traces do not select a privileged continuum lift or external physical meaning" + ] + }, + "portfolio_role": { + "summary": "Own the geometry and mathematical-representation layer consumed by other Interdependency projects without absorbing their semantics or empirical claims.", + "reports_to": { + "repository": "The-Interdependency/skill-lib", + "skill": "interdependent-work-graph", + "relation": "repo-owned report consumed by the deterministic portfolio projection" + } + }, + "status": { + "state": "active geometry repository; maintenance repair in exact-head review", + "current_claim": "UCNS retains exact geometric carriers and candidate modular/wave relations. Local repairs harden immutable/exact inputs, MPFR NaN ordering, no-exec contract auditing, selected-check receipts, and replayable distributions; no candidate is ratified by those repairs." + }, + "delivered": [ + { + "surface": "Public Gonol carrier", + "status": "implemented exact 157-position carrier", + "boundary": "glyph function meaning is not inferred from Unicode or lexical semantics" + }, + { + "surface": "native M\u00f6bius geometry", + "status": "implemented exact framed 360\u00b0 visible / 720\u00b0 complete return", + "boundary": "display equivalence does not collapse complete-state identity" + }, + { + "surface": "modular orbit and continuum boundary trace", + "status": "implemented candidate exact relation", + "boundary": "candidate status; no privileged lift, modulus, carrier, or physical meaning is selected" + }, + { + "surface": "Exact modular/trace and MPFR input boundaries", + "status": "implemented with negative regression checks", + "boundary": "non-Boolean integer and immutable record admission; NaN cannot supply ordered interval evidence; valid geometric laws unchanged" + }, + { + "surface": "Contract audit and selected-check receipt schema 2.1", + "status": "implemented fail-closed declaration bindings, actual pytest outcomes, bound descendant imports and lifetime, per-check snapshots, and Linux source-write observation", + "boundary": "skips, empty evidence, and source mutation do not pass; selected checks do not establish whole-repository, theorem, freshness, or canon standing" + }, + { + "surface": "Source distribution replay inputs and artifact gate", + "status": "implemented exact wheel/sdist metadata and directory validation with pinned build backend and source replay inputs", + "boundary": "archive presence and matching bytes are packaging evidence; archived certificates are not thereby recomputed" + } + ], + "active_frontier": [ + "close exact-head maintenance review and then verify the source-distribution/clean-install replay chain across supported Python versions", + "reconcile the intrinsic/extrinsic Structural Null representation before constructing an origin attachment", + "derive the complete visible-circle wave trace into the native frame-bearing M\u00f6bius carrier", + "ratify or reject modular-orbit / continuum-boundary-trace candidates under explicit falsifiers", + "resolve higher-dimensional recursive-scale laws and geometric operations for unresolved Public Gonol positions" + ], + "next_actions": [ + { + "action": "Complete exact-head review and Python 3.10/3.12 CI for UCNS PR #226; merge after all live findings and gates close", + "owner": "The-Interdependency/ucns maintainers", + "dependency": "Exact canonical skill-lib 5e7239b52dc5403d8d01fbc63341fe397719ec3f binds the declared parser; nine installed skills pass strict drift verification" + }, + { + "action": "Correct stale stack research claims about PR #220 and reconcile its exact research base before consuming results", + "owner": "The-Interdependency/stack research maintainers", + "dependency": "PR #220 merged with candidate-only wording; preserve existing dirty research and no automatic repin" + }, + { + "action": "Reconcile Structural Null representation, then derive or refute an explicit ambient origin attachment", + "owner": "The-Interdependency/ucns geometry", + "dependency": "geometric evidence and falsifier; coordinate defaults do not supply attachment authority" + }, + { + "action": "After attachment, evaluate direction, rotation system, marked outgoing dart, and closure in the stack-local constructor experiment", + "owner": "The-Interdependency/ucns geometry and The-Interdependency/stack research", + "dependency": "exact accepted attachment; this dependency order is a research contract, not ratified UCNS geometry" + }, + { + "action": "Propose a complete native M\u00f6bius wave lift with 360/720-degree and continuum-degree counterexample checks", + "owner": "The-Interdependency/ucns geometry", + "dependency": "explicit frame-bearing construction and candidate selection protocol" + }, + { + "action": "Recover the hash-bound source attachment and independently replay expensive P7/P5 certificates before stronger claims", + "owner": "The-Interdependency/ucns evidence maintainers", + "dependency": "exact source bytes, preregistrations, adequate resources, and owner review" + } + ], + "blocked": [ + "Final exact-head CI and merge remain unobserved at this report refresh.", + "Origin attachment and recursive traversal cannot be supplied by numerical defaults or downstream interpretations." + ], + "cross_repository_relations": [ + { + "repository": "The-Interdependency/skill-lib", + "relation": "consumer of canonical build/evidence and work-graph doctrine", + "authority_transfer": false + }, + { + "repository": "The-Interdependency/stack", + "relation": "canonical source for stack-pinned UCNS views and stack-local UCNS research", + "authority_transfer": false + }, + { + "repository": "The-Interdependency/edcm", + "relation": "geometry producer for downstream text-domain gonol construction; EDCM retains text/measurement authority", + "authority_transfer": false + }, + { + "repository": "The-Interdependency/pcea", + "relation": "forward arithmetic/geometric substrate may be consumed by PCEA without transferring security claims", + "authority_transfer": false + }, + { + "repository": "The-Interdependency/epac", + "relation": "geometry dependency for EPAC candidate constructions; external physics/chemistry standing does not transfer", + "authority_transfer": false + } + ], + "machine_entrypoints": { + "repo_report": "docs/work-graphs/repository-plan-report.json", + "system_overview": "README.md", + "package_source": "src/ucns/", + "public_gonol": "src/ucns/public_gonol.py", + "continuum_trace": "src/ucns/gonal_boundary_trace.py", + "continuum_trace_docs": "docs/modular-orbit-wave-trace.md", + "contract_audit": "tools/verify_skill_lib_contracts.py", + "selected_check_runner": "tools/run_skill_lib_boundaries.py", + "distribution_gate": "tools/verify_distributions.py", + "build_and_usage": "README.md#build" + }, + "hmmm": [ + "Structural Null executable representation and candidate documentation remain unreconciled.", + "Origin attachment, recursive-scale transitions, and unresolved Public Gonol operations lack selecting geometric laws.", + "Modular-orbit / continuum-boundary-trace ratification and complete native M\u00f6bius lift remain unresolved.", + "The exact cited M\u00f6bius Strips and Quantum Geometry.txt source attachment is absent from the inspected owning tree.", + "A passing default suite includes archived receipt checks, not a newly executed full independent Gr\u00f6bner replay.", + "Final-head remote CI, merge, release, cross-repository repinning, and proof-assistant verification remain separate observations.", + "Pre-existing ratio coverage gaps and large-module decomposition remain incremental maintenance work." + ] +} diff --git a/pyproject.toml b/pyproject.toml index 98c9a0a..d68221d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=68", "wheel"] +requires = ["setuptools==84.0.0", "wheel==0.48.0"] build-backend = "setuptools.build_meta" [project] @@ -7,11 +7,13 @@ name = "ucns" version = "0.0.0.dev0" description = "UCNS geometric and topological construction research" readme = "README.md" +license = {file = "LICENSE"} requires-python = ">=3.10" authors = [{name = "Erin Spencer"}] dependencies = ["mpmath>=1.3", "numpy>=1.24"] classifiers = [ "Development Status :: 2 - Pre-Alpha", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -20,7 +22,8 @@ classifiers = [ [project.optional-dependencies] test = [ - "pytest>=8", + "pytest>=9.1", + "tomli>=2; python_version < '3.11'", "mpmath>=1.3,<2", "sympy>=1.12,<2", ] @@ -28,7 +31,7 @@ research = [ "mpmath>=1.3,<2", "sympy>=1.12,<2", ] -build = ["build>=1.2", "twine>=5"] +build = ["build>=1.2", "twine>=5", "tomli>=2; python_version < '3.11'"] [tool.setuptools] package-dir = {"" = "src"} @@ -38,4 +41,5 @@ where = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] +collect_imported_tests = false addopts = "-q" diff --git a/src/ucns/gonal_boundary_trace.py b/src/ucns/gonal_boundary_trace.py index 0f54fde..7d3134c 100644 --- a/src/ucns/gonal_boundary_trace.py +++ b/src/ucns/gonal_boundary_trace.py @@ -1,3 +1,4 @@ +# ratios: loc_comments=226:108 imports_exports=5:7 calls_definitions=76:18 # === MODULE_BUILD === # id: ucns_gonal_boundary_trace # module_name: gonal_boundary_trace @@ -211,6 +212,8 @@ def __post_init__(self) -> None: def phase_at(self, residue: int) -> Fraction: """Return exact spatial phase in normalized turns at one declared position.""" + if isinstance(residue, bool) or not isinstance(residue, int): + raise GonalBoundaryTraceError("residue must be a nonboolean integer") for sample in self.samples: if sample.residue == residue: return sample.phase_turn @@ -242,7 +245,11 @@ def as_dict(self) -> dict[str, object]: @dataclass(frozen=True, slots=True) class CircleWaveCoveringTrace: - """Exact trace-level witness of a continuum degree-d spacetime pullback.""" + """Exact trace-level witness of a continuum degree-d spacetime pullback. + + Direct restoration requires validated trace records and immutable integer + action pairs. Use ``pullback_circle_wave_trace`` to construct a witness. + """ source: CircleWaveModeTrace target: CircleWaveModeTrace @@ -251,6 +258,14 @@ class CircleWaveCoveringTrace: action: tuple[tuple[int, int], ...] def __post_init__(self) -> None: + if not isinstance(self.source, CircleWaveModeTrace) or not isinstance(self.target, CircleWaveModeTrace): + raise GonalBoundaryTraceError("source and target must be CircleWaveModeTrace records") + if not isinstance(self.action, tuple) or any( + not isinstance(pair, tuple) or len(pair) != 2 + or any(isinstance(value, bool) or not isinstance(value, int) for value in pair) + for pair in self.action + ): + raise GonalBoundaryTraceError("action must contain immutable nonboolean integer pairs") _validate_covering_degree(self.covering_degree) _validate_time_scale(self.time_scale) if self.time_scale != self.covering_degree: @@ -347,6 +362,8 @@ def pullback_circle_wave_trace( explicitly, and this function requires ``d % m == geometry.multiplier``. """ + if not isinstance(trace, CircleWaveModeTrace) or not isinstance(geometry, ModularOrbitGeometry): + raise GonalBoundaryTraceError("pullback requires validated trace and modular geometry records") if trace.modulus != geometry.modulus: raise GonalBoundaryTraceError("trace and modular geometry moduli must match") if trace.positions != geometry.positions: @@ -379,3 +396,4 @@ def pullback_circle_wave_trace( "build_circle_wave_mode_trace", "pullback_circle_wave_trace", ] +# ratios: loc_comments=226:108 imports_exports=5:7 calls_definitions=76:18 diff --git a/src/ucns/modular_orbit.py b/src/ucns/modular_orbit.py index 3a815db..0dba580 100644 --- a/src/ucns/modular_orbit.py +++ b/src/ucns/modular_orbit.py @@ -1,3 +1,4 @@ +# ratios: loc_comments=204:82 imports_exports=4:5 calls_definitions=92:10 # === MODULE_BUILD === # id: ucns_modular_orbit_geometry # module_name: modular_orbit @@ -99,7 +100,11 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) class ModularOrbitGeometry: - """Immutable exact geometry of one finite modular multiplication action.""" + """Immutable exact geometry of one finite modular multiplication action. + + Restore records with nested tuples and nonboolean integers; numeric equality + to an integer is not sufficient admission. Use the builder for iterables. + """ modulus: int multiplier: int @@ -110,6 +115,19 @@ class ModularOrbitGeometry: embedding: tuple[CircularResiduePosition, ...] def __post_init__(self) -> None: + for name in ("positions", "action", "orbits", "periods", "embedding"): + if not isinstance(getattr(self, name), tuple): + raise ModularOrbitError(f"{name} must be an immutable tuple") + for name in ("action", "orbits"): + for row in getattr(self, name): + if not isinstance(row, tuple) or not row: + raise ModularOrbitError(f"{name} must contain nonempty immutable tuples") + if name == "action" and len(row) != 2: + raise ModularOrbitError("action entries must be source/target pairs") + if any(isinstance(value, bool) or not isinstance(value, int) for value in row): + raise ModularOrbitError(f"{name} must contain nonboolean integer residues") + if any(isinstance(value, bool) or not isinstance(value, int) for value in self.periods): + raise ModularOrbitError("periods must be nonboolean integers") if isinstance(self.modulus, bool) or not isinstance(self.modulus, int) or self.modulus <= 1: raise ModularOrbitError("modulus must be an integer greater than 1") if isinstance(self.multiplier, bool) or not isinstance(self.multiplier, int): @@ -170,6 +188,8 @@ def __post_init__(self) -> None: def target(self, residue: int) -> int: """Return the exact action target for a declared position.""" + if isinstance(residue, bool) or not isinstance(residue, int): + raise ModularOrbitError("residue must be a nonboolean integer") for source, target in self.action: if source == residue: return target @@ -178,6 +198,8 @@ def target(self, residue: int) -> int: def turn_of(self, residue: int) -> Fraction: """Return the exact normalized-circle turn for a declared position.""" + if isinstance(residue, bool) or not isinstance(residue, int): + raise ModularOrbitError("residue must be a nonboolean integer") for position in self.embedding: if position.residue == residue: return position.turn @@ -309,3 +331,4 @@ def build_modular_orbit_geometry( "ModularOrbitGeometry", "build_modular_orbit_geometry", ] +# ratios: loc_comments=204:82 imports_exports=4:5 calls_definitions=92:10 diff --git a/src/ucns/mpfr_interval.py b/src/ucns/mpfr_interval.py index af3da5e..088109c 100644 --- a/src/ucns/mpfr_interval.py +++ b/src/ucns/mpfr_interval.py @@ -1,3 +1,4 @@ +# ratios: loc_comments=374:65 imports_exports=7:9 calls_definitions=179:48 # === MODULE_BUILD === # id: ucns_mpfr_interval # module_name: mpfr_interval @@ -31,6 +32,16 @@ # then: both prime candidates retain lower endpoints above the declared centerline margin # class: evidence # since: 2026-08-11 +# +# id: mpfr_rejects_nan_ordering +# given: a NaN or uninitialized MPFR value reaches an interval, comparison, or sign query +# then: MPFRError is raised before unordered values can be used as certificate evidence +# class: safety +# +# id: mpfr_rational_inputs_are_exact +# given: a rational interval or MPFR rational number is requested +# then: only nonboolean integers and exact Fractions are accepted without implicit float or string conversion +# class: correctness # === END CONTRACTS === """Minimal outward-rounded interval arithmetic backed directly by system MPFR. @@ -38,6 +49,10 @@ This module deliberately avoids ``mpmath.iv``. It calls ``libmpfr`` through ``ctypes`` and passes an explicit directed rounding mode to every primitive operation used by the P7/P5 separation replay. + +Usage: ``MPInterval.rational(Fraction(1, 3))`` encloses an exact rational; +``MPInterval.decimal("0.1")`` encloses explicit decimal text. NaN is never +ordered evidence, including MPFR's initially unassigned number state. """ from __future__ import annotations @@ -101,6 +116,7 @@ def _bind(name: str, restype: object, *argtypes: object) -> object: _mpfr_atan2 = _bind("mpfr_atan2", ctypes.c_int, _PTR, _PTR, _PTR, ctypes.c_int) _mpfr_const_pi = _bind("mpfr_const_pi", ctypes.c_int, _PTR, ctypes.c_int) _mpfr_cmp = _bind("mpfr_cmp", ctypes.c_int, _PTR, _PTR) +_mpfr_nan_p = _bind("mpfr_nan_p", ctypes.c_int, _PTR) _mpfr_sgn = _bind("mpfr_sgn", ctypes.c_int, _PTR) _mpfr_get_d = _bind("mpfr_get_d", ctypes.c_double, _PTR, ctypes.c_int) _mpfr_get_str = _bind( @@ -168,6 +184,8 @@ def rational( precision: int = DEFAULT_PRECISION_BITS, rounding: int, ) -> "MPNumber": + if isinstance(value, bool) or not isinstance(value, (int, Fraction)): + raise MPFRError("rational input must be a nonboolean int or exact Fraction") item = value if isinstance(value, Fraction) else Fraction(value) numerator = cls.integer(item.numerator, precision=precision) denominator = cls.integer(item.denominator, precision=precision) @@ -186,6 +204,7 @@ def decimal_value( out = cls(precision) if _mpfr_set_str(out.ptr, value.encode("ascii"), 10, rounding) != 0: raise MPFRError(f"invalid MPFR decimal input: {value!r}") + out._require_ordered() return out @classmethod @@ -195,10 +214,17 @@ def pi(cls, *, precision: int = DEFAULT_PRECISION_BITS, rounding: int) -> "MPNum return out def compare(self, other: "MPNumber") -> int: + self._require_ordered() + other._require_ordered() return int(_mpfr_cmp(self.ptr, other.ptr)) + def _require_ordered(self) -> None: + if _mpfr_nan_p(self.ptr): + raise MPFRError("NaN cannot supply ordered interval evidence") + @property def sign(self) -> int: + self._require_ordered() return int(_mpfr_sgn(self.ptr)) def to_float(self, rounding: int = MPFR_RNDN) -> float: @@ -491,3 +517,4 @@ def atan2_interval(y: MPInterval, x: MPInterval) -> MPInterval: down.append(lower) up.append(upper) return MPInterval(_minimum(down), _maximum(up)) +# ratios: loc_comments=374:65 imports_exports=7:9 calls_definitions=179:48 diff --git a/tests/test_distributions.py b/tests/test_distributions.py new file mode 100644 index 0000000..db5ea1c --- /dev/null +++ b/tests/test_distributions.py @@ -0,0 +1,158 @@ +# ratios: loc_comments=137:10 imports_exports=9:1 calls_definitions=50:2 +# === CHECKS === +# id: check_distribution_replay_inputs +# proves: distributions_retain_exact_replay_inputs +# call: self::test_distribution_replay_inputs_fail_closed +# requires: python3, pytest +# timeout: 10 +# mutates: temporary_path +# cleanup: pytest temporary_path +# === END CHECKS === + +"""Build small archive fixtures; missing evidence must fail independently of Twine.""" + +import base64 +import csv +import hashlib +import io +from pathlib import Path +import tarfile +import zipfile + +import pytest +from tools import verify_distributions as audit + + + + +def _archives(root, sdist, wheel, *, omit="", altered="", extra="", sdist_extra="", wheel_omit="", wheel_altered="", metadata_extra="", wheel_flags="true", record_mode="", sdist_directory="", wheel_directory="", sdist_root="ucns-0", sdist_mode=None, directory_mode=0o755, directory_payload=b""): + core_metadata = "Metadata-Version: 2.4\nName: ucns\nVersion: 0\nSummary: Fixture\nAuthor: Test\nRequires-Python: >=3.10\nDescription-Content-Type: text/markdown\nLicense: fixture\nLicense-File: LICENSE\nDynamic: license-file\n" + with tarfile.open(sdist, "w:gz") as archive: + files = {**audit.expected_files(root), "setup.cfg": audit.GENERATED_SETUP_CFG, + "PKG-INFO": (core_metadata + "\nfixture\n").encode(), + "src/ucns.egg-info/PKG-INFO": (core_metadata + "\nfixture\n").encode(), + "src/ucns.egg-info/top_level.txt": b"ucns\n", + "src/ucns.egg-info/dependency_links.txt": b"\n", + "src/ucns.egg-info/requires.txt": b"", + "src/ucns.egg-info/SOURCES.txt": b""} + if sdist_extra: + files[sdist_extra] = b"unexpected" + files["src/ucns.egg-info/SOURCES.txt"] = ("\n".join(sorted(files.keys() - {"PKG-INFO", "setup.cfg"})) + "\n").encode() + for name, data in files.items(): + if name == omit: + continue + if name == altered: + data += b"drift" + member = tarfile.TarInfo(f"{sdist_root}/{name}") + member.size = len(data) + if name == "pyproject.toml" and sdist_mode is not None: + member.mode = sdist_mode + archive.addfile(member, io.BytesIO(data)) + if sdist_directory: + member = tarfile.TarInfo(f"{sdist_root}/{sdist_directory}") + member.type = tarfile.DIRTYPE + member.mode = directory_mode + member.size = len(directory_payload) + archive.addfile(member, io.BytesIO(directory_payload)) + with zipfile.ZipFile(wheel, "w") as archive: + files = {} + for name, data in audit.expected_files(root).items(): + if name.startswith("src/ucns/"): + files[name.removeprefix("src/")] = data + metadata = { + "METADATA": (core_metadata + metadata_extra + "\nfixture\n").encode(), + "WHEEL": f"Wheel-Version: 1.0\nGenerator: fixture\nRoot-Is-Purelib: {wheel_flags}\nTag: py3-none-any\n".encode(), + "licenses/LICENSE": (root / "LICENSE").read_bytes(), + } + for name, data in metadata.items(): + if name != wheel_omit: + files[f"ucns-0.dist-info/{name}"] = data + (b"drift" if name == wheel_altered else b"") + if extra: + files[extra] = b"unexpected" + record = io.StringIO() + writer = csv.writer(record) + for name, data in files.items(): + digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode() + writer.writerow((name, "sha256=" + digest, len(data))) + writer.writerow(("ucns-0.dist-info/RECORD", "", "")) + if wheel_omit != "RECORD": + files["ucns-0.dist-info/RECORD"] = b"" if record_mode == "empty" else record.getvalue().encode() + if record_mode == "wrong-hash": + files["ucns/__init__.py"] += b"changed after recording" + for name, data in files.items(): + archive.writestr(name, data) + if wheel_directory: + archive.writestr(wheel_directory.rstrip("/") + "/", directory_payload) + + +def test_distribution_replay_inputs_fail_closed(tmp_path: Path) -> None: + root = tmp_path / "repo" + root.mkdir() + for name in (*audit.ROOT_INPUTS, "src/ucns/__init__.py", "generated/receipt.json", "docs/preregistration.md", "tests/witness.txt", "tools/helper.sh", ".github/workflows/ci.yml", "docs/extensionless", ".agents/skills/msdmd/parsers/universal.py"): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("fixture\n") + (root / "pyproject.toml").write_text('[project]\nname="ucns"\nversion="0"\ndescription="Fixture"\nrequires-python=">=3.10"\nauthors=[{name="Test"}]\nreadme="README.md"\n') + outside = tmp_path / "outside" + outside.mkdir() + link = root / "tests/linked-fixture" + link.symlink_to(outside) + with pytest.raises(ValueError, match="unsupported source symlink"): + audit.expected_files(root) + link.unlink() + sdist, wheel = tmp_path / "ucns-0.tar.gz", tmp_path / "ucns-0-py3-none-any.whl" + _archives(root, sdist, wheel) + assert audit.verify_distributions(root, sdist, wheel) == [] + for options, message in ( + ({"wheel_directory": "ucns", "directory_payload": b"unaccounted"}, "nonempty archive directory"), + ({"sdist_directory": "tests", "directory_payload": b"unaccounted"}, "nonempty archive directory"), + ({"wheel_directory": "unexpected"}, "unexpected directory"), + ({"sdist_directory": "unexpected"}, "unexpected directory"), + ({"sdist_mode": 0}, "unusable sdist permissions"), + ({"sdist_mode": 0o400}, "unusable sdist permissions"), + ({"sdist_mode": 0o4644}, "unusable sdist permissions"), + ({"sdist_directory": "extra", "directory_mode": 0o644}, "unusable sdist permissions"), + ({"wheel_directory": "evil-1.dist-info"}, "exactly one .dist-info directory"), + ({"omit": "LICENSE"}, "missing LICENSE"), + ({"altered": "LICENSE"}, "altered LICENSE"), + ({"wheel_omit": "licenses/LICENSE"}, "missing wheel metadata"), + ({"wheel_altered": "licenses/LICENSE"}, "altered wheel license"), + ({"wheel_omit": "METADATA"}, "missing wheel metadata"), + ({"wheel_omit": "WHEEL"}, "missing wheel metadata"), + ({"wheel_omit": "RECORD"}, "missing wheel metadata"), + ({"sdist_extra": "setup.py"}, "unexpected payload setup.py"), + ({"sdist_extra": "setup.cfg"}, "altered generated setup.cfg"), + ({"sdist_extra": "PKG-INFO"}, "sdist PKG-INFO Name differs"), + ({"sdist_extra": "src/ucns.egg-info/PKG-INFO"}, "sdist src/ucns.egg-info/PKG-INFO Name differs"), + ({"omit": "PKG-INFO"}, "missing sdist metadata PKG-INFO"), + ({"omit": "src/ucns.egg-info/PKG-INFO"}, "missing sdist metadata src/ucns.egg-info/PKG-INFO"), + ({"sdist_extra": "src/ucns.egg-info/requires.txt"}, "requires.txt differs"), + ({"altered": "src/ucns.egg-info/SOURCES.txt"}, "SOURCES.txt differs"), + ({"omit": "setup.cfg"}, "missing generated setup.cfg"), + ({"metadata_extra": "Requires-Dist: unexpected>=1\n"}, "Requires-Dist differs"), + ({"wheel_flags": "false"}, "Root-Is-Purelib differs"), + ({"record_mode": "empty"}, "RECORD missing path"), + ({"record_mode": "wrong-hash"}, "RECORD digest or size mismatch"), + ({"sdist_root": "other-99"}, "sdist root identity"), + ({"sdist_directory": "pyproject.toml"}, "duplicate archive member"), + ({"wheel_directory": "ucns/__init__.py"}, "duplicate archive member"), + ({"sdist_directory": "../escaped"}, "unsafe archive name"), + ({"extra": "ucns/__init__.py/inside.py"}, "file/directory archive collision"), + ({"extra": "ucns-0.dist-info/entry_points.txt"}, "unexpected wheel metadata"), + ): + _archives(root, sdist, wheel, **options) + assert any(message in problem for problem in audit.verify_distributions(root, sdist, wheel)), options + for key, name, expected in (("omit", "tests/witness.txt", "missing"), ("altered", "docs/extensionless", "altered"), ("omit", ".github/workflows/ci.yml", "missing"), ("omit", "generated/receipt.json", "missing"), ("altered", "docs/preregistration.md", "altered"), ("extra", "ucns/lexical.py", "unexpected")): + _archives(root, sdist, wheel, **{key: name}) + assert any(expected in problem for problem in audit.verify_distributions(root, sdist, wheel)) + _archives(root, sdist, wheel, extra="../escaped.py") + assert any("unsafe" in problem for problem in audit.verify_distributions(root, sdist, wheel)) + _archives(root, sdist, wheel) + renamed = wheel.with_name("ucns-0-cp310-cp310-manylinux_2_17_x86_64.whl") + wheel.rename(renamed) + assert any("filename identity or tags" in problem for problem in audit.verify_distributions(root, sdist, renamed)) + renamed.rename(wheel) + with zipfile.ZipFile(wheel, "a") as archive, pytest.warns(UserWarning, match="Duplicate"): + archive.writestr("ucns/__init__.py", b"duplicate") + assert any("duplicate" in problem for problem in audit.verify_distributions(root, sdist, wheel)) +# ratios: loc_comments=137:10 imports_exports=9:1 calls_definitions=50:2 diff --git a/tests/test_gonal_boundary_trace.py b/tests/test_gonal_boundary_trace.py index a40c89f..ee1be47 100644 --- a/tests/test_gonal_boundary_trace.py +++ b/tests/test_gonal_boundary_trace.py @@ -1,3 +1,4 @@ +# ratios: loc_comments=101:51 imports_exports=7:6 calls_definitions=55:7 # === CHECKS === # id: check_gonal_boundary_trace_samples_circle_wave_mode_exactly # proves: gonal_boundary_trace_samples_circle_wave_mode_exactly @@ -48,6 +49,8 @@ # cleanup: none # === END CHECKS === +from __future__ import annotations + from dataclasses import replace from fractions import Fraction from pathlib import Path @@ -140,6 +143,20 @@ def test_trace_bridge_fails_closed() -> None: with pytest.raises(GonalBoundaryTraceError, match="time scale must be a positive integer"): replace(valid, time_scale=2.0) # type: ignore[arg-type] + identity = build_modular_orbit_geometry(3, 1) + trace = build_circle_wave_mode_trace(3, 1) + covering = pullback_circle_wave_trace(trace, identity, 1) + for action in (((False, False), (True, True), (2.0, 2.0)), None, [[0, 0], [1, 1], [2, 2]]): + with pytest.raises(GonalBoundaryTraceError): + replace(covering, action=action) + for query in (True, False, 1.0, Fraction(1), "1", []): + with pytest.raises(GonalBoundaryTraceError): + trace.phase_at(query) + with pytest.raises(GonalBoundaryTraceError): + replace(covering, source=None) + with pytest.raises(GonalBoundaryTraceError): + pullback_circle_wave_trace(trace, None, 1) + def test_trace_surface_is_geometry_only() -> None: geometry = build_modular_orbit_geometry(9, 7, range(1, 9)) @@ -163,3 +180,4 @@ def test_trace_is_candidate_scoped_in_canon() -> None: assert "candidate" in section.lower() assert "not ratified" in section.lower() +# ratios: loc_comments=101:51 imports_exports=7:6 calls_definitions=55:7 diff --git a/tests/test_modular_orbit.py b/tests/test_modular_orbit.py index 9f77665..04d67d5 100644 --- a/tests/test_modular_orbit.py +++ b/tests/test_modular_orbit.py @@ -1,3 +1,4 @@ +# ratios: loc_comments=84:43 imports_exports=5:5 calls_definitions=39:5 # === CHECKS === # id: check_modular_orbit_mod9_times_two # proves: modular_orbit_action_decomposes_exact_permutation, modular_orbit_circle_embedding_is_exact @@ -101,6 +102,23 @@ def test_invalid_carriers_fail_closed() -> None: with pytest.raises(ModularOrbitError, match="noncanonical residue"): replace(valid, positions=(0, [])) # type: ignore[arg-type] + identity = build_modular_orbit_geometry(3, 1) + for patch in ( + {"action": ((False, False), (True, True), (2.0, 2.0))}, + {"orbits": ((False,), (True,), (2.0,))}, + {"orbits": [list(orbit) for orbit in identity.orbits]}, + {"periods": (True, 1.0, 1)}, + {"orbits": None}, + {"orbits": ((0,), None, (2,))}, + ): + with pytest.raises(ModularOrbitError): + replace(identity, **patch) + for query in (True, False, 1.0, Fraction(1), "1", []): + with pytest.raises(ModularOrbitError): + identity.target(query) + with pytest.raises(ModularOrbitError): + identity.turn_of(query) + def test_serialized_surface_contains_geometry_only() -> None: payload = build_modular_orbit_geometry(9, 2, range(1, 9)).as_dict() @@ -129,3 +147,4 @@ def test_modular_orbit_is_candidate_scoped_in_canon() -> None: assert "candidate" in section.lower() assert "not ratified" in section.lower() assert "active cycle primitive" not in section.lower() +# ratios: loc_comments=84:43 imports_exports=5:5 calls_definitions=39:5 diff --git a/tests/test_mpfr_input_boundaries.py b/tests/test_mpfr_input_boundaries.py new file mode 100644 index 0000000..2c56cb3 --- /dev/null +++ b/tests/test_mpfr_input_boundaries.py @@ -0,0 +1,57 @@ +# ratios: loc_comments=29:18 imports_exports=4:2 calls_definitions=20:2 +# === CHECKS === +# id: check_mpfr_nan_is_not_ordered_evidence +# proves: mpfr_rejects_nan_ordering +# call: self::test_nan_cannot_be_an_interval_or_an_ordering_witness +# requires: python3, libmpfr +# timeout: 10 +# mutates: none +# cleanup: none +# +# id: check_mpfr_exact_rational_admission +# proves: mpfr_rational_inputs_are_exact +# call: self::test_rational_admission_preserves_exact_input_boundary +# requires: python3, libmpfr +# timeout: 10 +# mutates: none +# cleanup: none +# === END CHECKS === + +"""Run with pytest tests/test_mpfr_input_boundaries.py; requires system libmpfr.""" + +from fractions import Fraction +import math + +import pytest + +from ucns.mpfr_interval import MPFRError, MPInterval, MPNumber, MPFR_RNDD + + +def test_nan_cannot_be_an_interval_or_an_ordering_witness() -> None: + for value in ("nan", "NaN", "@NaN@"): + with pytest.raises(MPFRError): + MPInterval.decimal(value) + uninitialized = MPNumber() # MPFR initializes a number to NaN. + zero = MPNumber.integer(0) + with pytest.raises(MPFRError): + uninitialized.compare(zero) + with pytest.raises(MPFRError): + zero.compare(uninitialized) + with pytest.raises(MPFRError): + _ = uninitialized.sign + with pytest.raises(MPFRError): + MPInterval(uninitialized, zero) + assert MPInterval.rational(0).lo.sign == 0 + + +def test_rational_admission_preserves_exact_input_boundary() -> None: + for invalid in (True, False, 0.1, "1/3"): + with pytest.raises(MPFRError): + MPInterval.rational(invalid) + for value in (0, -3, Fraction(1, 3), Fraction(-7, 11)): + interval = MPInterval.rational(value) + assert Fraction.from_float(interval.lower_float()) <= value + assert Fraction.from_float(interval.upper_float()) >= value + assert math.isfinite(interval.lower_float()) + assert MPNumber.rational(Fraction(1, 2), rounding=MPFR_RNDD).to_float() == 0.5 +# ratios: loc_comments=29:18 imports_exports=4:2 calls_definitions=20:2 diff --git a/tests/test_skill_lib_boundary_runner.py b/tests/test_skill_lib_boundary_runner.py index 1b556a0..201639c 100644 --- a/tests/test_skill_lib_boundary_runner.py +++ b/tests/test_skill_lib_boundary_runner.py @@ -1,3 +1,4 @@ +# ratios: loc_comments=195:410 imports_exports=23:18 calls_definitions=248:20 # === CHECKS === # id: check_boundary_runner_audit_gate # proves: boundary_runner_audits_before_execution @@ -24,7 +25,7 @@ # cleanup: none # # id: check_boundary_runner_status_continuation -# proves: boundary_runner_classifies_and_continues +# proves: boundary_runner_classifies_and_continues, boundary_pytest_observes_actual_outcomes # call: self::test_runner_classifies_all_outcomes_and_continues # requires: python3 # timeout: 20 @@ -43,7 +44,41 @@ # proves: boundary_runner_has_no_activation_effect # call: self::test_passing_receipt_has_no_activation_or_selection_effect # requires: python3 -# timeout: 10 +# timeout: 30 +# mutates: temporary_path +# cleanup: pytest temporary_path +# === END CHECKS === + +# === CHECKS === +# id: check_boundary_runner_skips_are_not_passes +# proves: boundary_runner_classifies_and_continues +# call: self::test_skips_xfails_and_mixed_parameters_are_not_passes +# requires: python3, pytest +# timeout: 30 +# mutates: temporary_path +# cleanup: pytest temporary_path +# +# id: check_boundary_runner_invalid_declaration_continuation +# proves: boundary_runner_classifies_and_continues +# call: self::test_invalid_execution_metadata_does_not_abort_later_checks +# requires: python3, pytest +# timeout: 15 +# mutates: temporary_path +# cleanup: pytest temporary_path +# +# id: check_boundary_runner_source_identity +# proves: boundary_runner_receipt_is_bounded_and_bound +# call: self::test_source_mutation_prevents_acceptance +# requires: python3, pytest +# timeout: 15 +# mutates: temporary_path +# cleanup: pytest temporary_path +# +# id: check_boundary_runner_report_and_discovery_boundaries +# proves: boundary_runner_classifies_and_continues, boundary_runner_receipt_is_bounded_and_bound +# call: self::test_report_errors_suffix_discovery_and_launch_continuation +# requires: python3, pytest +# timeout: 20 # mutates: temporary_path # cleanup: pytest temporary_path # === END CHECKS === @@ -55,14 +90,10 @@ import json from pathlib import Path import sys +from tools import run_skill_lib_boundaries as runner RUNNER_PATH = Path(__file__).parents[1] / "tools" / "run_skill_lib_boundaries.py" -SPEC = importlib.util.spec_from_file_location("run_skill_lib_boundaries", RUNNER_PATH) -assert SPEC is not None and SPEC.loader is not None -runner = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = runner -SPEC.loader.exec_module(runner) def _repo(tmp_path: Path, functions: str, checks: list[dict[str, str]]) -> Path: @@ -70,6 +101,7 @@ def _repo(tmp_path: Path, functions: str, checks: list[dict[str, str]]) -> Path: (root / "src" / "pkg").mkdir(parents=True) (root / "tools").mkdir() (root / "tests").mkdir() + (root / "pyproject.toml").write_text('[tool.pytest.ini_options]\ncollect_imported_tests = false\ntestpaths = ["tests"]\n') contracts: list[str] = [] check_lines: list[str] = [] for index, check in enumerate(checks): @@ -116,6 +148,122 @@ def test_audit_gap_prevents_execution(tmp_path: Path) -> None: assert receipt["status"] == "audit-gap" assert receipt["outcomes"] == [] assert len(receipt["receipt_sha256"]) == 64 + root = _repo(tmp_path / "rebound-function", "def test_probe(): assert False\ndef test_probe(): pass\n", [{"id": "check_probe", "function": "test_probe"}]) + receipt = runner.run_boundaries(root) + assert receipt["status"] == "audit-gap" and not receipt["outcomes"], receipt + root = _repo(tmp_path / "nested", "def test_fails(): assert False\n", [{"id": "check_fails", "function": "test_fails"}]) + nested = root / "tests/sub" + nested.mkdir() + (root / "tests/test_feature.py").rename(nested / "test_feature.py") + (nested / "pyproject.toml").write_text('[tool.pytest.ini_options]\ncollect_imported_tests = false\naddopts = "-p custom_plugin"\n') + marker = tmp_path / "plugin-executed" + (root / "custom_plugin.py").write_text(f"from pathlib import Path\nPath({str(marker)!r}).write_text('executed')\ndef pytest_runtest_setup(item):\n item.obj = lambda: None\n") + receipt = runner.run_boundaries(root) + assert receipt["status"] == "audit-gap" and not receipt["outcomes"], receipt + assert not marker.exists() + # Even below the audit gate, execution must select the audited root config. + outcome = runner._run_check(root, runner._declared_checks(root)[0]) + assert outcome.status == "FAIL", outcome + assert not marker.exists() + hook_body = f"from pathlib import Path\ndef pytest_generate_tests(metafunc):\n Path({str(marker)!r}).write_text('hook ran')\n metafunc.function.__code__ = (lambda: None).__code__\ndef test_fails(): assert False\n" + root = _repo(tmp_path / "module-hook", hook_body, [{"id": "check_fails", "function": "test_fails"}]) + receipt = runner.run_boundaries(root) + assert receipt["status"] == "audit-gap" and not receipt["outcomes"], receipt + assert not marker.exists() + root = _repo(tmp_path / "descriptor", "from descriptor_helper import descriptor\nclass TestInjected:\n injected = descriptor\ndef test_probe(): pass\n", [{"id": "check_probe", "function": "test_probe"}]) + (root / "descriptor_helper.py").write_text(f"from pathlib import Path\nclass Inject:\n def __set_name__(self, owner, name):\n Path({str(marker)!r}).write_text('descriptor ran')\n owner.test_hidden = lambda self: 1 / 0\ndescriptor = Inject()\n") + receipt = runner.run_boundaries(root) + assert receipt["status"] == "audit-gap" and not receipt["outcomes"], receipt + assert not marker.exists() + root = _repo(tmp_path / "fixture-helper", "import pytest\n@pytest.fixture\ndef test_data(): return 1\ndef test_probe(test_data): assert test_data == 1\n", [{"id": "check_probe", "function": "test_probe"}]) + receipt = runner.run_boundaries(root) + assert receipt["status"] == "passed", receipt + callback_body = f"import pytest\nfrom pathlib import Path\ndef alter(value):\n Path({str(marker)!r}).write_text('callback ran')\n test_fails.__code__ = (lambda sample: None).__code__\n return str(value)\n@pytest.fixture(params=[1], ids=alter)\ndef sample(request): return request.param\ndef test_fails(sample): assert False\n" + root = _repo(tmp_path / "decorator-callback", callback_body, [{"id": "check_fails", "function": "test_fails"}]) + receipt = runner.run_boundaries(root) + assert receipt["status"] == "audit-gap" and not receipt["outcomes"], receipt + assert not marker.exists() + root = _repo(tmp_path / "import-optout", "from helper import __test__\ndef test_fails(): assert False\n", [{"id": "check_fails", "function": "test_fails"}]) + (root / "helper.py").write_text(f"from pathlib import Path\nPath({str(marker)!r}).write_text('import ran')\n__test__ = False\n") + (root / "tests/test_other.py").write_text("# === CHECKS ===\n# id: check_other\n# proves: contract_0\n# call: self::test_other\n# timeout: 5\n# mutates: none\n# cleanup: none\n# === END CHECKS ===\ndef test_other(): pass\n") + receipt = runner.run_boundaries(root) + assert receipt["status"] == "audit-gap" and not receipt["outcomes"], receipt + assert not marker.exists() + + + helper_source = "import inspect\ndef passing(): pass\nfor frame in inspect.stack():\n namespace = frame.frame.f_globals\n if 'test_probe' in namespace:\n passing.__name__ = 'test_probe'\n passing.__module__ = namespace['__name__']\n namespace['test_probe'] = passing\n" + root = _repo(tmp_path / "helper-replacement", "def test_probe(): assert False\nimport replacing_helper\n", [{"id": "check_probe", "function": "test_probe"}]) + helper = root / "tests/replacing_helper.py" + helper.write_text(helper_source) + receipt = runner.run_boundaries(root) + assert receipt["status"] == "audit-gap" and not receipt["outcomes"], receipt + # Root helpers are outside the declared source layout and must fail the audit. + helper.rename(root / "replacing_helper.py") + receipt = runner.run_boundaries(root) + assert receipt["status"] == "audit-gap" and not receipt["outcomes"], receipt + assert any("root helper" in gap for gap in receipt["audit_gaps"]) + # Below the audit gate, the runtime still rejects a replaced witness. + outcome = runner._run_check(root, runner._declared_checks(root)[0]) + assert outcome.status == "FAIL" and "witness code differs" in outcome.stdout_excerpt, outcome + hiding = "import inspect\nfor frame in inspect.stack():\n witness = frame.frame.f_globals.get('test_fails')\n if witness is not None:\n witness.__test__ = False\n" + root = _repo(tmp_path / "hidden-witness", "def test_fails(): assert False\nimport hide_witness\ndef test_passes(): pass\n", [{"id": "check_fails", "function": "test_fails"}, {"id": "check_passes", "function": "test_passes"}]) + (root / "hide_witness.py").write_text(hiding) + receipt = runner.run_boundaries(root) + assert receipt["status"] == "audit-gap" and not receipt["outcomes"], receipt + namespace = root / "hide_package" + namespace.mkdir() + (root / "hide_witness.py").rename(namespace / "effects.py") + source = root / "tests/test_feature.py" + source.write_text(source.read_text().replace("import hide_witness", "import hide_package.effects")) + receipt = runner.run_boundaries(root) + assert receipt["status"] == "audit-gap" and not receipt["outcomes"], receipt + + +# === CHECKS === +# id: check_geometry_suite_nonempty_pass +# proves: geometry_suite_requires_nonempty_pass +# call: self::test_geometry_suite_rejects_nonpasses +# requires: python3 +# timeout: 90 +# mutates: filesystem +# cleanup: tempdir_teardown +# === END CHECKS === +def test_geometry_suite_rejects_nonpasses(tmp_path: Path) -> None: + import os + import subprocess + import sys + cases = ( + ("pass", "def test_probe(): pass\n", True), + ("skip", "import pytest\ndef test_probe(): pytest.skip('unobserved')\n", False), + ("marked-skip", "import pytest\n@pytest.mark.skip(reason='unobserved')\ndef test_probe(): pass\n", False), + ("xfail", "import pytest\n@pytest.mark.xfail\ndef test_probe(): assert False\n", False), + ("xpass", "import pytest\n@pytest.mark.xfail(strict=False)\ndef test_probe(): pass\n", False), + ("empty", "# no executable checks\n", False), + ("collection-skip", "import pytest\npytest.skip('unobserved', allow_module_level=True)\n", False), + ("collection-error", "raise RuntimeError('broken collection')\n", False), + ("module-mark", "import pytest\npytestmark = pytest.mark.skip\ndef test_probe(): assert False\n", False), + ("hidden-witness", "def test_fails(): assert False\nimport hide_witness\ndef test_passes(): pass\n", False), + ("removed-witness", "def test_first(request): request.session.items[:] = [request.node]\ndef test_fails(): assert False\n", False), + ("removed-parameter", "import pytest\n@pytest.mark.parametrize('value', [0, 1], ids=['a::b', 'failing'])\ndef test_probe(value, request):\n if value == 0: request.session.items[:] = [request.node]\n assert value == 0\n", False), + ) + script = "import os,sys; from pathlib import Path; sys.path.insert(0,sys.argv[1]); from tools._boundary_pytest import run_suite; root=Path(sys.argv[2]); os.chdir(root); raise SystemExit(run_suite(['tests','-c','pyproject.toml','--noconftest','--strict-config'],root))" + environment = {key: value for key, value in os.environ.items() if key not in {"PYTHONPATH", "PYTHONHOME", "PYTEST_ADDOPTS", "PYTEST_PLUGINS"}} + environment["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1" + for label, body, expected_pass in cases: + checks = [{"id": "check_probe", "function": "test_probe", "timeout": "15"}] if label in {"removed-parameter", "marked-skip"} else [] + root = _repo(tmp_path / label, body, checks) + if label.startswith("collection-"): + (root / "tests/test_other.py").write_text("def test_other(): pass\n") + if label == "hidden-witness": + (root / "hide_witness.py").write_text("import inspect\nfor frame in inspect.stack():\n witness = frame.frame.f_globals.get('test_fails')\n if witness is not None:\n witness.__test__ = False\n") + result = subprocess.run([sys.executable, "-c", script, str(RUNNER_PATH.parents[1]), str(root)], env=environment, capture_output=True, text=True) + assert (result.returncode == 0) is expected_pass, (label, result.stdout, result.stderr) + if label in {"removed-parameter", "marked-skip"}: + receipt = runner.run_boundaries(root) + assert receipt["audit_closed"] and receipt["status"] == "not-passed", json.dumps(receipt, indent=2) + assert receipt["outcomes"][0]["status"] == ("ERROR" if label == "removed-parameter" else "SKIP"), json.dumps(receipt, indent=2) + if label == "removed-parameter": + assert "did not all execute" in receipt["outcomes"][0]["diagnostic"] def test_missing_capability_and_timeout_are_enforced(tmp_path: Path) -> None: @@ -155,22 +303,52 @@ def test_pass(): def test_fail(): assert False def test_error(): - raise RuntimeError("broken harness") + raise RuntimeError("AssertionError mentioned by a broken harness") +def test_subclass(): + class ContractViolation(AssertionError): + pass + raise ContractViolation("broken") """ checks = [ {"id": "check_pass", "function": "test_pass"}, {"id": "check_fail", "function": "test_fail"}, {"id": "check_error", "function": "test_error"}, + {"id": "check_subclass", "function": "test_subclass"}, ] receipt = runner.run_boundaries(_repo(tmp_path, functions, checks)) assert [item["status"] for item in receipt["outcomes"]] == [ - "PASS", "FAIL", "ERROR", + "PASS", "FAIL", "ERROR", "FAIL", ] assert receipt["outcome_counts"] == { - "PASS": 1, "FAIL": 1, "ERROR": 1, "TIMEOUT": 0, + "PASS": 1, "FAIL": 2, "ERROR": 1, "TIMEOUT": 0, "SKIP": 0, } +def test_report_errors_suffix_discovery_and_launch_continuation(tmp_path: Path, monkeypatch) -> None: + report = tmp_path / "report.xml" + assert runner._pytest_outcome(report, 0)[0] == "ERROR" + for text in ("not json", "[]", '{"status": "PASS"}', '{"status": "PASS", "calls": [], "other": [], "origins": {}}'): + report.write_text(text) + assert runner._pytest_outcome(report, 0)[0] == "ERROR" + root = _repo(tmp_path, "def test_first():\n pass\ndef test_later():\n pass\n", [ + {"id": "check_first", "function": "test_first"}, + {"id": "check_later", "function": "test_later"}, + ]) + (root / "tests/test_feature.py").rename(root / "tests/feature_test.py") + original = runner._run_check + + def launch(repository, check): + if check.id == "check_first": + raise OSError("fixture process launch failed") + return original(repository, check) + + monkeypatch.setattr(runner, "_run_check", launch) + receipt = runner.run_boundaries(root) + assert [outcome["status"] for outcome in receipt["outcomes"]] == ["ERROR", "PASS"] + assert receipt["outcomes"][0]["diagnostic"].startswith("OSError:") + assert receipt["status"] == "not-passed" + + def test_receipt_binds_declarations_outputs_and_identity(tmp_path: Path) -> None: root = _repo(tmp_path, "def test_output():\n print('evidence')\n", [{ "id": "check_output", "function": "test_output", @@ -188,6 +366,37 @@ def test_receipt_binds_declarations_outputs_and_identity(tmp_path: Path) -> None assert identity == sha256(encoded).hexdigest() + root = _repo(tmp_path / "text-input", "from pathlib import Path\ndef test_probe():\n assert Path(__file__).with_name('witness.txt').read_text() in {'one', 'two'}\n", [{"id": "check_probe", "function": "test_probe"}]) + witness = root / "tests/witness.txt" + snapshots = [] + for text in ("one", "two"): + witness.write_text(text) + observed = runner.run_boundaries(root) + assert observed["status"] == "passed" and observed["source_unchanged"], observed + assert observed["source_files_sha256"]["tests/witness.txt"] == sha256(text.encode()).hexdigest() + snapshots.append(observed["source_before_sha256"]) + assert snapshots[0] != snapshots[1] + for directory in runner.SOURCE_DIRECTORIES: + path = root / directory / "fixture-without-suffix" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"input") + inventory, _ = runner._source_snapshot(root) + assert inventory[path.relative_to(root).as_posix()] == sha256(b"input").hexdigest() + + + import pytest + outside = tmp_path / "outside-inputs" + outside.mkdir() + (outside / "witness").write_text("one") + for index, name in enumerate(("tests/fixtures", "tests/witness-link", "README.md", ".agents")): + linked = _repo(tmp_path / f"symlink-{index}", "def test_probe(): pass\n", [{"id": "check_probe", "function": "test_probe"}]) + path = linked / name + path.symlink_to(outside / "witness" if name == "tests/witness-link" or name == "README.md" else outside) + invalid = runner.run_boundaries(linked) + assert invalid["status"] == "audit-gap" and not invalid["outcomes"] + assert any("unsupported source symlink" in gap for gap in invalid["audit_gaps"]) + + def test_passing_receipt_has_no_activation_or_selection_effect(tmp_path: Path) -> None: receipt = runner.run_boundaries(_repo( tmp_path, "def test_pass():\n pass\n", @@ -197,3 +406,251 @@ def test_passing_receipt_has_no_activation_or_selection_effect(tmp_path: Path) - assert receipt["selection_effect"] == "none" assert receipt["edcm_activation"] == "inactive" assert receipt["canon_status"] == "none" + parametrized = _repo(tmp_path / "parameter-ids", "import pytest\n@pytest.mark.parametrize('value', [1, 2], ids=['a::b', 'nested[x]::y'])\ndef test_probe(value): assert value > 0\n", [{"id": "check_probe", "function": "test_probe"}]) + observed = runner.run_boundaries(parametrized) + assert observed["status"] == "passed", observed + + +def test_skips_xfails_and_mixed_parameters_are_not_passes(tmp_path: Path) -> None: + bodies = ( + "import pytest\ndef test_probe():\n pytest.skip('not observed')\n", + "import pytest\n@pytest.mark.xfail(reason='broken')\ndef test_probe():\n assert False\n", + "import pytest\n@pytest.mark.parametrize('x', [1, 2])\ndef test_probe(x):\n if x == 2: pytest.skip('partial')\n", + ) + for index, body in enumerate(bodies): + root = _repo(tmp_path / str(index), body, [{"id": "check_probe", "function": "test_probe"}]) + receipt = runner.run_boundaries(root) + assert receipt["status"] == "not-passed" + assert receipt["outcomes"][0]["status"] == "SKIP" + for index, decorator in enumerate(("@pytest.mark.xfail(strict=False)", "@pytest.mark.xfail(strict=True)")): + body = "import pytest\n" + decorator + "\ndef test_probe():\n pass\n" + root = _repo(tmp_path / f"xpass{index}", body, [{"id": "check_probe", "function": "test_probe"}]) + receipt = runner.run_boundaries(root) + assert receipt["status"] == "not-passed" + assert receipt["outcomes"][0]["status"] == "FAIL" + assert runner.run_boundaries(tmp_path / "absent")["status"] == "audit-gap" + + +def test_invalid_execution_metadata_does_not_abort_later_checks(tmp_path: Path) -> None: + root = _repo(tmp_path, "def test_bad():\n pass\ndef test_good():\n pass\n", [ + {"id": "check_bad", "function": "test_bad", "timeout": "not-an-integer"}, + {"id": "check_good", "function": "test_good"}, + ]) + receipt = runner.run_boundaries(root) + assert [outcome["status"] for outcome in receipt["outcomes"]] == ["ERROR", "PASS"] + assert receipt["status"] == "not-passed" + + +def test_source_mutation_prevents_acceptance(tmp_path: Path) -> None: + root = _repo(tmp_path, "from pathlib import Path\ndef test_probe():\n p=Path('src/pkg/feature.py')\n p.write_text(p.read_text()+'\\n# mutation\\n')\n", [ + {"id": "check_probe", "function": "test_probe"}, + ]) + receipt = runner.run_boundaries(root) + assert receipt["outcomes"][0]["status"] == "PASS" + assert receipt["status"] == "not-passed" + assert receipt["source_before_sha256"] != receipt["source_after_sha256"] + assert receipt["source_unchanged"] is False + root = _repo(tmp_path / "restored", "from pathlib import Path\ndef test_probe():\n p=Path('src/pkg/feature.py')\n original=p.read_bytes()\n p.write_bytes(original+b'\\n# transient\\n')\n p.write_bytes(original)\n", [ + {"id": "check_probe", "function": "test_probe"}, + ]) + receipt = runner.run_boundaries(root) + assert receipt["source_before_sha256"] == receipt["source_after_sha256"] + assert receipt["source_unchanged"] is False + assert receipt["status"] == "not-passed" + assert "src/pkg/feature.py" in receipt["outcomes"][0]["source_events"] + import pytest + import subprocess + with pytest.raises(ValueError, match="outside the bound source tree"): + runner.write_receipt(receipt, root / "generated/receipt.json") + assert not (root / "generated/receipt.json").exists() + result = subprocess.run([sys.executable, str(RUNNER_PATH), str(root), "--receipt", str(root / "generated/receipt.json")], capture_output=True, text=True) + assert result.returncode == 2 + assert "outside the bound source tree" in result.stderr + external = tmp_path / "outside-receipt.json" + source = root / "src/pkg/feature.py" + original = source.read_bytes() + external.hardlink_to(source) + runner.write_receipt(receipt, external) + assert source.read_bytes() == original + assert json.loads(external.read_text())["receipt_sha256"] == receipt["receipt_sha256"] + internal_link = root / "receipt-link.json" + internal_link.symlink_to(external) + external_before = external.read_bytes() + with pytest.raises(ValueError, match="outside the bound source tree"): + runner.write_receipt(receipt, internal_link) + result = subprocess.run([sys.executable, str(RUNNER_PATH), str(root), "--receipt", str(internal_link)], capture_output=True, text=True) + assert result.returncode == 2 + assert "outside the bound source tree" in result.stderr + assert internal_link.is_symlink() and external.read_bytes() == external_before + root_alias = tmp_path / "root-alias" + root_alias.symlink_to(root, target_is_directory=True) + with pytest.raises(ValueError, match="outside the bound source tree"): + runner.write_receipt(receipt, root_alias / internal_link.name) + assert internal_link.is_symlink() and external.read_bytes() == external_before + alias = tmp_path / "source-alias.py" + body = f"from pathlib import Path\ndef test_probe():\n p=Path({str(alias)!r})\n original=p.read_bytes()\n p.write_bytes(original+b'# transient\\n')\n p.write_bytes(original)\n" + root = _repo(tmp_path / "hardlinked", body, [{"id": "check_probe", "function": "test_probe"}]) + alias.hardlink_to(root / "src/pkg/feature.py") + receipt = runner.run_boundaries(root) + assert receipt["outcomes"][0]["status"] == "PASS" + assert receipt["source_before_sha256"] == receipt["source_after_sha256"] + assert receipt["status"] == "not-passed" + assert "src/pkg/feature.py" in receipt["outcomes"][0]["source_events"] +# === CHECKS === +# id: check_boundary_runner_import_origin +# proves: boundary_runner_receipt_is_bounded_and_bound, boundary_pytest_observes_actual_outcomes, boundary_descendants_import_bound_source +# call: self::test_check_imports_bound_source_despite_ambient_pythonpath +# requires: python3, pytest +# timeout: 30 +# mutates: temporary_path +# cleanup: pytest temporary_path +# === END CHECKS === + + + root = _repo(tmp_path / "new-symlink", "from pathlib import Path\ndef test_mutate():\n Path(__file__).with_name('new-link').symlink_to('test_feature.py')\ndef test_later(): pass\n", [{"id": "check_mutate", "function": "test_mutate"}, {"id": "check_later", "function": "test_later"}]) + receipt = runner.run_boundaries(root) + assert receipt["status"] == "not-passed" and not receipt["source_unchanged"] + assert [outcome["check_id"] for outcome in receipt["outcomes"]] == ["check_mutate", "check_later"] + assert all(outcome["status"] == "ERROR" for outcome in receipt["outcomes"]) + assert receipt["snapshot_errors"] and receipt["source_after_sha256"] == "" + assert receipt["outcomes"][0]["source_events"] + + +def test_check_imports_bound_source_despite_ambient_pythonpath(tmp_path: Path, monkeypatch) -> None: + body = "import pkg.feature\nimport subprocess, sys\ndef test_probe():\n assert pkg.feature.VALUE == 'bound'\n child = subprocess.check_output([sys.executable, '-c', 'import pkg.feature; print(pkg.feature.VALUE)'], text=True)\n assert child.strip() == 'bound'\n" + root = _repo(tmp_path, body, [{"id": "check_probe", "function": "test_probe", "timeout": "15"}]) + source = root / "src/pkg/feature.py" + source.write_text(source.read_text() + "VALUE = 'bound'\n") + # A regular package avoids unrelated namespace packages in the host. + (root / "src/pkg/__init__.py").write_text(source.read_text().replace("fixture_module", "fixture_init").replace("contract_0", "init_contract")) + (root / "tests/test_feature.py").write_text((root / "tests/test_feature.py").read_text().replace("# proves: contract_0", "# proves: contract_0, init_contract")) + alternate = tmp_path / "alternate" + (alternate / "pkg").mkdir(parents=True) + (alternate / "pkg/__init__.py").write_text("") + (alternate / "pkg/feature.py").write_text("VALUE = 'wrong'\n") + monkeypatch.setenv("PYTHONPATH", str(alternate)) + test_source = root / "tests/test_feature.py" + test_source.write_text(test_source.read_text().replace("text=True)", f"text=True, cwd={str(alternate)!r})")) + receipt = runner.run_boundaries(root) + assert receipt["status"] == "passed", json.dumps(receipt, indent=2) + assert receipt["outcomes"][0]["imported_sources"]["pkg.feature"] == [str(source)] + +# === CHECKS === +# id: check_boundary_background_descendants +# proves: boundary_runner_receipt_is_bounded_and_bound, boundary_pytest_observes_actual_outcomes, boundary_supervisor_ends_descendants +# call: self::test_background_descendants_block_acceptance +# requires: python3, pytest +# timeout: 30 +# mutates: temporary_path +# cleanup: pytest temporary_path +# === END CHECKS === + + + # A timestamp-valid cache must not replace inventoried source bytes. + import os + import py_compile + import subprocess + poisoned = _repo(tmp_path / "poisoned-cache", "def test_probe():\n from pkg.feature import VALUE\n assert VALUE == 1\n import subprocess, sys\n child = subprocess.run([sys.executable, '-c', 'from pkg.feature import VALUE; print(VALUE)'], check=True, capture_output=True, text=True)\n assert child.stdout.strip() == '1'\n", [{"id": "check_probe", "function": "test_probe", "timeout": "15"}]) + module = poisoned / "src/pkg/feature.py" + declarations = module.read_text() + module.write_text(declarations + "\nVALUE = 2\n") + stamp = module.stat() + cache = module.parent / "__pycache__" / f"{module.stem}.{sys.implementation.cache_tag}.pyc" + py_compile.compile(str(module), cfile=str(cache), doraise=True) + module.write_text(declarations + "\nVALUE = 1\n") + os.utime(module, ns=(stamp.st_atime_ns, stamp.st_mtime_ns)) + ordinary = dict(os.environ, PYTHONPATH=str(poisoned / "src"), PYTHONDONTWRITEBYTECODE="1") + ordinary.pop("PYTHONPYCACHEPREFIX", None) + old = subprocess.run([sys.executable, "-c", "from pkg.feature import VALUE; print(VALUE)"], env=ordinary, capture_output=True, text=True, check=True) + assert old.stdout.strip() == "2", "fixture must contain an executable stale cache" + observed = runner.run_boundaries(poisoned) + assert observed["status"] == "passed" and observed["source_unchanged"], json.dumps(observed, indent=2) + assert not any("__pycache__" in name for name in observed["source_files_sha256"]) + + +def test_background_descendants_block_acceptance(tmp_path: Path) -> None: + import os + import pytest + from tools._boundary_supervisor import _owned_children + proc = tmp_path / "proc" + proc.mkdir() + for pid, parent in ((101, os.getpid()), (102, 1)): + process = proc / str(pid) + process.mkdir() + (process / "stat").write_text(f"{pid} (name with ) spaces) S {parent} 0 0 0\n") + (proc / "103").mkdir() # Exited between directory enumeration and stat read. + assert _owned_children(proc) == [101] + task = proc / f"self/task/{os.getpid()}" + task.mkdir(parents=True) + (task / "children").write_text("101 104\n") + assert _owned_children(proc) == [101, 104] + child = "import time; from pathlib import Path; time.sleep(30); Path('src/pkg/feature.py').write_text('late mutation')" + body = f"import subprocess, sys\nfrom pathlib import Path\ndef test_probe():\n p=subprocess.Popen([sys.executable, '-c', {child!r}], start_new_session=True)\n Path('child.pid').write_text(str(p.pid))\n" + root = _repo(tmp_path, body, [{"id": "check_probe", "function": "test_probe"}]) + before = (root / "src/pkg/feature.py").read_bytes() + receipt = runner.run_boundaries(root) + assert receipt["status"] == "not-passed", receipt + assert receipt["outcomes"][0]["status"] == "ERROR" + assert receipt["outcomes"][0]["descendants_reaped"] >= 1 + pid = int((root / "child.pid").read_text()) + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) + assert (root / "src/pkg/feature.py").read_bytes() == before + body += " import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(30)\n" + timeout_root = _repo(tmp_path / "timeout-child", body, [{"id": "check_probe", "function": "test_probe", "timeout": "5"}]) + receipt = runner.run_boundaries(timeout_root) + assert receipt["outcomes"][0]["status"] == "TIMEOUT" + pid = int((timeout_root / "child.pid").read_text()) + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) +# === CHECKS === +# id: check_node24_capability_runs_typescript_witness +# proves: boundary_runner_consumes_capabilities_and_timeouts, boundary_runner_receipt_is_bounded_and_bound, boundary_supervisor_ends_descendants +# call: self::test_node24_capability_runs_typescript_witness +# requires: python3, node24 +# timeout: 20 +# mutates: filesystem +# cleanup: tempdir_teardown +# === END CHECKS === + + +def test_node24_capability_runs_typescript_witness(tmp_path: Path) -> None: + import os + import pytest + from unittest.mock import patch + from subprocess import CompletedProcess + for version, expected in (("v24.15.0\n", True), ("v22.23.2\n", False), ("not-a-version", False)): + with patch.object(runner.shutil, "which", return_value="/fake/node"): + with patch.object(runner.subprocess, "run", return_value=CompletedProcess([], 0, version, "")): + assert runner._capability_available("node24") is expected + with patch.object(runner.shutil, "which", return_value=None): + assert not runner._capability_available("node24") + receipt = runner.run_boundaries(RUNNER_PATH.resolve().parents[1], selected_ids=("check_vendored_typescript_field_preservation",)) + assert receipt["status"] == "passed", receipt + assert len(receipt["outcomes"]) == 1 and receipt["outcomes"][0]["status"] == "PASS", receipt + root = _repo(tmp_path, "def test_probe(): pass\n", [{"id": "check_probe", "function": "test_probe", "requires": "node24"}]) + source = root / "src/pkg/feature.py" + node = tmp_path / "node" + node.write_text(f"#!{sys.executable}\nfrom pathlib import Path\np=Path({str(source)!r})\noriginal=p.read_bytes()\np.write_bytes(original+b'# transient\\n')\np.write_bytes(original)\nprint('v24.15.0')\n") + node.chmod(0o755) + with patch.dict(os.environ, {"PATH": str(tmp_path) + os.pathsep + os.environ["PATH"]}): + receipt = runner.run_boundaries(root) + assert receipt["outcomes"][0]["status"] == "PASS", receipt + assert receipt["source_before_sha256"] == receipt["source_after_sha256"] + assert receipt["status"] == "not-passed" and not receipt["source_unchanged"] + assert "src/pkg/feature.py" in receipt["outcomes"][0]["source_events"] + pid_path = tmp_path / "probe-child.pid" + node.write_text(f"#!{sys.executable}\nimport subprocess, sys\nfrom pathlib import Path\np=subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)'], start_new_session=True)\nPath({str(pid_path)!r}).write_text(str(p.pid))\nprint('v24.15.0')\n") + with patch.dict(os.environ, {"PATH": str(tmp_path) + os.pathsep + os.environ["PATH"]}): + receipt = runner.run_boundaries(root) + assert receipt["status"] == "not-passed" and receipt["outcomes"][0]["status"] == "ERROR", receipt + assert receipt["outcomes"][0]["missing_capabilities"] == ("node24",) + with pytest.raises(ProcessLookupError): + os.kill(int(pid_path.read_text()), 0) + node.write_text(f"#!{sys.executable}\nimport os, time\nfrom pathlib import Path\nPath({str(pid_path)!r}).write_text(str(os.getpid()))\nprint('v24.15.0', flush=True)\ntime.sleep(30)\n") + with patch.dict(os.environ, {"PATH": str(tmp_path) + os.pathsep + os.environ["PATH"]}): + receipt = runner.run_boundaries(root) + assert receipt["outcomes"][0]["status"] == "ERROR", receipt + with pytest.raises(ProcessLookupError): + os.kill(int(pid_path.read_text()), 0) +# ratios: loc_comments=195:410 imports_exports=23:18 calls_definitions=248:20 diff --git a/tests/test_skill_lib_contracts.py b/tests/test_skill_lib_contracts.py index 56bef47..1aa9f2e 100644 --- a/tests/test_skill_lib_contracts.py +++ b/tests/test_skill_lib_contracts.py @@ -1,3 +1,4 @@ +# ratios: loc_comments=306:60 imports_exports=9:7 calls_definitions=162:7 # === CHECKS === # id: check_contract_audit_no_exec # proves: contract_audit_is_no_exec @@ -24,6 +25,24 @@ # cleanup: none # === END CHECKS === +# === CHECKS === +# id: check_contract_audit_empty_syntax_and_class_gaps +# proves: contract_audit_reports_graph_gaps +# call: self::test_empty_syntax_and_class_coverage_are_not_closed +# requires: python3 +# timeout: 10 +# mutates: filesystem +# cleanup: tempdir_teardown +# +# id: check_contract_audit_canonical_parser +# proves: contract_audit_reports_graph_gaps, contract_audit_is_no_exec +# call: self::test_nested_fences_cannot_hide_an_obligation +# requires: python3 +# timeout: 10 +# mutates: filesystem +# cleanup: tempdir_teardown +# === END CHECKS === + from pathlib import Path from tempfile import TemporaryDirectory @@ -86,3 +105,285 @@ def test_contract_audit_detects_gaps() -> None: def test_repository_contract_graph() -> None: ok, problems = audit_repository(ROOT) assert ok, "\n".join(problems) + + +def test_empty_syntax_and_class_coverage_are_not_closed(tmp_path: Path) -> None: + from test_skill_lib_boundary_runner import _repo + + ok, problems = audit_repository(tmp_path) + assert not ok and any("empty" in item for item in problems) + root = _repo(tmp_path, "def test_probe():\n pass\n", [{"id": "check_probe", "function": "test_probe"}]) + source = root / "src/pkg/feature.py" + original = source.read_text() + source.write_text(original + "\ndef syntax broken\n") + ok, problems = audit_repository(root) + assert not ok and any("syntax" in item.lower() for item in problems) + source.write_text(original) + (root / "tests/test_class.py").write_text("class TestUnregistered:\n def test_untracked(self):\n assert False\n") + ok, problems = audit_repository(root) + assert not ok and any("TestUnregistered::test_untracked" in item for item in problems) + branch = root / "tests/test_class.py" + for code in ( + "if True:\n def test_hidden(): assert False\n", + "if False:\n pass\nelse:\n def test_hidden(): assert False\n", + "if UNKNOWN:\n test_hidden = helper\n", + "for item in [1]:\n def test_hidden(): assert False\n", + "while UNKNOWN:\n def test_hidden(): assert False\n", + "try:\n pass\nfinally:\n def test_hidden(): assert False\n", + "with manager():\n def test_hidden(): assert False\n", + "match value:\n case 1:\n def test_hidden(): assert False\n", + "class TestConditional:\n if True:\n def test_hidden(self): assert False\n", + "if True:\n class TestHidden:\n def test_hidden(self): assert False\n", + "__test__ = False\nif True:\n __test__ = True\n def test_hidden(): assert False\n", + "class TestConditional:\n __test__ = False\n if True:\n __test__ = True\n def test_hidden(self): assert False\n", + "def helper(): assert False\nfor test_hidden in [helper]: pass\n", + "def helper(): assert False\nfor _, *test_hidden in [(1, helper)]: pass\n", + "with manager() as test_hidden: pass\n", + "if (test_hidden := helper): pass\n", + "match helper:\n case test_hidden: pass\n", + "match mapping:\n case {'x': value, **TestHidden}: pass\n", + "class TestConditional:\n for test_hidden in [helper]: pass\n", + "def helper(): assert False\n(test_hidden := helper)\n", + "def helper(): assert False\nvalues = [(test_hidden := helper) for item in [1]]\n", + "def helper(): assert False\nif True:\n (test_hidden := helper)\n", + ): + branch.write_text(code) + ok, problems = audit_repository(root) + assert not ok and any("conditional" in item for item in problems), (code, problems) + for code in ( + "if False:\n def test_inactive(): assert False\n", + "def helper():\n if True:\n def test_local(): assert False\n", + "__test__ = False\nif True:\n def test_disabled(): assert False\n", + ): + branch.write_text(code) + ok, problems = audit_repository(root) + assert ok, (code, problems) + branch.write_text("def testhidden(): assert False\n") + ok, problems = audit_repository(root) + assert not ok and any("testhidden" in item for item in problems), problems + branch.unlink() + config = root / "pyproject.toml" + for settings in ( + 'python_files = ["spec_*.py"]', + 'python_classes = ["Spec"]', + 'python_functions = ["spec_"]', + 'testpaths = ["integration"]', + 'addopts = "-o python_files=spec_*.py"', + ): + config.write_text('[tool.pytest.ini_options]\ncollect_imported_tests = false\n' + settings + '\n') + ok, problems = audit_repository(root) + assert not ok and any("pytest" in item for item in problems), (settings, problems) + config.write_text('[tool.pytest.ini_options]\ncollect_imported_tests = false\npython_files = ["test_*.py", "*_test.py"]\npython_classes = ["Test"]\npython_functions = ["test"]\ntestpaths = ["tests"]\naddopts = "-q"\n') + ok, problems = audit_repository(root) + assert ok, problems + for name in ("pytest.ini", ".pytest.ini", "pytest.toml", ".pytest.toml", "tox.ini", "setup.cfg"): + alternate = root / name + alternate.write_text("[tool:pytest]\npython_files=spec_*.py\n" if name == "setup.cfg" else "[pytest]\npython_files=spec_*.py\n" if name == "tox.ini" else "") + ok, problems = audit_repository(root) + assert not ok and any("collection configuration" in item for item in problems), (name, problems) + alternate.unlink() + (root / "setup.cfg").write_text("[egg_info]\ntag_build =\ntag_date = 0\n") + ok, problems = audit_repository(root) + assert ok, problems + for name in ("conftest.py", "tests/conftest.py"): + plugin = root / name + plugin.write_text("def pytest_pycollect_makeitem(collector, name, obj):\n return []\n") + ok, problems = audit_repository(root) + assert not ok and any("conftest collection/plugin" in item for item in problems), problems + plugin.unlink() + plugin = root / "tests/test_plugins.py" + plugin.write_text("pytest_plugins = ['custom_collector']\n") + ok, problems = audit_repository(root) + assert not ok and any("pytest plugin collection" in item for item in problems), problems + plugin.unlink() + config.write_text('[tool.pytest.ini_options]\ncollect_imported_tests = false\naddopts = "-q"\n') + ok, problems = audit_repository(root) + assert not ok and any("explicit testpaths" in item for item in problems), problems + config.unlink() + ok, problems = audit_repository(root) + assert not ok and any("explicit testpaths" in item for item in problems), problems + config.write_text('[tool.pytest.ini_options]\ncollect_imported_tests = false\ntestpaths = ["tests"]\n') + nested = root / "tests/sub/pyproject.toml" + nested.parent.mkdir() + nested.write_text('[tool.pytest.ini_options]\ncollect_imported_tests = false\naddopts = "-p custom_plugin"\n') + ok, problems = audit_repository(root) + assert not ok and any("nested pytest configuration" in item for item in problems), problems + nested.unlink() + dynamic = root / "tests/test_dynamic.py" + for code in ( + 'def helper(): pass\nglobals()["test_hidden"] = helper\n', + 'def helper(): pass\nglobals().update(test_hidden=helper)\n', + 'def helper(): pass\nhelper.__test__ = True\n', + 'def install(): globals()["test_hidden"] = helper\nvalue = install()\n', + 'def install(function): return function\n@install\ndef helper(): pass\n', + 'def __getattr__(name): return ["custom_plugin"]\n', + ): + dynamic.write_text(code) + ok, problems = audit_repository(root) + assert not ok and any("namespace" in item or "collection-time" in item for item in problems), (code, problems) + dynamic.unlink() + for code in ( + 'from descriptor_helper import descriptor\nclass TestInjected:\n injected = descriptor\n', + 'class TestInjected:\n from descriptor_helper import descriptor\n', + 'from descriptor_helper import descriptor\nclass Helper:\n injected: object = descriptor\nclass TestInherited(Helper): pass\n', + 'from descriptor_helper import descriptor\nclass TestInjected:\n for injected in [descriptor]: pass\n', + 'def pytest_generate_tests(metafunc): pass\n', + 'def helper(metafunc): pass\npytest_generate_tests = helper\n', + 'from hook_helper import pytest_generate_tests\n', + 'def setup_function(function): pass\n', + 'class TestHooks:\n def pytest_generate_tests(self, metafunc): pass\n', + 'from descriptor_helper import Base\nclass TestInjected:\n class Nested(Base): pass\n', + 'from descriptor_helper import Base\nclass Helper(Base):\n __test__ = False\n', + 'from descriptor_helper import Base as object\nclass TestInjected(object): pass\n', + 'from descriptor_helper import Base\nclass TestInjected(Base): pass\nclass Base: pass\n', + 'from descriptor_helper import Base\nclass TestInjected(Base): pass\nclass TestInjected: pass\n', + ): + dynamic.write_text(code) + ok, problems = audit_repository(root) + assert not ok and any("class namespace" in item or "implicit pytest hook" in item or "class base" in item for item in problems), (code, problems) + dynamic.unlink() + for code in ( + 'import pytest\npytestmark = pytest.mark.skip\n', + 'import pytest\npytestmark = [pytest.mark.xfail]\n', + 'from marker_helper import pytestmark\n', + 'import pytest\ndef alter(value): return value\n@pytest.fixture(params=[1], ids=alter)\ndef helper(request): return request.param\n', + 'import pytest\n@pytest.mark.parametrize("value", [1], ids=lambda value: str(value))\ndef helper(value): pass\n', + 'import pytest\n@pytest.mark.skipif("execute_a_condition()")\ndef helper(): pass\n', + 'from descriptor_helper import descriptor\nvalue = descriptor.attribute\n', + 'from descriptor_helper import descriptor\nassert descriptor.attribute\n', + ): + dynamic.write_text(code) + ok, problems = audit_repository(root) + assert not ok and any("collection-time" in item or "implicit pytest hook" in item for item in problems), (code, problems) + dynamic.unlink() + baseline = config.read_text() + for replacement in ('collect_imported_tests = true\n', 'collect_imported_tests = 0\n', ''): + config.write_text(baseline.replace('collect_imported_tests = false\n', replacement)) + ok, problems = audit_repository(root) + assert not ok and any("explicit collect_imported_tests" in item for item in problems), problems + for option in ('pythonpath = ["outside"]', 'future_collection_option = true'): + config.write_text(baseline + option + '\n') + ok, problems = audit_repository(root) + assert not ok and any("unsupported pytest collection settings" in item for item in problems), problems + config.write_text(baseline) + for index, decorator in enumerate(("@pytest.fixture", "@pytest.fixture()", "@pytest.fixture(name='other')")): + fixture_root = _repo(tmp_path / f"fixture-{index}", "import pytest\n" + decorator + "\ndef test_probe(): assert False\n", [{"id": "check_probe", "function": "test_probe"}]) + ok, problems = audit_repository(fixture_root) + assert not ok and any("check_probe call does not resolve" in item for item in problems), problems + for index, declaration in enumerate(("from helper import __test__", "from helper import disabled as __test__", "import helper as __test__")): + import_root = _repo(tmp_path / f"import-optout-{index}", declaration + "\ndef test_probe(): assert False\n", [{"id": "check_probe", "function": "test_probe"}]) + ok, problems = audit_repository(import_root) + assert not ok and any("dynamic test-module opt-out" in item for item in problems), problems + for index, declaration in enumerate(("(__test__,) = (False,)", "[__test__] = [False]", "value, (__test__,) = (1, (False,))", "*__test__, value = (1,)", "__test__ = (__test__,) = (False,)")): + unpack_root = _repo(tmp_path / f"unpacked-optout-{index}", declaration + "\ndef test_probe(): assert False\n", [{"id": "check_probe", "function": "test_probe"}]) + ok, problems = audit_repository(unpack_root) + assert not ok and any("destructured collection opt-out" in item for item in problems), problems + for index, prefix in enumerate(("def test_probe(): assert False\n", "test_probe = 1\n", "from helper import test_probe\n")): + rebound_root = _repo(tmp_path / f"rebound-function-{index}", prefix + "def test_probe(): pass\n", [{"id": "check_probe", "function": "test_probe"}]) + ok, problems = audit_repository(rebound_root) + assert not ok and any("check_probe call does not resolve" in item for item in problems), problems + ok, problems = audit_repository(root / "absent") + assert not ok and any("empty" in item for item in problems), problems + + +def test_nested_fences_cannot_hide_an_obligation(tmp_path: Path) -> None: + from test_skill_lib_boundary_runner import _repo + + root = _repo(tmp_path, "def test_probe():\n pass\n", [{"id": "check_probe", "function": "test_probe"}]) + source = root / "src/pkg/feature.py" + original = source.read_text() + source.write_text(original.replace("# === CONTRACTS ===", "# === CONTRACTS ===\n# id: hidden\n# given: x\n# then: y\n# === CONTRACTS ===")) + ok, problems = audit_repository(root) + assert not ok and any("nested" in item for item in problems) + + for malformed in ("# === CONTRACTS ==", "# == CONTRACTS ==="): + source.write_text(original + malformed + "\n# id: dropped\n# given: x\n# then: y\n# === END CONTRACTS ===\n") + ok, problems = audit_repository(root) + assert not ok and any("malformed declaration fence" in item for item in problems) + for malformed in ("# id:", "# id: two words", "# id missing_colon"): + source.write_text(original.replace("# === END CONTRACTS ===", malformed + "\n# given: x\n# then: y\n# === END CONTRACTS ===")) + ok, problems = audit_repository(root) + assert not ok and any("malformed id" in item for item in problems) + source.write_text(original) + (root / "tests/test_helpers.py").write_text("class Helper:\n def test_helper(self): pass\nclass TestDisabled:\n __test__ = False\n def test_disabled(self): pass\nclass TestTypedDisabled:\n __test__: bool = False\n def test_disabled(self): pass\ndef helper():\n class TestNested:\n def test_nested(self): pass\n") + ok, problems = audit_repository(root) + assert ok, problems + (root / "tests/test_inherited.py").write_text("class Base:\n def test_inherited(self): assert False\nclass TestChild(Base):\n pass\n") + ok, problems = audit_repository(root) + assert not ok and any("inherited class check" in item for item in problems) + inherited = root / "tests/test_inherited.py" + for declaration in ("__test__ = False", "__test__: bool = False"): + inherited.write_text(f"class Base:\n {declaration}\n def test_hidden(self): assert False\nclass TestChild(Base):\n pass\n") + ok, problems = audit_repository(root) + assert ok, problems + inherited.write_text("class A:\n __test__ = False\n def test_hidden(self): pass\nclass B(A): pass\nclass C(A):\n __test__ = True\nclass TestChild(B, C): pass\n") + ok, problems = audit_repository(root) + assert not ok and any("TestChild::test_hidden" in item for item in problems) + for code, label in ( + ("def hidden(): assert False\ntest_aliased = hidden\n", "unresolved executable alias"), + ("import unittest\nclass HiddenName(unittest.TestCase):\n def test_hidden(self): assert False\n", "HiddenName"), + ("from unittest import TestCase as Case\nclass HiddenName(Case):\n def test_hidden(self): assert False\n", "HiddenName"), + ("class Helper:\n __test__ = True\n def test_explicit(self): pass\n", "Helper::test_explicit"), + ("class TestAlias:\n def hidden(self): pass\n test_aliased = hidden\n", "TestAlias::test_aliased"), + ): + inherited.write_text(code) + ok, problems = audit_repository(root) + assert not ok and any(label in item for item in problems), problems + inherited.write_text("test_data = [1, 2]\n") + ok, problems = audit_repository(root) + assert ok, problems + inherited.write_text("__test__ = False\ndef test_disabled(): assert False\n") + ok, problems = audit_repository(root) + assert ok, problems +# === CHECKS === +# id: check_vendored_msdmd_field_preservation +# proves: msdmd_python_parser_preserves_field_names +# call: self::test_vendored_parser_retains_numeric_field_names +# requires: python3 +# timeout: 10 +# mutates: none +# cleanup: none +# === END CHECKS === + + +def test_vendored_parser_retains_numeric_field_names() -> None: + from tools.verify_skill_lib_contracts import _PARSER, PARSER_PATH, parse_blocks + declarations = parse_blocks(PARSER_PATH) + assert any(entry.block == "MODULE_BUILD" and entry.id == "msdmd_python_reference_parser" for entry in declarations) + assert any(entry.block == "CONTRACTS" and entry.id == "msdmd_python_parser_preserves_field_names" for entry in declarations) + text = "# === NARRATIVE ===\n# id: sample\n# evidence_sha256: abc123\n# === END NARRATIVE ===\nraise RuntimeError('not executable input')\n" + assert _PARSER.parse_text(text, "NARRATIVE") == [{"id": "sample", "evidence_sha256": "abc123"}] +# === CHECKS === +# id: check_vendored_typescript_field_preservation +# proves: msdmd_typescript_parser_preserves_field_names, contract_audit_reports_graph_gaps +# call: self::test_vendored_typescript_parser_retains_numeric_field_names +# requires: python3, node24 +# timeout: 10 +# mutates: filesystem +# cleanup: tempdir_teardown +# === END CHECKS === + + +def test_vendored_typescript_parser_retains_numeric_field_names(tmp_path: Path) -> None: + import json + import subprocess + from tools.verify_skill_lib_contracts import parse_blocks + helper = ROOT / ".agents/skills/msdmd/parsers/universal.ts" + declarations = parse_blocks(helper) + assert any(entry.block == "MODULE_BUILD" and entry.id == "msdmd_typescript_reference_parser" for entry in declarations) + assert any(entry.block == "CONTRACTS" and entry.id == "msdmd_typescript_parser_preserves_field_names" for entry in declarations) + marker = tmp_path / "executed" + source = tmp_path / "inspected.ts" + text = "// === NARRATIVE ===\n// id: sample\n// evidence_sha256: abc123\n// === END NARRATIVE ===\n" + source.write_text(text + 'import {writeFileSync} from "node:fs";\n' + f'writeFileSync({json.dumps(str(marker))}, "executed");\n') + script = f"import {{parseText, parseFile}} from {json.dumps(helper.as_uri())};" + f"process.stdout.write(JSON.stringify([parseText({json.dumps(text)}, 'NARRATIVE', '//'),parseFile({json.dumps(str(source))}, 'NARRATIVE')]));" + result = subprocess.run(["node", "--input-type=module", "--eval", script], check=True, capture_output=True, text=True) + expected = [{"id": "sample", "evidence_sha256": "abc123"}] + assert json.loads(result.stdout) == [expected, expected] + assert not marker.exists(), "TypeScript parsing executed inspected source" + broken = tmp_path / "repo/.agents/skills/msdmd/parsers/universal.ts" + broken.parent.mkdir(parents=True) + broken.write_text("// no declaration\nthrow new Error('must not execute');\n") + ok, problems = audit_repository(tmp_path / "repo") + assert not ok and any("universal.ts missing MODULE_BUILD" in item for item in problems), problems +# ratios: loc_comments=306:60 imports_exports=9:7 calls_definitions=162:7 diff --git a/tools/_boundary_pytest.py b/tools/_boundary_pytest.py new file mode 100644 index 0000000..23d1dd5 --- /dev/null +++ b/tools/_boundary_pytest.py @@ -0,0 +1,195 @@ +# ratios: loc_comments=138:35 imports_exports=10:3 calls_definitions=62:10 +# === MODULE_BUILD === +# id: boundary_pytest_observer +# module_name: _boundary_pytest +# module_kind: instrument +# summary: observes actual pytest exceptions, xfail outcomes, and imported source origins for UCNS receipts +# owner: Erin Spencer +# public_surface: run_suite; selected-check bootstrap launched by run_skill_lib_boundaries +# internal_surface: Observer, main +# auth_boundary: none +# storage_boundary: write +# storage_notes: writes caller-selected machine outcome report +# network_boundary: none +# user_data_boundary: none +# admin_only: false +# tests: tests/test_skill_lib_boundary_runner.py +# rollout: boundary runner subprocess bootstrap +# rollback: remove with boundary runner bootstrap integration +# === END MODULE_BUILD === +# === CONTRACTS === +# id: boundary_pytest_observes_actual_outcomes +# given: a selected pytest boundary runs through this bootstrap +# then: assertion subclasses fail, unexpected exceptions error, XPASS cannot pass, actual test function code must match the declared source, imported local package origins must match the bound tree +# class: evidence +# +# id: geometry_suite_requires_nonempty_pass +# given: the full geometry suite runs through run_suite +# then: empty, unexecuted declared witnesses or collected parameter items, skipped, xfailed, XPASS, failed, or collection-error evidence cannot produce exit status zero +# class: evidence +# === END CONTRACTS === + +"""Pytest evidence observer and selected-check bootstrap. + +CI usage: ``run_suite(["tests", "-c", "pyproject.toml", "--noconftest"], Path.cwd())``. +Selected-check CLI usage remains owned by tools/run_skill_lib_boundaries.py. + +Observations describe executed checks, not theorem standing or a hostile-code +sandbox. Ambient pytest plugins and PYTHONPATH are excluded by the parent. +""" +from __future__ import annotations + +from collections import Counter +import json +from pathlib import Path +import sys +import tempfile +from types import CodeType, FunctionType, MethodType + +from _pytest.assertion.rewrite import _rewrite_test + +import pytest + + +class Observer: + def __init__(self, root: Path): + self.root = root + self.calls: list[str] = [] + self.other: list[str] = [] + self.executed_witnesses: set[tuple[Path, str]] = set() + self.collected_items: tuple[object, ...] = () + self.item_calls: Counter[int] = Counter() + self.expected_code: dict[Path, CodeType] = {} + + def pytest_collection_finish(self, session): + self.collected_items = tuple(session.items) + + def item_coverage_closed(self) -> bool: + return Counter(id(item) for item in self.collected_items) == self.item_calls + + def _witness_matches_source(self, item) -> bool: + path = Path(item.path).resolve() + if not path.is_relative_to(self.root.resolve()): + return False + if path not in self.expected_code: + if item.config.getoption("assertmode") == "plain": + code = compile(path.read_bytes(), str(path), "exec", dont_inherit=True) + else: + _, code = _rewrite_test(path, item.config) + self.expected_code[path] = code + expected = self.expected_code[path] + names = [item.originalname or item.name.split("[", 1)[0]] + parent = item.parent + while isinstance(parent, pytest.Class): + names.insert(0, parent.name) + parent = parent.parent + for name in names: + candidates = [value for value in expected.co_consts if isinstance(value, CodeType) and value.co_name == name] + if len(candidates) != 1: + return False + expected = candidates[0] + actual = item.obj + if isinstance(actual, MethodType): + actual = actual.__func__ + return isinstance(actual, FunctionType) and actual.__code__ == expected + + @pytest.hookimpl(hookwrapper=True, tryfirst=True) + def pytest_pyfunc_call(self, pyfuncitem): + if not self._witness_matches_source(pyfuncitem): + pytest.fail("test witness code differs from its declared source", pytrace=False) + yield + if not self._witness_matches_source(pyfuncitem): + pytest.fail("test witness code changed during execution", pytrace=False) + + def pytest_collectreport(self, report): + if report.skipped: + self.other.append("SKIP") + elif report.failed: + self.other.append("ERROR") + + @pytest.hookimpl(hookwrapper=True, tryfirst=True) + def pytest_runtest_makereport(self, item, call): + report = (yield).get_result() + if report.skipped: + status = "SKIP" + elif hasattr(report, "wasxfail"): + status = "FAIL" # Includes explicit xfail(strict=False) XPASS. + elif call.excinfo is not None: + status = "FAIL" if call.when == "call" and ( + call.excinfo.errisinstance(AssertionError) + or call.excinfo.errisinstance(pytest.fail.Exception) + ) else "ERROR" + elif report.failed: + status = "FAIL" if call.when == "call" else "ERROR" + else: + status = "PASS" + if call.when == "call": + self.calls.append(status) + self.item_calls[id(item)] += 1 + self.executed_witnesses.add((Path(item.path).resolve(), item.originalname or item.name.split("[", 1)[0])) + elif status != "PASS": + self.other.append(status) + + +def run_suite(arguments: list[str], root: Path) -> int: + from tools.verify_skill_lib_contracts import _defined_functions + expected = {(path.resolve(), name) for path in (root / "tests").rglob("*.py") + if path.name.startswith("test_") or path.name.endswith("_test.py") + for name in _defined_functions(path) if name.startswith("test")} + observer = Observer(root) + previous_prefix = sys.pycache_prefix + try: + with tempfile.TemporaryDirectory(prefix="ucns-suite-bytecode-") as cache: + sys.pycache_prefix = cache + result = int(pytest.main(arguments, plugins=[observer])) + finally: + sys.pycache_prefix = previous_prefix + if result: + return result + missing = expected - observer.executed_witnesses + if missing: + print("Declared witnesses did not execute:", sorted(str(path) + "::" + name for path, name in missing)) + if not observer.item_coverage_closed(): + print("Collected test item execution differs:", [(item.nodeid, observer.item_calls[id(item)]) for item in observer.collected_items]) + return 0 if observer.calls and set(observer.calls) == {"PASS"} and not observer.other and not missing and observer.item_coverage_closed() else 1 + + +def main() -> int: + root = Path(sys.argv[1]).resolve() + report_path = Path(sys.argv[2]) + sys.path[:0] = [str(root / "src"), str(root)] + observer = Observer(root) + hook = sys.modules.get("sitecustomize") + if getattr(hook, "BOUND_ROOT", None) != str(root) or getattr(hook, "FINDER", None) not in sys.meta_path: + raise RuntimeError("bound descendant import hook is unavailable") + exit_code = int(pytest.main(sys.argv[3:], plugins=[observer])) + source_root = root / "src" + local_names = {p.stem if p.is_file() else p.name for p in source_root.iterdir()} if source_root.exists() else set() + origins = {} + wrong_origins = [] + for name, module in tuple(sys.modules.items()): + if name.split(".", 1)[0] not in local_names: + continue + paths = [getattr(module, "__file__", None), *getattr(module, "__path__", ())] + paths = [str(Path(p).resolve()) for p in paths if p is not None] + origins[name] = paths + if not paths or any(not Path(p).is_relative_to(source_root) for p in paths): + wrong_origins.append(name) + statuses = observer.calls + observer.other + if wrong_origins or "ERROR" in statuses: + status = "ERROR" + elif "FAIL" in statuses: + status = "FAIL" + elif "SKIP" in statuses: + status = "SKIP" + elif observer.calls and exit_code == 0 and observer.item_coverage_closed(): + status = "PASS" + else: + status = "ERROR" + report_path.write_text(json.dumps({"status": status, "calls": observer.calls, "other": observer.other, "origins": origins, "wrong_origins": wrong_origins, "descendants_reaped": 0, "item_coverage_closed": observer.item_coverage_closed()}), encoding="utf-8") + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) +# ratios: loc_comments=138:35 imports_exports=10:3 calls_definitions=62:10 diff --git a/tools/_boundary_site/sitecustomize.py b/tools/_boundary_site/sitecustomize.py new file mode 100644 index 0000000..845adae --- /dev/null +++ b/tools/_boundary_site/sitecustomize.py @@ -0,0 +1,77 @@ +# ratios: loc_comments=36:28 imports_exports=5:2 calls_definitions=19:5 +# === MODULE_BUILD === +# id: boundary_descendant_import_binding +# module_name: sitecustomize +# module_kind: instrument +# summary: keeps bound package imports ahead of child working directories during declared checks +# owner: Erin Spencer +# public_surface: none; installed on the boundary runner's sanitized PYTHONPATH +# internal_surface: BoundSourceFinder, BoundSourceLoader +# auth_boundary: none +# storage_boundary: read +# storage_notes: reads source package locations +# network_boundary: none +# user_data_boundary: none +# admin_only: false +# tests: tests/test_skill_lib_boundary_runner.py +# rollout: inherited by ordinary Python check descendants +# rollback: remove together with the runner's startup-hook contract +# === END MODULE_BUILD === +# === CONTRACTS === +# id: boundary_descendants_import_bound_source +# given: an ordinary Python check subprocess inherits the boundary environment +# then: bound source packages compile inventoried source bytes before same-named packages in the working directory +# class: evidence +# === END CONTRACTS === +"""Internal startup hook; the runner sets UCNS_BOUND_SOURCE_ROOT and PYTHONPATH. + +Explicit isolated/no-site interpreters and replaced environments do not inherit +this protocol. This is trusted-check evidence instrumentation, not a sandbox. +""" +from __future__ import annotations + +from importlib.machinery import PathFinder, SourceFileLoader, SourcelessFileLoader +import os +from pathlib import Path +import sys + + +BOUND_ROOT = os.environ.get("UCNS_BOUND_SOURCE_ROOT", "") +FINDER = None + + +class BoundSourceLoader(SourceFileLoader): + def get_code(self, fullname): + filename = self.get_filename(fullname) + return self.source_to_code(self.get_data(filename), filename) + + +class BoundSourceFinder: + def __init__(self, root: Path): + self.source = root / "src" + self.names = {p.stem if p.is_file() else p.name for p in self.source.iterdir() + if p.is_dir() or p.suffix == ".py"} if self.source.is_dir() else set() + + def find_spec(self, fullname, path=None, target=None): + if fullname.split(".", 1)[0] not in self.names: + return None + search = [str(self.source)] if "." not in fullname else path + spec = PathFinder.find_spec(fullname, search, target) + if spec is None: + raise ModuleNotFoundError(f"module absent from bound source: {fullname}") + locations = list(spec.submodule_search_locations or ()) + if spec.origin is not None: + locations.append(spec.origin) + if not locations or any(not Path(location).resolve().is_relative_to(self.source) for location in locations): + raise ImportError(f"module outside bound source: {fullname}") + if isinstance(spec.loader, SourceFileLoader): + spec.loader = BoundSourceLoader(fullname, spec.origin) + elif isinstance(spec.loader, SourcelessFileLoader): + raise ImportError(f"sourceless bytecode outside the source contract: {fullname}") + return spec + + +if BOUND_ROOT: + FINDER = BoundSourceFinder(Path(BOUND_ROOT).resolve()) + sys.meta_path.insert(0, FINDER) +# ratios: loc_comments=36:28 imports_exports=5:2 calls_definitions=19:5 diff --git a/tools/_boundary_supervisor.py b/tools/_boundary_supervisor.py new file mode 100644 index 0000000..8ee9380 --- /dev/null +++ b/tools/_boundary_supervisor.py @@ -0,0 +1,128 @@ +# ratios: loc_comments=79:32 imports_exports=8:1 calls_definitions=32:5 +# === MODULE_BUILD === +# id: boundary_process_supervisor +# module_name: _boundary_supervisor +# module_kind: instrument +# summary: owns check descendant lifetime outside the pytest process and its signal handlers +# owner: Erin Spencer +# public_surface: none; launched by run_skill_lib_boundaries +# internal_surface: main, child subreaping +# auth_boundary: none +# storage_boundary: write +# storage_notes: updates machine outcome report after child cleanup +# network_boundary: none +# user_data_boundary: none +# admin_only: false +# tests: tests/test_skill_lib_boundary_runner.py +# rollout: process supervisor for every selected check +# rollback: remove with boundary-runner integration +# === END MODULE_BUILD === +# === CONTRACTS === +# id: boundary_supervisor_ends_descendants +# given: a selected check or executable capability probe exits or exceeds its timeout, including after replacing its signal handlers +# then: a separate Linux subreaper kills and reaps remaining check descendants before accepting or returning its outcome +# class: evidence +# === END CONTRACTS === +"""Internal usage: python _boundary_supervisor.py TIMEOUT BOOTSTRAP ROOT REPORT PYTEST_ARGS. + +For a capability executable: python _boundary_supervisor.py --probe TIMEOUT COMMAND ARGS. + +Only the child runs pytest or test code. Descendant adoption therefore survives +child signal-handler changes, crashes, and detached sessions. This is trusted +check instrumentation, not containment against hostile code targeting ancestors. +""" +from __future__ import annotations + +import ctypes +import json +import os +from pathlib import Path +import signal +import subprocess +import sys + + +def _enable_descendant_reaping() -> None: + libc = ctypes.CDLL(None, use_errno=True) + if libc.prctl(36, 1, 0, 0, 0) != 0: # Linux PR_SET_CHILD_SUBREAPER. + raise OSError(ctypes.get_errno(), "cannot bind check descendant lifetime") + + +def _owned_children(proc_root: Path = Path("/proc")) -> list[int]: + """Use the optional task list, falling back to ordinary procfs parent IDs.""" + parent = os.getpid() + try: + return [int(pid) for pid in (proc_root / f"self/task/{parent}/children").read_text().split()] + except FileNotFoundError: + children = [] + for process in proc_root.iterdir(): + if not process.name.isdecimal(): + continue + try: + state = (process / "stat").read_text().rpartition(")")[2].split() + if int(state[1]) == parent: + children.append(int(process.name)) + except (FileNotFoundError, ProcessLookupError, PermissionError): + continue # Exited processes or unrelated users hidden by procfs. + return children + + +def _reap_descendants() -> int: + """Terminate and reap only children owned/adopted by this bootstrap.""" + observed = 0 + while True: + children = _owned_children() + if not children: + return observed + observed += len(children) + for pid in children: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + for pid in children: + try: + os.waitpid(pid, 0) + except ChildProcessError: + pass + + +def _supervise(command: list[str], timeout: int) -> tuple[int, bool, int]: + _enable_descendant_reaping() + _owned_children() # Verify procfs support before any check code is launched. + process = subprocess.Popen(command, start_new_session=True) + timed_out = False + try: + returncode = process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + timed_out = True + process.kill() + returncode = process.wait() + descendants = _reap_descendants() + return returncode, timed_out, descendants + + +def main() -> int: + if sys.argv[1] == "--probe": + returncode, timed_out, descendants = _supervise(sys.argv[3:], int(sys.argv[2])) + return 124 if timed_out else 1 if descendants else returncode + timeout = int(sys.argv[1]) + report = Path(sys.argv[4]) + returncode, timed_out, descendants = _supervise([sys.executable, *sys.argv[2:]], timeout) + try: + observed = json.loads(report.read_text()) + if not isinstance(observed, dict): + raise ValueError("invalid bootstrap report") + except (OSError, ValueError): + observed = {"status": "ERROR", "calls": [], "other": ["ERROR"], "origins": {}, "wrong_origins": []} + if timed_out or descendants: + observed["status"] = "ERROR" + observed.setdefault("other", []).append("ERROR") + observed.update(descendants_reaped=descendants, timed_out=timed_out) + report.write_text(json.dumps(observed), encoding="utf-8") + return 124 if timed_out else returncode + + +if __name__ == "__main__": + raise SystemExit(main()) +# ratios: loc_comments=79:32 imports_exports=8:1 calls_definitions=32:5 diff --git a/tools/run_skill_lib_boundaries.py b/tools/run_skill_lib_boundaries.py index 9471473..0069ac4 100644 --- a/tools/run_skill_lib_boundaries.py +++ b/tools/run_skill_lib_boundaries.py @@ -1,3 +1,4 @@ +# ratios: loc_comments=422:71 imports_exports=20:4 calls_definitions=187:20 # === MODULE_BUILD === # id: skill_lib_boundary_runner # module_name: run_skill_lib_boundaries @@ -7,9 +8,11 @@ # public_surface: command-line boundary runner, run_boundaries, write_receipt # internal_surface: capability resolution, subprocess classification, receipt hashing # auth_boundary: none -# storage_boundary: optional caller-selected JSON receipt path +# storage_boundary: write +# storage_notes: optional caller-selected JSON receipt path # network_boundary: none -# user_data_boundary: captured test output is bounded and retained only in the caller-selected receipt +# user_data_boundary: write +# user_data_notes: captured test output is bounded and retained only in the caller-selected receipt # admin_only: false # tests: tests/test_skill_lib_boundary_runner.py # rollout: explicit local and CI evidence runner; no product, EDCM, or canon activation @@ -34,13 +37,13 @@ # # id: boundary_runner_classifies_and_continues # given: one declared check passes, fails an assertion, raises unexpectedly, or times out -# then: the runner records PASS, FAIL, ERROR, or TIMEOUT respectively and continues with remaining selected checks +# then: the runner records PASS, FAIL, ERROR, TIMEOUT, or SKIP from machine-readable outcomes and continues after per-check harness errors; absent or skipped evidence never passes # class: evidence # since: 2026-08-15 # # id: boundary_runner_receipt_is_bounded_and_bound # given: a boundary run completes -# then: its receipt binds declarations, commands, capabilities, outcomes, output digests, declared mutation and cleanup, bounded output excerpts, and an identity digest +# then: its receipt binds source/declaration digests before and after execution, commands, capabilities, outcomes, declared mutation and cleanup, and bounded output; source mutation prevents acceptance # class: evidence # since: 2026-08-15 # @@ -51,20 +54,30 @@ # since: 2026-08-15 # === END CONTRACTS === -"""Execute UCNS skill-lib ``CHECKS`` declarations as bounded processes.""" +"""Execute UCNS skill-lib ``CHECKS`` declarations as bounded processes. + +Usage: ``python tools/run_skill_lib_boundaries.py . --check CHECK_ID +--receipt /tmp/receipt.json``. Schema v2.1 requires observed pytest outcomes, +bound import origins, unchanged source snapshots, and no Linux inotify write +events. JUnit remains diagnostic only. Receipts are not theorem or freshness +certificates. Timeouts retain the existing declared execution-safety boundary. +""" from __future__ import annotations import argparse +import ctypes import ctypes.util -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, field, replace from hashlib import sha256 import importlib.util import json import os +import re from pathlib import Path import shutil import signal +import struct import subprocess import sys import tempfile @@ -79,10 +92,85 @@ SCHEMA_ID = "ucns.skill-lib-boundary-run-receipt" -SCHEMA_VERSION = "1.0.0" +SCHEMA_VERSION = "2.1.0" MAX_EXCERPT_BYTES = 16_384 ALLOWED_MUTATIONS = {"none", "filesystem", "temporary_path"} ALLOWED_CLEANUPS = {"none", "tempdir_teardown", "pytest temporary_path"} +SOURCE_DIRECTORIES = ("src", "tools", "tests", "docs", "generated", ".agents/skills", ".github/workflows") +ROOT_INPUTS = ("pyproject.toml", "uv.lock", "pytest.ini", "setup.cfg", "MANIFEST.in", "conftest.py", "CANON.md", "AGENTS.md", "README.md", "CLAUDE.md", "LICENSE") +BOOTSTRAP = TOOLS_DIRECTORY / "_boundary_pytest.py" +SUPERVISOR = TOOLS_DIRECTORY / "_boundary_supervisor.py" +STARTUP_DIRECTORY = TOOLS_DIRECTORY / "_boundary_site" + + +class _SourceWatch: + """Observe Linux source write events, including a write followed by restoration. + + Receipt execution requires inotify; an unavailable observer fails closed. + This detects changed inputs, not malicious checks or a security sandbox. + """ + def __init__(self, root: Path): + self.root = root + self.libc = ctypes.CDLL(None, use_errno=True) + self.fd = self.libc.inotify_init1(os.O_NONBLOCK | os.O_CLOEXEC) + if self.fd < 0: + raise OSError(ctypes.get_errno(), "source observer unavailable") + self.watches = {} + try: + directories = {root} + for relative in SOURCE_DIRECTORIES: + base = root / relative + if base.exists(): + directories.add(base) + directories.update(p for p in base.rglob("*") if p.is_dir() and "__pycache__" not in p.parts) + parent = base.parent + while parent != root and parent.is_relative_to(root): + if parent.is_dir(): + directories.add(parent) + parent = parent.parent + # MODIFY, ATTRIB, MOVED_FROM/TO, CREATE, DELETE, DELETE_SELF, MOVE_SELF. + # File watches follow the inode, including writes via external hardlinks. + files, _ = _source_snapshot(root) + watched = directories | {root / name for name in files} + for directory in sorted(watched): + descriptor = self.libc.inotify_add_watch(self.fd, os.fsencode(directory), 0xFC6) + if descriptor < 0: + raise OSError(ctypes.get_errno(), "cannot watch source directory") + self.watches[descriptor] = directory + except BaseException: + os.close(self.fd) + raise + + def finish(self) -> tuple[str, ...]: + changed = set() + try: + while True: + try: + data = os.read(self.fd, 65536) + except BlockingIOError: + break + if not data: + break + offset = 0 + while offset < len(data): + descriptor, mask, _, length = struct.unpack_from("iIII", data, offset) + name = os.fsdecode(data[offset + 16:offset + 16 + length].split(b"\0", 1)[0]) + offset += 16 + length + if mask & 0x4000: + changed.add("hmmm: source event queue overflow") + continue + parent = self.watches.get(descriptor) + if parent is None: + changed.add("hmmm: unknown source watch") + continue + relative = (parent / name).relative_to(self.root).as_posix() + if "__pycache__" in Path(relative).parts: + continue + if relative in ROOT_INPUTS or any(relative == d or relative.startswith(d + "/") or d.startswith(relative + "/") for d in SOURCE_DIRECTORIES): + changed.add(relative) + finally: + os.close(self.fd) + return tuple(sorted(changed)) @dataclass(frozen=True) @@ -106,6 +194,12 @@ class CheckOutcome: stdout_excerpt: str stderr_excerpt: str missing_capabilities: tuple[str, ...] = () + diagnostic: str = "" + descendants_reaped: int = 0 + imported_sources: dict[str, list[str]] = field(default_factory=dict) + source_events: tuple[str, ...] = () + source_before_sha256: str = "" + source_after_sha256: str = "" def _sha(data: bytes) -> str: @@ -119,6 +213,15 @@ def _split(value: str) -> tuple[str, ...]: def _capability_available(name: str) -> bool: if name == "python3": return True + if name == "node24": + executable = shutil.which("node") + if executable is None: + return False + try: + result = subprocess.run([sys.executable, str(SUPERVISOR), "--probe", "5", executable, "--version"], capture_output=True, text=True) + except (OSError, UnicodeError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 and re.fullmatch(r"v24\.\d+\.\d+", result.stdout.strip()) is not None if name == "posix_shell": return os.name == "posix" and shutil.which("sh") is not None if name == "posix_resource": @@ -134,8 +237,9 @@ def _capability_available(name: str) -> bool: def _declared_checks(root: Path) -> tuple[Entry, ...]: checks: list[Entry] = [] - for path in sorted((root / "tests").rglob("test_*.py")): - checks.extend(entry for entry in parse_blocks(path) if entry.block == "CHECKS") + for path in sorted((root / "tests").rglob("*.py")): + if "__pycache__" not in path.parts and (path.name.startswith("test_") or path.name.endswith("_test.py")): + checks.extend(entry for entry in parse_blocks(path) if entry.block == "CHECKS") return tuple(checks) @@ -160,15 +264,92 @@ def _validate_check(check: Entry) -> tuple[tuple[str, ...], int, str, str]: def _excerpt(path: Path) -> tuple[str, int, str]: - data = path.read_bytes() - excerpt = data[:MAX_EXCERPT_BYTES] + digest = sha256() + size = 0 + excerpt = b"" + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(65_536), b""): + digest.update(chunk) + size += len(chunk) + excerpt += chunk[:max(0, MAX_EXCERPT_BYTES - len(excerpt))] text = excerpt.decode("utf-8", errors="replace") - if len(data) > len(excerpt): - text += f"\n[truncated {len(data) - len(excerpt)} bytes]" - return _sha(data), len(data), text + if size > len(excerpt): + text += f"\n[truncated {size - len(excerpt)} bytes]" + return digest.hexdigest(), size, text + + +def _pytest_outcome(path: Path, returncode: int) -> tuple[str, dict]: + """Validate the bootstrap's machine report; absent evidence is an error.""" + try: + observed = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return "ERROR", {} + if not isinstance(observed, dict): + return "ERROR", {} + calls, other = observed.get("calls"), observed.get("other") + origins = observed.get("origins") + allowed = {"PASS", "FAIL", "ERROR", "SKIP"} + if not isinstance(calls, list) or not isinstance(other, list) or not isinstance(origins, dict): + return "ERROR", {} + if any(not isinstance(value, str) or value not in allowed for value in calls + other): + return "ERROR", {} + statuses = calls + other + if observed.get("wrong_origins") or "ERROR" in statuses: + status = "ERROR" + elif "FAIL" in statuses: + status = "FAIL" + elif "SKIP" in statuses: + status = "SKIP" + else: + status = "PASS" if calls and returncode == 0 and observed.get("item_coverage_closed") is True else "ERROR" + if observed.get("status") != status: + return "ERROR", {} + return status, observed + + +def _source_snapshot(root: Path) -> tuple[dict[str, str], str]: + """Bind declared execution inputs, excluding bytecode caches.""" + entries = {root / name for name in ROOT_INPUTS} + for directory in SOURCE_DIRECTORIES: + base = root / directory + parent = base + while parent != root: + if parent.is_symlink(): + raise ValueError("unsupported source symlink: " + parent.relative_to(root).as_posix()) + entries.add(parent) + parent = parent.parent + entries.update(base.rglob("*")) + links = sorted(path.relative_to(root).as_posix() for path in entries if path.is_symlink()) + if links: + raise ValueError("unsupported source symlink: " + ", ".join(links)) + paths = {path for path in entries if path.is_file() and "__pycache__" not in path.parts} + inventory = {path.relative_to(root).as_posix(): _sha(path.read_bytes()) for path in sorted(paths)} + return inventory, _sha(json.dumps(inventory, sort_keys=True, separators=(",", ":")).encode()) + + +def _error_outcome(root: Path, check: Entry, error: Exception) -> CheckOutcome: + empty = _sha(b"") + return CheckOutcome( + check.id, check.source.relative_to(root).as_posix(), + _split(check.fields.get("proves", "")), check.fields.get("call", ""), (), + _split(check.fields.get("requires", "")), 0, + check.fields.get("mutates", ""), check.fields.get("cleanup", ""), + "ERROR", None, 0.0, empty, empty, 0, 0, "", "", + diagnostic=f"{type(error).__name__}: {error}", + ) def _run_check(root: Path, check: Entry) -> CheckOutcome: + started = time.monotonic() + watcher = _SourceWatch(root) + try: + outcome = _execute_check(root, check) + finally: + source_events = watcher.finish() + return replace(outcome, source_events=source_events, duration_seconds=round(time.monotonic() - started, 6)) + + +def _execute_check(root: Path, check: Entry) -> CheckOutcome: requires, timeout, mutates, cleanup = _validate_check(check) missing = tuple(name for name in requires if not _capability_available(name)) call = check.fields["call"] @@ -188,38 +369,42 @@ def _run_check(root: Path, check: Entry) -> CheckOutcome: with tempfile.TemporaryDirectory(prefix="ucns-boundary-") as temporary: stdout_path = Path(temporary) / "stdout" stderr_path = Path(temporary) / "stderr" + junit_path = Path(temporary) / "outcomes.xml" + report_path = Path(temporary) / "outcomes.json" + command = (sys.executable, str(BOOTSTRAP), str(root), str(report_path), + "-q", "-c", str(root / "pyproject.toml"), "--noconftest", f"{relative_source}::{function}", f"--junitxml={junit_path}", "-o", "xfail_strict=true") + command = (sys.executable, str(SUPERVISOR), str(timeout), *command[1:]) + environment = dict(os.environ) + for name in ("PYTHONPATH", "PYTHONHOME", "PYTEST_ADDOPTS", "PYTEST_PLUGINS"): + environment.pop(name, None) + environment.update(PYTHONDONTWRITEBYTECODE="1", PYTEST_DISABLE_PLUGIN_AUTOLOAD="1") + environment["PYTHONPYCACHEPREFIX"] = str(Path(temporary) / "bytecode") + environment["UCNS_BOUND_SOURCE_ROOT"] = str(root) + environment["PYTHONPATH"] = os.pathsep.join((str(STARTUP_DIRECTORY), str(root / "src"), str(root))) with stdout_path.open("wb") as stdout, stderr_path.open("wb") as stderr: process = subprocess.Popen( command, cwd=root, stdin=subprocess.DEVNULL, - stdout=stdout, stderr=stderr, start_new_session=True, + stdout=stdout, stderr=stderr, start_new_session=True, env=environment, ) - timed_out = False - try: - returncode = process.wait(timeout=timeout) - except subprocess.TimeoutExpired: - timed_out = True - try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - returncode = process.wait() + # The separate supervisor owns the timeout and descendant reaping; + # pytest signal handlers never run in that process. + returncode = process.wait() stdout_sha, stdout_bytes, stdout_excerpt = _excerpt(stdout_path) stderr_sha, stderr_bytes, stderr_excerpt = _excerpt(stderr_path) + status, observed = _pytest_outcome(report_path, returncode) + if observed.get("timed_out"): + status = "TIMEOUT" duration = round(time.monotonic() - started, 6) - if timed_out: - status = "TIMEOUT" - elif returncode == 0: - status = "PASS" - elif returncode == 1 and "AssertionError" in (stdout_excerpt + stderr_excerpt): - status = "FAIL" - else: - status = "ERROR" return CheckOutcome( check.id, relative_source, _split(check.fields["proves"]), call, command, requires, timeout, mutates, cleanup, status, returncode, duration, stdout_sha, stderr_sha, stdout_bytes, stderr_bytes, stdout_excerpt, stderr_excerpt, + diagnostic=("background descendants outlived the check" if observed.get("descendants_reaped") else + "collected pytest items did not all execute" if status == "ERROR" and observed.get("item_coverage_closed") is False else ""), + descendants_reaped=observed.get("descendants_reaped", 0), + imported_sources=observed.get("origins", {}), ) @@ -236,12 +421,19 @@ def run_boundaries( root: Path, *, selected_ids: Iterable[str] = (), ) -> dict[str, object]: root = root.resolve() - audit_ok, gaps = audit_repository(root) + try: + source_files, source_before = _source_snapshot(root) + except (ValueError, OSError) as error: + source_files, source_before = {}, "" + audit_ok, gaps = False, [f"GAP source snapshot {type(error).__name__}: {error}"] + else: + audit_ok, gaps = audit_repository(root) if not audit_ok: receipt: dict[str, object] = { "schema_id": SCHEMA_ID, "schema_version": SCHEMA_VERSION, "status": "audit-gap", "audit_closed": False, "audit_gaps": gaps, "outcomes": [], "selection_effect": "none", + "bound_source_root": str(root), "edcm_activation": "inactive", "canon_status": "none", } receipt["receipt_sha256"] = _receipt_identity(receipt) @@ -256,16 +448,54 @@ def run_boundaries( selected = checks if not requested else tuple( check for check in checks if check.id in requested ) - outcomes = tuple(_run_check(root, check) for check in selected) + outcomes = [] + snapshot_errors = [] + for check in selected: + check_before = check_after = "" + try: + _, check_before = _source_snapshot(root) + except (ValueError, OSError) as error: + snapshot_errors.append({"check_id": check.id, "phase": "before", "error": str(error)}) + outcomes.append(_error_outcome(root, check, error)) + continue + try: + outcome = _run_check(root, check) + except (ValueError, OSError, AttributeError) as error: + outcome = _error_outcome(root, check, error) + try: + _, check_after = _source_snapshot(root) + except (ValueError, OSError) as error: + snapshot_errors.append({"check_id": check.id, "phase": "after", "error": str(error)}) + outcome = replace(outcome, status="ERROR", diagnostic=f"source snapshot {type(error).__name__}: {error}") + outcomes.append(replace(outcome, source_before_sha256=check_before, source_after_sha256=check_after)) + try: + _, source_after = _source_snapshot(root) + except (ValueError, OSError) as error: + source_after = "" + snapshot_errors.append({"phase": "final", "error": str(error)}) statuses = {outcome.status for outcome in outcomes} + unchanged = not snapshot_errors and source_before == source_after and all( + not outcome.source_events and outcome.source_before_sha256 == outcome.source_after_sha256 + for outcome in outcomes + ) receipt: dict[str, object] = { "schema_id": SCHEMA_ID, "schema_version": SCHEMA_VERSION, - "status": "passed" if statuses <= {"PASS"} else "not-passed", + "bound_source_root": str(root), + "status": "passed" if outcomes and statuses == {"PASS"} and unchanged else "not-passed", "audit_closed": True, "audit_gaps": [], + "source_files_sha256": source_files, + "source_before_sha256": source_before, + "source_after_sha256": source_after, + "source_unchanged": unchanged, + "snapshot_errors": snapshot_errors, + "bootstrap_sha256": _sha(BOOTSTRAP.read_bytes()), + "supervisor_sha256": _sha(SUPERVISOR.read_bytes()), + "startup_hook_sha256": _sha((STARTUP_DIRECTORY / "sitecustomize.py").read_bytes()), + "python_version": sys.version, "selected_check_ids": [outcome.check_id for outcome in outcomes], "outcome_counts": { key: sum(outcome.status == key for outcome in outcomes) - for key in ("PASS", "FAIL", "ERROR", "TIMEOUT") + for key in ("PASS", "FAIL", "ERROR", "TIMEOUT", "SKIP") }, "outcomes": [asdict(outcome) for outcome in outcomes], "selection_effect": "none", "edcm_activation": "inactive", @@ -276,12 +506,25 @@ def run_boundaries( return receipt +def _receipt_inside_source(path: Path, root: Path) -> bool: + lexical = Path(os.path.abspath(path)) + destination = path.parent.resolve() / path.name + bound = root.resolve() + return any(candidate.is_relative_to(bound) for candidate in (lexical, destination, path.resolve())) + + def write_receipt(receipt: dict[str, object], path: Path) -> None: + bound_root = receipt.get("bound_source_root") + if not isinstance(bound_root, str) or not bound_root: + raise ValueError("receipt must identify its bound source tree") + if _receipt_inside_source(path, Path(bound_root)): + raise ValueError("receipt output must be outside the bound source tree") path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps(receipt, indent=2, sort_keys=True, ensure_ascii=False) + "\n", - encoding="utf-8", - ) + # Replacing the directory entry avoids writing through an external hardlink. + with tempfile.TemporaryDirectory(prefix=".ucns-receipt-", dir=path.parent) as temporary: + output = Path(temporary) / "receipt.json" + output.write_text(json.dumps(receipt, indent=2, sort_keys=True, ensure_ascii=False) + "\n", encoding="utf-8") + os.replace(output, path) def main(argv: Sequence[str] | None = None) -> int: @@ -290,6 +533,8 @@ def main(argv: Sequence[str] | None = None) -> int: parser.add_argument("--check", action="append", default=[], dest="checks") parser.add_argument("--receipt", type=Path) args = parser.parse_args(argv) + if args.receipt and _receipt_inside_source(args.receipt, Path(args.root)): + parser.error("receipt output must be outside the bound source tree") receipt = run_boundaries(Path(args.root), selected_ids=args.checks) if args.receipt: write_receipt(receipt, args.receipt) @@ -299,3 +544,4 @@ def main(argv: Sequence[str] | None = None) -> int: if __name__ == "__main__": raise SystemExit(main()) +# ratios: loc_comments=422:71 imports_exports=20:4 calls_definitions=187:20 diff --git a/tools/verify_distributions.py b/tools/verify_distributions.py new file mode 100644 index 0000000..867dcb3 --- /dev/null +++ b/tools/verify_distributions.py @@ -0,0 +1,407 @@ +# ratios: loc_comments=336:35 imports_exports=17:4 calls_definitions=178:11 +# === MODULE_BUILD === +# id: ucns_distribution_audit +# module_name: verify_distributions +# module_kind: instrument +# summary: checks built UCNS source and wheel archive bytes against repository-owned replay inputs +# owner: Erin Spencer +# public_surface: verify_distributions, command-line audit +# internal_surface: expected_files, read_archive +# auth_boundary: none +# storage_boundary: read +# storage_notes: read-only repository and archives +# network_boundary: none +# user_data_boundary: none +# admin_only: false +# tests: tests/test_distributions.py +# rollout: CI artifact gate after build +# rollback: remove tool, test, and CI invocation together +# since: 2026-09-11 +# unresolved: semantic validity of archived certificates and independent wheel runtime replay +# === END MODULE_BUILD === + +# === CONTRACTS === +# id: distributions_retain_exact_replay_inputs +# given: a built sdist and wheel are compared to a UCNS source tree +# then: missing or altered required inputs, unsafe archive members, duplicate members, unexpected executable payloads/metadata, and altered license material fail without extraction or execution +# class: evidence +# since: 2026-09-11 +# === END CONTRACTS === + +"""Usage: ``python tools/verify_distributions.py . dist`` after a clean build. + +The wheel contains the package plus a narrowly admitted generated ``.dist-info`` +set; the sdist also contains the tests, their evidence, preregistrations, tools, +vendored parser, and exact license bytes. Matching archive bytes is packaging +evidence only, not certificate verification or ratification. +""" + +from __future__ import annotations + +import argparse +import base64 +from collections import Counter +import csv +from email.parser import BytesParser +import hashlib +import io +from pathlib import Path, PurePosixPath +import tarfile +import zipfile + +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name, parse_wheel_filename, parse_sdist_filename +from packaging.tags import Tag +from packaging.version import Version + +try: + import tomllib +except ModuleNotFoundError: # Python 3.10; declared in the build extra. + import tomli as tomllib + + +ROOT_INPUTS = ( + "pyproject.toml", "README.md", "AGENTS.md", "CANON.md", "CLAUDE.md", + "LICENSE", "uv.lock", "MANIFEST.in", +) +TREE_INPUTS = ( + "src/ucns", "tests", "tools", "docs", "generated", ".agents/skills", ".github/workflows", +) + +SDIST_GENERATED = { + "setup.cfg", + "PKG-INFO", + "src/ucns.egg-info/PKG-INFO", + "src/ucns.egg-info/SOURCES.txt", + "src/ucns.egg-info/dependency_links.txt", + "src/ucns.egg-info/requires.txt", + "src/ucns.egg-info/top_level.txt", +} +GENERATED_SETUP_CFG = b"[egg_info]\ntag_build = \ntag_date = 0\n\n" +WHEEL_DIST_INFO_FILES = {"METADATA", "WHEEL", "RECORD", "top_level.txt"} +WHEEL_LICENSE_PATH = "licenses/LICENSE" + + +def expected_files(root: Path) -> dict[str, bytes]: + paths = {root / name for name in ROOT_INPUTS} + if any(path.is_symlink() for path in paths): + raise ValueError("unsupported source symlink in root distribution inputs") + for directory in TREE_INPUTS: + base = root / directory + parent = base + while parent != root: + if parent.is_symlink(): + raise ValueError(f"unsupported source symlink: {parent}") + parent = parent.parent + for path in base.rglob("*"): + if path.is_symlink(): + raise ValueError(f"unsupported source symlink: {path}") + if path.is_file() and "__pycache__" not in path.parts: + paths.add(path) + missing_roots = [path.name for path in paths if path.parent == root and not path.is_file()] + if missing_roots: + raise ValueError(f"missing root distribution inputs: {', '.join(sorted(missing_roots))}") + if not any(path.is_relative_to(root / "src/ucns") for path in paths): + raise ValueError("missing UCNS package source") + return {path.relative_to(root).as_posix(): path.read_bytes() for path in sorted(paths)} + + +def read_archive(path: Path, *, wheel: bool, expected_prefix: str | None = None, directories: set[str] | None = None) -> dict[str, bytes]: + """Read regular files only; reject ambiguous names rather than extracting.""" + files: dict[str, bytes] = {} + prefixes: set[str] = set() + kinds: dict[str, bool] = {} + + def record(name: str, data: bytes | None) -> None: + parts = PurePosixPath(name).parts + directory = data is None + if not parts or name.startswith("/") or ".." in parts or "\\" in name or "\0" in name: + raise ValueError(f"unsafe archive name: {name}") + if not wheel: + if expected_prefix is not None and parts[0] != expected_prefix: + raise ValueError("sdist root identity differs from project/filename") + prefixes.add(parts[0]) + if (len(parts) < 2 and not directory) or len(prefixes) != 1: + raise ValueError("sdist must have one enclosing directory") + parts = parts[1:] + name = "/".join(parts) + if name in kinds: + raise ValueError(f"duplicate archive member: {name}") + if any(kinds.get("/".join(parts[:index])) is False for index in range(1, len(parts))): + raise ValueError(f"file/directory archive collision: {name}") + if not directory and any(existing.startswith(name + "/") for existing in kinds): + raise ValueError(f"file/directory archive collision: {name}") + kinds[name] = directory + if directory and directories is not None: + directories.add(name) + if not directory: + files[name] = data + + if wheel: + with zipfile.ZipFile(path) as archive: + for member in archive.infolist(): + if (member.external_attr >> 16) & 0o170000 == 0o120000: + raise ValueError(f"archive symlink: {member.filename}") + if member.is_dir() and member.file_size: + raise ValueError(f"nonempty archive directory: {member.filename}") + record(member.filename, None if member.is_dir() else archive.read(member)) + else: + with tarfile.open(path, "r:gz") as archive: + for member in archive: + required_mode = 0o700 if member.isdir() else 0o600 + if member.mode & required_mode != required_mode or member.mode & 0o7000: + raise ValueError(f"unusable sdist permissions: {member.name}: {oct(member.mode)}") + if member.isdir(): + if member.size: + raise ValueError(f"nonempty archive directory: {member.name}") + record(member.name, None) + continue + if not member.isfile(): + raise ValueError(f"non-regular archive member: {member.name}") + stream = archive.extractfile(member) + if stream is None: + raise ValueError(f"unreadable archive member: {member.name}") + with stream: + record(member.name, stream.read()) + return files + + +def _requirement_key(value: str) -> tuple[str, ...]: + requirement = Requirement(value) + return (canonicalize_name(requirement.name), ",".join(sorted(requirement.extras)), + str(requirement.specifier), requirement.url or "", str(requirement.marker or "")) + + +def _project_requirements(project: dict) -> list[str]: + requirements = list(project.get("dependencies", [])) + for extra, dependencies in project.get("optional-dependencies", {}).items(): + for value in dependencies: + requirement = Requirement(value) + marker = f'({requirement.marker}) and extra == "{extra}"' if requirement.marker else f'extra == "{extra}"' + requirement.marker = None + requirements.append(f"{requirement}; {marker}") + return requirements + + +def _metadata_content_problems(data: bytes, expected: dict[str, bytes]) -> list[str]: + """Bind installer-facing metadata to this project's static configuration.""" + project = tomllib.loads(expected["pyproject.toml"].decode("utf-8"))["project"] + metadata = BytesParser().parsebytes(data) + wanted = { + "Metadata-Version": ["2.4"], "Name": [project["name"]], + "Version": [project["version"]], "Summary": [project["description"]], + "Requires-Python": [project["requires-python"]], + "Author": [", ".join(author["name"] for author in project["authors"])], + "Classifier": project.get("classifiers", []), + "Description-Content-Type": ["text/markdown"], "License-File": ["LICENSE"], + "Dynamic": ["license-file"], + "Provides-Extra": list(project.get("optional-dependencies", {})), + } + problems = [] + if metadata.defects: + problems.append("malformed wheel METADATA") + allowed = {key.lower() for key in wanted} | {"license", "requires-dist"} + for name in metadata.keys(): + if name.lower() not in allowed: + problems.append(f"unexpected wheel METADATA field {name}") + for name, values in wanted.items(): + if Counter(metadata.get_all(name, [])) != Counter(values): + problems.append(f"wheel METADATA {name} differs from project configuration") + licenses = metadata.get_all("License", []) + if len(licenses) != 1 or " ".join(licenses[0].split()) != " ".join(expected["LICENSE"].decode().split()): + problems.append("wheel METADATA License differs from source license") + if metadata.get_payload(decode=True).rstrip() != expected[project["readme"]].rstrip(): + problems.append("wheel METADATA description differs from source README") + requirements = _project_requirements(project) + try: + if Counter(map(_requirement_key, metadata.get_all("Requires-Dist", []))) != Counter(map(_requirement_key, requirements)): + problems.append("wheel METADATA Requires-Dist differs from project configuration") + except ValueError as error: + problems.append(f"invalid wheel METADATA Requires-Dist: {error}") + return problems + + +def _sdist_metadata_problems(actual: dict[str, bytes], expected: dict[str, bytes]) -> list[str]: + problems = [] + for name in ("PKG-INFO", "src/ucns.egg-info/PKG-INFO"): + if name not in actual: + problems.append(f"missing sdist metadata {name}") + else: + problems.extend(problem.replace("wheel METADATA", f"sdist {name}") + for problem in _metadata_content_problems(actual[name], expected)) + for name, value in (("top_level.txt", b"ucns\n"), ("dependency_links.txt", b"\n")): + if actual.get("src/ucns.egg-info/" + name) != value: + problems.append(f"altered or missing sdist {name}") + sources = actual.get("src/ucns.egg-info/SOURCES.txt", b"").decode("utf-8").splitlines() + if Counter(sources) != Counter(actual.keys() - {"PKG-INFO", "setup.cfg"}): + problems.append("sdist SOURCES.txt differs from archive members") + project = tomllib.loads(expected["pyproject.toml"].decode("utf-8"))["project"] + requirements = [] + marker = "" + try: + for line in actual.get("src/ucns.egg-info/requires.txt", b"").decode("utf-8").splitlines(): + if not line.strip(): + continue + if line.startswith("[") and line.endswith("]"): + extra, _, condition = line[1:-1].partition(":") + marker = condition + if extra: + marker = f'({marker}) and extra == "{extra}"' if marker else f'extra == "{extra}"' + else: + requirements.append(_requirement_key(line + ("; " + marker if marker else ""))) + if Counter(requirements) != Counter(map(_requirement_key, _project_requirements(project))): + problems.append("sdist requires.txt differs from project dependencies") + except (ValueError, UnicodeError): + problems.append("invalid sdist requires.txt") + return problems + + +def _record_problems(actual: dict[str, bytes], record_path: str) -> list[str]: + problems = [] + seen = set() + try: + for row in csv.reader(io.StringIO(actual[record_path].decode("utf-8")), strict=True): + if len(row) != 3: + problems.append("wheel RECORD row must have three fields") + continue + name, digest, size = row + if name in seen or name not in actual: + problems.append(f"wheel RECORD duplicate or unknown path {name}") + continue + seen.add(name) + if name == record_path: + if digest or size: + problems.append("wheel RECORD self-entry must omit hash and size") + else: + expected_digest = base64.urlsafe_b64encode(hashlib.sha256(actual[name]).digest()).rstrip(b"=").decode() + if digest != "sha256=" + expected_digest or size != str(len(actual[name])): + problems.append(f"wheel RECORD digest or size mismatch {name}") + except (UnicodeError, csv.Error) as error: + problems.append(f"malformed wheel RECORD: {error}") + for name in sorted(actual.keys() - seen): + problems.append(f"wheel RECORD missing path {name}") + return problems + + +def _wheel_metadata_problems(actual: dict[str, bytes], expected: dict[str, bytes], directories: set[str]) -> list[str]: + problems: list[str] = [] + prefixes = { + name.split("/", 1)[0] + for name in actual.keys() | directories + if name.split("/", 1)[0].endswith(".dist-info") + } + if len(prefixes) != 1: + return ["wheel must contain exactly one .dist-info directory"] + prefix = next(iter(prefixes)) + project = tomllib.loads(expected["pyproject.toml"].decode("utf-8"))["project"] + expected_prefix = f"{project['name'].replace('-', '_')}-{project['version']}.dist-info" + if prefix != expected_prefix: + problems.append("wheel .dist-info identity differs from project configuration") + allowed = {f"{prefix}/{name}" for name in WHEEL_DIST_INFO_FILES} + allowed.add(f"{prefix}/{WHEEL_LICENSE_PATH}") + for name in sorted(actual): + if name.startswith(f"{prefix}/") and name not in allowed: + problems.append(f"unexpected wheel metadata {name}") + required = {f"{prefix}/{name}" for name in ("METADATA", "WHEEL", "RECORD")} + required.add(f"{prefix}/{WHEEL_LICENSE_PATH}") + for name in sorted(required - actual.keys()): + problems.append(f"missing wheel metadata {name}") + packaged_license = actual.get(f"{prefix}/{WHEEL_LICENSE_PATH}") + if packaged_license is not None and packaged_license != expected["LICENSE"]: + problems.append(f"altered wheel license {prefix}/{WHEEL_LICENSE_PATH}") + if f"{prefix}/METADATA" in actual: + problems.extend(_metadata_content_problems(actual[f"{prefix}/METADATA"], expected)) + if f"{prefix}/WHEEL" in actual: + metadata = BytesParser().parsebytes(actual[f"{prefix}/WHEEL"]) + wanted = {"Wheel-Version": ["1.0"], "Root-Is-Purelib": ["true"], "Tag": ["py3-none-any"]} + if metadata.defects or metadata.get_payload().strip(): + problems.append("malformed wheel WHEEL metadata") + for name, values in wanted.items(): + if metadata.get_all(name, []) != values: + problems.append(f"wheel WHEEL {name} differs from pure Python configuration") + if len(metadata.get_all("Generator", [])) != 1 or not metadata["Generator"].strip(): + problems.append("wheel WHEEL must identify its generator") + if any(name.lower() not in {"wheel-version", "root-is-purelib", "tag", "generator"} for name in metadata.keys()): + problems.append("unexpected wheel WHEEL field") + if f"{prefix}/RECORD" in actual: + problems.extend(_record_problems(actual, f"{prefix}/RECORD")) + if f"{prefix}/top_level.txt" in actual and actual[f"{prefix}/top_level.txt"] != b"ucns\n": + problems.append("altered wheel top_level.txt") + return problems + + +def verify_distributions(root: Path, sdist: Path, wheel: Path) -> list[str]: + expected = expected_files(root.resolve()) + project = tomllib.loads(expected["pyproject.toml"].decode("utf-8"))["project"] + wheel_expected = { + name.removeprefix("src/"): data + for name, data in expected.items() + if name.startswith("src/ucns/") + } + problems: list[str] = [] + try: + name, version, build, tags = parse_wheel_filename(wheel.name) + if name != canonicalize_name(project["name"]) or version != Version(project["version"]) or build or tags != {Tag("py3", "none", "any")}: + problems.append("wheel filename identity or tags differ from project/WHEEL metadata") + name, version = parse_sdist_filename(sdist.name) + if name != canonicalize_name(project["name"]) or version != Version(project["version"]): + problems.append("sdist filename identity differs from project metadata") + except ValueError as error: + problems.append(f"invalid distribution filename: {error}") + for path, inputs, is_wheel in ((sdist, expected, False), (wheel, wheel_expected, True)): + try: + directories: set[str] = set() + actual = read_archive(path, wheel=is_wheel, expected_prefix=None if is_wheel else sdist.name.removesuffix(".tar.gz"), directories=directories) + except (OSError, ValueError, tarfile.TarError, zipfile.BadZipFile) as error: + problems.append(f"{path.name}: {error}") + continue + implied_directories = {parent.as_posix() for name in actual for parent in PurePosixPath(name).parents if parent.as_posix() != "."} + if not is_wheel: + implied_directories.add("") # The single enclosing sdist root. + for name in sorted(directories - implied_directories): + problems.append(f"{path.name}: unexpected directory {name}") + for name, data in inputs.items(): + if name not in actual: + problems.append(f"{path.name}: missing {name}") + elif actual[name] != data: + problems.append(f"{path.name}: altered {name}") + if is_wheel: + metadata_problems = _wheel_metadata_problems(actual, expected, directories) + problems.extend(f"{path.name}: {problem}" for problem in metadata_problems) + dist_prefixes = { + name.split("/", 1)[0] + for name in actual + if "/" in name and name.split("/", 1)[0].endswith(".dist-info") + } + metadata_prefix = next(iter(dist_prefixes), "") + for name in sorted(actual.keys() - inputs.keys()): + if not metadata_prefix or not name.startswith(f"{metadata_prefix}/"): + problems.append(f"{path.name}: unexpected payload {name}") + else: + problems.extend(f"{path.name}: {problem}" for problem in _sdist_metadata_problems(actual, expected)) + if "setup.cfg" not in actual: + problems.append(f"{path.name}: missing generated setup.cfg") + elif actual["setup.cfg"] != GENERATED_SETUP_CFG: + problems.append(f"{path.name}: altered generated setup.cfg") + for name in sorted(actual.keys() - inputs.keys()): + if name not in SDIST_GENERATED: + problems.append(f"{path.name}: unexpected payload {name}") + return problems + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("root", type=Path) + parser.add_argument("dist", type=Path) + args = parser.parse_args() + sdists, wheels = sorted(args.dist.glob("*.tar.gz")), sorted(args.dist.glob("*.whl")) + if len(sdists) != 1 or len(wheels) != 1: + parser.error("expected exactly one sdist and one wheel; use a clean output directory") + problems = verify_distributions(args.root, sdists[0], wheels[0]) + print("\n".join(problems) if problems else "distribution replay inputs: exact") + return int(bool(problems)) + + +if __name__ == "__main__": + raise SystemExit(main()) +# ratios: loc_comments=336:35 imports_exports=17:4 calls_definitions=178:11 diff --git a/tools/verify_skill_lib_contracts.py b/tools/verify_skill_lib_contracts.py index bd3c47c..9d71e46 100644 --- a/tools/verify_skill_lib_contracts.py +++ b/tools/verify_skill_lib_contracts.py @@ -1,3 +1,4 @@ +# ratios: loc_comments=595:54 imports_exports=12:4 calls_definitions=323:23 # === MODULE_BUILD === # id: skill_lib_contract_audit # module_name: verify_skill_lib_contracts @@ -27,7 +28,7 @@ # # id: contract_audit_reports_graph_gaps # given: a contract, check target, or self call is missing or unknown -# then: the audit reports the gap and exits nonzero +# then: the audit reports the gap and exits nonzero, including empty input, malformed syntax/fences, and unsupported class-based tests # class: evidence # since: 2026-07-21 # @@ -38,24 +39,41 @@ # since: 2026-07-21 # === END CONTRACTS === -"""Minimal no-exec skill-lib contract graph audit. +"""No-exec contract reconciliation using the vendored canonical msdmd parser. -The parser is intentionally bounded to the line-oriented msdmd fields used by -this repository. It does not replace skill-lib's canonical universal parser. +Usage: ``python tools/verify_skill_lib_contracts.py .``. Product and test files +are parsed, never imported. Only the pinned parser shipped beside this tool is +loaded. Unsupported class-based test targets are visible gaps, not coverage. """ from __future__ import annotations import ast +import configparser +import importlib.util import re +import shlex import sys from dataclasses import dataclass from pathlib import Path from typing import Dict, Iterable, List, Sequence, Set, Tuple +try: + import tomllib +except ModuleNotFoundError: # Python 3.10 verification dependency. + import tomli as tomllib + BLOCK_RE = re.compile(r"^\s*#\s*===\s*(MODULE_BUILD|CONTRACTS|CHECKS)\s*===\s*$") END_RE = re.compile(r"^\s*#\s*===\s*END\s+(MODULE_BUILD|CONTRACTS|CHECKS)\s*===\s*$") -FIELD_RE = re.compile(r"^\s*#\s*(?P[A-Za-z_][\w-]*):\s*(?P.*)$") +DECLARATION_FENCE_RE = re.compile(r"^\s*#\s*=+\s*(?:END\s+)?(?:MODULE_BUILD|CONTRACTS|CHECKS)\b") +ID_LIKE_RE = re.compile(r"^\s*#\s*id\b") +ID_RE = re.compile(r"^#\s*id:\s*([a-z_][a-z0-9_]*)\s*$") +PARSER_PATH = Path(__file__).resolve().parents[1] / ".agents/skills/msdmd/parsers/universal.py" +_SPEC = importlib.util.spec_from_file_location("_ucns_canonical_msdmd", PARSER_PATH) +if _SPEC is None or _SPEC.loader is None: + raise RuntimeError("canonical msdmd parser unavailable") +_PARSER = importlib.util.module_from_spec(_SPEC) +exec(compile(PARSER_PATH.read_bytes(), str(PARSER_PATH), "exec", dont_inherit=True), _PARSER.__dict__) REQUIRED_MODULE_FIELDS = { "module_name", "module_kind", @@ -74,6 +92,7 @@ } REQUIRED_CONTRACT_FIELDS = {"given", "then"} REQUIRED_CHECK_FIELDS = {"proves", "call", "timeout", "mutates", "cleanup"} +UNKNOWN_TEST_SETTING = object() @dataclass(frozen=True) @@ -88,76 +107,446 @@ def id(self) -> str: def _source_files(root: Path) -> Iterable[Path]: + # Reconcile both updated canonical parser implementations beside their owner. + for name in ("universal.py", "universal.ts"): + parser = root / ".agents/skills/msdmd/parsers" / name + if parser.is_file(): + yield parser for base in (root / "src", root / "tools", root / "tests"): if base.exists(): - yield from sorted(base.rglob("*.py")) + yield from (path for path in sorted(base.rglob("*.py")) if "__pycache__" not in path.parts) def parse_blocks(path: Path) -> List[Entry]: - entries: List[Entry] = [] + """Check fence integrity, then delegate entry grammar to canonical msdmd.""" + text = path.read_text(encoding="utf-8") + marker = _PARSER.marker_for(path) + if marker not in {"#", "//"}: + raise ValueError(f"unsupported declaration source: {path}") active: str | None = None - current: Dict[str, str] | None = None - - for raw in path.read_text(encoding="utf-8").splitlines(): + declarations = 0 + for line in text.splitlines(): + raw = "#" + line[len(marker):] if line.startswith(marker) else line start = BLOCK_RE.match(raw) if start: + if active is not None: + raise ValueError(f"{path}: nested {start.group(1)} inside {active}") active = start.group(1) - current = None continue end = END_RE.match(raw) if end: if active != end.group(1): raise ValueError(f"{path}: mismatched END {end.group(1)}") - if current: - entries.append(Entry(active, path, current)) active = None - current = None - continue - if active is None: - continue - field = FIELD_RE.match(raw) - if not field: continue - key, value = field.group("key"), field.group("value").strip() - if key == "id": - if current: - entries.append(Entry(active, path, current)) - current = {"id": value} - elif current is not None: - current[key] = value - + if DECLARATION_FENCE_RE.match(raw): + raise ValueError(f"{path}: malformed declaration fence: {raw.strip()}") + if active is not None and ID_LIKE_RE.match(raw): + if ID_RE.fullmatch(raw) is None: + raise ValueError(f"{path}: malformed id declaration: {raw.strip()}") + declarations += 1 if active is not None: raise ValueError(f"{path}: unterminated {active} block") + entries = [ + Entry(block, path, fields) + for block in ("MODULE_BUILD", "CONTRACTS", "CHECKS") + for fields in _PARSER.parse_text(text, block, marker=marker) + ] + if len(entries) != declarations: + raise ValueError(f"{path}: declarations lost by canonical parser") return entries +def _target_names(target: ast.AST) -> list[str]: + if isinstance(target, ast.Name): + return [target.id] + if isinstance(target, (ast.Tuple, ast.List)): + return [name for item in target.elts for name in _target_names(item)] + if isinstance(target, ast.Starred): + return _target_names(target.value) + return [] + + +def _is_fixture(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + for decorator in node.decorator_list: + reference = decorator.func if isinstance(decorator, ast.Call) else decorator + if (isinstance(reference, ast.Attribute) and reference.attr == "fixture" + and isinstance(reference.value, ast.Name) and reference.value.id == "pytest"): + return True + return False + + +def _bindings(body: list[ast.stmt]) -> dict[str, str]: + """Track direct namespace bindings; callable aliases remain unsupported.""" + bindings = {} + for node in body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + bindings[node.name] = "class" if isinstance(node, ast.ClassDef) else "fixture" if _is_fixture(node) else "function" + elif isinstance(node, (ast.Assign, ast.AnnAssign)) and node.value is not None: + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + kind = "literal" if isinstance(node.value, (ast.Constant, ast.List, ast.Tuple, ast.Set, ast.Dict)) else "unknown" + for target in targets: + for name in _target_names(target): + bindings[name] = kind + elif isinstance(node, (ast.Import, ast.ImportFrom)): + for alias in node.names: + bindings[alias.asname or alias.name.split(".", 1)[0]] = "literal" if isinstance(node, ast.Import) else "unknown" + elif isinstance(node, ast.AugAssign): + for name in _target_names(node.target): + bindings[name] = "unknown" + elif isinstance(node, ast.Delete): + for target in node.targets: + for name in _target_names(target): + bindings.pop(name, None) + return bindings + + +_COMPOUND_STATEMENTS = (ast.If, ast.For, ast.AsyncFor, ast.While, ast.Try, ast.With, ast.AsyncWith, ast.Match, getattr(ast, "TryStar", ast.Try)) + + +def _header_bindings(node: ast.AST, *, named_only: bool = False) -> set[str]: + """Include assignment targets in statement headers, without executing them.""" + names: set[str] = set() + if isinstance(node, ast.NamedExpr): + names.update(_target_names(node.target)) + for field, value in ast.iter_fields(node): + if field in {"body", "orelse", "finalbody"}: + continue + values = value if isinstance(value, list) else [value] + for item in values: + if isinstance(item, ast.AST): + if not named_only and isinstance(item, ast.Name) and isinstance(item.ctx, ast.Store): + names.add(item.id) + if not named_only and isinstance(item, (ast.MatchAs, ast.MatchStar, ast.ExceptHandler)) and item.name: + names.add(item.name) + if not named_only and isinstance(item, ast.MatchMapping) and item.rest: + names.add(item.rest) + names.update(_header_bindings(item, named_only=named_only)) + return names + + +def _conditional_test_names(body: list[ast.stmt]) -> set[str]: + """Find possible module/class test bindings without entering function bodies.""" + names: set[str] = set() + for node in body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if node.name.startswith("test"): + names.add(node.name) + elif isinstance(node, ast.ClassDef): + found, setting = _test_setting(node) + if not (found and setting is False) and (node.name.startswith("Test") or node.bases or setting is True): + names.add(node.name) + elif isinstance(node, ast.If) and isinstance(node.test, ast.Constant): + names.update(_conditional_test_names(node.body if node.test.value else node.orelse)) + elif isinstance(node, _COMPOUND_STATEMENTS): + groups = [getattr(node, name, []) for name in ("body", "orelse", "finalbody")] + groups.extend(handler.body for handler in getattr(node, "handlers", [])) + groups.extend(case.body for case in getattr(node, "cases", [])) + for group in groups: + names.update(_conditional_test_names(group)) + else: + for name, kind in _bindings([node]).items(): + if name in {"*", "__test__"} or name.startswith("test") and kind != "literal" or name.startswith("Test") and kind == "unknown": + names.add(name) + header = _header_bindings(node, named_only=not isinstance(node, _COMPOUND_STATEMENTS)) + names.update(name for name in header if name == "__test__" or name.startswith(("test", "Test"))) + return names + + +def _conditional_surface(body: list[ast.stmt]) -> set[str]: + names = _conditional_test_names([node for node in body if isinstance(node, _COMPOUND_STATEMENTS)]) + for node in body: + names.update(name for name in _header_bindings(node, named_only=True) if name == "__test__" or name.startswith(("test", "Test"))) + return names + + +def _class_mro(name: str, classes: dict[str, ast.ClassDef], active=()) -> list[str] | None: + """Compute local C3 order; unresolved bases and inconsistent orders are gaps.""" + if name == "object": + return [name] + if name not in classes or name in active: + return None + cls = classes[name] + if any(not isinstance(base, ast.Name) for base in cls.bases): + return None + bases = [base.id for base in cls.bases] or ["object"] + if len(set(bases)) != len(bases): + return None + parents = [_class_mro(base, classes, (*active, name)) for base in bases] + if any(parent is None for parent in parents): + return None + sequences = [list(parent) for parent in parents] + [list(bases)] + result = [name] + while any(sequences): + candidates = [sequence[0] for sequence in sequences if sequence] + candidate = next((head for head in candidates if all(head not in sequence[1:] for sequence in sequences)), None) + if candidate is None: + return None + result.append(candidate) + for sequence in sequences: + if sequence and sequence[0] == candidate: + sequence.pop(0) + return result + + +def _test_setting(cls: ast.ClassDef | ast.Module) -> tuple[bool, object]: + found, value = False, None + for node in cls.body: + if isinstance(node, (ast.Assign, ast.AnnAssign)) and node.value is not None: + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + if any("__test__" in _target_names(target) for target in targets): + found = True + if any("__test__" in _target_names(target) and not isinstance(target, ast.Name) for target in targets): + value = UNKNOWN_TEST_SETTING + continue + try: + value = ast.literal_eval(node.value) + except (ValueError, TypeError, SyntaxError): + value = UNKNOWN_TEST_SETTING + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and node.name == "__test__": + found, value = True, UNKNOWN_TEST_SETTING + elif isinstance(node, (ast.Import, ast.ImportFrom)) and "__test__" in _bindings([node]): + found, value = True, UNKNOWN_TEST_SETTING + elif isinstance(node, ast.AugAssign) and "__test__" in _target_names(node.target): + found, value = True, UNKNOWN_TEST_SETTING + elif isinstance(node, ast.Delete) and any("__test__" in _target_names(target) for target in node.targets): + found, value = False, None + return found, value + + def _defined_functions(path: Path) -> Set[str]: """Return top-level functions without importing or executing the module.""" tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - return { - node.name - for node in tree.body - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) - } + found, setting = _test_setting(tree) + if setting is UNKNOWN_TEST_SETTING or found and not setting: + return set() + counts = {} + for statement in tree.body: + for name in _bindings([statement]): + counts[name] = counts.get(name, 0) + 1 + return {name for name, kind in _bindings(tree.body).items() if kind == "function" and counts[name] == 1} def _missing(fields: Dict[str, str], required: Set[str]) -> Set[str]: return {name for name in required if not fields.get(name)} +def _collection_config_problems(root: Path) -> list[str]: + """The no-exec graph supports the repository's bounded default collection.""" + problems = [] + alternatives = {"pytest.toml", ".pytest.toml", "pytest.ini", ".pytest.ini", "tox.ini", "setup.cfg"} + for base in (root, root / "tests"): + if not base.is_dir(): + continue + paths = base.iterdir() if base == root else base.rglob("*") + for path in paths: + if path.is_file() and path.name == "pyproject.toml" and path.parent != root: + problems.append(f"GAP nested pytest configuration: {path}") + if path.is_file() and path.name == "conftest.py": + problems.append(f"GAP unsupported conftest collection/plugin surface: {path}") + if path.is_file() and path.name in alternatives: + if path.name in {"setup.cfg", "tox.ini"}: + try: + parser = configparser.ConfigParser(interpolation=None) + parser.read_string(path.read_text(encoding="utf-8")) + section = "tool:pytest" if path.name == "setup.cfg" else "pytest" + if not parser.has_section(section): + continue # Setuptools emits setup.cfg with only egg_info. + except (OSError, UnicodeError, configparser.Error) as error: + problems.append(f"GAP invalid pytest collection configuration: {path}: {error}") + continue + problems.append(f"GAP unsupported pytest collection configuration: {path}; use root pyproject.toml with default collection") + path = root / "pyproject.toml" + if not path.exists(): + problems.append("GAP pytest collection requires explicit testpaths = ['tests'] in root pyproject.toml") + return problems + try: + document = tomllib.loads(path.read_text(encoding="utf-8")) + section = document.get("tool", {}).get("pytest", {}) + if set(section) - {"ini_options"}: + problems.append(f"GAP unsupported native pytest collection configuration: {path}") + config = section.get("ini_options", {}) + if "testpaths" not in config: + problems.append("GAP pytest collection requires explicit testpaths = ['tests']") + if config.get("required_plugins"): + problems.append("GAP unsupported pytest plugin collection configuration") + defaults = {"python_files": ["test_*.py", "*_test.py"], "python_classes": ["Test"], "python_functions": ["test"], "testpaths": ["tests"]} + unknown = set(config) - set(defaults) - {"addopts", "collect_imported_tests"} + if unknown: + problems.append(f"GAP unsupported pytest collection settings: {', '.join(sorted(unknown))}") + if config.get("collect_imported_tests") is not False: + problems.append("GAP pytest collection requires explicit collect_imported_tests = false") + for name, expected in defaults.items(): + if name in config: + actual = config[name].split() if isinstance(config[name], str) else config[name] + if actual != expected: + problems.append(f"GAP unsupported pytest collection setting {name}: {actual!r}") + options = config.get("addopts", []) + options = shlex.split(options) if isinstance(options, str) else options + if not isinstance(options, list) or any(option not in {"-q", "-v", "-vv", "-ra", "--strict-markers", "--strict-config"} for option in options): + problems.append("GAP unsupported pytest addopts; collection-changing arguments are outside the audited boundary") + except (OSError, UnicodeError, ValueError, AttributeError, TypeError) as error: + problems.append(f"GAP invalid pytest collection configuration: {error}") + return problems + + +def _collection_surface_problems(tree: ast.Module, path: Path) -> list[str]: + """Reject collection metaprogramming instead of guessing its effects.""" + def surface(node): + yield node + for field, value in ast.iter_fields(node): + if field == "body" and isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + continue + for child in value if isinstance(value, list) else [value]: + if isinstance(child, ast.AST): + yield from surface(child) + nodes = list(surface(tree)) + imports = {} + for node in tree.body: + if isinstance(node, (ast.Import, ast.ImportFrom)): + for alias in node.names: + imports[alias.asname or alias.name] = (node.module, alias.name) if isinstance(node, ast.ImportFrom) else (alias.name, None) + rebound = {node.id for node in nodes if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store)} + rebound.update(node.name for node in nodes if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))) + binding_counts = {} + for node in nodes: + if isinstance(node, ast.stmt): + for name in _bindings([node]): + binding_counts[name] = binding_counts.get(name, 0) + 1 + path_bound = imports.get("Path") == ("pathlib", "Path") and "Path" not in rebound and binding_counts.get("Path") == 1 and "__file__" not in binding_counts + pytest_bound = imports.get("pytest") == ("pytest", None) and "pytest" not in rebound and binding_counts.get("pytest") == 1 + def pytest_decorator(reference): + if not pytest_bound or not isinstance(reference, ast.Attribute): + return False + if isinstance(reference.value, ast.Name): + return reference.value.id == "pytest" and reference.attr == "fixture" + parent = reference.value + return isinstance(parent, ast.Attribute) and parent.attr == "mark" and isinstance(parent.value, ast.Name) and parent.value.id == "pytest" and not reference.attr.startswith("_") + def safe_call(node): + spelling = ast.unparse(node.func) + if path_bound and spelling == "Path": + return len(node.args) == 1 and isinstance(node.args[0], ast.Name) and node.args[0].id == "__file__" and not node.keywords + if path_bound and spelling == "Path(__file__).resolve": + return not node.args and not node.keywords + if not pytest_decorator(node.func): + return False + try: + arguments = [ast.literal_eval(value) for value in node.args] + keywords = {item.arg: ast.literal_eval(item.value) for item in node.keywords} + except (ValueError, TypeError, SyntaxError): + return False + if None in keywords: + return False + if node.func.attr in {"skipif", "xfail"}: + conditions = [*arguments, *([keywords["condition"]] if "condition" in keywords else [])] + if any(type(value) is not bool for value in conditions): + return False # String conditions are executable expressions. + return True + def literal(node): + try: + ast.literal_eval(node) + return True + except (ValueError, TypeError, SyntaxError): + return False + path_constants = set() + def source_path(node): + if isinstance(node, ast.Name): + return node.id in path_constants + if isinstance(node, ast.Call): + return path_bound and ast.unparse(node.func) in {"Path", "Path(__file__).resolve"} and safe_call(node) + if isinstance(node, ast.Attribute): + return node.attr == "parent" and source_path(node.value) + if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Attribute) and node.value.attr == "parents": + return source_path(node.value.value) and isinstance(node.slice, ast.Constant) and type(node.slice.value) is int and node.slice.value >= 0 + return (isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div) + and source_path(node.left) and isinstance(node.right, ast.Constant) and isinstance(node.right.value, str)) + problems = [] + for statement in tree.body: + if isinstance(statement, (ast.Assign, ast.AnnAssign)) and statement.value is not None: + targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target] + if source_path(statement.value): + path_constants.update(name for target in targets for name in _target_names(target) if binding_counts.get(name) == 1) + classes = {node.name: node for node in tree.body if isinstance(node, ast.ClassDef)} + postponed_annotations = any(isinstance(node, ast.ImportFrom) and node.module == "__future__" and any(alias.name == "annotations" for alias in node.names) for node in tree.body) + implicit_hooks = {"setup_module", "teardown_module", "setup_function", "teardown_function", + "setup_class", "teardown_class", "setup_method", "teardown_method", + "setUpModule", "tearDownModule"} + for node in nodes: + if isinstance(node, ast.stmt) and not isinstance(node, (ast.Import, ast.ImportFrom, ast.Assign, ast.AnnAssign, ast.Expr, ast.Pass, ast.If, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + problems.append(f"GAP unsupported collection-time statement: {path}:{node.lineno}") + if isinstance(node, ast.AnnAssign) and not postponed_annotations and not isinstance(node.annotation, (ast.Name, ast.Constant)): + problems.append(f"GAP deferred annotations required for compound type expressions: {path}:{node.lineno}") + names = set(_bindings([node])) if isinstance(node, ast.stmt) else set() + names.update(_header_bindings(node, named_only=not isinstance(node, _COMPOUND_STATEMENTS))) + if any(name.startswith("pytest_") or name == "pytestmark" or name in implicit_hooks for name in names): + problems.append(f"GAP unsupported implicit pytest hook: {path}:{node.lineno}") + if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign, ast.Delete)): + targets = node.targets if isinstance(node, (ast.Assign, ast.Delete)) else [node.target] + if any("__test__" in _target_names(target) and not isinstance(target, ast.Name) for target in targets): + problems.append(f"GAP destructured collection opt-out: {path}:{node.lineno}") + if any(isinstance(item, (ast.Subscript, ast.Attribute)) for target in targets for item in ast.walk(target)): + problems.append(f"GAP indirect test-namespace mutation: {path}:{node.lineno}") + if isinstance(node, (ast.Assign, ast.AnnAssign)) and node.value is not None and not (literal(node.value) or source_path(node.value)): + problems.append(f"GAP unresolved collection-time value: {path}:{node.lineno}; use literal data or source-path constants") + if isinstance(node, ast.Expr) and not literal(node.value): + problems.append(f"GAP unsupported collection-time expression: {path}:{node.lineno}") + if isinstance(node, _COMPOUND_STATEMENTS) and not (isinstance(node, ast.If) and isinstance(node.test, ast.Constant) and type(node.test.value) is bool): + problems.append(f"GAP unsupported collection-time control flow: {path}:{node.lineno}") + if isinstance(node, ast.Call) and not safe_call(node): + problems.append(f"GAP unsupported collection-time call: {path}:{node.lineno}; move execution into fixtures or checks") + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if any(not (literal(value) or source_path(value)) for value in [*node.args.defaults, *(value for value in node.args.kw_defaults if value is not None)]): + problems.append(f"GAP unresolved collection-time default: {path}:{node.lineno}") + annotations = [node.returns, *(argument.annotation for argument in [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs, *([node.args.vararg] if node.args.vararg else []), *([node.args.kwarg] if node.args.kwarg else [])])] + if not postponed_annotations and any(value is not None and not isinstance(value, (ast.Name, ast.Constant)) for value in annotations): + problems.append(f"GAP deferred annotations required for compound type expressions: {path}:{node.lineno}") + for decorator in node.decorator_list: + reference = decorator.func if isinstance(decorator, ast.Call) else decorator + if not pytest_decorator(reference): + problems.append(f"GAP unsupported collection-time decorator: {path}:{decorator.lineno}") + if isinstance(node, ast.ClassDef) and node.keywords: + problems.append(f"GAP unsupported class construction keywords: {path}:{node.lineno}") + if isinstance(node, ast.ClassDef): + ambiguous = binding_counts.get(node.name) != 1 or any(isinstance(base, ast.Name) and binding_counts.get(base.id, 0) > 1 for base in node.bases) + if ambiguous or _class_mro(node.name, classes) is None or any(isinstance(base, ast.Name) and base.id == "object" and "object" in binding_counts for base in node.bases): + problems.append(f"GAP unresolved collection-time class base: {path}:{node.lineno}") + for statement in node.body: + if isinstance(statement, (ast.Assign, ast.AnnAssign)): + if statement.value is not None: + try: + ast.literal_eval(statement.value) + except (ValueError, TypeError, SyntaxError): + problems.append(f"GAP unresolved class namespace value (possible descriptor): {path}:{statement.lineno}") + elif not isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Pass)): + if not (isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Constant) and isinstance(statement.value.value, str)): + problems.append(f"GAP unsupported class namespace statement: {path}:{statement.lineno}") + if node.name in {"__getattr__", "__getattribute__", "__dir__", "__init_subclass__", "__set_name__"}: + problems.append(f"GAP unsupported collection-time namespace protocol: {path}:{node.lineno}") + return problems + + def audit_repository(root: Path) -> Tuple[bool, List[str]]: + root = root.resolve() entries: List[Entry] = [] - problems: List[str] = [] - for path in _source_files(root): + problems: List[str] = _collection_config_problems(root) + paths = tuple(_source_files(root)) + trees: Dict[Path, ast.Module] = {} + if not paths: + problems.append(f"GAP empty source/test tree: {root}") + for path in paths: try: + if path.suffix == ".py": + trees[path] = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) path_entries = parse_blocks(path) - except (SyntaxError, ValueError) as exc: - problems.append(f"GAP parse {exc}") + except (OSError, UnicodeError, SyntaxError, ValueError) as exc: + problems.append(f"GAP parse {type(exc).__name__}: {exc}") continue entries.extend(path_entries) - if path.is_relative_to(root / "src") or path.is_relative_to(root / "tools"): + if (path.is_relative_to(root / "src") or path.is_relative_to(root / "tools") + or path.is_relative_to(root / ".agents/skills/msdmd/parsers")): declared = {entry.block for entry in path_entries} for required_block in ("MODULE_BUILD", "CONTRACTS"): if required_block not in declared: @@ -182,6 +571,8 @@ def audit_repository(root: Path) -> Tuple[bool, List[str]]: contracts = {entry.id: entry for entry in entries if entry.block == "CONTRACTS"} checks = [entry for entry in entries if entry.block == "CHECKS"] + if not contracts or not checks: + problems.append("GAP empty contract/check graph cannot establish evidence") proved: Set[str] = set() for check in checks: @@ -204,9 +595,9 @@ def audit_repository(root: Path) -> Tuple[bool, List[str]]: else: is_test_module = ( check.source.is_relative_to(root / "tests") - and check.source.name.startswith("test_") + and (check.source.name.startswith("test_") or check.source.name.endswith("_test.py")) ) - if not is_test_module or not name.startswith("test_"): + if not is_test_module or not name.startswith("test"): problems.append( f"GAP {check.id} call does not target an executable pytest test: {call}" ) @@ -216,15 +607,84 @@ def audit_repository(root: Path) -> Tuple[bool, List[str]]: for contract_id in sorted(set(contracts) - proved): problems.append(f"GAP {contract_id} has no CHECKS entry claiming to prove it") - for test_path in sorted((root / "tests").rglob("test_*.py")) if (root / "tests").exists() else (): + for test_path, tree in trees.items(): + if test_path.is_relative_to(root / "tests") and any(isinstance(node, ast.Name) and node.id == "pytest_plugins" for node in ast.walk(tree)): + problems.append(f"GAP unsupported pytest plugin collection surface: {test_path}") + if test_path.is_relative_to(root / "tests"): + for node in ast.walk(tree): + names = ([node.module] if isinstance(node, ast.ImportFrom) and node.module and not node.level + else [alias.name for alias in node.names] if isinstance(node, ast.Import) else []) + for name in names: + top = name.split(".", 1)[0] + if top not in {"src", "tools", "tests"} and ((root / (top + ".py")).exists() or (root / top).is_dir()): + problems.append(f"GAP imported root helper outside audited source layout: {name} in {test_path}") + problems.extend(_collection_surface_problems(tree, test_path)) + if not test_path.is_relative_to(root / "tests") or not ( + test_path.name.startswith("test_") or test_path.name.endswith("_test.py") + ): + continue declared_calls = { entry.fields.get("call", "")[len("self::") :] for entry in checks if entry.source == test_path and entry.fields.get("call", "").startswith("self::") } - for function in _defined_functions(test_path): - if function.startswith("test_") and function not in declared_calls: - problems.append(f"GAP executable check {test_path}::{function} has no resolving CHECKS declaration") + found, setting = _test_setting(tree) + conditional = _conditional_surface(tree.body) + if found and not setting and "__test__" not in conditional: + continue + for name in sorted(conditional): + problems.append(f"GAP conditional test binding {test_path}::{name}; use direct module-level test definitions") + if setting is UNKNOWN_TEST_SETTING: + problems.append(f"GAP dynamic test-module opt-out {test_path}") + bindings = _bindings(tree.body) + for name, kind in bindings.items(): + if name == "*": + problems.append(f"GAP unresolved wildcard test-module import {test_path}") + elif name.startswith("test"): + if kind == "unknown": + problems.append(f"GAP unresolved executable alias {test_path}::{name}") + elif kind == "function" and name not in declared_calls: + problems.append(f"GAP executable check {test_path}::{name} has no resolving CHECKS declaration") + elif name.startswith("Test") and kind == "unknown": + problems.append(f"GAP unresolved class alias {test_path}::{name}") + classes = {node.name: node for node in tree.body if isinstance(node, ast.ClassDef) and bindings.get(node.name) == "class"} + for cls in classes.values(): + found, setting = _test_setting(cls) + order = _class_mro(cls.name, classes) + conditional_opt_out = any("__test__" in _conditional_surface(classes[name].body) for name in (order or [cls.name]) if name in classes) + if conditional_opt_out: + problems.append(f"GAP conditional class opt-out {test_path}::{cls.name}") + if not found and order is not None: + for ancestor in order[1:]: + if ancestor in classes: + found, setting = _test_setting(classes[ancestor]) + if found: + break + if found and not setting and not conditional_opt_out: + continue + if setting is UNKNOWN_TEST_SETTING: + problems.append(f"GAP dynamic class opt-out {test_path}::{cls.name}") + continue + if order is None: + # unittest.TestCase collection does not require a Test prefix. + # Unknown external bases can carry executable tests under any name. + problems.append(f"GAP inherited class check {test_path}::{cls.name}; unresolved base surface") + continue + if not cls.name.startswith("Test") and setting is not True: + continue + inherited = [classes[name] for name in order if name in classes] + for ancestor in inherited: + for name in sorted(_conditional_surface(ancestor.body)): + problems.append(f"GAP conditional class check {test_path}::{cls.name}::{name}") + if any(isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in {"__init__", "__new__"} for ancestor in inherited for node in ancestor.body): + continue + methods = {} + for ancestor in reversed(inherited): + methods.update(_bindings(ancestor.body)) + for name, kind in methods.items(): + if name.startswith("test") and kind in {"function", "unknown"}: + label = "inherited class check" if cls.bases else "unsupported class check" + problems.append(f"GAP {label} {test_path}::{cls.name}::{name}; use a declared top-level self::test_fn witness") return not problems, problems @@ -243,3 +703,4 @@ def main(argv: Sequence[str] | None = None) -> int: if __name__ == "__main__": raise SystemExit(main()) +# ratios: loc_comments=595:54 imports_exports=12:4 calls_definitions=323:23 diff --git a/uv.lock b/uv.lock index e1aaa89..bb30689 100644 --- a/uv.lock +++ b/uv.lock @@ -805,70 +805,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, ] -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - [[package]] name = "readme-renderer" version = "45.0" @@ -1054,11 +990,9 @@ dependencies = [ [package.optional-dependencies] build = [ { name = "build" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "twine" }, ] -lexical = [ - { name = "pyyaml" }, -] research = [ { name = "mpmath" }, { name = "sympy" }, @@ -1067,6 +1001,7 @@ test = [ { name = "mpmath" }, { name = "pytest" }, { name = "sympy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] [package.metadata] @@ -1076,13 +1011,14 @@ requires-dist = [ { name = "mpmath", marker = "extra == 'research'", specifier = ">=1.3,<2" }, { name = "mpmath", marker = "extra == 'test'", specifier = ">=1.3,<2" }, { name = "numpy", specifier = ">=1.24" }, - { name = "pytest", marker = "extra == 'test'", specifier = ">=8" }, - { name = "pyyaml", marker = "extra == 'lexical'", specifier = ">=6,<7" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=9.1" }, { name = "sympy", marker = "extra == 'research'", specifier = ">=1.12,<2" }, { name = "sympy", marker = "extra == 'test'", specifier = ">=1.12,<2" }, + { name = "tomli", marker = "python_full_version < '3.11' and extra == 'build'", specifier = ">=2" }, + { name = "tomli", marker = "python_full_version < '3.11' and extra == 'test'", specifier = ">=2" }, { name = "twine", marker = "extra == 'build'", specifier = ">=5" }, ] -provides-extras = ["test", "research", "lexical", "build"] +provides-extras = ["test", "research", "build"] [[package]] name = "urllib3"