Skip to content

Instantly share code, notes, and snippets.

@talisein
Last active July 7, 2026 06:08
Show Gist options
  • Select an option

  • Save talisein/72767fba379a94f655c43f8b44253b9b to your computer and use it in GitHub Desktop.

Select an option

Save talisein/72767fba379a94f655c43f8b44253b9b to your computer and use it in GitHub Desktop.
Meson Requiremenets for C++ Modules -- My Take

C++ Modules Support — Clang Port Requirements (harvest-edge design)

Status: Draft / architecture, from verified experiments. Target compiler: Clang (verified against Clang 22.1.8 + clang-scan-deps 22.1.8, Fedora). GCC and MSVC are covered by the companion documents. Backend: Ninja only. Companion to: MODULES_REQUIREMENTS.md (the design and its rules) and HEADER_UNIT_REQUIREMENTS.md (the declared-units addition). That first document scoped Clang out (its §6, §14) on the claim that Clang "will not self-name BMIs into a directory the way GCC does." This document records the experimental audit of Clang against every rule in that design, states precisely which rule breaks, and specifies the one added mechanism — a build-time BMI-naming ("harvest") edge — that ports named modules and import std to Clang with every invariant intact. Header units on Clang are out of scope here (§6).


1. Purpose

Allow a Meson project built with Clang to use C++20 named modules (including module partitions) and import std; within a single build tree, with the same user-facing behavior as the GCC and MSVC ports and the same properties:

  • static, a-priori-known compile command lines (no module name, no BMI path, no -fmodule-file=/-fmodule-output=<path> on any compile command);
  • no per-module flags synthesized from scan results;
  • no reconfiguration triggered by editing source contents;
  • a single shared module cache, imports resolved by name lookup in a directory;
  • ordering carried by Ninja dyndep from a cold-capable P1689 scan.

2. Audit verdict — where Clang breaks the rules

Every rule of MODULES_REQUIREMENTS.md was tested against Clang 22.1.8 (experiment log: Appendix A). The result is narrower than the original exclusion assumed:

Exactly one axiom breaks: §2.3, "the compiler owns naming and placement of BMIs" — and only on the producer side.

  • With bare -fmodule-output (Clang's only self-naming mode), the BMI is written next to the object, named after the source: mismatch.cppm providing export module oddname; yields obj/mismatch.pcm, not <cache>/oddname.pcm. Clang has no "write into this directory keyed by module name" mode like GCC's gcm.cache or MSVC's /ifcOutput <dir>\.
  • The only way to make the compiler itself produce <name>.pcm is -fmodule-output=<cache>/<name>.pcm — a per-module output flag whose value is scan output. That single fact violates, as written, NFR1(a) (command varies with scan results), NFR1(b) (a BMI path on a command line), and FR4 ("MUST NOT invent per-module output flags"). One root cause, three clause hits. This is CMake's fork in the road — CMake accepted collator-generated per-module flags; this design cannot.

Everything else passes, several rules verbatim:

Rule Clang result
§2.2 / FR5 — imports resolved by name in a directory, no per-module flags Passes exactly. -fprebuilt-module-path=<dir> resolves imports — including the full transitive closure and partitions — by <name>.pcm lookup, with the same :- partition filename convention as GCC/MSVC (pkg:partpkg-part.pcm). Verified: a consumer compiled with only that directory flag resolved pkg, transitively pkg:part, and a second module; linked; ran.
FR2 — cold-capable scan Passes. clang-scan-deps -format=p1689 emits accurate provides/requires with no BMI in existence, including requires: std cold. Scan rebuild tracking works: -MD -MF in the wrapped command passes through and writes a depfile.
FR3 — collator name→path mapping Computable (<cache>/<name>.pcm, :- verified); the break is only that no compile edge natively writes the mapped path (§2.3 above; repaired by the harvest edge, §5).
FR11 — import std via dependency('std') Passes with both stdlibs. libc++ ships libc++.modules.json in the same is-std-library schema as libstdc++'s (plus a local-arguments.system-include-directories field, §7). And Clang compiles libstdc++'s own bits/std.cc (with -x c++-module), links, runs — so the default clang-with-libstdc++ configuration is covered, not just libc++. Verified at c++20 and c++23.
FR1 — extension + explicit opt-in, never content Same shape as MSVC. Clang decides interface-unit-ness by extension (.cppm) or explicit -x c++-module, never content — -fmodule-output is silently unused on a plain .cc. Clang therefore lands exactly on the declaration contract documented for the K7 decision: .cppm/.ixx auto-detected; an export module in a plain .cpp/.cc is not supported. GCC remains the lenient compiler; Clang joins MSVC.
A1 / §7.1 — uniform BMI-affecting flags Enforced, not just contractual. A cpp_std mismatch between a BMI and its consumer is a hard error ("configuration mismatch"); macro divergence is tolerated. Meson's existing cross-target cpp_std divergence warning corresponds to a Clang error — stricter than GCC, in the direction the contract already points.
FR6–FR10, FR8, FR9, NFR2, NFR4 Unaffected — mechanism-independent; they ride the same dyndep/collator machinery (with the harvest edge slotted in, §5/§10).

Secondary deviations that are new machinery but not rule breaks:

  • The scanner is a separate binary (clang-scan-deps), not the compiler with scan flags: it must be detected, should be version-matched to the clang in use, wraps the full compile command after --, and writes to stdout (or -o). New tool-detection and rule shape; same architecture.
  • -fprebuilt-module-path is required — Clang has no implicit default cache (a bare import pkg; is a fatal "module 'pkg' not found"). Same permitted category as MSVC's /ifcSearchDir: a directory flag, static.
  • Clang does not create the module output directory (-fmodule-output= into a missing directory is an error) — same os.makedirs treatment the MSVC path already has for ifc.cache.

3. Inherited invariants (from MODULES_REQUIREMENTS.md)

Binding here unchanged, as in the header-units document: the guiding principles (§2 there), the axioms A1–A4, the user contract (§7 there), and FR1–FR11. A1's consequence carries over: one uniform flag set → a single shared module cache is sound for .pcm files too, with no per-target or per-consumer keying. Clang's enforcement of A1 (§2 table) makes violations louder here than on GCC, not different in kind.

4. Terminology

Extends MODULES_REQUIREMENTS.md §4.

  • BMI — for Clang: a .pcm file.
  • Module cache — the shared directory consumers name via -fprebuilt-module-path and harvest edges populate; pcm.cache/ by analogy with gcm.cache/ifc.cache.
  • Source-keyed BMI — the .pcm bare -fmodule-output writes next to the object: the object path with .o.pcm (verified: obj/pkgpart.cppm.oobj/pkgpart.cppm.pcm). Its path is knowable at generation time; its name has no relation to the module name.
  • Harvest edge — the added per-MIU build edge that copies the source-keyed BMI to <cache>/<module>.pcm, reading the module name from the MIU's already-built .ddi at build time. The command line is static; the module name never appears on it.

5. The repair: a build-time BMI-naming step (the harvest edge)

This is the "build-time BMI-naming step" MODULES_REQUIREMENTS.md §14 anticipated. Every link in the chain is verified (Appendix A):

  1. MIU compile — static. Compile with bare -fmodule-output (plus -fprebuilt-module-path=<cache> so the MIU's own imports resolve). The BMI lands at the source-keyed path — knowable at generation time, so the compile edge can declare it as an ordinary implicit output. No module name, no BMI path, nothing scan-derived on the command line.
  2. Harvest — static command, build-time knowledge. A Meson-internal helper takes (source-keyed .pcm, the MIU's .ddi, cache dir) — all three generation-time-knowable — reads the provides logical-name from the .ddi, and copies the BMI to <cache>/<name>.pcm (partition :-). Declared output: a stamp. The real cache path is carried by the dyndep (next item), exactly as .gcm paths are today.
  3. Dyndep. The collator (which already knows every module name at collate time) emits, for the harvest edge, <cache>/<name>.pcm as an implicit output, and for each consumer object, the cache paths it requires as order-only inputs — FR3 unchanged except that the provider of the cache path is the harvest edge rather than the compile edge.
  4. Consumer accepts the copy. Clang resolves a copied/renamed .pcm via -fprebuilt-module-path lookup without complaint — validation does not key on the original path. Verified end-to-end (compile, link, run, exit 0), including a module whose source filename differs from its name.

Cost, honestly stated: one extra (trivial) process per module-interface unit, and each BMI existing twice (source-keyed + cache copy). NFR3's "at most one process per source" gains a small asterisk: one scan per source plus one copy per MIU; no IPC, no response files, no per-consumer duplication.

Rejected alternatives.

  • Per-module -fmodule-output=<cache>/<name>.pcm from the collator — the CMake model. Rejected: breaks NFR1(a)+(b) and FR4; the whole point of the jussi path is not doing this.
  • Mandate "MIU filename == module name" so the output path is knowable from the filename alone. Rejected: it still puts a BMI path on the command line (NFR1(b) as written), imposes a naming convention GCC/MSVC users don't need, and is ambiguous for partitions (pkg-part.cppm: module pkg:part or module pkg-part?).
  • Wait for an upstream -fmodule-output-dir (module-name-keyed directory output). The right long-term fix; tracked as an open question (§13). The harvest edge is deletable line-for-line the day it exists.

6. Out of scope (and why)

Inherits every exclusion of MODULES_REQUIREMENTS.md §6. Additionally:

Item Why excluded
Header units on Clang Not audited here. Clang has distinct machinery (-fmodule-header, -fmodule-file=<path> consumption) whose fit with HEADER_UNIT_REQUIREMENTS.md needs its own experiment pass. Named modules and import std only.
Module interfaces in plain .cpp/.cc sources Clang cannot be told "infer from content" (FR1 row in §2): interface treatment needs the extension or a per-source -x c++-module. Same documented limitation as MSVC (K7 decision); .cppm/.ixx or nothing. A future per-source opt-in kwarg would lift this on Clang and MSVC together.
-fmodule-file=<name>=<path> consumption Per-module consumer flags; forbidden by FR5/NFR1. The directory mechanism (-fprebuilt-module-path) is sufficient and verified.
Clang-in-MSVC-mode (clang-cl) Untested; separate flag surface.

7. Compiler interface — Clang (verified)

All verified on Clang 22.1.8 / clang-scan-deps 22.1.8; Appendix A is the log.

Enable modules: -std=c++20 (or later). No extra enabling flag for named modules.

Scan — cold-capable P1689, separate tool:

clang-scan-deps -format=p1689 -o <out.ddi> -- \
    clang++ <target-compile-args> -c <source> -o <obj> -MD -MF <depfile>
  • Emits P1689 provides (with is-interface) / requires; accurate with no BMI existing, including requires: std.
  • The -MD -MF inside the wrapped command passes through: the scan writes a make-format depfile for its own rebuild tracking (add -MT <out.ddi> so the depfile targets the scan output, as the GCC scan does via -fdeps-target).
  • clang-scan-deps must be found alongside the compiler and version-matched to it (a scanner older than the compiler may not understand new flags).

MIU compile — self-named but source-keyed:

clang++ -std=c++20 -fprebuilt-module-path=<cache> -fmodule-output -c <miu> -o <obj>

writes the object and the BMI at <obj-path with .o → .pcm>. The BMI filename derives from the object path, never the module name. .cppm is recognized as an interface unit; other extensions need -x c++-module (silently not treated as a module otherwise — the flag is reported unused).

Import resolution — directory + transitive, no per-module flags:

clang++ -std=c++20 -fprebuilt-module-path=<cache> -c <consumer> -o <obj>

resolves every import — and the transitive closure, verified through a re-exported partition — by <name>.pcm lookup in the directory. Partition pkg:partpkg-part.pcm (same scheme as .gcm/.ifc). A copied/renamed .pcm is accepted. Without the flag there is no fallback lookup: fatal "module not found".

MIU object must be linked — as on GCC/MSVC; the experiments linked every MIU's object.

Flag mismatch: consuming a BMI under a different -std is a hard error (-Wmodule-file-config-mismatch, promoted to error); differing -D macros are accepted.

import std sources:

  • libc++: clang++ -print-file-name=libc++.modules.json (with -stdlib=libc++) → manifest in the libstdc++-compatible schema (is-std-library: true, source-path relative to the manifest), plus local-arguments.system-include-directories that the std compile must add as -isystem (also manifest-relative). Sources: std.cppm, std.compat.cppm.
  • libstdc++ (Meson's default with Clang on Linux): clang++ -print-file-name=libstdc++.modules.json resolves to the selected GCC's manifest, and Clang compiles bits/std.cc with -x c++-module -Wno-reserved-module-identifier; the result links and runs.
  • Both stdlibs verified at -std=c++20 and -std=c++23. -Wno-reserved-module-identifier is required on the std MIU compile for both (the sources declare the reserved name std).

8. Functional requirements (additions/overrides for Clang)

FR1–FR11 stand. Clang adds:

FR-C1 — Scanner detection. Meson MUST locate clang-scan-deps when the C++ compiler is Clang and the project uses modules, prefer the copy shipped with that Clang, and error clearly when it is missing or older than the compiler. supports_cpp_modules_p1689() for Clang is "a usable, version-matched clang-scan-deps exists" plus the minimum Clang this port supports.

FR-C2 — Scan edge shape. The scan edge runs clang-scan-deps -format=p1689 -o <ddi> -- <full compile command> -MD -MF <depfile> -MT <ddi>. The wrapped command MUST be the target's real compile command (same args the compile edge uses, §7), so the scan sees the same dialect. One scan per source, as FR2.

FR-C3 — MIU compile edge. Module-interface sources compile with bare -fmodule-output and the shared -fprebuilt-module-path=<cache>; the source-keyed .pcm is declared as an implicit output of the compile edge (its path is generation-time-knowable). The command MUST NOT contain -fmodule-output=<path>.

FR-C4 — Harvest edge. Each MIU gets one harvest edge (§5): inputs = the source-keyed .pcm and the MIU's .ddi; static command; declared output = a stamp; the collator's dyndep declares <cache>/<name>.pcm as its implicit output. The helper MUST apply the :- mapping and MUST fail loudly if the .ddi lists no provides (an MIU that scanned as a non-interface indicates a broken scan).

FR-C5 — Consumer edges. Every C++ compile of a module-enabled target carries exactly one module-related flag: -fprebuilt-module-path=<cache>. No -fmodule-file=, no per-module anything. (This is get_module_compile_args() for Clang.)

FR-C6 — Collation. FR3 unchanged, except the provider of <cache>/<name>.pcm in the dyndep is the module's harvest edge, and consumers' order-only inputs point at the cache paths (never at source-keyed .pcms). FR10 diagnostics unchanged.

FR-C7 — Cache directory creation. Meson MUST create the shared cache directory up front (as the MSVC path does for ifc.cache); Clang does not create output directories.

FR-C8 — Interface-unit detection. By extension exactly as GCC/MSVC (.cppm/.ixx, _has_cpp_module_source); non-.cppm extensions get -x c++-module added the way MSVC gets /interface /TP — keyed by extension, never content. Plain-.cpp interfaces are unsupported (documented, as on MSVC).

FR-C9 — import std synthesis. FR11 unchanged in shape. The synthesizer MUST key on the selected stdlib: libc++'s manifest when -stdlib=libc++ is in effect, libstdc++'s otherwise; MUST honor the manifest's local-arguments.system-include-directories (manifest-relative) as -isystem; MUST add -Wno-reserved-module-identifier and (for non-.cppm sources such as libstdc++'s bits/std.cc) -x c++-module to the std MIU compiles only. The std provider rides FR-C3/C4 like any MIU: harvested into the shared cache, its objects linked as an ordinary dependency library.

FR-C10 — A1 divergence. The existing cross-target cpp_std divergence warning stays; on Clang the build-time consequence is a hard configuration-mismatch error rather than IFNDR, which is acceptable (louder, same contract).

9. Build-graph / phases

Per module-enabled target (delta from MODULES_REQUIREMENTS.md §10 marked):

  each source ──► [scan edge: clang-scan-deps -format=p1689 -- <compile cmd>]
                      ──► src.ddi                                   (FR-C2)
                        │
   dep targets' provided-module maps
                        ▼
                  [collate edge] ──► target.dyndep + target.provided-modules
                        │
                        ▼
  each MIU ──► [compile: -fmodule-output -fprebuilt-module-path=cache]
                      ──► obj.o + obj.pcm (source-keyed, implicit out) (FR-C3)
                        │
                        ▼
              [harvest edge: obj.pcm + src.ddi ──► stamp]             (FR-C4)
                        │  dyndep: implicit out = cache/<name>.pcm
                        ▼
  each source ──► [compile edge, dyndep=…, -fprebuilt-module-path=cache]
                      ──► obj.o                                       (FR-C5)
                        ▼
                     [link edge]   (MIU objects linked, as GCC/MSVC)
  • Scan, compile, and harvest command lines are all static; only the dyndep (built at build time by the collator, which has the scan results) carries module names — as file paths in the graph, never as flags.

10. NFR1 audit

Both sub-clauses hold for every compile and scan command: (a) no command varies with scan results — the MIU/consumer/scan commands differ only in input/output paths fixed at generation time; (b) no BMI path and no module name appears on any compile or scan command line — the only module-related flags are -fmodule-output (bare) and -fprebuilt-module-path=<dir> (a directory). The harvest edge's command names the source-keyed .pcm — a generation-time artifact path derived from the object name, carrying no module knowledge; the module-named cache path exists only inside the dyndep, exactly where .gcm paths live today. The spirit and letter of "static command lines + dyndep-for-ordering" survive.

11. Known limitations

  1. Two copies of every BMI (source-keyed + cache). Disk, not time; a future upstream -fmodule-output-dir deletes the harvest edge (§13).
  2. Plain-.cpp module interfaces unsupported (FR-C8), as MSVC.
  3. Header units on Clang unaudited (§6).
  4. std.compat untested. std is verified end-to-end on both stdlibs; std.compat is listed in both manifests and expected to ride identically (it additionally import std;, ordered by the ordinary scan like GCC's), but it has not been run here yet.
  5. One toolchain sample. Clang 22 on one machine; the minimum supported Clang (P1689 in clang-scan-deps landed in 16; -fmodule-output in 17; manifest shipping varies by distro) needs pinning against CI, not this box.

12. Failure modes → required behavior

Extends MODULES_REQUIREMENTS.md §12.

Failure Required behavior
clang-scan-deps missing / version-mismatched Configure-time error naming the tool and the Clang it must match (FR-C1)
MIU in a plain .cpp Not detected; documented limitation (FR-C8), as MSVC
Harvest finds no provides in the .ddi Hard error naming the source (FR-C4)
Import with cache entry absent Ordinary FR10 missing-provider error at collate; never a raw "module not found" for in-tree providers
BMI/consumer cpp_std divergence Clang hard error (config mismatch); Meson warns at generate as today (FR-C10)
Cache dir missing Never happens: created up front (FR-C7)
import std; without dependency('std') FR10 missing-provider error with the FR11 hint

13. Deferred / open questions

  • Upstream -fmodule-output-dir. A module-name-keyed output directory in Clang would make it a straight GCC/MSVC peer and delete FR-C4 wholesale. Worth pursuing/tracking upstream; the harvest edge is designed to be removable.
  • Reduced BMIs. Newer Clang can emit reduced BMIs (interface-only, -fmodules-reduced-bmi), which shrink rebuild cascades (better restat behavior for FR6). Not exercised here; adopt once the minimum Clang allows.
  • clang-cl and Clang targeting the MSVC ABI: separate flag surface, untested.
  • Header units on Clang against HEADER_UNIT_REQUIREMENTS.md: needs its own experiment pass (-fmodule-header semantics, cold-scan behavior of clang-scan-deps on header-unit imports).
  • Minimum version pinning (§11.5) once CI has a Clang matrix.

Appendix A — Verified Clang behavior (Clang 22.1.8, clang-scan-deps 22.1.8)

Experiment artifacts: scanbench-style scratch tree (clangexp/), sources pkg.cppm (export module pkg; export import :part;), pkgpart.cppm (export module pkg:part;), mismatch.cppm (export module oddname; — name ≠ filename), main.cpp (import pkg; import oddname;), stdmain.cpp (import std; + std::println).

  • Cold scan: clang-scan-deps -format=p1689 -- clang++ -std=c++20 -c pkg.cppm -o pkg.o with no .pcm anywhere → correct provides: pkg (is-interface: true), requires: pkg:part; consumer scan → requires: pkg, oddname; import std; scans cold to requires: std.
  • Depfile passthrough: -MD -MF scan.d inside the wrapped command → scan.d written by the scan invocation.
  • Source-keyed BMI: -fmodule-output -o obj/pkgpart.oobj/pkgpart.pcm; -o obj/pkgpart.cppm.oobj/pkgpart.cppm.pcm (object path, .o.pcm); mismatch.cppm (module oddname) → obj/mismatch.pcmnever the module name.
  • Directory lookup: consumer with only -fprebuilt-module-path=cache resolves pkg, transitively pkg:part (as cache/pkg-part.pcm:-), and oddname; links; runs (exit 0).
  • Copied BMIs accepted: all cache entries above were cp'd from bare -fmodule-output outputs, including one renamed (mismatch.pcmoddname.pcm).
  • No implicit cache: same consumer without the flag → fatal module 'pkg' not found.
  • Config mismatch: consume a c++20 BMI at -std=c++23 → hard error (config mismatch); differing -D → accepted.
  • Extension-keyed interface detection: -fmodule-output on a .cc MIU → "argument unused" warning, no BMI; -x c++-module → BMI produced.
  • No output-dir creation: -fmodule-output=missing-dir/std.pcm → error unable to open output file.
  • import std / libc++: manifest at …/lib64/libc++.modules.json (is-std-library, manifest-relative source-path, local-arguments.system-include-directories); std.cppm compiled with -std=c++23 -stdlib=libc++ -Wno-reserved-module-identifier -isystem <manifest-relative dir> -fmodule-output; consumer with -fprebuilt-module-path compiled, linked with std's object, ran (std::println output). Also builds at -std=c++20.
  • import std / libstdc++: clang++ -print-file-name=libstdc++.modules.json resolves (GCC 16's manifest); bits/std.cc compiled by Clang with -x c++-module -Wno-reserved-module-identifier; consumer compiled against the resulting std.pcm with the default stdlib, linked, ran.

C++ Header Units — Requirements (GCC, declared-units prototype)

Status: Draft / architecture. Prototype-limited on purpose. Target compiler: GCC only (same versions as named modules; the cpp_header_units kwarg documents GCC ≥ 14). Clang and MSVC are out of scope. Backend: Ninja only. Companion to: MODULES_REQUIREMENTS.md. That document scoped header units out (its §6, §6.1). This document scopes a minimal, opt-in header-unit feature back in under one added crutch — the units are declared in meson.build — and specifies what that feature must and must not do. Every invariant of the named-modules design carries over unchanged; header units are an addition to it, not a fork of it.


1. Purpose

Allow a Meson project built with GCC to use C++20 header units (import "pkg/hdr.h"; and import <pkg/hdr.h>;) alongside named modules within a single build tree, while preserving every property of the named-modules feature:

  • static, a-priori-known compile command lines (no BMI path, no module name, no -fmodule-file= on any command line);
  • no per-module / per-unit compiler flags synthesized from scan results;
  • no reconfiguration triggered by editing the contents of a header;
  • a single shared module cache (gcm.cache), no per-target BMI directories.

The one thing this feature asks of the user that named modules did not: the header units a target imports must be listed on that target (cpp_header_units: [...]). §2 explains why that list is unavoidable and why it is the whole of the crutch.

2. Why header units need the declaration (the cold-scanner problem)

MODULES_REQUIREMENTS.md excluded header units because of a hard GCC fact (verified, §8 there and §7 here):

GCC's P1689 dependency scanner fails on a header-unit import whose BMI does not yet exist — it emits an empty .ddi silently (no error). A named module import, by contrast, scans cold: the scanner reports requires: <name> with no BMI present.

Named modules therefore ride the scan → collate → dyndep machinery with the BMI built later, ordered by dyndep. Header units cannot: their BMI must already exist the first time the scanner sees the importing TU. The two documented ways to satisfy that are:

  1. A live module-mapper oracle (-fmodule-mapper=|prog/:port/…, libcody IPC, g++-mapper-server) that builds the unit on demand mid-compilation. This is incompatible with NinjaMODULES_REQUIREMENTS.md §6.1: Ninja cannot model a compiler talking to a running server, and adopting it means forking Ninja or running a side daemon that races Ninja's ownership of the build graph. Rejected for the same reason header units were excluded.
  2. Pre-build the BMI from a static, a-priori list, so the scanner is never cold. This keeps every command line static and keeps Ninja the sole owner of the graph. This is the prototype.

The cpp_header_units list is exactly what option 2 needs and what option 1 would have discovered dynamically. It is a deliberate trade: projects give up automatic discovery and fine-grained inter-unit ordering (§11) in exchange for a minimally supported, Ninja-compatible, jussi-aligned header-unit path. It is the least crutch that makes header units correct without reopening §6.1.

3. Inherited invariants (from MODULES_REQUIREMENTS.md)

These are not restated to be re-decided — they are binding here unchanged. The guiding principles (that doc's §2) and axioms (its §3) apply in full:

  • Command lines are knowable without reading source contents. A header unit's BMI path never appears on a command line; consumers resolve it by directory lookup in gcm.cache. Only ordering is discovered, and header units are ordered by static edges (§9 FR-H4), not even by dyndep.
  • Units are like headers. A header unit's BMI is located by the compiler in the module cache; Meson tells the compiler only which directory. No tool hands the compiler an explicit BMI path per unit.
  • A1 — Uniform flags. Every C++ compilation in the build uses the same BMI-affecting flags. A header unit's BMI freezes the preprocessor state it was built with (macros in effect, include path), so A1 is if anything more load-bearing for header units than for named modules: a unit built under one macro set and imported under another is IFNDR. See §7.1.
  • A2 — No BMI distribution. Unit BMIs live and die in one build tree.
  • A3 — Visibility is convention. Not applicable to header units beyond the named-module case.
  • A4 — All sources declared; no globbing. Extended here: the header units themselves are declared too (that is the crutch). Adding/removing a declared unit is a reconfigure, as for any meson.build edit.

Consequence of A1 carries over: because BMI-affecting flags are uniform build-wide, a single shared gcm.cache holds unit BMIs too; there is no per-target or per-consumer unit-BMI keying.

4. Terminology

Extends MODULES_REQUIREMENTS.md §4.

  • Header unit — a header imported as a module (import "h"; / import <h>;) rather than #included. Produces a BMI but (as used here) no object.
  • Import spelling — the header exactly as written inside the import, minus the import/;: "pkg/hdr.h" is spelled pkg/hdr.h; <pkg/hdr.h> is spelled <pkg/hdr.h>. The quote-vs-angle form selects user vs system header-unit mode and is significant (§8).
  • Unit stamp — the Ninja output of a header-unit build edge. Because GCC writes the real BMI to a mangled gcm.cache path Meson does not compute or track, the edge's declared output is a touched stamp file used purely for ordering and rebuild tracking; the BMI itself is found by directory lookup.

5. In scope

  • GCC header units declared per target via cpp_header_units, used alongside named modules and import std; in the same target and build.
  • User header units (quote spelling → -x c++-user-header) and system header units (angle spelling → -x c++-system-header), including units that wrap dependency and system headers resolved on the target's include path.
  • A single shared gcm.cache holding unit BMIs, named-module BMIs, and std BMIs together.
  • Correct incremental rebuilds: editing a header rebuilds its unit BMI and recompiles the sources that import it (§9 FR-H6).
  • Global deduplication of identical units (same spelling + mode) across targets into one build edge (§9 FR-H3).

6. Out of scope (and why)

Inherits every exclusion of MODULES_REQUIREMENTS.md §6, plus:

Item Why excluded
Automatic discovery of header units (scanning to find which headers are imported as units) Defeated by the cold-scanner failure (§2). Discovery is exactly what the declaration replaces.
Live module-mapper / libcody / g++-mapper-server driving on-demand unit builds Incompatible with Ninja — MODULES_REQUIREMENTS.md §6.1. This is the mechanism the declaration exists to avoid.
Inter-unit ordering (a header unit that itself imports another header unit) Units are built by static edges that are ordered before the target's scans/compiles but not relative to each other (§9 FR-H4, §11). A unit whose prerequisites are only #included (absorbed textually) is fine; one that imports another unit is unsupported.
Propagation of unit declarations through dependencies A declare_dependency/link_with does not carry a producer's cpp_header_units to consumers. Each target that imports a unit must declare it (§7.4). Unlike named-module provider maps (that doc's FR7), there is no unit map to propagate — a header unit has no owning "provider" target, only a spelling resolved on an include path.
-fmodule-header (CWD-relative) unit building It resolves only against the compiler's CWD and cannot reach dependency/system headers on the include path. The prototype instead compiles the spelling with the target's own args (§8), which is why it can wrap third-party headers.
Installing / exporting unit BMIs A2.
Non-GCC / non-Ninja As named modules.

7. User contract — back doors that MUST NOT be opened

In addition to MODULES_REQUIREMENTS.md §7:

  1. Uniform BMI-affecting flags across the whole build (A1), including preprocessor state. A header unit's BMI freezes the macros and include environment it saw. Importing it from a TU compiled with different module-affecting flags is IFNDR. Meson MUST NOT try to support divergence; it MAY warn on divergent cpp_std between module-sharing targets (as for named modules).
  2. The declared spelling MUST match the import spelling and resolve on the target's include path. cpp_header_units: ['pkg/h.h'] supports import "pkg/h.h";; ['<pkg/h.h>'] supports import <pkg/h.h>;. A mismatch (declaring h.h but importing <h.h>, or a spelling that resolves to a different file than the import does) yields a BMI the consumer's import does not find — an unbuilt/again-cold import. Meson does not police this; it is a precondition.
  3. No header unit may import another header unit (§6, §11). Units may only #include their prerequisites. Violation is unsupported and MAY misbuild because inter-unit order is not modeled.
  4. Every header unit a source imports MUST be declared on that source's target. The declaration does not propagate (§6). A consumer importing a unit already declared by a dependency must still declare it itself; the dedup (FR-H3) makes this cost one shared edge, not one BMI per target.
  5. Use Ninja; do not hand-run compiles. As named modules — stale unit BMIs in the shared cache are only reasoned about within Ninja's graph.

8. Compiler interface — GCC (verified)

Extends MODULES_REQUIREMENTS.md §8.

Build a header unit's BMI:

g++ <target-compile-args> <module-args> -MD -MF <stamp>.d \
    -fmodule-only -x c++-user-header   -c <spelling>   # quote import
g++ <target-compile-args> <module-args> -MD -MF <stamp>.d \
    -fmodule-only -x c++-system-header -c <spelling>   # angle import
  • The spelling is resolved on the include path carried by <target-compile-args> (the same args the target's normal compiles use), so the produced BMI matches what a consumer's import looks up. This is what lets a unit wrap a header that lives in a dependency or system include dir.
  • -fmodule-only builds the BMI and skips the object — a header unit contributes no object to link (contrast std, whose object is linked).
  • GCC writes the BMI to a mangled path inside gcm.cache that Meson does not compute. Meson therefore does not name the BMI as an output; the edge's output is a stamp (… && touch <stamp>). No BMI path ever reaches a command line — preserving NFR1.
  • -MD -MF makes GCC emit a depfile listing every header textually included while building the unit (system headers included, matching a normal compile), which drives rebuild-on-edit (FR-H6).

Import a header unit — automatic, directory-based. A consumer TU compiled with the shared gcm.cache resolves import "…"; / import <…>; by directory lookup, with no -fmodule-file= and no BMI path — exactly as for named modules, provided the BMI already exists (which the pre-build guarantees).

Scanning a TU that imports a unit succeeds only if the unit's BMI already exists; otherwise the .ddi is silently empty (§2). Hence FR-H4's ordering.

9. Functional requirements

Named-module requirements FR1–FR11 (that doc's §9) are unchanged. Header units add:

FR-H1 — Declaration opts the target in. Declaring cpp_header_units on a target MUST mark it as using the GCC module machinery (scan + collate + module-compile), because its sources import units and so must be scanned and compiled as module TUs — even if the target provides no named module itself.

FR-H2 — Spelling → mode → build edge. For each declared entry Meson MUST derive the (spelling, mode) pair:

  • a File or a plain quoted-style string (pkg/h.h) → user header unit, -x c++-user-header;
  • an angle-wrapped string (<pkg/h.h>) → system header unit, -x c++-system-header (the <> are stripped for the spelling). The edge MUST compile that spelling with the target's own compile args (§8) so the BMI matches consumer imports.

FR-H3 — Global dedup. Header-unit build edges MUST be deduplicated across the whole build by (spelling, mode). Two targets declaring the same unit share one edge and one BMI. The stamp path MUST be stable and collision-free for distinct keys (the prototype keys a meson-private/header-units/<base>.<hash>.stamp file on a hash of <mode>:<spelling>).

FR-H4 — Ordering by static edges (never cold). Every declared unit's stamp MUST be an order-only input to all of the target's scan edges and an implicit input to all of the target's compile edges, so that:

  • no scan runs before the units it might import exist (defeating the cold-scanner failure, §2), and
  • no compile runs before the BMIs it imports exist. Ordering among units themselves is not modeled (they are peers under the target); this is the source of the no-unit-imports-a-unit limitation (§11). Unit ordering MUST NOT be expressed through dyndep — units are pre-scan, so they cannot participate in the dyndep the scan produces.

FR-H5 — Collator ignores header-unit requires. When the P1689 scan of a module TU reports a requires whose logical-name is a header-unit require, the collator MUST recognize it and skip it: it is neither a dyndep dependency (the BMI is pre-built by a static edge) nor a missing named module (it MUST NOT raise FR10's missing-provider error). The prototype distinguishes a header-unit require by shape — its logical-name is a path (contains /), whereas a named module is an identifier with an optional :partition — because GCC omits the P1689 lookup-method field. Named-module collation (that doc's FR3/FR10) MUST be unaffected.

FR-H6 — Rebuild tracking (edit a header → rebuild). A header unit MUST rebuild when the header (or anything it textually includes) changes: the unit edge MUST emit a -MD depfile so Ninja tracks those inputs. A source that imports a unit MUST take that unit's stamp as an implicit input so it recompiles when the BMI changes. Editing a header's contents MUST NOT trigger a Meson reconfigure (as FR9); adding/removing a declared unit is a reconfigure (A4, §3).

FR-H7 — No object from a unit. A header-unit edge MUST NOT contribute an object to any link (-fmodule-only). (Contrast FR11's std object, which MUST be linked.)

10. Build-graph / phases

Per module-enabled target (extends that doc's §10):

   declared cpp_header_units (A4 + crutch)
                 │
                 ▼
  each unit ──► [header-unit edge: -fmodule-only -x c++-*-header] ──► unit.stamp
                 │  (BMI written to mangled gcm.cache path, untracked)
                 │  deduped globally by (spelling, mode)
                 │
     stamps as order-only deps ▼           stamps as implicit deps ▼
  each source ──► [scan edge] ──► src.ddi        (never cold)
                 │
                 ▼
             [collate edge] ──► target.dyndep   (header-unit requires skipped)
                 │
                 ▼
  each source ──► [compile edge, dyndep=…] ──► obj.o   (unit BMIs present)
                 ▼
              [link edge]   (no unit objects; std object still linked)
  • Unit edges have static command lines (spelling + mode + target args); no scan result feeds them.
  • No edge's command line contains a BMI path or a module/unit name flag (NFR1).

11. Known limitations (prototype)

  1. No inter-unit ordering. Units are built before the target's scans and compiles but not relative to each other (FR-H4). A header unit that imports another header unit is unsupported. Units whose prerequisites are only #included (absorbed textually into the BMI) are fine.
  2. Declarations do not propagate. Every target importing a unit must declare it; there is no dependency-object propagation as there is for named-module provider maps (§6, §7.4).
  3. Manual, static list. Adding/removing an import means editing meson.build (reconfigure). No discovery. This is the crutch of §2, accepted as the price of Ninja compatibility.
  4. Spelling fidelity is the user's job. Meson does not verify that a declared spelling resolves to the same file the import resolves to (§7.2).

12. Failure modes → required behavior

Extends that doc's §12.

Failure Required behavior
Source imports an undeclared header unit Scanner goes cold → silently empty .ddi → build fails downstream. Not policed by Meson; documented precondition (§7.4). (Future: could be diagnosed.)
Declared spelling ≠ import spelling / resolves elsewhere Consumer import not found; unsupported (§7.2).
Header unit imports another header unit Unsupported; MAY misbuild (no inter-unit order) (§11.1).
Same unit declared by many targets Deduped to one edge/BMI (FR-H3); correct.
Header-unit require seen by collator Skipped, not treated as missing named module (FR-H5).
Header edited Unit BMI rebuilt + importers recompiled, no reconfigure (FR-H6).
Divergent module-affecting flags / cpp_std A1 violation; MAY warn (§7.1).
Non-GCC / non-Ninja Feature unsupported (as named modules).

13. Testing requirements

Extends that doc's §13.

  • Mixed target: a user header unit (import "util.h";), a system header unit (import <angleutil.h>;), and a named module (import mod;) in one target build, link, and run. (Covered by test cases/unit/141 gcc header units.)
  • Dedup: the same unit declared on two targets produces one build edge.
  • Incremental: editing a header rebuilds its unit BMI and recompiles only the importing sources, with no reconfigure and no changed compile command lines (NFR1).
  • Command-line audit: no scan/compile/unit command contains -fmodule-file= or a .gcm path; unit commands carry -fmodule-only and the correct -x c++-user-header / -x c++-system-header.
  • Collator: a header-unit requires in a scan does not raise the missing-provider error and does not appear in the dyndep.

14. Deferred / open questions

  • Diagnose an undeclared unit. The cold-scanner failure is silent (empty .ddi); Meson could detect the empty result and hint that a header unit needs declaring. Not done.
  • Propagating declarations through declare_dependency so a consumer of a module library inherits the units it must import. Requires a unit-declaration channel on the dependency object; out of scope now.
  • Inter-unit ordering, which would lift §11.1, needs the units modeled as an ordered mini-graph (scan-of-units, or declared order) — a step toward the discovery machinery the crutch exists to avoid.
  • Clang / MSVC, tracked with the named-modules Clang work (that doc's §14).

Appendix A — Verified GCC behavior (header units)

  • A TU importing a header unit scans cold to an empty .ddi if the unit's BMI is absent (silent); it scans correctly once the BMI exists — motivating pre-build (§2).
  • -fmodule-only -x c++-user-header -c <spelling> (or c++-system-header) builds the unit's BMI into gcm.cache (mangled name) and emits no object.
  • The spelling is resolved on the include path from the target's compile args, so a unit can wrap a dependency/system header that -fmodule-header (CWD-only) could not reach.
  • A consumer resolves import "…"; / import <…>; by directory lookup with no -fmodule-file= once the BMI exists.
  • -MD on the unit edge lists the header's textual includes (system headers included), driving rebuild-on-edit.

C++ Modules Support — Requirements (GCC, jussi-aligned)

Status: Draft / architecture. Scope-limited on purpose. Target compiler: GCC only (verified against GCC 16.1.1). Clang and MSVC are out of scope. Backend: Ninja only.

This document specifies what a first, correct C++20 named-modules feature for Meson must do, deliberately taking the "jussi path": the build system keeps command lines static and knowable a priori, treats compiled module interfaces (BMIs) like it treats headers (found by name in a search directory), scans sources only to discover build ordering, and never synthesizes per-module compiler flags. It intentionally rejects the CMake/collator model of build-time command-line generation beyond what dyndep ordering strictly needs.


1. Purpose

Allow a Meson project built with GCC to use C++20 named modules (including module partitions) within a single build tree — one target's modules consumed by another target or subproject — with:

  • static, a-priori-known compile command lines,
  • no per-module -fmodule-* flags,
  • no reconfiguration triggered by editing the contents of a source file,
  • support for module sources produced by generators at build time.

2. Guiding principles (the jussi path)

  1. Command lines are knowable without reading source contents. The only thing discovered by looking inside files is ordering, and that is delivered to Ninja through dyndep, not through changed flags.
  2. Modules are like headers. A BMI is located by module name in a search directory, exactly as a header is located by name on an include path. No tool hands the compiler an explicit path per module.
  3. The compiler owns naming and placement of BMIs. The build system only tells the compiler which directory to use; the module-name → filename mapping is the compiler's documented behavior, not ours to compute.
  4. One responsibility split. The compiler writes/reads BMIs where told; the build system orders compilations. Neither reaches into the other's job.
  5. Do the least that is correct. Anything the four axioms (§3) make unnecessary is out of scope, not deferred-but-implied.

3. Foundational assumptions (axioms)

These are accepted as given and are preconditions, not features to enforce:

  • A1 — Uniform flags. The user guarantees that every C++ compilation in the build (all targets, all subprojects) uses the same BMI-affecting flags (c++ std version, -D macros affecting module content, stdlib selection, and any other flag GCC bakes into a BMI). Meson already channels users toward this via add_project_arguments; here it is a hard requirement.
  • A2 — No BMI distribution. BMIs live and die inside one build tree. Nothing is installed, exported, or consumed as a prebuilt binary module.
  • A3 — Visibility is convention, not enforcement. "Private" modules are a naming/documentation boundary. The build system does not police who imports what.
  • A4 — All sources are declared; no globbing. Every source, including generator outputs, is named in meson.build (generator outputs via their declared output). Adding or removing a source is a reconfigure, as today.

Consequence of A1: because BMI-affecting flags are uniform build-wide, a single shared module cache is sound. Per-target BMI directories and per-consumer BMI variants — the machinery other build systems need to support mixed flags — are therefore not required. This is the central simplification the axioms buy.

4. Terminology

  • MIU — Module Interface Unit: a source that export module X; (or export module X:part;). Produces a BMI.
  • BMI — Binary Module Interface. For GCC: a .gcm file.
  • Module cache — the directory holding BMIs. For GCC this is gcm.cache/.
  • Scan — running the compiler in dependency-scan mode to emit P1689 JSON describing what a TU provides and requires.
  • Collation — merging per-source P1689 results (plus provided-module maps of dependency targets) into a Ninja dyndep file.

5. In scope

  • GCC named modules and module partitions (X, X:part).
  • Producer/consumer relationships within one build tree: across targets and across subprojects.
  • Module sources generated at build time, provided their output filenames are declared (A4).
  • Build-time scanning using GCC's native P1689 output.
  • Dyndep-driven ordering so content edits never force a reconfigure.
  • Propagation of "this target provides modules" through dependency objects (declare_dependency / link_with), so a consumer needs only to link.
  • import std; (and import std.compat;) via dependency('std'), built from the selected standard library. See FR11.

6. Out of scope (and why)

Item Why excluded
Clang, MSVC Explicitly punted; GCC-only first cut. Clang additionally lacks a module-name→BMI-path output flag, which this design would otherwise have to work around.
Header units (import <header>; / import "h";) GCC's P1689 scanner fails on a cold header-unit import — it needs the BMI already built (re-verified GCC 16.1.0: a fatal error, "failed to read compiled module … imports must be built before being imported … returning to the gate", and no .ddi is written — a loud failure, not a silent/empty one). Resolving them without a pre-built BMI therefore requires the live module-mapper oracle, which is incompatible with Ninja (§6.1). Named modules and partitions only. See HEADER_UNIT_REQUIREMENTS.md for the opt-in prototype that scopes them back in.
BMI install / distribution / prebuilt system modules A2. BMIs are not portable across compiler version or flags anyway.
Public/private visibility enforcement A3. Convention only.
Mixed flags / multi-config / per-consumer BMI variants A1 forbids the input that would require them.
External live module-mapper oracle / libcody IPC / autodiscovery (P1184) Incompatible with Ninja — see §6.1. We use GCC's in-process default mapper, which is gcm.cache.
Non-Ninja backends dyndep is a Ninja feature.
A homemade/regex scanner as the source of truth The compiler's native P1689 is authoritative and already present in GCC.

6.1 On the GCC module mapper

-fmodule-mapper names an oracle GCC consults for module-name → CMI lookups. GCC's default is an in-process oracle that is the gcm.cache directory — no server, no IPC — and that is what the jussi path uses (every named-module build in this document runs on it with zero mapper flags). A static mapper file is redundant with gcm.cache in this scope.

The excluded form is the external live oracle (|program, :port, =socket, <>fd; g++-mapper-server), which the compiler queries mid-compilation. It is incompatible with Ninja: Ninja has no way to model a compiler talking to a running server, so all module ordering must instead live in the static graph plus dyndep. Using it would mean forking Ninja (rejected — Ninja is Meson's output) or running a side daemon that coordinates work Ninja believes it owns. The live oracle is the right tool only for the cases we scoped out (header units, on-demand driving, system-module resolution) — which is why the same Ninja-incompatibility excludes both it and header units.

7. User contract — back doors that MUST NOT be opened

The design is correct only under these conditions. Each is a documented precondition; where cheap, Meson SHOULD detect and error rather than silently misbuild.

  1. Uniform BMI-affecting flags across the whole build (A1). Violation → BMI incompatibility ("language dialect differs", "Bad file data"). Meson MUST NOT attempt to support divergence; it MAY warn if it detects differing cpp_std between targets that share modules.
  2. Globally unique module names within any linked executable. Two targets that each define a module of the same name, linked together, is IFNDR in GCC (module name is the linkage discriminator). The collator (§9 FR10) MUST detect duplicate provides reaching one link and error.
  3. No preprocessor-gated imports that depend on state the scanner cannot see. Simple #if/#ifdef around import is fine — GCC's scanner preprocesses. __has_include-style unbounded predicates and header-unit macro effects are the failure cases, and they fall in out-of-scope territory (§6).
  4. Use Ninja; do not hand-run compiles or delete build.ninja. Stale .gcm files left in the shared cache are only reasoned about within Ninja's graph. Meson SHOULD run ninja -t cleandead semantics for dead BMIs.
  5. Do not rely on importing modules from targets outside the link closure. Under a shared cache this can accidentally resolve. Per A3 it is not policed, but it is unsupported and MAY warn.

8. Compiler interface — GCC (verified)

All of the following is verified on GCC 16.1.1 and is the contract the implementation depends on.

Enable modules: -std=c++20 (or later) plus -fmodules (older GCC: -fmodules-ts).

BMI output — automatic, directory-based. Compiling an MIU with no output-path flag writes the BMI to gcm.cache/<mapped-name>.gcm, relative to the process CWD (which under Ninja is the build directory). The mapping is deterministic:

  • module pkggcm.cache/pkg.gcm
  • partition pkg:partgcm.cache/pkg-part.gcm (:-)

No per-module output flag is used or needed.

BMI import — automatic, directory-based. Importing a module requires no -fmodule-file= flag: GCC resolves imports (including the full transitive closure) by looking up <name>.gcm in gcm.cache. Verified: a TU importing only pkg links correctly without naming pkg:part or any transitive dependency on the command line.

Scanning — native P1689, before any BMI exists.

g++ -std=c++20 -fmodules \
    -fdeps-format=p1689r5 -fdeps-file=<out.ddi> -fdeps-target=<obj.o> \
    -M -MF <header.d> <source>

emits P1689r5 JSON with provides (logical-name, is-interface) and requires (logical-name). Verified to run and be accurate without the required BMIs existing yet, which is what makes pre-build scanning viable.

9. Functional requirements

FR1 — Identify module sources. Meson MUST determine which C++ sources are MIUs. Default: by extension (.cppm, .ixx, and any project-declared module extensions). Because GCC does not distinguish module sources by extension and real code uses .cc for MIUs, Meson MUST also provide an explicit opt-in (e.g. a per-source/target declaration) to mark sources as modules. Detection MUST NOT require reading file contents. Detection is a hint for "scan this / compile-as-module"; it is not the source of module names (those come from the scan).

FR2 — Scan for provides/requires at build time. For each C++ source in a module-enabled target, Meson MUST run GCC's P1689 scan (§8) as a build-graph edge whose output is that source's .ddi. Scanning MUST NOT require BMIs to exist. Recommended granularity: one scan edge per source (GCC scans one TU at a time); per-target batching is a permitted future optimization but MUST NOT change correctness.

FR3 — Collate to a Ninja dyndep. A Meson-provided collator MUST consume, per target: the target's .ddi files, plus the provided-module maps of its dependency targets (§FR7), and emit a Ninja dyndep file that, for each object:

  • lists the .gcm files it requires as order-only inputs, and
  • lists the .gcm file it provides as an implicit output. The module-name → .gcm path mapping MUST be computed from the documented GCC scheme (§8), not by scanning output filenames.

The collator MUST NOT special-case std/std.compat. They are resolved like any other module: the synthesized std provider (FR11) publishes a provided-module map (FR7) that the collation of a target declaring dependency('std') consumes. A requires: std with no provider in the link is the ordinary missing-provider error of FR10.

FR4 — BMI location. Under A1 a single shared module cache (gcm.cache at the build root) MUST be used. The implementation MUST NOT invent per-target BMI directories or per-module output flags. (Per-target directories are reserved for a future world that relaxes A1; see §14.)

FR5 — Import resolution. Consumers MUST resolve imports via the shared cache directory with no per-module flags. For GCC this is automatic given a shared gcm.cache; the implementation MUST NOT emit -fmodule-file= mappings.

FR6 — Build ordering. Producers of a required module MUST be ordered before consumers via the dyndep (FR3). Ordering MUST be expressed as order-only edges so that BMI regeneration does not needlessly recompile consumers when the BMI is unchanged (restat).

FR7 — Cross-target / subproject propagation. A target that provides modules MUST publish a provided-module map (module-name → .gcm path) as a build artifact. declare_dependency / link_with MUST make that map available to consuming targets' collation, so that linking a library is sufficient to (a) order its modules first and (b) resolve its module names. No enumeration of the producer's .cppm in the consumer, and no separate module list in the dependency object, is required — the module search directory is contributed automatically, analogous to include_directories for headers.

The standard library is a synthesized provider that rides this same mechanism: dependency('std') (FR11) is an InternalDependency wrapping a synthesized module-providing library, so declaring it publishes std/std.compat into the consumer's collation and links their objects exactly as any link_with library would. The only thing special about std is that its target is synthesized from the standard library's own sources rather than written by the user.

FR8 — Generated module sources. A generator/custom_target whose declared output includes a module source MUST be supported: the scan edge for that source depends on the generator, and the resulting dyndep is produced at build time. Module names of generated MIUs are discovered by the build-time scan, not required in advance. (A4 supplies the filenames; the scan supplies the names.)

FR9 — Reconfiguration semantics. Editing the contents of a source (changing its imports/exports) MUST NOT trigger a Meson reconfigure; it MUST be absorbed by rescan + dyndep at build time. Adding/removing a source file remains a reconfigure (A4), as today.

FR10 — Validation / diagnostics. The collator MUST error (not warn) when:

  • two providers of the same module name reach a single link target (contract §7.2);
  • a required module is provided by no target in the build (report the requiring TU and the missing name);
  • a module dependency cycle exists (report the cycle; do not defer to ninja: cycle detected). It SHOULD warn when a required module is provided only by a target outside the consumer's link closure (contract §7.5).

FR11 — import std; via dependency('std'). std and std.compat MUST be supported through an explicit dependency('std'): a target whose source writes import std; declares that dependency, which both resolves the import and links the standard library's module objects. std is treated as an ordinary named module — verified: import std; scans cold (P1689 reports requires: std with no BMI present), unlike a header unit — so it rides the FR2 scan / FR3 collate / FR6 order / FR7 propagation machinery unchanged. Specifically:

  • Interface. dependency('std') synthesizes, once per machine, an ordinary module-providing static library from the standard library's interface sources and returns it as an InternalDependency. Declaring it makes the target module-enabled (it links a module provider, FR7) and puts the std objects on its link line; no cpp_modules keyword, option, or source listing is required. The handle is deliberately per-target, not a global option (the boolean cpp_importstd is explicitly not revived): that buys laziness — std is built only when some target asks for it — and correct transitive linkage (see Linkage).
  • No backend special case. Because std is then just another module provider, the collator, dyndep, and link edges carry no std-specific code; the synthesized target publishes a provided-module map (FR7) that resolves import std; exactly as any library's map resolves its modules. Forgetting the dependency surfaces as the ordinary missing-provider error (FR10), which hints to add dependency('std').
  • Discovery via stdlib selection. The interface source is located from the selected standard library — the mechanism that already decides which stdlib is linked. For libstdc++ this is the *.modules.json manifest, whose entries carry is-std-library: true and map stdbits/std.cc, std.compatbits/std.compat.cc. Locating the sources by that flag keeps the synthesis principled rather than a hard-coded special case.
  • Build. The synthesized library compiles the located sources as MIUs into the shared cache (gcm.cache/std.gcm, gcm.cache/std.compat.gcm) using the build's uniform flags, producing a BMI and an object per module (std.o — verified to contain exactly the module initializer) that the library archives. std.compat requires std; that inter-module edge is ordered by FR6 from the scan like any other, so both sources live in the one synthesized target with no hand-written ordering.
  • One instance per build. Under A1 there is a single BMI-affecting flag set, so one std build serves every consumer and fits FR4's single shared cache with no keying. This is why std is in scope cheaply here: A1 dissolves the "cannot mix cpp_std" limitation that forces per-consumer std variants elsewhere.
  • Linkage. The std objects are linked as an ordinary dependency library. dependency('std') carries the synthesized static archive on the link line of every target that declares it, and — through FR7 — of every target that (transitively) links such a target without importing std itself, so the object is linked exactly where a std symbol is referenced and never archived into every intermediate static library. The linker resolves a std object when a symbol references it and omits it otherwise. This holds by construction, not by accident: the object is on the link line because it is a linked library, never left off to rely on libstdc++'s currently-trivial std initializer — which breaks the moment the std object carries real code, as MSVC's ~134 KB std.obj already does (§15.1). Whether the archive's members are force-linked (--whole-archive) or pulled on demand is an implementation choice; on-demand is preferred so an artifact that references no std symbol is not bloated by the object (load-bearing on MSVC, where the object is large).
  • Version gate. Requires a GCC new enough to ship the std module and manifest (GCC ≥ 15); error clearly below that.

10. Build-graph / phases

Per module-enabled target:

                (generator, if any)
                        │  declared outputs (A4)
                        ▼
  each source ──► [scan edge] ──► src.ddi (P1689)
                        │
   dep targets' provided-module maps
                        ▼
                  [collate edge] ──► target.dyndep  +  target.provided-modules
                        │
                        ▼
  each source ──► [compile edge, dyndep=target.dyndep] ──► obj.o (+ name.gcm for MIUs)
                        ▼
                     [link edge]
  • Scan and compile edges have identical, static command lines except for the input/output; the compile edge additionally names dyndep.
  • No edge's command line contains a module name or a BMI path.

11. Non-functional requirements

  • NFR1 — Static command lines. No compile command may vary based on scan results. (This is the acceptance test for "jussi-aligned".)
  • NFR2 — Correctness under incremental builds. Content edits, generated sources, and cross-target changes MUST produce correct rebuilds without manual reconfigure.
  • NFR3 — Performance. Scanning adds at most one process per source (or per target if batched). No IPC, no per-file response files, no per-consumer BMI duplication.
  • NFR4 — Backend. Ninja with dyndep support required; feature MUST be cleanly disabled/errored on other backends.

12. Failure modes → required behavior

Failure Required behavior
Duplicate module name into one link Hard error at collation (§FR10)
Missing provider for a required module Hard error naming TU + module (for std/std.compat, hint to add dependency('std') — FR11)
Module cycle Hard error describing the cycle
Divergent cpp_std between module-sharing targets Warn (A1 is user's contract)
Non-Ninja backend Error: feature unsupported on this backend
GCC too old for -fmodules / P1689 Error with version requirement

13. Testing requirements

  • Single target: MIU + consumer in one target.
  • Partitions: X:part produce/consume; assert gcm.cache/X-part.gcm.
  • Cross-target: library provides module, executable links and imports it, with no module flags in the executable's command line.
  • Cross-subproject: same, across a subproject boundary.
  • Generated MIU: generator emits a .cppm; consumer builds correctly.
  • import std;: a target declaring dependency('std') whose source uses import std; builds, links, and runs; assert gcm.cache/std.gcm is produced and the synthesized std library is on the link line. Same for import std.compat;. Cover the transitive case: a target that only links a std-importing library (without importing std itself) still links the std library, while that intermediate static library does not archive it.
  • Incremental: change a source's import set; assert rebuild without reconfigure and without changed compile command lines (NFR1).
  • Negative: duplicate module name, missing provider, and cycle each produce the required diagnostic.
  • Command-line audit: assert no compile/scan command contains -fmodule-file= or a .gcm path (NFR1).

14. Deferred / open questions

  • Clang. Needs a build-time BMI-naming step (or upstream -fmodule-output-dir) because it will not self-name BMIs into a directory the way GCC does. Audited 2026-07-06: see CLANG_REQUIREMENTS.md — this is Clang's only rule break (§2.3, producer side; consumers resolve by directory name lookup exactly as this design wants), and the harvest-edge design there ports named modules and import std (both stdlibs, verified) with NFR1 intact.
  • Relaxing A1 (per-target dirs). If mixed flags ever need support, FR4's single cache becomes per-target module directories with consumers adding producer dirs to their search path (jussi's elaborated scheme). Explicitly out of scope now.
  • Scan batching. One scanner process per target (vs per source) as a performance optimization, contingent on correctness parity.
  • MSVC. Portability is analyzed in §15 (named modules port cleanly with NFR1 intact); it is out of scope for the GCC-first cut but not blind.

15. MSVC portability

Status: MSVC is out of scope for this GCC-first cut (§header). This section records how the named-modules architecture would port to MSVC so a future backend is not designed blind. All MSVC behavior below is verified on MSVC 19.44 (toolset 14.44.35207, /std:c++20), not taken from docs.

The headline is the opposite of header units (see HEADER_UNIT_REQUIREMENTS.md §15, where MSVC forces a compromise): named modules port cleanly and NFR1 is preserved in full. MSVC resolves a module — and its entire transitive closure — by name within a search directory, exactly as GCC does from gcm.cache. Consumers need only directory-level flags; no module name and no BMI path ever reaches a command line, and nothing varies with scan results. The gcm.cache in-process mapper (§6.1) becomes an explicit /ifcSearchDir <dir>; that is the whole of the difference for consumers.

15.1 Verified MSVC behavior

  • Enable. /std:c++20 alone; modules are on by default (no /experimental:module). .ixx is recognized as a module interface; /interface is the explicit opt-in — the MSVC analogue of FR1's extension + opt-in.
  • MIU build → directory, auto-named. cl /std:c++20 /interface /TP <src> /ifcOutput <dir>\ /Fo:<obj> writes <dir>\<module>.ifc, the filename derived from the module name by the compiler, and also emits an object. Partition pkg:partpkg-part.ifc — the same :- scheme as GCC's pkg-part.gcm (§8). Only a directory (/ifcOutput <dir>\) is passed, never a per-module output path.
  • Import resolution — directory + transitive, no per-module flags. A consumer importing only pkg, compiled with only /ifcSearchDir <dir>, resolves pkg and transitively its partition from the directory. No /reference name=path, no BMI path — the direct analogue of GCC resolving the transitive closure from gcm.cache with bare -std=c++20 -fmodules (§8).
  • MIU object MUST be linked. Dropping the interface's object yields LNK2019: unresolved external symbol — the definitions live in the MIU object, as on GCC. (FR7 / link behavior.)
  • Scanner — cold-capable, P1689. cl /scanDependencies <out.json> emits provides/requires before any .ifc exists, with a lookup-method field. For named modules this is parity with GCC (both scan cold); the header-unit cold-scan defect that plagues GCC is irrelevant here.
  • import std; (FR11) works and sharpens the linkage rule. MSVC ships the std interface sources at %VCToolsInstallDir%modules\{std.ixx, std.compat.ixx} with a modules.json manifest listing them (module-sources) — the direct analogue of libstdc++'s *.modules.json (FR11 discovery). Synthesizing the std library from those sources as FR11 prescribes (built with /interface into a cache dir, consumed via /ifcSearchDir), import std; compiles, links, and runs. Crucially, MSVC's std.obj is ~134 KB of real code, not the trivial initializer GCC emits today — so FR11 linking the std object as an ordinary dependency library is unconditionally load-bearing on MSVC; a design that merely "happened to link without it" would fail here, which FR11's by-construction linkage rules out.

15.2 What ports unchanged

FR1–FR11 all survive; only the flag spellings and the cache-location mechanism change (§15.3). In particular:

  • FR2/FR3 scan → collate → dyndep is intact: cold-capable P1689 (via /scanDependencies) feeds a collator that emits a Ninja dyndep ordering producers before consumers. The name→BMI mapping FR3 computes becomes <name><cachedir>\<name>.ifc (partition :-), used to build dyndep edges, not command-line flags.
  • FR4 single shared cache → one shared .ifc directory named by /ifcSearchDir (+ /ifcOutput on producers). A1's consequence (a single cache is sound) is unchanged.
  • FR5 import resolution / FR6 ordering/ifcSearchDir directory lookup + dyndep order-only edges, verified transitive.
  • FR7 propagation → a producing target contributes its .ifc directory to consumers' /ifcSearchDir, exactly as include_directories contributes header dirs; std is the same synthesized provider.
  • FR8 generated / FR9 reconfigure / FR11 std via dependency('std') → unchanged in shape.

15.3 What changes — mechanical GCC→MSVC mapping

Concern GCC (this doc) MSVC port
Enable -std=c++20 -fmodules /std:c++20 (modules default on)
MIU opt-in extension + per-source flag (FR1) .ixx default; /interface
MIU compile output no flag → gcm.cache/<name>.gcm /ifcOutput <dir>\<dir>\<name>.ifc (auto-named)
BMI extension .gcm .ifc
Partition filename pkg-part.gcm pkg-part.ifc (same :-)
Cache location fixed gcm.cache/, implicit (CWD-relative) explicit /ifcSearchDir <dir>
Import resolution automatic directory + transitive /ifcSearchDir directory + transitive (verified)
Per-module flags none (MUST NOT emit -fmodule-file=) none needed (MUST NOT emit /reference name=path)
Scan -fdeps-format=p1689r5 -fdeps-file=… -M -MF … /scanDependencies <out.json>
MIU object produced, linked produced, linked (LNK2019 without it)
std sources libstdc++ *.modules.jsonbits/std.cc etc. modules.jsonstd.ixx, std.compat.ixx
std object trivial initializer today ~134 KB real code, must link

15.4 NFR1 — fully preserved (the key contrast with header units)

Every flag the MSVC port adds to a consumer command line is a directory (/ifcSearchDir) or, on producers, a directory (/ifcOutput <dir>\). None names a module, none carries a BMI path, and none varies with scan results — so both NFR1 sub-clauses hold: (a) no command varies with the scan, and (b) no BMI path appears on any command line. This is exactly why named modules are the clean case on MSVC while header units are not (HEADER_UNIT_REQUIREMENTS.md §15.4): a module has an identifier with a <name>.ifc convention a directory can be searched by; a header unit's spelling has none, forcing an explicit name=path. The asymmetry "named modules clean, header units the exception" therefore holds on both compilers, reached from opposite directions.

15.5 Consequence — no collator-generated command lines (still jussi-aligned)

Because resolution is by directory, an MSVC backend keeps static command lines + dyndep-for-ordering — the jussi model of §2 — and specifically does not need the per-module /reference name=path command-line generation that CMake's collator emits. FR3's collator still runs (it builds the dyndep), but its output is ordering, not flags. The one Clang caveat of §14 (Clang cannot self-name BMIs into a directory) does not apply to MSVC: /ifcOutput <dir>\ self-names like GCC, so MSVC sits with GCC on the easy side of that split.

Version gate: MSVC named modules need VS 2022 (cl 19.3x+); the std module needs a toolset recent enough to ship modules.json (verified on 14.44).


Appendix A — Verified GCC behavior (GCC 16.1.1)

  • MIU compiled with no output flag → gcm.cache/<name>.gcm; partition pkg:partgcm.cache/pkg-part.gcm.
  • Consumer importing pkg builds and links with only -std=c++20 -fmodules — no -fmodule-file=, transitive pkg:part resolved from the cache.
  • P1689 scan (-fdeps-format=p1689r5 -fdeps-file=… -fdeps-target=… -M -MF … -fmodules) emits accurate provides/requires before any .gcm exists.
  • import std; scans cold (reports requires: std, no BMI needed) — unlike a header unit. libstdc++ ships libstdc++.modules.json mapping stdbits/std.cc and std.compatbits/std.compat.cc (is-std-library: true). Building that source yields gcm.cache/std.gcm + an object containing only the module initializer; consumers resolve std by directory lookup.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment