From 702308047f8ac6701da545928f28668d2fe7fe10 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:42:36 -0400 Subject: [PATCH] chore(ci): class every tests/ target and run all but the unit ones Unit tests are banned in this repository. tools/ci/test-targets.txt classes each tests/*.rs file as real, contract, hardening or unit, with its reason; tools/ci/run_test_targets.sh runs every class but unit, then the documentation examples, and fails on a file the manifest does not class. CI's Test job runs it in place of a bare `cargo test`, under the same job names. The inline #[cfg(test)] suites in src/ stop running with it. The inline test that kept fixed_entity_label and decode_fixed_entity_body in step is replaced by tools/ci/dispatch_arms.py, which reads both lists from src/entities/dispatch.rs, in CI's Spec coverage job. CLAUDE.md, CONTRIBUTING.md, the PR template, AGENTS.md, ARCHITECTURE.md, docs/EXTENDING_DECODERS.md and RELEASE.md say unit tests are banned. --- .github/PULL_REQUEST_TEMPLATE.md | 4 +- .github/workflows/ci.yml | 23 +++- AGENTS.md | 2 +- ARCHITECTURE.md | 34 ++++-- CLAUDE.md | 16 +-- CONTRIBUTING.md | 36 +++++- RELEASE.md | 2 +- docs/EXTENDING_DECODERS.md | 9 +- tools/ci/dispatch_arms.py | 193 +++++++++++++++++++++++++++++++ tools/ci/run_test_targets.sh | 84 ++++++++++++++ tools/ci/test-targets.txt | 76 ++++++++++++ 11 files changed, 446 insertions(+), 33 deletions(-) create mode 100755 tools/ci/dispatch_arms.py create mode 100755 tools/ci/run_test_targets.sh create mode 100644 tools/ci/test-targets.txt diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index eed4fd63..883d3a87 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -20,9 +20,9 @@ from `cargo run --release --example coverage_report -- `. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f8a6b35..40d3410a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,10 +73,23 @@ jobs: - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 with: key: ${{ matrix.os }}-${{ matrix.toolchain }} + - name: Every tests/*.rs target is classified + shell: bash + run: tools/ci/run_test_targets.sh --check-only - name: cargo build --release run: cargo +${{ matrix.toolchain }} build --release --all-features --all-targets - - name: cargo test --release - run: cargo +${{ matrix.toolchain }} test --release --all-features + # Unit tests are banned: tools/ci/test-targets.txt classes every + # tests/*.rs target as real, contract, hardening, cli or unit, and the + # script runs all but unit, then the documentation examples. It fails + # on a target the manifest does not class, so a new one cannot run or + # skip unnoticed. The inline #[cfg(test)] suites in src/ are not run. + # Corpus-reading targets skip here (no ../../samples) and run in the + # Corpus (public DWG) job. + - name: Real-file, contract and hardening targets, and the documentation examples + shell: bash + env: + RUSTUP_TOOLCHAIN: ${{ matrix.toolchain }} + run: tools/ci/run_test_targets.sh --release --all-features - name: The specification's worked examples (#107) run: cargo +${{ matrix.toolchain }} run --release --all-features --example spec_examples @@ -603,6 +616,12 @@ jobs: # up-to-date off, so two PRs can each add the same `## 7x.`. - name: ARCHITECTURE.md's numbered sections run in order run: python3 tools/maintainer/train/arch.py check + # The inline test that pinned fixed_entity_label to + # decode_fixed_entity_body was a unit test and no longer runs; this + # reads both lists from src/entities/dispatch.rs, so a new decoder that + # adds its arm to one and not the other fails here. + - name: The fixed-code dispatcher's label list and body arms agree + run: python3 tools/ci/dispatch_arms.py --check pii-guard: name: PII guard diff --git a/AGENTS.md b/AGENTS.md index 52b84e50..cda9e1d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,4 +5,4 @@ The repository's guidance for every coding agent is in `CLAUDE.md`; read it firs ## Review guidelines - dwg-rs is a clean-room implementation (`CLEANROOM.md`). Never cite, quote, link or paraphrase LibreDWG, the ODA/Teigha SDK, an Autodesk SDK or any other GPL or proprietary DWG implementation in a review comment, and never suggest consulting one. Ground a finding in the ODA *Open Design Specification for .dwg files* v5.4.1 (cite its section), Autodesk's public *DXF Reference*, AutoCAD's own DWG and DXF output in the pinned corpus (`tools/corpus/pins.tsv`), or this repository's code. -- Do not ask for unit tests. The project adds none; it proves behaviour on the pinned public corpus, the DXF oracle and the end-to-end verifiers, so ask for that evidence instead. +- Do not ask for unit tests: they are banned here (`CLAUDE.md`). The project adds none; it proves behaviour on the pinned public corpus, the DXF oracle and the end-to-end verifiers, so ask for that evidence instead. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1975f0ad..d32e8465 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -176,9 +176,11 @@ bit granularity. Key methods: | `read_ms` | Two-byte modular stream | `u64` (unsigned) | | `read_handle` | 4-bit code + 4-bit counter + payload | `Handle { code, value }` | -The `BitWriter` methods mirror each `read_*` method exactly. Property -tests in `tests/proptest_roundtrip.rs` lock the invariant that -every primitive round-trips bit-exactly. +The `BitWriter` methods mirror each `read_*` method exactly. Every +primitive round-trips bit-exactly; CI exercises that end to end, because +the committed canonical fixtures are written with `BitWriter` and read back +by the reader, and `tests/proptest_roundtrip.rs` (class `unit`, not run) +states it as a property. ## 5. LZ77 decompression (spec §4.7) @@ -5141,9 +5143,13 @@ input to a full `DwgFile::to_bytes()` once Stages 2-5 are completed. ## 10. Test strategy -Evidence comes from real executables run on real files. New work is -shown on the corpus, not with new unit tests; the suites already in -the tree stay and must keep passing. +Evidence comes from real executables run on real files. Unit tests +are banned: none are added, and none run in CI. `tools/ci/test-targets.txt` +classes every file under `tests/` as `real`, `cli`, `contract`, +`hardening` or `unit`; the Test job runs all but `unit` +(`tools/ci/run_test_targets.sh`), and fails on a file the manifest +does not class. The inline `#[cfg(test)]` suites in `src/` are being +deleted area by area, and git history is the archive. 1. **Self-validating decoders.** Every entity and object decoder must end exactly on its record's data-stream boundary (§7i), so a wrong @@ -5161,15 +5167,21 @@ the tree stay and must keep passing. generator, the coverage report must stay above its floor, and the class-census and reference-closure probes must pass. CI does not read the real corpus yet (#95). -4. **Property, golden and fuzz checks.** `tests/proptest_roundtrip.rs` - round-trips the bit-level primitives, `tests/integration_svg_goldens.rs` - pins SVG output, and `fuzz/` runs nightly against every byte-level - attack surface. +4. **Fuzz and contract checks.** `tests/integration_fuzz_corpus_regression.rs` + replays every fuzz seed through its entry point (class `hardening`), + `tests/code_table_agreement.rs` checks the object-type tables against + the specification's Table 4 (class `contract`), + `tools/ci/dispatch_arms.py` keeps the fixed-code dispatcher's label list + and body arms in step, and `fuzz/` runs nightly against every byte-level + attack surface. `tests/proptest_roundtrip.rs` and + `tests/integration_svg_goldens.rs` are classed `unit`: kept as source, + not run. 5. **Streaming E2E.** `tools/streaming/verify.py` drives the built `stream_header` example in separate processes under generated chunk schedules (docs/streaming/README.md). -`cargo test --release --all-features` must pass before a PR merges. +`tools/ci/run_test_targets.sh --release --all-features` must pass before +a PR merges. ## 11. Legal posture diff --git a/CLAUDE.md b/CLAUDE.md index 5863a751..a66c0653 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,22 +11,22 @@ dwg-rs is an Apache-2.0 Rust reader (and early writer) for Autodesk DWG, R13 to ```sh cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings -cargo test --release --all-features +tools/ci/run_test_targets.sh --release --all-features # every non-unit target of tools/ci/test-targets.txt, then the doc tests RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features cargo deny check advisories bans licenses sources # CI pins cargo-deny 0.19.4 CARGO_TARGET_DIR=target-msrv cargo +1.85 check --all-features # MSRV without evicting the stable cache python3 tools/ci/spec_coverage.py --check # docs/spec-coverage.md agrees with its data +python3 tools/ci/dispatch_arms.py --check # fixed_entity_label and decode_fixed_entity_body agree ``` -CI also builds with `RUSTFLAGS="-D warnings"`, so a warning anywhere fails the Test job. +CI also builds with `RUSTFLAGS="-D warnings"`, so a warning anywhere fails the Test job. The Test job runs `tools/ci/run_test_targets.sh`, not a bare `cargo test`: it runs the `real`, `contract` and `hardening` targets of `tools/ci/test-targets.txt` and the documentation examples, never the `unit` ones and never the inline `#[cfg(test)]` suites in `src/`. -Single tests: +Single targets: ```sh +tools/ci/run_test_targets.sh --check-only # every tests/*.rs file is classified cargo test --release --all-features --test canonical_corpus # one integration file (tests/.rs) cargo test --release --all-features --test samples arc_r14_is_ac1014 # one test in it -cargo test --release --all-features --lib entities::line # lib tests by module path filter -UPDATE_GOLDENS=1 cargo test --release --test integration_svg_goldens # regenerate tests/golden/svg ``` The binaries (`dwg-info`, `dwg-corpus`, `dwg-dump`, `dwg-convert`, `dwg-to-dxf`, `dwg-to-gltf`, `dwg-write`) all have `required-features = ["cli"]`; plain `cargo build` builds none of them: @@ -74,7 +74,7 @@ python3 -I tools/corpus/measure.py check --results r.json # every drawing The `Corpus (public DWG)` job runs exactly this and fails on any count change in either direction, so a PR that changes a decoded, skipped or errored count must commit the regenerated baseline (`python3 -I tools/corpus/measure.py baseline --results r.json` after the `run` above, or the `corpus-measurement` artifact the job uploads). Streaming E2E reads its own pinned nextgis files (`tools/streaming/*-corpus.json`, cross-checked against `pins.tsv`). -The older real-file integration tests (`tests/samples.rs`, `block_names.rs`, `corpus_roundtrip.rs`, `r2013_entity_values.rs`) read a 19-file subset at `../../samples` relative to the crate root and **skip silently** when it is absent, so a green local `cargo test` says nothing about real drawings unless that directory exists. `fetch.py --legacy-samples ../../samples` writes exactly that subset there; from a worktree, `../../samples` resolves against the worktree's own path. +The older real-file integration tests (`tests/samples.rs`, `block_names.rs`, `corpus_roundtrip.rs`, `r2013_entity_values.rs`) read a 19-file subset at `../../samples` relative to the crate root and **skip silently** when it is absent, so a green run of them says nothing about real drawings unless that directory exists. `fetch.py --legacy-samples ../../samples` writes exactly that subset there; from a worktree, `../../samples` resolves against the worktree's own path. Coverage numbers come from two places, and each says which: the headline tables in `README.md` and `STATUS.md` cite the corpus job's run on `main`, while older sections (STATUS.md's 2026-08-30 baseline, the evidence behind `closes` states in `docs/spec-coverage.md`) are dated local measurements of the 19-file subset. Never estimate or carry a number forward: re-measure, and cite the run or the command and date. @@ -100,8 +100,8 @@ Invariants across the crate: `#![forbid(unsafe_code)]`; malformed input returns - **Clean-room** (`CLEANROOM.md`): never consult or import LibreDWG, the ODA/Teigha SDK, Autodesk SDKs or any GPL DWG source. Algorithm-description comments in MIT, Apache or BSD projects may resolve a spec ambiguity only with a dated disclosure added to `CLEANROOM.md`. Every PR body carries the source-provenance declaration from the PR template. - **Cite the spec by v5.4.1 number.** Objects are §20.4.x; older code still says §19.4.x (#106). `docs/spec-coverage.md` gives every section a state (`missing`, `decoder`, `partial`, `closes`, `complete`) and is generated: edit `docs/data/spec-coverage.tsv`, then run `python3 tools/ci/spec_coverage.py --write`. CI's Spec coverage job fails when a section has no row or the page and its data disagree. A state changes only in the PR that brings its evidence. -- **Evidence is real-file, not unit tests.** The PR template asks for the corpus records a change was measured on and the reproducing command, with no new unit tests; existing suites stay and must pass. A new format finding gets a probe under `examples/` (module doc saying what it proves and how to verify it) and a dated section in `ARCHITECTURE.md` with its evidence table. -- **New decoders** follow `docs/EXTENDING_DECODERS.md`. For a fixed-code entity: a type-code constant, a `DecodedEntity` variant, a `fixed_entity_label` entry and a `decode_fixed_entity_body` arm in `entities/dispatch.rs`. The label list and the body arms must match (`fixed_entity_label_covers_every_body_arm` checks it), and `checked_inline` applies the boundary check for you. Custom classes dispatch on their DXF class name instead. `templates/entity-decoder` is a cargo-generate skeleton. +- **Unit tests are banned** (the maintainer's standing directive, 2026-09-23), and evidence is real-file. Do not add one, and do not add a regression test for a fix: a unit-suite pass count is never evidence. The PR template asks for the corpus records a change was measured on and the reproducing command. `tools/ci/test-targets.txt` classes every `tests/*.rs` file as `real` (reads real drawings or the committed canonical fixtures), `cli`, `contract` (checks a published claim), `hardening` (replays inputs that crashed a parser) or `unit` (synthetic-only or property tests); the Test job runs all but `unit`, and `tools/ci/run_test_targets.sh` fails on a file the manifest does not class, so a new target needs a line there and a reason. The `unit` files stay as unrun source, and the inline `#[cfg(test)]` suites in `src/` are deleted area by area (git history is the archive): never add one, and never let a decoder PR re-grow one. A new format finding gets a probe under `examples/` (module doc saying what it proves and how to verify it) and a dated section in `ARCHITECTURE.md` with its evidence table. +- **New decoders** follow `docs/EXTENDING_DECODERS.md`. For a fixed-code entity: a type-code constant, a `DecodedEntity` variant, a `fixed_entity_label` entry and a `decode_fixed_entity_body` arm in `entities/dispatch.rs`. The label list and the body arms must match (`python3 tools/ci/dispatch_arms.py --check` reads both from the source, in CI's Spec coverage job), and `checked_inline` applies the boundary check for you. Custom classes dispatch on their DXF class name instead. `templates/entity-decoder` is a cargo-generate skeleton. - Every `pub fn` in `src/` gets a `///` line naming its bit code, flag mask or spec section; the docs-rs workflow fails below 80% and the tree is at 100%. - No absolute home paths (`/Users//`, `/home//`, `C:\Users\\`) anywhere outside `.github/workflows/`: the PII guard fails on them. No PII in fixtures; use synthetic values. - Conventional Commits with the scopes listed in CONTRIBUTING.md. `main` is squash-merge only, so one PR is one commit and one finding. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2649e6d5..5e12126f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,10 +20,15 @@ The gate (identical to the required CI checks — run it before every push): ```sh cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings -cargo test --release +tools/ci/run_test_targets.sh --release --all-features cargo deny check all # cargo install cargo-deny ``` +`tools/ci/run_test_targets.sh` runs the real-file, contract and hardening +targets of [`tools/ci/test-targets.txt`](tools/ci/test-targets.txt) and the +documentation examples, as CI's Test job does. Unit tests are banned; see +[Unit tests are banned](#unit-tests-are-banned) below. + Two soft gates you will also hit: - **Docstrings.** The docs-rs workflow requires at least 80% of `pub fn` in @@ -143,8 +148,8 @@ Open an issue (or a draft PR) before starting work on any of: - Rust 2024 edition. - `cargo fmt` before every commit. -- `cargo test --release` must pass. The CI in `.github/workflows/` - enforces this. +- `tools/ci/run_test_targets.sh --release --all-features` must pass. The + CI in `.github/workflows/` enforces this. - **No `unsafe` in the library crate.** See above. - **No panics in parsing paths.** Malformed input must return an `Error`, never `panic!`. Defensive caps on loop iteration and @@ -155,6 +160,29 @@ Open an issue (or a draft PR) before starting work on any of: - **Every probe under `examples/`** gets a module-level doc comment explaining *what fact it proves* and *how to verify* the result. +## Unit tests are banned + +Do not add them, and do not add a regression test for a fix. A unit-suite +pass count is not evidence. Changes are verified end to end: against real +drawings and AutoCAD's own DXF exports of them, through the built binaries, +and by the contract checks that keep the published claims honest. The +evidence a pull request carries is the corpus records it was measured on and +the command that reproduces it. + +`tools/ci/test-targets.txt` classes every file under `tests/` as `real` +(reads real drawings, or the committed canonical fixtures), `cli`, `contract` +(checks a published claim), `hardening` (replays inputs that crashed a +parser) or `unit` (synthetic-only or property tests). +`tools/ci/run_test_targets.sh` runs all but `unit`, in CI's Test job on every +OS, and fails on a file the manifest does not class, so a new target needs a +line there and a reason. The `unit` files stay as source and are not run; the +inline `#[cfg(test)]` suites in `src/` are being deleted area by area, and +git history is the archive. Targets that read the corpus skip cleanly when +`../../samples` is absent, as it is on every Test leg; the Corpus (public DWG) +job places the 19 pinned drawings there for the targets it lists. For a format +fact, the evidence is a probe under `examples/` and a dated section of +`ARCHITECTURE.md`. + ## Commit messages Conventional Commits format: @@ -182,7 +210,7 @@ When you discover something new about the file format: (byte offsets, observed values, variant count across versions). 3. If the finding is a decoding rule, show it on real files: name the corpus records it changes and paste the coverage report from - before and after into the pull request. Don't add unit tests. + before and after into the pull request. Don't add tests. This keeps every claim independently verifiable, which is the whole point of open reverse-engineering work. diff --git a/RELEASE.md b/RELEASE.md index c75f3ef4..203f5ca4 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -85,7 +85,7 @@ Before tagging, confirm: ```bash cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings -cargo test --release --all-features +tools/ci/run_test_targets.sh --release --all-features RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features cargo deny check advisories bans licenses sources cargo package --allow-dirty # dry-run the publish tarball diff --git a/docs/EXTENDING_DECODERS.md b/docs/EXTENDING_DECODERS.md index 9b9707ad..3774370a 100644 --- a/docs/EXTENDING_DECODERS.md +++ b/docs/EXTENDING_DECODERS.md @@ -142,8 +142,8 @@ Other kinds of record take other routes in the same file: ## Step 4 — Prove it on real files The proof that a decoder is right is the corpus, not a synthetic -payload you built to match your own reading of the spec. Do not add -unit tests (the pull-request template asks for none). +payload you built to match your own reading of the spec. Unit tests are +banned (see `CONTRIBUTING.md`): do not add one, inline or under `tests/`. 1. Measure before and after with the coverage report on real drawings: @@ -209,11 +209,12 @@ count-driven decoder. ```bash cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings -cargo test --release --all-features +tools/ci/run_test_targets.sh --release --all-features +python3 tools/ci/dispatch_arms.py --check RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features ``` -The real-file integration tests (`tests/samples.rs` and its +The real-file targets (`tests/samples.rs` and its neighbours) look for the corpus at `../../samples` relative to the crate and skip silently when it is absent, so a passing run only covers real drawings if that directory exists. diff --git a/tools/ci/dispatch_arms.py b/tools/ci/dispatch_arms.py new file mode 100755 index 00000000..ad6b80c4 --- /dev/null +++ b/tools/ci/dispatch_arms.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""The fixed-code dispatcher's label list and its body arms stay in step. + +src/entities/dispatch.rs gates every fixed-code record through +`fixed_entity_label` before `decode_fixed_entity_body` reads it. A code the +body decodes but the label list omits never reaches the body: the record +comes back `Unhandled` and is counted as skipped, with no error to say a +decoder exists. A code the list names but the body does not decode comes back +`Error` ("no fixed-code entity decoder"). Both lists are read from the source, +so a new decoder that adds its arm to one and not the other fails here. + +A label arm with no body arm is tolerated only where it is listed below with +its reason, and the entry must go the day the arm does: the check fails when a +listed exception stops being one, as tests/class_census.rs does for its gaps. + +Usage: + + python3 tools/ci/dispatch_arms.py --check +""" + +import re +import sys +from pathlib import Path + +SOURCE = Path(__file__).resolve().parents[2] / "src" / "entities" / "dispatch.rs" + +LABEL_ONLY = { + "OBJECT_TYPE_GEODATA": ( + "GEODATA (0x4FB) is a non-entity object: decode_in_page sends it to " + "dispatch_object before dispatch_fixed_code, so its label arm is never read" + ), +} + + +def sanitize(src): + """The source with comments and the insides of string and char literals blanked.""" + out = list(src) + i, n = 0, len(src) + while i < n: + c = src[i] + if src.startswith("//", i): + j = src.find("\n", i) + j = n if j < 0 else j + out[i:j] = " " * (j - i) + i = j + elif src.startswith("/*", i): + depth, j = 1, i + 2 + while j < n and depth: + if src.startswith("/*", j): + depth, j = depth + 1, j + 2 + elif src.startswith("*/", j): + depth, j = depth - 1, j + 2 + else: + j += 1 + blank(out, src, i, j) + i = j + elif c in "rb" and (i == 0 or not (src[i - 1].isalnum() or src[i - 1] == "_")): + m = re.compile(r'b?r(#*)"').match(src, i) + if m: + end = src.find('"' + m.group(1), m.end()) + end = n if end < 0 else end + 1 + len(m.group(1)) + blank(out, src, m.end(), end) + i = end + else: + i += 1 + elif c == '"': + j = i + 1 + while j < n and src[j] != '"': + j += 2 if src[j] == "\\" else 1 + blank(out, src, i + 1, j) + i = j + 1 + elif c == "'": + m = re.compile(r"'(\\.[^']*|[^\\'])'").match(src, i) + if m: + blank(out, src, i + 1, m.end() - 1) + i = m.end() + else: + i += 1 + else: + i += 1 + return "".join(out) + + +def blank(out, src, a, b): + for k in range(a, min(b, len(src))): + if src[k] != "\n": + out[k] = " " + + +def close_of(text, open_at): + pairs = {"{": "}", "(": ")", "[": "]"} + stack = [] + for k in range(open_at, len(text)): + ch = text[k] + if ch in pairs: + stack.append(pairs[ch]) + elif ch in ")}]": + if not stack or stack.pop() != ch: + raise SystemExit(f"{SOURCE.name}: unbalanced {ch!r} at offset {k}") + if not stack: + return k + raise SystemExit(f"{SOURCE.name}: no closing bracket for offset {open_at}") + + +def match_arms(text, fn): + sig = re.search(r"\bfn\s+" + fn + r"\s*\(", text) + if not sig: + raise SystemExit(f"{SOURCE.name}: no `fn {fn}`") + args_end = close_of(text, sig.end() - 1) + body_start = text.index("{", args_end) + body = text[body_start : close_of(text, body_start) + 1] + m = re.search(r"\bmatch\s+type_code\s*\{", body) + if not m: + raise SystemExit(f"{SOURCE.name}: `fn {fn}` has no `match type_code`") + arms_text = body[m.end() : close_of(body, m.end() - 1)] + arms, i, n = [], 0, len(arms_text) + while True: + while i < n and arms_text[i].isspace(): + i += 1 + if i >= n: + return arms + j, depth = i, 0 + while j < n and not (depth == 0 and arms_text.startswith("=>", j)): + depth += arms_text[j] in "({[" + depth -= arms_text[j] in ")}]" + j += 1 + if j >= n: + raise SystemExit(f"{SOURCE.name}: `fn {fn}` has an arm with no `=>`") + pattern, i = arms_text[i:j].strip(), j + 2 + while i < n and arms_text[i].isspace(): + i += 1 + if i < n and arms_text[i] == "{": + end = close_of(arms_text, i) + 1 + expr, i = arms_text[i:end], end + while i < n and arms_text[i].isspace(): + i += 1 + if i < n and arms_text[i] == ",": + i += 1 + else: + j, depth = i, 0 + while j < n and not (depth == 0 and arms_text[j] == ","): + depth += arms_text[j] in "({[" + depth -= arms_text[j] in ")}]" + j += 1 + expr, i = arms_text[i:j].strip(), j + 1 + arms.append((pattern, expr)) + + +def atoms(pattern): + return {"".join(a.split()) for a in pattern.split("|")} - {"_"} + + +def main(): + if sys.argv[1:] != ["--check"]: + raise SystemExit(__doc__) + text = sanitize(SOURCE.read_text()) + labelled, decoded = set(), set() + for pattern, expr in match_arms(text, "fixed_entity_label"): + if not expr.startswith("return"): + labelled |= atoms(pattern) + for pattern, expr in match_arms(text, "decode_fixed_entity_body"): + decoded |= atoms(pattern) + problems = [] + if len(labelled) < 30 or len(decoded) < 30: + problems.append( + f"read only {len(labelled)} label arms and {len(decoded)} body arms: " + f"this script no longer understands {SOURCE.name}" + ) + for code in sorted(decoded - labelled): + problems.append( + f"{code} is decoded by decode_fixed_entity_body but has no label in " + "fixed_entity_label, so its records come back Unhandled" + ) + label_only = labelled - decoded + for code in sorted(label_only - set(LABEL_ONLY)): + problems.append( + f"{code} has a label in fixed_entity_label but no arm in " + "decode_fixed_entity_body, so its records come back as errors" + ) + for code in sorted(set(LABEL_ONLY) - label_only): + problems.append(f"{code} is no longer a label-only code: remove it from LABEL_ONLY") + if problems: + print("\n".join(problems), file=sys.stderr) + return 1 + print( + f"{len(decoded)} decoded codes all labelled; " + f"{len(label_only)} labelled without a body ({', '.join(sorted(label_only)) or 'none'})" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/ci/run_test_targets.sh b/tools/ci/run_test_targets.sh new file mode 100755 index 00000000..d93a0800 --- /dev/null +++ b/tools/ci/run_test_targets.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Run the real, cli, contract and hardening targets of tools/ci/test-targets.txt, +# then the documentation examples. +# +# Usage: tools/ci/run_test_targets.sh [--check-only | cargo test options...] +# e.g. tools/ci/run_test_targets.sh --release --all-features +# --check-only checks the manifest and runs nothing. +# +# The toolchain is whatever cargo resolves: set RUSTUP_TOOLCHAIN to pick one. +# +# Fails when a tests/*.rs target is missing from the manifest, listed twice or +# classed with an unknown word, or when the manifest names a file that is not in +# tests/. Unit targets are never run, and neither are the inline #[cfg(test)] +# suites in src/: only the selected targets are built, so a unit target cannot +# slip into a run by accident. +# +# Targets without a skip= list share one cargo invocation (`--no-fail-fast`, so +# every failure of the run is shown); each target with a skip= list gets its +# own, because libtest's --skip applies to every binary of an invocation. Every +# invocation runs even when an earlier one failed, and the exit status is 1 if +# any did. Cargo refuses --doc together with --test, so the doc tests run last +# on their own. +set -euo pipefail + +root="$(cd "$(dirname "$0")/../.." && pwd)" +manifest="$root/tools/ci/test-targets.txt" + +listed=$(awk '!/^#/ && NF >= 2 {print $2}' "$manifest" | sort) +present=$(cd "$root/tests" && ls ./*.rs | sed 's|^\./||; s|\.rs$||' | sort) +dupes=$(printf '%s\n' "$listed" | uniq -d) +missing=$(comm -13 <(printf '%s\n' "$listed") <(printf '%s\n' "$present")) +stale=$(comm -23 <(printf '%s\n' "$listed") <(printf '%s\n' "$present")) +unknown=$(awk '!/^#/ && NF >= 2 && $1 !~ /^(real|cli|contract|hardening|unit)$/ {print $1 " " $2}' "$manifest") +if [ -n "$dupes$missing$stale$unknown" ]; then + [ -n "$missing" ] && echo "not classified in tools/ci/test-targets.txt: $missing" >&2 + [ -n "$stale" ] && echo "listed but not in tests/: $stale" >&2 + [ -n "$dupes" ] && echo "listed twice: $dupes" >&2 + [ -n "$unknown" ] && echo "unknown class: $unknown" >&2 + exit 1 +fi +if [ "${1:-}" = "--check-only" ]; then + echo "every tests/*.rs target is classified" + exit 0 +fi + +plain=() +skipped=() +while read -r class target rest; do + case "$class" in + real | cli | contract | hardening) ;; + *) continue ;; + esac + if [[ "${rest:-}" == skip=* ]]; then + skipped+=("$target ${rest#skip=}") + else + plain+=("$target") + fi +done < <(grep -v '^#' "$manifest") + +status=0 + +if [ ${#plain[@]} -gt 0 ]; then + selected=() + for target in "${plain[@]}"; do selected+=(--test "$target"); done + echo "== ${#plain[@]} targets: ${plain[*]}" + cargo test "$@" --no-fail-fast "${selected[@]}" || status=1 +fi + +for entry in ${skipped[@]+"${skipped[@]}"}; do + target="${entry%% *}" + IFS=',' read -r -a names <<< "${entry#* }" + filters=() + for name in "${names[@]}"; do filters+=(--skip "$name"); done + echo "== $target, without ${#names[@]} synthetic-fixture tests" + cargo test "$@" --no-fail-fast --test "$target" -- "${filters[@]}" || status=1 +done + +echo "== documentation examples" +cargo test "$@" --doc || status=1 + +if [ "$status" -ne 0 ]; then + echo "::error::a test target or the documentation examples failed; every failure is in the log above" +fi +exit "$status" diff --git a/tools/ci/test-targets.txt b/tools/ci/test-targets.txt new file mode 100644 index 00000000..23f7c6e5 --- /dev/null +++ b/tools/ci/test-targets.txt @@ -0,0 +1,76 @@ +# Which tests/*.rs targets CI's Test job runs, and why. +# +# Unit tests are banned in this repository (the maintainer's standing +# directive, 2026-09-23): none are added, and none run in CI. What runs is +# verification against real drawings, checks that keep the published claims +# honest, and replays of the inputs that crashed a parser. Every tests/*.rs +# target must appear here exactly once, so a new target cannot silently run +# or silently skip; tools/ci/run_test_targets.sh fails when one is missing. +# The inline #[cfg(test)] suites in src/ are unit tests too: no job runs them, +# and they are deleted area by area (git history is the archive). +# +# Format: [skip=,...] +# real reads real drawings (the public corpus at ../../samples, or the +# committed canonical fixtures) through the public API and checks +# what the reader or an exporter gives against the file +# cli drives the built binaries end to end (no target is classed so +# yet) +# contract checks a published claim or invariant (a table against the +# specification's table) against the code +# hardening replays inputs that crashed or overran a parser of untrusted +# files (fuzz findings) through the public API: run, as a parser +# of hostile input must keep surviving them (the same class +# rvt-rs keeps for its fuzz regressions) +# unit synthetic-only or property tests: kept as source, not run +# +# A target that reads ../../samples skips cleanly when the directory is absent, +# which is every Test leg; the Corpus (public DWG) job places the 19 pinned +# drawings there (tools/corpus/fetch.py --legacy-samples) and runs the +# corpus-reading targets it lists (ci.yml). +# `skip=` leaves out the synthetic-fixture tests inside a real-file target. + +# The BLOCK_HEADER and BLOCK sentinel names of the 19 corpus drawings, and of +# the committed canonical fixtures. +real block_names +# Per-file entity counts, decoded values, handle maps and class census of the +# committed canonical fixtures, read through the public API. +real canonical_corpus +# AcDb:Classes declared counts against the instances the walk reaches, on the +# 19 corpus drawings (a ratchet: a closed or a new gap both fail). +real class_census +# Open, section list, decompression and handle map of every corpus drawing. +real corpus_roundtrip +# dwg-to-dxf's converter on a corpus drawing, release by release. +real integration_dxf_roundtrip +# dwg-to-gltf's converter on a corpus drawing: GLB container, JSON and meshes. +real integration_gltf +# The R14, R2000 and R2007 object walks on the corpus drawings. The seven +# tests it skips build their flat files and page maps byte by byte. +real integration_legacy_container skip=r14_flat_file_walks_its_object_map_and_decodes_a_line,r2000_flat_file_walks_its_object_map_and_decodes_a_line,r13_r15_object_stream_is_the_whole_file,r13_r15_locators_carry_the_canonical_section_names,r2007_locates_its_string_stream_from_the_object_prologue,r2007_system_page_size_matches_the_spec_pseudo_code,r2007_page_map_pairs_accumulate_into_offsets +# Decoded LINE, CIRCLE, ARC and AC1032 records against the values AutoCAD wrote. +real r2013_entity_values +# Version, locators, sections, handle map, class map and visual styles of the +# 19 corpus drawings. +real samples + +# ObjectType::from_code, the DIMENSION subtype table and the dispatcher's +# fixed code table against the specification's Table 4 (§5). +contract code_table_agreement + +# Every seed under fuzz/corpus/ through the entry point it exercises: none may +# panic or hang. +hardening integration_fuzz_corpus_regression + +# Synthetic RawObjects with zeroed payloads and proptest, through decode_from_raw. +unit dispatch_roundtrip +# Synthetic bit streams per release for nine decoders. +unit integration_entity_regression +# SvgDoc text against committed goldens: synthetic documents, and a hard-coded +# SVG tagged with a corpus drawing's version. +unit integration_svg_goldens +# BitWriter, section_writer and file_writer round trips through the reader, in memory. +unit integration_write_roundtrip +# Hand-built malformed LZ77 streams and bit cursors. +unit mutation_failure_modes +# proptest round trips of the bit primitives and LZ77. +unit proptest_roundtrip