|
| 1 | +# Workspace-aware `mcpp test` + zero-shell self-contained mcpp-index (Design) |
| 2 | + |
| 3 | +Two coupled deliverables toward "把对库的测试做成真实工程、基于 mcpp 自包含、消除所有 |
| 4 | +shell": |
| 5 | + |
| 6 | +- **Phase 1 (repo `mcpp`)** — make `mcpp test` workspace-aware: `mcpp test -p <member>`, |
| 7 | + `mcpp build|test --workspace`, and fix bare `mcpp test` at a workspace root. |
| 8 | +- **Phase 2 (repo `mcpp-index`)** — turn each `tests/<lib>/` into a real mcpp test |
| 9 | + project (`mcpp test` + behavioral assertions), migrate the `smoke_compat_*.sh` |
| 10 | + heredocs into them, and delete every shell driver — CI becomes `mcpp test --workspace`. |
| 11 | + |
| 12 | +Pairs with the typed `import mcpp;` build library |
| 13 | +(`2026-06-30-l3-build-mcpp-implementation-design.md` §forward-note, task #20) as the |
| 14 | +**0.0.79 "workspace-test + build.mcpp library"** release. |
| 15 | + |
| 16 | +--- |
| 17 | + |
| 18 | +## 1. Problem (grounded in the code) |
| 19 | + |
| 20 | +`mcpp build` is workspace-aware; `mcpp test` is not. |
| 21 | + |
| 22 | +- `mcpp build -p <member>` works (`src/cli.cppm` build subcommand has |
| 23 | + `.option("package").short_name('p')`; `cmd_build` copies it to |
| 24 | + `BuildOverrides::package_filter` — `src/cli/cmd_build.cppm`; `prepare_build` |
| 25 | + resolves the member and **reassigns `root` to the member dir** — |
| 26 | + `src/build/prepare.cppm:415-509`). |
| 27 | +- `mcpp test` has **no** `-p` / `--workspace` (`src/cli.cppm` test subcommand). |
| 28 | +- **The bug:** `run_tests` (`src/build/execute.cppm:421-459`) calls |
| 29 | + `find_manifest_root()` → gets the **workspace** root → `expand_glob(*root, |
| 30 | + "tests/**/*.cpp")` → collects *every member's* `tests/.../main.cpp` → |
| 31 | + `seenNames.insert("main")` collides → `error: duplicate test name 'main'`. Test |
| 32 | + discovery runs **before** any member selection, on the unscoped workspace root. |
| 33 | + |
| 34 | +Proof: |
| 35 | +``` |
| 36 | +$ mcpp build # virtual ws root → "Workspace building member 'tests/examples/build-mcpp'" (picks ONE, arbitrarily) |
| 37 | +$ mcpp test # virtual ws root → error: duplicate test name 'main' (globs ALL, unscoped) |
| 38 | +``` |
| 39 | + |
| 40 | +## 2. Architecture principle |
| 41 | + |
| 42 | +**Scope, don't duplicate.** The root-test bug is *missing member scoping*, not a |
| 43 | +missing second globber. The fix is to run the **same** member resolution `build` |
| 44 | +uses *before* test discovery, so `tests/**/*.cpp` is globbed from the **member** |
| 45 | +dir. `--workspace` is **thin orchestration**: a loop over members that calls the |
| 46 | +**existing per-member pipeline** once each — no parallel build/test path. |
| 47 | + |
| 48 | +Concretely, extract today's inline member logic into one shared helper and have |
| 49 | +both `build` and `test` consume it: |
| 50 | + |
| 51 | +```cpp |
| 52 | +// src/build/workspace.cppm (new, or fold into project.cppm) |
| 53 | +namespace mcpp::build { |
| 54 | + struct MemberRef { std::string name; std::filesystem::path dir; }; |
| 55 | + // Resolve which members a command acts on, from the (already-loaded) root |
| 56 | + // manifest + overrides. Encapsulates the selection rules in §3. |
| 57 | + std::expected<std::vector<MemberRef>, std::string> |
| 58 | + select_members(const manifest::Manifest& root, const std::filesystem::path& rootDir, |
| 59 | + const BuildOverrides& ov, bool wantAll); |
| 60 | +} |
| 61 | +``` |
| 62 | +
|
| 63 | +- `-p <name>` → 1 member (match by directory basename **or** member path — the |
| 64 | + rule already in `prepare.cppm:430-447`). |
| 65 | +- `--workspace` (or bare at a **virtual** workspace) → **all** members. |
| 66 | +- Bare at a **rooted** workspace (`[package]` + `[workspace]`) → the root package |
| 67 | + (members only via `--workspace`), matching today. |
| 68 | +
|
| 69 | +`prepare_build`'s existing per-member switch (load member manifest, `merge_workspace_deps`, |
| 70 | +inherit toolchain/target/indices, `root = memberDir`) stays as the **single-member** |
| 71 | +mechanism; `select_members` just decides the *set*, and the orchestration loop calls |
| 72 | +`prepare_build(package_filter = member.name)` per member. |
| 73 | +
|
| 74 | +## 3. Behavior matrix (decision) |
| 75 | +
|
| 76 | +| invocation | virtual workspace | rooted workspace | plain package | |
| 77 | +|---|---|---|---| |
| 78 | +| `mcpp build` / `mcpp test` | **all members** | root package | the package | |
| 79 | +| `... -p <m>` | member `<m>` | member `<m>` | error (no members) | |
| 80 | +| `... --workspace` | all members | all members (+root) | error | |
| 81 | +| `mcpp run` (+`-p`) | default/`-p` member | root/`-p` | the package | |
| 82 | +
|
| 83 | +**Change from today:** bare build/test at a *virtual* workspace goes from "pick one |
| 84 | +arbitrary member" → **all members** (Cargo-consistent; the pick-one was a |
| 85 | +placeholder). `run` stays single-target (no `--workspace`). This is the only |
| 86 | +behavior change; verify/adjust the workspace e2e (`tests/e2e/*workspace*`). |
| 87 | +
|
| 88 | +## 4. Phase 1 — implementation touch points |
| 89 | +
|
| 90 | +1. **CLI** (`src/cli.cppm`, test subcommand ~L244-256): add |
| 91 | + ```cpp |
| 92 | + .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") |
| 93 | + .help("Run tests only for the named workspace member")) |
| 94 | + .option(cl::Option("workspace").help("Run tests for all workspace members")) |
| 95 | + ``` |
| 96 | + and add `.option("workspace")` to the **build** subcommand (~L215-237). |
| 97 | +2. **Parse** (`src/cli/cmd_build.cppm`): in `cmd_test`, copy `-p` → |
| 98 | + `ov.package_filter`; read `--workspace` → `bool all`. Same `--workspace` read in |
| 99 | + `cmd_build`. |
| 100 | +3. **`select_members` helper** (new `src/build/workspace.cppm`): the §2 logic, |
| 101 | + extracted from `prepare.cppm:422-453` so both paths share it. |
| 102 | +4. **`run_tests` scoping** (`src/build/execute.cppm:421-459`): before the |
| 103 | + `expand_glob`, resolve the member root for the single-member case (when |
| 104 | + `package_filter` set, via the shared helper) and glob from the **member dir**, |
| 105 | + not the workspace root. This kills the "duplicate main" bug by *scoping*. |
| 106 | +5. **`--workspace` orchestration** (`src/cli/cmd_build.cppm`): when `all`, call |
| 107 | + `select_members(..., wantAll=true)` and loop — `cmd_build` runs |
| 108 | + `prepare_build`+`run_build_plan` per member; `cmd_test` runs `run_tests` per |
| 109 | + member (each with `package_filter = member.name`). Aggregate exit codes |
| 110 | + (first non-zero wins; print a per-member summary). Continue-on-failure so one |
| 111 | + member's failure still reports the rest. |
| 112 | +6. **Tests** (`tests/e2e/90_workspace_test.sh`): a virtual workspace with 2 members |
| 113 | + each having `tests/<distinct>.cpp` asserting behavior; assert |
| 114 | + `mcpp test -p memberA` runs only A's tests, `mcpp test --workspace` runs both |
| 115 | + (no duplicate-stem error), bare `mcpp test` at the root runs both. |
| 116 | +7. **Docs** (`docs/06-workspace.md` + zh): document `-p`/`--workspace` for |
| 117 | + build/test and the bare-at-root semantics. |
| 118 | + |
| 119 | +## 5. Phase 2 — mcpp-index zero-shell restructure (repo `mcpp-index`) |
| 120 | + |
| 121 | +### Target layout |
| 122 | +``` |
| 123 | +mcpp-index/ |
| 124 | + pkgs/ # the index (recipes) — unchanged |
| 125 | + mcpp.toml # [workspace] members=tests/* ⊕ [indices] compat={path="."} |
| 126 | + tests/ |
| 127 | + cjson/ |
| 128 | + mcpp.toml # [package] cjson-tests; [dependencies] compat.cjson |
| 129 | + tests/parse.cpp # mcpp test — assert cJSON_Parse fields |
| 130 | + eigen/ tests/matmul.cpp # assert A*B values |
| 131 | + nlohmann.json/ tests/roundtrip.cpp # parse→dump→parse equality |
| 132 | + openblas/ # [target.'cfg(windows)'.dependencies] openblas; tests/dgemm.cpp asserts [19 22;43 50] (no-op gate off-Windows) |
| 133 | + build-mcpp/ # build.mcpp generates a source; tests/ assert it linked |
| 134 | +``` |
| 135 | + |
| 136 | +### Test mechanism |
| 137 | +mcpp's native `mcpp test` discovers `tests/**/*.cpp`, builds each as a test binary, |
| 138 | +runs it (non-zero = fail). Members use plain assertion `.cpp` (a failing `assert`/ |
| 139 | +non-zero return), no external framework required — keeps members dependency-light |
| 140 | +and host-portable. (gtest remains available via `[dev-dependencies]` if a member |
| 141 | +wants richer output, but is not required.) |
| 142 | + |
| 143 | +### Migration (delete all shell) |
| 144 | +- Convert each `smoke_compat_*.sh` heredoc body into the matching member's |
| 145 | + `tests/*.cpp` (the heredocs already contain the usage snippets). |
| 146 | +- **Delete** `tests/smoke_compat_{core,imgui,archive,imgui_window}.sh`, |
| 147 | + `tests/smoke_imgui_module.sh`, `tests/smoke_compat_portable.sh`, |
| 148 | + `tests/run_workspace.sh`, `tests/run_example.sh`. |
| 149 | +- **`validate.yml`** collapses: the per-suite jobs become one matrix or a single |
| 150 | + `mcpp test --workspace` step: |
| 151 | + ```yaml |
| 152 | + - run: mcpp test --workspace # linux; the whole index, self-contained |
| 153 | + ``` |
| 154 | + Windows/macOS jobs run the same command (platform-gated members like openblas |
| 155 | + self-gate via `cfg(windows)`; the `detect` job can still narrow to `-p <lib>` on |
| 156 | + PRs touching one recipe). No shell driver remains. |
| 157 | + |
| 158 | +### Result |
| 159 | +The index repo is simultaneously (a) the package index and (b) a mcpp workspace |
| 160 | +whose members really *use and test* every recipe — driven entirely by `mcpp`, no |
| 161 | +`.sh`. "基于 mcpp 自包含" achieved. |
| 162 | + |
| 163 | +## 6. Typed `import mcpp;` library (task #20) — same 0.0.79 |
| 164 | + |
| 165 | +Independent of Phase 1; ships in the same release. Per the build.mcpp design doc's |
| 166 | +forward note: a typed module **bundled in the mcpp binary**, emitting the existing |
| 167 | +stdout `mcpp:` wire protocol, implemented with C-level I/O so neither it nor |
| 168 | +`build.mcpp` needs `import std;`. Converts the `build.mcpp` examples/docs to the |
| 169 | +modules-first `import mcpp;` form. The `build-mcpp` mcpp-index member adopts it in |
| 170 | +Phase 2. |
| 171 | + |
| 172 | +## 7. Sequencing & releases |
| 173 | + |
| 174 | +1. **mcpp PR A** — Phase 1 (workspace-aware test) + e2e + docs. |
| 175 | +2. **mcpp PR B** — typed `import mcpp;` library + convert build.mcpp docs/examples/test. |
| 176 | +3. **Release mcpp 0.0.79** (A+B) → full ecosystem loop (mirror → index → verify → pin). |
| 177 | +4. **mcpp-index PR C** — Phase 2 restructure on 0.0.79; delete all shell; CI = |
| 178 | + `mcpp test --workspace` → its CI green → merge. |
| 179 | + |
| 180 | +## 8. Risks / soundness notes |
| 181 | + |
| 182 | +- **Behavior change** (bare virtual-ws build/test → all members): the one |
| 183 | + compatibility-affecting change; covered by updating the workspace e2e and is the |
| 184 | + more-correct semantics. `run` is untouched (single-target). |
| 185 | +- **No parallel code path**: `--workspace`/`-p` are orchestration over the proven |
| 186 | + per-member `prepare_build`/`run_tests`; the helper centralizes selection so the |
| 187 | + rules can't drift between `build` and `test` (the class of bug we're fixing). |
| 188 | +- **Aggregate reporting**: `--workspace` must continue-on-failure and print a |
| 189 | + per-member pass/fail summary, else one red member hides the rest. |
| 190 | +- **Per-member dep isolation** is the reason for the workspace (vs one mega |
| 191 | + `[package]`): members resolve independently, so conflicting transitive deps and |
| 192 | + platform-only libs (openblas/Windows) don't couple. Recorded as the rejected |
| 193 | + single-project alternative. |
0 commit comments