From 9b940b2c029497b1883169322cbffe744c2d97c3 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:00:15 +0800 Subject: [PATCH 1/4] 2026.9.29.2: a workspace plan reads what a member states about itself from the member The plan's root is a virtual root that holds the values shared by the whole graph. Five statements a member makes about itself were read from that root in 2026.9.29.1 and were therefore empty; the mcpp-index sweep of 2026.9.29.1 found the first two, and an inventory of every read of the root's manifest found the rest (design document section 17.1). - A member's relative [indices].path is anchored at the member when the member is loaded; it was resolved against the workspace root (e2e 120). - The std module is built when a selected member's target entries import std, so a member whose only sources are its tests is tested (e2e 836 M1). - Each selected member's [resources] and windows_code_page are compiled against the member's directory and include directories, under res//, and embedded into the member's images only (e2e 837). A quoted include or resource file in a script is found through the include directories, as the resource compiler finds it. - [build] linkage is a root-position value. - A member's unknown mcpp: capability is refused, and its cfg() and schema warnings are reported, as a root's (e2e 836 M2, M3). --- ...2026-09-29-workspace-build-graph-design.md | 33 +- CHANGELOG.md | 32 + docs/07-workspace.md | 11 +- docs/zh/07-workspace.md | 9 +- mcpp.toml | 2 +- modules/versioning/src/version.cppm | 2 +- src/build/ninja_backend.cppm | 5 + src/build/plan.cppm | 7 + src/build/prepare/config.cpp | 33 +- src/build/prepare/manifest.cpp | 198 +++--- src/build/prepare/plan.cpp | 652 ++++++++++-------- src/build/prepare/scan.cpp | 2 +- src/build/prepare/state.cppm | 3 +- src/build/resources.cppm | 15 +- src/project.cppm | 12 +- tests/e2e/120_ws_root_indices.sh | 49 +- ..._members_own_statements_reach_its_build.sh | 86 +++ ...837_a_members_resources_reach_its_image.sh | 108 +++ tests/unit/test_build_resources.cpp | 27 + tests/unit/test_workspace_plan.cpp | 35 + 20 files changed, 910 insertions(+), 411 deletions(-) create mode 100644 tests/e2e/836_a_members_own_statements_reach_its_build.sh create mode 100644 tests/e2e/837_a_members_resources_reach_its_image.sh diff --git a/.agents/docs/2026-09-29-workspace-build-graph-design.md b/.agents/docs/2026-09-29-workspace-build-graph-design.md index f90e7e36..d4f6f784 100644 --- a/.agents/docs/2026-09-29-workspace-build-graph-design.md +++ b/.agents/docs/2026-09-29-workspace-build-graph-design.md @@ -435,8 +435,10 @@ it. **Configurations.** The selected members are grouped by their effective root-position values: the toolchain request, `[package] standard`, -`dialect_cxxflags`, `cxx_runtime`, `dependency_linkage`, the target and the -profile, each after workspace inheritance and the command line. Each group is +`dialect_cxxflags`, `cxx_runtime`, `linkage`, `dependency_linkage`, the target, +the profile and the indices, each after workspace inheritance and the command +line. A relative path a member writes (an `[indices].path`) is anchored at the +member before the values are compared. Each group is one plan with one virtual root, whose root-position values are the group's. Two groups that resolve to one directory (two spellings of one toolchain) are built one after the other in it. @@ -538,3 +540,30 @@ Readings with the implementation (Linux, llvm 22.1.8): mcpp's own repository, a rooted workspace, builds itself with the new engine (full build, gcc 16.1.0, 115 s), and its module tests run through member plans (`mcpp test -p graph`, `-p versioning`). + +### 17.1 What a member states about itself (2026.9.29.2) + +The inventory of §15 classified the sites that treat `packages[0]` as the +root. It did not cover the sites that read the root's manifest (`state.m`, +`plan.manifest`) for a value the package owns; under a virtual root those read +an empty value. The mcpp-index sweep of 2026.9.29.1 found two of them, and an +inventory of every such read (about 170 sites) found the rest. Each is now +read from the member, or made a value of the plan: + +| Statement | 2026.9.29.1 | Now | Criterion | +|---|---|---|---| +| a member's relative `[indices].path` | resolved against the workspace root | anchored at the member when the member is loaded; two spellings of one tree are one configuration | e2e 120, unit `IndexPathsAreAnchoredWhereTheyWereWritten` | +| `import std` in a member's test entries | not seen (the root's targets only) | the entries of the root and of every selected member | e2e 836 M1 | +| a member's `[resources]`, `windows_code_page` | the virtual root's (empty) | per member: its directory and include directories, `res//`, its images only | e2e 837 | +| `[build] linkage` | not copied to the virtual root | a root-position value | unit `RootPositionValuesSeparateMembers` | +| unknown `mcpp:` capability, cfg() and schema warnings | not reported for a member | refused and reported for each selected member | e2e 836 M2, M3 | + +Each criterion fails on 2026.9.29.1 and passes on 2026.9.29.2. The resource +case also showed a defect of every build: a quoted `#include` in a script was +tracked beside the script although the resource compiler finds it through the +include directories, so a script that relied on them could not be built. The +scan now resolves as the compiler does (unit +`InputsAreResolvedThroughTheIncludeDirectories`). + +The rule the inventory missed is general: a virtual root answers for the plan, +and a read of the root's manifest is correct only for a value of the plan. diff --git a/CHANGELOG.md b/CHANGELOG.md index 28be9563..6b51c1d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,38 @@ > Each `## []` section is that release's notes. Entries are written in English > from 2026.9.28.3 on; earlier entries remain as written. +## [2026.9.29.2] - 2026-09-29 + +This release corrects what a workspace plan reads from its members. The plan's +root is a virtual root that holds the values shared by the whole graph; five +statements that a member makes about itself were read from that root in +2026.9.29.1 and were therefore empty. The mcpp-index sweep of 2026.9.29.1 found +the first two. + +### Fixed + +- **A member's own relative `[indices].path` is anchored at the member.** It + was resolved against the workspace root, so a member that declares its own + index (47 members of mcpp-index do) found no package. Two members that name + one tree with different relative paths are now one configuration (e2e 120). +- **A member whose tests import `std` is tested with the std module built.** + The decision read the entry files of the root's targets only, and the virtual + root has none; a member whose only sources are its tests failed to compile + them (e2e 836). +- **A member's `[resources]` and `windows_code_page` reach its own images.** + Each selected member's resources are compiled against the member's directory + and include directories, under `res//`, and embedded into the + member's programs and shared libraries only (e2e 837). A quoted `#include` + or resource file in a script is now found through the include directories as + the resource compiler finds it, in every build. +- **`[build] linkage` is a value of the plan.** It chooses the C runtime that + every object is compiled against, so members that differ in it are separate + configurations, and the plan takes it from its members. +- **A member's manifest is checked as a root's.** An unknown capability under + the reserved `mcpp:` prefix is refused, and a cfg() predicate mcpp cannot + evaluate and the other schema warnings are reported, for each selected + member (e2e 836). + ## [2026.9.29.1] - 2026-09-29 This release builds a workspace as one graph per configuration. The selected diff --git a/docs/07-workspace.md b/docs/07-workspace.md index 8c638370..1e851845 100644 --- a/docs/07-workspace.md +++ b/docs/07-workspace.md @@ -421,9 +421,11 @@ member that several members use is compiled once. - **Configurations.** Members are built in one graph when they share their toolchain request, target, C++ standard, `dialect_cxxflags`, C++ runtime, - profile and the other `[build]` values that apply to a whole graph. Members - that differ in one of them are built in separate graphs, at the same time, - sharing the command's jobs. + `linkage`, profile, indices and the other `[build]` values that apply to a + whole graph. Members that differ in one of them are built in separate + graphs, at the same time, sharing the command's jobs. A relative path a + member writes, such as its own `[indices]` path, is read from the member's + directory. - **Selection.** `--workspace`, and a virtual root without `-p`, select every member. `-p X`, and a command run in X's directory, plan X and what X reaches. The two share the build directory, so `mcpp build --workspace` @@ -436,6 +438,9 @@ member that several members use is compiled once. declares it, and is refused when no selected member declares it. - **Hooks.** The `[hooks]` of every selected member run around the build, in member order. +- **Resources.** A member's `[resources]` and `windows_code_page` are + compiled against the member's directory and include directories and embedded + into that member's programs and shared libraries only (2026.9.29.2+). - **No-op builds.** A command repeated with nothing changed is answered by one check per configuration, without planning. - **Module names.** Members built in one graph share one module namespace: diff --git a/docs/zh/07-workspace.md b/docs/zh/07-workspace.md index 7c0a54cc..ad7a6b34 100644 --- a/docs/zh/07-workspace.md +++ b/docs/zh/07-workspace.md @@ -392,9 +392,10 @@ mcpp test --workspace --workspace-timeout 1800 # whole fan-out (default 0 = no 对工作空间的命令把成员放在一起规划:被选中的成员及其全部依赖构成一张构建图,只有一个 `build.ninja`,被多个成员使用的成员只编译一次。 -- **配置。** 工具链请求、目标、C++ 标准、`dialect_cxxflags`、C++ 运行时、配置档,以及其他 - 作用于整张图的 `[build]` 值都相同的成员在同一张图中构建。其中任一项不同的成员在各自的图中 - 构建,这些图同时进行,共享命令的并行任务数。 +- **配置。** 工具链请求、目标、C++ 标准、`dialect_cxxflags`、C++ 运行时、`linkage`、配置档、 + 索引,以及其他作用于整张图的 `[build]` 值都相同的成员在同一张图中构建。其中任一项不同的成员 + 在各自的图中构建,这些图同时进行,共享命令的并行任务数。成员写下的相对路径(例如它自己的 + `[indices]` 路径)按该成员的目录解析。 - **选择。** `--workspace`,以及虚拟工作空间根下不带 `-p` 的命令,选中全体成员;`-p X` 与在 X 的目录中执行的命令规划 X 及其所依赖的一切。两者共用构建目录,因此先执行 `mcpp build --workspace` 再执行 `mcpp build -p X` 不编译任何内容;只有当某个包在两条命令中 @@ -404,6 +405,8 @@ mcpp test --workspace --workspace-timeout 1800 # whole fan-out (default 0 = no - **Feature。** `--features f` 在每个声明了 `f` 的被选成员中启用它;没有被选成员声明它时, 命令被拒绝。 - **Hooks。** 每个被选成员的 `[hooks]` 按成员顺序在构建前后运行。 +- **资源。** 成员的 `[resources]` 与 `windows_code_page` 按该成员的目录与 include 目录编译, + 只嵌入该成员自己的程序与共享库(2026.9.29.2+)。 - **无事可做的构建。** 在没有任何改动时重复执行的命令,每个配置只做一次检查,不重新规划。 - **模块名。** 在同一张图中构建的成员共享一个模块命名空间:两个成员各自提供同名模块时,不能在 同一条 `--workspace` 命令中构建;分别用 `-p` 构建。 diff --git a/mcpp.toml b/mcpp.toml index 3c737776..13293072 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.9.29.1" +version = "2026.9.29.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index 40e106fb..a863d91a 100644 --- a/modules/versioning/src/version.cppm +++ b/modules/versioning/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.9.29.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.9.29.2"; } // namespace mcpp diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index f682183a..40fc6f96 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -2751,6 +2751,11 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements) { escape_ninja_path(ru.output), escape_ninja_path(ru.source), implicit.empty() ? std::string{} : " |" + implicit)); + if (!ru.flags.empty() && ru.flags != plan.rcFlags) { + std::string rcf; + for (auto const& f : ru.flags) { rcf += ' '; rcf += shell_quote_arg(f); } + append(std::format(" rcflags ={}\n", rcf)); + } } if (!plan.resourceUnits.empty()) append("\n"); diff --git a/src/build/plan.cppm b/src/build/plan.cppm index bf48b222..16e98baa 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -176,6 +176,13 @@ struct ResourceUnit { // (verified against llvm-rc 22.1.8: /I, /D, no dependency output), so these // come from a text scan plus `[resources].extra-inputs`. std::vector implicitInputs; + // The directories the script's `#include`s are searched in: its package's + // directory and include_dirs. A workspace plan compiles the resources of + // several members, each against its own package. + std::vector includeDirs; + // The resource compiler's flags for this script. `BuildPlan::rcFlags` are + // the first unit's; a unit whose flags differ states its own. + std::vector flags; }; struct RuntimeCapabilityProvider { diff --git a/src/build/prepare/config.cpp b/src/build/prepare/config.cpp index 7c406dd5..b7e0ff41 100644 --- a/src/build/prepare/config.cpp +++ b/src/build/prepare/config.cpp @@ -794,7 +794,8 @@ bool is_std_module(std::string_view name) { bool graph_or_targets_import_std(const mcpp::modgraph::Graph& graph, const mcpp::manifest::Manifest& manifest, - const std::filesystem::path& projectRoot) { + const std::filesystem::path& projectRoot, + const std::vector& packages) { for (auto& u : graph.units) { for (auto& req : u.requires_) { if (is_std_module(req.logicalName)) @@ -804,16 +805,26 @@ bool graph_or_targets_import_std(const mcpp::modgraph::Graph& graph, // Some target entry files can be added to the plan after the package scan. // Check them here so std BMI setup matches what make_plan will compile: they - // are read by the same scan_entry_file make_plan reads them with. - const auto extTable = mcpp::extension_table_for(manifest.buildConfig.moduleExtensions, - manifest.buildConfig.deviceExtensions); - for (auto& t : manifest.targets) { - if (t.main.empty()) continue; - const auto entry = mcpp::modgraph::scan_entry_file(projectRoot / t.main, - manifest.package.name, extTable); - for (auto const& req : entry.requires_) - if (is_std_module(req.logicalName)) return true; - } + // are read by the same scan_entry_file make_plan reads them with. The + // packages whose targets make_plan compiles are the root and, in a + // workspace plan, every selected member (a member whose only sources are + // its tests is the case the root alone misses). + auto targets_import_std = [](const mcpp::manifest::Manifest& m, + const std::filesystem::path& root) { + const auto extTable = mcpp::extension_table_for(m.buildConfig.moduleExtensions, + m.buildConfig.deviceExtensions); + for (auto& t : m.targets) { + if (t.main.empty()) continue; + const auto entry = mcpp::modgraph::scan_entry_file(root / t.main, + m.package.name, extTable); + for (auto const& req : entry.requires_) + if (is_std_module(req.logicalName)) return true; + } + return false; + }; + if (targets_import_std(manifest, projectRoot)) return true; + for (auto const& pkg : packages) + if (pkg.selectedMember && targets_import_std(pkg.manifest, pkg.root)) return true; return false; } diff --git a/src/build/prepare/manifest.cpp b/src/build/prepare/manifest.cpp index 337704e6..5be45e05 100644 --- a/src/build/prepare/manifest.cpp +++ b/src/build/prepare/manifest.cpp @@ -47,6 +47,107 @@ import mcpp.project; namespace mcpp::build { +// AND ONLY FOR THE MANIFESTS THE AUTHOR IS LOOKING AT: the root, and in a +// workspace plan each selected member. A layer name this engine does not know +// is a typo there, and a version gap in a dependency's. The reserved `mcpp:` +// prefix exists so the first is an error rather than a silently disabled +// behaviour; refusing the second as well meant the layer vocabulary could +// never be extended by a published package (`warn_unknown_xpkg_keys` carries +// that half). +static std::expected +refuse_unknown_capability(const mcpp::manifest::Manifest& m, + const std::filesystem::path& manifestPath) { + if (m.unknownCapabilities.empty()) return {}; + auto const& cap = m.unknownCapabilities.front(); + auto why = mcpp::targetside::parse_capability(cap); + return std::unexpected(std::format( + "{}: {}", manifestPath.string(), + why ? std::format("`{}` names no capability mcpp knows.", cap) + : why.error())); +} + +// The schema warnings of a manifest the author is looking at, and its cfg() +// sections that cannot apply. Under --strict they become errors -- same policy +// as the feature/platform schema checks. +static std::expected +report_manifest_statements(const mcpp::manifest::Manifest& m, bool strict) { + std::vector warnings = m.schemaWarnings; + // #540: a cfg() predicate mcpp cannot evaluate must say so. + // + // A PREDICATE THAT ANSWERS FALSE AND A PREDICATE THAT WAS NEVER + // UNDERSTOOD USED TO READ THE SAME. `cfgpred` returns false for an unknown + // key and for an unknown bareword, and a `[target..build]` section + // whose predicate is false is dropped without a word — so a typo, and every + // `cfg(c-abi = …)` section docs/14 documented before this release, produced + // a successful build configured as if the section had not been written. + // + // Reported here rather than in the manifest parser because the vocabulary + // lives with the evaluator, and a second copy of it in `toml.cppm` is the + // exact defect this release is fixing four other instances of. + // + // Scoped to the manifests the author is looking at (the root, and in a + // workspace plan each selected member), which matches the existing policy + // for every other schema warning: a dependency may adopt a predicate a + // consumer's older mcpp does not know, and its build stays quiet. + for (auto const& cc : m.conditionalConfigs) { + auto unknown = cfgpred::unknown_tokens(cc.predicate); + if (!unknown.empty()) { + std::string names; + for (auto const& u : unknown) { + if (!names.empty()) names += ", "; + names += '\'' + u + '\''; + } + warnings.push_back(std::format( + "[target.'{}'] names {} in its cfg() predicate, which mcpp does " + "not know, so the section never applies (ignored). {}", + cc.predicate, names, cfgpred::vocabulary_sentence())); + } + // A RESOLVED layer is answered AFTER dependency resolution, so a + // dependency selected by one would form a cycle with the resolution + // that produces the answer — docs/14 states this. The section's build + // inputs are honoured by the second pass; its dependencies cannot be, + // and saying so is the difference between a documented limit and a + // silent drop. + // + // `accelerator` is not one of these (see kCfgEarlyLayerKeys), so + // `[target.'cfg(accelerator = "cuda")'.dependencies]` is honoured and + // never reaches this warning: nothing about it is circular, because the + // accel is an input to the build rather than an answer from the graph. + if (cfgpred::uses_layer(cc.predicate) + && !(cc.dependencies.empty() && cc.devDependencies.empty() + && cc.buildDependencies.empty() && cc.featureDeps.empty())) { + warnings.push_back(std::format( + "[target.'{}'] conditions dependencies on a target-side layer " + "(ignored). A layer is resolved from the dependency graph, so a " + "dependency chosen by one would decide the answer it is asking " + "for. Build inputs under this predicate DO apply; move the " + "dependency to an unconditional [dependencies] entry, or " + "condition it on the triple instead.", + cc.predicate)); + } + // The same reason holds for a row's library form: whether a package + // is linked shared is decided while the graph is resolved, before a + // layer has an answer. + if (cfgpred::uses_layer(cc.predicate) && !cc.targetKinds.empty()) { + warnings.push_back(std::format( + "[target.'{}'] conditions a target's kind or linkage on a " + "target-side layer (ignored). A layer is resolved from the " + "dependency graph, and a library's form is decided while that " + "graph is resolved; condition the statement on the triple " + "instead.", + cc.predicate)); + } + } + + // Surface non-fatal manifest schema warnings (e.g. unsupported [targets.*] + // keys). + for (auto const& w : warnings) { + if (strict) return std::unexpected(w); + mcpp::diag::warning("manifest/schema", w); + } + return {}; +} + // The plan's root for a selection of workspace members (workspace design // 2026-09-29 §15): a virtual root that holds the values the plan shares, and a // member edge to each selected member, which puts the member in the graph and @@ -85,6 +186,10 @@ select_workspace_members(PrepareState& state, const std::filesystem::path& wsRoo state.selectedMembers[canonical] = mcpp::project::product_directory_name(all, mp); auto member = mcpp::project::load_member_manifest(*state.wsManifest, wsRoot, mp); if (!member) return std::unexpected(member.error()); + if (auto r = refuse_unknown_capability(*member, dir / "mcpp.toml"); !r) + return r; + if (auto r = report_manifest_statements(*member, state.overrides.strict); !r) + return r; std::vector requested; for (auto const& t : tokens) { if (auto fwd = mcpp::pm::split_feature_forward_token(t)) { @@ -395,20 +500,9 @@ std::expected phase0_manifest_and_workspace(PrepareState& sta state.effective = std::move(*loaded); } - // AND ONLY FOR THE ROOT. A layer name this engine does not know is a - // typo in the manifest the author is looking at, and a version gap in a - // dependency's. The reserved `mcpp:` prefix exists so the first is an error - // rather than a silently disabled behaviour; refusing the second as well - // meant the layer vocabulary could never be extended by a published package - // (`warn_unknown_xpkg_keys` carries that half). - if (!state.m->unknownCapabilities.empty()) { - auto const& cap = state.m->unknownCapabilities.front(); - auto why = mcpp::targetside::parse_capability(cap); - return std::unexpected(std::format( - "{}: {}", (*state.root / "mcpp.toml").string(), - why ? std::format("`{}` names no capability mcpp knows.", cap) - : why.error())); - } + // The root's; a workspace plan's members are refused as they are selected. + if (auto r = refuse_unknown_capability(*state.m, *state.root / "mcpp.toml"); !r) + return r; // A DISTRIBUTION package is not a source tree, and building "in" one is a // failure that looks like a success: `interface/` holds declarations whose @@ -495,79 +589,9 @@ std::expected phase0_manifest_and_workspace(PrepareState& sta if (!state.workspacePlan()) for (auto& t : state.extraTargets) state.m->targets.push_back(t); - // #540: a cfg() predicate mcpp cannot evaluate must say so. - // - // A PREDICATE THAT ANSWERS FALSE AND A PREDICATE THAT WAS NEVER - // UNDERSTOOD USED TO READ THE SAME. `cfgpred` returns false for an unknown - // key and for an unknown bareword, and a `[target..build]` section - // whose predicate is false is dropped without a word — so a typo, and every - // `cfg(c-abi = …)` section docs/14 documented before this release, produced - // a successful build configured as if the section had not been written. - // - // Reported here rather than in the manifest parser because the vocabulary - // lives with the evaluator, and a second copy of it in `toml.cppm` is the - // exact defect this release is fixing four other instances of. - // - // Scoped to the root manifest by where it sits, which matches the existing - // policy for every other schema warning: a dependency may adopt a predicate - // a consumer's older mcpp does not know, and its build stays quiet. - for (auto const& cc : state.m->conditionalConfigs) { - auto unknown = cfgpred::unknown_tokens(cc.predicate); - if (!unknown.empty()) { - std::string names; - for (auto const& u : unknown) { - if (!names.empty()) names += ", "; - names += '\'' + u + '\''; - } - state.m->schemaWarnings.push_back(std::format( - "[target.'{}'] names {} in its cfg() predicate, which mcpp does " - "not know, so the section never applies (ignored). {}", - cc.predicate, names, cfgpred::vocabulary_sentence())); - } - // A RESOLVED layer is answered AFTER dependency resolution, so a - // dependency selected by one would form a cycle with the resolution - // that produces the answer — docs/14 states this. The section's build - // inputs are honoured by the second pass; its dependencies cannot be, - // and saying so is the difference between a documented limit and a - // silent drop. - // - // `accelerator` is not one of these (see kCfgEarlyLayerKeys), so - // `[target.'cfg(accelerator = "cuda")'.dependencies]` is honoured and - // never reaches this warning: nothing about it is circular, because the - // accel is an input to the build rather than an answer from the graph. - if (cfgpred::uses_layer(cc.predicate) - && !(cc.dependencies.empty() && cc.devDependencies.empty() - && cc.buildDependencies.empty() && cc.featureDeps.empty())) { - state.m->schemaWarnings.push_back(std::format( - "[target.'{}'] conditions dependencies on a target-side layer " - "(ignored). A layer is resolved from the dependency graph, so a " - "dependency chosen by one would decide the answer it is asking " - "for. Build inputs under this predicate DO apply; move the " - "dependency to an unconditional [dependencies] entry, or " - "condition it on the triple instead.", - cc.predicate)); - } - // The same reason holds for a row's library form: whether a package - // is linked shared is decided while the graph is resolved, before a - // layer has an answer. - if (cfgpred::uses_layer(cc.predicate) && !cc.targetKinds.empty()) { - state.m->schemaWarnings.push_back(std::format( - "[target.'{}'] conditions a target's kind or linkage on a " - "target-side layer (ignored). A layer is resolved from the " - "dependency graph, and a library's form is decided while that " - "graph is resolved; condition the statement on the triple " - "instead.", - cc.predicate)); - } - } - - // Surface non-fatal manifest schema warnings (e.g. unsupported [targets.*] - // keys). Under --strict they become errors — same policy as the - // feature/platform schema checks below. - for (auto const& w : state.m->schemaWarnings) { - if (state.overrides.strict) return std::unexpected(w); - mcpp::diag::warning("manifest/schema", w); - } + // The root's; a workspace plan's members are reported as they are selected. + if (auto r = report_manifest_statements(*state.m, state.overrides.strict); !r) + return r; // Load mcpp.lock once, up front: it is a resolution input for git deps // (#329), which decide the commit to build long before anything is diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index 4c671d91..8c54570f 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -1344,13 +1344,75 @@ static std::expected step13_assembly_units(PrepareState& stat return {}; } +// The resource compiler of the plan's resource units, and each unit's flags. +static std::expected +step13_resource_compiler(PrepareState& state, BuildContext& ctx) { + if (ctx.plan.resourceUnits.empty()) return {}; + namespace rsrc = mcpp::build::resources; + const auto trip = mcpp::toolchain::triple::parse(state.tc->targetTriple) + .value_or(mcpp::toolchain::triple::host_triple()); + const auto dialectId = mcpp::toolchain::dialect_for(*state.tc).id; + const bool msvcStyle = (dialectId == "msvc"); + + // Lazy + hard failure, exactly like nasm: a dropped resource + // surfaces as "where did my icon go", which is unattributable. + auto tool = rsrc::find_rc_tool(*state.tc, dialectId); + if (!tool) { + return std::unexpected(std::format( + "[resources] needs a Windows resource compiler for the " + "{} toolchain targeting {}, and none was found next to " + "{}.\n Expected {} in the toolchain's own bin directory " + "(mcpp does not search PATH for build tools).", + dialectId, trip.str(), state.tc->binaryPath.string(), + msvcStyle ? "rc.exe or llvm-rc" + : "-windres, windres or llvm-windres")); + } + ctx.plan.rcPath = tool->path; + ctx.plan.rcStyle = tool->style; + + // UTF-8 input, always. `[package]` metadata is user text and + // routinely non-ASCII; without this llvm-rc refuses the script + // outright ("Non-ASCII 8-bit codepoint can't be interpreted in + // the current codepage") rather than mangling it, so a project + // with a Chinese description could not build at all. + // + // Include search: the script's package first, then whatever the + // toolchain puts on INCLUDE. llvm-rc preprocesses but does NOT read + // INCLUDE (rc.exe does), so the SDK dirs have to be spelled out for it -- + // that is what makes `#include ` work, and it is the + // supported way to get VS_VERSION_INFO defined. Each unit carries the + // flags of its package; the plan's flags are the first unit's, so a + // plan with one package states them once. + const std::string ip = msvcStyle ? "/I" : "-I"; + std::vector systemIncludes; + if (msvcStyle && tool->name().find("llvm-rc") != std::string::npos) { + for (auto const& ev : state.tc->envOverrides) { + if (ev.key != "INCLUDE") continue; + // Shared splitter: `;` only. See rsrc::split_env_list -- + // the drive colon is not a separator. + for (auto dir : rsrc::split_env_list(ev.value)) + systemIncludes.push_back(ip + std::string(dir)); + } + } + for (auto& ru : ctx.plan.resourceUnits) { + ru.flags.push_back(msvcStyle ? "/C" : "--codepage=65001"); + if (msvcStyle) ru.flags.push_back("65001"); + for (auto const& d : ru.includeDirs) ru.flags.push_back(ip + d.string()); + ru.flags.insert(ru.flags.end(), systemIncludes.begin(), systemIncludes.end()); + } + ctx.plan.rcFlags = ctx.plan.resourceUnits.front().flags; + return {}; +} + static std::expected step13_windows_resources(PrepareState& state, BuildContext& ctx) { // ─── Windows resources: [resources] → a tracked link input (mcpp#365) ── // // Four rules, in this order: - // 1. Only the ROOT package's [resources] is read. A dependency's version - // resource would fight its consumer's for ordinal 1, and a dependency - // that produces no PE image of its own has nothing to embed into. + // 1. Only the [resources] of the package being built is read: the root, + // or in a workspace plan each selected member, whose resources reach + // that member's images only. A dependency's version resource would + // fight its consumer's for ordinal 1, and a dependency that produces + // no PE image of its own has nothing to embed into. // 2. A DECLARED FILE THAT DOES NOT EXIST IS AN ERROR — on EVERY target. // Whether a path exists is a fact about the working tree, not about // the target; gating it on is_pe() meant a Linux or macOS CI could not @@ -1374,317 +1436,315 @@ static std::expected step13_windows_resources(PrepareState& s // through `[resources] files`: both would sit at ordinal 1, the package // said nothing about code pages, and its own manifest is the one it ships. // A DECLARED `utf-8` beside such a manifest is refused below instead. - const bool hostToolBuild = state.overrides.tool_depth > 0; - const bool ownManifest = hostToolBuild - && std::ranges::any_of(state.m->resources.files, [&](const auto& f) { - const auto abs = (f.is_absolute() ? f : (*state.root / f)).lexically_normal(); - return mcpp::build::resources::scan_rc(abs).declaresManifest; - }); - auto codePageOf = [&](const mcpp::manifest::Target& t) -> std::string_view { - if (!t.windowsCodePage.empty()) return t.windowsCodePage; - return (hostToolBuild && t.is_program() && !ownManifest) ? "utf-8" : "legacy"; + // + // A SUBJECT is one package whose resources are planned: its manifest, the + // directory its paths were written in, and the images it owns. Outside a + // workspace plan the root is the only subject and owns every image that + // is not a dependency's program, which is the historical rule. + struct Subject { + const mcpp::manifest::Manifest* m; + std::filesystem::path dir; + std::string owner; // empty: the root }; - const bool anyUtf8Image = std::ranges::any_of(state.m->targets, [&](const auto& t) { - return t.is_program() && codePageOf(t) == "utf-8"; - }); - if (state.m->resources.declared() || anyUtf8Image) { - namespace rsrc = mcpp::build::resources; - const auto& R = state.m->resources; - - // Rule 2 — target-independent, so it runs before the is_pe() gate. - auto resolve_declared = [&](const std::filesystem::path& p, - std::string_view key) - -> std::expected - { - // Lexical, not weakly_canonical: canonicalising resolves symlinks, - // and a symlinked source tree would then bake a different path into - // the generated script than the one the user wrote. (Same reason - // mcpp#344 made the cache anchor lexical.) - auto abs = (p.is_absolute() ? p : (*state.root / p)).lexically_normal(); - std::error_code ec; - if (!std::filesystem::is_regular_file(abs, ec)) - return std::unexpected(std::format( - "[resources] {} = \"{}\" does not exist (looked at {}).\n" - " A declared resource is a build input like any other " - "source: mcpp will not quietly ship a binary without it. " - "Remove the key if the resource is not wanted.", - key, p.generic_string(), abs.generic_string())); - return abs; + std::vector subjects; + if (!state.workspacePlan()) { + subjects.push_back({&*state.m, *state.root, {}}); + } else { + for (auto const& pkg : state.packages) + if (pkg.selectedMember) + subjects.push_back({&pkg.manifest, pkg.root, + mcpp::build::qualified_package_name(pkg.manifest)}); + } + const bool hostToolBuild = state.overrides.tool_depth > 0; + const auto trip = mcpp::toolchain::triple::parse(state.tc->targetTriple) + .value_or(mcpp::toolchain::triple::host_triple()); + const auto dialectId = mcpp::toolchain::dialect_for(*state.tc).id; + const bool msvcStyle = (dialectId == "msvc"); + + for (auto const& S : subjects) { + const auto& M = *S.m; + const bool ownManifest = hostToolBuild + && std::ranges::any_of(M.resources.files, [&](const auto& f) { + const auto abs = (f.is_absolute() ? f : (S.dir / f)).lexically_normal(); + return mcpp::build::resources::scan_rc(abs).declaresManifest; + }); + auto codePageOf = [&](const mcpp::manifest::Target& t) -> std::string_view { + if (!t.windowsCodePage.empty()) return t.windowsCodePage; + return (hostToolBuild && t.is_program() && !ownManifest) ? "utf-8" : "legacy"; }; - - std::filesystem::path iconAbs; - if (!R.icon.empty()) { - auto r = resolve_declared(R.icon, "icon"); - if (!r) return std::unexpected(r.error()); - iconAbs = *r; - } - std::vector extraInputs; - for (auto const& e : R.extraInputs) { - auto r = resolve_declared(e, "extra-inputs"); - if (!r) return std::unexpected(r.error()); - extraInputs.push_back(*r); - } - std::vector scriptFiles; - for (auto const& f : R.files) { - auto r = resolve_declared(f, "files"); - if (!r) return std::unexpected(r.error()); - scriptFiles.push_back(*r); - } - - const auto trip = mcpp::toolchain::triple::parse(state.tc->targetTriple) - .value_or(mcpp::toolchain::triple::host_triple()); - - // Rules 3 and 4 are early returns rather than nesting: the body below is - // ~150 lines and an `else` around all of it reads as an accident. - auto plan_resources = [&]() -> std::expected { - const auto dialectId = mcpp::toolchain::dialect_for(*state.tc).id; - const bool msvcStyle = (dialectId == "msvc"); - const std::string_view outExt = msvcStyle ? ".res" : ".o"; - const auto resDir = ctx.plan.outputDir / "res"; - std::error_code mkEc; - std::filesystem::create_directories(resDir, mkEc); - - // Which link units embed resources: images, not archives. A `.res` - // inside a static library is dropped by every linker that reads one. - // Test binaries are images too, but deliberately excluded: an icon - // and an OriginalFilename belong to what the project SHIPS, and a - // test executable is not that. (`role = "object"` makes the opposite - // call, for the opposite reason — see its note above.) - std::vector peUnits; - for (std::size_t i = 0; i < ctx.plan.linkUnits.size(); ++i) { - auto k = ctx.plan.linkUnits[i].kind; - // A dependency's program (mcpp#711) carries its own package's - // identity, not this one's. - if (!ctx.plan.linkUnits[i].artifactOf.empty()) continue; - if (k == mcpp::build::LinkUnit::Binary || - k == mcpp::build::LinkUnit::SharedLibrary) - peUnits.push_back(i); - } - // Nothing to embed into. Compiling the scripts anyway would leave - // orphan edges nothing depends on, and demanding a resource - // compiler for them would fail a build that has no use for one. - // A degradation, not a warning: the user asked for something and - // got nothing, so `--strict` should see it. - if (peUnits.empty()) { - mcpp::diag::degraded("resources/no-image", std::format( - "[resources] is declared but '{}' produces no executable or " - "shared library for {}", state.m->package.name, trip.str()), - "nothing embeds the icon or the version metadata", - "add a [targets.] with kind = \"bin\" or \"shared\", " - "or drop the [resources] section"); - return {}; - } - - // Two scripts with the same stem in different directories would - // otherwise write the same artifact — a silent "multiple rules - // generate" that ninja reports far from the cause. - std::set usedStems; - auto add_unit = [&](const std::filesystem::path& src, - std::string_view stem, - std::vector inputs, - std::size_t attachTo) - -> std::expected + const bool anyUtf8Image = std::ranges::any_of(M.targets, [&](const auto& t) { + return t.is_program() && codePageOf(t) == "utf-8"; + }); + if (M.resources.declared() || anyUtf8Image) { + namespace rsrc = mcpp::build::resources; + const auto& R = M.resources; + + // Rule 2 — target-independent, so it runs before the is_pe() gate. + auto resolve_declared = [&](const std::filesystem::path& p, + std::string_view key) + -> std::expected { - if (!usedStems.insert(std::string(stem)).second) + // Lexical, not weakly_canonical: canonicalising resolves symlinks, + // and a symlinked source tree would then bake a different path into + // the generated script than the one the user wrote. (Same reason + // mcpp#344 made the cache anchor lexical.) + auto abs = (p.is_absolute() ? p : (S.dir / p)).lexically_normal(); + std::error_code ec; + if (!std::filesystem::is_regular_file(abs, ec)) return std::unexpected(std::format( - "[resources] two resource scripts are named '{}.rc'; " - "they would produce the same artifact. Rename one.", stem)); - mcpp::build::ResourceUnit ru; - ru.source = src; - ru.output = std::filesystem::path("res") / - (std::string(stem) + std::string(outExt)); - ru.implicitInputs = std::move(inputs); - ctx.plan.resourceUnits.push_back(std::move(ru)); - const auto& out = ctx.plan.resourceUnits.back().output; - if (attachTo == static_cast(-1)) { - for (auto i : peUnits) ctx.plan.linkUnits[i].objects.push_back(out); - } else { - ctx.plan.linkUnits[attachTo].objects.push_back(out); - } - return {}; + "[resources] {} = \"{}\" does not exist (looked at {}).\n" + " A declared resource is a build input like any other " + "source: mcpp will not quietly ship a binary without it. " + "Remove the key if the resource is not wanted.", + key, p.generic_string(), abs.generic_string())); + return abs; }; - // Author-written scripts: compiled once, linked into every image. - for (auto const& rcSrc : scriptFiles) { - auto scan = rsrc::scan_rc(rcSrc); - if (scan.versionInfoNamedByString) { - // The mcpp#365 silent failure, caught on the way in. A - // degradation rather than a warning: the impact is exactly - // the thing this feature exists to remove — a shipped binary - // whose version metadata Windows cannot read — so a build - // that asked for `--strict` must not pass over it. - mcpp::diag::degraded("resources/versioninfo", std::format( - "{}: `{} VERSIONINFO` names the version resource '{}' " - "instead of ordinal 1", - rcSrc.filename().generic_string(), scan.versionInfoName, - scan.versionInfoName), - "Windows will not find it — GetFileVersionInfo looks up " - "MAKEINTRESOURCE(1) and every field comes back empty, " - "while every tool that prints the resource TYPE still " - "says it is fine", - "VS_VERSION_INFO is a macro from ; add " - "`#include ` to the script, or write " - "`1 VERSIONINFO`"); - } - for (auto const& g : scan.gaps) { - mcpp::diag::degraded("resources/inputs", - std::format("{}: `{}` names its file through a macro, so " - "mcpp cannot track it", - rcSrc.filename().generic_string(), g), - "editing that file will not trigger a rebuild", - "list it in [resources] extra-inputs = [...]"); - } - if (scan.declaresManifest && anyUtf8Image) - return std::unexpected(std::format( - "[resources] {} embeds an application manifest, and " - "`windows_code_page = \"utf-8\"` embeds another at the same " - "ordinal (1).\n Keep one: add `" - "UTF-8` to your manifest and set " - "`windows_code_page = \"legacy\"`, or drop your manifest.", - rcSrc.filename().generic_string())); - auto inputs = std::move(scan.inputs); - inputs.insert(inputs.end(), extraInputs.begin(), extraInputs.end()); - if (auto a = add_unit(rcSrc, rcSrc.stem().string(), - std::move(inputs), - static_cast(-1)); !a) - return std::unexpected(a.error()); + std::filesystem::path iconAbs; + if (!R.icon.empty()) { + auto r = resolve_declared(R.icon, "icon"); + if (!r) return std::unexpected(r.error()); + iconAbs = *r; + } + std::vector extraInputs; + for (auto const& e : R.extraInputs) { + auto r = resolve_declared(e, "extra-inputs"); + if (!r) return std::unexpected(r.error()); + extraInputs.push_back(*r); + } + std::vector scriptFiles; + for (auto const& f : R.files) { + auto r = resolve_declared(f, "files"); + if (!r) return std::unexpected(r.error()); + scriptFiles.push_back(*r); } - // The synthesised script: per image, because OriginalFilename and - // the version block belong to a specific artifact, and the - // manifest to a specific executable. - const bool synthVersion = R.declared() && R.synthesize_version_info(); - auto wantsUtf8 = [&](const mcpp::build::LinkUnit& lu) { - if (lu.kind != mcpp::build::LinkUnit::Binary) return false; - if (!lu.artifactOf.empty()) return false; - for (auto const& t : state.m->targets) - if (t.name == lu.targetName) - return t.is_program() && codePageOf(t) == "utf-8"; - return false; - }; - if (!iconAbs.empty() || synthVersion || anyUtf8Image) { - // A version key mcpp cannot order (an upstream build number) - // leaves FILEVERSION's four numeric fields at zero while the - // string fields keep the real text. Say so — the properties - // dialog will disagree with `[package].version` and nothing - // else would explain why. - if (synthVersion && !state.m->package.version.empty() - && !mcpp::version_req::parse_version(state.m->package.version)) { - mcpp::diag::degraded("resources/version", - std::format("[package].version = \"{}\" has no numeric " - "form", state.m->package.version), - "the embedded FILEVERSION / PRODUCTVERSION fields are " - "0,0,0,0 (the string fields keep the real version)", - "set [resources.version-info] explicitly, or use a " - "dotted numeric version"); + // Rules 3 and 4 are early returns rather than nesting: the body below is + // ~150 lines and an `else` around all of it reads as an accident. + auto plan_resources = [&]() -> std::expected { + const std::string_view outExt = msvcStyle ? ".res" : ".o"; + // A member's resources are compiled in a directory of its own, so + // two members' scripts and synthesised scripts never share a name. + const auto resRel = S.owner.empty() ? std::filesystem::path("res") + : std::filesystem::path("res") / S.owner; + const auto resDir = ctx.plan.outputDir / resRel; + std::error_code mkEc; + std::filesystem::create_directories(resDir, mkEc); + + // Which link units embed resources: images, not archives. A `.res` + // inside a static library is dropped by every linker that reads one. + // Test binaries are images too, but deliberately excluded: an icon + // and an OriginalFilename belong to what the project SHIPS, and a + // test executable is not that. (`role = "object"` makes the opposite + // call, for the opposite reason — see its note above.) + // In a workspace plan a member's images are its link units and + // its shared libraries, which are linked with the graph's. + auto owns = [&](const mcpp::build::LinkUnit& lu) { + if (S.owner.empty()) return true; + if (lu.memberOf == S.owner) return true; + return lu.kind == mcpp::build::LinkUnit::SharedLibrary + && std::ranges::any_of(M.targets, [&](const auto& t) { + return t.kind == mcpp::manifest::Target::SharedLibrary + && t.name == lu.targetName; + }); + }; + std::vector peUnits; + for (std::size_t i = 0; i < ctx.plan.linkUnits.size(); ++i) { + auto k = ctx.plan.linkUnits[i].kind; + // A dependency's program (mcpp#711) carries its own package's + // identity, not this one's. + if (!ctx.plan.linkUnits[i].artifactOf.empty()) continue; + if (!owns(ctx.plan.linkUnits[i])) continue; + if (k == mcpp::build::LinkUnit::Binary || + k == mcpp::build::LinkUnit::SharedLibrary) + peUnits.push_back(i); } - for (auto i : peUnits) { - const auto& lu = ctx.plan.linkUnits[i]; - const bool utf8 = wantsUtf8(lu); - if (iconAbs.empty() && !synthVersion && !utf8) continue; - std::filesystem::path manifestAbs; - if (utf8) { - manifestAbs = resDir / (lu.targetName + ".mcpp.manifest"); - const auto manifestText = rsrc::utf8_code_page_manifest(); - std::string had; - if (std::ifstream in(manifestAbs, std::ios::binary); in) - had.assign(std::istreambuf_iterator(in), {}); - if (had != manifestText) { - std::ofstream os(manifestAbs, std::ios::binary); - if (!os) return std::unexpected(std::format( - "cannot write the application manifest '{}'", - manifestAbs.string())); - os << manifestText; - } + // Nothing to embed into. Compiling the scripts anyway would leave + // orphan edges nothing depends on, and demanding a resource + // compiler for them would fail a build that has no use for one. + // A degradation, not a warning: the user asked for something and + // got nothing, so `--strict` should see it. + if (peUnits.empty()) { + mcpp::diag::degraded("resources/no-image", std::format( + "[resources] is declared but '{}' produces no executable or " + "shared library for {}", M.package.name, trip.str()), + "nothing embeds the icon or the version metadata", + "add a [targets.] with kind = \"bin\" or \"shared\", " + "or drop the [resources] section"); + return {}; + } + + // Two scripts with the same stem in different directories would + // otherwise write the same artifact — a silent "multiple rules + // generate" that ninja reports far from the cause. + std::set usedStems; + auto add_unit = [&](const std::filesystem::path& src, + std::string_view stem, + std::vector inputs, + std::size_t attachTo) + -> std::expected + { + if (!usedStems.insert(std::string(stem)).second) + return std::unexpected(std::format( + "[resources] two resource scripts are named '{}.rc'; " + "they would produce the same artifact. Rename one.", stem)); + mcpp::build::ResourceUnit ru; + ru.source = src; + ru.output = resRel / (std::string(stem) + std::string(outExt)); + ru.includeDirs.push_back(S.dir); + for (auto const& d : M.buildConfig.includeDirs) + ru.includeDirs.push_back(d.is_absolute() ? d : (S.dir / d)); + ru.implicitInputs = std::move(inputs); + ctx.plan.resourceUnits.push_back(std::move(ru)); + const auto& out = ctx.plan.resourceUnits.back().output; + if (attachTo == static_cast(-1)) { + for (auto i : peUnits) ctx.plan.linkUnits[i].objects.push_back(out); + } else { + ctx.plan.linkUnits[attachTo].objects.push_back(out); + } + return {}; + }; + + // Author-written scripts: compiled once, linked into every image. + for (auto const& rcSrc : scriptFiles) { + std::vector rcIncludes; + for (auto const& d : M.buildConfig.includeDirs) + rcIncludes.push_back(d.is_absolute() ? d : (S.dir / d)); + auto scan = rsrc::scan_rc(rcSrc, rcIncludes); + if (scan.versionInfoNamedByString) { + // The mcpp#365 silent failure, caught on the way in. A + // degradation rather than a warning: the impact is exactly + // the thing this feature exists to remove — a shipped binary + // whose version metadata Windows cannot read — so a build + // that asked for `--strict` must not pass over it. + mcpp::diag::degraded("resources/versioninfo", std::format( + "{}: `{} VERSIONINFO` names the version resource '{}' " + "instead of ordinal 1", + rcSrc.filename().generic_string(), scan.versionInfoName, + scan.versionInfoName), + "Windows will not find it — GetFileVersionInfo looks up " + "MAKEINTRESOURCE(1) and every field comes back empty, " + "while every tool that prints the resource TYPE still " + "says it is fine", + "VS_VERSION_INFO is a macro from ; add " + "`#include ` to the script, or write " + "`1 VERSIONINFO`"); } - // A script synthesised for the manifest alone carries - // nothing else: a package that declares no [resources] - // asked for no version resource. - mcpp::manifest::Resources forScript = R; - if (!synthVersion) forScript.versionInfo = false; - auto text = rsrc::synthesize_rc( - state.m->package, forScript, lu.output.filename().string(), - iconAbs, manifestAbs); - if (!text) return std::unexpected(text.error()); - // A stable path, so `cp` + `files = [...]` reproduces the - // same resource byte for byte (the L0→L1 escape hatch). - auto rcPath = resDir / (lu.targetName + ".mcpp.rc"); - // Write only on change: rewriting unconditionally would - // relink on every build. - std::string existing; - if (std::ifstream in(rcPath, std::ios::binary); in) - existing.assign(std::istreambuf_iterator(in), {}); - if (existing != *text) { - std::ofstream os(rcPath, std::ios::binary); - if (!os) return std::unexpected(std::format( - "cannot write generated resource script '{}'", - rcPath.string())); - os << *text; + for (auto const& g : scan.gaps) { + mcpp::diag::degraded("resources/inputs", + std::format("{}: `{}` names its file through a macro, so " + "mcpp cannot track it", + rcSrc.filename().generic_string(), g), + "editing that file will not trigger a rebuild", + "list it in [resources] extra-inputs = [...]"); } - std::vector inputs; - if (!iconAbs.empty()) inputs.push_back(iconAbs); - if (!manifestAbs.empty()) inputs.push_back(manifestAbs); + if (scan.declaresManifest && anyUtf8Image) + return std::unexpected(std::format( + "[resources] {} embeds an application manifest, and " + "`windows_code_page = \"utf-8\"` embeds another at the same " + "ordinal (1).\n Keep one: add `" + "UTF-8` to your manifest and set " + "`windows_code_page = \"legacy\"`, or drop your manifest.", + rcSrc.filename().generic_string())); + auto inputs = std::move(scan.inputs); inputs.insert(inputs.end(), extraInputs.begin(), extraInputs.end()); - if (auto a = add_unit(rcPath, lu.targetName + ".mcpp", - std::move(inputs), i); !a) + if (auto a = add_unit(rcSrc, rcSrc.stem().string(), + std::move(inputs), + static_cast(-1)); !a) return std::unexpected(a.error()); } - } - if (ctx.plan.resourceUnits.empty()) return {}; - - // Lazy + hard failure, exactly like nasm: a dropped resource - // surfaces as "where did my icon go", which is unattributable. - auto tool = rsrc::find_rc_tool(*state.tc, dialectId); - if (!tool) { - return std::unexpected(std::format( - "[resources] needs a Windows resource compiler for the " - "{} toolchain targeting {}, and none was found next to " - "{}.\n Expected {} in the toolchain's own bin directory " - "(mcpp does not search PATH for build tools).", - dialectId, trip.str(), state.tc->binaryPath.string(), - msvcStyle ? "rc.exe or llvm-rc" - : "-windres, windres or llvm-windres")); - } - ctx.plan.rcPath = tool->path; - ctx.plan.rcStyle = tool->style; - - // UTF-8 input, always. `[package]` metadata is user text and - // routinely non-ASCII; without this llvm-rc refuses the script - // outright ("Non-ASCII 8-bit codepoint can't be interpreted in - // the current codepage") rather than mangling it, so a project - // with a Chinese description could not build at all. - ctx.plan.rcFlags.push_back(msvcStyle ? "/C" : "--codepage=65001"); - if (msvcStyle) ctx.plan.rcFlags.push_back("65001"); - - // Include search: the project first, then whatever the toolchain - // puts on INCLUDE. llvm-rc preprocesses but does NOT read INCLUDE - // (rc.exe does), so the SDK dirs have to be spelled out for it — - // that is what makes `#include ` work, and it is the - // supported way to get VS_VERSION_INFO defined. - const std::string ip = msvcStyle ? "/I" : "-I"; - ctx.plan.rcFlags.push_back(ip + state.root->string()); - for (auto const& d : state.m->buildConfig.includeDirs) { - auto abs = d.is_absolute() ? d : (*state.root / d); - ctx.plan.rcFlags.push_back(ip + abs.string()); - } - if (msvcStyle && tool->name().find("llvm-rc") != std::string::npos) { - for (auto const& ev : state.tc->envOverrides) { - if (ev.key != "INCLUDE") continue; - // Shared splitter: `;` only. See rsrc::split_env_list — - // the drive colon is not a separator. - for (auto dir : rsrc::split_env_list(ev.value)) - ctx.plan.rcFlags.push_back(ip + std::string(dir)); + // The synthesised script: per image, because OriginalFilename and + // the version block belong to a specific artifact, and the + // manifest to a specific executable. + const bool synthVersion = R.declared() && R.synthesize_version_info(); + auto wantsUtf8 = [&](const mcpp::build::LinkUnit& lu) { + if (lu.kind != mcpp::build::LinkUnit::Binary) return false; + if (!lu.artifactOf.empty()) return false; + for (auto const& t : M.targets) + if (t.name == lu.targetName) + return t.is_program() && codePageOf(t) == "utf-8"; + return false; + }; + if (!iconAbs.empty() || synthVersion || anyUtf8Image) { + // A version key mcpp cannot order (an upstream build number) + // leaves FILEVERSION's four numeric fields at zero while the + // string fields keep the real text. Say so — the properties + // dialog will disagree with `[package].version` and nothing + // else would explain why. + if (synthVersion && !M.package.version.empty() + && !mcpp::version_req::parse_version(M.package.version)) { + mcpp::diag::degraded("resources/version", + std::format("[package].version = \"{}\" has no numeric " + "form", M.package.version), + "the embedded FILEVERSION / PRODUCTVERSION fields are " + "0,0,0,0 (the string fields keep the real version)", + "set [resources.version-info] explicitly, or use a " + "dotted numeric version"); + } + for (auto i : peUnits) { + const auto& lu = ctx.plan.linkUnits[i]; + const bool utf8 = wantsUtf8(lu); + if (iconAbs.empty() && !synthVersion && !utf8) continue; + std::filesystem::path manifestAbs; + if (utf8) { + manifestAbs = resDir / (lu.targetName + ".mcpp.manifest"); + const auto manifestText = rsrc::utf8_code_page_manifest(); + std::string had; + if (std::ifstream in(manifestAbs, std::ios::binary); in) + had.assign(std::istreambuf_iterator(in), {}); + if (had != manifestText) { + std::ofstream os(manifestAbs, std::ios::binary); + if (!os) return std::unexpected(std::format( + "cannot write the application manifest '{}'", + manifestAbs.string())); + os << manifestText; + } + } + // A script synthesised for the manifest alone carries + // nothing else: a package that declares no [resources] + // asked for no version resource. + mcpp::manifest::Resources forScript = R; + if (!synthVersion) forScript.versionInfo = false; + auto text = rsrc::synthesize_rc( + M.package, forScript, lu.output.filename().string(), + iconAbs, manifestAbs); + if (!text) return std::unexpected(text.error()); + // A stable path, so `cp` + `files = [...]` reproduces the + // same resource byte for byte (the L0→L1 escape hatch). + auto rcPath = resDir / (lu.targetName + ".mcpp.rc"); + // Write only on change: rewriting unconditionally would + // relink on every build. + std::string existing; + if (std::ifstream in(rcPath, std::ios::binary); in) + existing.assign(std::istreambuf_iterator(in), {}); + if (existing != *text) { + std::ofstream os(rcPath, std::ios::binary); + if (!os) return std::unexpected(std::format( + "cannot write generated resource script '{}'", + rcPath.string())); + os << *text; + } + std::vector inputs; + if (!iconAbs.empty()) inputs.push_back(iconAbs); + if (!manifestAbs.empty()) inputs.push_back(manifestAbs); + inputs.insert(inputs.end(), extraInputs.begin(), extraInputs.end()); + if (auto a = add_unit(rcPath, lu.targetName + ".mcpp", + std::move(inputs), i); !a) + return std::unexpected(a.error()); + } } - } - return {}; - }; - if (trip.is_pe()) - if (auto r = plan_resources(); !r) return std::unexpected(r.error()); + return {}; + }; + + if (trip.is_pe()) + if (auto r = plan_resources(); !r) return std::unexpected(r.error()); + } } - return {}; + + return step13_resource_compiler(state, ctx); } // The member path (relative to the workspace root) of a package root, when the diff --git a/src/build/prepare/scan.cpp b/src/build/prepare/scan.cpp index df30b308..4d845574 100644 --- a/src/build/prepare/scan.cpp +++ b/src/build/prepare/scan.cpp @@ -110,7 +110,7 @@ static std::expected step11_scan_sources(PrepareState& state) return std::unexpected(msg); } - return graph_or_targets_import_std(state.scan.graph, *state.m, *state.root); + return graph_or_targets_import_std(state.scan.graph, *state.m, *state.root, state.packages); } static std::expected diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index b6bdc7d0..7706704d 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -594,7 +594,8 @@ std::vector feature_closure(const mcpp::manifest::Manifest& pm, bool is_std_module(std::string_view name); bool graph_or_targets_import_std(const mcpp::modgraph::Graph& graph, const mcpp::manifest::Manifest& manifest, - const std::filesystem::path& projectRoot); + const std::filesystem::path& projectRoot, + const std::vector& packages); // toolchain_env.cpp: target rows, sysroots, the MSVC binding, build-program environments const mcpp::manifest::TargetEntry* diff --git a/src/build/resources.cppm b/src/build/resources.cppm index 9546eac5..4be4c20f 100644 --- a/src/build/resources.cppm +++ b/src/build/resources.cppm @@ -97,7 +97,10 @@ std::vector split_env_list(std::string_view value); struct ScanResult { // Quoted `#include`s and the data files named by resource statements, - // resolved against the .rc's directory. Angled includes are deliberately + // resolved as the resource compiler resolves them: against the .rc's + // directory, then the include directories it is given. A name found in + // none of them is recorded against the .rc's directory, where a file a + // build action produces is ordered before the compile. Angled includes are deliberately // absent: `` belongs to the toolchain, which is immutable for // the life of a build directory and already folded into the fingerprint. std::vector inputs; @@ -117,7 +120,8 @@ struct ScanResult { bool declaresManifest = false; }; -ScanResult scan_rc(const std::filesystem::path& rc); +ScanResult scan_rc(const std::filesystem::path& rc, + std::span includeDirs = {}); // ─── Synthesising the common case ───────────────────────────────────────── @@ -335,7 +339,8 @@ compile_utf8_manifest(const mcpp::toolchain::Toolchain& tc, return out; } -ScanResult scan_rc(const std::filesystem::path& rc) { +ScanResult scan_rc(const std::filesystem::path& rc, + std::span includeDirs) { ScanResult out; std::ifstream is(rc, std::ios::binary); if (!is) return out; @@ -361,6 +366,10 @@ ScanResult scan_rc(const std::filesystem::path& rc) { auto add_input = [&](std::string_view raw) { std::filesystem::path p{std::string(raw)}; auto abs = p.is_absolute() ? p : dir / p; + std::error_code ec; + if (!p.is_absolute() && !std::filesystem::exists(abs, ec)) + for (auto const& d : includeDirs) + if (std::filesystem::exists(d / p, ec)) { abs = d / p; break; } if (std::find(out.inputs.begin(), out.inputs.end(), abs) == out.inputs.end()) out.inputs.push_back(std::move(abs)); }; diff --git a/src/project.cppm b/src/project.cppm index 374bab98..26181362 100644 --- a/src/project.cppm +++ b/src/project.cppm @@ -687,6 +687,13 @@ load_member_manifest(const mcpp::manifest::Manifest& workspace, auto mm = mcpp::manifest::load(dir / "mcpp.toml", {.insideWorkspace = true}); if (!mm) return std::unexpected(std::format( "workspace member '{}': {}", memberPath, mm.error().format())); + // A relative `[indices].path` the member declares was written in the + // member's directory. The plan's root is the workspace root, so the path + // is anchored here, and two members that write the same relative path + // for different trees have different root-position keys. + for (auto& [_, idx] : mm->indices) + if (idx.is_local() && idx.path.is_relative()) + idx.path = (dir / idx.path).lexically_normal(); inherit_workspace_config(*mm, workspace, wsRoot); if (auto bad = workspace_inheritance_error(*mm, dir)) return std::unexpected(*bad); return std::move(*mm); @@ -694,7 +701,8 @@ load_member_manifest(const mcpp::manifest::Manifest& workspace, // The values of a member's manifest that are one value per plan: the // toolchain request and the target rows, the C++ standard, the graph-wide -// `[build]` keys, the profiles and the indices. Members whose keys are equal +// `[build]` keys (among them `linkage`, which chooses the C runtime every +// object is compiled against), the profiles and the indices. Members whose keys are equal // are planned together; the key is a canonical string of those values and // nothing else, so a member's own flags, sources and dependencies never // separate it from another member. @@ -727,6 +735,7 @@ export std::string root_position_key(const mcpp::manifest::Manifest& m) { field("cxx_runtime_tests", b.cxxRuntimeTests); field("cxx_runtime_shared", b.cxxRuntimeShared); field("static_stdlib", b.staticStdlib ? "1" : "0"); + field("linkage", b.linkage); field("dependency_linkage", b.dependencyLinkage); field("target", b.target); field("macos_deployment_target", b.macosDeploymentTarget); @@ -811,6 +820,7 @@ virtual_workspace_root(const mcpp::manifest::Manifest& workspace, b.macosDeploymentTarget = f.macosDeploymentTarget; b.iosDeploymentTarget = f.iosDeploymentTarget; b.defaultProfile = f.defaultProfile; + b.linkage = f.linkage; b.dependencyLinkage = f.dependencyLinkage; b.cacheMode = f.cacheMode; b.platformDependencies = f.platformDependencies; diff --git a/tests/e2e/120_ws_root_indices.sh b/tests/e2e/120_ws_root_indices.sh index 8e727478..d1d909ab 100755 --- a/tests/e2e/120_ws_root_indices.sh +++ b/tests/e2e/120_ws_root_indices.sh @@ -11,6 +11,11 @@ # — previously `merge_workspace_deps` only propagated `version`, so a # path-form workspace dependency was silently dropped and failed to # resolve. +# 3. A member that declares its own `[indices]` with a relative path wrote +# the path in ITS directory. The plan's root is the workspace root, so +# the member's path is anchored at the member (2026.9.29.1 resolved it +# against the workspace root and found no index). The two members name +# one tree, so they are one configuration. set -e TMP=$(mktemp -d) @@ -71,7 +76,7 @@ EOF # ── Workspace root: virtual, one member, no re-declaration required ───── cat > mcpp.toml < member-b/mcpp.toml <<'EOF' +[indices] +x = { path = "../local-index" } + +[package] +name = "member-b" +version = "0.1.0" + +[dependencies.x] +widget2 = "1.0.0" +EOF +cat > member-b/src/main.cpp <<'EOF' +import widget2; + +int main() { + return widget2_value() == 35 ? 0 : 1; +} +EOF + "$MCPP" build -p member-a > build.log 2>&1 || { cat build.log echo "FAIL: member did not resolve root-anchored [indices]/[workspace.dependencies] path" @@ -131,6 +156,28 @@ EOF exit 1 } +"$MCPP" build -p member-b > build-b.log 2>&1 || { + cat build-b.log + echo "FAIL: a member's own relative [indices].path is not anchored at the member" + exit 1 +} +"$MCPP" run -p member-b > run-b.log 2>&1 || { + cat run-b.log + echo "FAIL: member-b's program failed" + exit 1 +} +"$MCPP" build --workspace > build-ws.log 2>&1 || { + cat build-ws.log + echo "FAIL: --workspace" + exit 1 +} +n=$(find target -name build.ninja | wc -l) +[[ "$n" == 1 ]] || { + find target -name build.ninja + echo "FAIL: members naming one index tree are $n configurations, expected 1" + exit 1 +} + grep -q '\[package\."x.widget2"\]' mcpp.lock || { cat mcpp.lock 2>/dev/null || true echo "FAIL: expected x.widget2 lock entry" diff --git a/tests/e2e/836_a_members_own_statements_reach_its_build.sh b/tests/e2e/836_a_members_own_statements_reach_its_build.sh new file mode 100644 index 00000000..d27a72af --- /dev/null +++ b/tests/e2e/836_a_members_own_statements_reach_its_build.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# 836_a_members_own_statements_reach_its_build.sh -- workspace design +# 2026-09-29 §15 and §17. +# +# A workspace plan's root is a virtual root that holds the plan's values. What +# a member states about itself is read from the member, never from that root: +# +# M1 a member whose only sources are its tests, and whose tests `import +# std`, is tested with the std module built (2026.9.29.1 read the entry +# files of the root's targets only, and the virtual root has none); +# M2 a capability under the reserved `mcpp:` prefix that no layer names is +# refused in a member's manifest as in a root's; +# M3 a cfg() predicate a member writes and mcpp cannot evaluate is +# reported as in a root's manifest. +# +# A member's own relative [indices].path is e2e 120; a member's [resources] +# is e2e 837. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" + +# ── M1 ────────────────────────────────────────────────────────────────────── +mkdir -p ws1/probe/tests +cat > ws1/mcpp.toml <<'EOF' +[workspace] +members = ["probe"] +EOF +cat > ws1/probe/mcpp.toml <<'EOF' +[package] +name = "probe-tests" +version = "0.1.0" +EOF +cat > ws1/probe/tests/hello.cpp <<'EOF' +import std; +int main() { + std::println("probe"); + return 0; +} +EOF +(cd ws1 && "$MCPP" test -p probe > ../m1.log 2>&1) \ + || fail "M1 a member whose tests import std is not tested" m1.log +(cd ws1/probe && "$MCPP" test > ../../m1b.log 2>&1) \ + || fail "M1 the same member, tested from its directory" m1b.log + +# ── M2 ────────────────────────────────────────────────────────────────────── +mkdir -p ws2/a/src +cat > ws2/mcpp.toml <<'EOF' +[workspace] +members = ["a"] +EOF +cat > ws2/a/mcpp.toml <<'EOF' +[package] +name = "a" +version = "0.1.0" +provides = ["mcpp:no-such-layer"] +EOF +printf 'int main() { return 0; }\n' > ws2/a/src/main.cpp +if (cd ws2 && "$MCPP" build --workspace > ../m2.log 2>&1); then + fail "M2 a member's unknown mcpp: capability was not refused" m2.log +fi +grep -q 'a/mcpp.toml' m2.log && grep -q 'mcpp:no-such-layer' m2.log \ + || fail "M2 the refusal names the member's manifest and the capability" m2.log + +# ── M3 ────────────────────────────────────────────────────────────────────── +mkdir -p ws3/b/src +cat > ws3/mcpp.toml <<'EOF' +[workspace] +members = ["b"] +EOF +cat > ws3/b/mcpp.toml <<'EOF' +[package] +name = "b" +version = "0.1.0" + +[target.'cfg(no_such_key = "x")'.build] +cxxflags = ["-DNEVER=1"] +EOF +printf 'int main() { return 0; }\n' > ws3/b/src/main.cpp +(cd ws3 && "$MCPP" build --workspace > ../m3.log 2>&1) || fail "M3 build" m3.log +grep -q "no_such_key" m3.log || fail "M3 a member's unknown cfg() key is not reported" m3.log + +echo "PASS: 836_a_members_own_statements_reach_its_build" diff --git a/tests/e2e/837_a_members_resources_reach_its_image.sh b/tests/e2e/837_a_members_resources_reach_its_image.sh new file mode 100644 index 00000000..f96a07b8 --- /dev/null +++ b/tests/e2e/837_a_members_resources_reach_its_image.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# requires: mingw-cross +# 837_a_members_resources_reach_its_image.sh -- workspace design 2026-09-29 +# §15 and §17. +# +# In a workspace plan each selected member's [resources] is compiled against +# the member's directory and embedded into the member's images only +# (2026.9.29.1 read the virtual root's [resources], which is empty, so no +# member's icon, version resource or script reached its program). +# +# R1 each member's program carries its own icon and not the other's; +# R2 a member's synthesised version resource is its own [package]'s; +# R3 a member's own script is compiled with the member's include_dirs. +# +# Linux to Windows through the MinGW cross toolchain, as e2e 198. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" +export MCPP_HOME="${MCPP_HOME:-$HOME/.mcpp}" + +hexof() { od -An -v -tx1 "$1" | tr -d ' \n'; } +# A 4x1 32bpp icon whose sixteen pixel bytes are findable in the linked image +# (the layout of _windows_resources_body.sh). +write_icon() { # $1 = file, $2 = 4 BGRA pixels as printf escapes + printf '\x00\x00\x01\x00\x01\x00\x04\x01\x00\x00\x01\x00\x20\x00\x3c\x00\x00\x00\x16\x00\x00\x00\x28\x00\x00\x00\x04\x00\x00\x00\x02\x00\x00\x00\x01\x00\x20\x00\x00\x00\x00\x00\x14\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'"$2"'\x00\x00\x00\x00' > "$1" +} +ICON_CLI='\xd3\x1c\x7a\x45\x92\xe6\x0b\xa8\x41\xf7\x2d\x63\xbe\x50\x84\x19' +ICON_GUI='\x6c\xa2\x38\xd7\xe1\x4b\x95\x0f\x77\xc4\x1a\x8e\x2b\xf3\x60\xd5' +ICON_CLI_HEX='d31c7a4592e60ba841f72d63be508419' +ICON_GUI_HEX='6ca238d7e14b950f77c41a8e2bf360d5' + +cat > mcpp.toml <<'EOF' +[workspace] +members = ["cli", "gui"] +EOF + +mkdir -p cli/src cli/assets +write_icon cli/assets/app.ico "$ICON_CLI" +printf 'int main() { return 0; }\n' > cli/src/main.cpp +cat > cli/mcpp.toml <<'EOF' +[package] +name = "cli" +version = "1.2.3" +authors = ["Cli Corp"] + +[resources] +icon = "assets/app.ico" + +[targets.cli] +kind = "bin" +main = "src/main.cpp" +EOF + +# gui's script finds its identifiers through the member's include_dirs, and +# its icon beside the script. +mkdir -p gui/src gui/include gui/assets +write_icon gui/assets/app.ico "$ICON_GUI" +printf 'int main() { return 0; }\n' > gui/src/main.cpp +printf '#define GUI_ICON 101\n' > gui/include/gui_ids.h +cat > gui/gui.rc <<'EOF' +#include "gui_ids.h" +GUI_ICON ICON "assets/app.ico" +EOF +cat > gui/mcpp.toml <<'EOF' +[package] +name = "gui" +version = "4.5.6" + +[build] +include_dirs = ["include"] + +[resources] +files = ["gui.rc"] +extra-inputs = ["assets/app.ico"] + +[targets.gui] +kind = "bin" +main = "src/main.cpp" +EOF + +"$MCPP" build --workspace --target x86_64-windows-gnu > b.log 2>&1 || fail "workspace build" b.log + +CLI_EXE=$(find target -path '*/bin/cli/cli.exe' | head -1) +GUI_EXE=$(find target -path '*/bin/gui/gui.exe' | head -1) +[ -f "$CLI_EXE" ] && [ -f "$GUI_EXE" ] || { find target -name '*.exe'; fail "the members' programs" b.log; } + +# R1 +hexof "$CLI_EXE" | grep -q "$ICON_CLI_HEX" || fail "R1 cli.exe does not carry cli's icon" b.log +hexof "$CLI_EXE" | grep -q "$ICON_GUI_HEX" && fail "R1 cli.exe carries gui's icon" b.log +hexof "$GUI_EXE" | grep -q "$ICON_GUI_HEX" || fail "R1 gui.exe does not carry gui's icon" b.log +hexof "$GUI_EXE" | grep -q "$ICON_CLI_HEX" && fail "R1 gui.exe carries cli's icon" b.log + +# R2 +GEN_RC=$(find target -path '*/res/cli/cli.mcpp.rc' | head -1) +[ -f "$GEN_RC" ] || { find target -path '*/res/*'; fail "R2 no synthesised script for cli under res/cli/" b.log; } +grep -q 'FILEVERSION 1,2,3,0' "$GEN_RC" || fail "R2 cli's FILEVERSION is not cli's [package].version" "$GEN_RC" +grep -q '"CompanyName", "Cli Corp"' "$GEN_RC" || fail "R2 cli's CompanyName" "$GEN_RC" + +# R3: gui's script compiled (the build succeeded), against gui's include_dirs. +NINJA=$(dirname "$(dirname "$(dirname "$CLI_EXE")")")/build.ninja +grep -A2 'rc_object .*gui\.rc' "$NINJA" | grep -q 'gui/include' \ + || fail "R3 gui.rc is not compiled with gui's include_dirs" "$NINJA" + +echo "PASS: 837_a_members_resources_reach_its_image" diff --git a/tests/unit/test_build_resources.cpp b/tests/unit/test_build_resources.cpp index c4dd7895..312b9f80 100644 --- a/tests/unit/test_build_resources.cpp +++ b/tests/unit/test_build_resources.cpp @@ -181,6 +181,33 @@ END EXPECT_EQ(s.inputs.size(), 4u); } +// A quoted include and a resource file are found where the resource compiler +// finds them: beside the script, then in the include directories it is given. +// A name found nowhere stays beside the script, where a file a build action +// produces is ordered before the compile. +TEST(BuildResources, InputsAreResolvedThroughTheIncludeDirectories) { + TempDir d; + fs::create_directories(d.path / "include"); + d.write("include/ids.h", "#define APP_ICON 101\n"); + d.write("include/app.ico", "icon"); + d.write("local.ico", "icon"); + auto rc = d.write("app.rc", R"(#include "ids.h" +1 ICON "app.ico" +2 ICON "local.ico" +3 ICON "generated.ico" +)"); + const std::vector includes = {d.path / "include"}; + auto s = res::scan_rc(rc, includes); + auto at = [&](std::string_view leaf) { + for (auto const& p : s.inputs) if (p.filename() == leaf) return p; + return fs::path{}; + }; + EXPECT_EQ(at("ids.h"), d.path / "include" / "ids.h"); + EXPECT_EQ(at("app.ico"), d.path / "include" / "app.ico"); + EXPECT_EQ(at("local.ico"), d.path / "local.ico"); + EXPECT_EQ(at("generated.ico"), d.path / "generated.ico"); +} + // The numbers of a VERSIONINFO block contain 24 as well; only the TYPE // position of a statement declares a manifest. TEST(BuildResources, ATwentyFourOutsideTheTypePositionIsNotAManifest) { diff --git a/tests/unit/test_workspace_plan.cpp b/tests/unit/test_workspace_plan.cpp index d6e78f7c..ddb24216 100644 --- a/tests/unit/test_workspace_plan.cpp +++ b/tests/unit/test_workspace_plan.cpp @@ -55,6 +55,10 @@ TEST(WorkspacePlan, RootPositionValuesSeparateMembers) { EXPECT_NE(mcpp::project::root_position_key(m), ref); } { auto m = member("a"); m.buildConfig.cxxRuntime = "static"; EXPECT_NE(mcpp::project::root_position_key(m), ref); } + // `linkage` chooses the C runtime every object of the plan is compiled + // against, so it is the plan's and not the member's. + { auto m = member("a"); m.buildConfig.linkage = "static"; + EXPECT_NE(mcpp::project::root_position_key(m), ref); } } // The virtual root carries the plan's values and nothing a package owns. @@ -101,3 +105,34 @@ TEST(WorkspacePlan, ProductDirectoriesAreNamedByPackage) { EXPECT_EQ(mcpp::project::product_directory_name(all, "ns2/common"), "ns2.common"); EXPECT_EQ(mcpp::project::product_directory_name(all, "."), ""); } + +// A relative `[indices].path` a member declares was written in the member's +// directory, and one the workspace declares in the workspace's: both are +// anchored where they were written, so two members naming one tree share a +// configuration and a member's path never resolves against the plan's root. +TEST(WorkspacePlan, IndexPathsAreAnchoredWhereTheyWereWritten) { + namespace fs = std::filesystem; + const auto ws = fs::temp_directory_path() + / std::format("mcpp-ws-index-{}", std::random_device{}()); + fs::create_directories(ws / "a"); + fs::create_directories(ws / "b"); + auto write = [](const fs::path& p, std::string_view text) { + std::ofstream(p) << text; + }; + write(ws / "mcpp.toml", + "[workspace]\nmembers = [\"a\", \"b\"]\n\n[indices]\nx = { path = \"idx\" }\n"); + write(ws / "a" / "mcpp.toml", "[package]\nname = \"a\"\nversion = \"0.1.0\"\n"); + write(ws / "b" / "mcpp.toml", + "[indices]\nx = { path = \"../idx\" }\n\n[package]\nname = \"b\"\nversion = \"0.1.0\"\n"); + auto root = mcpp::manifest::load(ws / "mcpp.toml"); + ASSERT_TRUE(root.has_value()); + auto a = mcpp::project::load_member_manifest(*root, ws, "a"); + auto b = mcpp::project::load_member_manifest(*root, ws, "b"); + ASSERT_TRUE(a.has_value()) << a.error(); + ASSERT_TRUE(b.has_value()) << b.error(); + EXPECT_EQ(a->indices.at("x").path, (ws / "idx").lexically_normal()); + EXPECT_EQ(b->indices.at("x").path, (ws / "idx").lexically_normal()); + EXPECT_EQ(mcpp::project::root_position_key(*a), mcpp::project::root_position_key(*b)); + std::error_code ec; + fs::remove_all(ws, ec); +} From 34920cb9568d73d088c38ef1da6b8bdd6b5ea139 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:02:20 +0800 Subject: [PATCH 2/4] A script's includes are resolved against the directories its resource compiler searches, the package directory first --- src/build/prepare/plan.cpp | 13 +++++++------ src/project.cppm | 8 ++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index 8c54570f..3df8cc46 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -1528,6 +1528,12 @@ static std::expected step13_windows_resources(PrepareState& s const auto resRel = S.owner.empty() ? std::filesystem::path("res") : std::filesystem::path("res") / S.owner; const auto resDir = ctx.plan.outputDir / resRel; + // Where the resource compiler looks for a script's includes and + // files: the package's directory, then its include_dirs. The + // scan resolves them the same way. + std::vector rcIncludes{S.dir}; + for (auto const& d : M.buildConfig.includeDirs) + rcIncludes.push_back(d.is_absolute() ? d : (S.dir / d)); std::error_code mkEc; std::filesystem::create_directories(resDir, mkEc); @@ -1591,9 +1597,7 @@ static std::expected step13_windows_resources(PrepareState& s mcpp::build::ResourceUnit ru; ru.source = src; ru.output = resRel / (std::string(stem) + std::string(outExt)); - ru.includeDirs.push_back(S.dir); - for (auto const& d : M.buildConfig.includeDirs) - ru.includeDirs.push_back(d.is_absolute() ? d : (S.dir / d)); + ru.includeDirs = rcIncludes; ru.implicitInputs = std::move(inputs); ctx.plan.resourceUnits.push_back(std::move(ru)); const auto& out = ctx.plan.resourceUnits.back().output; @@ -1607,9 +1611,6 @@ static std::expected step13_windows_resources(PrepareState& s // Author-written scripts: compiled once, linked into every image. for (auto const& rcSrc : scriptFiles) { - std::vector rcIncludes; - for (auto const& d : M.buildConfig.includeDirs) - rcIncludes.push_back(d.is_absolute() ? d : (S.dir / d)); auto scan = rsrc::scan_rc(rcSrc, rcIncludes); if (scan.versionInfoNamedByString) { // The mcpp#365 silent failure, caught on the way in. A diff --git a/src/project.cppm b/src/project.cppm index 26181362..643fbdba 100644 --- a/src/project.cppm +++ b/src/project.cppm @@ -702,10 +702,10 @@ load_member_manifest(const mcpp::manifest::Manifest& workspace, // The values of a member's manifest that are one value per plan: the // toolchain request and the target rows, the C++ standard, the graph-wide // `[build]` keys (among them `linkage`, which chooses the C runtime every -// object is compiled against), the profiles and the indices. Members whose keys are equal -// are planned together; the key is a canonical string of those values and -// nothing else, so a member's own flags, sources and dependencies never -// separate it from another member. +// object is compiled against), the profiles and the indices. Members whose +// keys are equal are planned together; the key is a canonical string of those +// values and nothing else, so a member's own flags, sources and dependencies +// never separate it from another member. export std::string root_position_key(const mcpp::manifest::Manifest& m) { std::string s; auto field = [&](std::string_view name, std::string_view value) { From f6a50ce07a715f00a094bfc9828219ca6d443fe1 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:06:54 +0800 Subject: [PATCH 3/4] A member's product directory holds every graph-built shared library of its closure --- ...2026-09-29-workspace-build-graph-design.md | 6 ++++ CHANGELOG.md | 9 ++++-- src/build/plan.cppm | 24 +++++++++++++++ ...35_a_shared_library_is_placed_by_a_link.sh | 29 ++++++++++++++++--- 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/.agents/docs/2026-09-29-workspace-build-graph-design.md b/.agents/docs/2026-09-29-workspace-build-graph-design.md index d4f6f784..d84af43c 100644 --- a/.agents/docs/2026-09-29-workspace-build-graph-design.md +++ b/.agents/docs/2026-09-29-workspace-build-graph-design.md @@ -557,6 +557,7 @@ read from the member, or made a value of the plan: | a member's `[resources]`, `windows_code_page` | the virtual root's (empty) | per member: its directory and include directories, `res//`, its images only | e2e 837 | | `[build] linkage` | not copied to the virtual root | a root-position value | unit `RootPositionValuesSeparateMembers` | | unknown `mcpp:` capability, cfg() and schema warnings | not reported for a member | refused and reported for each selected member | e2e 836 M2, M3 | +| the shared libraries of a member's closure | only those the member's units link were placed | every graph-built shared library of the closure, with its aliases, as §15 states | e2e 835 L3 | Each criterion fails on 2026.9.29.1 and passes on 2026.9.29.2. The resource case also showed a defect of every build: a quoted `#include` in a script was @@ -565,5 +566,10 @@ include directories, so a script that relied on them could not be built. The scan now resolves as the compiler does (unit `InputsAreResolvedThroughTheIncludeDirectories`). +The last row is not a read of the root's manifest: a root's program sits in +`bin/` beside every graph-built library, so no build before the workspace plan +needed the transitive set named. The index member `wayland` (libffi under +libwayland-client) showed it. + The rule the inventory missed is general: a virtual root answers for the plan, and a read of the root's manifest is correct only for a value of the plan. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b51c1d7..24faa9fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,9 @@ This release corrects what a workspace plan reads from its members. The plan's root is a virtual root that holds the values shared by the whole graph; five statements that a member makes about itself were read from that root in -2026.9.29.1 and were therefore empty. The mcpp-index sweep of 2026.9.29.1 found -the first two. +2026.9.29.1 and were therefore empty, and a member's product directory lacked +the shared libraries that its libraries need. The mcpp-index sweep of +2026.9.29.1 found the first two. ### Fixed @@ -28,6 +29,10 @@ the first two. member's programs and shared libraries only (e2e 837). A quoted `#include` or resource file in a script is now found through the include directories as the resource compiler finds it, in every build. +- **A member's product directory holds every graph-built shared library of its + closure.** Only the libraries a member's own units link were placed, so a + library that another library needs (libffi under libwayland-client) was + missing and the program did not start (e2e 835 L3). - **`[build] linkage` is a value of the plan.** It chooses the C runtime that every object is compiled against, so members that differ in it are separate configurations, and the plan takes it from its members. diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 16e98baa..dc6a3642 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -3018,6 +3018,30 @@ make_plan(const mcpp::manifest::Manifest& manifest, } units.push_back(std::move(lu)); } + // Every graph-built shared library of the closure is placed beside + // the member's images, not only those its units link: a library that + // another shared library needs (libffi under libwayland-client) is + // found there, as it is in `bin/` beside a root's program. + const bool loadsLibraries = std::ranges::any_of(units, [](const LinkUnit& u) { + return u.kind != LinkUnit::StaticLibrary; + }); + for (auto i : closureIdx) { + if (!loadsLibraries) break; + auto it = sharedTargetsByPackage.find(i); + if (it == sharedTargetsByPackage.end()) continue; + for (auto t : it->second) { + auto const& dep = sharedDepTargets[t]; + std::vector files{dep.output}; + for (auto const& alias : runtime_aliases_for_target(dep.target, naming)) + files.push_back(alias); + for (auto const& f : files) { + if (f.parent_path() == productDir) continue; + const bool seen = std::ranges::any_of(group.placements, + [&](auto const& pl) { return pl.source == f; }); + if (!seen) group.placements.push_back({f, place(f)}); + } + } + } // A program the closure ships through `artifacts` (mcpp#711) is linked // once, at `bin/`, and placed beside the member's programs, where a // program that launches it looks for it. diff --git a/tests/e2e/835_a_shared_library_is_placed_by_a_link.sh b/tests/e2e/835_a_shared_library_is_placed_by_a_link.sh index 5d2a5f80..cec531ca 100755 --- a/tests/e2e/835_a_shared_library_is_placed_by_a_link.sh +++ b/tests/e2e/835_a_shared_library_is_placed_by_a_link.sh @@ -10,7 +10,10 @@ # # L1 each program runs from its product directory; # L2 the library in the two product directories is one file (one inode), -# or, where links are not possible, two copies with equal content. +# or, where links are not possible, two copies with equal content; +# L3 a shared library that another shared library of the closure needs is +# placed too (2026.9.29.1 placed only the libraries a member's own units +# link, so a program whose library needs libffi did not start). set -e TMP=$(mktemp -d) @@ -21,18 +24,30 @@ MCPP="${MCPP:-mcpp}" cat > mcpp.toml <<'EOF' [workspace] -members = ["shlib", "p1", "p2"] +members = ["base", "shlib", "p1", "p2"] EOF -mkdir -p shlib/src p1/src p2/src +mkdir -p base/src shlib/src p1/src p2/src +cat > base/mcpp.toml <<'EOF' +[package] +name = "base" +version = "0.1.0" + +[targets.base] +kind = "shared" +EOF +printf 'export module basis;\nexport int base_v() { return 6; }\n' > base/src/base.cppm cat > shlib/mcpp.toml <<'EOF' [package] name = "shlib" version = "0.1.0" +[dependencies] +base = { path = "../base" } + [targets.shlib] kind = "shared" EOF -printf 'export module shlib;\nexport int shared_v() { return 7; }\n' > shlib/src/shlib.cppm +printf 'export module shlib;\nimport basis;\nexport int shared_v() { return base_v() + 1; }\n' > shlib/src/shlib.cppm for p in p1 p2; do cat > $p/mcpp.toml < Date: Tue, 29 Sep 2026 09:27:55 +0800 Subject: [PATCH 4/4] e2e 836: the refusal's path is read with either separator --- tests/e2e/836_a_members_own_statements_reach_its_build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/836_a_members_own_statements_reach_its_build.sh b/tests/e2e/836_a_members_own_statements_reach_its_build.sh index d27a72af..b094daa6 100644 --- a/tests/e2e/836_a_members_own_statements_reach_its_build.sh +++ b/tests/e2e/836_a_members_own_statements_reach_its_build.sh @@ -62,7 +62,7 @@ printf 'int main() { return 0; }\n' > ws2/a/src/main.cpp if (cd ws2 && "$MCPP" build --workspace > ../m2.log 2>&1); then fail "M2 a member's unknown mcpp: capability was not refused" m2.log fi -grep -q 'a/mcpp.toml' m2.log && grep -q 'mcpp:no-such-layer' m2.log \ +grep -qE 'ws2[/\\]a[/\\]mcpp\.toml' m2.log && grep -q 'mcpp:no-such-layer' m2.log \ || fail "M2 the refusal names the member's manifest and the capability" m2.log # ── M3 ──────────────────────────────────────────────────────────────────────