re(RE-14.1): HostObjAttr (0x006b) signal is bimodal — text vs real re… #299
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| # Docs-only changes don't need to spin up the Windows wheel | |
| # matrix. Keep the fast Ubuntu jobs (fmt / clippy / doc / pii) | |
| # for any commit that actually touches code, including tests | |
| # or workflow files. `paths-ignore` is a whole-run filter so | |
| # this skips ALL jobs when only ignored paths changed. | |
| paths-ignore: | |
| - "**.md" | |
| - "docs/**" | |
| - "CHANGELOG.md" | |
| - "LICENSE*" | |
| - ".gitignore" | |
| - ".github/ISSUE_TEMPLATE/**" | |
| - "CITATION.cff" | |
| pull_request: | |
| branches: [main] | |
| # Supersede any in-flight run for the same ref when a new push | |
| # arrives. Prevents #52a → #52b → #52c back-to-back pushes from | |
| # burning 3× runner-minutes on stale Windows matrices nobody will | |
| # look at. `cancel-in-progress: true` is the budget saver; the | |
| # group key is per-ref so concurrent PR + push on different refs | |
| # don't cancel each other. | |
| concurrency: | |
| group: ci-${{ github.ref }} | |
| cancel-in-progress: true | |
| # Least-privilege at the workflow level. No CI job here modifies the | |
| # repo, opens issues, comments on PRs, or otherwise needs write | |
| # access; `contents: read` is enough. Individual jobs can request | |
| # additional scopes with a per-job `permissions:` block if that | |
| # ever changes. | |
| permissions: | |
| contents: read | |
| env: | |
| CARGO_TERM_COLOR: always | |
| RUSTFLAGS: "-D warnings" | |
| jobs: | |
| fmt: | |
| name: cargo fmt | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: rustfmt | |
| - run: cargo fmt --all -- --check | |
| clippy: | |
| name: cargo clippy | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: clippy | |
| - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 | |
| - run: cargo clippy --all-targets --all-features -- -D warnings | |
| test: | |
| name: cargo test / ${{ matrix.os }} / ${{ matrix.rust }} | |
| runs-on: ${{ matrix.os }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, macos-latest, windows-latest] | |
| rust: [stable] | |
| include: | |
| - os: ubuntu-latest | |
| rust: "1.85" # MSRV (Rust 2024 edition minimum) | |
| steps: | |
| # Windows' git default `core.autocrlf=true` mangles binary files | |
| # that aren't explicitly marked `binary` in `.gitattributes`. | |
| # The phi-ag/rvt corpus stores some `.rfa` files outside LFS | |
| # without binary attribution — Windows checkout therefore | |
| # silently CRLF-converts them and CFB parsing fails on affected | |
| # streams. Disable autocrlf globally before any checkout runs. | |
| - name: Disable autocrlf on Windows | |
| if: runner.os == 'Windows' | |
| run: git config --global core.autocrlf false | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| # Fetch the Autodesk-owned `rac_basic_sample_family` corpus from | |
| # phi-ag/rvt. rvt-rs intentionally does not redistribute these | |
| # files (see SECURITY.md §Scope); they're pulled at build time. | |
| - name: Fetch phi-ag/rvt sample corpus | |
| uses: actions/checkout@v4 | |
| with: | |
| repository: phi-ag/rvt | |
| path: _corpus | |
| lfs: true | |
| - uses: dtolnay/rust-toolchain@master | |
| with: | |
| toolchain: ${{ matrix.rust }} | |
| - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 | |
| - name: Build | |
| run: cargo build --release --all-targets | |
| - name: Unit tests | |
| run: cargo test --release --lib --bins | |
| - name: Doc tests | |
| run: cargo test --release --doc | |
| # Regression-proof the Q5.2 100%-classification claim. Fails if | |
| # any field in any release decodes to FieldType::Unknown, or if | |
| # any expected corpus file is missing (guarantees non-vacuous | |
| # coverage). | |
| - name: Coverage gate — 100% field-type classification | |
| env: | |
| RVT_SAMPLES_DIR: ${{ github.workspace }}/_corpus/examples/Autodesk | |
| RVT_REQUIRE_CORPUS: "1" | |
| run: cargo test --release --test field_type_coverage -- --nocapture | |
| # Broader integration suite (round-trips, part-atom parsing, | |
| # history extraction, stream inventory, etc.) runs against the | |
| # corpus on all three OSes. Previously failed on Windows due to | |
| # a path-separator bug (CFB stream names came back with `\` | |
| # instead of `/` on Windows; fixed in reader::stream_names). | |
| - name: Integration tests (corpus-dependent) | |
| env: | |
| RVT_SAMPLES_DIR: ${{ github.workspace }}/_corpus/examples/Autodesk | |
| run: cargo test --release --test samples | |
| - name: IFC export integration tests | |
| env: | |
| RVT_SAMPLES_DIR: ${{ github.workspace }}/_corpus/examples/Autodesk | |
| run: cargo test --release --test ifc_roundtrip | |
| ifc-smoke: | |
| # IFC-42: run the synthetic-project integration test and assert | |
| # the emitted STEP file has the expected entity counts. Catches | |
| # regressions where the writer drops elements, the bridge layer | |
| # mis-counts openings, or STEP serialisation silently truncates. | |
| # Uses the committed fixture at `tests/fixtures/synthetic-project.ifc` | |
| # as the reference — any writer change that alters counts must | |
| # update this job AND the fixture together. | |
| name: IFC synthetic-project smoke | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 | |
| - name: Build + run synthetic-project integration test | |
| run: cargo test --release --test ifc_synthetic_project | |
| - name: Assert entity counts in committed fixture | |
| run: | | |
| set -euo pipefail | |
| f=tests/fixtures/synthetic-project.ifc | |
| expect() { | |
| local pat="$1" want="$2" | |
| local got | |
| got=$(grep -c "$pat" "$f" || true) | |
| if [ "$got" != "$want" ]; then | |
| echo "::error::IFC count drift: $pat got $got, want $want" | |
| exit 1 | |
| fi | |
| echo "ok: $pat = $got" | |
| } | |
| expect IFCPROJECT 1 | |
| expect IFCBUILDINGSTOREY 3 | |
| expect IFCWALL 4 | |
| expect IFCSLAB 1 | |
| expect IFCDOOR 1 | |
| expect IFCWINDOW 2 | |
| expect IFCMATERIAL 2 | |
| expect IFCPROPERTYSET 2 | |
| expect IFCOPENINGELEMENT 1 | |
| expect IFCRELVOIDSELEMENT 1 | |
| expect IFCRELFILLSELEMENT 1 | |
| expect IFCEXTRUDEDAREASOLID 7 | |
| ifcopenshell-validate: | |
| # IFC-41: load the committed synthetic-project.ifc fixture in | |
| # IfcOpenShell and assert it opens without errors, carries the | |
| # right spatial hierarchy, and resolves every IfcRel* link. This | |
| # is the independent-validator gate — the Rust-side ifc-smoke | |
| # job above counts substrings, but IfcOpenShell actually parses | |
| # the STEP file through the full IFC4 schema, so errors the | |
| # substring check can't see (dangling refs, wrong attribute | |
| # arity, enum values outside the spec vocabulary) fail here. | |
| name: IfcOpenShell validates synthetic project | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Install IfcOpenShell (prebuilt wheel) | |
| run: | | |
| python -m pip install --upgrade pip | |
| # ifcopenshell ships prebuilt wheels on PyPI as | |
| # `ifcopenshell-python` for Linux/macOS/Windows x64. Pin | |
| # to a stable 0.8.x series — the 0.7 API has semantic | |
| # differences in entity-by-type iteration. | |
| python -m pip install 'ifcopenshell>=0.8.0,<0.9.0' | |
| - name: Validate synthetic-project fixture | |
| run: | | |
| python - <<'PY' | |
| import ifcopenshell | |
| path = "tests/fixtures/synthetic-project.ifc" | |
| f = ifcopenshell.open(path) | |
| # Schema identifier — we only ship IFC4. | |
| assert f.schema == "IFC4", f"expected IFC4, got {f.schema}" | |
| # Spatial hierarchy: 1 project, 1 site, 1 building, 3 storeys. | |
| assert len(f.by_type("IfcProject")) == 1 | |
| assert len(f.by_type("IfcSite")) == 1 | |
| assert len(f.by_type("IfcBuilding")) == 1 | |
| assert len(f.by_type("IfcBuildingStorey")) == 3, ( | |
| f"expected 3 storeys, got {len(f.by_type('IfcBuildingStorey'))}" | |
| ) | |
| # Per-element entities. | |
| assert len(f.by_type("IfcWall")) == 4 | |
| assert len(f.by_type("IfcSlab")) == 1 | |
| assert len(f.by_type("IfcDoor")) == 1 | |
| assert len(f.by_type("IfcWindow")) == 2 | |
| # Openings + rels must have resolved references (no | |
| # dangling pointers — IfcOpenShell would raise during | |
| # open() if they did; double-check here). | |
| openings = f.by_type("IfcOpeningElement") | |
| assert len(openings) == 1 | |
| for op in openings: | |
| voids = [r for r in f.by_type("IfcRelVoidsElement") | |
| if r.RelatedOpeningElement.id() == op.id()] | |
| fills = [r for r in f.by_type("IfcRelFillsElement") | |
| if r.RelatingOpeningElement.id() == op.id()] | |
| assert len(voids) == 1, f"opening {op} has {len(voids)} voids" | |
| assert len(fills) == 1, f"opening {op} has {len(fills)} fills" | |
| print("IfcOpenShell validation passed:") | |
| print(f" schema: {f.schema}") | |
| print(f" entities: {len(list(f))}") | |
| print(f" walls/slabs/doors/windows: 4/1/1/2") | |
| print(f" storeys: 3") | |
| PY | |
| # IFC-44: second fixture — structural framing + shared | |
| # RepresentationMap + faceted-brep furniture. Validates IFC-17 / | |
| # IFC-20 / IFC-21 / IFC-24 / IFC-30 paths that the project | |
| # fixture above doesn't exercise. | |
| - name: Validate synthetic-structural fixture | |
| run: | | |
| python - <<'PY' | |
| import ifcopenshell | |
| path = "tests/fixtures/synthetic-structural.ifc" | |
| f = ifcopenshell.open(path) | |
| assert f.schema == "IFC4" | |
| assert len(f.by_type("IfcColumn")) == 2, "expected 2 columns" | |
| assert len(f.by_type("IfcBeam")) == 2, "expected 2 beams" | |
| assert len(f.by_type("IfcDoor")) == 3, "expected 3 doors" | |
| assert len(f.by_type("IfcFurniture")) == 1, "expected 1 furniture" | |
| # IFC-21: shared representation map, one entity, three | |
| # mapped items referencing it. | |
| reps = f.by_type("IfcRepresentationMap") | |
| assert len(reps) == 1, f"expected 1 IfcRepresentationMap, got {len(reps)}" | |
| mapped_items = f.by_type("IfcMappedItem") | |
| assert len(mapped_items) == 3, ( | |
| f"expected 3 IfcMappedItem instances, got {len(mapped_items)}" | |
| ) | |
| # All three mapped items must reference the same map. | |
| for mi in mapped_items: | |
| assert mi.MappingSource.id() == reps[0].id(), ( | |
| "mapped item points at a different representation map" | |
| ) | |
| # IFC-20: one faceted brep for the furniture. | |
| assert len(f.by_type("IfcFacetedBrep")) == 1 | |
| # IFC-30: MaterialProfileSet usage on the 2 beams. | |
| assert len(f.by_type("IfcMaterialProfileSetUsage")) >= 1 | |
| # IFC-24: I-shape profiles (3 total) + 1 circle profile. | |
| assert len(f.by_type("IfcIShapeProfileDef")) == 3 | |
| assert len(f.by_type("IfcCircleProfileDef")) == 1 | |
| print("IFC-44 structural fixture validation passed:") | |
| print(f" columns/beams/doors/furniture: 2/2/3/1") | |
| print(f" IfcRepresentationMap/IfcMappedItem: 1/3 (3:1 sharing ratio)") | |
| print(f" IfcFacetedBrep: 1 (tetrahedron)") | |
| PY | |
| # L5B-11.8: real-file walker → IFC end-to-end gate. Emit a | |
| # fresh IFC4 STEP from one of the family samples via `rvt-ifc`, | |
| # then load it in IfcOpenShell and assert: schema is IFC4, the | |
| # IfcProject exists, and every element entity that the walker | |
| # threaded through resolves without dangling references. This | |
| # gate catches regressions that the synthetic-fixture checks | |
| # can't see — e.g. a walker change that silently emits zero | |
| # elements on real files, or breaks step_writer's handling of | |
| # BuildingElement entities appended after the Project. | |
| - name: Build rvt-ifc + walker->IFC real-file gate | |
| run: | | |
| cargo build --release --bin rvt-ifc | |
| # The family corpus resolves via RVT_SAMPLES_DIR (used by | |
| # `tests/common::samples_dir`) — if phi-ag/rvt was checked | |
| # out, point at it; otherwise skip (family samples aren't | |
| # redistributed by this repo). | |
| if [ -d "${RVT_SAMPLES_DIR:-./_corpus/examples/Autodesk}" ]; then | |
| SAMPLE=$(ls "${RVT_SAMPLES_DIR:-./_corpus/examples/Autodesk}"/*.rfa 2>/dev/null | head -1) | |
| fi | |
| if [ -z "$SAMPLE" ]; then | |
| echo "skipping walker->IFC real-file gate: no family sample available" | |
| exit 0 | |
| fi | |
| ./target/release/rvt-ifc "$SAMPLE" -o /tmp/walker-real-file.ifc | |
| python - <<'PY' | |
| import ifcopenshell | |
| f = ifcopenshell.open("/tmp/walker-real-file.ifc") | |
| assert f.schema == "IFC4", f"schema: {f.schema}" | |
| # Project must exist (metadata-only baseline). | |
| assert len(f.by_type("IfcProject")) == 1, "lost IfcProject" | |
| # Walker may emit zero elements on family files — that's | |
| # acceptable. But if any BuildingElementProxy entities were | |
| # emitted, each must resolve without dangling references | |
| # (IfcOpenShell would have raised during open() otherwise). | |
| proxies = f.by_type("IfcBuildingElementProxy") | |
| print(f"L5B-11.8 walker->IFC gate passed:") | |
| print(f" schema: {f.schema}") | |
| print(f" IfcProject: 1") | |
| print(f" IfcBuildingElementProxy (walker-recovered): {len(proxies)}") | |
| PY | |
| doc: | |
| name: cargo doc | |
| runs-on: ubuntu-latest | |
| env: | |
| RUSTDOCFLAGS: "-D warnings" | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 | |
| - run: cargo doc --no-deps --lib | |
| # Q-09: benchmark-build regression gate. | |
| # | |
| # Runs `cargo bench --no-run` to compile every benchmark target | |
| # against the current library API. Without this job, a PR that | |
| # renames a public item or changes a function's signature can | |
| # break the benchmarks silently — `cargo test` doesn't build the | |
| # `[[bench]]` targets, and nobody runs benches on every push. | |
| # | |
| # A full runtime regression gate (compare wallclock numbers to a | |
| # baseline, fail on >X% slowdown) would need a dedicated runner | |
| # class with stable performance characteristics — GitHub's shared | |
| # runners have variable load, so wallclock comparisons need wide | |
| # tolerances to avoid false positives. Compile-gate is the | |
| # high-value, low-noise version we can enforce on every push. | |
| bench-build: | |
| name: cargo bench --no-run | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 | |
| with: | |
| key: bench-build | |
| # `--no-run` compiles the benches but doesn't execute them. | |
| # This catches API drift (renamed public items, changed | |
| # function signatures, missing traits on types used in | |
| # benches) that `cargo test` doesn't, since benches live in | |
| # their own `[[bench]]` targets. | |
| - run: cargo bench --no-run | |
| # Q-12: reproduce the docs.rs build locally in CI. | |
| # docs.rs runs `cargo doc` on nightly with `--all-features` and the | |
| # `--cfg docsrs` flag set; broken rustdoc (invalid links, missing | |
| # items, syntax errors in code examples) breaks the docs.rs page — | |
| # catching it in CI means every PR validates that rvt-rs will render | |
| # properly on docs.rs before the release cut. | |
| docs-rs-preview: | |
| name: docs.rs build preview | |
| runs-on: ubuntu-latest | |
| env: | |
| # Same flags docs.rs passes, plus -D warnings so this job fails | |
| # instead of silently publishing broken docs. | |
| RUSTDOCFLAGS: "--cfg docsrs -D warnings" | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| # docs.rs builds with nightly — any `#[cfg_attr(docsrs, ...)]` | |
| # attributes + `#![feature(...)]` gates must be available. | |
| - uses: dtolnay/rust-toolchain@nightly | |
| - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 | |
| # Mirror the [package.metadata.docs.rs] settings from Cargo.toml | |
| # (`all-features = true`) — if these drift, update both. | |
| - run: cargo +nightly doc --no-deps --all-features | |
| python-wheel: | |
| name: Python wheel / ${{ matrix.os }} | |
| runs-on: ${{ matrix.os }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, macos-latest, windows-latest] | |
| steps: | |
| # Windows CFB stream names need the same autocrlf=false guard | |
| # we use for the Rust Windows integration job, or LFS-bound | |
| # corpus bytes get CRLF-mangled. Keeping this idempotent. | |
| - name: Disable autocrlf on Windows | |
| if: runner.os == 'Windows' | |
| run: git config --global core.autocrlf false | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - name: Fetch phi-ag/rvt sample corpus | |
| uses: actions/checkout@v4 | |
| with: | |
| repository: phi-ag/rvt | |
| path: _corpus | |
| lfs: true | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 | |
| with: | |
| key: python-wheel-${{ matrix.os }} | |
| # Build the abi3 wheel. SEC-12/13: wheel sources live in the | |
| # `rvt-py/` workspace member crate; maturin follows the | |
| # `manifest-path` in pyproject.toml. `--release` produces an | |
| # optimised binary matching what users get via `pip install rvt`. | |
| - name: Build wheel via maturin | |
| uses: PyO3/maturin-action@v1 | |
| with: | |
| command: build | |
| args: --release --out dist | |
| # Install into this runner's Python, then run the pytest suite | |
| # we ship in tests/python/. This is the real regression gate — | |
| # if the wheel doesn't actually import or the bindings return | |
| # wrong values, CI fails here. | |
| - name: Install the built wheel | |
| shell: bash | |
| run: | | |
| python3 -m pip install --upgrade pip pytest | |
| python3 -m pip install --force-reinstall dist/*.whl | |
| - name: Run pytest integration tests | |
| shell: bash | |
| env: | |
| RVT_SAMPLES_DIR: ${{ github.workspace }}/_corpus/examples/Autodesk | |
| run: python3 -m pytest tests/python -v | |
| - name: Upload wheel artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: rvt-wheel-${{ matrix.os }} | |
| path: dist/*.whl | |
| if-no-files-found: error | |
| # Supply-chain hardening. `cargo-deny check` enforces our deny.toml: | |
| # permissive-license allowlist, RustSec advisory deny, crates.io-only | |
| # source, no wildcards. Keeps the dependency tree auditable as the | |
| # project grows. | |
| deny: | |
| name: cargo deny | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - uses: EmbarkStudios/cargo-deny-action@91bf2b620e09e18d6eb78b92e7861937469acedb # v2.0.17 | |
| with: | |
| command: check | |
| # Fails CI on any RustSec advisory published against the current | |
| # dependency set. Complements cargo-deny's coverage — deny has the | |
| # per-advisory `ignore` list for triage flexibility, audit catches | |
| # fresh advisories the moment they land. | |
| audit: | |
| name: cargo audit | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0 | |
| with: | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| pii-guard: | |
| name: PII guard | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - name: Scan for PII-shaped leak patterns | |
| run: | | |
| set -e | |
| echo "Scanning repo for patterns that must never be committed..." | |
| # Each regex catches a *class* of PII without spelling out | |
| # any specific sensitive value. The patterns are intentionally | |
| # broad — they're belt-and-suspenders on top of src/redact.rs. | |
| PATTERNS=( | |
| # Absolute home paths (macOS, Linux, Windows). | |
| '/Users/[a-z][a-z0-9._-]+/' | |
| '/home/[a-z][a-z0-9._-]+/' | |
| 'C:\\Users\\[a-z][a-z0-9._-]+\\' | |
| # Autodesk employee-owned OneDrive authoring paths. | |
| 'OneDrive - Autodesk\\[^\\]*\\Revit - [0-9]{3,}' | |
| # Autodesk build-server paths baked into DLLs. | |
| 'F:\\Ship\\[0-9]{4}_' | |
| ) | |
| failed=0 | |
| for p in "${PATTERNS[@]}"; do | |
| if git grep -I -n -P "$p" -- ':!.github/workflows/' ':!src/redact.rs' 2>/dev/null; then | |
| echo "::error::PII-shaped pattern matched: $p" | |
| failed=1 | |
| fi | |
| done | |
| if [ "$failed" -ne 0 ]; then | |
| exit 1 | |
| fi | |
| echo "PII guard: clean." |