diff --git a/.agents/docs/2026-09-12-a-verified-web-run-that-asked-the-host-for-node.md b/.agents/docs/2026-09-12-a-verified-web-run-that-asked-the-host-for-node.md index 636aa55dd..5cb0a7b41 100644 --- a/.agents/docs/2026-09-12-a-verified-web-run-that-asked-the-host-for-node.md +++ b/.agents/docs/2026-09-12-a-verified-web-run-that-asked-the-host-for-node.md @@ -5,7 +5,7 @@ status: landed # A verified Web run that asked the host for node -**Status:** implemented in mcpp 2026.9.12.1 and openxlings/xim-pkgindex#823. +**Status:** implemented in mcpp 2026.9.12.2 (2026.9.12.1 was not published) and openxlings/xim-pkgindex#823. ## What was measured diff --git a/.agents/docs/2026-09-12-engine-gaps-after-the-sdk-batch.md b/.agents/docs/2026-09-12-engine-gaps-after-the-sdk-batch.md new file mode 100644 index 000000000..b9ba5d8b6 --- /dev/null +++ b/.agents/docs/2026-09-12-engine-gaps-after-the-sdk-batch.md @@ -0,0 +1,605 @@ +--- +subject: triage +status: landed +--- + +# The engine gaps left open after the SDK batch + +**Status:** landed in mcpp 2026.9.12.2. The four questions in the first draft +were answered in review (§9), and three statements were measured and corrected +before implementation (§2, §5.2, §7). The corrections made during +implementation are recorded in §11. The ecosystem changes E1 and E2 follow the +release (§10). + +## 0. Scope, and the ledger it starts from + +The scope is issues #564 to #618 in mcpp-community/mcpp, the gaps the +2026-09-11 record left open, and one defect found while measuring them. Each +issue was checked against the code on `main` at `c688fcab`, not against the PR +that claims to fix it. + +| item | before | finding on `main` | action | +|---|---|---|---| +| #564 `default_jobs` unread | open | `prepare.cppm` reads `defaultJobs` into `globalDefaultJobs`; `default_backend` is removed | closed, citing #607 | +| #597 WebAssembly target | open | `ObjectFormat::Wasm`; the `wasm32-emscripten` row is `verified` | closed, citing #605, #610, #617 | +| #599 bench hub path | open | the hub is the pinned tree's own path; the uninitialised branch fails under CI | closed, citing #607 | +| #603 clang on Windows, level 23 | open | the clang path calls `std_module_min_level_for_stl` | closed, citing #607 | +| #604 MSVC `/reference` pair | open | flags are appended verbatim; `orphaned_reference` refuses early | closed, citing #607 | +| #606 scanner inside comments | open | one three-state pass; `tests/e2e/639` | closed, citing #607 | +| #609 MSVC STL 14.51 `_Find_vectorized` | open | upstream microsoft/STL#6294; nothing in mcpp is wrong | §6, then closed | +| #611 personal notes | open | a to-do list spanning three repositories | left open; its one engine item is #613 | +| #613 install hooks and the standard library | open | the refusal already exists; the hook environment does not | §2 | +| #614 `XLINGS_PROJECT_DIR` asymmetry | open | unfixed | §3 | +| #615 runtime files that are not DLLs | open | unfixed | §4 | +| #618 Windows GUI subsystem | open | unfixed | §1 | +| G1 no per-target tool declaration | recorded 2026-09-11 | **misdiagnosed**: the declaration exists | §5.1 | +| G2 no whole-graph channel for `-pthread` | recorded 2026-09-11 | **misdiagnosed in part**: the channel exists; scoping and a requirement do not | §5.2 | +| T1 `--toolchain` replayed by the fast path | found 2026-09-12 | a defect | §7 | +| openkal-musl never built for Darwin | recorded 2026-09-11 | package-side | not engine; belongs to openkal-musl | +| no device runner for `aarch64-ios` | recorded 2026-09-11 | needs a developer signature | not engine; out of scope | + +## 1. #618 — a Windows GUI executable, declared on its target + +### 1.1 Are `ldflags` equivalent to a field? + +No. Four independent reasons, any one of which would be enough. + +1. **The spelling belongs to one linker dialect, and a PE target has two.** The + flags in #365's report, `-Wl,-subsystem:windows -Wl,-entry:mainCRTStartup`, + are link.exe and lld-link syntax passed through a clang driver. The same + program built for `x86_64-windows-gnu` links with GNU ld or lld in MinGW + mode, which take `--subsystem windows`; GCC's own spelling is `-mwindows`. + The engine has already recorded a GNU linker rejecting a subsystem option + (`ld: unrecognized option '--subsystem'`, `prepare.cppm:9412`). One intent + therefore needs one `cfg` block per ABI, and a project that forgets one gets + a console on that ABI with no diagnostic. +2. **The pair is a pair on one CRT only.** On the MSVC CRT, + `/SUBSYSTEM:WINDOWS` changes the default entry to `WinMainCRTStartup`, so a + portable `int main()` fails with `LNK2019: unresolved external symbol + WinMain` unless `/ENTRY:mainCRTStartup` accompanies it; `/ENTRY:main` links + and skips CRT initialisation. mingw-w64's startup code is different, and + copying the MSVC entry override there is not correct by construction. The + correct flags are a function of the subsystem and the CRT, which a project + should not have to compute. +3. **The scope is wrong, and no flag-carrying key has the right scope.** + `[build] ldflags` and `[target..build] ldflags` land in the global + `$ldflags` of `build.ninja`, so every `mcpp test` binary becomes a GUI + program whose output no terminal shows, and they propagate to consumers + (docs/30: "`[build] ldflags` already propagates to consumers"). + `mcpp:link-flag` reaches consumers by design. `[targets.]` has no + link-side key. +4. **The engine cannot read a flag's meaning.** With a field, mcpp knows the + artefact is a GUI program: `mcpp run` can state that the program has no + console, `mcpp test` can refuse the key on a test target, and a packager can + treat the artefact as an application. + +A per-target `ldflags` key would fix reason 3 and none of the others. It is not +part of this change. + +### 1.2 The fields + +```toml +[targets.myapp] +kind = "bin" +main = "src/main.cpp" +windows_subsystem = "windows" # "console" (default) | "windows" +windows_entry = "main" # "main" (default) | "wmain" | "WinMain" | "wWinMain" +``` + +**Naming, as decided in review.** `windows_subsystem` names the one platform it +affects, so a reader on another platform can tell it is inert there. The value is +`"windows"`, the PE subsystem's own name and the value Rust's +`#![windows_subsystem]` and Meson's `win_subsystem` use, so a developer arriving +from either reads it without translation. One spelling, with no alias. + +**The entry point.** `windows_entry` names the function the program defines, not +the CRT symbol that calls it. It is independent of the subsystem, because a +console program may define `wmain`. + +| `windows_entry` | MSVC CRT startup | mingw-w64 | +|---|---|---| +| `main` | `mainCRTStartup` | default | +| `wmain` | `wmainCRTStartup` | `-municode` | +| `WinMain` | `WinMainCRTStartup` | default | +| `wWinMain` | `wWinMainCRTStartup` | `-municode` | + +### 1.3 Rendering + +| target | `windows_subsystem = "windows"` renders | decided by | +|---|---|---| +| PE, MSVC style: cl, clang-cl, clang targeting `*-windows-msvc` | `/SUBSYSTEM:WINDOWS` plus the entry's `/ENTRY:` symbol, spelled with `-Wl,` under a GNU-style driver | `pe_msvc_abi` (§11.1) | +| PE, GNU style: MinGW gcc, clang targeting `*-windows-gnu` | `-mwindows`, plus `-municode` for a wide entry | `pe_msvc_abi` (§11.1) | +| ELF, Mach-O, Wasm | nothing; no diagnostic; byte-identical artefact | `ObjectFormat` | + +`"console"` with `"main"` renders nothing on every target, because both are the +linker's defaults. On the MSVC ABI any other combination renders both flags +(§11.2). The ABI is answered by `pe_msvc_abi`, the predicate the import library +flag already uses, and the emitter spells the flag for the linker it invokes +(§11.1). + +### 1.4 Scope + +- Appended to `LinkUnit::linkFlags` of that target's `Binary` unit only + (`plan.cppm:103`, rendered per edge as `$unit_ldflags`). +- Refused, naming the target and the key, on library targets and on test + targets. +- It never reaches consumers or another target of the package. +- `kKnownTargetKeys` gains both keys. The warning that lists per-target keys is + generated from the same list, because the hand-written copy already omits + `exports`. +- A build program selects the subsystem for a target it names, through a + directive, so a framework's rule package can mark the application it knows + about. The directive names a target of the package being built and therefore + cannot leak into consumers. + +### 1.5 Criteria + +1. `windows_subsystem = "windows"` with `int main()` links on + `x86_64-windows-msvc` and on `x86_64-windows-gnu`. The PE optional header's + Subsystem field reads 2 (`IMAGE_SUBSYSTEM_WINDOWS_GUI`), read from the bytes. +2. In the same package, the `mcpp test` binaries and a second `bin` target read + 3 (`IMAGE_SUBSYSTEM_WINDOWS_CUI`). +3. A static constructor in the GUI target runs before `main` on both ABIs. +4. The same manifest on Linux produces no diagnostic and an artefact + byte-identical to one built without the keys. +5. The keys are refused on a library target and on a test target, each refusal + naming the target and the key; an unknown value is refused naming the + accepted values. +6. Rendering is unit-tested for every row of the tables in §1.2 and §1.3. + +### 1.6 What this does not do + +It does not produce an application bundle, embed an application manifest, or +choose DPI awareness. Those belong to packaging formats and to `[resources]`. + +## 2. #613 — an install hook cannot see the consumer's standard library + +### 2.1 What the code does + +Build programs receive the resolved toolchain as environment variables: +`MCPP_COMPILER`, `MCPP_CXX_STDLIB`, `MCPP_TARGET`, the `MCPP_TARGET_*` splits and +the `MCPP_TOOLCHAIN_*` paths (`build_program.cppm`, around line 540). The value +of `MCPP_CXX_STDLIB` is the toolchain's `stdlibId`: `libstdc++` for gcc, +`libc++` for clang, `msvc-stl` for clang targeting MSVC. Install hooks receive +none of these. `install_packages` runs as +`cd && env -u XLINGS_PROJECT_DIR XLINGS_HOME= xlings interface +install_packages …` (`xlings.cppm:1435`). + +### 2.2 The refusal already exists + +The first draft proposed a new `abi = { cxx_stdlib = … }` declaration. It is not +needed. The layer grammar already states the requirement, and the engine already +refuses it at resolution. Measured 2026-09-12 with a path dependency declaring +`requires = ["mcpp:c++-abi=libstdc++"]` and a project on `llvm@22.1.8`: + +``` +error: `stdreq@0.1.0` requires the c++-abi to be `libstdc++`. + c++-abi libc++ (payload) + required libstdc++ (required by stdreq@0.1.0) +``` + +With the default gcc toolchain the same project resolves +`c++-abi libstdc++ (payload)` and builds. + +### 2.3 What remains + +1. **Order.** The requirement must be refused before an index package's install + hook runs, not after a source build has already spent several minutes. This + is measured first; if the check follows provisioning, it moves ahead of it. +2. **The hook's environment.** The install command carries the subset build + programs already receive, under the same names and the same rule ("always + emitted, empty when not applicable"): `MCPP_COMPILER`, `MCPP_CXX_STDLIB`, + `MCPP_TARGET`, `MCPP_TARGET_OS`, `MCPP_TARGET_ARCH`, `MCPP_TARGET_ENV`. They + are empty while a toolchain payload itself installs. +3. **The rule for a hook.** A hook may use these values to refuse or to + diagnose. It must not build a variant into a store directory that does not + name the variant, because the store is keyed by package and version, and the + first consumer would otherwise decide the flavour for every later one. A + store keyed by variant is an xlings change and is out of scope. +4. **Documentation.** docs/22 and docs/06 state the recipe for a source-built + static package: declare `requires = ["mcpp:c++-abi="]`. + +### 2.4 Criteria + +- A requirement mismatch on an index package with an install hook is refused + before the hook runs; the refusal names the layer, both implementations and + the package. +- A hook that prints `MCPP_CXX_STDLIB` prints the resolved `stdlibId`, and an + empty value while a toolchain payload installs. The environment composition is + unit-tested on both platforms. + +## 3. #614 — two meanings of "global mode", and an error that stops at the boundary + +### 3.1 The asymmetry + +`build_command_prefix` (`xlings.cppm`, from line 1136) and the `self init` call +(around line 1590) express global mode as `env -u XLINGS_PROJECT_DIR` on POSIX +and as `env::set("XLINGS_PROJECT_DIR", "")` on Windows. Absent and empty are +different answers to "which scope is this", and xlings resolves its subos scope +from that variable. The Windows branch also mutates mcpp's own process +environment, so the value outlives the invocation that needed it. + +### 3.2 Fix + +- One function decides the environment of an xlings invocation: home, project + directory or its absence, PATH prefix, and the hook variables of §2.3. Each + platform renders that decision. The three copies of the decision become its + callers. +- On Windows the decision is applied through the scoped guard + `modules/platform/src/env.cppm` already provides, so global mode is unset and + the prior value is restored when the command returns. + +### 3.3 The diagnostic half + +mcpp prints `xlings reported: ` from the NDJSON error event +(`package_fetcher.cppm:396`). xlings' own `[xim]` error lines never reach the +user. When `install_packages` exits non-zero, mcpp appends xlings' error-level +lines to the diagnostic, bounded to the last 20, each prefixed so it reads as +xlings' words. + +### 3.4 Criteria + +- A unit test of the environment function: global mode produces "unset" on both + platforms, project mode produces the path, and the process environment is + unchanged afterwards. +- A failing install's `[xim]` error line appears in mcpp's error output. + +## 4. #615 — deploying runtime files that are not DLLs, into subdirectories + +### 4.1 What exists + +`runtime.deploy_files` is an explicit, platform-neutral list of strings, readable +from `[runtime]` in a manifest (`toml.cppm:2030`) and from a package's exports +(`xpkg.cppm:2080`). Each entry becomes `DeployFile{source, dest}` with +`dest = bin/` (`plan.cppm:1182-1197`). Two readers consume it: the +collision check (`flags.cppm:1267`) and the copy edges +(`ninja_backend.cppm:653`). + +### 4.2 Why a new key, not a new form of the old one + +An older mcpp reading `deploy_files` with a table entry does not report an error. +Its reader calls `read_string()`, which returns an empty string without +advancing when the next token is `{`, and the loop around it never terminates. +A published descriptor that extended `deploy_files` would hang every older +client that resolved it. The `runtime` table, by contrast, skips sub-keys it +does not know. The table form therefore takes a new sub-key, `deploy`. + +### 4.3 Contract + +```lua +runtime = { + deploy_files = { "bin/vulkan-1.dll" }, -- unchanged + deploy = { + { from = "lib/libMoltenVK.dylib", to = "." }, + { from = "share/vulkan/icd.d/MoltenVK_icd.json", to = "vulkan/icd.d" }, + }, +}, +``` + +- `from` is relative to the package root; `to` is a directory relative to the + executable's directory. Both obey the string rules the payload descriptor + applies to `frontend`: `/`-separated, not absolute, no drive, no `.` or `..` + component, except that `to = "."` names the executable's directory itself. + Anything else is refused by name. +- `dest` becomes `bin//`. Both readers key on the full relative + destination, so two files with the same name in different directories do not + collide, while two sources for one destination still do. +- Honoured on every object format. DLL discovery through `runtime_search_dirs` + stays DLL-only, a PE loader rule. +- The manifest's `[runtime] deploy` accepts the same table. +- `mcpp pack` carries the files at the same relative paths. + +### 4.4 Criteria + +- A dependency deploying a file to `.` and another to a nested directory: after + `mcpp build`, both exist at `bin/` and `bin//`; after `mcpp test`, the test + binaries see the same layout. +- A plan built from `deploy_files` strings only is byte-identical to today's. +- `to = "../x"`, `to = "/x"`, a backslash, and a missing `from` are each + refused, naming the package and the entry. +- An older mcpp resolves a descriptor carrying `runtime.deploy` without error, + which is the reason for the new key. + +## 5. The two gaps recorded by the SDK batch + +### 5.1 G1 was misdiagnosed: the per-target tool declaration exists + +The 2026-09-11 record and `examples/13-platform-targets/mcpp.toml` state that a +tool cannot be declared per target, citing +`error: [target.aarch64-ios-sim.xlings] does not accept 'deps'`. The refusal is +real; the conclusion drawn from it is not. `[target..xlings.workspace]` +is accepted, and its entries are folded into the same install list `deps` feeds +(`toml.cppm`, around line 2890). It is covered by +`tests/unit/test_target_xlings_axis.cpp` and `tests/e2e/625`. + +Measured 2026-09-12 on linux-x86_64 with mcpp 2026.9.12.1: with +`"xim:apple-simulator-tools" = ""` under `[target.aarch64-ios-sim.xlings.workspace]`, +a host `mcpp build` exits 0 and never mentions the macOS-only package, and +`mcpp build --target aarch64-ios-sim` is refused at the SDK gate. + +What is missing is three statements that point to it: + +1. The refusal gains a second sentence naming + `[target..xlings.workspace]`. +2. `examples/13-platform-targets` declares `simctl-run`'s package beside the row + that uses it, and its README drops the manual `xlings install` step. The iOS + CI fixture does the same. +3. This record states the correction; the 2026-09-11 record is not edited. + +### 5.2 G2 was misdiagnosed in part: the channel exists; scoping and a requirement do not + +**What was recorded.** openkal-emscripten's README states that mcpp has no +channel for a flag that applies to a whole dependency graph, citing: + +``` +error: POSIX thread support was disabled in precompiled file + '.../pcm.cache/openkal.types.pcm' but is currently enabled +``` + +**What was measured, 2026-09-12.** The channel is `[build] dialect_cxxflags`, +which docs/04 describes as applied "to the std BMI prebuild, the module scan and +every translation unit in the graph, including dependencies", and which enters +each dependency's cache key (`cache_key.cppm`, `dialect_flags`). A program that +references `kal_task_start`, with the `threads` feature on: + +| root manifest | result | +|---|---| +| `dialect_cxxflags = ["-pthread"]`, `ldflags = ["-pthread"]` | links; 16 `-pthread` in `build.ninja`, including the global `cxxflags` line; the generated JavaScript mentions `SharedArrayBuffer` and `PThread` | +| `cxxflags = ["-pthread"]`, `ldflags = ["-pthread"]` | the recorded error, verbatim, on `openkal.task.pcm` | + +The recorded failure put the flag in the per-package channel. + +**What remains.** + +1. **Scoping.** `dialect_cxxflags` is not a conditional key: + `[target..build]` accepts only build inputs, so a portable manifest + cannot limit `-pthread` to the Web target. +2. **Portability.** `-pthread` is a GNU-driver spelling. A manifest that builds + the same program with cl has no correct value to write. +3. **A requirement.** openkal-emscripten's `threads` feature cannot state that it + needs the switch, so a consumer who forgets it gets a precompiled-module + mismatch instead of a sentence. + +**Decision: a typed `abi` table, first member `threads`.** Reason 2 is the same +reason §1 prefers a field to `ldflags`, and reason 3 is only possible with a +typed value. The table is built now so that a later graph-wide ABI switch has a +place that is not another free-form flag list. + +```toml +[target.'cfg(os = "emscripten")'.abi] +threads = true +``` + +- A sub-table of `[target.]`, so it takes a triple or a `cfg` + predicate and is evaluated against the resolved target, like `.build`. +- Rendered through the existing graph-global dialect channel: `-pthread` joins + `plan.dialectFlags`, and therefore the std module prebuild, the scan, every + translation unit and every dependency's cache key, and `-pthread` joins the + link. That is for GNU-style drivers (gcc, clang, em++). MSVC-style drivers + render nothing, since the MSVC runtime is always multithreaded. +- Only `threads` is accepted. An unknown member is refused naming the accepted + set, so the table cannot become a flag list. +- A feature or a package states the requirement with + `requires_abi = { threads = true }`. When the resolved target's `abi` does not + satisfy it, resolution refuses, naming the package, the feature, the member and + the manifest line that would satisfy it. An older mcpp reports the unknown key + and skips it, so the key can be published. +- `dialect_cxxflags` stays the raw channel for flags that have no typed member. + It does not become conditional in this change: the typed member covers the + measured case, and a conditional raw flag would bring back reason 2. + +**Criteria.** + +- With `threads = true` under the Web target's `abi` table, the `threads` feature + of openkal-emscripten links, and a program that starts a task runs under node. +- Without it, a feature declaring `requires_abi = { threads = true }` is refused + at resolution, naming the key. +- A dependency's cache key differs between the two builds, and a host build of + the same manifest is byte-identical to one without the table. +- An unknown member, and a non-boolean `threads`, are refused by name. + +## 6. #609 — a known toolchain hazard, stated where readers look + +Nothing in mcpp is wrong: microsoft/STL#6294 is open upstream. mcpp is still the +tool that assembles clang with the MSVC STL, so `docs/20-toolchains.md` and its +Chinese copy gain a second "Known Toolchain Hazard" section beside the existing +one. It states the error text, the affected STL release (14.51), the upstream +issue, and the two workarounds measured downstream: an explicit `operator==` on +the element type, or an older runner image. There is no engine change and no +detection heuristic. #609 is closed as documented upstream tracking. + +## 7. T1 — the fast path replays a build the command line asked to replace + +**Measured 2026-09-12.** In a project with no dependencies: + +| step | command | result | +|---|---|---| +| 1 | `mcpp build` | `Resolved gcc@16.1.0`, builds into `target/x86_64-linux-gnu/9dde3d1f4b99cc99` | +| 2 | `mcpp build --toolchain llvm@22.1.8` | `Finished dev in 0.00s`; nothing resolved; the same directory; the gcc artefact is left in place | +| 3 | the same command in a fresh copy | `Resolved llvm@22.1.8`, builds into `1e0091d91feafd6c`, and the artefact's `.comment` names clang 22.1.8 | + +Step 2 is a build that reports success with the wrong compiler. It also skips +every resolution-time check, including the §2.2 refusal. That is how this defect +was found: the §2.2 measurement first read "exit 0" under llvm, because the llvm +build never happened. + +**Fix.** The fast path is taken only when the inputs that choose the toolchain +are the ones the recorded build used. The command-line override is such an +input, beside the manifest's `[toolchain]` and the global default, so a +different override declines the fast path and resolution runs. Every other +command-line input that changes resolution is checked in the same pass, and the +rule is stated once, as a named set. + +**Criteria.** An end-to-end test of A-B-A: build with the default, build with +`--toolchain llvm@22.1.8`, build with the default again. After each step the +artefact's own `.comment` section names the expected compiler, and step 2 +resolves rather than replays. The test is run once with the fix removed, and +must fail there. + +## 8. Self-review + +Each angle states what the design does, and what would be wrong with the +alternative. + +- **Architecture.** Every change lands on a mechanism that already exists: + `LinkUnit` and the PE link-flag predicate (§1), the layer requirement check (§2), the + `runtime` table's skipping of unknown sub-keys (§4), the xlings workspace + selector (§5.1), the graph-global dialect channel and the dependency cache key + (§5.2), and the fast path's recorded inputs (§7). No new channel carries raw + flags. +- **Stability.** T1 removes a silent wrong-compiler build. Every new key refuses + malformed input by name rather than ignoring it. +- **Simplicity.** Two gaps recorded as missing engine features are closed with + statements and examples (§5.1) or with an existing requirement grammar (§2.2). + New keys exist only where a raw flag cannot express the intent: the subsystem + and entry (§1), and the thread ABI (§5.2). +- **User experience.** A failure that surfaced as a link error, a + precompiled-module mismatch or a silently wrong artefact becomes a sentence + naming the key that fixes it. Names follow the convention of the ecosystems + users arrive from (§1.2). +- **Compatibility.** No existing key changes meaning. New descriptor keys are + placed where older parsers skip rather than hang: `runtime.deploy` (§4.2), + `requires_abi` as an unknown feature key (§5.2). `console`/`main` render + nothing, so no existing Windows command line changes. +- **Cross-platform.** Rendering is decided by the object format and the PE link-flag predicate; a key + that means nothing for a format is inert and byte-identical there. The Windows + criteria run on Windows CI, not on Wine. +- **Consistency.** One vocabulary per concept: `mcpp:c++-abi` is the standard + library requirement, `windows_*` keys are Windows-only, `abi` holds graph-wide + ABI switches, and every path rule is the descriptor's `frontend` rule. +- **Seamless upgrade.** A manifest that builds today builds identically after the + change. The one behavioural change a user can observe is T1: a + `--toolchain` build that used to replay now builds with the toolchain it + names. +- **Test coverage.** Each section lists criteria, including the inert case on + other platforms and a refusal for each malformed shape. Each new unit test is + run once with its fix removed. The sandbox verification gains checks for §4, + §5.1, §5.2 and §7 on published artefacts. + +**Rejected in review.** A per-target `ldflags` key (§1.1); a new +`abi.cxx_stdlib` declaration, superseded by the existing layer requirement +(§2.2); extending `deploy_files` with tables, which would hang older clients +(§4.2); a general whole-graph flag list (§5.2); making `dialect_cxxflags` +conditional (§5.2). + +## 9. Decisions recorded from review + +1. The subsystem value is `"windows"`, as in Rust and Meson. +2. `windows_entry` ships in the same change. +3. For #613, a resolve-time refusal is sufficient; a variant-keyed store is left + to xlings. The refusal turned out to exist already (§2.2). +4. The graph-wide switch is an `abi` table rather than a single key, built now. + +## 10. Task list and dependencies + +``` +repo id task depends on +------------------ --- ------------------------------------------------------- ------------ +mcpp M1 §5.1 refusal text, examples/13, iOS CI fixture - +mcpp M2 §6 docs/20 + zh hazard section - +mcpp M3 §7 fast-path inputs; A-B-A e2e - +mcpp M4 §3 xlings environment function; error surfacing - +mcpp M5 §2.3 check order; hook environment; docs M4 +mcpp M6 §4 runtime.deploy: parsers, plan, readers, pack, tests - +mcpp M7 §1 windows_subsystem/windows_entry: parse, render, - + scope, directive, unit tests, Windows e2e, docs/04 +mcpp M8 §5.2 abi table: parse, render, cache key, requires_abi, - + unit tests, wasm e2e, docs/20, docs/06 +mcpp M9 CHANGELOG (the unreleased 2026.9.12.1 entry folds into M1-M8 + the new version), version, record status, index +mcpp M10 CI green, self-review, release, mirrors, index bump, M9 + sandbox verification, bootstrap pin +openkal-emscripten E1 README correction (§5.2); `threads` feature declares M10 + requires_abi; CI engine pin; a task program runs +mcpp-index E2 compat.mysql-connector-cpp declares M10 (and the + `requires = ["mcpp:c++-abi=libstdc++"]` index floor) +``` + +M1 to M4, M6, M7 and M8 are independent and are implemented in parallel. The +three ecosystem changes follow the release, because each adopts a key only the +new engine reads, and E2's descriptor must be checked against the index's +minimum engine version before it is published. + +## 11. Corrections made during implementation + +Each item states what the sections above said, what was measured or read in the +code, and what was built instead. + +1. **§1.3, the discriminator.** The ABI is not read from `plan.rcStyle`. + `LinkUnit` carries the declared words, and the emitter renders them, because + only the emitter knows whether the link is a separate linker invocation, which + decides between `/SUBSYSTEM:` and `-Wl,/SUBSYSTEM:`. The ABI is answered by + `pe_msvc_abi`, extracted from `pe_link_flag`, so the import library and the + subsystem cannot address two different linkers. +2. **§1.3, the MSVC row.** Both `/SUBSYSTEM:` and `/ENTRY:CRTStartup` are + written whenever either key differs from its default. Without `/SUBSYSTEM:`, + link.exe infers the subsystem from the entry function the objects define, so + `WinMain` with the console subsystem would link as a GUI program; without + `/ENTRY:`, the GUI subsystem selects `WinMainCRTStartup`, which a portable + `int main()` does not satisfy. +3. **§2.3 item 1, the order.** The layer requirement check runs in target-side + resolution, after the dependency graph is installed, and it is not moved ahead + of provisioning. A package's manifest may live inside its payload, so the + complete set of requirements is known only after installation. The hook + environment is what lets an install hook refuse before it compiles. +4. **§2.3 item 2, the values.** `MCPP_TARGET` in a hook follows the build-program + rule: the requested triple, or the host triple for a native build. The six + values are computed by `install_hook_env`, from which the build-program + environment also takes them, in their existing order, so no build program's + re-run key changes. The toolchain values are empty while a dependency + installs, which §2.3 assumed otherwise: `tc` is resolved after the dependency + graph, because a package in the graph may supply a target-side layer, so no + compiler or standard library has been decided when a dependency's hook runs. + Measured with tests/e2e/648, whose hook compiled in an empty `compiler=` and + `stdlib=` beside the host's `os=linux`. Resolving the toolchain before + installation is the reorder the engine deliberately does not make, and a + guessed value would let a hook build the wrong variant, so the variables are + emitted empty, which also keeps a value inherited from a parent process out + of the hook. Neither §2.4 criterion holds as written: the refusal follows the + hook (item 3), and a hook cannot print a resolved `stdlibId`. What holds is + that the `c++-abi` refusal names both implementations before compilation, and + that the hook sees the build's target and never an inherited toolchain value. +5. **§3.1, absent and empty on Windows.** The CRT defines `_putenv_s(key, "")` as + removal, so the Windows branch already produced an absent + `XLINGS_PROJECT_DIR`, and the two platforms did not disagree about global + mode. What was wrong on Windows was the lifetime: the value stayed in mcpp's + environment after the invocation. The asymmetry that did exist was on POSIX, + where the `install_packages` fallback spelled global mode by hand whatever the + project directory was. +6. **§3.2, the scope of the guard.** Only `XLINGS_PROJECT_DIR` is scoped. + `XLINGS_HOME` and the PATH prefix are left process-wide on Windows, as before; + scoping them has not been measured on Windows and is not part of this change. + The hook variables of §2.3 are applied by the dependency installer's own + scope rather than by the xlings environment function. +7. **§4.3, the TOML form.** mcpp's TOML layer refuses an array of tables in any + section not on an allowlist, and `runtime.deploy` had to join it. The unit + test written for the key reported this before an end-to-end test or a user + could. +8. **§5.2, rendering.** Rendering is decided by the object format rather than by + the driver: `-pthread` on every target that is neither PE nor freestanding, + nothing on PE and nothing on a freestanding target. The switch reaches the + root's `dialect_cxxflags`, `cflags` and `ldflags` and every dependency's + `cflags`. The MinGW driver would accept `-pthread`; it is not rendered there, + and threads on that ABI are outside this change's criteria. +9. **§7, the named set.** A recorded build is replayed only for the same target + triple, profile, cache mode, requested features and toolchain request. The + toolchain request is the command-line override (`--toolchain`, + `MCPP_TOOLCHAIN`) together with the machine default (`[toolchain] default`). + `--offline`, `--locked` and `--jobs` change how a resolution is fetched, + checked or executed, not what it chooses, and are not compared. An entry + written before the `toolchain=` line declines once. +10. **§7, the criterion.** A CI runner has one toolchain family installed, so the + A-B-A test requests the platform's own toolchain through `--toolchain` and + asserts that resolution runs, which the fast path skips. The machine-default + leg switches to a second installed version of the same family and asserts + the version string in the artefact; it reports itself as not measured where + no second version is installed. +11. **Outside this change: the fast path is taken only for ELF artefacts.** + tests/e2e/645's control, two unchanged plain builds, found the second one + resolving the toolchain on macOS and on Windows CI. `try_fast_build` + requires `validated_artifact_snapshot`, which requires a `Pass` + runtime-validation verdict for every artefact, and only an ELF artefact + records one; a Mach-O or PE build therefore always takes the full path. The + decline predates this change (#400). 645 distinguishes the two causes by the + `toolchain=` lines the two builds record: identical lines report the host as + not measured, and differing lines fail. diff --git a/.agents/docs/README.md b/.agents/docs/README.md index cba56d49e..d48559927 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded --- ``` -279 records. +280 records. ## By subject @@ -52,12 +52,14 @@ Records that declare one. Everything else is listed by date below. ### triage +- [The engine gaps left open after the SDK batch](2026-09-12-engine-gaps-after-the-sdk-batch.md) — landed - [Six open issues: what each one actually is, and what would answer it](2026-09-11-six-open-issues-analysis.md) — active ## By date ### 2026-09 +- [The engine gaps left open after the SDK batch](2026-09-12-engine-gaps-after-the-sdk-batch.md) — landed - [A verified Web run that asked the host for node](2026-09-12-a-verified-web-run-that-asked-the-host-for-node.md) — landed - [Six open issues: what each one actually is, and what would answer it](2026-09-11-six-open-issues-analysis.md) — active - [SDK toolchains, the payload/engine seam, and openkal across iOS, Android and Web](2026-09-11-sdk-toolchains-and-ios-local-verification.md) — landed diff --git a/.github/workflows/ci-macos-ios.yml b/.github/workflows/ci-macos-ios.yml index 2bc2e2859..bbef757b2 100644 --- a/.github/workflows/ci-macos-ios.yml +++ b/.github/workflows/ci-macos-ios.yml @@ -203,11 +203,11 @@ jobs: [target.aarch64-ios-sim] runner = ["simctl-run"] - # Declared at the top level here and not in examples/13, because a - # tool declaration is not conditional on a target and this package - # exists for macOS alone. This fixture is macOS-only, so it can say - # it; a portable manifest cannot. - [xlings.workspace] + # Declared on the row that runs it, as examples/13 does. A target + # section's `xlings.workspace` is installed only when that target is + # built, so the step below that runs the simulator artefact through + # `simctl-run` is also the measurement that this declaration works. + [target.aarch64-ios-sim.xlings.workspace] "xim:apple-simulator-tools" = "" TOML cat > /tmp/iostest/src/main.cpp << 'CPP' diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b11a4040..f30fbf978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ ## [Unreleased] -## [2026.9.12.1] - 2026-09-12 +## [2026.9.12.2] - 2026-09-12 + +2026.9.12.1 未单独发布,其条目并入本版本。 ### Web 产物的运行不再依赖宿主的 `node` @@ -34,6 +36,77 @@ Emscripten 链接产出的是首行为 `#!/usr/bin/env node` 的 JavaScript 启 忽略它,所以配方可以先发;在此之前安装的 emsdk 载荷没有描述文件,行为与之前相同,重新 安装后获得。 +### Windows GUI 可执行文件:`windows_subsystem` 与 `windows_entry`(#618) + +- `[targets.]` 增加 `windows_subsystem = "console" | "windows"` 与 + `windows_entry = "main" | "wmain" | "WinMain" | "wWinMain"`。它们是字段而不是链接标志,因为正确的 + 标志取决于 ABI:MSVC ABI 渲染为 `/SUBSYSTEM:` 与 CRT 启动符号 `/ENTRY:CRTStartup`(经 GNU + 风格驱动时带 `-Wl,`),只要任一键偏离默认值两条都写出;GNU ABI 渲染为 `-mwindows` 与 `-municode`。 + 在 ELF、Mach-O 与 WebAssembly 上不产生任何标志,产物逐字节不变。 +- 只到达声明它们的可执行目标的链接;同包的其他可执行文件、测试二进制与消费者保持控制台子系统。库目标 + 声明任一键被拒绝,拒绝信息指出目标与键名;取值不在集合内被拒绝并列出可接受的取值。 +- 构建程序协议升至 10:`mcpp::windows_subsystem(target, value)` 与 `mcpp::windows_entry(target, value)` + 为本包的可执行目标设置同一字段。指向未声明的目标、非可执行目标,或与 mcpp.toml 矛盾的取值,在应用任何 + 指令之前被拒绝;缓存命中的路径施加同一检查。 +- `[targets.]` 不支持键的警告所列出的键表改为由解析器接受的键表生成;此前手写的副本漏掉了 `exports`。 + +### 快路径比较工具链请求 + +- 快路径此前比较目标三元组、profile、缓存模式与 feature,但不比较 `--toolchain`(即 `MCPP_TOOLCHAIN`) + 与本机默认工具链(config.toml 的 `[toolchain] default`)。实测:以 gcc 构建后执行 + `mcpp build --toolchain llvm@22.1.8`,输出 `Finished dev in 0.00s` 并保留 gcc 产物,解析阶段的检查 + 全部被跳过。 +- 构建缓存记录增加 `toolchain=` 行,`mcpp build` 与 `mcpp run` 的快路径都比较它。早于该行的记录被拒绝 + 一次,随后的构建重新写入。`--offline`、`--locked` 与 `--jobs` 不改变解析的选择,不参与比较。 + +### `runtime.deploy`:把运行期文件放进相对可执行文件的目录(#615) + +- `[runtime] deploy = [{ from = "...", to = "..." }]` 与描述文件的 `runtime.deploy`:`from` 相对声明它的 + 包,`to` 相对可执行文件所在目录,`"."` 表示该目录本身。`deploy_files` 把每一项放在可执行文件旁,无法 + 满足从固定子目录读取的加载器,例如 macOS 上的 Vulkan loader 读取 `<可执行文件目录>/vulkan/icd.d`。 +- mcpp.toml 与描述文件使用同一条路径规则:以 `/` 分隔,不得为绝对路径、不得指定盘符、不得含空分量、`.` + 或 `..` 分量;违反的项按序号被拒绝。同一目标位置的两个来源被拒绝,同名文件放进不同目录不构成冲突。 + 测试二进制看到同样的布局。 +- `mcpp pack` 把 `deploy_files` 与 `deploy` 的文件放到打包后可执行文件旁的同一相对位置;此前打包过程不读取 + 这两个列表中的任何一个。 +- 它是独立的键,而不是 `deploy_files` 的表形式:早于它的描述文件读取器在 `deploy_files` 中遇到 `{` 时 + 不会终止,而对不认识的 `runtime` 键会跳过。 + +### 产物的 ABI 开关:`[target..abi] threads` 与 `requires_abi` + +- 线程支持是整个产物共享的性质:标准库模块预构建、依赖扫描、每个包的每个翻译单元与链接必须一致。根 + manifest 以 `[target..abi] threads = true` 声明;在既非 PE 也非 freestanding 的目标上渲染为 + `-pthread`,并经方言 flag 进入依赖缓存键。未知成员与非布尔的 `threads` 被拒绝。 +- 依赖以 `[package] requires_abi = { threads = true }` 或 + `[features] = { requires_abi = { threads = true } }` 声明需求;根包未满足时在编译之前被拒绝, + 拒绝信息指出包与 feature,并给出满足它的表。依赖自己写的 `[target..abi]` 被报告 + (`abi/dependency-table`)且不生效。 + +### 安装钩子的环境与 `c++-abi` 的做法(#613) + +- 依赖包的安装钩子收到 `MCPP_TARGET`、`MCPP_TARGET_OS`、`MCPP_TARGET_ARCH` 与 `MCPP_TARGET_ENV`,名称 + 与规则同构建程序一致,由同一个函数计算。`MCPP_COMPILER` 与 `MCPP_CXX_STDLIB` 同样总是写出,但在依赖 + 安装时为空:工具链在依赖图之后才解析,此时没有可陈述的编译器与标准库。每个变量都显式写出,钩子不会读到 + 从父进程继承的值;钩子不得把某种变体构建进名称未体现该变体的存储目录。 +- 从源码构建静态库的包以 `requires = ["mcpp:c++-abi="]` 声明它所针对的标准库;工具链解析出另一 + 实现的工程被拒绝,拒绝信息指出两个实现。docs/06、docs/22 与 docs/32 记录这一做法。 + +### xlings 调用的环境与错误输出(#614) + +- 一次 xlings 调用的环境由一个函数决定,全局模式是不存在的 `XLINGS_PROJECT_DIR`。POSIX 把它渲染进 + 命令前缀;Windows 在调用期间以作用域守卫施加,调用结束后恢复原值,项目目录不再留在 mcpp 之后启动的 + 进程的环境中。`install_packages` 的回退路径此前在 POSIX 上无视项目目录、一律按全局模式拼写,现与直接 + 路径一致。 +- 安装失败时,xlings 自己的错误级别输出(含 `error`、`E_` 或以 `[xim]` 开头的行,最多最后 20 行)以 + `xlings:` 前缀附在 mcpp 的诊断之后;此前 stderr 被丢弃。 + +### 其他 + +- `[target..xlings]` 下写包名时,拒绝信息指出正确的位置 `[target..xlings.workspace]`; + 示例 13 与 iOS 模拟器 CI 夹具改用该写法声明 `xim:apple-simulator-tools`,示例 README 不再要求手动安装。 +- docs/20 记录 clang 与 MSVC STL 14.51 组合下 `std::find` 作用于宽平凡可比较类型时的已知工具链缺陷 + (microsoft/STL#6294,#609)。 + ## [2026.9.11.4] - 2026-09-11 ### iOS 三行:生态编译器与定位到的 SDK diff --git a/docs/04-mcpp-toml.md b/docs/04-mcpp-toml.md index 6062fcc57..22f123482 100644 --- a/docs/04-mcpp-toml.md +++ b/docs/04-mcpp-toml.md @@ -202,6 +202,55 @@ A `soname` is meaningful on `kind = "lib"` too — see [`dependency_linkage`](#dependency_linkage--static-or-shared-is-the-consumers-decision) below, where the form a library takes becomes the consumer's decision. +#### `windows_subsystem` and `windows_entry` — a Windows GUI executable (mcpp 2026.9.12.2+) + +```toml +[targets.myapp] +kind = "bin" +main = "src/main.cpp" +windows_subsystem = "windows" # "console" (default) | "windows" +windows_entry = "main" # "main" (default) | "wmain" | "WinMain" | "wWinMain" +``` + +A PE executable records a subsystem. `"console"` attaches a console, and +`"windows"` produces a GUI program that starts without one. `windows_entry` +names the function the program defines, not the startup symbol that calls it, +and it is independent of the subsystem: a console program may define `wmain`, +and a GUI program may keep a portable `int main()`. + +The keys are fields rather than link flags because the correct flags depend on +the ABI, and a flag cannot state which ABI it addresses: + +| `windows_subsystem` / `windows_entry` | MSVC ABI (cl, clang-cl, clang targeting `*-windows-msvc`) | GNU ABI (MinGW gcc, clang targeting `*-windows-gnu`) | +|---|---|---| +| `"console"` / `"main"` | nothing | nothing | +| `"windows"` / `"main"` | `/SUBSYSTEM:WINDOWS /ENTRY:mainCRTStartup` | `-mwindows` | +| `"windows"` / `"WinMain"` | `/SUBSYSTEM:WINDOWS /ENTRY:WinMainCRTStartup` | `-mwindows` | +| `"windows"` / `"wWinMain"` | `/SUBSYSTEM:WINDOWS /ENTRY:wWinMainCRTStartup` | `-mwindows -municode` | +| `"console"` / `"wmain"` | `/SUBSYSTEM:CONSOLE /ENTRY:wmainCRTStartup` | `-municode` | + +On the MSVC ABI both flags are written whenever either key differs from its +default, because the linker infers each from the other when one is absent: the +GUI subsystem alone selects `WinMainCRTStartup`, which a portable `int main()` +does not satisfy, and `/ENTRY:main` skips CRT initialisation, static +constructors included. A GNU-style driver receives the MSVC-ABI flags as +`-Wl,/SUBSYSTEM:...`. + +The keys reach the link of the declaring target only. A second executable, the +`mcpp test` binaries and the consumers of the package keep the console +subsystem, which is why `[build] ldflags` is not the place for these flags: that +channel reaches every link in the graph. On ELF, Mach-O and WebAssembly the keys +render nothing and the artefact is byte-identical to one built without them, so +a cross-platform manifest needs no `cfg` block. A library target that declares +either key is refused, and the refusal names the target and the key. + +A build program sets the same fields for an executable of its own package with +`mcpp::windows_subsystem("", "windows")` and +`mcpp::windows_entry("", "wmain")` ([build.mcpp](30-build-mcpp.md)). + +Application bundles, application manifests and DPI awareness are not part of +these keys; they belong to packaging formats and to `[resources]`. + #### Per-target keys ```toml @@ -223,6 +272,8 @@ required_features = ["gui"] # only built when feature `gui` is | `defines` | Preprocessor macros (`name` or `name=value`); desugar to `-D` on both the C and C++ entry compile. | | `cxxflags` / `cflags` | Extra compile flags for this target. Do **not** put `-std=...` here — use `[package].standard`. | | `required_features` | The target is emitted only when **every** listed feature is active in the build; otherwise it is silently skipped. A gate only — it does not activate features (use `--features` / `[features].default`). **One exception, and it is not a second rule:** when this target is requested as a host tool (`tools = [...]`, §2.14), the target is what was *asked for*, so its `required_features` become the sub-build's *inputs*. Same field, one meaning — the resolution just runs in the opposite direction. | +| `windows_subsystem` *(2026.9.12.2+)* | The PE subsystem of an executable: `"console"` (the default) or `"windows"`, a GUI program that starts without a console. Reaches this target's link and no other, and renders nothing on a target that is not PE. See the section above. | +| `windows_entry` *(2026.9.12.2+)* | The entry function the program defines: `"main"` (the default), `"wmain"`, `"WinMain"` or `"wWinMain"`. See the section above. | > **Scope (important):** `defines` / `cxxflags` / `cflags` on a target apply **only to that > target's exclusive entry source** (its `main`) — never to shared module/impl objects, which @@ -1055,6 +1106,7 @@ transitive_needed_dirs = ["runtime/closure"] runtime_search_dirs = ["runtime"] frameworks = ["WindowKit"] deploy_files = ["bin/widget.dll"] +deploy = [ { from = "share/vulkan/icd.d/widget_icd.json", to = "vulkan/icd.d" } ] # Use an exact canonical identity when multiple providers exist. [runtime."display.present"] @@ -1109,6 +1161,23 @@ Link intent keeps discovery stages separate: | `runtime_search_dirs` | RUNPATH/rpath only, never `-L` | rpath only | no flag | | `frameworks` | no flag | `-framework` | no flag | | `deploy_files` | copy edge | copy edge | copy beside the output; never a linker flag | +| `deploy` *(2026.9.12.2+)* | copy edge into `bin//` | copy edge into `bin//` | copy edge into `bin//`; never a linker flag | + +`deploy` places a file in a directory relative to the executable, which +`deploy_files` cannot express because it places every entry beside the +executable. A loader that reads a fixed subdirectory needs it: the Vulkan loader +on macOS reads driver manifests from `/vulkan/icd.d`. Each entry +is a table of exactly two strings. `from` is relative to the declaring package's +root, and `to` is relative to the executable's directory, where `"."` means that +directory itself. Both are separated by `/` on every host, and neither may be +absolute, name a drive, or contain an empty, `.` or `..` component; an entry +that does is refused, and the refusal names its index. Two sources for one +destination are refused naming the destination, while one file name in two +directories is not a collision. `deploy` is a key of its own rather than a table +form of `deploy_files`, because a descriptor reader that predates it meets `{` +inside `deploy_files` and does not terminate, whereas it skips a `runtime` key it +does not know. `mcpp pack` stages the files of both keys at the same relative +path beside the packed executable. For one compatibility train, `library_dirs` maps only to runtime search, `dlopen_libs` maps to required run-phase soname requirements, and diff --git a/docs/06-features-and-capabilities.md b/docs/06-features-and-capabilities.md index 66485e461..5b280fe7f 100644 --- a/docs/06-features-and-capabilities.md +++ b/docs/06-features-and-capabilities.md @@ -192,6 +192,24 @@ provides = ["mcpp:compiler-runtime=compiler-rt", "mcpp:c++-abi=libc++"] requires = ["mcpp:compiler=llvm"] ``` +A requirement on the artefact's ABI switch is stated with `requires_abi`, on the +package or on one feature, rather than as a layer: + +```toml +[package] +requires_abi = { threads = true } + +[features] +mt = { requires_abi = { threads = true } } +``` + +Only the root manifest sets the switch (`[target..abi]`, [22 — The +Target Side](22-target-side.md)); a requirement it does not satisfy is refused +before compilation, naming the package and the feature. A package whose install +hook compiles a static library against one C++ standard library states that +implementation as a layer requirement, `requires = ["mcpp:c++-abi=libstdc++"]`, +for the reason given in the same chapter. + A package that is a standard library states its `std` module source under `[build]`, where the flags it needs become conditional like any other build input. diff --git a/docs/20-toolchains.md b/docs/20-toolchains.md index ecb5f576d..6a9e1b421 100644 --- a/docs/20-toolchains.md +++ b/docs/20-toolchains.md @@ -873,6 +873,37 @@ Tracked as [mcpp#256](https://github.com/mcpp-community/mcpp/issues/256). bundled LLVM toolchains, so a future Clang bump that fixes — or re-breaks — this becomes visible instead of silently changing what packages can express. +## Known Toolchain Hazard: `std::find` Over a Wide Trivially Comparable Type (clang + MSVC STL 14.51) + +A translation unit that calls `std::find` over a trivially copyable type wider +than eight bytes fails to compile inside the standard library when the compiler +is clang and the standard library is the MSVC STL at 14.51 (Visual Studio 18): + +```text +xutility:320:23: error: static assertion failed: unexpected size +xutility:6542:49: note: in instantiation of function template specialization + 'std::_Find_vectorized' requested here +``` + +This is the shape of mcpp's default Windows toolchain, clang targeting +`x86_64-pc-windows-msvc`, so the failure surfaces through `mcpp build` although +nothing in mcpp or in the program is wrong. The MSVC STL admits the type to its +vectorized path through a clang-only trait that has no upper size bound, and +the function it dispatches to implements 1-, 2-, 4- and 8-byte elements only. +MSVC's own front end never takes that path. The same source compiles against the +MSVC STL 14.3x that `windows-2022` ships. + +The defect is upstream, tracked as +[microsoft/STL#6294](https://github.com/microsoft/STL/issues/6294). Two +workarounds were measured downstream: + +- Give the element type a user-written `operator==` instead of a defaulted one. + The type is then not trivially equality-comparable, and the STL keeps its + scalar path. +- Build on an image whose MSVC STL predates 14.51, such as `windows-2022`. + +Tracked as [mcpp#609](https://github.com/mcpp-community/mcpp/issues/609). + ## The C++ runtime contract (`cxx_runtime`) `cxx_runtime` states what the produced artifact promises about the machine that diff --git a/docs/22-target-side.md b/docs/22-target-side.md index 388b9ed9f..b531645dd 100644 --- a/docs/22-target-side.md +++ b/docs/22-target-side.md @@ -227,6 +227,24 @@ The check runs before compilation begins. The combination it rejects otherwise fails inside the runtime's own headers, in a message naming a file the reader has never opened and no decision mcpp made. +The same statement is the recipe for a package whose install hook compiles a +static library from source. The library is compiled against one C++ standard +library and cannot be linked into a program that uses another, and the store +directory it is installed into is keyed by package and version, not by that +choice. Such a package declares the implementation it was built for: + +```toml +requires = ["mcpp:c++-abi=libstdc++"] +``` + +A project whose toolchain resolves another `c++-abi` is then refused, naming +both implementations, instead of failing at the link. The check runs once the +toolchain is resolved, which is after the dependency graph is installed, so the +install hook has already run; the hook receives the build's target but no +toolchain values ([32 — Authoring a Payload](32-authoring-a-payload.md)). It +must not build a different variant into the same store directory, because the +first consumer would then decide the variant for every later one. + ### Standard Library Module Sources A package that is a standard library states where its `std` module source is @@ -500,6 +518,56 @@ this returns. To branch on the resolved layer, use a layer predicate: Side](22-target-side.md)). This paragraph said "which C library was resolved" until 2026.9.1.1, which was the wrong one of the two. +### `abi` — a switch the whole artefact shares (mcpp 2026.9.12.2+) + +```toml +[target.'cfg(os = "emscripten")'.abi] +threads = true +``` + +Some properties of a target are not a flag a translation unit may choose. Thread +support is one: on WebAssembly every object, the precompiled standard library +module and the link must agree on shared memory and atomics, and one translation +unit built without them makes the link fail or the module refuse to load. Such a +property is written as a typed member of `[target..abi]` rather than as +a flag in `cxxflags`, so the engine applies it to every unit that has to agree +and compares it with what a package needs. + +| Member | Type | Renders as | Reaches | +|---|---|---|---| +| `threads` | boolean | `-pthread` on a target that is neither PE nor freestanding; nothing on PE and on freestanding targets | the standard library module prebuild, the dependency scan, every C and C++ translation unit of every package, and the link | + +The member enters the dependency cache key through the dialect flags, so a +dependency built without threads is never reused by a build with them. An +unknown member, and a `threads` that is not a boolean, are refused. + +**Only the root manifest decides.** The switch belongs to the artefact, and the +root is the only package that builds one. A dependency that writes +`[target..abi]` is reported (`abi/dependency-table`) and changes +nothing. A dependency states what it needs instead: + +```toml +[package] +requires_abi = { threads = true } # the package needs threads + +[features] +mt = { requires_abi = { threads = true } } # only this feature needs them +``` + +A requirement the root does not satisfy is refused before anything compiles, +naming the package and what required the switch: + +``` +error: `wasmrt` requires the artefact's ABI to have threads (feature `mt`), and this build does not state it. + Add to the root manifest, for the targets that need it: + + [target.'cfg(os = "")'.abi] + threads = true +``` + +Without the refusal the mismatch surfaces as a precompiled-module configuration +error that names neither the package nor the switch. + ## Current limitations - **A dependency cannot be conditioned on the accelerator.** The accelerator diff --git a/docs/30-build-mcpp.md b/docs/30-build-mcpp.md index 43844a8ab..69d865cbc 100644 --- a/docs/30-build-mcpp.md +++ b/docs/30-build-mcpp.md @@ -64,6 +64,8 @@ is ignored, so diagnostics may be logged freely. | `mcpp:include-dir-after=` *(0.0.100+)* | like `include-dir`, but searched **after** the system directories (`-idirafter`) — for payload trees that shadow system headers | | `mcpp:runner=` *(2026.8.19.2+)* | one argv token of the command that EXECUTES this build's artifact, when the host cannot. Emitted once per token, in order; the artifact path is appended (or substituted for `{}`). Reaches the **consumer**. Emit the executable as an ABSOLUTE path, and only **one** dependency may supply it | | `mcpp:link-flag=` *(2026.9.6.5+)* | add a **linker flag** this program computed, verbatim. The outlet `link-lib` / `link-search` / `link-script` leave open: a generated version script (`-Wl,--version-script=`), `-Wl,--wrap=malloc` for a runtime that takes over a C-library symbol, `-Wl,--exclude-libs,ALL` so a statically absorbed third party does not become part of this package's ABI. Appended after `[build] ldflags`, in emission order. **Reaches the consumer**, exactly as `[build] ldflags` does — see below | +| `mcpp:windows-subsystem=:` *(2026.9.12.2+)* | set the PE subsystem (`console` or `windows`) of the executable `` of **this** package, the same field as `[targets.] windows_subsystem` (docs/04). Reaches that target's link and no other, never a consumer, and renders nothing on a target that is not PE. A target the package does not declare with `kind = "bin"`, a value outside the set, and a value that contradicts mcpp.toml are each refused before any directive is applied | +| `mcpp:windows-entry=:` *(2026.9.12.2+)* | set the entry function (`main`, `wmain`, `WinMain` or `wWinMain`) of the executable ``, the same field as `windows_entry`; the scope and the refusals are those of `windows-subsystem` | | `mcpp:link-script=` *(2026.8.19+)* | link with this **linker script** (`-T`; relative resolves against the package root, and the emitted path is absolute because the link runs in the build directory). Reaches the **consumer**, unlike `include-dir` — a board's memory layout is the one thing a consumer cannot write for itself | | `mcpp:warning=` *(2026.8.21.2+)* | say something to the user and **keep going**. The one directive that changes no compile line, no link line and no source set. Survives the build cache — see below | | `mcpp:fact==` *(2026.9.5.2+)* | state something the program **established about the machine** (`cuda.driver=12.4`). Compared against floors before anything is compiled; see below | @@ -129,6 +131,7 @@ int main() { | `mcpp::rerun_if_changed_glob(pat)` *(2026.8.6.2+)* | `mcpp:rerun-if-changed-glob=` — re-run when the **set** of files matching `pat` changes (see below) | | `mcpp::dep_bin(pkg, tool)` *(2026.8.5.1+)* | reads `MCPP_DEP__BIN_` — the absolute path of a **host tool** built by a dependency (see below) | | `mcpp::link_flag(s)` *(2026.9.6.5+)* | `mcpp:link-flag=` | +| `mcpp::windows_subsystem(target, value)` / `mcpp::windows_entry(target, value)` *(2026.9.12.2+)* | `mcpp:windows-subsystem=` / `mcpp:windows-entry=` | | `mcpp::link_script(p)` *(2026.8.19+)* | `mcpp:link-script=` | | `mcpp::runner(tok)` *(2026.8.19.2+)* | `mcpp:runner=` — see below | | `mcpp::xpkg_dir(ns, name)` / `mcpp::xpkg_dir(name)` *(2026.8.19+)* | the payload directory of a package declared in `[xlings.workspace]` — by this manifest, or by a dependency compiled into this build program *(2026.9.6.6+)*; `""` when it was not declared or is not installed (see below) | diff --git a/docs/32-authoring-a-payload.md b/docs/32-authoring-a-payload.md index 1c86104bc..db9c7ba21 100644 --- a/docs/32-authoring-a-payload.md +++ b/docs/32-authoring-a-payload.md @@ -69,6 +69,34 @@ end registers what the payload offers. Everything else in this chapter is one of those two doing more. +### The environment an install hook receives (mcpp 2026.9.12.2+) + +When mcpp installs a package a project depends on, the package's `install()` +runs with the build's target in its environment, under the names and by the rule +a build program uses ([build.mcpp](30-build-mcpp.md)): every variable is present, +and empty when it has no value, so a hook never reads a value inherited from the +process that started mcpp. + +| Variable | Value while a dependency installs | +|---|---| +| `MCPP_TARGET` | the target triple the build was asked for, or the host triple for a native build | +| `MCPP_TARGET_OS`, `MCPP_TARGET_ARCH`, `MCPP_TARGET_ENV` | the segments of that triple | +| `MCPP_COMPILER`, `MCPP_CXX_STDLIB` | empty | + +The toolchain values are empty because the toolchain is resolved after the +dependency graph: a package in the graph may supply a target-side layer, so no +compiler or standard library has been decided when a dependency installs, and a +hook that guessed one could build the wrong variant. On Windows an empty variable +is an absent one, and `os.getenv` answers `nil` for it. The hook of a toolchain +payload receives none of these variables. + +A hook may use the target to refuse or to diagnose. It must not build a variant +into a store directory whose name does not state the variant: the store is keyed +by package and version, so the first consumer would decide the variant for every +later one. A package compiled against one C++ standard library states that with +`requires = ["mcpp:c++-abi=libstdc++"]`, which is checked once the toolchain is +resolved ([22 — The Target Side](22-target-side.md)). + ## The four things a descriptor must get right **One version, two URLs.** Every version carries a `GLOBAL` and a `CN` URL and diff --git a/docs/zh/04-mcpp-toml.md b/docs/zh/04-mcpp-toml.md index 47acb367f..ab0979d3f 100644 --- a/docs/zh/04-mcpp-toml.md +++ b/docs/zh/04-mcpp-toml.md @@ -186,6 +186,46 @@ ICD 相撞。 `soname` 对 `kind = "lib"` 同样有意义 —— 见下文的 `dependency_linkage`, 库以何种形态出现是**消费者**的决定。 +#### `windows_subsystem` 与 `windows_entry` —— Windows GUI 可执行文件(mcpp 2026.9.12.2+) + +```toml +[targets.myapp] +kind = "bin" +main = "src/main.cpp" +windows_subsystem = "windows" # "console"(默认)| "windows" +windows_entry = "main" # "main"(默认)| "wmain" | "WinMain" | "wWinMain" +``` + +PE 可执行文件记录一个子系统。`"console"` 为程序附加控制台,`"windows"` 产生启动时不带控制台的 +GUI 程序。`windows_entry` 指程序定义的函数,而不是调用该函数的启动符号;它与子系统相互独立:控制台 +程序可以定义 `wmain`,GUI 程序也可以保留可移植的 `int main()`。 + +这两个键是字段而不是链接标志,原因是正确的标志取决于 ABI,而一条标志无法说明自己面向哪个 ABI: + +| `windows_subsystem` / `windows_entry` | MSVC ABI(cl、clang-cl、面向 `*-windows-msvc` 的 clang) | GNU ABI(MinGW gcc、面向 `*-windows-gnu` 的 clang) | +|---|---|---| +| `"console"` / `"main"` | 无 | 无 | +| `"windows"` / `"main"` | `/SUBSYSTEM:WINDOWS /ENTRY:mainCRTStartup` | `-mwindows` | +| `"windows"` / `"WinMain"` | `/SUBSYSTEM:WINDOWS /ENTRY:WinMainCRTStartup` | `-mwindows` | +| `"windows"` / `"wWinMain"` | `/SUBSYSTEM:WINDOWS /ENTRY:wWinMainCRTStartup` | `-mwindows -municode` | +| `"console"` / `"wmain"` | `/SUBSYSTEM:CONSOLE /ENTRY:wmainCRTStartup` | `-municode` | + +在 MSVC ABI 上,只要任一键偏离默认值,两条标志就都写出。原因是链接器在缺少其中一条时由另一条推断: +单独的 GUI 子系统会选择 `WinMainCRTStartup`,而可移植的 `int main()` 无法满足它;`/ENTRY:main` +则会跳过 CRT 初始化,静态构造也随之被跳过。GNU 风格的驱动收到的 MSVC ABI 标志形如 +`-Wl,/SUBSYSTEM:...`。 + +这两个键只到达声明它们的目标的链接。同一包的其他可执行文件、`mcpp test` 的测试二进制以及该包的消费者 +都保持控制台子系统;这也是这些标志不应写进 `[build] ldflags` 的原因:该通道到达图中的每一次链接。 +在 ELF、Mach-O 与 WebAssembly 上,这两个键不产生任何标志,产物与未声明它们时逐字节相同,因此跨平台的 +manifest 不需要 `cfg` 块。库目标声明任一键会被拒绝,拒绝信息指出目标与键名。 + +构建程序通过 `mcpp::windows_subsystem("", "windows")` 与 +`mcpp::windows_entry("", "wmain")` 为本包的可执行文件设置同样的字段 +([build.mcpp](30-build-mcpp.md))。 + +应用程序包、应用程序清单与 DPI 感知不属于这两个键,它们归属于打包格式与 `[resources]`。 + #### 按目标的键(per-target keys) ```toml @@ -207,6 +247,8 @@ required_features = ["gui"] # 仅当 feature `gui` 激活时 | `defines` | 预处理宏(`name` 或 `name=value`),脱糖为 `-D`,作用于该目标入口的 C 与 C++ 编译。 | | `cxxflags` / `cflags` | 该目标的额外编译标志。**不要**放 `-std=...`——用 `[package].standard`。 | | `required_features` | 仅当列出的 feature **全部**激活时才生成该目标,否则静默跳过。只是门禁——不激活 feature(用 `--features` / `[features].default`)。 | +| `windows_subsystem` *(2026.9.12.2+)* | 可执行文件的 PE 子系统:`"console"`(默认)或 `"windows"`(启动时不带控制台的 GUI 程序)。只到达该目标的链接,在非 PE 目标上不产生任何标志。见上一节。 | +| `windows_entry` *(2026.9.12.2+)* | 程序定义的入口函数:`"main"`(默认)、`"wmain"`、`"WinMain"` 或 `"wWinMain"`。见上一节。 | > **作用域(重要):** 目标上的 `defines` / `cxxflags` / `cflags` **只作用于该目标独占的入口源** > (它的 `main`)——**绝不**作用于共享的模块/实现对象(那些只编译一次、被每个目标链接,即 mcpp 的 @@ -920,6 +962,7 @@ transitive_needed_dirs = ["runtime/closure"] runtime_search_dirs = ["runtime"] frameworks = ["WindowKit"] deploy_files = ["bin/widget.dll"] +deploy = [ { from = "share/vulkan/icd.d/widget_icd.json", to = "vulkan/icd.d" } ] # 多 provider 时使用精确 canonical identity。 [runtime."display.present"] @@ -956,6 +999,16 @@ LinkIntent 把不同发现阶段分开: | `runtime_search_dirs` | 只进 RUNPATH/rpath,绝不进 `-L` | 只进 rpath | 无 flag | | `frameworks` | 无 flag | `-framework` | 无 flag | | `deploy_files` | copy edge | copy edge | 复制到产物旁,绝不成为 linker flag | +| `deploy` *(2026.9.12.2+)* | copy edge,复制到 `bin//` | copy edge,复制到 `bin//` | copy edge,复制到 `bin//`;绝不成为 linker flag | + +`deploy` 把文件放进相对可执行文件的目录;`deploy_files` 表达不了这一点,因为它把每一项都放在可执行 +文件旁。读取固定子目录的加载器需要它:macOS 上的 Vulkan loader 从 `<可执行文件目录>/vulkan/icd.d` +读取驱动清单。每一项是恰好含两个字符串的表:`from` 相对声明它的包的根目录,`to` 相对可执行文件所在 +目录,`"."` 表示该目录本身。两者在所有宿主上都以 `/` 分隔,不得是绝对路径、不得指定盘符,也不得含 +空分量、`.` 或 `..` 分量;违反的项被拒绝,拒绝信息指出该项的序号。同一目标位置的两个来源被拒绝并指出 +目标位置,同名文件放进两个不同目录则不构成冲突。`deploy` 是独立的键而不是 `deploy_files` 的表形式: +早于它的描述文件读取器在 `deploy_files` 中遇到 `{` 时不会终止,而对不认识的 `runtime` 键会跳过。`mcpp pack` +把两个键的文件放到打包后可执行文件旁的同一相对位置。 一个兼容发布周期内仍读取旧字段:`library_dirs` 只映射到运行期搜索; `dlopen_libs` 映射为必需的 run-phase soname requirement;`capabilities` 映射为必需的 diff --git a/docs/zh/06-features-and-capabilities.md b/docs/zh/06-features-and-capabilities.md index 7d6cd9d5a..b945cf30a 100644 --- a/docs/zh/06-features-and-capabilities.md +++ b/docs/zh/06-features-and-capabilities.md @@ -166,6 +166,20 @@ provides = ["mcpp:compiler-runtime=compiler-rt", "mcpp:c++-abi=libc++"] requires = ["mcpp:compiler=llvm"] ``` +对产物 ABI 开关的需求用 `requires_abi` 陈述,写在包上或某个 feature 上,而不是写成层: + +```toml +[package] +requires_abi = { threads = true } + +[features] +mt = { requires_abi = { threads = true } } +``` + +只有根 manifest 设置该开关(`[target..abi]`,见 [22 —— 目标侧](22-target-side.md)); +根包未满足的需求在编译之前被拒绝,拒绝信息指出包与 feature。安装钩子针对某一个 C++ 标准库编译静态库的 +包,则把该实现陈述为层需求,即 `requires = ["mcpp:c++-abi=libstdc++"]`,原因见同一章。 + 作为标准库的包在 `[build]` 下陈述它的 `std` 模块源, 其所需的 flag 在那里与任何其它构建输入一样可条件化。 diff --git a/docs/zh/20-toolchains.md b/docs/zh/20-toolchains.md index eeb98a641..c599374c3 100644 --- a/docs/zh/20-toolchains.md +++ b/docs/zh/20-toolchains.md @@ -800,6 +800,32 @@ inline MA& operator+=(MA& a, const MB& b); 工具链上的金丝雀 —— 未来某次 Clang 升级修好(或再次弄坏)这一点时, 它会显式暴露出来,而不是悄悄改变包能表达的东西。 +## 已知工具链风险:宽的可平凡比较类型上的 `std::find`(clang + MSVC STL 14.51) + +编译器是 clang、标准库是 MSVC STL 14.51(Visual Studio 18)时,对宽度超过八字节的 +可平凡复制类型调用 `std::find` 的翻译单元,会在标准库内部编译失败: + +```text +xutility:320:23: error: static assertion failed: unexpected size +xutility:6542:49: note: in instantiation of function template specialization + 'std::_Find_vectorized' requested here +``` + +这正是 mcpp 在 Windows 上默认工具链的形态(clang 面向 `x86_64-pc-windows-msvc`), +所以即使 mcpp 和程序本身都没有错误,失败也会经由 `mcpp build` 出现。MSVC STL 通过一个 +只在 clang 下生效、且没有尺寸上限的 trait 把该类型放进向量化路径,而它分派到的函数只 +实现了 1、2、4、8 字节的元素;MSVC 自己的前端不走这条路径。同一份源码在 +`windows-2022` 所带的 MSVC STL 14.3x 上可以编译。 + +缺陷在上游,见 [microsoft/STL#6294](https://github.com/microsoft/STL/issues/6294)。 +下游实测过两种绕过方式: + +- 为元素类型写一个用户定义的 `operator==`,而不是默认的。该类型因此不再可平凡相等比较, + STL 走标量路径。 +- 在 MSVC STL 早于 14.51 的镜像上构建,例如 `windows-2022`。 + +见 [mcpp#609](https://github.com/mcpp-community/mcpp/issues/609)。 + ## C++ 运行时契约(`cxx_runtime`) `cxx_runtime` 声明的是**产物对运行它的机器做出的承诺**。它是**分发**属性而非 diff --git a/docs/zh/22-target-side.md b/docs/zh/22-target-side.md index d797269ba..accf20166 100644 --- a/docs/zh/22-target-side.md +++ b/docs/zh/22-target-side.md @@ -193,6 +193,19 @@ requires = ["mcpp:compiler=llvm"] 该检查在编译开始之前运行。它所拒绝的组合,否则将在该运行时自身的头文件深处失败, 其消息命名一个读者从未打开过的文件,以及一个 mcpp 从未作出的决定。 +同一条声明也是「安装钩子从源码编译静态库」这类包的做法。这样的库针对某一个 C++ 标准库编译,无法链接进 +使用另一个标准库的程序;而它安装到的存储目录按包名与版本区分,并不区分这一选择。因此这类包声明它所针对 +的实现: + +```toml +requires = ["mcpp:c++-abi=libstdc++"] +``` + +工具链解析出另一个 `c++-abi` 的工程随后会被拒绝,拒绝信息同时指出两个实现,而不是在链接时失败。这项检查 +在工具链解析之后进行,而工具链在依赖图安装之后才解析,因此检查时安装钩子已经运行过;钩子收到本次构建的 +目标,但收不到工具链的取值([32 —— 编写载荷](32-authoring-a-payload.md))。钩子不得把另一种变体构建进 +同一个存储目录,否则第一个消费者就会替之后所有消费者决定变体。 + ### 标准库模块源 作为标准库的包陈述它的 `std` 模块源在何处,以及该源需要什么。 @@ -420,6 +433,48 @@ C 库,那时解析出的 `c-abi` 就不是这里返回的东西。要按已解 这一段在 2026.9.1.1 之前写的是「解析到的是哪份 C 库」,那是两者里错的那一个。 参见[40 —— 裸机与 freestanding 目标](40-baremetal.md)。 +### `abi` —— 整个产物共享的开关(mcpp 2026.9.12.2+) + +```toml +[target.'cfg(os = "emscripten")'.abi] +threads = true +``` + +目标的某些性质不是单个翻译单元可以自行选择的 flag。线程支持即是一例:在 WebAssembly 上,每个目标文件、 +预编译的标准库模块与链接必须在共享内存与原子操作上保持一致,只要有一个翻译单元未启用它们,链接就会失败, +或模块拒绝加载。这类性质写作 `[target..abi]` 的有类型成员,而不是 `cxxflags` 中的 flag, +引擎因此能把它施加到每个必须一致的单元上,并把它与包的需求相比较。 + +| 成员 | 类型 | 渲染为 | 到达 | +|---|---|---|---| +| `threads` | 布尔 | 在既非 PE 也非 freestanding 的目标上为 `-pthread`;在 PE 与 freestanding 目标上不产生任何 flag | 标准库模块的预构建、依赖扫描、所有包的每个 C 与 C++ 翻译单元,以及链接 | + +该成员经由方言 flag 进入依赖缓存键,因此未启用线程时构建的依赖不会被启用线程的构建复用。未知成员,以及 +不是布尔值的 `threads`,都会被拒绝。 + +**只有根 manifest 做决定。** 这个开关属于产物,而根包是唯一构建产物的包。依赖写下的 +`[target..abi]` 会被报告(`abi/dependency-table`),且不改变任何东西。依赖改为声明自己的需求: + +```toml +[package] +requires_abi = { threads = true } # 整个包需要线程 + +[features] +mt = { requires_abi = { threads = true } } # 只有这个 feature 需要线程 +``` + +根包未满足的需求在任何编译开始之前被拒绝,拒绝信息指出包名以及提出需求的对象: + +``` +error: `wasmrt` requires the artefact's ABI to have threads (feature `mt`), and this build does not state it. + Add to the root manifest, for the targets that need it: + + [target.'cfg(os = "")'.abi] + threads = true +``` + +若没有这项拒绝,不匹配会表现为预编译模块的配置错误,而该错误既不指出包,也不指出开关。 + ## 当前边界 - **依赖不能以加速器为条件。** 加速器这一层是从依赖图解析出来的,因此由它选择的依赖 diff --git a/docs/zh/30-build-mcpp.md b/docs/zh/30-build-mcpp.md index 2552d352f..befce0504 100644 --- a/docs/zh/30-build-mcpp.md +++ b/docs/zh/30-build-mcpp.md @@ -61,6 +61,8 @@ mcpp build # 编译 + 运行 build.mcpp,然后构建工程 | `mcpp:include-dir-after=` *(0.0.100+)* | 同 `include-dir`,但排在系统目录**之后**搜索(`-idirafter`)——用于会遮蔽系统头的 payload 源树 | | `mcpp:runner=` *(2026.8.19.2+)* | 执行本次构建产物的命令的**一个 argv token**(宿主跑不了它时)。一个 token 一次调用、按顺序;产物路径会被追加(或替换 `{}`)。**到达消费者**。可执行文件要发**绝对路径**,且**只能有一个**依赖提供它 | | `mcpp:link-flag=` *(2026.9.6.5+)* | 加一条本程序**算出来的**链接标志,原样传递。这是 `link-lib` / `link-search` / `link-script` 各自命名一类东西之后留下的出口:生成的版本脚本(`-Wl,--version-script=`)、运行时接管 C 库符号用的 `-Wl,--wrap=malloc`、以及 `-Wl,--exclude-libs,ALL`(静态吞入的第三方不得成为本包 ABI 的一部分)。按发出顺序追加在 `[build] ldflags` 之后。**到达消费者**,与 `[build] ldflags` 一致 —— 理由见下 | +| `mcpp:windows-subsystem=:` *(2026.9.12.2+)* | 设置**本包**可执行目标 `` 的 PE 子系统(`console` 或 `windows`),与 `[targets.] windows_subsystem`(docs/04)是同一字段。只到达该目标的链接,不到达其他目标或消费者,在非 PE 目标上不产生任何标志。本包未以 `kind = "bin"` 声明该目标、取值不在集合内、取值与 mcpp.toml 的声明矛盾,这三种情形都在应用任何指令之前被拒绝 | +| `mcpp:windows-entry=:` *(2026.9.12.2+)* | 设置可执行目标 `` 的入口函数(`main`、`wmain`、`WinMain` 或 `wWinMain`),与 `windows_entry` 是同一字段;作用域与拒绝条件同 `windows-subsystem` | | `mcpp:link-script=` *(2026.8.19+)* | 用这个**链接脚本**链接(`-T`;相对路径按包根解析,发出的是绝对路径,因为链接是在构建目录里跑的)。与 `include-dir` 不同,它**到达消费者** —— 板子的内存布局恰恰是消费者写不出来的那一项 | | `mcpp:warning=` *(2026.8.21.2+)* | 对用户说一句话并**继续**。唯一一条不改变编译行、链接行与源码集的指令。它**穿过构建缓存** —— 见下 | | `mcpp:fact==` *(2026.9.5.2+)* | 陈述程序**测得的机器事实**(`cuda.driver=12.4`)。在编译任何东西之前与 floor 比较;见下 | @@ -118,6 +120,7 @@ int main() { | `mcpp::rerun_if_changed_glob(pat)` *(2026.8.6.2+)* | `mcpp:rerun-if-changed-glob=` —— 匹配 `pat` 的文件**集合**发生变化时重跑(见下) | | `mcpp::dep_bin(pkg, tool)` *(2026.8.5.1+)* | 读 `MCPP_DEP__BIN_` —— 依赖构建出的 **host 工具**的绝对路径(见下) | | `mcpp::link_flag(s)` *(2026.9.6.5+)* | `mcpp:link-flag=` | +| `mcpp::windows_subsystem(target, value)` / `mcpp::windows_entry(target, value)` *(2026.9.12.2+)* | `mcpp:windows-subsystem=` / `mcpp:windows-entry=` | | `mcpp::link_script(p)` *(2026.8.19+)* | `mcpp:link-script=` | | `mcpp::runner(tok)` *(2026.8.19.2+)* | `mcpp:runner=` —— 见下 | | `mcpp::xpkg_dir(ns, name)` / `mcpp::xpkg_dir(name)` *(2026.8.19+)* | `[xlings.workspace]` 里声明的包的载荷目录 —— 本 manifest 声明的,或编进本构建程序的某个依赖声明的(2026.9.6.6+);没声明或没安装时返回 `""`(见下) | diff --git a/docs/zh/32-authoring-a-payload.md b/docs/zh/32-authoring-a-payload.md index 15b1ef24b..58cc4a4ed 100644 --- a/docs/zh/32-authoring-a-payload.md +++ b/docs/zh/32-authoring-a-payload.md @@ -64,6 +64,27 @@ end `install()` 把解开的目录树放到 mcpp 会去找的位置;`config()` 登记这个载荷提供什么。 本章其余内容,都是这两个函数在多做一些事。 +### 安装钩子收到的环境(mcpp 2026.9.12.2+) + +mcpp 安装工程所依赖的包时,该包的 `install()` 在环境中收到本次构建的目标,变量名与规则同构建程序一致 +([build.mcpp](30-build-mcpp.md)):每个变量都显式写出,没有取值时为空,因此钩子不会读到从启动 mcpp 的 +进程继承来的值。 + +| 变量 | 依赖安装时的取值 | +|---|---| +| `MCPP_TARGET` | 本次构建所请求的目标三元组;原生构建时为宿主三元组 | +| `MCPP_TARGET_OS`、`MCPP_TARGET_ARCH`、`MCPP_TARGET_ENV` | 该三元组的各段 | +| `MCPP_COMPILER`、`MCPP_CXX_STDLIB` | 空 | + +工具链的取值为空,因为工具链在依赖图之后才解析:依赖图中的包可能提供目标侧的层,所以依赖安装时编译器与 +标准库都尚未确定,而猜测一个取值的钩子可能构建出错误的变体。在 Windows 上空变量即不存在的变量, +`os.getenv` 对它返回 `nil`。工具链载荷的钩子不会收到这些变量。 + +钩子可以用目标来拒绝或给出诊断,但不得把某种变体构建进名称未体现该变体的存储目录:存储目录按包名与版本 +区分,否则第一个消费者就会替之后所有消费者决定变体。针对某一个 C++ 标准库编译的包应以 +`requires = ["mcpp:c++-abi=libstdc++"]` 陈述这一点,该需求在工具链解析之后检查 +([22 —— 目标侧](22-target-side.md))。 + ## 描述符必须做对的四件事 **一个版本,两个 URL。** 每个版本都带 `GLOBAL` 与 `CN` 两个 URL 和一个 `sha256`。 diff --git a/examples/13-platform-targets/README.md b/examples/13-platform-targets/README.md index e152179a8..bb1fbe160 100644 --- a/examples/13-platform-targets/README.md +++ b/examples/13-platform-targets/README.md @@ -25,7 +25,7 @@ node bin/platform-targets -> 1-2-3 运行用的 `node` 取自 `xim:emsdk` 声明的依赖 `xim:node`,而不是 PATH 上的某一个。载荷在 `.mcpp-toolchain.json` 里用 `runner` 写出它,项目与依赖图都没有声明 runner 时 mcpp 使用 -它。这需要 mcpp 2026.9.12.1,以及在配方更新之后安装的 emsdk 载荷;更早安装的载荷没有 +它。这需要 mcpp 2026.9.12.2,以及在配方更新之后安装的 emsdk 载荷;更早安装的载荷没有 描述文件,仍按产物首行的 `#!/usr/bin/env node` 取 PATH 上的 `node`。 `mcpp run` 会用 `node` 跑它,所以不需要额外的一步。工程侧**一个新词汇都不需要**: @@ -142,15 +142,17 @@ runner = ["simctl-run"] `arm64-apple-ios18.0-simulator`,这是 Apple 自己的拼法。不发 `-miphoneos-version-min`:三元组已经说过了,而一个标志会成为第二个说它的地方。 -`simctl-run` 来自 `xim:apple-simulator-tools`,需要先装: +`simctl-run` 来自 `xim:apple-simulator-tools`,声明在使用它的那一行旁边: -```bash -xlings install apple-simulator-tools +```toml +[target.aarch64-ios-sim.xlings.workspace] +"xim:apple-simulator-tools" = "" ``` -这一步没有写进清单,而这是一处**限制**而不是一个选择:`deps` 不按目标条件化, -而把它写在顶层会让这个例子的 Linux 构建依赖一个只为 macOS 存在的包 —— 两条都实测 -过,`mcpp.toml` 里记着那两条消息。 +目标段下的 `xlings.workspace` 只在构建该目标时安装,所以这个例子的 Linux、Android +与 Web 行都不会去要一个只为 macOS 存在的包。此前这里写的是"需要先手动安装",依据是 +`[target.aarch64-ios-sim.xlings] does not accept 'deps'` 这条拒绝;拒绝是真的,结论 +不是:`deps` 是不带条件的写法,目标段接受的是 `workspace`。 runner 是一个 argv 前缀,而一次**会话**不是:挑一台设备、启动、等待、spawn、把程序 自己的退出状态返回 —— 清单里的一行没有开始也没有结束,这就是那部分知识住在一个包里 diff --git a/examples/13-platform-targets/mcpp.toml b/examples/13-platform-targets/mcpp.toml index 09ac4a09a..96ed843b5 100644 --- a/examples/13-platform-targets/mcpp.toml +++ b/examples/13-platform-targets/mcpp.toml @@ -47,24 +47,19 @@ runner = ["simctl-run"] [target.x86_64-ios-sim] runner = ["simctl-run"] -# THE RUNNER'S PROGRAM IS NOT DECLARED HERE, AND THAT IS A LIMITATION RATHER -# THAN A CHOICE. +# THE RUNNER'S PROGRAM IS DECLARED BESIDE THE ROWS THAT USE IT. # -# `simctl-run` comes from `xim:apple-simulator-tools`, and the natural place to -# say so is beside the row that uses it. That does not exist: `deps` is not -# conditional on a target, and declaring it at the top level breaks every other -# row on every other host. Both measured: +# `simctl-run` comes from `xim:apple-simulator-tools`, a package that exists for +# macOS alone. Declared under a target's `xlings.workspace`, it is installed +# only when that target is built, so the Linux, Android and Web rows of this +# example never ask for it. # -# error: [target.aarch64-ios-sim.xlings] does not accept 'deps'. Only -# `workspace` is conditional on a target -# error: package 'xim:apple-simulator-tools' has no build for linux -# (available on: macosx) -# -# The second is the sharper one: this example exists to build for several -# platforms from one source, and an unconditional tool declaration makes the -# Linux build depend on a package that only exists for macOS. -# -# So the program is installed by whoever runs the simulator rows -- -# `xlings install apple-simulator-tools` -- and mcpp's runner lookup finds it -# on PATH. See the README. A per-target tool axis would remove the step, and it -# is recorded rather than worked around. +# An earlier version of this file said the declaration was impossible, citing +# `[target.aarch64-ios-sim.xlings] does not accept 'deps'`. The refusal was +# real and the conclusion was not: `deps` is the unconditional spelling, and +# `workspace` is the one a target section accepts. +[target.aarch64-ios-sim.xlings.workspace] +"xim:apple-simulator-tools" = "" + +[target.x86_64-ios-sim.xlings.workspace] +"xim:apple-simulator-tools" = "" diff --git a/mcpp.toml b/mcpp.toml index 1538eacd0..704cae494 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.9.12.1" +version = "2026.9.12.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/buildmcpp/src/directives.cppm b/modules/buildmcpp/src/directives.cppm index 5dc9dc742..ad901ca33 100644 --- a/modules/buildmcpp/src/directives.cppm +++ b/modules/buildmcpp/src/directives.cppm @@ -150,6 +150,11 @@ enum class Slot : std::size_t { // empty. See `mcpp::provides_pack_format` for the author-facing half of the // same rule -- declare unconditionally, submit conditionally. PackFormats, + // A NAMED EXECUTABLE'S PE SUBSYSTEM AND ENTRY (#618), as `:`. + // Two slots rather than one parsed pair, because each is a field of + // `manifest::Target` with its own set of accepted values. + WindowsSubsystem, + WindowsEntry, Count }; inline constexpr std::size_t kSlotCount = static_cast(Slot::Count); @@ -197,6 +202,14 @@ enum class Scope { // machine must also declare what would change it (`rerun_if_changed` on // the file the fact was read from), or the fact outlives the machine. Claim, + // REACHES THE LINK OF ONE TARGET OF THIS PACKAGE, NAMED IN THE VALUE. + // + // Not `LinkGlobal`, which reaches every consumer's link, and not + // `PackagePrivate`, which reaches this package's translation units and no + // link at all. A subsystem is a property of one executable: applied to a + // consumer, a test binary or a second executable of the same package, it is + // the defect #618 reports against `[build] ldflags`. + TargetLink, }; // How the raw wire value is normalized before it is stored. Applied ONCE, at @@ -232,7 +245,7 @@ struct Def { int sinceProtocol; }; -inline constexpr std::array kTable{{ +inline constexpr std::array kTable{{ // wire tag slot scope transform must missingPrefix missingSuffix since {"cxxflag", "cxxflag", Slot::CxxFlags, Scope::PackagePrivate, Transform::Verbatim, false, "", "", 1}, {"cflag", "cflag", Slot::CFlags, Scope::PackagePrivate, Transform::Verbatim, false, "", "", 1}, @@ -365,6 +378,12 @@ inline constexpr std::array kTable{{ // newer entry already discards the whole record through the unknown-tag // path. {"pack-format", "pack-format", Slot::PackFormats, Scope::Claim, Transform::Verbatim, false, "", "", 9}, + // v10 (#618). The value names a target, and `target_directive_error` + // refuses a name this package does not declare as an executable before + // anything is applied. Persisted like every row but the re-run keys, so a + // cached run applies what the program said. + {"windows-subsystem", "windows-subsystem", Slot::WindowsSubsystem, Scope::TargetLink, Transform::Verbatim, false, "", "", 10}, + {"windows-entry", "windows-entry", Slot::WindowsEntry, Scope::TargetLink, Transform::Verbatim, false, "", "", 10}, }}; // ── Collected output of one run ──────────────────────────────────────────── @@ -485,6 +504,14 @@ std::optional decode_action(std::string_view payloa // worse than none. std::string action_error(const Directives& d); +// Non-empty when a `windows-subsystem` or `windows-entry` directive names +// something `apply` cannot honour: a value that is not `:`, a +// value outside the accepted set, a target this package does not declare, a +// target that is not an executable, or a value that contradicts mcpp.toml or an +// earlier directive of the same program. Checked before `apply` on both the run +// path and the cache-hit path, so the two apply one rule. +std::string target_directive_error(const mcpp::manifest::Manifest& m, const Directives& d); + // Resolve an action's paths against `pkgRoot` and make its Source outputs // exist, so the ordinary source scan can see them. // @@ -849,6 +876,26 @@ void apply(mcpp::manifest::Manifest& m, const Directives& d) { for (auto const& f : d.at(Slot::PackFormats)) bc.packFormats.push_back(f); + // A named executable's subsystem and entry. `target_directive_error` has + // refused every value that names no executable, so the conditions below + // only keep this function total. + for (auto const& entry : d.at(Slot::WindowsSubsystem)) { + const auto sep = entry.rfind(':'); + if (sep == std::string::npos) continue; + const auto name = entry.substr(0, sep); + for (auto& t : m.targets) + if (t.name == name && t.kind == mcpp::manifest::Target::Binary) + t.windowsSubsystem = entry.substr(sep + 1); + } + for (auto const& entry : d.at(Slot::WindowsEntry)) { + const auto sep = entry.rfind(':'); + if (sep == std::string::npos) continue; + const auto name = entry.substr(0, sep); + for (auto& t : m.targets) + if (t.name == name && t.kind == mcpp::manifest::Target::Binary) + t.windowsEntry = entry.substr(sep + 1); + } + // Build-graph nodes. Decoded here rather than at parse time so the cache // stores the payload verbatim and a replay is byte-identical to a run. for (auto const& payload : d.at(Slot::Actions)) { @@ -888,6 +935,71 @@ std::optional decode_action(std::string_view payloa } } +// One of the two named-target directives; `subsystem` selects the field. +static std::string named_target_error(const mcpp::manifest::Manifest& m, + const std::vector& entries, + std::string_view wire, std::string_view key, + bool subsystem) { + std::map stated; // target name -> value + for (auto const& entry : entries) { + const auto sep = entry.rfind(':'); + if (sep == std::string::npos || sep == 0 || sep + 1 == entry.size()) + return std::format( + "build.mcpp emitted `mcpp:{}={}`, which is not `:`.", + wire, entry); + const std::string name = entry.substr(0, sep); + const std::string value = entry.substr(sep + 1); + if (auto list = mcpp::manifest::windows_choice_problem(subsystem, value); + !list.empty()) + return std::format( + "build.mcpp emitted `mcpp:{}={}`, and \"{}\" is not one of {}.", + wire, entry, value, list); + const mcpp::manifest::Target* target = nullptr; + for (auto const& t : m.targets) + if (t.name == name) { target = &t; break; } + if (target == nullptr) { + std::string names; + for (auto const& t : m.targets) + names += (names.empty() ? "" : ", ") + t.name; + return std::format( + "build.mcpp emitted `mcpp:{}={}`, and package `{}` declares no " + "target named `{}` (its targets: {}).", + wire, entry, m.package.name, name, + names.empty() ? std::string("none") : names); + } + if (target->kind != mcpp::manifest::Target::Binary) + return std::format( + "build.mcpp emitted `mcpp:{}={}`, and `{}` applies to an executable " + "(`kind = \"bin\"`); target `{}` is not one.", + wire, entry, key, name); + const std::string& declared = + subsystem ? target->windowsSubsystem : target->windowsEntry; + if (!declared.empty() && declared != value) + return std::format( + "build.mcpp emitted `mcpp:{}={}`, and mcpp.toml declares " + "`[targets.{}] {} = \"{}\"`. One of the two has to change.", + wire, entry, name, key, declared); + auto found = stated.find(name); + if (found == stated.end()) + stated.emplace(name, value); + else if (found->second != value) + return std::format( + "build.mcpp emitted `mcpp:{}` twice for target `{}`, as \"{}\" and " + "as \"{}\".", + wire, name, found->second, value); + } + return {}; +} + +std::string target_directive_error(const mcpp::manifest::Manifest& m, const Directives& d) { + if (auto e = named_target_error(m, d.at(Slot::WindowsSubsystem), + "windows-subsystem", "windows_subsystem", true); + !e.empty()) + return e; + return named_target_error(m, d.at(Slot::WindowsEntry), + "windows-entry", "windows_entry", false); +} + std::string action_error(const Directives& d) { for (auto const& payload : d.at(Slot::Actions)) { // The typed API sets this when an argv did not fit its fixed buffer. diff --git a/modules/buildmcpp/src/program_protocol.cppm b/modules/buildmcpp/src/program_protocol.cppm index 501374f12..2c273578a 100644 --- a/modules/buildmcpp/src/program_protocol.cppm +++ b/modules/buildmcpp/src/program_protocol.cppm @@ -71,7 +71,12 @@ export namespace mcpp::build::program_protocol { // to it. Same cost as v5's: a package calling `mcpp::provides_pack_format()` // fails on an older engine at the build.mcpp COMPILE, because that engine's // bundled module has no such function. -inline constexpr int kProtocolVersion = 9; +// v10: adds `windows-subsystem` and `windows-entry` -- a named executable's PE +// subsystem and entry function (#618), the build-program form of the +// `[targets.]` keys of the same names. Same cost as v5's: a package +// calling `mcpp::windows_subsystem()` fails on an older engine at the +// build.mcpp COMPILE, because that engine's bundled module has no such function. +inline constexpr int kProtocolVersion = 10; // ── Cache-format epoch ───────────────────────────────────────────────────── // diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 220d15488..f3a3b8599 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -506,6 +506,9 @@ std::expected parse_string(std::string_view content, "target.*.build.flags", // #258 — middle segment is the cfg predicate "runtime.requirements", "runtime.artifacts", + // #615: `deploy = [{ from = "...", to = "..." }]`. The reader refuses + // every entry that is not a table of exactly those two strings. + "runtime.deploy", // #544: `deps = [{ linux = "..." }]` — every entry a per-platform // table — is the same Value shape as `[[xlings.deps]]`, and the guard // cannot tell the inline form from the doubled-bracket typo. The @@ -518,7 +521,7 @@ std::expected parse_string(std::string_view content, "[[{}]] (array-of-tables) is not allowed for section '{}'; " "array-of-tables syntax is only supported for [[build.flags]], " "[[features..flags]], [[runtime.requirements]], " - "[[runtime.artifacts]], and [xlings] deps entries", + "[[runtime.artifacts]], [[runtime.deploy]], and [xlings] deps entries", *badPath, *badPath))); } @@ -759,6 +762,21 @@ std::expected parse_string(std::string_view content, read_str_array(ft, "requires", reqs); read_str_array(ft, "provides", provs); if (!reqs.empty()) m.featureRequires[fname] = std::move(reqs); + // `requires_abi = { threads = true }`: this feature needs the + // artefact's ABI switch on. See Manifest::featureRequiresAbiThreads. + if (auto rait = ft.find("requires_abi"); rait != ft.end()) { + if (!rait->second.is_table()) + return std::unexpected(error(origin, std::format( + "features.{}.requires_abi must be a table such as " + "`{{ threads = true }}`", fname))); + for (auto& [ak, av] : rait->second.as_table()) { + if (ak != "threads" || !av.is_bool()) + return std::unexpected(error(origin, std::format( + "features.{}.requires_abi.{}: the members are " + "`threads`, a boolean", fname, ak))); + m.featureRequiresAbiThreads[fname] = av.as_bool(); + } + } if (!provs.empty()) m.featureProvides[fname] = std::move(provs); // The device extensions this feature's rule compiles. Normalised // the same way `module_extensions` is, so `comp` and `.comp` are @@ -838,7 +856,7 @@ std::expected parse_string(std::string_view content, if (fval.is_table()) { static constexpr std::string_view kKnownFeatureKeys[] = { "defines", "flags", "forward", "implies", "provides", - "requires", "sources", + "requires", "requires_abi", "sources", // THE TWO RULE-PACKAGE KEYS, WHICH THIS PARSER READS ABOUT // FORTY LINES ABOVE AND THEN REPORTED AS UNSUPPORTED. // @@ -927,6 +945,18 @@ std::expected parse_string(std::string_view content, m.unknownCapabilities.push_back(entry); m.requires_ = *v; } + // [package] requires_abi -- see Manifest::requiresAbiThreads. + if (auto* ra = doc->get("package.requires_abi")) { + if (!ra->is_table()) + return std::unexpected(error(origin, + "[package] requires_abi must be a table such as `{ threads = true }`")); + for (auto& [ak, av] : ra->as_table()) { + if (ak != "threads" || !av.is_bool()) + return std::unexpected(error(origin, std::format( + "[package] requires_abi.{}: the members are `threads`, a boolean", ak))); + m.requiresAbiThreads = av.as_bool(); + } + } // [package] exclusive — capabilities this package claims sole provision of. // // Not validated against the reserved prefix: exclusivity is a property of @@ -1167,6 +1197,35 @@ std::expected parse_string(std::string_view content, read_list("cxxflags", t.cxxflags); read_list("defines", t.defines); read_list("required_features", t.requiredFeatures); + // `windows_subsystem` / `windows_entry` (#618). A closed set each, so a + // misspelling is refused by name rather than rendered as nothing. + auto read_choice = [&](const char* key, std::string& out, bool subsystem) + -> std::expected { + auto it = tt.find(key); + if (it == tt.end()) return {}; + if (!it->second.is_string()) + return std::unexpected(error(origin, std::format( + "targets.{}.{} must be a string", tname, key))); + const std::string v = it->second.as_string(); + if (auto list = windows_choice_problem(subsystem, v); !list.empty()) + return std::unexpected(error(origin, std::format( + "targets.{}.{} = \"{}\" is not one of {}", tname, key, v, list))); + out = v; + return {}; + }; + if (auto r = read_choice("windows_subsystem", t.windowsSubsystem, true); !r) + return std::unexpected(r.error()); + if (auto r = read_choice("windows_entry", t.windowsEntry, false); !r) + return std::unexpected(r.error()); + // An executable's property. A library has no subsystem, and a GUI + // subsystem on anything a test runner executes is the defect #618 + // describes, so both are refused naming the key. + if ((!t.windowsSubsystem.empty() || !t.windowsEntry.empty()) + && t.kind != Target::Binary) + return std::unexpected(error(origin, std::format( + "targets.{}.{} applies to an executable (`kind = \"bin\"`), and this " + "target is not one", tname, + t.windowsSubsystem.empty() ? "windows_entry" : "windows_subsystem"))); // Guard: -std=... belongs to [package].standard, not per-target flags // (same rule as [build].cxxflags). Reject early with a clear message. for (auto const& flag : t.cxxflags) { @@ -1185,6 +1244,7 @@ std::expected parse_string(std::string_view content, static constexpr std::string_view kKnownTargetKeys[] = { "kind", "main", "soname", "exports", "cflags", "cxxflags", "defines", "required_features", + "windows_entry", "windows_subsystem", }; for (auto& [key, _] : tt) { bool known = false; @@ -1192,10 +1252,17 @@ std::expected parse_string(std::string_view content, if (!known) { m.schemaWarnings.push_back(std::format( "[targets.{}] has unsupported key '{}' (ignored). Per-target keys: " - "kind, main, soname, cflags, cxxflags, defines, required_features. " - "For config that must affect shared code, split into a workspace " + "{}. For config that must affect shared code, split into a workspace " "member or use [features]; for a whole-build mode use [profile.*].", - tname, key)); + tname, key, [] { + // THE LIST IN THE MESSAGE IS THE LIST ABOVE; the + // hand-written copy it replaces had fallen behind by + // `exports`. + std::string s; + for (auto k : kKnownTargetKeys) + s += (s.empty() ? "" : ", ") + std::string(k); + return s; + }())); } } m.targets.push_back(std::move(t)); @@ -2038,6 +2105,38 @@ std::expected parse_string(std::string_view content, out = it->second.as_string(); return true; }; + // `runtime.deploy` (#615): `{ from, to }` tables. A key of its own rather + // than a table form of `deploy_files`; see manifest::DeployEntry and the + // matching branch of the descriptor reader. + if (auto* deploy = doc->get("runtime.deploy")) { + if (!deploy->is_array()) + return std::unexpected(error(origin, + "runtime.deploy must be an array of `{ from = \"...\", to = \"...\" }` tables")); + std::size_t index = 0; + for (auto const& value : deploy->as_array()) { + ++index; + if (!value.is_table()) + return std::unexpected(error(origin, std::format( + "runtime.deploy[{}] must be a table with `from` and `to`", index))); + auto const& table = value.as_table(); + for (auto const& [key, _] : table) + if (key != "from" && key != "to") + return std::unexpected(error(origin, std::format( + "runtime.deploy[{}] has unsupported key '{}'; the keys are " + "`from` and `to`", index, key))); + std::string from, to; + if (!table_string(table, "from", from) || !table_string(table, "to", to)) + return std::unexpected(error(origin, std::format( + "runtime.deploy[{}]: `from` and `to` must be strings", index))); + if (auto p = deploy_path_problem("from", from, false); !p.empty()) + return std::unexpected(error(origin, + std::format("runtime.deploy[{}]: {}", index, p))); + if (auto p = deploy_path_problem("to", to, true); !p.empty()) + return std::unexpected(error(origin, + std::format("runtime.deploy[{}]: {}", index, p))); + m.runtimeConfig.linkIntent.deploy.push_back({from, to}); + } + } if (auto* requirements = doc->get("runtime.requirements")) { if (!requirements->is_array()) { return std::unexpected(error(origin, @@ -2154,7 +2253,7 @@ std::expected parse_string(std::string_view content, // gives: they are a channel, not a typo. Here every `[runtime.]` // names a capability whose spelling this file cannot know. static constexpr std::string_view kKnownRuntimeKeys[] = { - "artifacts", "capabilities", "deploy_files", "dlopen_libs", "frameworks", + "artifacts", "capabilities", "deploy", "deploy_files", "dlopen_libs", "frameworks", "libraries", "library_dirs", "link_library_dirs", "provides", "requirements", "runtime_search_dirs", "transitive_needed_dirs", }; @@ -2646,6 +2745,27 @@ std::expected parse_string(std::string_view content, // `[target..runtime]` — the dialect-neutral link intent. Two // keys only, and the same two `[runtime]` already has at the top // level: this makes them per-target, it does not invent a vocabulary. + // `[target..abi]` -- graph-wide ABI switches as typed members + // rather than flags (design 2026-09-12, section 5.2). One member + // today, and an unknown member is refused, so the table cannot + // become a second flag list. + if (auto ait = body.find("abi"); ait != body.end()) { + if (!ait->second.is_table()) + return std::unexpected(error(origin, std::format( + "[target.{}].abi must be a table, e.g. `[target.{}.abi]` " + "with `threads = true`", triple, triple))); + for (auto& [ak, av] : ait->second.as_table()) { + if (ak != "threads") + return std::unexpected(error(origin, std::format( + "[target.{}.abi] has no member '{}'; the members are: " + "threads", triple, ak))); + if (!av.is_bool()) + return std::unexpected(error(origin, std::format( + "[target.{}.abi].threads must be true or false", triple))); + cc.abiThreads = av.as_bool(); + cc.abiThreadsDeclared = true; + } + } if (auto rit = body.find("runtime"); rit != body.end() && rit->second.is_table()) { auto& rt = rit->second.as_table(); if (auto f = rt.find("link_library_dirs"); f != rt.end() && f->second.is_array()) @@ -2906,11 +3026,22 @@ std::expected parse_string(std::string_view content, // is one per project rather than one per target. Refused // rather than ignored: a silently dropped environment is // the failure #531 was filed for. + // + // AND THE REFUSAL NAMES THE TABLE THAT DOES ACCEPT A TOOL. + // Its first version said only "Only `workspace` is + // conditional on a target", and a reader who had written + // `deps` read that as "a tool cannot be declared per + // target" -- the 2026-09-11 record did, and recorded a gap + // that did not exist. `workspace` IS the per-target tool + // declaration: its entries join the same install list + // `deps` feeds, and only when this target is built. return std::unexpected(error(origin, std::format( - "[target.{}.xlings] does not accept '{}'. Only " - "`workspace` is conditional on a target; `subos` names " - "the project's environment and belongs in the " - "top-level [xlings].", triple, k))); + "[target.{}.xlings] does not accept '{}'. A tool this " + "target needs is declared under " + "[target.{}.xlings.workspace] as `\"\" = " + "\"\"`, and is installed only when this target " + "is built. `subos` names the project's environment and " + "belongs in the top-level [xlings].", triple, k, triple))); } } if (auto fit = body.find("feature-xlings"); diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index aa7aa29b1..d4cf0b9ba 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -112,6 +112,25 @@ struct Modules { std::map scanOverrides; }; +// The accepted values of `windows_subsystem` (`subsystem = true`) and +// `windows_entry` (#618), stated once and read by the manifest parser and by the +// build-program directive that sets the same fields, so the two cannot accept +// different sets. Returns an empty string when `value` is accepted, and +// otherwise the accepted values, quoted and comma-separated, for the refusal. +inline std::string windows_choice_problem(bool subsystem, std::string_view value) { + static constexpr std::string_view kSubsystems[] = {"console", "windows"}; + static constexpr std::string_view kEntries[] = {"main", "wmain", "WinMain", "wWinMain"}; + std::string list; + bool accepted = false; + auto consider = [&](std::string_view choice) { + if (choice == value) accepted = true; + list += (list.empty() ? "" : ", ") + std::format("\"{}\"", choice); + }; + if (subsystem) { for (auto choice : kSubsystems) consider(choice); } + else { for (auto choice : kEntries) consider(choice); } + return accepted ? std::string{} : list; +} + struct Target { std::string name; enum Kind { Library, Binary, SharedLibrary, TestBinary } kind; @@ -148,6 +167,13 @@ struct Target { // active in the current build (otherwise it is silently skipped). Gate // only — it does not activate features (use --features / [features].default). std::vector requiredFeatures; + // `windows_subsystem` and `windows_entry` (#618): a PE executable's + // subsystem ("console" | "windows") and the entry FUNCTION it defines + // ("main" | "wmain" | "WinMain" | "wWinMain"). Empty = the linker's default. + // Rendered per ABI onto this target's own link unit and nowhere else, and + // inert on every object format that is not PE. + std::string windowsSubsystem; + std::string windowsEntry; }; // `DependencySpec` and `kDefaultNamespace` have moved to mcpp.pm.dep_spec. @@ -768,6 +794,15 @@ struct BuildConfig : BuildInputs { // auto-promotion of known flags found in [build] cxxflags // (see dialect_flags()). std::vector dialectCxxflags; + // `[target..abi] threads` -- whether the artefact is built with + // POSIX threads. A GRAPH-WIDE ABI SWITCH, so only the ROOT's value is + // rendered: into the dialect flag set above (every C++ translation unit, + // the std module's own commands, the scan, every dependency's cache key), + // into every package's C flags, and into the link. A dependency states what + // it needs with `requires_abi` instead. Declared is kept apart from the + // value because `threads = false` is also a statement. + bool abiThreads = false; + bool abiThreadsDeclared = false; std::string cStandard; // Escape hatch for the hermetic link check: a sandbox toolchain whose // CRT/loader resolve OUTSIDE the sandbox is a hard error by default @@ -921,6 +956,43 @@ struct RuntimeArtifact { std::string hostFingerprint; }; +// `runtime.deploy` (#615): a file placed in a DIRECTORY RELATIVE TO THE +// EXECUTABLE, which `deploy_files` cannot express, because it flattens every +// entry into `bin/`. The Vulkan loader on macOS reads its driver +// manifest from `/vulkan/icd.d`, and a flattened copy is never +// found there. +struct DeployEntry { + std::filesystem::path from; // relative to the package root + std::string to; // relative to the executable's directory; "." is that directory +}; + +// One rule for a path a deploy entry names, checked on the string so that the +// answer is the same on every host; see the payload descriptor's `frontend` +// check for the measurement behind that choice. `dotAllowed` admits exactly +// ".", which `to` uses to mean "beside the executable". Returns the problem, or +// an empty string when there is none. +inline std::string deploy_path_problem(std::string_view field, std::string_view v, + bool dotAllowed) { + if (v.empty()) return std::format("`{}` is empty", field); + if (dotAllowed && v == ".") return {}; + if (v.find('\\') != std::string_view::npos) + return std::format("`{}` contains a backslash; the separator is `/` on every host", + field); + if (v.front() == '/') + return std::format("`{}` is absolute; it is a relative path", field); + if (v.find(':') != std::string_view::npos) + return std::format("`{}` names a drive or a scheme; it is a relative path", field); + for (std::size_t i = 0, n = 0; i <= v.size(); ++i) { + if (i != v.size() && v[i] != '/') { ++n; continue; } + const auto part = v.substr(i - n, n); + n = 0; + if (part.empty()) return std::format("`{}` has an empty path component", field); + if (part == "." || part == "..") + return std::format("`{}` has a `.` or `..` component", field); + } + return {}; +} + // Platform-neutral link intent. Platform spelling belongs to flags.cppm; // notably runtimeSearchDirs are not link-library search paths. struct LinkIntent { @@ -930,6 +1002,7 @@ struct LinkIntent { std::vector runtimeSearchDirs; std::vector frameworks; std::vector deployFiles; + std::vector deploy; // `runtime.deploy` (#615) }; // `[runtime]` — requirements needed when linking/launching built binaries. @@ -1197,6 +1270,10 @@ struct ConditionalConfig { // add to them. std::vector linkLibraryDirs; std::vector libraries; + // `[target..abi]` -- graph-wide ABI switches as typed members. See + // BuildConfig::abiThreads for what the value does and where it applies. + bool abiThreads = false; + bool abiThreadsDeclared = false; // Conditional dependencies (Phase 1b): merged into the corresponding // manifest maps in prepare_build when the predicate matches the resolved // target — before dependency resolution, so they resolve like any dep. @@ -1263,7 +1340,7 @@ inline bool is_empty(const ConditionalConfig& c) { return is_empty(c.inputs) && c.linkLibraryDirs.empty() && c.libraries.empty() && c.dependencies.empty() && c.devDependencies.empty() && c.buildDependencies.empty() && c.featureDeps.empty() - && c.xlings.empty(); + && c.xlings.empty() && !c.abiThreadsDeclared; } // `[lib]` — library "root" interface convention. @@ -1595,6 +1672,12 @@ struct Manifest { // through untouched, exactly as they do in `provides`. // The spelling is `requires_` because `requires` is a keyword. std::vector requires_; + // `requires_abi = { threads = true }` at package level, and per feature. + // A statement that the ARTEFACT's ABI has a switch on, compared at + // resolution with the root's `[target..abi]`. Only `threads` + // exists; a feature's entry counts only when that feature is active. + bool requiresAbiThreads = false; + std::map featureRequiresAbiThreads; // [package] exclusive — the capabilities this package claims it is the ONLY // provider of. // diff --git a/modules/manifest/src/xpkg.cppm b/modules/manifest/src/xpkg.cppm index 9f5d382d1..31d2f28d0 100644 --- a/modules/manifest/src/xpkg.cppm +++ b/modules/manifest/src/xpkg.cppm @@ -1702,6 +1702,32 @@ synthesize_from_xpkg_lua(std::string_view luaContent, cur.skip_ws_and_comments(); } cur.consume('}'); + } else if (sub == "requires_abi" && cur.peek() == '{') { + // `requires_abi = { threads = true }` -- see + // Manifest::featureRequiresAbiThreads. An older mcpp + // records this key as unknown and skips it. + auto abiBody = cur.read_table_body(); + LuaCursor ab{abiBody}; + ab.skip_ws_and_comments(); + while (!ab.eof()) { + auto ak = ab.read_key(); + if (ak.empty()) { + ab.skip_ws_and_comments(); + if (!ab.eof()) ++ab.pos; + continue; + } + if (!ab.consume('=')) break; + ab.skip_ws_and_comments(); + auto av = ab.read_bareword(); + if (ak != "threads" || (av != "true" && av != "false")) + return std::unexpected(ManifestError{ + std::format("features.{}.requires_abi.{}: the " + "members are `threads`, a boolean", + fname, ak), + m.sourcePath, 0, 0}); + m.featureRequiresAbiThreads[fname] = (av == "true"); + ab.skip_ws_and_comments(); + } } else { // Unknown subfield — skip its value, but RECORD it so // the adoption-site diagnostic (warn_unknown_xpkg_keys, @@ -2089,6 +2115,68 @@ synthesize_from_xpkg_lua(std::string_view luaContent, ? &m.runtimeConfig.linkIntent.runtimeSearchDirs : &m.runtimeConfig.linkIntent.deployFiles; for (auto& path : paths) destination->emplace_back(std::move(path)); + } else if (sub == "deploy") { + // `{ { from = "...", to = "..." }, ... }` (#615). A key of its + // own rather than a table form of `deploy_files`: an older + // mcpp reading `deploy_files` meets `{`, `read_string` returns + // without advancing, and that loop never ends; the same mcpp + // skips a `runtime` sub-key it does not know. See + // manifest::DeployEntry. + if (!rc.consume('{')) { + return std::unexpected(ManifestError{ + "expected '{' after `runtime.deploy =`", + m.sourcePath, 0, 0}); + } + rc.skip_ws_and_comments(); + std::size_t index = 0; + while (!rc.eof() && rc.peek() != '}') { + ++index; + if (rc.peek() != '{') { + return std::unexpected(ManifestError{ + std::format("runtime.deploy[{}] must be a table with " + "`from` and `to`", index), + m.sourcePath, 0, 0}); + } + auto entryBody = rc.read_table_body(); + LuaCursor entry{entryBody}; + std::string from, to; + entry.skip_ws_and_comments(); + while (!entry.eof()) { + auto field = entry.read_key(); + if (field.empty()) { + entry.skip_ws_and_comments(); + if (!entry.eof()) ++entry.pos; + continue; + } + if (!entry.consume('=')) { + return std::unexpected(ManifestError{ + std::format("runtime.deploy[{}].{} is malformed", + index, field), + m.sourcePath, 0, 0}); + } + if (field == "from") from = entry.read_string(); + else if (field == "to") to = entry.read_string(); + else { + return std::unexpected(ManifestError{ + std::format("runtime.deploy[{}] has unsupported key " + "'{}'; the keys are `from` and `to`", + index, field), + m.sourcePath, 0, 0}); + } + entry.skip_ws_and_comments(); + } + if (auto p = deploy_path_problem("from", from, false); !p.empty()) + return std::unexpected(ManifestError{ + std::format("runtime.deploy[{}]: {}", index, p), + m.sourcePath, 0, 0}); + if (auto p = deploy_path_problem("to", to, true); !p.empty()) + return std::unexpected(ManifestError{ + std::format("runtime.deploy[{}]: {}", index, p), + m.sourcePath, 0, 0}); + m.runtimeConfig.linkIntent.deploy.push_back({from, to}); + rc.skip_ws_and_comments(); + } + rc.consume('}'); } else if (sub == "frameworks") { if (auto r = read_string_list( m.runtimeConfig.linkIntent.frameworks); !r) @@ -2167,6 +2255,35 @@ synthesize_from_xpkg_lua(std::string_view luaContent, // spelling here is a known key: skip the table itself. if (cur.peek() == '{') cur.skip_table(); } + else if (key == "requires_abi") { + // `requires_abi = { threads = true }` -- see + // Manifest::requiresAbiThreads. An older mcpp skips an unknown + // top-level key and records it. + if (cur.peek() != '{') + return std::unexpected(ManifestError{ + "expected '{' after `requires_abi =`", m.sourcePath, 0, 0}); + auto abiBody = cur.read_table_body(); + LuaCursor ab{abiBody}; + ab.skip_ws_and_comments(); + while (!ab.eof()) { + auto ak = ab.read_key(); + if (ak.empty()) { + ab.skip_ws_and_comments(); + if (!ab.eof()) ++ab.pos; + continue; + } + if (!ab.consume('=')) break; + ab.skip_ws_and_comments(); + auto av = ab.read_bareword(); + if (ak != "threads" || (av != "true" && av != "false")) + return std::unexpected(ManifestError{ + std::format("requires_abi.{}: the members are `threads`, " + "a boolean", ak), + m.sourcePath, 0, 0}); + m.requiresAbiThreads = (av == "true"); + ab.skip_ws_and_comments(); + } + } else if (key == "schema") { // Descriptor schema tag (e.g. "0.1") — accepted, currently // informational only. diff --git a/modules/platform/src/env.cppm b/modules/platform/src/env.cppm index 7ac88fb1f..7b636bd29 100644 --- a/modules/platform/src/env.cppm +++ b/modules/platform/src/env.cppm @@ -23,6 +23,11 @@ std::optional get(std::string_view key); // Set an environment variable in the current process. void set(const std::string& key, const std::string& value); +// Remove an environment variable from the current process. On Windows this is +// `_putenv_s(key, "")`, which the CRT defines as removal, so absent and empty +// are one state there, and `get` answers nullopt for both on every platform. +void unset(const std::string& key); + // ── Network policy: offline mode ────────────────────────────────────────── // // One process-wide knob answering "may mcpp reach the network at all?", read @@ -122,6 +127,14 @@ void set(const std::string& key, const std::string& value) { #endif } +void unset(const std::string& key) { +#if defined(_WIN32) + _putenv_s(key.c_str(), ""); +#else + unsetenv(key.c_str()); +#endif +} + ScopedEnv::ScopedEnv(std::string key, std::optional value) : key_(std::move(key)) { if (auto* existing = std::getenv(key_.c_str())) { @@ -132,11 +145,7 @@ ScopedEnv::ScopedEnv(std::string key, std::optional value) if (value) { set(key_, *value); } else { -#if defined(_WIN32) - _putenv_s(key_.c_str(), ""); -#else - unsetenv(key_.c_str()); -#endif + unset(key_); } } @@ -144,11 +153,7 @@ ScopedEnv::~ScopedEnv() { if (had_previous_ && previous_) { set(key_, *previous_); } else { -#if defined(_WIN32) - _putenv_s(key_.c_str(), ""); -#else - unsetenv(key_.c_str()); -#endif + unset(key_); } } diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index c894851dd..2f33b48d1 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.12.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.9.12.2"; } // namespace mcpp diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index 90758a08a..31089549f 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -328,6 +328,15 @@ bool mentions_missing_mcpp_api(std::string_view compilerOutput); // the program always compiles AND runs on the host) and apply its directives to // `m.buildConfig`. `tc` supplies the sysroot / runtime flags a fresh sandbox // needs to compile + link a freestanding host program. No-op when absent. +// THE PART OF A BUILD PROGRAM'S ENVIRONMENT AN INSTALL HOOK ALSO RECEIVES +// (#613): `MCPP_COMPILER`, `MCPP_CXX_STDLIB`, `MCPP_TARGET` and its three +// segments, in that order. One function computes them for both, so a hook and a +// build program cannot be told different things about one build. Every value is +// present, and empty when it does not apply; `MCPP_TARGET` is the host triple +// when `env.targetTriple` is empty. +std::vector> +install_hook_env(const BuildProgramEnv& env); + std::expected run_build_program( mcpp::manifest::Manifest& m, const std::filesystem::path& root, @@ -502,7 +511,16 @@ std::vector> contract_env(const fs::path& root, const fs::path& outDir, const BuildProgramEnv& env) { std::vector> e; auto hostT = mcpp::toolchain::triple::host_triple().str(); - e.emplace_back("MCPP_TARGET", env.targetTriple.empty() ? hostT : env.targetTriple); + // The toolchain and target names an install hook also receives, from the + // one function that computes them for both (#613). Emitted in the order + // they always had, so the re-run key of an existing program is unchanged. + const auto shared = install_hook_env(env); + auto shared_value = [&](std::string_view key) { + for (auto const& [k, v] : shared) + if (k == key) return v; + return std::string{}; + }; + e.emplace_back("MCPP_TARGET", shared_value("MCPP_TARGET")); // THE SAME VALUE UNFILLED — EMPTY WHEN NOBODY NAMED A TARGET. // // `MCPP_TARGET` above answers "which machine is this for", and filling it @@ -529,19 +547,14 @@ contract_env(const fs::path& root, const fs::path& outDir, const BuildProgramEnv // link and a package should supply nothing. e.emplace_back("MCPP_TARGET_REQUESTED", env.targetTriple); // Convenience splits of the resolved target (Cargo CARGO_CFG_TARGET_* - // parity): parsed ONCE here through the canonical triple parser so every + // parity): parsed ONCE, in install_hook_env, through the canonical triple parser so every // build.mcpp stops hand-splitting MCPP_TARGET. MCPP_TARGET_ENV is "" when // the triple has no env segment (macOS); all three are "" for an // escape-hatch triple outside the canonical vocabulary. They ride the // same env vector, so contract_hash folds them into the re-run key. - { - mcpp::toolchain::triple::Triple t{}; - if (env.targetTriple.empty()) t = mcpp::toolchain::triple::host_triple(); - else if (auto p = mcpp::toolchain::triple::parse(env.targetTriple)) t = *p; - e.emplace_back("MCPP_TARGET_OS", t.os); - e.emplace_back("MCPP_TARGET_ARCH", t.arch); - e.emplace_back("MCPP_TARGET_ENV", t.env); - } + e.emplace_back("MCPP_TARGET_OS", shared_value("MCPP_TARGET_OS")); + e.emplace_back("MCPP_TARGET_ARCH", shared_value("MCPP_TARGET_ARCH")); + e.emplace_back("MCPP_TARGET_ENV", shared_value("MCPP_TARGET_ENV")); e.emplace_back("MCPP_HOST", hostT); // Always emitted, empty when they do not apply: a build program reads // these through `env_or`, which cannot tell "absent" from "empty", and an @@ -550,8 +563,8 @@ contract_env(const fs::path& root, const fs::path& outDir, const BuildProgramEnv e.emplace_back("MCPP_TOOLCHAIN_DIR", env.toolchainDir); e.emplace_back("MCPP_TOOLCHAIN_SYSROOT", env.toolchainSysroot); e.emplace_back("MCPP_TOOLCHAIN_BINUTILS_DIR", env.toolchainBinutilsDir); - e.emplace_back("MCPP_COMPILER", env.compilerId); - e.emplace_back("MCPP_CXX_STDLIB", env.cxxStdlib); + e.emplace_back("MCPP_COMPILER", shared_value("MCPP_COMPILER")); + e.emplace_back("MCPP_CXX_STDLIB", shared_value("MCPP_CXX_STDLIB")); e.emplace_back("MCPP_TARGET_SYSROOT", env.targetSysroot); e.emplace_back("MCPP_TARGET_BUILTINS_LIB", env.targetBuiltinsLib); e.emplace_back("MCPP_TARGET_LIBC_PROFILE", env.targetLibcProfile); @@ -827,6 +840,27 @@ std::string synthesised_rule_program(const std::vector& modules) { } // namespace +std::vector> +install_hook_env(const BuildProgramEnv& env) { + mcpp::toolchain::triple::Triple t{}; + std::string target; + if (env.targetTriple.empty()) { + t = mcpp::toolchain::triple::host_triple(); + target = t.str(); + } else { + target = env.targetTriple; + if (auto p = mcpp::toolchain::triple::parse(env.targetTriple)) t = *p; + } + return { + {"MCPP_COMPILER", env.compilerId}, + {"MCPP_CXX_STDLIB", env.cxxStdlib}, + {"MCPP_TARGET", target}, + {"MCPP_TARGET_OS", t.os}, + {"MCPP_TARGET_ARCH", t.arch}, + {"MCPP_TARGET_ENV", t.env}, + }; +} + std::expected run_build_program( mcpp::manifest::Manifest& m, const fs::path& root, @@ -1045,6 +1079,8 @@ std::expected run_build_program( // directives, no run. CacheRecord cache = read_cache(bdir); if (cache_fresh(root, bdir, cache, programHash, compilerHash, ctxHash)) { + if (auto terr = dirs::target_directive_error(m, cache.directives); !terr.empty()) + return std::unexpected(terr); dirs::apply(m, cache.directives); // ONE OF TWO SITES, AND THE ONE THAT IS EASY TO FORGET. // @@ -1463,6 +1499,11 @@ std::expected run_build_program( if (auto aerr = dirs::action_error(d); !aerr.empty()) { return std::unexpected(aerr); } + // A directive that names a target is checked against the manifest before + // anything is applied, for the same reason. + if (auto terr = dirs::target_directive_error(m, d); !terr.empty()) { + return std::unexpected(terr); + } if (d.protocol == 0) { for (auto const& k : d.unknownKeys) mcpp::ui::warning(std::format( diff --git a/src/build/execute.cppm b/src/build/execute.cppm index a06cd4af3..d3820a8ce 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -18,6 +18,8 @@ import mcpp.build.plan; import mcpp.toolchain.triple; import mcpp.freestanding.runner; import mcpp.build.runner_lookup; // #544: where the runner's program is +import mcpp.home; // config.toml, for the machine's default toolchain +import mcpp.libs.toml; import mcpp.toolchain.registry; // a payload's own runner (PayloadDescriptor::runner) import mcpp.build.directives; // the device-slot table: run / flash / monitor / debug import mcpp.freestanding.linkline; @@ -92,6 +94,36 @@ constexpr std::string_view kBuildCacheFile = "target/.build_cache"; constexpr int kBuildCacheMaxEntries = 8; // P3: one entry per (target, fingerprint) pair. +// THE INPUTS THAT CHOOSE A TOOLCHAIN AND ARE NOT IN THE MANIFEST. +// +// The fast path replays a recorded build when the request matches the entry +// that recorded it. `--toolchain` (arriving as MCPP_TOOLCHAIN) and the +// machine's default (`mcpp toolchain default`, stored in config.toml) both +// choose the compiler, and neither was compared. Measured 2026-09-12: after +// `mcpp build` with gcc, `mcpp build --toolchain llvm@22.1.8` printed +// `Finished dev in 0.00s` and left the gcc artefact in place, skipping every +// resolution-time check with it. The manifest's own `[toolchain]` needs no +// entry here: the freshness check already declines when mcpp.toml is newer +// than the recorded build. +// +// THE NAMED SET. A recorded build is replayed only for the same target triple, +// profile, cache mode, requested features and toolchain request. The other +// global options change how a resolution is fetched (`--offline`), checked +// (`--locked`) or executed (`--jobs`), not what it chooses, and are not +// compared. +std::string toolchain_request_identity() { + std::string cli; + if (const char* e = std::getenv("MCPP_TOOLCHAIN"); e) cli = e; + std::string machineDefault; + std::error_code ec; + const auto configFile = mcpp::home::root() / "config.toml"; + if (std::filesystem::exists(configFile, ec)) { + if (auto doc = mcpp::libs::toml::parse_file(configFile)) + machineDefault = doc->get_string("toolchain.default").value_or(""); + } + return std::format("cli={};default={}", cli, machineDefault); +} + struct BuildCacheEntry { std::string targetTriple; // "" for default target std::string outputDir; @@ -195,6 +227,12 @@ struct BuildCacheEntry { // features" — correct for every entry such a cache could hold whose // request also has none, and a miss otherwise, which is the safe direction. std::string features; + // The toolchain request this entry was built for; see + // toolchain_request_identity. Recorded is kept apart from the value + // because a cache written before the field existed must decline once, + // not match a request whose inputs it never saw. + std::string toolchainRequest; + bool toolchainRecorded = false; }; std::vector read_build_cache(const std::filesystem::path& projectRoot) { @@ -315,6 +353,13 @@ std::vector read_build_cache(const std::filesystem::path& proje e.features = line.substr(9); haveNextLine = static_cast(std::getline(f, line)); } + // Optional `toolchain=`. Absent means the entry predates the + // field, and every fast path declines it once; see the field. + if (haveNextLine && line.starts_with("toolchain=")) { + e.toolchainRequest = line.substr(10); + e.toolchainRecorded = true; + haveNextLine = static_cast(std::getline(f, line)); + } entries.push_back(std::move(e)); if (!haveNextLine || line.empty()) break; } @@ -361,7 +406,8 @@ void write_build_cache(const std::filesystem::path& projectRoot, std::vector depSourceRoots = {}, bool runnerDeclared = false, bool runTierPending = false, - const std::string& features = {}) { + const std::string& features = {}, + const std::string& toolchainRequest = {}) { auto path = projectRoot / kBuildCacheFile; auto entries = read_build_cache(projectRoot); @@ -385,6 +431,8 @@ void write_build_cache(const std::filesystem::path& projectRoot, newEntry.runnerDeclared = runnerDeclared; newEntry.runTierPending = runTierPending; newEntry.features = features; + newEntry.toolchainRequest = toolchainRequest; + newEntry.toolchainRecorded = true; entries.insert(entries.begin(), std::move(newEntry)); // Trim to LRU capacity. @@ -428,6 +476,7 @@ void write_build_cache_entries(const std::filesystem::path& path, f << "runner=" << (e.runnerDeclared ? 1 : 0) << '\n'; f << "runtier=" << (e.runTierPending ? 1 : 0) << '\n'; f << "features=" << e.features << '\n'; + f << "toolchain=" << e.toolchainRequest << '\n'; } } @@ -870,7 +919,10 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, // the entry is matched on it, because the output // directory is keyed on a fingerprint that includes // it and the entry was not. - normalize_features(ctx.activeFeatureRequest)); + normalize_features(ctx.activeFeatureRequest), + // The toolchain request, so a later `--toolchain` or + // a changed machine default declines the fast path. + toolchain_request_identity()); } // The one place the --strict policy is settled. Degradations reported by @@ -1144,6 +1196,8 @@ struct FastPathIdentity { // What `--features` asked for, normalised so that spelling and order // cannot make two identical requests compare unequal. std::string features; + // See toolchain_request_identity. + std::string toolchainRequest; }; std::optional @@ -1162,6 +1216,7 @@ fast_path_identity(const std::filesystem::path& projectRoot, m->buildConfig.target, m->hooks.active(), normalize_features(featuresRequested), + toolchain_request_identity(), }; } @@ -1257,7 +1312,8 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo const BuildCacheEntry* match = nullptr; for (auto& e : entries) { if (e.targetTriple == currentTarget && e.profile == want->profile - && e.cacheMode == want->cacheMode && e.features == want->features) { + && e.cacheMode == want->cacheMode && e.features == want->features + && e.toolchainRecorded && e.toolchainRequest == want->toolchainRequest) { match = &e; break; } @@ -1389,7 +1445,8 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, const BuildCacheEntry* match = nullptr; for (auto& e : entries) { if (e.targetTriple.empty() && e.profile == want->profile - && e.cacheMode == want->cacheMode && e.features == want->features) { + && e.cacheMode == want->cacheMode && e.features == want->features + && e.toolchainRecorded && e.toolchainRequest == want->toolchainRequest) { match = &e; break; } diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index fed7c862b..39d3d87e6 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -160,6 +160,17 @@ inline void link_script(const char* path) { std::printf("mcpp:link-scrip // `--exclude-libs`. Reaches the consumer's link line, as `[build] ldflags` // already does -- see the table row for why a private form is not offered. inline void link_flag(const char* flag) { std::printf("mcpp:link-flag=%s\n", flag); } +// The PE subsystem ("console" | "windows") and the entry function ("main" | +// "wmain" | "WinMain" | "wWinMain") of an executable target of THIS package, +// named by `target` (#618). The same fields as `[targets.] +// windows_subsystem` / `windows_entry`: they reach that target's link and no +// other, and are inert on every target that is not PE. +inline void windows_subsystem(const char* target, const char* value) { + std::printf("mcpp:windows-subsystem=%s:%s\n", target, value); +} +inline void windows_entry(const char* target, const char* value) { + std::printf("mcpp:windows-entry=%s:%s\n", target, value); +} // ── Build-graph nodes (mcpp 2026.8.5.1+) ──────────────────────────────── // Declare WORK instead of doing it. A build program is a good place to decide // what the build looks like and a bad place to perform it: work done here is diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index cdebeeef0..9c551b1e8 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -65,6 +65,14 @@ std::string emit_ninja_string(const BuildPlan& plan); std::string filter_ninja_output(std::string_view output, std::span commandPrefixes); +// The link flags one executable's `windows_subsystem` / `windows_entry` render +// to (#618). Empty on every object format other than PE, and for the pair of +// defaults. `sep` is `LinkStyle::SeparateLinker`, as for `pe_link_flag`. +// Exported so each row of the rendering table is stated as a unit test. +std::vector windows_executable_link_flags(const BuildPlan& plan, bool sep, + std::string_view subsystem, + std::string_view entry); + // Emitter self-check: every ninja rule's command must begin with a program. // // Exported so the invariant can be stated against hand-written manifests as @@ -232,6 +240,15 @@ std::string join_flags(const std::vector& flags) { // default install name is the path it was LINKED at, so a package built in // /tmp/build-xyz records /tmp/build-xyz and cannot be relocated — which is // every distributed dylib. `@rpath/` is the only default that travels. +// Whether a PE link speaks the MSVC ABI. Asked of the target triple, and of the +// compiler's own answer only when there is no triple; `pe_link_flag` below says +// why the compiler binary is the wrong question. One definition, so the import +// library and the subsystem cannot address two different linkers. +bool pe_msvc_abi(const BuildPlan& plan) { + const auto t = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple); + return t ? t->is_msvc_env() : mcpp::toolchain::is_msvc_target(plan.toolchain); +} + // A PE link flag, spelled for the TARGET ABI and wrapped for the driver. // // NOT a dialect-table entry, and Windows CI is why. Clang targeting the MSVC @@ -249,10 +266,7 @@ std::string pe_link_flag(const BuildPlan& plan, bool sep, std::string_view msvcForm, std::string_view gnuForm, std::string_view path) { - const auto t = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple); - const bool msvcAbi = t ? t->is_msvc_env() - : mcpp::toolchain::is_msvc_target(plan.toolchain); - if (!msvcAbi) return std::string(gnuForm) + std::string(path); + if (!pe_msvc_abi(plan)) return std::string(gnuForm) + std::string(path); auto flag = std::string(msvcForm) + std::string(path); return sep ? flag : "-Wl," + flag; } @@ -519,6 +533,43 @@ std::string action_phony_name(std::string_view pkg) { } // namespace +// WHAT `windows_subsystem` AND `windows_entry` RENDER TO (#618). +// +// On the MSVC ABI both flags are written whenever either key differs from its +// default. link.exe and lld-link infer each from the other when one is absent: +// with no `/SUBSYSTEM:` the subsystem follows the entry function the objects +// define (WinMain selects WINDOWS), and with no `/ENTRY:` the CRT startup +// follows the subsystem (WINDOWS selects WinMainCRTStartup, which a portable +// `int main()` does not satisfy). Stating both removes both inferences. The +// entry is the CRT startup symbol and never the program's function, because +// `/ENTRY:main` links and skips CRT initialisation, static constructors +// included. +// +// On the GNU ABI the driver owns both decisions: `-mwindows` selects the GUI +// subsystem, and `-municode` selects mingw-w64's wide startup, which calls +// `wmain`, or `wWinMain` through the runtime library. A narrow `WinMain` needs +// neither, because the runtime library supplies a `main` that calls it. +std::vector windows_executable_link_flags(const BuildPlan& plan, bool sep, + std::string_view subsystem, + std::string_view entry) { + const auto t = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple); + const bool pe = t ? t->is_pe() : bool(mcpp::platform::is_windows); + if (!pe) return {}; + const bool gui = subsystem == "windows"; + const std::string_view fn = entry.empty() ? std::string_view("main") : entry; + if (!gui && fn == "main") return {}; + std::vector out; + if (pe_msvc_abi(plan)) { + auto spell = [sep](std::string flag) { return sep ? flag : "-Wl," + flag; }; + out.push_back(spell(gui ? "/SUBSYSTEM:WINDOWS" : "/SUBSYSTEM:CONSOLE")); + out.push_back(spell(std::format("/ENTRY:{}CRTStartup", fn))); + return out; + } + if (gui) out.push_back("-mwindows"); + if (fn == "wmain" || fn == "wWinMain") out.push_back("-municode"); + return out; +} + std::string link_failure_advice(std::string_view output) { // Both linkers, both spellings. lld says "undefined symbol: X"; GNU ld says // "undefined reference to `X'". Matched on the operator's own name rather @@ -2259,6 +2310,12 @@ std::string emit_ninja_string(const BuildPlan& plan) { // of libX11 than it was linked against. mcpp::build::link_line::UnitTail tail; tail.dependencies = join_flags(lu.linkFlags); + // #618: this executable's own subsystem and entry. Rendered here + // rather than carried in `linkFlags`, because the spelling depends + // on `sepLinker`, which only this emitter knows. + if (lu.kind == LinkUnit::Binary) + tail.dependencies += join_flags(windows_executable_link_flags( + plan, sepLinker, lu.windowsSubsystem, lu.windowsEntry)); // mcpp#426: a link unit with no C++ in it takes only the part of // the contract that is not a statement about the C++ runtime. // Swapping the driver is not sufficient by itself — this slot names diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 0fe1db43d..28111a4ed 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -129,6 +129,12 @@ struct LinkUnit { std::string soname; // ABI name for shared libraries std::vector runtimeAliases; // relative aliases, e.g. bin/libfoo.so.1 std::optional entryMain; // src path of main.cpp for bin + // `windows_subsystem` / `windows_entry` of the target this unit links + // (#618), carried as the declared words. Rendering them needs the linker + // the emitter addresses, which the plan does not know, so the backend does + // it; see `windows_executable_link_flags`. Empty on every non-Binary unit. + std::string windowsSubsystem; + std::string windowsEntry; }; // One Windows resource script compiled into one linkable resource artifact @@ -744,6 +750,18 @@ ResolvedRuntimeContract resolve_runtime_contract( out.linkIntent.runtimeSearchDirs); append_paths(runtime.linkIntent.deployFiles, out.linkIntent.deployFiles); + // `runtime.deploy` (#615): the source resolves against the package that + // declared it; the destination stays relative, because it is relative + // to an executable this package has not seen. + for (auto const& entry : runtime.linkIntent.deploy) { + mcpp::manifest::DeployEntry resolved{ + absolute_from(package.root, entry.from), entry.to}; + const bool seen = std::ranges::any_of(out.linkIntent.deploy, + [&](auto const& e) { + return e.from == resolved.from && e.to == resolved.to; + }); + if (!seen) out.linkIntent.deploy.push_back(std::move(resolved)); + } // Legacy library_dirs means run-time discovery only. It deliberately // does not enter linkLibraryDirs; callers that need -L must opt into // the structured field. @@ -1179,10 +1197,18 @@ make_plan(const mcpp::manifest::Manifest& manifest, } } - auto add_deploy = [&](const std::filesystem::path& source) + // `toDir` is a `runtime.deploy` destination, relative to the executable's + // directory; empty and "." both mean that directory itself, which is where + // every `deploy_files` entry goes. The collision check keys on the full + // relative destination, so two files of one name in two directories do not + // collide, and two sources for one destination still do. + auto add_deploy = [&](const std::filesystem::path& source, + std::string_view toDir = {}) -> std::optional { const auto normalized = source.lexically_normal(); - const auto dest = std::filesystem::path("bin") / source.filename(); + auto destDir = std::filesystem::path("bin"); + if (!toDir.empty() && toDir != ".") destDir /= std::filesystem::path(toDir); + const auto dest = destDir / source.filename(); auto existing = std::ranges::find_if(plan.runtimeDeployFiles, [&](auto const& value) { return value.dest == dest; }); if (existing != plan.runtimeDeployFiles.end()) { @@ -1202,6 +1228,10 @@ make_plan(const mcpp::manifest::Manifest& manifest, if (auto collision = add_deploy(source)) return std::unexpected(std::move(*collision)); } + for (auto const& entry : plan.linkIntent.deploy) { + if (auto collision = add_deploy(entry.from, entry.to)) + return std::unexpected(std::move(*collision)); + } for (auto const& dir : plan.linkIntent.runtimeSearchDirs) { std::error_code dirEc; if (!std::filesystem::is_directory(dir, dirEc)) continue; @@ -1766,6 +1796,8 @@ make_plan(const mcpp::manifest::Manifest& manifest, lu.kind = LinkUnit::Binary; lu.output = target_output(t, naming); if (!t.main.empty()) lu.entryMain = projectRoot / t.main; + lu.windowsSubsystem = t.windowsSubsystem; + lu.windowsEntry = t.windowsEntry; } lu.loaderTagFlag = loader_tag_flag(lu.kind); diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 925b4fffe..00e03da0c 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -421,6 +421,13 @@ export void merge_conditional_config(mcpp::manifest::Manifest& m, for (auto const& l : cc.libraries) m.runtimeConfig.linkIntent.libraries.push_back(l); merge_conditional_xlings(m, cc); + // `[target..abi]`: recorded for every package; rendered only for + // the root, where prepare_build reads it. Last matching section wins, + // the rule every other conditional scalar follows. + if (cc.abiThreadsDeclared) { + m.buildConfig.abiThreads = cc.abiThreads; + m.buildConfig.abiThreadsDeclared = true; + } // `modules.sources` is the scanner's own view and is not part of // BuildInputs, so conditional sources are mirrored into it here. for (auto const& s : cc.inputs.sources) @@ -2846,6 +2853,31 @@ prepare_build(bool print_fingerprint, if (!m->conditionalConfigs.empty()) { merge_conditional_config(*m, cfgCtx()); } + // `[target..abi] threads` -- the ROOT's statement, rendered once, + // into channels that already reach the whole artefact: the graph-global + // dialect flag set (every C++ translation unit, the std module's own + // commands, the scan, every dependency's cache key), the C flags of every + // package (the root here, each dependency where it is loaded), and the link. + // + // For hosted targets that are not PE. On PE the MSVC runtime is always + // multithreaded and mingw-w64's threading model belongs to its payload; a + // freestanding target has no thread library to select. + const bool abiThreadsRendered = [&] { + if (!m->buildConfig.abiThreads) return false; + const auto abiTriple = mcpp::toolchain::triple::parse( + overrides.target_triple.empty() + ? mcpp::toolchain::triple::host_triple().str() + : overrides.target_triple); + return abiTriple && !abiTriple->is_pe() && !abiTriple->is_freestanding(); + }(); + auto add_once = [](std::vector& v, std::string_view flag) { + if (std::ranges::find(v, flag) == v.end()) v.emplace_back(flag); + }; + if (abiThreadsRendered) { + add_once(m->buildConfig.dialectCxxflags, "-pthread"); + add_once(m->buildConfig.cflags, "-pthread"); + add_once(m->buildConfig.ldflags, "-pthread"); + } // `[build].defines` must reach the scanner (P1689) and the compile edge, // and must participate in the fingerprint. Fold before dependency // resolution / fingerprinting. @@ -5021,6 +5053,13 @@ prepare_build(bool print_fingerprint, // #238: retain whatever error/warn text the child DID emit so we // can fold it into a diagnostic if install_packages exits non-zero. std::string capturedChildError; + // xlings' own error lines, after its structured summary (#614). + auto append_xlings_stderr = [](std::string& into, + const mcpp::xlings::CallResult& r) { + if (r.exitCode == 0) return; + for (auto const& line : r.stderrTail) + into += (into.empty() ? "" : "\n ") + std::string("xlings: ") + line; + }; auto install_one = [&](std::string target) -> std::expected { if (useProjectEnv) { // Project/custom-index deps install into the project-local @@ -5041,12 +5080,14 @@ prepare_build(bool print_fingerprint, projEnv, "install_packages", argsJson, &progress); capturedChildError = progress.captured_error(); if (!r) return std::unexpected(mcpp::pm::CallError{r.error()}); + append_xlings_stderr(capturedChildError, *r); return *r; } std::vector targets{ std::move(target) }; mcpp::fetcher::InstallProgressHandler progress; auto r = fetcher.install(targets, &progress); capturedChildError = progress.captured_error(); + if (r) append_xlings_stderr(capturedChildError, *r); return r; }; // Target = `:@` (SPEC-001 §6). @@ -5066,6 +5107,35 @@ prepare_build(bool print_fingerprint, // had asked for `mcpplibs:gtest` — the error itself only named the // dependency, which is the one thing nobody doubts. std::vector attempted{ target }; + // #613: THE INSTALL HOOK'S ENVIRONMENT, under the names and the rule + // a build program gets: always emitted, empty when not applicable, + // so a hook never reads a value inherited from a parent process. + // Computed by `install_hook_env`, the function the build-program + // environment takes the same six values from. + // + // THE TOOLCHAIN VALUES ARE EMPTY HERE ON THE ORDINARY PATH. `tc` is + // resolved after the dependency graph (see its declaration: a + // package in the graph may supply a target-side layer), so when a + // dependency installs there is no resolved compiler or standard + // library to state, and a guessed one would be worse than none. + // Measured with tests/e2e/648. The target names are decided, and a + // package states the standard library it was built for with + // `requires = ["mcpp:c++-abi=..."]`, checked once `tc` exists. A + // hook must not build a variant into a store directory that does + // not name the variant, because the store is keyed by package and + // version. Scoped: restored when this dependency's install returns, + // compat retries below included. + mcpp::build::BuildProgramEnv hookEnv; + fill_target_build_env(hookEnv, tc ? &*tc : nullptr); + hookEnv.targetTriple = overrides.target_triple; + // Six names, fixed by install_hook_env; one guard each. + const auto hookVars = mcpp::build::install_hook_env(hookEnv); + mcpp::platform::env::ScopedEnv hookVar0(hookVars.at(0).first, hookVars.at(0).second); + mcpp::platform::env::ScopedEnv hookVar1(hookVars.at(1).first, hookVars.at(1).second); + mcpp::platform::env::ScopedEnv hookVar2(hookVars.at(2).first, hookVars.at(2).second); + mcpp::platform::env::ScopedEnv hookVar3(hookVars.at(3).first, hookVars.at(3).second); + mcpp::platform::env::ScopedEnv hookVar4(hookVars.at(4).first, hookVars.at(4).second); + mcpp::platform::env::ScopedEnv hookVar5(hookVars.at(5).first, hookVars.at(5).second); auto r = install_one(target); if (r && r->exitCode != 0 && (ns.empty() || ns == mcpp::pm::kDefaultNamespace)) { @@ -5231,6 +5301,9 @@ prepare_build(bool print_fingerprint, cfgCtx()); } fold_build_defines_into_flags(manifest->buildConfig); + // The root's `abi.threads` reaches a dependency's C translation units + // here; its C++ units already receive it through the dialect flag set. + if (abiThreadsRendered) add_once(manifest->buildConfig.cflags, "-pthread"); return std::pair{effRoot, std::move(*manifest)}; }; @@ -6745,6 +6818,9 @@ prepare_build(bool print_fingerprint, cfgCtx()); } fold_build_defines_into_flags(dep_manifest->buildConfig); + // The root's `abi.threads` reaches this dependency's C translation + // units here, as it does for a version dependency. + if (abiThreadsRendered) add_once(dep_manifest->buildConfig.cflags, "-pthread"); } else { auto loaded = loadVersionDep(name, key.ns, key.shortName, spec.version); if (!loaded) return std::unexpected(loaded.error()); @@ -7279,6 +7355,8 @@ prepare_build(bool print_fingerprint, // apply() as each package's features activate; bound after the loops below. std::map> capProviders; std::vector> capRequires; + // `requires_abi`: (what, requirer). See Manifest::requiresAbiThreads. + std::vector> abiRequires; // Who claimed sole provision of what. Separate from capProviders because // the question it answers is different: capProviders asks "can this // requirement be satisfied", this asks "can these two coexist at all". @@ -7371,7 +7449,21 @@ prepare_build(bool print_fingerprint, if (auto it = pkg.manifest.featureRequires.find(f); it != pkg.manifest.featureRequires.end()) for (auto& cap : it->second) capRequires.emplace_back(cap, pcap); - } + if (auto it = pkg.manifest.featureRequiresAbiThreads.find(f); + it != pkg.manifest.featureRequiresAbiThreads.end() && it->second) + abiRequires.emplace_back(std::format("feature `{}`", f), pcap); + } + if (pkg.manifest.requiresAbiThreads) + abiRequires.emplace_back("the package", pcap); + // A DEPENDENCY'S OWN `[target..abi]` DOES NOT CHANGE THE + // BUILD. The switch belongs to the artefact, which the root decides; + // a table written in a dependency is reported rather than silently + // ignored, and points at the key a dependency does have. + if (pcap != m->package.name && pkg.manifest.buildConfig.abiThreadsDeclared) + mcpp::diag::warning("abi/dependency-table", std::format( + "`{}` declares [target..abi], which only the root " + "manifest decides; a dependency states what it needs with " + "`requires_abi = {{ threads = true }}`", pcap)); // `[targets.*] required_features` on a DEPENDENCY. // // THIS GATE EXISTED ONLY FOR THE ROOT. The root's targets are @@ -8814,6 +8906,22 @@ prepare_build(bool print_fingerprint, // answer. if (auto err = checkVersionFloors(); err) return std::unexpected(*err); + // `requires_abi`: a package needs the artefact's ABI switch on. The + // root's `[target..abi]` is the only table that sets it, so a + // mismatch is refused naming both halves, before anything compiles -- + // otherwise it surfaces as a precompiled-module configuration mismatch + // that names neither. + if (!m->buildConfig.abiThreads && !abiRequires.empty()) { + auto const& [what, requirer] = abiRequires.front(); + return std::unexpected(std::format( + "`{}` requires the artefact's ABI to have threads ({}), and this " + "build does not state it.\n" + " Add to the root manifest, for the targets that need it:\n" + "\n" + " [target.'cfg(os = \"\")'.abi]\n" + " threads = true", requirer, what)); + } + std::set boundCaps; for (auto& [cap, requirer] : capRequires) { if (!boundCaps.insert(cap).second) continue; // one diagnosis per cap @@ -12067,6 +12175,13 @@ prepare_build(bool print_fingerprint, path_array(ctx.plan.linkIntent.runtimeSearchDirs)}, {"frameworks", ctx.plan.linkIntent.frameworks}, {"deploy_files", path_array(ctx.plan.linkIntent.deployFiles)}, + {"deploy", [&] { + auto a = nlohmann::json::array(); + for (auto const& d : ctx.plan.linkIntent.deploy) + a.push_back({{"from", d.from.generic_string()}, + {"to", d.to}}); + return a; + }()}, }}, {"search", search}, {"validation", { diff --git a/src/pack/pack.cppm b/src/pack/pack.cppm index b2437b1f8..cb8f66e7b 100644 --- a/src/pack/pack.cppm +++ b/src/pack/pack.cppm @@ -91,6 +91,12 @@ struct Options { // toolset's `VC\Redist\MSVC\\\Microsoft.VC*.CRT\` for cl. // Searched ONLY under the toolchain-coupled contract — see make_plan. std::vector toolchainRuntimeDirs; + // What the build placed relative to the executable, from + // `runtime.deploy_files` and `runtime.deploy` (#615), as paths relative to + // the executable's directory. Each is staged at the same relative path + // beside the packed executable, in every mode: these are files a program + // opens, not libraries a closure decides about. + std::vector runtimeFiles; // Does the RESOLVED C++ runtime contract require the toolchain's own // runtime to travel WITH the artifact — i.e. `cxx_runtime = // "toolchain-coupled"`? @@ -815,6 +821,31 @@ void copy_if_exists(const std::filesystem::path& src, std::filesystem::copy_options::overwrite_existing, ec); } +// The runtime files the build placed relative to the executable (#615), copied +// to the same relative path beside the staged executable. The build produced +// every one of them, so a missing file is an error naming it rather than a +// bundle that silently lacks it. +std::expected +stage_runtime_files(const Plan& plan, const std::filesystem::path& stagedExeDir) +{ + const auto builtDir = plan.builtBinary.parent_path(); + for (auto const& rel : plan.opts.runtimeFiles) { + const auto src = builtDir / rel; + const auto dst = stagedExeDir / rel; + std::error_code ec; + if (!std::filesystem::is_regular_file(src, ec)) + return std::unexpected(Error{std::format( + "runtime file '{}' is not beside the built executable (looked at '{}')", + rel.generic_string(), src.string())}); + std::filesystem::create_directories(dst.parent_path(), ec); + std::filesystem::copy_file(src, dst, + std::filesystem::copy_options::overwrite_existing, ec); + if (ec) return std::unexpected(Error{std::format( + "failed to copy {} -> {}: {}", src.string(), dst.string(), ec.message())}); + } + return {}; +} + // ─── PE: the closure, read rather than executed ───────────────────────── // // BFS over the import tables, resolving each name against `searchDirs`. A @@ -951,6 +982,7 @@ run_pe(const Plan& plan) std::filesystem::copy_options::overwrite_existing, ec); if (ec) return std::unexpected(Error{std::format( "copy binary failed: {}", ec.message())}); + if (auto r = stage_runtime_files(plan, stagedExe.parent_path()); !r) return r; copy_if_exists(plan.projectRoot / "README.md", plan.stagingRoot); copy_if_exists(plan.projectRoot / "LICENSE", plan.stagingRoot); @@ -1116,6 +1148,8 @@ run(const Plan& plan, const mcpp::config::GlobalConfig& cfg) | std::filesystem::perms::group_exec | std::filesystem::perms::others_exec, std::filesystem::perm_options::add, ec); + // 2b. Runtime files beside it, at the paths the build used (#615). + if (auto r = stage_runtime_files(plan, bundledBinary.parent_path()); !r) return r; // 3. README / LICENSE if present at project root. copy_if_exists(plan.projectRoot / "README.md", plan.stagingRoot); diff --git a/src/pack/pipeline.cppm b/src/pack/pipeline.cppm index 8d1ce9b79..cdbee9860 100644 --- a/src/pack/pipeline.cppm +++ b/src/pack/pipeline.cppm @@ -248,6 +248,10 @@ export int build_and_pack(Options opts, bool modeFromUser, opts.depSearchDirs = ctx->plan.runtimeLibraryDirs; for (auto const& d : ctx->plan.linkIntent.runtimeSearchDirs) opts.depSearchDirs.push_back(d); + // What the build placed relative to the executable (#615). The plan's + // destinations are `bin//`, and the executable is in `bin/`. + for (auto const& d : ctx->plan.runtimeDeployFiles) + opts.runtimeFiles.push_back(d.dest.lexically_relative("bin")); } // ─── Build the plan + run ──────────────────────────────────────── diff --git a/src/xlings/xlings.cppm b/src/xlings/xlings.cppm index 37526099d..e1edb8369 100644 --- a/src/xlings/xlings.cppm +++ b/src/xlings/xlings.cppm @@ -270,6 +270,38 @@ std::string shq_meta(std::string_view s); // XLINGS_HOME='' '' std::string build_command_prefix(const Env& env); +// THE ENVIRONMENT OF ONE XLINGS INVOCATION (#614), decided once. Each entry is +// a variable, its value, and whether it is present at all. Global mode is an +// absent XLINGS_PROJECT_DIR, because xlings resolves its subos scope from that +// variable. POSIX renders the decision into the command prefix (`env -u` and +// `K=V`); Windows applies it to the process through ScopedInvocationEnv. +struct InvocationVar { + std::string name; + std::string value; + bool present = true; +}; +std::vector invocation_env(const Env& env); + +// Applies the scope half of `invocation_env` to this process for the guard's +// lifetime on Windows, and restores the prior value when the guard ends. On +// POSIX the command prefix carries it and the guard does nothing. Every +// function that runs a command built by `build_command_prefix` holds one while +// the command runs, so a project directory set for one invocation does not +// reach the processes mcpp starts afterwards. XLINGS_HOME and the PATH prefix +// keep their process-wide lifetime. +class ScopedInvocationEnv { +public: + explicit ScopedInvocationEnv(const Env& env); + ~ScopedInvocationEnv(); + ScopedInvocationEnv(const ScopedInvocationEnv&) = delete; + ScopedInvocationEnv& operator=(const ScopedInvocationEnv&) = delete; + +private: + bool active_ = false; + bool hadPrevious_ = false; + std::string previous_; +}; + // Build full xlings interface command. // interface --args '' 2>/dev/null std::string build_interface_command(const Env& env, @@ -326,8 +358,20 @@ struct CallResult { std::vector dataEvents; std::optional error; std::string resultJson; + // xlings' own error-level lines from its stderr, the last few, kept only + // when the call failed (#614). The NDJSON error event carries a summary + // ("config hook failed"); the line that says WHICH binding was rejected and + // why is a log line on stderr, which this call used to discard. + std::vector stderrTail; }; +// The error-level lines of an xlings invocation's stderr, the last `limit` of +// them (#614). A line is kept when it contains "error" in any case, contains +// `E_`, or starts with `[xim]`; a trailing carriage return is dropped. Exported +// so the selection is stated as a unit test rather than through a failing +// install. +std::vector stderr_error_tail(std::string_view text, std::size_t limit = 20); + struct EventHandler { virtual ~EventHandler() = default; virtual void on_progress(const ProgressEvent&) {} @@ -1134,32 +1178,50 @@ std::filesystem::path sandbox_init_marker(const Env& env) { // ─── Shell command builders ───────────────────────────────────────── +std::vector invocation_env(const Env& env) { + return { + {"XLINGS_HOME", env.home.string(), true}, + {"XLINGS_PROJECT_DIR", env.projectDir.string(), !env.projectDir.empty()}, + }; +} + +ScopedInvocationEnv::ScopedInvocationEnv(const Env& env) { + if constexpr (mcpp::platform::is_windows) { + for (auto const& var : invocation_env(env)) { + if (var.name != "XLINGS_PROJECT_DIR") continue; + if (auto prior = mcpp::platform::env::get(var.name)) { + hadPrevious_ = true; + previous_ = *prior; + } + active_ = true; + if (var.present) mcpp::platform::env::set(var.name, var.value); + else mcpp::platform::env::unset(var.name); + } + } +} + +ScopedInvocationEnv::~ScopedInvocationEnv() { + if (!active_) return; + if (hadPrevious_) mcpp::platform::env::set("XLINGS_PROJECT_DIR", previous_); + else mcpp::platform::env::unset("XLINGS_PROJECT_DIR"); +} + std::string build_command_prefix(const Env& env) { auto xvmBin = paths::sandbox_bin(env).string(); if constexpr (mcpp::platform::is_windows) { + // The scope variable is applied by the caller's ScopedInvocationEnv. mcpp::platform::env::set("XLINGS_HOME", env.home.string()); - mcpp::platform::env::set("XLINGS_PROJECT_DIR", - env.projectDir.empty() ? "" : env.projectDir.string()); mcpp::platform::windows::prepend_path(xvmBin); return env.binary.string(); } else { - if (env.projectDir.empty()) { - // Global mode: unset XLINGS_PROJECT_DIR (existing behavior). - return std::format( - "cd {} && env -u XLINGS_PROJECT_DIR PATH={}:\"$PATH\" XLINGS_HOME={} {}", - shq(env.home.string()), - shq(xvmBin), - shq(env.home.string()), - shq(env.binary.string())); + // `env` takes its `-u` operands before its assignments. + std::string unset, assign; + for (auto const& var : invocation_env(env)) { + if (var.present) assign += std::format(" {}={}", var.name, shq(var.value)); + else unset += std::format(" -u {}", var.name); } - // Project-level mode: set XLINGS_PROJECT_DIR so xlings uses - // additive project repos alongside global repos. - return std::format( - "cd {} && env PATH={}:\"$PATH\" XLINGS_HOME={} XLINGS_PROJECT_DIR={} {}", - shq(env.home.string()), - shq(xvmBin), - shq(env.home.string()), - shq(env.projectDir.string()), + return std::format("cd {} && env{} PATH={}:\"$PATH\"{} {}", + shq(env.home.string()), unset, shq(xvmBin), assign, shq(env.binary.string())); } } @@ -1306,7 +1368,19 @@ std::expected call(const Env& env, std::string_view capability, std::string_view argsJson, EventHandler* handler) { - auto cmd = build_interface_command(env, capability, argsJson); + ScopedInvocationEnv scope(env); // #614 + // STDERR GOES TO A FILE, NOT TO THE NULL DEVICE AND NOT INTO STDOUT (#614). + // Stdout is parsed line by line as NDJSON, so merging stderr into it could + // split an event; discarding it lost the one line that names a rejection. + // The file is read only when the call fails, and removed either way. + const auto stderrFile = std::filesystem::temp_directory_path() + / std::format("mcpp-xlings-{}.stderr", + std::chrono::steady_clock::now().time_since_epoch().count()); + auto cmd = std::format("{} interface {} --args {} 2>{}", + build_command_prefix(env), capability, shq_meta(argsJson), + mcpp::platform::is_windows + ? std::format("\"{}\"", stderrFile.string()) + : shq(stderrFile.string())); // #238: under MCPP_VERBOSE=1 surface the exact xlings invocation so a // failing install_packages can be reproduced/inspected by hand. The @@ -1343,15 +1417,49 @@ call(const Env& env, std::string_view capability, }, *ev); }); if (rc != 0 && result.exitCode == 0) result.exitCode = rc; + if (result.exitCode != 0) { + // Error-level lines only, the last 20: enough to name a rejection, and + // bounded so a verbose child cannot bury the diagnostic it is attached + // to. + std::ifstream in(stderrFile); + std::stringstream text; + text << in.rdbuf(); + result.stderrTail = stderr_error_tail(text.str()); + } + std::error_code rmEc; + std::filesystem::remove(stderrFile, rmEc); return result; } +std::vector stderr_error_tail(std::string_view text, std::size_t limit) { + std::deque tail; + std::size_t pos = 0; + while (pos < text.size()) { + const auto nl = text.find('\n', pos); + std::string line(text.substr(pos, nl == std::string_view::npos + ? std::string_view::npos : nl - pos)); + pos = nl == std::string_view::npos ? text.size() : nl + 1; + if (!line.empty() && line.back() == '\r') line.pop_back(); + std::string lower = line; + std::ranges::transform(lower, lower.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (lower.find("error") == std::string::npos + && line.find("E_") == std::string::npos + && !line.starts_with("[xim]")) + continue; + tail.push_back(std::move(line)); + if (tail.size() > limit) tail.pop_front(); + } + return {tail.begin(), tail.end()}; +} + // ─── install_with_progress ────────────────────────────────────────── int install_with_progress(const Env& env, std::string_view target, const BootstrapProgressCallback& cb, bool quiet) { + ScopedInvocationEnv scope(env); // #614 auto argsJson = std::format( R"({{"targets":["{}"],"yes":true}})", target); @@ -1424,22 +1532,11 @@ int install_with_progress(const Env& env, std::string_view target, // Seal stdin (same rationale as the direct path above) so the install can't // block on a terminal read. The protocol is NDJSON-over-stdout + "yes":true, // so nothing here needs the terminal. - auto cmd = [&]() -> std::string { - if constexpr (mcpp::platform::is_windows) { - return std::format("{} interface install_packages --args {} {} tests/smoke.cpp +"$MCPP" test > test.log 2>&1 || fail "mcpp test failed" test.log +checked=0 +while IFS= read -r exe; do + case "$(basename "$exe")" in gui.exe|tool.exe|winmain.exe) continue ;; esac + got=$(pe_subsystem "$exe") + [ "$got" = "3" ] || fail "$exe has Subsystem $got, expected 3" test.log + checked=$((checked + 1)) +done < <(find target -type f -name '*.exe') +# cli, wide, and at least one test binary. +[ "$checked" -ge 3 ] || fail "only $checked console executables were found" test.log +echo "console executables OK ($checked)" diff --git a/tests/e2e/643_windows_subsystem_cross.sh b/tests/e2e/643_windows_subsystem_cross.sh new file mode 100755 index 000000000..1755f75f9 --- /dev/null +++ b/tests/e2e/643_windows_subsystem_cross.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# requires: mingw-cross +# 643_windows_subsystem_cross.sh -- `windows_subsystem` / `windows_entry` (#618) +# from Linux to Windows through mingw-cross, which links for the GNU ABI +# (`-mwindows`, `-municode`). The assertions are shared with 642. The execution +# leg runs under wine when this host has it; wine is evidence rather than proof +# (see 257), and the native half is 642. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +export MCPP_HOME="${MCPP_HOME:-$HOME/.mcpp}" +export WINEDEBUG=-all + +BUILD_ARGS="--target x86_64-windows-gnu" +RUN_EXE="" +command -v wine > /dev/null 2>&1 && RUN_EXE="wine" +source "$(dirname "$0")/_windows_subsystem_body.sh" diff --git a/tests/e2e/644_windows_keys_are_inert_elsewhere.sh b/tests/e2e/644_windows_keys_are_inert_elsewhere.sh new file mode 100755 index 000000000..6f076f686 --- /dev/null +++ b/tests/e2e/644_windows_keys_are_inert_elsewhere.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# requires: elf +# 644_windows_keys_are_inert_elsewhere.sh -- #618 criteria 4 and 5 on a target +# that is not PE. +# +# 4. `windows_subsystem` / `windows_entry` change no byte of an ELF artefact +# and print nothing, so a cross-platform manifest needs no `cfg` block. +# 5. The manifest refuses the keys on a library target, naming the target and +# the key, and a build program that names no executable of its package is +# refused, naming the target. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +mkdir -p "$TMP/proj/src" +cd "$TMP/proj" +printf 'int main() { return 0; }\n' > src/main.cpp + +write_manifest() { # $1 = extra lines for [targets.inert] + cat > mcpp.toml < plain.log 2>&1 || fail "the plain build failed" plain.log +[ -n "$(artefact)" ] || fail "no artefact from the plain build" plain.log +cp "$(artefact)" "$TMP/plain.bin" + +rm -rf target +write_manifest 'windows_subsystem = "windows" +windows_entry = "wWinMain"' +"$MCPP" build --release > keyed.log 2>&1 || fail "the build with the keys failed" keyed.log +[ -n "$(artefact)" ] || fail "no artefact from the build with the keys" keyed.log +cmp "$TMP/plain.bin" "$(artefact)" || fail "the keys changed the ELF artefact" keyed.log +if grep -Eqi 'subsystem|windows_entry|unsupported key' keyed.log; then + fail "the keys produced output on a target that is not PE" keyed.log +fi +echo "inert on ELF OK" + +# The directive form is inert as well. +rm -rf target +write_manifest "" +cat > build.mcpp <<'CPP' +import mcpp; +int main() { + mcpp::windows_subsystem("inert", "windows"); + return 0; +} +CPP +"$MCPP" build --release > directive.log 2>&1 || fail "the build with the directive failed" directive.log +cmp "$TMP/plain.bin" "$(artefact)" || fail "the directive changed the ELF artefact" directive.log +echo "directive inert on ELF OK" + +# ── 5. Refusals ─────────────────────────────────────────────────────────── +cat > build.mcpp <<'CPP' +import mcpp; +int main() { + mcpp::windows_subsystem("nosuch", "windows"); + return 0; +} +CPP +if "$MCPP" build --release > refuse-directive.log 2>&1; then + fail "a directive naming no target was accepted" refuse-directive.log +fi +grep -q 'declares no target named `nosuch`' refuse-directive.log \ + || fail "the directive refusal does not name the target" refuse-directive.log +rm -f build.mcpp + +mkdir -p "$TMP/lib/src" +cd "$TMP/lib" +printf 'export module core;\nexport int core_value() { return 1; }\n' > src/core.cppm +cat > mcpp.toml <<'TOML' +[package] +name = "lib" +version = "0.1.0" + +[targets.core] +kind = "lib" +windows_subsystem = "windows" +TOML +if "$MCPP" build > refuse-lib.log 2>&1; then + fail "a library target accepted windows_subsystem" refuse-lib.log +fi +grep -q 'targets.core.windows_subsystem applies to an executable' refuse-lib.log \ + || fail "the refusal does not name the target and the key" refuse-lib.log +echo "refusals OK" diff --git a/tests/e2e/645_the_fast_path_compares_the_toolchain_request.sh b/tests/e2e/645_the_fast_path_compares_the_toolchain_request.sh new file mode 100755 index 000000000..dcef102fc --- /dev/null +++ b/tests/e2e/645_the_fast_path_compares_the_toolchain_request.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# 645_the_fast_path_compares_the_toolchain_request.sh -- the fast path replays a +# recorded build only for the toolchain request that recorded it (T1 of the +# 2026-09-12 engine-gaps record). +# +# Measured before the fix: after `mcpp build` with gcc, `mcpp build --toolchain +# llvm@22.1.8` printed `Finished dev in 0.00s` and left the gcc artefact in +# place. Neither `--toolchain` (which reaches the build as MCPP_TOOLCHAIN) nor +# the machine default (`[toolchain] default` in config.toml) was compared, and +# every resolution-time check was skipped with them. +# +# Asserted on whether toolchain resolution ran, which the fast path skips: +# A `mcpp build` twice; the second is the fast path. This is the control: +# without it the assertions below could not fail. +# B the same toolchain requested through `--toolchain`; resolution runs, +# and MCPP_TOOLCHAIN is then the same request. +# A `mcpp build` resolves again, and a further one is the fast path. +# `mcpp run` has a fast path of its own and takes the same A-B-A. +# The machine-default leg sets `[toolchain] default` in an isolated home to a +# second installed version of the same family and asserts that the artefact +# changed compiler. It is reported as not measured when no second version is +# installed. +set -e + +HERE="$(cd "$(dirname "$0")" && pwd)" +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +resolutions() { grep -c 'Resolving toolchain' "$1" || true; } + +mkdir -p "$TMP/proj/src" +cd "$TMP/proj" +printf 'int main() { return 0; }\n' > src/main.cpp +cat > mcpp.toml <<'TOML' +[package] +name = "fastpath" +version = "0.1.0" +TOML + +# ── A ─────────────────────────────────────────────────────────────────────── +"$MCPP" build > a1.log 2>&1 || fail "the first build failed" a1.log +own=$(sed -n 's/.*Resolved \([^ ]*\) .*/\1/p' a1.log | head -1) +[ -n "$own" ] || fail "could not learn this platform's toolchain" a1.log +first_request=$(grep '^toolchain=' target/.build_cache 2>/dev/null | head -1) +"$MCPP" build > a2.log 2>&1 || fail "the second build failed" a2.log +if [ "$(resolutions a2.log)" != 0 ]; then + # THE CONTROL FAILED, AND WHAT DECIDES THE VERDICT IS WHY. Two plain builds + # make the same request, so identical `toolchain=` lines in the entries they + # recorded mean the fast path declined for a reason this test does not + # measure. Measured on macOS and Windows CI: the fast path requires an ELF + # runtime-validation verdict for every artefact (execute.cppm, + # try_fast_build), which a Mach-O or PE artefact never records. + second_request=$(grep '^toolchain=' target/.build_cache 2>/dev/null | head -1) + if [ -n "$first_request" ] && [ "$first_request" = "$second_request" ]; then + echo "NOT MEASURED: an unchanged second build did not take the fast path on this host, and both builds recorded the same request ($first_request)" + echo "--- the recorded entry ---"; cat target/.build_cache + exit 0 + fi + fail "control: an unchanged second build resolved the toolchain, and the recorded requests differ ('$first_request', then '$second_request')" a2.log +fi + +# ── B ─────────────────────────────────────────────────────────────────────── +"$MCPP" build --toolchain "$own" > b1.log 2>&1 || fail "--toolchain $own failed" b1.log +[ "$(resolutions b1.log)" != 0 ] \ + || fail "--toolchain $own was answered by the fast path of a build that did not request it" b1.log +MCPP_TOOLCHAIN="$own" "$MCPP" build > b2.log 2>&1 || fail "MCPP_TOOLCHAIN=$own failed" b2.log +[ "$(resolutions b2.log)" = 0 ] \ + || fail "MCPP_TOOLCHAIN=$own is the request --toolchain makes, and it was not answered by that build's fast path" b2.log + +# ── A ─────────────────────────────────────────────────────────────────────── +"$MCPP" build > a3.log 2>&1 || fail "the build after --toolchain failed" a3.log +[ "$(resolutions a3.log)" != 0 ] \ + || fail "a build without --toolchain was answered by the fast path of the --toolchain build" a3.log +"$MCPP" build > a4.log 2>&1 || fail "the settling build failed" a4.log +[ "$(resolutions a4.log)" = 0 ] || fail "the default request did not return to the fast path" a4.log +echo "build A-B-A OK ($own)" + +# ── The same A-B-A through `mcpp run` ───────────────────────────────────── +"$MCPP" run > r1.log 2>&1 || fail "mcpp run failed" r1.log +"$MCPP" run > r2.log 2>&1 || fail "the second mcpp run failed" r2.log +[ "$(resolutions r2.log)" = 0 ] || fail "control: an unchanged second run resolved the toolchain" r2.log +# `mcpp run` takes the request through the environment, the channel +# `--toolchain` itself uses. +MCPP_TOOLCHAIN="$own" "$MCPP" run > r3.log 2>&1 || fail "MCPP_TOOLCHAIN=$own mcpp run failed" r3.log +[ "$(resolutions r3.log)" != 0 ] \ + || fail "MCPP_TOOLCHAIN=$own mcpp run was answered by the fast path of a run that did not request it" r3.log +echo "run A-B-A OK" + +# ── The machine default ──────────────────────────────────────────────────── +family=${own%@*} +own_version=${own#*@} +export MCPP_HOME="$TMP/home" +source "$HERE/_inherit_toolchain.sh" +# The newest other version first: an old one may predate the module support +# the build needs (gcc 13 rejects -fmodules), which would test the compiler +# rather than the fast path. +other="" +for v in $(ls "$MCPP_HOME/registry/data/xpkgs/xim-x-$family" 2>/dev/null | sort -V -r); do + dir="$MCPP_HOME/registry/data/xpkgs/xim-x-$family/$v" + [ "$v" != "$own_version" ] && [ -x "$dir/bin/g++" -o -x "$dir/bin/clang++" -o -x "$dir/bin/clang++.exe" ] \ + && { other="$family@$v"; break; } +done +if [ -z "$other" ]; then + echo "NOT MEASURED: the machine-default leg, because no second $family version is installed" + exit 0 +fi + +rm -rf target .mcpp +"$MCPP" toolchain default "$own" > d0.log 2>&1 || fail "mcpp toolchain default $own failed" d0.log +"$MCPP" build > d1.log 2>&1 || fail "the build with default $own failed" d1.log +"$MCPP" build > d2.log 2>&1 || fail "the second build with default $own failed" d2.log +[ "$(resolutions d2.log)" = 0 ] || fail "control: an unchanged build in the isolated home resolved the toolchain" d2.log + +"$MCPP" toolchain default "$other" > d3.log 2>&1 || fail "mcpp toolchain default $other failed" d3.log +"$MCPP" build > d4.log 2>&1 || fail "the build with default $other failed" d4.log +[ "$(resolutions d4.log)" != 0 ] \ + || fail "changing the machine default to $other was answered by the fast path of the $own build" d4.log +grep -q "Resolved $other" d4.log || fail "the build did not resolve the new default $other" d4.log +artefact=$(ls -t $(find target -type f -name 'fastpath*' -path '*/bin/*') | head -1) +if [ "$family" = gcc ] && [ "$(od -An -c -N4 "$artefact" | tr -d ' ')" = '177ELF' ]; then + grep -aq "${other#*@}" "$artefact" \ + || fail "the artefact the build left does not carry $other's version string" d4.log +fi +echo "machine-default leg OK ($own -> $other)" diff --git a/tests/e2e/646_runtime_deploy_places_files_in_a_directory.sh b/tests/e2e/646_runtime_deploy_places_files_in_a_directory.sh new file mode 100755 index 000000000..dc1426823 --- /dev/null +++ b/tests/e2e/646_runtime_deploy_places_files_in_a_directory.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# 646_runtime_deploy_places_files_in_a_directory.sh -- `runtime.deploy` (#615) +# places a runtime file in a directory relative to the executable. +# +# `deploy_files` flattens every entry into the executable's directory, and a +# loader that reads a fixed subdirectory (the Vulkan loader on macOS reads +# `/vulkan/icd.d`) never finds a flattened copy. Asserted: +# 1. the root's entries land at bin//, and `to = "."` places the +# file beside the executable; a test binary finds the same layout; +# 2. a dependency's entry resolves `from` against the dependency and lands in +# the consumer's bin/; +# 3. one file name in two directories is not a collision, and two sources for +# one destination are refused naming the destination; +# 4. a destination that leaves the executable's directory is refused naming +# the entry. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +# ── A dependency that deploys into a subdirectory ───────────────────────── +mkdir -p "$TMP/icd/share/vulkan/icd.d" "$TMP/icd/src" +cd "$TMP/icd" +printf '{"ICD": {"library_path": "libvulkan_lvp.so"}}\n' > share/vulkan/icd.d/lvp_icd.json +printf 'export module icd;\nexport int icd_value() { return 3; }\n' > src/icd.cppm +cat > mcpp.toml <<'TOML' +[package] +name = "icd" +version = "0.1.0" + +[targets.icd] +kind = "lib" + +[runtime] +deploy = [ { from = "share/vulkan/icd.d/lvp_icd.json", to = "vulkan/icd.d" } ] +TOML + +# ── The consumer ─────────────────────────────────────────────────────────── +mkdir -p "$TMP/app/src" "$TMP/app/assets/layers" +cd "$TMP/app" +printf 'root readme\n' > assets/readme.txt +printf 'layer manifest\n' > assets/layers/lvp_icd.json +cat > src/main.cpp <<'CPP' +import icd; +int main() { return icd_value() == 3 ? 0 : 1; } +CPP + +write_manifest() { # $1 = the entries of the root's `runtime.deploy` + cat > mcpp.toml < build.log 2>&1 || fail "the build failed" build.log +exe=$(find target -type f \( -name app -o -name app.exe \) -path '*/bin/*' | head -1) +[ -n "$exe" ] || fail "no executable under target/" build.log +bin=$(dirname "$exe") +[ -f "$bin/readme.txt" ] || fail "to = \".\" did not place readme.txt beside the executable" build.log +grep -q 'layer manifest' "$bin/layers/lvp_icd.json" 2>/dev/null \ + || fail "bin/layers/lvp_icd.json is missing or is not the root's file" build.log +grep -q 'library_path' "$bin/vulkan/icd.d/lvp_icd.json" 2>/dev/null \ + || fail "bin/vulkan/icd.d/lvp_icd.json is missing or is not the dependency's file" build.log +[ ! -e "$bin/lvp_icd.json" ] || fail "an entry with a directory was also flattened beside the executable" build.log +"$MCPP" run > run.log 2>&1 || fail "the program did not run" run.log +echo "placement OK" + +# The test binaries see the same layout beside themselves. +mkdir -p tests +cat > tests/layout.cpp <<'CPP' +#include +int main(int, char** argv) { + const auto dir = std::filesystem::absolute(argv[0]).parent_path(); + return std::filesystem::exists(dir / "vulkan" / "icd.d" / "lvp_icd.json") + && std::filesystem::exists(dir / "layers" / "lvp_icd.json") ? 0 : 1; +} +CPP +"$MCPP" test > test.log 2>&1 \ + || fail "a test binary did not find the deployed layout beside itself" test.log +rm -rf tests +echo "test layout OK" + +# ── 3. Two sources for one destination ──────────────────────────────────── +write_manifest '{ from = "assets/layers/lvp_icd.json", to = "vulkan/icd.d" }' +if "$MCPP" build > collision.log 2>&1; then + fail "two sources for bin/vulkan/icd.d/lvp_icd.json were accepted" collision.log +fi +grep -Eq "runtime deploy collision: .* both target 'bin.vulkan.icd\.d.lvp_icd\.json'" collision.log \ + || fail "the collision is not refused naming the destination" collision.log +echo "collision OK" + +# ── 4. A destination outside the executable's directory ─────────────────── +write_manifest '{ from = "assets/readme.txt", to = "../outside" }' +if "$MCPP" build > escape.log 2>&1; then + fail "a destination outside the executable's directory was accepted" escape.log +fi +grep -Fq 'runtime.deploy[1]: `to` has a `.` or `..` component' escape.log \ + || fail "the refusal does not name the entry and the component" escape.log +echo "escape refusal OK" diff --git a/tests/e2e/647_abi_threads_is_one_switch_for_the_graph.sh b/tests/e2e/647_abi_threads_is_one_switch_for_the_graph.sh new file mode 100755 index 000000000..fa2e59cc7 --- /dev/null +++ b/tests/e2e/647_abi_threads_is_one_switch_for_the_graph.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# requires: elf +# 647_abi_threads_is_one_switch_for_the_graph.sh -- `[target..abi] +# threads` and `requires_abi` (design 2026-09-12, section 5.2). +# +# The threads ABI is a property of the artefact: every translation unit, the +# standard library module and the link must agree, so the root decides it and a +# dependency states that it needs it. Asserted on an ELF host, where the switch +# renders as -pthread: +# 1. a dependency that requires threads is refused while the root does not +# state them, naming the dependency and what required them (the package, or +# one of its features); +# 2. with the root's `[target.'cfg(os = "linux")'.abi] threads = true` the +# build succeeds, -pthread reaches the dependency's compile command, and the +# build lands in a different fingerprint directory than one without it; +# 3. a dependency that writes the table itself is reported and changes nothing; +# 4. a table scoped to another target changes nothing in a host build. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +ABI_TABLE=$(cat <<'TOML' +[target.'cfg(os = "linux")'.abi] +threads = true +TOML +) + +mkdir -p "$TMP/mtdep/src" +cd "$TMP/mtdep" +printf 'export module mtdep;\nexport int mt_value() { return 5; }\n' > src/mtdep.cppm +write_dep() { # $1 = extra [package] lines, $2 = trailing tables + cat > "$TMP/mtdep/mcpp.toml" < src/main.cpp <<'CPP' +import mtdep; +int main() { return mt_value() == 5 ? 0 : 1; } +CPP +write_app() { # $1 = the dependency's inline table, $2 = trailing tables + cat > "$TMP/app/mcpp.toml" < plain.log 2>&1 || fail "the plain build failed" plain.log +plain_dir=$(dirname "$(newest_ninja)") +if pthread_in_dep_command; then + fail "control: -pthread is already on the dependency's compile command without the switch" plain.log +fi + +# A table scoped to another target changes nothing in a host build. +SCOPED_TABLE=$(cat <<'TOML' +[target.'cfg(os = "emscripten")'.abi] +threads = true +TOML +) +write_app '{ path = "../mtdep" }' "$SCOPED_TABLE" +"$MCPP" build > scoped.log 2>&1 || fail "a table scoped to emscripten failed a host build" scoped.log +[ "$(dirname "$(newest_ninja)")" = "$plain_dir" ] \ + || fail "a table scoped to emscripten moved the host build to another fingerprint directory" scoped.log +if pthread_in_dep_command; then + fail "a table scoped to emscripten put -pthread on a host build" scoped.log +fi +echo "scoped table inert on the host OK" + +# ── 1. Refused while the root does not state threads ────────────────────── +write_dep 'requires_abi = { threads = true }' '' +if "$MCPP" build > refuse-package.log 2>&1; then + fail "a dependency requiring threads was built without them" refuse-package.log +fi +grep -qF "\`mtdep\` requires the artefact's ABI to have threads (the package)" refuse-package.log \ + || fail "the refusal does not name the package requirement" refuse-package.log +grep -qF 'threads = true' refuse-package.log \ + || fail "the refusal does not show the table that states it" refuse-package.log + +write_dep '' '' +write_app '{ path = "../mtdep", features = ["mt"] }' '' +if "$MCPP" build > refuse-feature.log 2>&1; then + fail "a feature requiring threads was built without them" refuse-feature.log +fi +grep -qF "\`mtdep\` requires the artefact's ABI to have threads (feature \`mt\`)" refuse-feature.log \ + || fail "the refusal does not name the feature" refuse-feature.log +echo "refusals OK" + +# ── 2. The root states it ───────────────────────────────────────────────── +write_app '{ path = "../mtdep", features = ["mt"] }' "$ABI_TABLE" +"$MCPP" build > threads.log 2>&1 || fail "the build with threads failed" threads.log +pthread_in_dep_command || fail "-pthread did not reach the dependency's compile command" threads.log +[ "$(dirname "$(newest_ninja)")" != "$plain_dir" ] \ + || fail "the build with threads reused the fingerprint directory of the build without" threads.log +"$MCPP" run > run.log 2>&1 || fail "the program did not run" run.log +echo "switch OK" + +# ── 3. A dependency's own table is reported and changes nothing ─────────── +rm -rf target +write_dep '' "$ABI_TABLE" +write_app '{ path = "../mtdep" }' '' +"$MCPP" build > dep-table.log 2>&1 || fail "a dependency's abi table failed the build" dep-table.log +grep -qF "\`mtdep\` declares [target..abi], which only the root manifest decides" dep-table.log \ + || fail "a dependency's abi table was not reported" dep-table.log +if pthread_in_dep_command; then + fail "a dependency's abi table changed the dependency's compile command" dep-table.log +fi +echo "dependency table OK" diff --git a/tests/e2e/648_an_install_hook_sees_the_build_target.sh b/tests/e2e/648_an_install_hook_sees_the_build_target.sh new file mode 100755 index 000000000..6b015f90d --- /dev/null +++ b/tests/e2e/648_an_install_hook_sees_the_build_target.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# requires: elf +# 648_an_install_hook_sees_the_build_target.sh -- #613: a dependency's install +# hook receives the build's target under the names a build program uses, and the +# two toolchain names present and empty. +# +# The toolchain is resolved after the dependency graph, so no compiler or +# standard library exists when a dependency installs, and the values are emitted +# empty rather than guessed. The run exports both toolchain names first: a hook +# that reads a non-empty value read one inherited from its parent, which the +# always-emitted rule exists to prevent. +# +# The package below has no download. Its `install()` writes the module mcpp +# compiles next, with the values it read compiled in, and the consumer prints +# them. What is compared is therefore what the hook saw, carried in the artefact. +# The composition itself is unit-tested on every platform (test_install_hook_env). +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +export MCPP_HOME="$TMP/mcpp-home" +source "$(dirname "$0")/_inherit_toolchain.sh" + +mkdir -p "$TMP/proj/local-index/pkgs/h" "$TMP/proj/src" +cd "$TMP/proj" + +cat > local-index/pkgs/h/acme.hookprobe.lua <<'LUA' +package = { + spec = "1", + namespace = "acme", + name = "hookprobe", + description = "Compiles in what its install hook saw", + licenses = {"MIT"}, + type = "package", + xpm = { + linux = { ["latest"] = { ref = "1.0.0" }, ["1.0.0"] = { } }, + macosx = { ["latest"] = { ref = "1.0.0" }, ["1.0.0"] = { } }, + windows = { ["latest"] = { ref = "1.0.0" }, ["1.0.0"] = { } }, + }, + mcpp = { + language = "c++23", + import_std = false, + sources = { "src/hookprobe.cppm" }, + targets = { ["hookprobe"] = { kind = "lib" } }, + deps = {}, + }, +} + +import("xim.libxpkg.pkginfo") + +function install() + local dir = pkginfo.install_dir() + os.mkdir(path.join(dir, "src")) + local function value(name) return os.getenv(name) or "" end + io.writefile(path.join(dir, "src", "hookprobe.cppm"), string.format([[ +export module hookprobe; +export const char* hook_compiler() { return "%s"; } +export const char* hook_stdlib() { return "%s"; } +export const char* hook_target_os() { return "%s"; } +export const char* hook_target() { return "%s"; } +]], value("MCPP_COMPILER"), value("MCPP_CXX_STDLIB"), value("MCPP_TARGET_OS"), + value("MCPP_TARGET"))) + return true +end +LUA + +cat > src/main.cpp <<'CPP' +#include +import hookprobe; +int main() { + std::printf("compiler=%s stdlib=%s os=%s target=%s\n", + hook_compiler(), hook_stdlib(), hook_target_os(), hook_target()); + return 0; +} +CPP + +cat > mcpp.toml <<'TOML' +[package] +name = "consumer" +version = "0.1.0" + +[dependencies.acme] +hookprobe = "1.0.0" + +[indices] +acme = { path = "local-index" } +TOML + +MCPP_COMPILER=inherited MCPP_CXX_STDLIB=inherited "$MCPP" run > run.log 2>&1 \ + || fail "the build or the run failed" run.log +grep -Eq '^compiler= stdlib= os=linux target=[a-z0-9_]+-linux' run.log \ + || fail "the install hook did not see the build's target with empty toolchain names" run.log +echo "install hook environment OK" diff --git a/tests/e2e/649_pack_carries_runtime_deploy_files.sh b/tests/e2e/649_pack_carries_runtime_deploy_files.sh new file mode 100755 index 000000000..2d49a3758 --- /dev/null +++ b/tests/e2e/649_pack_carries_runtime_deploy_files.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# requires: pack +# 649_pack_carries_runtime_deploy_files.sh -- `mcpp pack` stages what the build +# placed relative to the executable (`runtime.deploy_files` and +# `runtime.deploy`, #615) at the same relative path beside the packed +# executable. Pack read neither list before, so a program that found its driver +# manifest in the build directory could not find it once packed. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +export MCPP_HOME=$HOME/.mcpp + +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +cd "$TMP" +"$MCPP" new app > /dev/null +cd app +mkdir -p share/vulkan/icd.d +printf 'icd manifest\n' > share/vulkan/icd.d/lvp_icd.json +printf 'notes\n' > share/notes.txt +cat >> mcpp.toml <<'TOML' + +[toolchain] +linux = "gcc@16.1.0" + +[runtime] +deploy_files = ["share/notes.txt"] +deploy = [ { from = "share/vulkan/icd.d/lvp_icd.json", to = "vulkan/icd.d" } ] +TOML + +"$MCPP" pack > pack.log 2>&1 || fail "mcpp pack failed" pack.log +tarball=$(ls target/dist/app-0.1.0-*.tar.gz 2>/dev/null | head -1) +[ -n "$tarball" ] || fail "no tarball under target/dist" pack.log +mkdir -p "$TMP/x" +tar -xzf "$tarball" -C "$TMP/x" +root=$(ls -d "$TMP"/x/app-0.1.0-*/ | head -1) +[ -x "${root}bin/app" ] || fail "the bundle has no bin/app" pack.log +grep -q 'icd manifest' "${root}bin/vulkan/icd.d/lvp_icd.json" 2>/dev/null \ + || fail "runtime.deploy did not reach bin/vulkan/icd.d in the bundle" pack.log +grep -q 'notes' "${root}bin/notes.txt" 2>/dev/null \ + || fail "runtime.deploy_files did not reach bin/ in the bundle" pack.log +echo "pack carries runtime files OK" diff --git a/tests/e2e/_windows_subsystem_body.sh b/tests/e2e/_windows_subsystem_body.sh new file mode 100644 index 000000000..dc8c62502 --- /dev/null +++ b/tests/e2e/_windows_subsystem_body.sh @@ -0,0 +1,130 @@ +# Shared body for the `windows_subsystem` / `windows_entry` e2e tests (#618). +# +# Sourced by 642 (native Windows, MSVC ABI) and 643 (Linux to Windows through +# mingw-cross, GNU ABI). The two ABIs take different flags for one declaration, +# and asserting the same bytes through both keeps that fork honest. Every +# assertion reads the PE optional header's Subsystem field from the linked +# image, so a flag that was spelled but ignored by the linker cannot pass. +# +# Callers set, before sourcing: +# TMP scratch directory (created, trap-cleaned) +# MCPP the binary under test +# BUILD_ARGS extra `mcpp build` arguments ("" natively, --target when cross) +# RUN_EXE a command that executes a PE image, or "" when this host has +# none; criterion 3 is then reported as not measured + +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +# The Subsystem field of a PE image: e_lfanew at 0x3C, then the 4-byte +# signature and the 20-byte COFF header, then offset 68 of the optional header, +# which is the same in PE32 and PE32+. 2 is WINDOWS_GUI and 3 is WINDOWS_CUI. +pe_subsystem() { + local lfanew + lfanew=$(od -An -tu4 -j 60 -N 4 "$1" | tr -d ' ') + od -An -tu2 -j $((lfanew + 92)) -N 2 "$1" | tr -d ' ' +} + +mkdir -p "$TMP/proj/src" "$TMP/proj/app" +cd "$TMP/proj" + +cat > src/probe.cppm <<'EOF' +export module probe; +export int probe_value() { return 7; } +EOF + +# A static constructor and `main` each write a line. With `/ENTRY:main` the CRT +# is never initialised and the constructor line is missing; with the CRT +# startup symbol it precedes the `main` line. +cat > app/gui.cpp <<'EOF' +#include +struct Probe { + Probe() { + if (auto f = std::fopen("order.txt", "w")) { std::fputs("ctor\n", f); std::fclose(f); } + } +}; +static Probe probe_instance; +int main() { + if (auto f = std::fopen("order.txt", "a")) { std::fputs("main\n", f); std::fclose(f); } + return 0; +} +EOF +printf 'int main() { return 0; }\n' > app/cli.cpp +printf 'int main() { return 0; }\n' > app/tool.cpp +printf 'int wmain() { return 0; }\n' > app/wide.cpp +cat > app/winmain.cpp <<'EOF' +#include +int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int) { return 0; } +EOF + +# The directive form: the program, not the manifest, chooses `tool`'s subsystem. +cat > build.mcpp <<'EOF' +import mcpp; +int main() { + mcpp::windows_subsystem("tool", "windows"); + return 0; +} +EOF + +cat > mcpp.toml <<'EOF' +[package] +name = "subsys" +version = "0.1.0" + +[modules] +sources = ["src/**/*.cppm"] + +[targets.gui] +kind = "bin" +main = "app/gui.cpp" +windows_subsystem = "windows" + +[targets.cli] +kind = "bin" +main = "app/cli.cpp" + +[targets.tool] +kind = "bin" +main = "app/tool.cpp" + +[targets.wide] +kind = "bin" +main = "app/wide.cpp" +windows_entry = "wmain" + +[targets.winmain] +kind = "bin" +main = "app/winmain.cpp" +windows_subsystem = "windows" +windows_entry = "WinMain" +EOF + +# shellcheck disable=SC2086 +"$MCPP" build $BUILD_ARGS > build.log 2>&1 || fail "the build failed" build.log + +exe_of() { + local p + p=$(find target -type f -name "$1.exe" -path '*/bin/*' | head -1) + [ -n "$p" ] || fail "no $1.exe under target/" build.log + printf '%s' "$p" +} + +# Criteria 1 and 2: the declaring executables read 2, the others read 3. +for pair in gui:2 tool:2 winmain:2 cli:3 wide:3; do + name=${pair%%:*}; want=${pair##*:} + got=$(pe_subsystem "$(exe_of "$name")") + [ "$got" = "$want" ] || fail "$name.exe has Subsystem $got, expected $want" build.log +done +echo "subsystem bytes OK (gui, tool, winmain = 2; cli, wide = 3)" + +# Criterion 3: the GUI program's static constructor runs before main, and the +# wide entry program starts. +if [ -n "$RUN_EXE" ]; then + rm -f order.txt + $RUN_EXE "$(exe_of gui)" > run.log 2>&1 || fail "gui.exe did not exit 0" run.log + [ "$(tr -d '\r' < order.txt 2>/dev/null)" = "$(printf 'ctor\nmain')" ] \ + || fail "the static constructor did not run before main" order.txt run.log + $RUN_EXE "$(exe_of wide)" > run.log 2>&1 || fail "wide.exe did not exit 0" run.log + echo "CRT initialisation OK" +else + echo "NOT MEASURED: criterion 3, because this host cannot execute a PE image" +fi diff --git a/tests/unit/test_build_directives.cpp b/tests/unit/test_build_directives.cpp index 90f22ea86..e9d3584c3 100644 --- a/tests/unit/test_build_directives.cpp +++ b/tests/unit/test_build_directives.cpp @@ -209,7 +209,9 @@ TEST(BuildDirectives, SerializeDeserializeRoundTrip) { "mcpp:include-dir-after=after\n" "mcpp:fact=widget.driver=1.2\n" "mcpp:floor=widget.driver >= 1.0\n" - "mcpp:pack-format=appimage\n"); + "mcpp:pack-format=appimage\n" + "mcpp:windows-subsystem=gui:windows\n" + "mcpp:windows-entry=gui:wWinMain\n"); std::ostringstream os; dirs::serialize(os, d); @@ -718,3 +720,85 @@ TEST(BuildDirectives, DecodeActionDefaultsDepfileToEmptyWhenAbsent) { ASSERT_TRUE(a.has_value()); EXPECT_EQ(a->depfile, ""); } + +// ── #618: a named executable's subsystem and entry ────────────────────────── +// +// `windows-subsystem` and `windows-entry` name a target of the package being +// built. What is asserted: the value reaches that target's fields and nothing +// else, and every value `apply` could not honour is refused before it runs. + +namespace { + +mcpp::manifest::Manifest manifest_with_gui_and_core() { + mcpp::manifest::Manifest m; + m.package.name = "app"; + mcpp::manifest::Target gui; + gui.name = "gui"; + gui.kind = mcpp::manifest::Target::Binary; + mcpp::manifest::Target core; + core.name = "core"; + core.kind = mcpp::manifest::Target::Library; + m.targets = {gui, core}; + return m; +} + +} // namespace + +TEST(BuildDirectives, WindowsSubsystemReachesTheNamedExecutableOnly) { + auto d = parse("mcpp:protocol=10\n" + "mcpp:windows-subsystem=gui:windows\n" + "mcpp:windows-entry=gui:wWinMain\n"); + ASSERT_FALSE(dirs::protocol_error(d).has_value()); + auto m = manifest_with_gui_and_core(); + ASSERT_EQ(dirs::target_directive_error(m, d), ""); + dirs::apply(m, d); + EXPECT_EQ(m.targets[0].windowsSubsystem, "windows"); + EXPECT_EQ(m.targets[0].windowsEntry, "wWinMain"); + EXPECT_TRUE(m.targets[1].windowsSubsystem.empty()); + EXPECT_TRUE(m.targets[1].windowsEntry.empty()); + // Nothing reaches a flag channel another target or a consumer reads. + EXPECT_TRUE(m.buildConfig.ldflags.empty()); + EXPECT_TRUE(m.buildConfig.cxxflags.empty()); +} + +TEST(BuildDirectives, WindowsSubsystemRowsHaveTheTargetLinkScope) { + for (auto wire : {"windows-subsystem", "windows-entry"}) { + auto def = dirs::find_by_wire(wire); + ASSERT_NE(def, nullptr) << wire; + EXPECT_EQ(def->scope, dirs::Scope::TargetLink) << wire; + EXPECT_EQ(def->sinceProtocol, 10) << wire; + EXPECT_FALSE(def->tag.empty()) << wire; + } +} + +TEST(BuildDirectives, WindowsSubsystemRefusesWhatApplyCannotHonour) { + const std::pair cases[] = { + {"mcpp:windows-subsystem=windows\n", "which is not `:`"}, + {"mcpp:windows-subsystem=gui:\n", "which is not `:`"}, + {"mcpp:windows-subsystem=gui:gui\n", "\"gui\" is not one of \"console\", \"windows\""}, + {"mcpp:windows-entry=gui:main2\n", + "\"main2\" is not one of \"main\", \"wmain\", \"WinMain\", \"wWinMain\""}, + {"mcpp:windows-subsystem=nosuch:windows\n", + "declares no target named `nosuch` (its targets: gui, core)"}, + {"mcpp:windows-entry=core:wmain\n", "target `core` is not one"}, + {"mcpp:windows-subsystem=gui:windows\nmcpp:windows-subsystem=gui:console\n", + "twice for target `gui`, as \"windows\" and as \"console\""}, + }; + for (auto [text, expected] : cases) { + auto d = parse(std::format("mcpp:protocol=10\n{}", text)); + auto err = dirs::target_directive_error(manifest_with_gui_and_core(), d); + EXPECT_NE(err.find(expected), std::string::npos) << text << " -> " << err; + } +} + +TEST(BuildDirectives, WindowsSubsystemThatContradictsTheManifestIsRefused) { + auto m = manifest_with_gui_and_core(); + m.targets[0].windowsSubsystem = "console"; + auto d = parse("mcpp:protocol=10\nmcpp:windows-subsystem=gui:windows\n"); + auto err = dirs::target_directive_error(m, d); + EXPECT_NE(err.find("[targets.gui] windows_subsystem = \"console\""), std::string::npos) + << err; + // Restating the manifest's own value is not a contradiction. + m.targets[0].windowsSubsystem = "windows"; + EXPECT_EQ(dirs::target_directive_error(m, d), ""); +} diff --git a/tests/unit/test_install_hook_env.cpp b/tests/unit/test_install_hook_env.cpp new file mode 100644 index 000000000..bc8222ac7 --- /dev/null +++ b/tests/unit/test_install_hook_env.cpp @@ -0,0 +1,62 @@ +#include + +import std; +import mcpp.build.build_program; + +// #613: the part of a build program's environment an install hook receives. +// The names, their order, and the rule for each value are stated here once for +// every platform; tests/e2e/648 shows a real install hook reading them. + +namespace { + +using HookEnv = std::vector>; + +std::string value_of(const HookEnv& env, std::string_view key) { + for (auto const& [k, v] : env) + if (k == key) return v; + ADD_FAILURE() << key << " is missing"; + return {}; +} + +} // namespace + +TEST(InstallHookEnv, NamesTheSixVariablesInOrder) { + auto env = mcpp::build::install_hook_env(mcpp::build::BuildProgramEnv{}); + std::vector names; + for (auto const& [k, v] : env) names.push_back(k); + EXPECT_EQ(names, (std::vector{ + "MCPP_COMPILER", "MCPP_CXX_STDLIB", "MCPP_TARGET", + "MCPP_TARGET_OS", "MCPP_TARGET_ARCH", "MCPP_TARGET_ENV"})); +} + +TEST(InstallHookEnv, WithoutAToolchainTheToolchainValuesArePresentAndEmpty) { + auto env = mcpp::build::install_hook_env(mcpp::build::BuildProgramEnv{}); + EXPECT_EQ(value_of(env, "MCPP_COMPILER"), ""); + EXPECT_EQ(value_of(env, "MCPP_CXX_STDLIB"), ""); +} + +TEST(InstallHookEnv, ANativeBuildNamesTheHost) { + mcpp::build::BuildProgramEnv bp; + bp.compilerId = "gcc"; + bp.cxxStdlib = "libstdc++"; + auto env = mcpp::build::install_hook_env(bp); + EXPECT_EQ(value_of(env, "MCPP_COMPILER"), "gcc"); + EXPECT_EQ(value_of(env, "MCPP_CXX_STDLIB"), "libstdc++"); + const auto target = value_of(env, "MCPP_TARGET"); + ASSERT_FALSE(target.empty()); + EXPECT_FALSE(value_of(env, "MCPP_TARGET_OS").empty()) << target; + EXPECT_TRUE(target.starts_with(value_of(env, "MCPP_TARGET_ARCH"))) << target; +} + +TEST(InstallHookEnv, ACrossBuildSplitsTheRequestedTriple) { + mcpp::build::BuildProgramEnv bp; + bp.targetTriple = "aarch64-linux-android"; + bp.compilerId = "clang"; + bp.cxxStdlib = "libc++"; + auto env = mcpp::build::install_hook_env(bp); + EXPECT_EQ(value_of(env, "MCPP_TARGET"), "aarch64-linux-android"); + EXPECT_EQ(value_of(env, "MCPP_TARGET_OS"), "linux"); + EXPECT_EQ(value_of(env, "MCPP_TARGET_ARCH"), "aarch64"); + EXPECT_EQ(value_of(env, "MCPP_TARGET_ENV"), "android"); + EXPECT_EQ(value_of(env, "MCPP_CXX_STDLIB"), "libc++"); +} diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 94ebfa090..17bdca98f 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -2413,6 +2413,100 @@ cxxfalgs = ["-DTYPO"] EXPECT_NE(m->schemaWarnings[0].find("unsupported key"), std::string::npos); } +// #618: `windows_subsystem` / `windows_entry` on an executable target. +TEST(Manifest, ParsesWindowsSubsystemAndEntryOnABinaryTarget) { + constexpr auto src = R"( +[package] +name = "app" +version = "0.1.0" +[targets.app] +kind = "bin" +main = "src/main.cpp" +windows_subsystem = "windows" +windows_entry = "wWinMain" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->targets.size(), 1u); + EXPECT_EQ(m->targets[0].windowsSubsystem, "windows"); + EXPECT_EQ(m->targets[0].windowsEntry, "wWinMain"); + EXPECT_TRUE(m->schemaWarnings.empty()); +} + +TEST(Manifest, RefusesWindowsKeysOnALibraryNamingTheTargetAndTheKey) { + const std::pair keys[] = { + {"windows_subsystem", "windows"}, {"windows_entry", "wmain"}}; + for (std::string_view kind : {"lib", "shared"}) { + for (auto [key, value] : keys) { + const auto src = std::format(R"( +[package] +name = "app" +version = "0.1.0" +[targets.core] +kind = "{}" +{} = "{}" +)", kind, key, value); + auto m = mcpp::manifest::parse_string(src); + ASSERT_FALSE(m.has_value()) << kind << " " << key; + EXPECT_NE(m.error().message.find(std::format("targets.core.{}", key)), + std::string::npos) << m.error().message; + EXPECT_NE(m.error().message.find("executable"), std::string::npos) + << m.error().message; + } + } +} + +TEST(Manifest, RefusesAnUnknownWindowsValueNamingTheAcceptedOnes) { + constexpr auto subsystem = R"( +[package] +name = "app" +version = "0.1.0" +[targets.app] +kind = "bin" +main = "src/main.cpp" +windows_subsystem = "gui" +)"; + auto m = mcpp::manifest::parse_string(subsystem); + ASSERT_FALSE(m.has_value()); + EXPECT_NE(m.error().message.find("\"gui\" is not one of \"console\", \"windows\""), + std::string::npos) << m.error().message; + + constexpr auto entry = R"( +[package] +name = "app" +version = "0.1.0" +[targets.app] +kind = "bin" +main = "src/main.cpp" +windows_entry = "mainCRTStartup" +)"; + auto e = mcpp::manifest::parse_string(entry); + ASSERT_FALSE(e.has_value()); + EXPECT_NE(e.error().message.find( + "is not one of \"main\", \"wmain\", \"WinMain\", \"wWinMain\""), + std::string::npos) << e.error().message; +} + +// The key list in the warning is generated from the list the parser accepts, +// so a key the parser reads cannot be missing from the message. +TEST(Manifest, UnsupportedTargetKeyWarningListsEveryAcceptedKey) { + constexpr auto src = R"( +[package] +name = "app" +version = "0.1.0" +[targets.app] +kind = "bin" +main = "src/main.cpp" +bogus = "x" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->schemaWarnings.size(), 1u); + for (auto key : {"exports", "windows_subsystem", "windows_entry", "required_features"}) + EXPECT_NE(m->schemaWarnings[0].find(key), std::string::npos) + << key << " missing from: " << m->schemaWarnings[0]; +} + TEST(Manifest, RejectsStdFlagInTargetCxxflags) { constexpr auto src = R"( [package] @@ -5166,3 +5260,124 @@ TEST(Manifest, AByteOrderMarkOnTheManifestIsNotAnError) { ASSERT_TRUE(m.has_value()) << m.error().format(); EXPECT_EQ(m->package.name, "x"); } + +// ── #615: `runtime.deploy` ────────────────────────────────────────────────── + +TEST(Manifest, RuntimeDeployParsesFromAndTo) { + constexpr auto src = R"( +[package] +name = "icd" +version = "0.1.0" +[runtime] +deploy = [ + { from = "share/vulkan/icd.d/lvp_icd.json", to = "vulkan/icd.d" }, + { from = "share/readme.txt", to = "." }, +] +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + auto const& d = m->runtimeConfig.linkIntent.deploy; + ASSERT_EQ(d.size(), 2u); + EXPECT_EQ(d[0].from, std::filesystem::path("share/vulkan/icd.d/lvp_icd.json")); + EXPECT_EQ(d[0].to, "vulkan/icd.d"); + EXPECT_EQ(d[1].from, std::filesystem::path("share/readme.txt")); + EXPECT_EQ(d[1].to, "."); + // A key of its own: `deploy_files` is untouched. + EXPECT_TRUE(m->runtimeConfig.linkIntent.deployFiles.empty()); + EXPECT_TRUE(m->schemaWarnings.empty()); +} + +TEST(Manifest, RuntimeDeployRefusesEachMalformedEntryNamingIt) { + const std::pair cases[] = { + {R"({ from = "a.json", to = "../outside" })", "`to` has a `.` or `..` component"}, + {R"({ from = "/etc/a.json", to = "x" })", "`from` is absolute"}, + {R"({ from = "C:/a.json", to = "x" })", "`from` names a drive or a scheme"}, + {R"({ from = "a\\b.json", to = "x" })", "`from` contains a backslash"}, + {R"({ from = "a.json", to = "" })", "`to` is empty"}, + {R"({ from = ".", to = "x" })", "`from` has a `.` or `..` component"}, + {R"({ from = "a//b.json", to = "x" })", "`from` has an empty path component"}, + {R"({ from = "a.json", dest = "x" })", "has unsupported key 'dest'"}, + {R"({ from = "a.json", to = 3 })", "`from` and `to` must be strings"}, + {R"("a.json")", "must be a table with `from` and `to`"}, + }; + for (auto [entry, expected] : cases) { + const auto src = std::format(R"( +[package] +name = "icd" +version = "0.1.0" +[runtime] +deploy = [ {} ] +)", entry); + auto m = mcpp::manifest::parse_string(src); + ASSERT_FALSE(m.has_value()) << entry; + EXPECT_NE(m.error().message.find(expected), std::string::npos) + << entry << " -> " << m.error().message; + EXPECT_NE(m.error().message.find("runtime.deploy[1]"), std::string::npos) + << m.error().message; + } +} + +// ── `requires_abi` (design 2026-09-12, section 5.2) ──────────────────────── + +TEST(Manifest, RequiresAbiThreadsParsesOnThePackageAndOnAFeature) { + constexpr auto src = R"( +[package] +name = "wasmrt" +version = "0.1.0" +requires_abi = { threads = true } +[features] +mt = { requires_abi = { threads = true } } +st = { requires_abi = { threads = false } } +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + EXPECT_TRUE(m->requiresAbiThreads); + ASSERT_TRUE(m->featureRequiresAbiThreads.contains("mt")); + EXPECT_TRUE(m->featureRequiresAbiThreads.at("mt")); + ASSERT_TRUE(m->featureRequiresAbiThreads.contains("st")); + EXPECT_FALSE(m->featureRequiresAbiThreads.at("st")); + EXPECT_TRUE(m->schemaWarnings.empty()); +} + +TEST(Manifest, AbiTablesRefuseAnythingButABooleanThreads) { + const std::pair cases[] = { + {"[package]\nname = \"a\"\nversion = \"0.1.0\"\nrequires_abi = true\n", + "[package] requires_abi must be a table such as `{ threads = true }`"}, + {"[package]\nname = \"a\"\nversion = \"0.1.0\"\nrequires_abi = { threads = 1 }\n", + "[package] requires_abi.threads: the members are `threads`, a boolean"}, + {"[package]\nname = \"a\"\nversion = \"0.1.0\"\n[features]\nmt = { requires_abi = { thread = true } }\n", + "features.mt.requires_abi.thread: the members are `threads`, a boolean"}, + {"[package]\nname = \"a\"\nversion = \"0.1.0\"\n[target.'cfg(os = \"linux\")'.abi]\nthread = true\n", + "has no member 'thread'; the members are: threads"}, + {"[package]\nname = \"a\"\nversion = \"0.1.0\"\n[target.'cfg(os = \"linux\")'.abi]\nthreads = \"yes\"\n", + ".threads must be true or false"}, + }; + for (auto [src, expected] : cases) { + auto m = mcpp::manifest::parse_string(src); + ASSERT_FALSE(m.has_value()) << src; + EXPECT_NE(m.error().message.find(expected), std::string::npos) + << src << " -> " << m.error().message; + } +} + +// ── The per-target tool declaration (design 2026-09-12, section 5.1) ─────── +// +// A package written directly under `[target..xlings]` is refused, and +// the refusal names the table that does accept it, which is the statement the +// SDK batch's record was missing when it concluded the declaration did not exist. +TEST(Manifest, TargetXlingsRefusalNamesTheWorkspaceTable) { + constexpr auto src = R"( +[package] +name = "app" +version = "0.1.0" +[target.aarch64-ios-sim.xlings] +"xim:apple-simulator-tools" = "" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_FALSE(m.has_value()); + EXPECT_NE(m.error().message.find( + "[target.aarch64-ios-sim.xlings] does not accept 'xim:apple-simulator-tools'"), + std::string::npos) << m.error().message; + EXPECT_NE(m.error().message.find("[target.aarch64-ios-sim.xlings.workspace]"), + std::string::npos) << m.error().message; +} diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index e9362f727..bdb890bab 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -2050,3 +2050,130 @@ TEST(NinjaBackend, MsvcModuleEdgesSplitTheInterfaceFlagFromTheLanguageFlag) { // The trailing-space spelling with nothing after it must not exist. EXPECT_EQ(ninja.find("/ifcOutput \n"), std::string::npos) << ninja; } + +// ── #618: `windows_subsystem` / `windows_entry` ───────────────────────────── +// +// Every row of the rendering tables in the design record (§1.2, §1.3), stated +// against the function the emitter calls, and once through the emitted graph. + +namespace { + +BuildPlan plan_for_triple(std::string_view triple) { + auto plan = minimal_plan(); + plan.toolchain.targetTriple = std::string(triple); + return plan; +} + +using Flags = std::vector; + +} // namespace + +TEST(NinjaBackend, WindowsKeysRenderNothingOffPe) { + for (auto triple : {"x86_64-linux-gnu", "x86_64-linux-musl", "aarch64-macos"}) { + auto plan = plan_for_triple(triple); + for (bool sep : {false, true}) + EXPECT_EQ(windows_executable_link_flags(plan, sep, "windows", "wWinMain"), Flags{}) + << triple; + } +} + +TEST(NinjaBackend, WindowsDefaultsRenderNothingOnEitherAbi) { + for (auto triple : {"x86_64-windows-msvc", "x86_64-windows-gnu"}) { + auto plan = plan_for_triple(triple); + for (auto [subsystem, entry] : {std::pair{"", ""}, std::pair{"console", ""}, + std::pair{"", "main"}, std::pair{"console", "main"}}) + EXPECT_EQ(windows_executable_link_flags(plan, false, subsystem, entry), Flags{}) + << triple << " " << subsystem << " " << entry; + } +} + +TEST(NinjaBackend, WindowsMsvcAbiStatesBothSubsystemAndCrtEntry) { + auto plan = plan_for_triple("x86_64-windows-msvc"); + const std::tuple rows[] = { + {"windows", "", {"/SUBSYSTEM:WINDOWS", "/ENTRY:mainCRTStartup"}}, + {"windows", "main", {"/SUBSYSTEM:WINDOWS", "/ENTRY:mainCRTStartup"}}, + {"windows", "WinMain", {"/SUBSYSTEM:WINDOWS", "/ENTRY:WinMainCRTStartup"}}, + {"windows", "wWinMain", {"/SUBSYSTEM:WINDOWS", "/ENTRY:wWinMainCRTStartup"}}, + {"windows", "wmain", {"/SUBSYSTEM:WINDOWS", "/ENTRY:wmainCRTStartup"}}, + {"console", "wmain", {"/SUBSYSTEM:CONSOLE", "/ENTRY:wmainCRTStartup"}}, + {"", "WinMain", {"/SUBSYSTEM:CONSOLE", "/ENTRY:WinMainCRTStartup"}}, + }; + for (auto const& [subsystem, entry, expected] : rows) { + // link.exe invoked directly takes the flag bare... + EXPECT_EQ(windows_executable_link_flags(plan, true, subsystem, entry), expected) + << subsystem << " " << entry; + // ...and a GNU-style driver (clang targeting the MSVC ABI) passes it on. + Flags wrapped; + for (auto const& f : expected) wrapped.push_back("-Wl," + f); + EXPECT_EQ(windows_executable_link_flags(plan, false, subsystem, entry), wrapped) + << subsystem << " " << entry; + } +} + +TEST(NinjaBackend, WindowsGnuAbiUsesTheDriverFlags) { + auto plan = plan_for_triple("x86_64-windows-gnu"); + const std::tuple rows[] = { + {"windows", "", {"-mwindows"}}, + {"windows", "main", {"-mwindows"}}, + {"windows", "WinMain", {"-mwindows"}}, + {"windows", "wWinMain", {"-mwindows", "-municode"}}, + {"console", "wmain", {"-municode"}}, + {"", "WinMain", {}}, + }; + for (auto const& [subsystem, entry, expected] : rows) + EXPECT_EQ(windows_executable_link_flags(plan, false, subsystem, entry), expected) + << subsystem << " " << entry; +} + +// Through the emitted graph: the flags reach the declaring executable's link +// edge and no other edge, including a test binary of the same package. +TEST(NinjaBackend, WindowsSubsystemReachesOnlyTheDeclaringExecutable) { + auto plan = plan_for_triple("x86_64-windows-gnu"); + plan.compileUnits.push_back({ + .source = "src/gui.cpp", + .kind = mcpp::SourceKind::Cxx, + .object = "obj/gui.o", + .packageName = "objc_rule_test", + }); + plan.compileUnits.push_back({ + .source = "src/cli.cpp", + .kind = mcpp::SourceKind::Cxx, + .object = "obj/cli.o", + .packageName = "objc_rule_test", + }); + LinkUnit gui{ + .targetName = "gui", + .kind = mcpp::build::LinkUnit::Binary, + .objects = {"obj/gui.o"}, + .output = "bin/gui.exe", + .entryMain = "src/gui.cpp", + }; + gui.windowsSubsystem = "windows"; + plan.linkUnits.push_back(gui); + plan.linkUnits.push_back({ + .targetName = "cli", + .kind = mcpp::build::LinkUnit::Binary, + .objects = {"obj/cli.o"}, + .output = "bin/cli.exe", + .entryMain = "src/cli.cpp", + }); + LinkUnit test{ + .targetName = "unit", + .kind = mcpp::build::LinkUnit::TestBinary, + .objects = {"obj/cli.o"}, + .output = "bin/unit.exe", + }; + // A test binary never carries the fields (the manifest refuses them), and + // even when a plan sets them the emitter does not render them. + test.windowsSubsystem = "windows"; + plan.linkUnits.push_back(test); + + auto ninja = emit_ninja_string(plan); + EXPECT_EQ(count_occurrences(ninja, "-mwindows"), 1u) << ninja; + const auto guiEdge = ninja.find("build bin/gui.exe"); + ASSERT_NE(guiEdge, std::string::npos) << ninja; + const auto nextEdge = ninja.find("\nbuild ", guiEdge + 1); + const auto flag = ninja.find("-mwindows"); + EXPECT_GT(flag, guiEdge) << ninja; + EXPECT_LT(flag, nextEdge) << ninja; +} diff --git a/tests/unit/test_runtime_contract.cpp b/tests/unit/test_runtime_contract.cpp index 82dbfc0cc..48645f2b6 100644 --- a/tests/unit/test_runtime_contract.cpp +++ b/tests/unit/test_runtime_contract.cpp @@ -447,3 +447,56 @@ TEST(RuntimeIdentity, UcrtIsAFloorDeclarationNotAPrivatePayload) { EXPECT_FALSE(b.loader.has_value()); EXPECT_TRUE(b.libraryDirs.empty()); } + +// #615: `runtime.deploy` in a descriptor -- the same `{ from, to }` entries as +// mcpp.toml, and the same path rule. +TEST(RuntimeContract, XpkgReadsRuntimeDeployEntries) { + constexpr auto lua = R"( +package = { + spec = "1", + namespace = "acme", + name = "icd", + xpm = { linux = { ["1.0.0"] = { url = "u", sha256 = "h" } } }, + mcpp = { + sources = { "src/icd.cpp" }, + runtime = { + deploy = { + { from = "share/vulkan/icd.d/lvp_icd.json", to = "vulkan/icd.d" }, + { to = ".", from = "share/readme.txt" }, + }, + deploy_files = { "bin/backend.dll" }, + }, + }, +} +)"; + auto parsed = mf::synthesize_from_xpkg_lua( + lua, "icd", "1.0.0", mcpp::platform::HostPlatform::current()); + ASSERT_TRUE(parsed) << parsed.error().format(); + auto const& d = parsed->runtimeConfig.linkIntent.deploy; + ASSERT_EQ(d.size(), 2u); + EXPECT_EQ(d[0].from, std::filesystem::path("share/vulkan/icd.d/lvp_icd.json")); + EXPECT_EQ(d[0].to, "vulkan/icd.d"); + EXPECT_EQ(d[1].from, std::filesystem::path("share/readme.txt")); + EXPECT_EQ(d[1].to, "."); + // The key after it is still read, so the entry loop consumed exactly its table. + EXPECT_EQ(parsed->runtimeConfig.linkIntent.deployFiles, + std::vector{"bin/backend.dll"}); +} + +TEST(RuntimeContract, XpkgRuntimeDeployRefusesAnEscapingDestination) { + constexpr auto lua = R"( +package = { + spec = "1", namespace = "acme", name = "icd", + xpm = { linux = { ["1.0.0"] = { url = "u", sha256 = "h" } } }, + mcpp = { + sources = { "src/icd.cpp" }, + runtime = { deploy = { { from = "a.json", to = "../x" } } }, + }, +} +)"; + auto parsed = mf::synthesize_from_xpkg_lua( + lua, "icd", "1.0.0", mcpp::platform::HostPlatform::current()); + ASSERT_FALSE(parsed); + EXPECT_NE(parsed.error().message.find("runtime.deploy[1]: `to` has a `.` or `..` component"), + std::string::npos) << parsed.error().message; +} diff --git a/tests/unit/test_xlings.cpp b/tests/unit/test_xlings.cpp index d7afd3d86..70a9f9b25 100644 --- a/tests/unit/test_xlings.cpp +++ b/tests/unit/test_xlings.cpp @@ -521,3 +521,120 @@ TEST(XlingsIndexRevision, StatusCarriesTheRevision) { std::filesystem::remove_all(home); } + +// ─── stderr_error_tail (#614) ───────────────────────────────────────── +// +// xlings' own error lines follow mcpp's diagnostic when an install fails. The +// selection keeps the lines that name a rejection and bounds them, so a verbose +// child cannot bury the diagnostic they are attached to. + +TEST(XlingsStderrTail, KeepsOnlyErrorLevelLines) { + auto tail = mcpp::xlings::stderr_error_tail( + "[xim] resolving acme.widget\r\n" + "downloading 42%\n" + "Error: checksum mismatch\n" + "hint: E_NETWORK, retry later\n" + "done\n"); + EXPECT_EQ(tail, (std::vector{ + "[xim] resolving acme.widget", + "Error: checksum mismatch", + "hint: E_NETWORK, retry later"})); +} + +TEST(XlingsStderrTail, KeepsTheLastLinesUpToTheLimit) { + std::string text; + for (int i = 0; i < 30; ++i) text += std::format("error {}\n", i); + auto tail = mcpp::xlings::stderr_error_tail(text); + ASSERT_EQ(tail.size(), 20u); + EXPECT_EQ(tail.front(), "error 10"); + EXPECT_EQ(tail.back(), "error 29"); + EXPECT_EQ(mcpp::xlings::stderr_error_tail(text, 2), + (std::vector{"error 28", "error 29"})); + EXPECT_TRUE(mcpp::xlings::stderr_error_tail("").empty()); + EXPECT_EQ(mcpp::xlings::stderr_error_tail("error without a newline"), + (std::vector{"error without a newline"})); +} + +// ─── invocation_env / ScopedInvocationEnv (#614) ────────────────────── +// +// One decision for the environment of an xlings invocation, rendered per +// platform. Global mode is an absent XLINGS_PROJECT_DIR on both platforms, and +// the process environment is the same after the invocation as before it. + +namespace { + +mcpp::xlings::Env xlings_env(std::string_view projectDir) { + mcpp::xlings::Env env; + env.home = std::filesystem::temp_directory_path() / "mcpp-xlings-home"; + env.binary = env.home / "bin" / "xlings"; + env.projectDir = std::filesystem::path(projectDir); + return env; +} + +// The decided entry for `key`; a default entry named "" when the decision omits +// the key. +mcpp::xlings::InvocationVar decided(const mcpp::xlings::Env& env, std::string_view key) { + for (auto const& var : mcpp::xlings::invocation_env(env)) + if (var.name == key) return var; + return {}; +} + +} // namespace + +TEST(XlingsInvocationEnv, GlobalModeIsAnAbsentProjectDirectory) { + auto global = xlings_env(""); + auto scope = decided(global, "XLINGS_PROJECT_DIR"); + ASSERT_EQ(scope.name, "XLINGS_PROJECT_DIR"); + EXPECT_FALSE(scope.present); + auto home = decided(global, "XLINGS_HOME"); + ASSERT_EQ(home.name, "XLINGS_HOME"); + EXPECT_TRUE(home.present); + EXPECT_EQ(home.value, global.home.string()); + + auto project = xlings_env("proj-dir"); + auto projectScope = decided(project, "XLINGS_PROJECT_DIR"); + EXPECT_TRUE(projectScope.present); + EXPECT_EQ(projectScope.value, project.projectDir.string()); +} + +TEST(XlingsInvocationEnv, TheProcessEnvironmentIsUnchangedAfterwards) { + namespace env = mcpp::platform::env; + // Held so that what the Windows prefix sets process-wide is restored too. + env::ScopedEnv keepPath("PATH", env::get("PATH")); + env::ScopedEnv keepHome("XLINGS_HOME", env::get("XLINGS_HOME")); + env::ScopedEnv prior("XLINGS_PROJECT_DIR", std::string("prior-project")); + + { + auto global = xlings_env(""); + mcpp::xlings::ScopedInvocationEnv scope(global); + (void)mcpp::xlings::build_command_prefix(global); +#if defined(_WIN32) + EXPECT_FALSE(env::get("XLINGS_PROJECT_DIR").has_value()); +#endif + } + EXPECT_EQ(env::get("XLINGS_PROJECT_DIR"), std::optional("prior-project")); + + { + auto project = xlings_env("proj-dir"); + mcpp::xlings::ScopedInvocationEnv scope(project); + (void)mcpp::xlings::build_command_prefix(project); +#if defined(_WIN32) + EXPECT_EQ(env::get("XLINGS_PROJECT_DIR"), + std::optional(project.projectDir.string())); +#endif + } + EXPECT_EQ(env::get("XLINGS_PROJECT_DIR"), std::optional("prior-project")); +} + +#if !defined(_WIN32) +TEST(XlingsInvocationEnv, ThePosixPrefixRendersTheDecision) { + auto global = mcpp::xlings::build_command_prefix(xlings_env("")); + EXPECT_NE(global.find("env -u XLINGS_PROJECT_DIR PATH="), std::string::npos) << global; + EXPECT_EQ(global.find("XLINGS_PROJECT_DIR="), std::string::npos) << global; + + auto project = mcpp::xlings::build_command_prefix(xlings_env("/work/proj")); + EXPECT_EQ(project.find("-u XLINGS_PROJECT_DIR"), std::string::npos) << project; + EXPECT_NE(project.find("XLINGS_PROJECT_DIR="), std::string::npos) << project; + EXPECT_NE(project.find("/work/proj"), std::string::npos) << project; +} +#endif