Skip to content

build/ci: one Catch2 for every configure, and two sanitizer sweeps that enumerate ctest blind (fixes #674, fixes #691) - #694

Closed
Yaraslaut wants to merge 2 commits into
masterfrom
laneCATCH2-batch-674-691
Closed

Yaraslaut wants to merge 2 commits into
masterfrom
laneCATCH2-batch-674-691

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Two CI-configuration tickets, one commit each.


#674 — one Catch2 for every configure (bde6cfc0)

find_package(Catch2 CONFIG QUIET) at CMakeLists.txt:560 handed the build whatever Catch2 the machine had, and seventeen Linux legs installed catch2 from apt without pinning it. clang-tidy therefore analysed REQUIRE/TEST_CASE expansions against a different release on a runner than on a workstation. The root now fetches v3.8.1 unconditionally, with SYSTEM.

SYSTEM is the load-bearing word — measured here, not taken on trust

Two configures from empty, differing only in that keyword, clang-tidy 22.1.8:

$ clang-tidy -p <build with SYSTEM>    --quiet tests/test_executor.cpp | grep -cE ': (error|warning): '
1
$ clang-tidy -p <build without SYSTEM> --quiet tests/test_executor.cpp | grep -cE ': (error|warning): '
45

Grouped, the new ones are Catch2's own macro expansions reclassified as user code:

     21 [cppcoreguidelines-avoid-do-while,-warnings-as-errors]
     11 [misc-use-anonymous-namespace,-warnings-as-errors]
      1 [performance-unnecessary-value-param,-warnings-as-errors]

and the compile database confirms the mechanism:

'-isystem' '.../b-sys/_deps/catch2-src/src/catch2/..'
'-isystem' '.../b-sys/_deps/catch2-build/generated-includes'

This reproduces #674's earlier lane exactly (1 vs 45). Drop SYSTEM and the clang-tidy-diff job is permanently red across 134 tests/ TUs and every example suite.

The mechanism in the ticket is wrong in detail, and the ticket should still not close as invalid

The compile database records no Catch2 include path at all. find_package resolved to an imported target whose INTERFACE_INCLUDE_DIRECTORIES is /usr/include, which CMake drops as an implicit compiler directory; the header that got included was whichever the compiler's default system search path found. Read literally, that satisfies #674's own closing condition. It should not close as invalid — the divergence is real, just one level lower than written, and the fix is the one the ticket names. Confirming the earlier lane's finding on this revision.

A bug found on the way, and fixed here because the change is wrong without it

examples/{concepts,bank,vetted_hmac} are add_subdirectory()'d before the Tests section, and each ran its own find_package(Catch2 3 CONFIG QUIET) with a message(WARNING) fallback. On any machine without the distro package, those three suites were silently not built while the configure succeeded. With apt's catch2 gone, leaving the fetch where it was would have dropped bank's and concepts' suites from every leg that builds them — and every one of those legs would still have reported success. Textbook "control that measures nothing".

So the fetch moved above the examples, and all five find_package(Catch2 ...) sites became if(NOT TARGET Catch2::Catch2WithMain) + FATAL_ERROR. This is part of the change, not a separate finding: #674 cannot land correctly without it.

The nine .clang-tidy files: rewritten, not renumbered

Their prose asserted "CI pins catch2 3.4.0 — ubuntu-24.04's package, which predates that comment". v3.8.1 carries the NOLINT(bugprone-chained-comparison) those files attribute to 3.15.3 — verified in the fetched tree:

_deps/catch2-src/src/catch2/internal/catch_test_macro_impl.hpp:50:
    catchAssertionHandler.handleExpr( Catch::Decomposer() <= __VA_ARGS__ ); /* NOLINT(bugprone-chained-comparison) */ \

so the sentence is now false and the suppression inert. (Already true on master for any contributor without the distro package.) The rewritten prose names no version at all — the pin lives in MORPH_CATCH2_TAG and nowhere else — states that the entry is measured to subtract nothing today, and states why it stays: the argument is about the macro, not about a release. Nine copies of a number are nine chances to be wrong about it.

scripts/check_catch2_pin.sh: retired, not re-pointed

The ticket asks for a decision and a reason. Retired, with its self-test (444 lines between them):

  • Its behavioural half read /usr/include/catch2. Nothing installs that any more and the build never consults it. A gate asserting a pin nothing installs is precisely the failure this cluster is made of.
  • Its textual half compared prose against CATCH2_VERSION in ci.yml, which is gone — ci.yml no longer decides the Catch2.
  • Re-pointing it at the CMake pin would have required the nine files to name the version again, manufacturing nine duplicate strings so the gate had something to compare: a gate whose subject it created.
  • The one real duplication the new design would have introduced — morph_cache_dep's tag against the GIT_TAG three lines below — is removed by a variable rather than gated.

vcpkg too

catch2 is dropped from vcpkg.json and CONTRIBUTING.md. vcpkg is the Windows legs' version of "whatever the machine has". Removing the manifest entry adds no cost — the four Windows presets stop using vcpkg's Catch2 the moment the find_package goes, whatever the manifest says. The real and unmeasured cost is that those four presets now build Catch2 from source with no compiler cache at all (the Windows job has neither sccache nor MORPH_DEP_CACHE), which #674's cost analysis counted only for the seventeen Linux legs. If a Windows leg becomes the critical path, that is the line to revisit.


#691 — the two sweeps without their Test step's environment (41bbf115)

check_sanitizer_instrumentation.sh opens with ctest --show-only=json-v1, and ctest re-enumerates any DISCOVERY_MODE PRE_TEST suite by running its binary with --list-tests. Headless, a Qt-linked binary aborts there — and that does not cost one suite, it costs the whole listing: ctest exits 8 with zero bytes of stdout and the sweep reports only ctest listed no tests. That is #690, three sessions, two of which could not reproduce it because a workstation has DISPLAY.

kanban-tsan and ladder-sanitizers get QT_QPA_PLATFORM: offscreen on that step. linux-sanitizers deliberately does not — it configures no Qt, so the line would be inert, and an inert line invites the next reader to work out what it guards.

Re-derived from the parsed workflow on this branch:

linux-sanitizers       sweep-env=NO   buildsQt=no
kanban-tsan            sweep-env=yes  buildsQt=yes
bank-sanitizers        sweep-env=yes  buildsQt=yes
ladder-sanitizers      sweep-env=yes  buildsQt=yes

Why not the gate

#691's triage names the alternative: a gate asserting "a Qt-linked target must not use DISCOVERY_MODE PRE_TEST", which fixes the cause rather than the symptom. It is not available without reversing #692. bank's three suites are Qt-linked and PRE_TEST (examples/bank/CMakeLists.txt) — exactly the configuration #690 was about — and #692, merged two commits ago, chose to fix that with the environment rather than by moving bank to POST_BUILD. Such a gate would fail on master's own tree today. It would also have to compute Qt linkage transitively through morph::qt and morph_ladder_gui, which a text gate cannot do honestly and a generate-time gate could only do by walking each target's link closure.

Cost of what I did: two env: blocks, inert until someone changes a discovery mode, plus a third place the reasoning has to be kept true. Cost of the gate: undoing #692 and moving bank's suites to POST_BUILD, to buy an invariant enforced at the cause instead of three comments — defensible, but it is a change to how the ladder registers tests and belongs to whoever owns that, not to a CI ticket.


Verification

Arch Linux, clang 22.1.8, cmake 4.4.3, 12 cores, on this branch.

Measured:

  • configure from empty, MORPH_BUILD_TESTS=ON: -isystem for both Catch2 directories, no -I;
  • clang-tidy on tests/test_executor.cpp: 1 with SYSTEM, 45 without;
  • full build + ctest -j8: 100% tests passed out of 1588, Total Test time (real) = 66.85 sec — including morph_concepts_tests, one of the three suites the old ordering would silently have skipped;
  • configure from empty with MORPH_BUILD_LADDER=ON MORPH_BUILD_QT=ON MORPH_BUILD_BANK_EXAMPLE=ON MORPH_BUILD_OFFLINE_SQLITE=ON: all 9 rungs register, generate succeeds — so examples/common's TARGET guard and include(Catch) inside cmake/morph_add_rung.cmake both resolve against the fetched Catch2 (the earlier lane could not run this);
  • check_rung_filters.sh (58 checks), check_tidy_suppression_scope.sh, check_workflow_job_banners.py (26 banners), check_workflow_option_coverage.py, check_ci_clang_pin.sh, check_bidi_controls.py (1249 files), test_check_sanitizer_instrumentation.sh: all pass;
  • every workflow parses as YAML; vcpkg.json parses as JSON.

Not verified:

The one claim the branch's safety rests on

That SYSTEM on FetchContent_Declare is what keeps clang-tidy's classification of Catch2 macro expansions unchanged. Everything else here is reversible noise; if that keyword is wrong or gets dropped in a future edit, the clang-tidy-diff job goes red across the whole test tree at once. It is measured both ways above rather than asserted, and the CMake comment says so at the site.

Filed separately

Closes #674.
Closes #691.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

Yaraslaut and others added 2 commits September 22, 2026 03:02
 #674, refs #666)

`find_package(Catch2 CONFIG QUIET)` handed the build whatever Catch2 the
machine had, and seventeen Linux legs installed `catch2` from apt without
pinning it. clang-tidy therefore analysed `REQUIRE`/`TEST_CASE` expansions
against a different release on a runner than on a workstation, and disagreed
about what counted as a finding -- silently, because nothing recorded which
Catch2 a given build used.

The mechanism was one level lower than #674 states, and the ticket's own
closing condition reads as satisfied by that. It is not: the compile database
records no Catch2 include path at all, because `find_package` resolved to an
imported target whose INTERFACE_INCLUDE_DIRECTORIES is `/usr/include`, which
CMake drops as an implicit compiler directory. The header that got included
was simply the one the compiler's default system search path found. Same
divergence, reached by a different route, so the fix is the one the ticket
names.

The root CMakeLists.txt now fetches v3.8.1 unconditionally, with `SYSTEM`.

`SYSTEM` is the load-bearing word. Without it a fetched dependency's include
directory arrives as `-I`, clang-tidy classifies Catch2's macro expansions as
user code, and `tests/test_executor.cpp` goes from 1 diagnostic to 45 (21
cppcoreguidelines-avoid-do-while and 11 misc-use-anonymous-namespace out of
REQUIRE/TEST_CASE, plus notes). Measured both ways on this commit, clang-tidy
22.1.8, two configures differing only in that keyword.

The fetch moved *above* the examples rather than staying in the Tests section,
and that is a bug fix rather than tidying. examples/{concepts,bank,
vetted_hmac} are add_subdirectory()'d before that section, and each ran its
own `find_package(Catch2 3 CONFIG QUIET)` with a `message(WARNING)` fallback
-- so on any machine without the distro package those three suites were
silently not built while the configure still succeeded. With apt's catch2
gone from CI, leaving the fetch where it was would have dropped bank's and
concepts' suites from every leg that builds them, and each leg would still
have reported success. All five `find_package(Catch2 ...)` sites are now
`if(NOT TARGET Catch2::Catch2WithMain)` with FATAL_ERROR: the target either
exists because the root fetched it, or the build ordering is broken and says
so.

The nine `*/tests/.clang-tidy` files are rewritten, not renumbered. Their
prose asserted "CI pins catch2 3.4.0 -- ubuntu-24.04's package, which predates
that comment"; v3.8.1 carries the `NOLINT(bugprone-chained-comparison)` those
files attribute to 3.15.3, so the sentence is now false and the suppression
inert. (It was already inert on master for any contributor without the distro
package, who got 3.8.1 through the existing fallback.) The rewritten prose
names no version at all -- the pin lives in `MORPH_CATCH2_TAG` and nowhere
else -- says the entry is measured to subtract nothing today, and says why it
stays: the argument is about the macro, not about a release.

`scripts/check_catch2_pin.sh` and its self-test are retired rather than
re-pointed, and that is the decision the ticket asks for. Its behavioural half
read `/usr/include/catch2`, which nothing installs any more and the build
never consults -- a gate asserting a pin nothing installs is precisely the
failure this cluster is made of. Its textual half compared prose against
`CATCH2_VERSION` in ci.yml, which is gone because ci.yml no longer decides the
Catch2. Re-pointing it at the CMake pin would have required the nine files to
name the version again, manufacturing nine duplicate strings so that a gate
had something to compare -- a gate whose subject it created. The one real
duplication the new design would have introduced, `morph_cache_dep`'s tag
against `GIT_TAG` three lines below, is removed by a variable instead of
gated.

`catch2` is dropped from `vcpkg.json` and from CONTRIBUTING.md as well.
vcpkg is the Windows legs' version of "whatever the machine has", and with
the find_package gone its Catch2 would be installed and never consumed.
Removing the manifest entry adds no cost -- the four Windows presets stop
using vcpkg's Catch2 the moment the find_package goes, whatever the manifest
says -- it only stops them installing a package nothing links. The real and
unmeasured cost is that those four presets now build Catch2 from source with
no compiler cache at all (the Windows job has neither sccache nor
MORPH_DEP_CACHE), which #674's cost analysis counted only for the seventeen
Linux legs. If a Windows leg becomes the critical path, that is the line to
revisit.

Verified on this commit, Arch Linux, clang 22.1.8, cmake 4.4.3:

  - configure from empty, MORPH_BUILD_TESTS=ON: the compile database carries
    `-isystem .../_deps/catch2-src/src/catch2/..` and `-isystem
    .../_deps/catch2-build/generated-includes`, and no `-I` for Catch2;
  - clang-tidy -p <that build> tests/test_executor.cpp -> 1 diagnostic;
    same TU against a build differing only by the missing `SYSTEM` -> 45;
  - full build and `ctest -j8`: `100% tests passed out of 1588`,
    `Total Test time (real) =  66.85 sec`, including morph_concepts_tests,
    which is one of the three suites the old ordering would have skipped;
  - configure from empty with MORPH_BUILD_LADDER=ON MORPH_BUILD_QT=ON
    MORPH_BUILD_BANK_EXAMPLE=ON MORPH_BUILD_OFFLINE_SQLITE=ON: all 9 rungs
    register and generate succeeds, so examples/common's TARGET guard and
    `include(Catch)` in cmake/morph_add_rung.cmake both resolve against the
    fetched Catch2;
  - check_rung_filters.sh, check_tidy_suppression_scope.sh,
    check_workflow_job_banners.py, check_workflow_option_coverage.py,
    check_ci_clang_pin.sh, check_bidi_controls.py and
    test_check_sanitizer_instrumentation.sh all pass.

Not verified: the ladder and bank binaries were configured but not built or
run here; no measurement was taken on a GitHub runner, so the build-time cost
(~1.7 min added to the critical path, ~28 runner-minutes, near zero warm via
sccache) is carried over from #674's measurements and the warm figure remains
inferred.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…ep's display setting (fixes #691, refs #690)

`check_sanitizer_instrumentation.sh` starts with `ctest --show-only=json-v1`,
and ctest re-enumerates any suite registered `DISCOVERY_MODE PRE_TEST` by
running its binary with `--list-tests`. On a runner there is no display, so a
Qt-linked binary aborts there -- and the abort does not cost one suite, it
costs the whole listing: ctest exits 8 with zero bytes of stdout and the
sweep reports only `ctest listed no tests`. That is #690, which cost three
sessions, two of which failed to reproduce it locally because a workstation
has DISPLAY set.

#692 gave bank-sanitizers' sweep the variable. The same step in kanban-tsan
and ladder-sanitizers still runs without it, and both build Qt-linked suites.
They are green today only because every Qt-linked suite they build is
registered POST_BUILD (cmake/morph_add_rung.cmake:532,
examples/common/CMakeLists.txt:307), so the enumeration happened during Build,
where the variable is set. Nothing about either sweep step protects them: one
`POST_BUILD` changed to `PRE_TEST` in morph_add_rung.cmake turns two green
jobs red with that same uninformative message.

linux-sanitizers deliberately does not get the block. It configures no Qt, so
the line would be inert, and an inert line invites the next reader to work
out what it guards.

The alternative #691's triage names -- a gate asserting "a Qt-linked target
must not use DISCOVERY_MODE PRE_TEST" -- was considered and is not available
without reversing #692. bank's three suites are Qt-linked *and* PRE_TEST
(examples/bank/CMakeLists.txt), which is exactly the configuration #690 was
about, and #692 chose to fix it with the environment rather than by moving
bank to POST_BUILD. Such a gate would therefore fail on master's own tree
today. It would also have to compute Qt linkage transitively through
morph::qt and morph_ladder_gui, which a text gate cannot do honestly and a
generate-time gate could only do by walking each target's link closure. Cost
of the approach taken: two `env:` blocks that are inert until someone changes
a discovery mode, and a third place the reasoning has to be kept true. Cost
of the gate: undoing #692 and moving bank's suites to POST_BUILD, to buy an
invariant enforced at the cause rather than three comments -- defensible, but
it is a change to how the ladder registers tests, which belongs to whoever
owns that, not to a CI ticket.

Verified on this commit by re-deriving #691's four-job table from the parsed
workflow:

    linux-sanitizers       sweep-env=NO   buildsQt=no
    kanban-tsan            sweep-env=yes  buildsQt=yes
    bank-sanitizers        sweep-env=yes  buildsQt=yes
    ladder-sanitizers      sweep-env=yes  buildsQt=yes

Every workflow still parses as YAML, and check_workflow_job_banners.py and
check_workflow_option_coverage.py pass.

Not verified: that either job *would* fail without this, which needs a
PRE_TEST Qt suite on a headless runner. That remains inferred from #690's
reproduced mechanism, as #691 states.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner verification

The bug you found inside #674 is the most important thing in this PR, and I confirmed it on master:

examples/concepts/CMakeLists.txt:29:    find_package(Catch2 3 CONFIG QUIET)
                                 :30:    if(NOT Catch2_FOUND)
                                 :31:        message(WARNING "Catch2 not found; examples/concepts tests will not be built.")

A warning. Configure succeeds, the suite silently vanishes. Same shape in examples/bank and twice in examples/vetted_hmac.

So on any machine without the distro package, three example suites were never built and nothing said so above a warning nobody reads — and removing apt's catch2, which this PR does, would have made that land on CI: bank's and concepts' suites dropping out of every leg that builds them, each still reporting success. That is this repository's headline failure mode, and the change would have caused an instance of it rather than merely leaving one.

Not filing it separately was right. It is not a separable finding; it is a precondition — the change is wrong without it. Moving the fetch above the examples and converting all five sites to if(NOT TARGET Catch2::Catch2WithMain) + FATAL_ERROR is verified on the branch:

examples/bank/CMakeLists.txt:199        if(NOT TARGET Catch2::Catch2WithMain)
examples/common/CMakeLists.txt:171      if(NOT TARGET Catch2::Catch2WithMain)
examples/concepts/CMakeLists.txt:27     if(NOT TARGET Catch2::Catch2WithMain)
examples/vetted_hmac/CMakeLists.txt:40  if(NOT TARGET Catch2::Catch2WithMain)
examples/vetted_hmac/CMakeLists.txt:69  if(NOT TARGET Catch2::Catch2WithMain)

Every remaining find_package(Catch2 in the tree is now inside a comment explaining what was removed. Both gate scripts are gone, and the nine .clang-tidy mentions of check_catch2_pin.sh are past-tense history ("existed to make that divergence loud"), not dangling pointers — which is the right way to retire a name.

Retiring the gate rather than re-pointing it is well argued: re-pointing would have required the nine files to name the version again, "manufacturing nine duplicate strings so the gate had something to compare". And the one real duplication the new design introduces is removed by a variable rather than gated — which is the better answer than a gate every time it is available.

#691's reasoning is the part I had not seen: the gate alternative I suggested ("a Qt-linked target must not use PRE_TEST") is not available without reversing #692, because bank's suites are Qt-linked and PRE_TEST today — #692 chose the environment over moving them to POST_BUILD. So that gate would fail on master's own tree. Costing both options and finding mine unavailable is exactly what I asked for and did not expect.

My resume message was stale, and you were right to say so

I warned that only three .clang-tidy files were staged and that check_catch2_pin.sh was untouched. That snapshot was taken mid-529-recovery, before your staged set was complete; all nine files and both scripts were already handled. The warning cost you nothing, but it was me reporting a partial observation as a finding — the same thing I have corrected lanes for twice today.

The cost nobody had counted

The four Windows presets now build Catch2 from source with no compiler cache — that job has neither sccache nor MORPH_DEP_CACHE. The earlier lane's ~1.7 min / ~28 runner-minute estimate did not include it. Flagging it in the commit and PR rather than absorbing it is right, and it is the line to revisit if a Windows leg becomes the critical path.

#693 is a good catch with an honest label: every FetchContent pin written twice (morph_cache_dep tag vs GIT_TAG), with FETCHCONTENT_SOURCE_DIR_<NAME> FORCE-set from the cache slug — so a disagreement makes CI build the cached revision and a local build the declared one, silently, both green. Marked inferred from reading, not reproduced.

Not verified by me: the 1-vs-45 measurement, the 1588-test run, and the ladder+bank configure. That last one matters most — it is the step the earlier lane could not run, and it is what proves the TARGET guards resolve against the fetched Catch2.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner: eight legs red, all at Configure — a real defect, cause not yet established

  Application ladder                     Configure (gcc-debug, ladder + Qt + bank on)
  Application ladder / ASan+UBSan        Configure (clang-asan, ladder + Qt on)
  Bank example / UBSan                   Configure (clang-ubsan, bank example + GUI)
  Kanban / ThreadSanitizer               Configure (clang-tsan, kanban only)
  Linux / all optional features (clang)  Configure (every optional feature ON)
  Linux / all optional features (gcc)    Configure (every optional feature ON)
  Linux / clang-coverage                 Configure
  clang-tidy-diff                        Configure (generates compile_commands.json …)

Eight for eight at Configure — this is the CMake restructure, not the environment. None of today's infrastructure signatures fails there.

What I established, and what I did not

Found the difference that matters and it is not what either of us assumed. cmake/DepCache.cmake:34:

elseif(DEFINED ENV{CI} AND DEFINED ENV{HOME})
    set(MORPH_DEP_CACHE_DIR "$ENV{HOME}/.cache/morph-dep-cache")

GitHub always sets CI; a workstation never does. So your local configure never exercised morph_cache_dep at all — the code path this PR newly routes Catch2 through is exactly the one a local run skips. That is worth knowing regardless of whether it turns out to be the cause.

But it does not reproduce. With CI=true, a cold MORPH_DEP_CACHE, and kanban-tsan's flags copied verbatim from the workflow:

-- morph: dep cache: populating Catch2 (v3.8.1) at .../Catch2_v3_8_1
-- morph: dep cache: Catch2 from .../Catch2_v3_8_1
configure exit=0

So the dep-cache path works here. The cause is something else — a cache restored from a key rather than cold, network shape, or Qt provenance are the candidates I have not tested.

One check is still pending, so the log is unreadable. I am not guessing further: today has repeatedly punished that, and this PR deserves the actual error.

A correction on my own first attempt

My first reproduction "failed" with MORPH_BUILD_LADDER requires MORPH_BUILD_QT=ON — because I passed -DMORPH_BUILD_LADDER=ON without -DMORPH_BUILD_QT=ON. That is a pre-existing, deliberate guard doing its job, not this branch's bug, and I nearly reported it as one. Copying the job's own flags rather than approximating them is what caught it. Third invalid probe of mine today, same root cause each time: approximating the configuration that judges you instead of copying it.

Where that leaves the PR

Nothing about the earlier verification retracts — the silent-skip bug you found and fixed is real (I confirmed message(WARNING) on master), SYSTEM is load-bearing, and retiring the gate rather than re-pointing it is well argued. But eight configure failures is the branch's to answer, and the next step is reading that log, not another hypothesis.

It will also need a rebase: master is now a9cb5649 (#689 landed).

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut Yaraslaut closed this Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant