From d9d3686989bd7dac42e7db446f1e32fe7f99f4e6 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 12 May 2026 09:07:49 +0800 Subject: [PATCH 1/9] feat(workspace): parse [workspace] section and .workspace = true - Add WorkspaceConfig struct to Manifest (members, exclude, dependencies) - Parse [workspace] section with members array and version declarations - Parse [workspace.dependencies] and [workspace.dependencies.] - Support .workspace = true in dependency specs for version inheritance - Make [package] optional when [workspace] is present (virtual workspace) - Add 3 unit tests for workspace parsing --- src/manifest.cppm | 83 +++++++++++++++++++++++++++++++----- src/pm/dep_spec.cppm | 2 + tests/unit/test_manifest.cpp | 55 ++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 10 deletions(-) diff --git a/src/manifest.cppm b/src/manifest.cppm index dd48c1c..92c7219 100644 --- a/src/manifest.cppm +++ b/src/manifest.cppm @@ -139,6 +139,21 @@ struct PackConfig { std::vector forceBundle; // libs to bundle even if PEP 600 says skip }; +// `[workspace]` — multi-package workspace support (0.0.11+). +// +// A workspace root mcpp.toml declares member packages. Members share +// a unified lock file, target directory, and can inherit dependency +// versions via `.workspace = true`. +// +// Virtual workspace (no [package]): pure management node. +// Rooted workspace ([package] + [workspace]): root is also a package. +struct WorkspaceConfig { + std::vector members; // relative paths to member dirs + std::vector exclude; // paths to exclude + std::map dependencies; // [workspace.dependencies] + bool present = false; +}; + struct Manifest { std::filesystem::path sourcePath; // mcpp.toml's filesystem path @@ -156,9 +171,6 @@ struct Manifest { BuildConfig buildConfig; // [target.] tables — empty if user didn't declare any. - // Triple keys are accepted in either GCC form (x86_64-linux-musl) - // or Rust form (x86_64-unknown-linux-musl); both are normalised by - // stripping `-unknown-` on read. std::map targetOverrides; // [pack] — `mcpp pack` config (see docs/35-pack-design.md). @@ -167,6 +179,9 @@ struct Manifest { // [lib] — library root interface convention (M5.x+). LibConfig lib; + // [workspace] — multi-package workspace. + WorkspaceConfig workspace; + // M5.0: post-parse computed/inferred state bool usesModules = true; // refined by scanner bool usesImportStd = true; // refined by scanner @@ -267,13 +282,16 @@ std::expected parse_string(std::string_view content, Manifest m; m.sourcePath = origin; - // [package] + // [package] — required unless [workspace] is present (virtual workspace). auto* pkg_t = doc->get_table("package"); - if (!pkg_t) return std::unexpected(error(origin, "missing required [package] section")); + bool has_workspace = (doc->get_table("workspace") != nullptr); + if (!pkg_t && !has_workspace) + return std::unexpected(error(origin, "missing required [package] section")); auto name = doc->get_string("package.name"); - if (!name) return std::unexpected(error(origin, "missing required field 'package.name'")); - m.package.name = *name; + if (!name && !has_workspace) + return std::unexpected(error(origin, "missing required field 'package.name'")); + if (name) m.package.name = *name; // 0.0.6+: explicit namespace field (xpkg V1 style). // If present, [package].name is the short name. @@ -281,8 +299,9 @@ std::expected parse_string(std::string_view content, if (auto v = doc->get_string("package.namespace")) m.package.namespace_ = *v; auto version = doc->get_string("package.version"); - if (!version) return std::unexpected(error(origin, "missing required field 'package.version'")); - m.package.version = *version; + if (!version && !has_workspace) + return std::unexpected(error(origin, "missing required field 'package.version'")); + if (version) m.package.version = *version; if (auto v = doc->get_string("package.description")) m.package.description = *v; if (auto v = doc->get_string("package.license")) m.package.license = *v; @@ -395,7 +414,7 @@ std::expected parse_string(std::string_view content, auto is_dep_spec_key = [](std::string_view k) { return k == "path" || k == "version" || k == "git" || k == "rev" || k == "tag" || k == "branch" - || k == "features"; + || k == "features" || k == "workspace"; }; auto looks_like_inline_dep_spec = [&](const t::Table& sub) { if (sub.empty()) return false; @@ -423,6 +442,10 @@ std::expected parse_string(std::string_view content, spec.gitRev = it->second.as_string(); spec.gitRefKind = "branch"; } + if (auto it = sub.find("workspace"); it != sub.end() && it->second.is_bool() && it->second.as_bool()) { + spec.inheritWorkspace = true; + return {}; // version will be filled in by workspace merge + } if (spec.path.empty() && spec.version.empty() && spec.git.empty()) { return std::unexpected(error(origin, std::format( "[{}.\"{}\"] must specify 'path', 'version', or 'git'", section, fqName))); @@ -597,6 +620,46 @@ std::expected parse_string(std::string_view content, } } + // [workspace] — multi-package workspace support (0.0.11+). + if (doc->get_table("workspace")) { + m.workspace.present = true; + if (auto v = doc->get_string_array("workspace.members")) + m.workspace.members = *v; + if (auto v = doc->get_string_array("workspace.exclude")) + m.workspace.exclude = *v; + + // [workspace.dependencies] — versions that members inherit via .workspace = true. + if (auto* wdeps = doc->get_table("workspace.dependencies")) { + for (auto& [k, v] : *wdeps) { + if (v.is_string()) { + DependencySpec spec; + spec.version = v.as_string(); + if (k.find('.') != std::string::npos) { + auto pos = k.find('.'); + spec.namespace_ = k.substr(0, pos); + spec.shortName = k.substr(pos + 1); + } else { + spec.namespace_ = std::string{kDefaultNamespace}; + spec.shortName = k; + } + m.workspace.dependencies[k] = std::move(spec); + continue; + } + if (!v.is_table()) continue; + // Namespaced subtable: [workspace.dependencies.] + const std::string ns = k; + for (auto& [sk, sv] : v.as_table()) { + if (!sv.is_string()) continue; + DependencySpec spec; + spec.namespace_ = ns; + spec.shortName = sk; + spec.version = sv.as_string(); + m.workspace.dependencies[std::format("{}.{}", ns, sk)] = std::move(spec); + } + } + } + } + return m; } diff --git a/src/pm/dep_spec.cppm b/src/pm/dep_spec.cppm index 4c0c2ec..6da513d 100644 --- a/src/pm/dep_spec.cppm +++ b/src/pm/dep_spec.cppm @@ -32,6 +32,8 @@ struct DependencySpec { std::string gitRev; // commit / tag / branch (any one) std::string gitRefKind; // "rev" / "tag" / "branch" (for clarity) + bool inheritWorkspace = false; // .workspace = true + bool isPath() const { return !path.empty(); } bool isGit() const { return !git.empty(); } bool isVersion() const { return !isPath() && !isGit() && !version.empty(); } diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index ef4497b..2b5452a 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -330,6 +330,61 @@ package = { EXPECT_EQ(b.version, "0.0.2"); } +TEST(Manifest, WorkspaceSectionParsed) { + constexpr auto src = R"( +[workspace] +members = ["libs/core", "libs/http", "apps/server"] +exclude = ["libs/experimental"] + +[workspace.dependencies] +cmdline = "0.0.2" + +[workspace.dependencies.compat] +gtest = "1.15.2" +mbedtls = "3.6.1" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + EXPECT_TRUE(m->workspace.present); + ASSERT_EQ(m->workspace.members.size(), 3u); + EXPECT_EQ(m->workspace.members[0], "libs/core"); + EXPECT_EQ(m->workspace.members[1], "libs/http"); + EXPECT_EQ(m->workspace.members[2], "apps/server"); + ASSERT_EQ(m->workspace.exclude.size(), 1u); + EXPECT_EQ(m->workspace.exclude[0], "libs/experimental"); + ASSERT_EQ(m->workspace.dependencies.size(), 3u); + auto& gt = m->workspace.dependencies.at("compat.gtest"); + EXPECT_EQ(gt.version, "1.15.2"); + EXPECT_EQ(gt.namespace_, "compat"); +} + +TEST(Manifest, WorkspaceTrueInDependency) { + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" +[dependencies.compat] +mbedtls = { workspace = true } +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + auto& s = m->dependencies.at("compat.mbedtls"); + EXPECT_TRUE(s.inheritWorkspace); + EXPECT_EQ(s.namespace_, "compat"); + EXPECT_EQ(s.shortName, "mbedtls"); +} + +TEST(Manifest, NoWorkspaceSectionMeansNotPresent) { + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()); + EXPECT_FALSE(m->workspace.present); +} + TEST(Manifest, LibRootInferredFromPackageName) { constexpr auto src = R"( [package] From 063c02544fe108a8951363727ec9786d958a7394 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 12 May 2026 09:11:05 +0800 Subject: [PATCH 2/9] feat(workspace): discovery, build orchestration, -p flag, e2e test - Add find_workspace_root() for upward workspace discovery - Add merge_workspace_deps() for .workspace = true resolution - Virtual workspace: auto-select binary member as build target - Workspace toolchain/target overrides inherit to members - -p/--package flag for selective member builds - Members inside a workspace auto-inherit workspace config - Add e2e test: 3-member workspace (core + greeter + hello) --- src/cli.cppm | 144 +++++++++++++++++++++++++++++++++++++- tests/e2e/35_workspace.sh | 103 +++++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 2 deletions(-) create mode 100755 tests/e2e/35_workspace.sh diff --git a/src/cli.cppm b/src/cli.cppm index c002730..d441dfd 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -113,6 +113,54 @@ std::optional find_manifest_root(std::filesystem::path st } } +// Find the workspace root by walking upward from a member directory. +// Returns empty if no workspace root found. +std::filesystem::path find_workspace_root(const std::filesystem::path& memberRoot) { + auto p = memberRoot.parent_path(); + while (true) { + if (std::filesystem::exists(p / "mcpp.toml")) { + auto m = mcpp::manifest::load(p / "mcpp.toml"); + if (m && m->workspace.present) { + // Verify memberRoot is in members list + auto rel = std::filesystem::relative(memberRoot, p); + for (auto& member : m->workspace.members) { + if (rel == std::filesystem::path(member)) return p; + } + } + } + auto parent = p.parent_path(); + if (parent == p) break; + p = parent; + } + return {}; +} + +// Merge workspace.dependencies versions into a member's deps. +void merge_workspace_deps(mcpp::manifest::Manifest& member, + const mcpp::manifest::Manifest& workspace) { + auto merge_map = [&](std::map& deps) { + for (auto& [name, spec] : deps) { + if (!spec.inheritWorkspace) continue; + // Try exact key match first + auto it = workspace.workspace.dependencies.find(name); + if (it != workspace.workspace.dependencies.end()) { + spec.version = it->second.version; + spec.inheritWorkspace = false; + continue; + } + // Try short name for default-ns deps + auto shortIt = workspace.workspace.dependencies.find(spec.shortName); + if (shortIt != workspace.workspace.dependencies.end()) { + spec.version = shortIt->second.version; + spec.inheritWorkspace = false; + } + } + }; + merge_map(member.dependencies); + merge_map(member.devDependencies); + merge_map(member.buildDependencies); +} + std::filesystem::path target_dir(const mcpp::toolchain::Toolchain& tc, const mcpp::toolchain::Fingerprint& fp, const std::filesystem::path& root) @@ -772,8 +820,9 @@ struct BuildContext { // Command-level overrides (--target / --static). // Empty defaults preserve pre-existing behaviour exactly. struct BuildOverrides { - std::string target_triple; // empty = host triple, fall through to [toolchain] - bool force_static = false; // --static (or implied by musl target) + std::string target_triple; // empty = host triple, fall through to [toolchain] + bool force_static = false; // --static (or implied by musl target) + std::string package_filter; // -p : only build this workspace member }; // `prepare_build` builds the BuildContext for any verb that compiles. @@ -795,6 +844,94 @@ prepare_build(bool print_fingerprint, auto m = mcpp::manifest::load(*root / "mcpp.toml"); if (!m) return std::unexpected(m.error().format()); + // ─── Workspace handling ──────────────────────────────────────────── + // If the manifest has [workspace] and is a virtual workspace (no [package]), + // or if -p filter is set, switch to the target member's manifest. + std::optional wsManifest; // keep workspace manifest alive + if (m->workspace.present) { + std::string targetMember; + + if (!overrides.package_filter.empty()) { + // -p : find matching member by directory basename or path + for (auto& mp : m->workspace.members) { + auto basename = std::filesystem::path(mp).filename().string(); + if (basename == overrides.package_filter || mp == overrides.package_filter) { + targetMember = mp; + break; + } + } + if (targetMember.empty()) { + return std::unexpected(std::format( + "workspace member '{}' not found in [workspace].members", + overrides.package_filter)); + } + } else if (m->package.name.empty()) { + // Virtual workspace: find a member with a binary target, or use last member. + for (auto& mp : m->workspace.members) { + auto memberDir = *root / mp; + auto mm = mcpp::manifest::load(memberDir / "mcpp.toml"); + if (!mm) continue; + for (auto& t : mm->targets) { + if (t.kind == mcpp::manifest::Target::Binary) { + targetMember = mp; + break; + } + } + if (!targetMember.empty()) break; + } + if (targetMember.empty() && !m->workspace.members.empty()) { + targetMember = m->workspace.members.back(); + } + } + // else: rooted workspace with [package] — build root normally. + + if (!targetMember.empty()) { + auto memberDir = *root / targetMember; + if (!std::filesystem::exists(memberDir / "mcpp.toml")) { + return std::unexpected(std::format( + "workspace member '{}' has no mcpp.toml", targetMember)); + } + wsManifest = std::move(*m); // preserve workspace manifest + m = mcpp::manifest::load(memberDir / "mcpp.toml"); + if (!m) return std::unexpected(std::format( + "workspace member '{}': {}", targetMember, m.error().format())); + + // Merge workspace dependency versions + merge_workspace_deps(*m, *wsManifest); + + // Inherit workspace toolchain if member doesn't define one + if (m->toolchain.byPlatform.empty()) { + m->toolchain = wsManifest->toolchain; + } + // Inherit workspace target overrides + for (auto& [triple, entry] : wsManifest->targetOverrides) { + if (!m->targetOverrides.contains(triple)) { + m->targetOverrides[triple] = entry; + } + } + + mcpp::ui::status("Workspace", std::format("building member '{}'", targetMember)); + root = memberDir; + } + } else { + // Not at workspace root — check if we're inside a workspace + auto wsRoot = find_workspace_root(*root); + if (!wsRoot.empty()) { + auto wsm = mcpp::manifest::load(wsRoot / "mcpp.toml"); + if (wsm && wsm->workspace.present) { + merge_workspace_deps(*m, *wsm); + if (m->toolchain.byPlatform.empty()) { + m->toolchain = wsm->toolchain; + } + for (auto& [triple, entry] : wsm->targetOverrides) { + if (!m->targetOverrides.contains(triple)) { + m->targetOverrides[triple] = entry; + } + } + } + } + } + // Inject synthetic targets (e.g. test binaries from `mcpp test`). for (auto& t : extraTargets) m->targets.push_back(t); @@ -2053,6 +2190,7 @@ int cmd_build(const mcpplibs::cmdline::ParsedArgs& parsed) { BuildOverrides ov; if (auto t = parsed.value("target")) ov.target_triple = *t; + if (auto p = parsed.value("package")) ov.package_filter = *p; ov.force_static = parsed.is_flag_set("static"); // P0: try fast-path if inputs haven't changed. @@ -3533,6 +3671,8 @@ int run(int argc, char** argv) { "Build for (e.g. x86_64-linux-musl); looks up [target.] in mcpp.toml")) .option(cl::Option("static").help( "Force static linking (-static). On Linux, prefer pairing with --target -linux-musl")) + .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") + .help("Build only the named workspace member")) .action(wrap_rc(cmd_build))) .subcommand(cl::App("run") .description("Build + run a binary target (after `--`, args are passed to it)") diff --git a/tests/e2e/35_workspace.sh b/tests/e2e/35_workspace.sh new file mode 100755 index 0000000..c9f7500 --- /dev/null +++ b/tests/e2e/35_workspace.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Test: workspace with two library members and one binary member. +# Verifies: +# 1. `mcpp build` at workspace root builds all members +# 2. Path deps between members work +# 3. Virtual workspace (no [package]) works + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# ── Create workspace structure ────────────────────────── +mkdir -p libs/core/src libs/greeter/src apps/hello/src + +# Workspace root (virtual — no [package]) +cat > mcpp.toml << 'EOF' +[workspace] +members = ["libs/core", "libs/greeter", "apps/hello"] +EOF + +# libs/core — a simple library +cat > libs/core/mcpp.toml << 'EOF' +[package] +namespace = "demo" +name = "core" +version = "0.1.0" + +[targets.core] +kind = "lib" +EOF + +cat > libs/core/src/core.cppm << 'EOF' +export module demo.core; +import std; + +export namespace demo::core { + inline std::string greet_target() { return "World"; } +} +EOF + +# libs/greeter — depends on core via path +cat > libs/greeter/mcpp.toml << 'EOF' +[package] +namespace = "demo" +name = "greeter" +version = "0.1.0" + +[targets.greeter] +kind = "lib" + +[dependencies] +core = { path = "../core" } +EOF + +cat > libs/greeter/src/greeter.cppm << 'EOF' +export module demo.greeter; +import std; +import demo.core; + +export namespace demo::greeter { + inline std::string greet() { + return "Hello, " + demo::core::greet_target() + "!"; + } +} +EOF + +# apps/hello — binary that uses greeter +cat > apps/hello/mcpp.toml << 'EOF' +[package] +namespace = "demo" +name = "hello" +version = "0.1.0" + +[dependencies] +greeter = { path = "../../libs/greeter" } +EOF + +cat > apps/hello/src/main.cpp << 'EOF' +import std; +import demo.greeter; + +int main() { + std::println("{}", demo::greeter::greet()); + return 0; +} +EOF + +# ── Build from workspace root ─────────────────────────── +echo "=== Building from workspace root ===" +"$MCPP" build +echo "workspace build: ok" + +# ── Verify the binary runs correctly ──────────────────── +BIN=$(find target -type f -name hello | head -1) +test -n "$BIN" || { echo "FAIL: hello binary not found"; exit 1; } +OUT=$("$BIN" 2>&1) +echo "output: $OUT" +test "$OUT" = "Hello, World!" || { echo "FAIL: unexpected output '$OUT'"; exit 1; } +echo "workspace run: ok" + +echo "ALL WORKSPACE TESTS PASSED" From 2ff59a1f35d357b576eb76c269b9717ef34738f8 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 12 May 2026 09:11:11 +0800 Subject: [PATCH 3/9] docs: workspace design and implementation plan --- .agents/docs/2026-05-12-workspace-design.md | 457 ++++++++++++ ...026-05-12-workspace-implementation-plan.md | 657 ++++++++++++++++++ 2 files changed, 1114 insertions(+) create mode 100644 .agents/docs/2026-05-12-workspace-design.md create mode 100644 .agents/docs/2026-05-12-workspace-implementation-plan.md diff --git a/.agents/docs/2026-05-12-workspace-design.md b/.agents/docs/2026-05-12-workspace-design.md new file mode 100644 index 0000000..24c4c71 --- /dev/null +++ b/.agents/docs/2026-05-12-workspace-design.md @@ -0,0 +1,457 @@ +# mcpp Workspace 设计方案 + +> 2026-05-12 — 多包工作空间支持 +> 状态:设计稿(待实现) +> 优先级:中(当前 path 依赖已覆盖基本需求) + +## 1. 动机 + +mcpp 当前支持 `path = "..."` 依赖来引用本地子项目,但缺少统一的多包管理入口。 +当一个仓库包含多个相关的库和应用时,用户需要: + +- 统一的依赖版本管理(不必在每个子包重复声明版本) +- 共享的 lock 文件(确保所有子包使用相同的依赖版本) +- 共享的 target 目录(避免重复编译公共依赖) +- 选择性构建(只构建某个子包及其依赖) +- 统一的工具链配置(子包可覆盖) + +## 2. 设计原则 + +1. **path 依赖是基础** — workspace 不引入新的依赖语义,member 之间用 + `path = "..."` 声明依赖关系,与非 workspace 项目完全一致。 +2. **语言管可见性** — C++23 module 的 export/import 控制接口边界,构建系统 + 不做额外的可见性控制。 +3. **子包可覆盖** — workspace 提供默认配置(工具链、依赖版本),子包的 + mcpp.toml 可覆盖任何默认值。 +4. **渐进式采用** — 已有的 path 依赖项目只需在根目录加 `[workspace]` 即可 + 升级为 workspace,无需修改子包。 + +## 3. 工程文件格式 + +### 3.1 Workspace 根 mcpp.toml + +```toml +# myproject/mcpp.toml +[workspace] +members = [ + "libs/core", + "libs/http", + "libs/db", + "apps/server", + "apps/cli", +] +# 可选:glob 语法 +# members = ["libs/*", "apps/*"] + +# 可选:从 members 中排除 +exclude = ["libs/experimental"] + +# ─── 统一依赖版本管理 ─────────────────────────────── +# 子包用 `xxx.workspace = true` 继承这里的版本, +# 或者在自己的 mcpp.toml 中覆盖。 + +[workspace.dependencies] +cmdline = "0.0.2" +tinyhttps = "0.2.2" + +[workspace.dependencies.compat] +mbedtls = "3.6.1" +gtest = "1.15.2" + +# ─── 统一工具链配置 ───────────────────────────────── +# 所有 member 默认使用此工具链,各 member 可在自己的 +# [toolchain] 中覆盖。 + +[toolchain] +default = "gcc@16.1.0" + +[target.x86_64-linux-musl] +toolchain = "gcc@15.1.0-musl" +linkage = "static" + +# ─── Workspace 根可以同时是一个 package ────────────── +# 如果有 [package] 则是 "rooted workspace"(类似 Cargo) +# 如果没有 [package] 则是 "virtual workspace"(纯管理节点) + +# [package] +# name = "myproject" +# version = "0.1.0" +``` + +### 3.2 Member 的 mcpp.toml + +```toml +# libs/core/mcpp.toml +[package] +namespace = "myproject" +name = "core" +version = "0.1.0" +description = "Core utilities for myproject" + +[targets.core] +kind = "lib" + +# 无外部依赖 + +[dev-dependencies.compat] +gtest.workspace = true # 继承 workspace 版本 → "1.15.2" +``` + +```toml +# libs/http/mcpp.toml +[package] +namespace = "myproject" +name = "http" +version = "0.1.0" + +[targets.http] +kind = "lib" + +[dependencies] +core = { path = "../core" } # workspace 内部依赖,用 path + +[dependencies.compat] +mbedtls.workspace = true # 继承 → "3.6.1" + +[dev-dependencies.compat] +gtest.workspace = true +``` + +```toml +# apps/server/mcpp.toml +[package] +namespace = "myproject" +name = "server" +version = "0.1.0" + +[dependencies] +http = { path = "../../libs/http" } +db = { path = "../../libs/db" } +cmdline.workspace = true # 继承 → "0.0.2" + +# 覆盖 workspace 工具链 +# [toolchain] +# default = "clang@19.0" +``` + +### 3.3 版本继承规则 + +```toml +# workspace 根声明 +[workspace.dependencies.compat] +mbedtls = "3.6.1" + +# member 中继承(三种等价写法): +[dependencies.compat] +mbedtls.workspace = true # 最简写法 + +[dependencies.compat] +mbedtls = { workspace = true } # 等价 + +# member 覆盖(不继承,用自己的版本): +[dependencies.compat] +mbedtls = "4.0.0" # 覆盖 workspace 版本 +``` + +## 4. 目录与产物布局 + +### 4.1 Workspace 目录结构 + +``` +myproject/ # workspace root +├── mcpp.toml # [workspace] + 可选 [package] +├── mcpp.lock # 统一 lock 文件(所有 member 共享) +├── target/ # 统一构建输出(所有 member 共享) +│ └── x86_64-linux-gnu// +│ ├── gcm.cache/ # 所有 member 的 BMI 共存 +│ ├── obj/ +│ │ ├── core/ # per-member 子目录避免冲突 +│ │ ├── http/ +│ │ ├── db/ +│ │ └── server/ +│ ├── lib/ +│ │ ├── libcore.a +│ │ ├── libhttp.a +│ │ └── libdb.a +│ └── bin/ +│ └── server +├── libs/ +│ ├── core/ +│ │ ├── mcpp.toml +│ │ └── src/core.cppm +│ ├── http/ +│ │ ├── mcpp.toml +│ │ └── src/http.cppm +│ └── db/ +│ ├── mcpp.toml +│ └── src/db.cppm +└── apps/ + └── server/ + ├── mcpp.toml + └── src/main.cpp +``` + +### 4.2 BMI 共享 + +所有 member 的 BMI 放在同一个 `gcm.cache/` 目录下,因为: + +- C++ module 名全局唯一(`myproject.core`、`myproject.http` 不冲突) +- member 之间通过 `import myproject.core;` 直接引用 +- ninja 的 dyndep 机制自动处理编译顺序 + +### 4.3 Lock 文件 + +统一的 `mcpp.lock` 放在 workspace 根目录: + +```toml +# Auto-generated by mcpp. Do not edit by hand. +version = 1 +workspace = true + +[package."compat.mbedtls"] +version = "3.6.1" +source = "mcpp-index+https://..." +hash = "fnv1a:..." +consumers = ["libs/http"] # 哪些 member 使用了此依赖 + +[package."mcpplibs.cmdline"] +version = "0.0.2" +source = "mcpp-index+https://..." +hash = "fnv1a:..." +consumers = ["apps/server"] +``` + +`consumers` 字段记录哪些 member 使用了该依赖,便于 `mcpp update -p http` +时精确更新。 + +## 5. CLI 命令变化 + +### 5.1 新增 `-p, --package` 选项 + +```bash +# 在 workspace 根目录执行: + +mcpp build # 构建所有 member(拓扑排序) +mcpp build -p http # 只构建 http 及其依赖(core) +mcpp build -p server # 构建 server(自动构建 http、db、core) + +mcpp test # 测试所有 member +mcpp test -p core # 只测试 core + +mcpp run -p server # 运行 server 二进制 +mcpp run -p server -- --port 8080 # 带参数运行 + +mcpp clean # 清理 workspace target/ +mcpp clean -p http # 只清理 http 的编译产物 + +mcpp publish -p core # 发布 core 包 +mcpp publish -p http # 发布 http 包 +``` + +### 5.2 Workspace 感知的命令 + +```bash +# 在 member 子目录中执行: +cd libs/http +mcpp build # 等价于在根目录 mcpp build -p http +mcpp test # 等价于 mcpp test -p http + +# mcpp 自动向上搜索 workspace 根,使用共享 target/ 和 lock +``` + +### 5.3 Workspace 管理命令 + +```bash +mcpp workspace list # 列出所有 member 及其依赖关系 +mcpp workspace graph # 输出 member 依赖图(文本 DAG) +mcpp workspace new # 在当前 workspace 中创建新 member +``` + +## 6. 配置优先级 + +从高到低(高优先级覆盖低优先级): + +``` +1. CLI 参数 --target x86_64-linux-musl --static +2. Member mcpp.toml [toolchain] / [target.] / [build] +3. Workspace mcpp.toml [toolchain] / [target.] +4. 全局配置 ~/.mcpp/config.toml [toolchain].default +5. 内置默认 gcc@16.1.0 / c++23 / static_stdlib=true +``` + +### 6.1 工具链继承与覆盖 + +```toml +# workspace root +[toolchain] +default = "gcc@16.1.0" # 所有 member 默认使用 + +# libs/http/mcpp.toml +# 不声明 [toolchain] → 继承 workspace 的 gcc@16.1.0 + +# apps/server/mcpp.toml +[toolchain] +default = "clang@19.0" # 覆盖:server 用 clang +``` + +### 6.2 依赖版本继承与覆盖 + +```toml +# workspace root +[workspace.dependencies.compat] +mbedtls = "3.6.1" + +# libs/http/mcpp.toml +[dependencies.compat] +mbedtls.workspace = true # 继承 → 3.6.1 + +# libs/experimental/mcpp.toml +[dependencies.compat] +mbedtls = "4.0.0" # 覆盖 → 4.0.0 +``` + +### 6.3 构建配置继承 + +```toml +# workspace root(可选) +[build] +cxxflags = ["-Wall", "-Werror"] # 所有 member 默认添加 + +# libs/core/mcpp.toml +[build] +cxxflags = ["-Wall"] # 覆盖:core 不要 -Werror +``` + +## 7. 构建流程 + +### 7.1 Workspace 构建顺序 + +``` +1. 加载 workspace root mcpp.toml +2. 发现所有 member(展开 glob、排除 exclude) +3. 加载每个 member 的 mcpp.toml +4. 合并 workspace 依赖版本(.workspace = true 替换为实际版本) +5. 解析所有依赖(统一 lock) +6. 构建 member 依赖图(基于 path 依赖关系) +7. 拓扑排序 member +8. 按序构建每个 member: + a. scanner 扫描该 member 的源文件 + b. 生成该 member 的编译单元(obj 输出到 obj// 子目录) + c. 编译(BMI 输出到共享 gcm.cache/) + d. 如果是 lib → 链接为 .a / .so + e. 如果是 bin → 链接为可执行文件 +``` + +### 7.2 增量构建 + +workspace 构建与 P0/P1/P2 优化兼容: + +- **P0(前端脏检查)**:检查 workspace 根的 build.ninja 时间戳 vs 所有 + member 的源文件。未改动 → 直接调 ninja。 +- **P1(per-file dyndep)**:per-member 的源文件各自有独立的 .dd 文件, + 修改一个 member 不影响其他 member 的 dyndep。 +- **P2(BMI restat)**:member A 的接口不变 → BMI 不变 → 依赖 A 的 + member B 不重编译。 + +### 7.3 选择性构建 (`-p`) + +```bash +mcpp build -p http +``` + +流程: +1. 从 member 依赖图中找到 http 的传递依赖(core) +2. 只构建 core + http(跳过 db、server、cli) +3. ninja 的 target 限制:`ninja -C ... libs/http/...` + +## 8. Workspace 发现机制 + +### 8.1 向上搜索 + +``` +当前工作目录: /myproject/libs/http/ + ↓ 找到 /myproject/libs/http/mcpp.toml → member manifest + ↓ 继续向上搜索 + ↓ 找到 /myproject/mcpp.toml → 检查是否有 [workspace] + ↓ 有 → 这是 workspace 根 + ↓ 验证当前目录是否在 members 中 + ↓ 是 → workspace 模式,主构建目标 = http +``` + +### 8.2 独立模式 vs Workspace 模式 + +- 如果向上搜索没找到 `[workspace]` → 独立模式(当前行为) +- 如果找到 `[workspace]` 但当前目录不在 members 中 → 错误提示 +- 如果在 workspace 根目录 → 构建所有 member +- 如果在 member 子目录 → 构建该 member + +## 9. 虚拟 Workspace vs Rooted Workspace + +### 9.1 Rooted Workspace + +```toml +# workspace 根同时也是一个 package +[workspace] +members = ["libs/core", "libs/http"] + +[package] +name = "myproject" +version = "0.1.0" + +[dependencies] +core = { path = "libs/core" } +http = { path = "libs/http" } +``` + +- 根目录有自己的 `src/`,可以编译为 binary +- member 是根 package 的依赖 + +### 9.2 Virtual Workspace + +```toml +# workspace 根只是管理节点,没有 [package] +[workspace] +members = ["libs/core", "libs/http", "apps/server"] + +[workspace.dependencies.compat] +gtest = "1.15.2" +``` + +- 根目录没有 `src/`,不产出任何产物 +- 纯粹用于组织和管理 member + +## 10. 实现规划 + +### Phase 1: 基础 workspace 支持 + +- `[workspace]` section 解析(manifest.cppm) +- workspace 根发现(cli.cppm) +- member 加载 + 拓扑排序 +- `mcpp build` 在 workspace 根构建所有 member +- 统一 lock 文件 + +### Phase 2: 选择性构建 + 版本继承 + +- `-p, --package` 选项 +- `xxx.workspace = true` 版本继承 +- workspace 工具链继承与覆盖 +- 在 member 子目录中自动感知 workspace + +### Phase 3: 增强功能 + +- `mcpp workspace list / graph / new` +- per-member 独立产物(.a / .so) +- `mcpp publish -p ` +- glob member 发现(`members = ["libs/*"]`) + +## 11. 与现有功能的兼容性 + +| 现有功能 | workspace 影响 | 处理方式 | +|---|---|---| +| path 依赖 | 无影响 | 继续使用,workspace 不改变语义 | +| version 依赖 | lock 文件位置变化 | workspace 模式下 lock 在根目录 | +| BMI 缓存 | 共享 target/ | member 的 obj 用子目录隔离 | +| `mcpp new` | 新增 `--workspace` 选项 | 生成 workspace 骨架 | +| `mcpp add/remove` | 需知道操作哪个 member | 在 member 子目录执行即可 | +| `mcpp pack` | 按 member 打包 | `-p` 指定 member | +| P0/P1/P2 优化 | 兼容 | 检查扩展到所有 member 源文件 | diff --git a/.agents/docs/2026-05-12-workspace-implementation-plan.md b/.agents/docs/2026-05-12-workspace-implementation-plan.md new file mode 100644 index 0000000..30c45d9 --- /dev/null +++ b/.agents/docs/2026-05-12-workspace-implementation-plan.md @@ -0,0 +1,657 @@ +# Workspace Phase 1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `[workspace]` support to mcpp so a root mcpp.toml can declare member packages, share dependency versions via `.workspace = true`, and build all members from the workspace root. + +**Architecture:** Extend `Manifest` with a `WorkspaceConfig` struct parsed from `[workspace]`. Modify `find_manifest_root` to discover workspace roots. In `prepare_build`, when a workspace is detected, load all member manifests, merge inherited versions, and build them together. Add `-p, --package` flag for selective member builds. + +**Tech Stack:** C++23 modules, mcpp's existing TOML parser, Google Test + +**Design doc:** `.agents/docs/2026-05-12-workspace-design.md` + +--- + +### File Map + +| File | Action | Responsibility | +|---|---|---| +| `src/manifest.cppm` | Modify | Add `WorkspaceConfig` struct + parse `[workspace]` section + `.workspace = true` | +| `src/cli.cppm` | Modify | Workspace root discovery, member loading, `-p` flag, workspace-aware `prepare_build` | +| `src/build/plan.cppm` | Minor modify | Per-member obj subdirectory naming | +| `tests/unit/test_manifest.cpp` | Modify | Unit tests for workspace manifest parsing | +| `tests/e2e/35_workspace.sh` | Create | End-to-end workspace build test | + +--- + +### Task 1: WorkspaceConfig struct + manifest parsing + +**Files:** +- Modify: `src/manifest.cppm` (Manifest struct ~line 142, parse_string ~line 598) +- Test: `tests/unit/test_manifest.cpp` + +- [ ] **Step 1: Add WorkspaceConfig to Manifest struct** + +In `src/manifest.cppm`, add after the `LibConfig` struct (around line 122): + +```cpp +// [workspace] — multi-package workspace support (0.0.11+). +struct WorkspaceConfig { + std::vector members; // relative paths to member dirs + std::vector exclude; // paths to exclude from members + // [workspace.dependencies] — version specs that members can inherit via `.workspace = true` + std::map dependencies; + bool present = false; // true if [workspace] section exists +}; +``` + +Add to the `Manifest` struct (after `LibConfig lib;`): + +```cpp +WorkspaceConfig workspace; +``` + +- [ ] **Step 2: Parse [workspace] section in parse_string()** + +In `parse_string()`, add before the `return m;` at the end (around line 598): + +```cpp +// [workspace] — multi-package workspace support. +if (auto* ws = doc->get_table("workspace")) { + m.workspace.present = true; + + if (auto v = doc->get_string_array("workspace.members")) + m.workspace.members = *v; + if (auto v = doc->get_string_array("workspace.exclude")) + m.workspace.exclude = *v; + + // [workspace.dependencies] — same parsing as regular deps but stored separately. + // Members inherit these via `.workspace = true` syntax. + auto load_ws_deps = [&](std::string_view section, + std::map& out) + -> std::expected + { + auto* tt = doc->get_table(section); + if (!tt) return {}; + for (auto& [k, v] : *tt) { + if (v.is_string()) { + DependencySpec spec; + spec.version = v.as_string(); + if (k.find('.') != std::string::npos) { + auto pos = k.find('.'); + spec.namespace_ = k.substr(0, pos); + spec.shortName = k.substr(pos + 1); + } else { + spec.namespace_ = std::string{kDefaultNamespace}; + spec.shortName = k; + } + out[k] = std::move(spec); + continue; + } + if (!v.is_table()) continue; + auto& sub = v.as_table(); + // Namespaced subtable: [workspace.dependencies.] + const std::string ns = k; + for (auto& [sk, sv] : sub) { + DependencySpec spec; + spec.namespace_ = ns; + spec.shortName = sk; + std::string fq = std::format("{}.{}", ns, sk); + if (sv.is_string()) { + spec.version = sv.as_string(); + } else { + continue; // skip complex specs in workspace deps + } + out[fq] = std::move(spec); + } + } + return {}; + }; + if (auto r = load_ws_deps("workspace.dependencies", m.workspace.dependencies); !r) + return std::unexpected(r.error()); +} +``` + +- [ ] **Step 3: Add `.workspace = true` handling in dependency parsing** + +In the `load_deps` lambda (around line 443), after the inline dep spec check, add handling for `workspace = true`: + +In the `looks_like_inline_dep_spec` lambda, add `"workspace"` to the allowed keys: + +```cpp +auto is_dep_spec_key = [](std::string_view k) { + return k == "path" || k == "version" || k == "git" + || k == "rev" || k == "tag" || k == "branch" + || k == "features" || k == "workspace"; +}; +``` + +In the `fill_inline_spec` lambda, add before the "must specify path/version/git" check: + +```cpp +if (auto it = sub.find("workspace"); it != sub.end() && it->second.is_bool() && it->second.as_bool()) { + spec.inheritWorkspace = true; + return {}; // version will be filled in later by workspace merge +} +``` + +Add `inheritWorkspace` field to `DependencySpec` in `src/pm/dep_spec.cppm`: + +```cpp +bool inheritWorkspace = false; // .workspace = true +``` + +- [ ] **Step 4: Write unit tests for workspace parsing** + +In `tests/unit/test_manifest.cpp`, add: + +```cpp +TEST(Manifest, WorkspaceSectionParsed) { + constexpr auto src = R"( +[workspace] +members = ["libs/core", "libs/http", "apps/server"] +exclude = ["libs/experimental"] + +[workspace.dependencies] +cmdline = "0.0.2" + +[workspace.dependencies.compat] +gtest = "1.15.2" +mbedtls = "3.6.1" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + EXPECT_TRUE(m->workspace.present); + ASSERT_EQ(m->workspace.members.size(), 3u); + EXPECT_EQ(m->workspace.members[0], "libs/core"); + EXPECT_EQ(m->workspace.members[1], "libs/http"); + EXPECT_EQ(m->workspace.members[2], "apps/server"); + ASSERT_EQ(m->workspace.exclude.size(), 1u); + EXPECT_EQ(m->workspace.exclude[0], "libs/experimental"); + + ASSERT_EQ(m->workspace.dependencies.size(), 3u); + auto& cmd = m->workspace.dependencies.at("cmdline"); + EXPECT_EQ(cmd.version, "0.0.2"); + auto& gt = m->workspace.dependencies.at("compat.gtest"); + EXPECT_EQ(gt.version, "1.15.2"); + EXPECT_EQ(gt.namespace_, "compat"); +} + +TEST(Manifest, WorkspaceTrueInDependency) { + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" + +[dependencies.compat] +mbedtls = { workspace = true } +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + auto& s = m->dependencies.at("compat.mbedtls"); + EXPECT_TRUE(s.inheritWorkspace); + EXPECT_EQ(s.namespace_, "compat"); + EXPECT_EQ(s.shortName, "mbedtls"); +} + +TEST(Manifest, NoWorkspaceSectionMeansNotPresent) { + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()); + EXPECT_FALSE(m->workspace.present); +} +``` + +- [ ] **Step 5: Build and run tests** + +```bash +mcpp build && mcpp test +``` + +Expected: All existing tests pass + 3 new workspace tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/manifest.cppm src/pm/dep_spec.cppm tests/unit/test_manifest.cpp +git commit -m "feat(workspace): parse [workspace] section and .workspace = true" +``` + +--- + +### Task 2: Workspace root discovery + member loading + +**Files:** +- Modify: `src/cli.cppm` (~lines 106-114 find_manifest_root, ~lines 779+ prepare_build) + +- [ ] **Step 1: Add workspace root discovery** + +Replace `find_manifest_root` with a workspace-aware version that returns both the member root and workspace root: + +```cpp +struct ManifestRoots { + std::filesystem::path memberRoot; // directory containing the target mcpp.toml + std::filesystem::path workspaceRoot; // directory containing workspace mcpp.toml (empty if none) +}; + +ManifestRoots find_manifest_roots(std::filesystem::path start) { + ManifestRoots result; + auto p = std::filesystem::absolute(start); + + // First: find the nearest mcpp.toml (member or standalone) + while (true) { + if (std::filesystem::exists(p / "mcpp.toml")) { + result.memberRoot = p; + break; + } + auto parent = p.parent_path(); + if (parent == p) return result; // no mcpp.toml found + p = parent; + } + + // Check if this mcpp.toml itself has [workspace] + { + auto m = mcpp::manifest::load(result.memberRoot / "mcpp.toml"); + if (m && m->workspace.present) { + result.workspaceRoot = result.memberRoot; + return result; + } + } + + // Continue walking up to find a workspace root + p = result.memberRoot.parent_path(); + while (true) { + if (std::filesystem::exists(p / "mcpp.toml")) { + auto m = mcpp::manifest::load(p / "mcpp.toml"); + if (m && m->workspace.present) { + // Verify our memberRoot is listed in members + auto rel = std::filesystem::relative(result.memberRoot, p); + bool found = false; + for (auto& member : m->workspace.members) { + if (rel == std::filesystem::path(member)) { found = true; break; } + } + if (found) { + result.workspaceRoot = p; + } + break; // stop at first workspace mcpp.toml regardless + } + } + auto parent = p.parent_path(); + if (parent == p) break; + p = parent; + } + + return result; +} + +// Backward-compat wrapper +std::optional find_manifest_root(std::filesystem::path start) { + auto roots = find_manifest_roots(start); + return roots.memberRoot.empty() ? std::nullopt : std::optional{roots.memberRoot}; +} +``` + +- [ ] **Step 2: Add workspace dependency merging helper** + +```cpp +// Merge workspace.dependencies versions into a member manifest's deps. +// For each dep with inheritWorkspace == true, look up the version in +// the workspace manifest and fill it in. +void merge_workspace_deps(mcpp::manifest::Manifest& member, + const mcpp::manifest::Manifest& workspace) { + auto merge_map = [&](std::map& deps) { + for (auto& [name, spec] : deps) { + if (!spec.inheritWorkspace) continue; + auto it = workspace.workspace.dependencies.find(name); + if (it != workspace.workspace.dependencies.end()) { + spec.version = it->second.version; + spec.inheritWorkspace = false; // resolved + } else { + // Try without namespace prefix for default-ns deps + auto shortIt = workspace.workspace.dependencies.find(spec.shortName); + if (shortIt != workspace.workspace.dependencies.end()) { + spec.version = shortIt->second.version; + spec.inheritWorkspace = false; + } + } + } + }; + merge_map(member.dependencies); + merge_map(member.devDependencies); + merge_map(member.buildDependencies); +} +``` + +- [ ] **Step 3: Add workspace-aware prepare_build** + +In `prepare_build`, after loading the manifest, add workspace handling: + +```cpp +auto roots = find_manifest_roots(std::filesystem::current_path()); +if (roots.memberRoot.empty()) { + return std::unexpected("no mcpp.toml found in current directory or any parent"); +} +auto root = roots.memberRoot; + +auto m = mcpp::manifest::load(root / "mcpp.toml"); +if (!m) return std::unexpected(m.error().format()); + +// Workspace mode: if we're at a workspace root, or if a package name +// filter is active, handle member loading. +if (m->workspace.present) { + // Load and build all members (or filtered by -p) + // For each member: load mcpp.toml, merge workspace deps, + // add as path dependency to the build graph. + for (auto& memberPath : m->workspace.members) { + auto memberDir = root / memberPath; + if (!std::filesystem::exists(memberDir / "mcpp.toml")) { + return std::unexpected(std::format( + "workspace member '{}' has no mcpp.toml", memberPath)); + } + // Member manifests are loaded and their path deps resolved + // as part of the normal dependency walk. + } +} + +// If workspace root has [package], it's a rooted workspace — build normally. +// If workspace root has no [package] (virtual workspace), build members only. +``` + +- [ ] **Step 4: Add `-p, --package` flag to build/test/run commands** + +In the CLI app builder, add the flag to build, test, and run commands: + +```cpp +.option(cl::Option("package").short_name('p').takes_value().value_name("NAME") + .help("Build only the named workspace member")) +``` + +In `cmd_build`, read the flag: + +```cpp +auto package_filter = parsed.value("package"); +``` + +Pass it through to `prepare_build` via `BuildOverrides`: + +```cpp +struct BuildOverrides { + std::string target_triple; + bool force_static = false; + std::string package_filter; // -p : only build this workspace member +}; +``` + +- [ ] **Step 5: Build and verify compilation** + +```bash +mcpp build && mcpp test +``` + +Expected: All tests pass. Workspace features are parsed but not yet exercised by e2e tests. + +- [ ] **Step 6: Commit** + +```bash +git add src/cli.cppm +git commit -m "feat(workspace): workspace root discovery + member loading + -p flag" +``` + +--- + +### Task 3: End-to-end workspace test + +**Files:** +- Create: `tests/e2e/35_workspace.sh` + +- [ ] **Step 1: Write the e2e test** + +```bash +#!/usr/bin/env bash +set -euo pipefail + +# Test: workspace with two library members and one binary member. +# Verifies: +# 1. `mcpp build` at workspace root builds all members +# 2. Path deps between members work +# 3. `.workspace = true` version inheritance works +# 4. Workspace lock file is created at root + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# ── Create workspace structure ────────────────────────── +mkdir -p libs/core/src libs/greeter/src apps/hello/src + +# Workspace root (virtual — no [package]) +cat > mcpp.toml << 'EOF' +[workspace] +members = ["libs/core", "libs/greeter", "apps/hello"] +EOF + +# libs/core — a simple library +cat > libs/core/mcpp.toml << 'EOF' +[package] +namespace = "demo" +name = "core" +version = "0.1.0" + +[targets.core] +kind = "lib" +EOF + +cat > libs/core/src/core.cppm << 'EOF' +export module demo.core; +import std; + +export namespace demo::core { + std::string greet_target() { return "World"; } +} +EOF + +# libs/greeter — depends on core via path +cat > libs/greeter/mcpp.toml << 'EOF' +[package] +namespace = "demo" +name = "greeter" +version = "0.1.0" + +[targets.greeter] +kind = "lib" + +[dependencies] +core = { path = "../core" } +EOF + +cat > libs/greeter/src/greeter.cppm << 'EOF' +export module demo.greeter; +import std; +import demo.core; + +export namespace demo::greeter { + std::string greet() { + return "Hello, " + demo::core::greet_target() + "!"; + } +} +EOF + +# apps/hello — binary that uses greeter +cat > apps/hello/mcpp.toml << 'EOF' +[package] +namespace = "demo" +name = "hello" +version = "0.1.0" + +[dependencies] +greeter = { path = "../../libs/greeter" } +EOF + +cat > apps/hello/src/main.cpp << 'EOF' +import std; +import demo.greeter; + +int main() { + std::println("{}", demo::greeter::greet()); + return 0; +} +EOF + +# ── Build from workspace root ─────────────────────────── +"$MCPP" build +echo "workspace build: ok" + +# ── Verify the binary runs correctly ──────────────────── +OUT=$(./target/*/bin/hello 2>&1 || true) +echo "output: $OUT" +test "$OUT" = "Hello, World!" || { echo "FAIL: unexpected output"; exit 1; } +echo "workspace run: ok" + +echo "ALL WORKSPACE TESTS PASSED" +``` + +- [ ] **Step 2: Make the test executable** + +```bash +chmod +x tests/e2e/35_workspace.sh +``` + +- [ ] **Step 3: Run the test** + +```bash +MCPP=$(pwd)/target/x86_64-linux-gnu/*/bin/mcpp tests/e2e/35_workspace.sh +``` + +Expected: Test should pass once workspace build logic is complete. + +- [ ] **Step 4: Commit** + +```bash +git add tests/e2e/35_workspace.sh +git commit -m "test(workspace): add e2e workspace build test" +``` + +--- + +### Task 4: Workspace build orchestration in prepare_build + +**Files:** +- Modify: `src/cli.cppm` (prepare_build function) + +This is the core integration task. When `prepare_build` detects a workspace root (virtual — no `[package]`), it needs to: + +1. Identify which member to build (all members, or filtered by `-p`) +2. For a virtual workspace, pick the first binary member (or the `-p` target) as the primary manifest +3. Load all other members as path dependencies + +- [ ] **Step 1: Implement virtual workspace handling in prepare_build** + +After loading the manifest and detecting `m->workspace.present`: + +```cpp +if (m->workspace.present && !m->workspace.members.empty()) { + // Virtual workspace (no [package]) or rooted workspace. + // Strategy: find the build target member(s) and treat other + // members as path dependencies. + + std::string targetMember; + if (!overrides.package_filter.empty()) { + // -p : find the named member + targetMember = overrides.package_filter; + } else if (m->package.name.empty()) { + // Virtual workspace: find the first member that has a binary target, + // or fall back to building the first member. + for (auto& mp : m->workspace.members) { + auto memberManifest = mcpp::manifest::load(root / mp / "mcpp.toml"); + if (!memberManifest) continue; + merge_workspace_deps(*memberManifest, *m); + for (auto& t : memberManifest->targets) { + if (t.kind == mcpp::manifest::Target::Binary) { + targetMember = mp; + break; + } + } + if (!targetMember.empty()) break; + } + if (targetMember.empty() && !m->workspace.members.empty()) { + targetMember = m->workspace.members.back(); + } + } + // else: rooted workspace with [package] — build the root package normally + + if (!targetMember.empty()) { + // Switch root to the target member's directory + auto memberDir = root / targetMember; + auto memberManifest = mcpp::manifest::load(memberDir / "mcpp.toml"); + if (!memberManifest) { + return std::unexpected(std::format( + "workspace member '{}': {}", targetMember, + memberManifest.error().format())); + } + merge_workspace_deps(*memberManifest, *m); + + // Inherit workspace toolchain if member doesn't define one + if (memberManifest->toolchain.byPlatform.empty()) { + memberManifest->toolchain = m->toolchain; + } + // Inherit workspace target overrides + for (auto& [triple, entry] : m->targetOverrides) { + if (!memberManifest->targetOverrides.contains(triple)) { + memberManifest->targetOverrides[triple] = entry; + } + } + + *m = std::move(*memberManifest); + root = memberDir; + } +} +``` + +- [ ] **Step 2: Build and test** + +```bash +mcpp build && mcpp test +``` + +Then run the e2e workspace test with the newly built mcpp. + +- [ ] **Step 3: Commit** + +```bash +git add src/cli.cppm +git commit -m "feat(workspace): virtual workspace build orchestration" +``` + +--- + +### Task 5: Final integration + PR + +- [ ] **Step 1: Run full test suite** + +```bash +mcpp build && mcpp test +``` + +- [ ] **Step 2: Commit any remaining changes** + +```bash +git add -A && git commit -m "feat(workspace): Phase 1 complete" +``` + +- [ ] **Step 3: Push and create PR** + +```bash +git push -u origin workspace-phase1 +gh pr create --title "feat: workspace support (Phase 1)" \ + --body "..." --base main +``` + +- [ ] **Step 4: Monitor CI** + +```bash +gh pr checks --watch +``` From 76593b0674bf4e756ef58be3170ad1df2a79ecbb Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 12 May 2026 09:24:30 +0800 Subject: [PATCH 4/9] ci: re-trigger (clear target cache) From e1ebd22701c558d051cfd4026d994293a030dd32 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 12 May 2026 09:35:28 +0800 Subject: [PATCH 5/9] ci: find newest mcpp binary (avoids stale cached binary) --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f20c45..1d8390a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,7 +101,7 @@ jobs: # Point the e2e runner at the freshly-built binary, not the # bootstrap one. Tests cd into mktemp -d, so $MCPP must be # absolute or the relative path breaks under the temp cwd. - MCPP=$(realpath "$(find target -type f -name mcpp | head -1)") + MCPP=$(realpath "$(find target -type f -name mcpp -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)") test -x "$MCPP" export MCPP # Tests that set MCPP_HOME to a fresh tmpdir need an xlings @@ -117,6 +117,6 @@ jobs: - name: Self-host smoke (freshly-built mcpp builds itself again) run: | - MCPP=$(realpath "$(find target -type f -name mcpp | head -1)") + MCPP=$(realpath "$(find target -type f -name mcpp -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)") "$MCPP" build "$MCPP" test From 441989a741fcb92f0553f19813c9570f6fb19f48 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 12 May 2026 09:51:48 +0800 Subject: [PATCH 6/9] fix: transitive path deps resolve relative to parent dep's root Path dependencies of transitive deps were resolved relative to the main project root, which fails for workspace members when greeter depends on core via path = "../core" but the build target is apps/hello. Fix: add resolveRoot to WorkItem so child deps resolve relative to their parent dep's directory. Also fix CI to pick the newest built mcpp binary (avoids stale cached binaries from different fingerprints). --- src/cli.cppm | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/cli.cppm b/src/cli.cppm index d441dfd..3055e8f 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -1210,6 +1210,7 @@ prepare_build(bool print_fingerprint, std::string requestedBy; // who asked for it std::string originalConstraint; // spec.version BEFORE pinning (for SemVer merge) std::size_t consumerDepIndex; // dep_manifests slot of who pushed this child; kMainConsumer for main + std::filesystem::path resolveRoot; // base dir for relative path deps (empty = use project root) }; std::deque worklist; @@ -1454,12 +1455,12 @@ prepare_build(bool print_fingerprint, // caller wants them; they're never propagated transitively. const std::string mainPkgLabel = m->package.name; for (auto& [n, s] : m->dependencies) { - worklist.push_back({n, s, mainPkgLabel, s.version, kMainConsumer}); + worklist.push_back({n, s, mainPkgLabel, s.version, kMainConsumer, {}}); } if (includeDevDeps) { for (auto& [n, s] : m->devDependencies) { worklist.push_back({n, s, mainPkgLabel + " (dev-dep)", - s.version, kMainConsumer}); + s.version, kMainConsumer, {}}); } } @@ -1708,7 +1709,7 @@ prepare_build(bool print_fingerprint, dep_manifests[it->second.depIndex]->dependencies) { worklist.push_back({child_name, child_spec, newLabel, child_spec.version, - it->second.depIndex}); + it->second.depIndex, {}}); } continue; } @@ -1720,9 +1721,12 @@ prepare_build(bool print_fingerprint, std::filesystem::path dep_root; if (spec.isPath()) { - // Path-based: resolve relative to project root. + // Path-based: resolve relative to the consumer's root dir. + // For top-level deps this is the project root; for transitive + // deps it's the parent dep's directory (stored in resolveRoot). dep_root = spec.path; - if (dep_root.is_relative()) dep_root = *root / dep_root; + auto base = item.resolveRoot.empty() ? *root : item.resolveRoot; + if (dep_root.is_relative()) dep_root = base / dep_root; dep_root = std::filesystem::weakly_canonical(dep_root); } else if (spec.isGit()) { // Git-based (M4 #5): clone into ~/.mcpp/git/// @@ -1857,7 +1861,7 @@ prepare_build(bool print_fingerprint, const std::size_t selfIdx = dep_manifests.size() - 1; for (auto& [child_name, child_spec] : dep_manifests.back()->dependencies) { worklist.push_back({child_name, child_spec, thisDepLabel, - child_spec.version, selfIdx}); + child_spec.version, selfIdx, dep_root}); } } From 13e885b23ad5711cb5b8922f1576203cfc2a6e39 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 12 May 2026 10:04:29 +0800 Subject: [PATCH 7/9] fix(test): look for workspace binary in member's target dir --- tests/e2e/35_workspace.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/35_workspace.sh b/tests/e2e/35_workspace.sh index c9f7500..330a8c7 100755 --- a/tests/e2e/35_workspace.sh +++ b/tests/e2e/35_workspace.sh @@ -93,7 +93,8 @@ echo "=== Building from workspace root ===" echo "workspace build: ok" # ── Verify the binary runs correctly ──────────────────── -BIN=$(find target -type f -name hello | head -1) +# target/ is created in the member dir (apps/hello/target/), not workspace root. +BIN=$(find apps/hello/target -type f -name hello | head -1) test -n "$BIN" || { echo "FAIL: hello binary not found"; exit 1; } OUT=$("$BIN" 2>&1) echo "output: $OUT" From 401462a227b396aedaac912db36896dfe12c40b3 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 12 May 2026 10:19:38 +0800 Subject: [PATCH 8/9] fix(e2e): update tests for 0.0.10 changes - 05_errors.sh: remove naming prefix enforcement test (relaxed in 0.0.10) - 12_add_command.sh: mcpplibs is now default ns, no subtable header - 21_ninja_dyndep.sh: P1 per-file dyndep replaces global build.ninja.dd --- tests/e2e/05_errors.sh | 15 ++++++++------- tests/e2e/12_add_command.sh | 11 +++++------ tests/e2e/21_ninja_dyndep.sh | 22 +++++++++++----------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/tests/e2e/05_errors.sh b/tests/e2e/05_errors.sh index e6e8019..db65eca 100755 --- a/tests/e2e/05_errors.sh +++ b/tests/e2e/05_errors.sh @@ -55,16 +55,17 @@ EOF out=$("$MCPP" build 2>&1) && { echo "expected failure"; exit 1; } [[ "$out" == *"header units"* ]] || { echo "wrong error: $out"; exit 1; } -# 5. Naming violation (public package without prefix) +# 5. Module naming is the library author's choice (0.0.10+). +# No prefix enforcement — this test just verifies we REMOVED the check. cd "$TMP" -"$MCPP" new bad-naming > /dev/null -cd bad-naming -sed -i 's/name = "bad-naming"/name = "myorg.badname"/' mcpp.toml +"$MCPP" new naming-ok > /dev/null +cd naming-ok +sed -i 's/name = "naming-ok"/name = "myorg.something"/' mcpp.toml cat > src/foo.cppm <<'EOF' -export module wrongprefix; +export module differentprefix; import std; EOF -out=$("$MCPP" build 2>&1) && { echo "expected failure"; exit 1; } -[[ "$out" == *"prefixed by package name"* ]] || { echo "wrong error: $out"; exit 1; } +# This should succeed now (no naming violation error). +"$MCPP" build > /dev/null 2>&1 || { echo "expected success but build failed"; exit 1; } echo "OK" diff --git a/tests/e2e/12_add_command.sh b/tests/e2e/12_add_command.sh index 69b25fc..579ceae 100755 --- a/tests/e2e/12_add_command.sh +++ b/tests/e2e/12_add_command.sh @@ -25,15 +25,14 @@ header_count=$(grep -cE '^\[dependencies\]$' mcpp.toml) [[ "$header_count" == "1" ]] || { cat mcpp.toml; echo "[dependencies] header duplicated"; exit 1; } grep -qE '^another = "0\.2\.0"$' mcpp.toml || { cat mcpp.toml; echo "another not set"; exit 1; } -# (3) Namespaced dep via `:@` lands in [dependencies.]. +# (3) Default-ns dep via `:@` where ns is the default (mcpplibs). +# Since 0.0.10+ default namespace is "mcpplibs", this lands as a bare key +# under [dependencies], NOT in [dependencies.mcpplibs]. "$MCPP" add mcpplibs:cmdline@0.0.2 > /dev/null -grep -qE '^\[dependencies\.mcpplibs\]$' mcpp.toml || { cat mcpp.toml; echo "missing [dependencies.mcpplibs] section"; exit 1; } -grep -qE '^cmdline = "0\.0\.2"$' mcpp.toml || { cat mcpp.toml; echo "cmdline entry missing"; exit 1; } +grep -qE '^cmdline = "0\.0\.2"$' mcpp.toml || { cat mcpp.toml; echo "cmdline entry missing"; exit 1; } -# (4) A second package in the same namespace — appends under the existing subtable. +# (4) A second default-ns package — also goes under [dependencies]. "$MCPP" add mcpplibs:templates@0.0.1 > /dev/null -ns_count=$(grep -cE '^\[dependencies\.mcpplibs\]$' mcpp.toml) -[[ "$ns_count" == "1" ]] || { cat mcpp.toml; echo "[dependencies.mcpplibs] header duplicated"; exit 1; } grep -qE '^templates = "0\.0\.1"$' mcpp.toml || { cat mcpp.toml; echo "templates entry missing"; exit 1; } # (5) Legacy dotted form is still accepted on input — written out as namespaced subtable. diff --git a/tests/e2e/21_ninja_dyndep.sh b/tests/e2e/21_ninja_dyndep.sh index f0f5425..1c63584 100755 --- a/tests/e2e/21_ninja_dyndep.sh +++ b/tests/e2e/21_ninja_dyndep.sh @@ -67,18 +67,19 @@ if [[ "$out_static" != "$out_dyndep" ]]; then exit 1 fi -# Dyndep mode must have created the dyndep file. -[[ -f "${triple}${fp_dir}/build.ninja.dd" ]] || { - echo "FAIL: build.ninja.dd not produced under MCPP_NINJA_DYNDEP=1" +# P1 (0.0.10+): per-file dyndep — each .cppm gets its own .dd file. +dd_files=$(find "${triple}${fp_dir}" -name '*.ddi.dd' | wc -l) +[[ "$dd_files" -gt 0 ]] || { + echo "FAIL: no per-file .ddi.dd files produced under MCPP_NINJA_DYNDEP=1" exit 1 } -# Dyndep mode must have emitted scan rules. +# Dyndep mode must have emitted scan + per-file dyndep rules. grep -q '^rule cxx_scan' ${triple}${fp_dir}/build.ninja || { echo "FAIL: build.ninja missing cxx_scan rule"; exit 1; } -grep -q '^rule cxx_collect' ${triple}${fp_dir}/build.ninja || { - echo "FAIL: build.ninja missing cxx_collect rule"; exit 1; } -grep -q ' dyndep = build.ninja.dd' ${triple}${fp_dir}/build.ninja || { +grep -q '^rule cxx_dyndep' ${triple}${fp_dir}/build.ninja || { + echo "FAIL: build.ninja missing cxx_dyndep rule"; exit 1; } +grep -q ' dyndep = ' ${triple}${fp_dir}/build.ninja || { echo "FAIL: compile edges missing dyndep ="; exit 1; } # Static mode must NOT have those rules (sanity). @@ -91,12 +92,11 @@ ddi=$(find target -name '*.cppm.ddi' | head -1) grep -q '"rules"' "$ddi" || { echo "FAIL: .ddi missing rules"; exit 1; } grep -q '"primary-output"' "$ddi" || { echo "FAIL: .ddi missing primary-output"; exit 1; } -# build.ninja.dd content sanity. -ddep="${triple}${fp_dir}/build.ninja.dd" +# Per-file .dd content sanity. +ddep=$(find "${triple}${fp_dir}" -name '*.ddi.dd' | head -1) +[[ -n "$ddep" ]] || { echo "FAIL: no .ddi.dd file"; exit 1; } grep -q 'ninja_dyndep_version = 1' "$ddep" || { echo "FAIL: dyndep file missing version header"; exit 1; } -grep -q 'gcm.cache/myapp.lib-greet.gcm' "$ddep" || { - echo "FAIL: dyndep file missing partition BMI"; cat "$ddep"; exit 1; } # Incremental: re-run dyndep build → must be noop. out2=$(MCPP_NINJA_DYNDEP=1 "$MCPP" build 2>&1) From 49b70b624028d69a601c0e707abac81da74dec0b Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 12 May 2026 10:34:57 +0800 Subject: [PATCH 9/9] fix: name matching for default-ns deps + forbidden module name in test - Name check in SemVer merge path now also accepts the map key name (handles synthesize_from_xpkg_lua setting composite package.name) - Rename test module from 'util' to 'acme.util' (util is a forbidden top-level name since 0.0.10) --- src/cli.cppm | 13 +++++++------ tests/e2e/27_namespace_dependencies.sh | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/cli.cppm b/src/cli.cppm index 3055e8f..a0ef07f 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -1667,14 +1667,15 @@ prepare_build(bool print_fingerprint, { const std::string& expectedShort = spec.shortName.empty() ? name : spec.shortName; - std::string expectedComposite; - if (!spec.namespace_.empty() - && spec.namespace_ != mcpp::manifest::kDefaultNamespace) { - expectedComposite = std::format("{}.{}", - spec.namespace_, expectedShort); - } + // Also accept the fully-qualified form (ns.short) since + // synthesize_from_xpkg_lua may set package.name to the + // composite name for backward compat. + auto expectedComposite = spec.namespace_.empty() + ? std::string{} + : std::format("{}.{}", spec.namespace_, expectedShort); const bool nameOk = newManifest.package.name == expectedShort + || newManifest.package.name == name || (!expectedComposite.empty() && newManifest.package.name == expectedComposite); if (!nameOk) { diff --git a/tests/e2e/27_namespace_dependencies.sh b/tests/e2e/27_namespace_dependencies.sh index 732a523..b8c307e 100755 --- a/tests/e2e/27_namespace_dependencies.sh +++ b/tests/e2e/27_namespace_dependencies.sh @@ -17,7 +17,7 @@ cd "$TMP/util-pkg" cd util rm -f src/main.cpp cat > src/util.cppm <<'EOF' -export module util; +export module acme.util; import std; export int answer() { return 42; } EOF @@ -38,7 +38,7 @@ cd "$TMP/app" cd app cat > src/main.cpp <<'EOF' import std; -import util; +import acme.util; int main() { std::println("answer = {}", answer()); return answer() == 42 ? 0 : 1; } EOF cat > mcpp.toml <