-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathbuild_program.cppm
More file actions
1606 lines (1539 loc) · 86.3 KB
/
Copy pathbuild_program.cppm
File metadata and controls
1606 lines (1539 loc) · 86.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// mcpp.build.build_program — L3 `build.mcpp`: a project-local native imperative
// build program (Zig's build.zig / Cargo's build.rs model, but in C++ so it
// dogfoods mcpp). Compiled with the HOST toolchain and run BEFORE the main build;
// it emits stdout `mcpp:` directives that augment the main build (extra flags,
// link libraries/search dirs, defines, generated sources). A declared-input cache
// (Discipline 2) re-runs it only when its source, a declared input, or a declared
// env var changes — the documented replacement for the bare `.mcpp_ok` marker.
//
// See .agents/docs/2026-06-30-l3-build-mcpp-implementation-design.md.
module;
export module mcpp.build.build_program;
import std;
import mcpp.manifest;
import mcpp.platform;
import mcpp.pm.mangle; // imported_module_names -- what build.mcpp asks for
import mcpp.platform.process;
import mcpp.toolchain.cppfly; // std_flag (dialect- and c++fly-aware -std= spelling)
import mcpp.toolchain.dialect; // CommandDialect — gnu vs cl.exe spellings
import mcpp.toolchain.fingerprint; // hash_file / hash_string (FNV-1a, 16 hex)
import mcpp.build.directives; // the directive definition table (own module: see its header)
import mcpp.build.refusal; // the machine-readable identity of a refusal
import mcpp.build.hostprogram; // bundled `mcpp` module compile (own module: see its header)
import mcpp.toolchain.hostflags; // the shared host-compile flag producer
import mcpp.toolchain.linkmodel; // shared C-library / clang-cfg-bypass model
import mcpp.toolchain.model; // Toolchain, PayloadPaths, is_clang/is_musl_target/is_mingw_target
import mcpp.toolchain.registry; // archive_tool
import mcpp.toolchain.stdmod; // ensure_built — the SAME std BMI the main build uses
import mcpp.toolchain.triple; // host_triple (MCPP_HOST contract value)
import mcpp.ui;
import mcpp.version; // MCPP_VERSION — the hint names the engine the reader is on
export namespace mcpp::build {
// Build-program environment contract (G3) — what the running build.mcpp can
// see, mirroring Cargo's env family. Injected as MCPP_* variables into the
// child ONLY (never the calling process), and folded into the cache key so a
// target/profile/feature change re-runs the program.
struct BuildProgramEnv {
std::string targetTriple; // resolved canonical triple; "" = host
// The resolved toolchain's payload root and the target's own C library
// root. Both exist so a package can ASK instead of DECLARE — see
// hostprogram::toolchain_dir / sysroot_dir for why declaring was wrong.
std::string toolchainDir;
std::string targetSysroot;
// THE TWO ANSWERS A SECOND COMPILER NEEDS AND CANNOT DERIVE.
//
// `toolchainSysroot` is the `--sysroot` mcpp passes to its own compiler and
// `toolchainBinutilsDir` the directory it names with `-B`; either is empty
// when mcpp passes none. They are not the same question as
// `targetSysroot`, which is a TIER fact (a bare-metal target's own C
// library payload, empty on a hosted target) — these two are ENVIRONMENT
// facts, and on a hosted subos both are non-empty precisely because the C
// library is not at `/usr/include` and the assembler is not at `/usr/bin`.
//
// Measured 2026-09-05 on the CUDA example. `nvcc` refuses a libc++ host
// compiler and fails on GCC 16's `<type_traits>`, so its rule package
// resolves a second host compiler from a declared payload. That compiler
// is not one mcpp resolved, so nothing tells it where anything is, and the
// first `#include` in NVIDIA's own `crt/host_config.h` fails:
//
// host_config.h:218: fatal error: features.h: No such file or directory
//
// Every rule package driving a compiler mcpp did not resolve has the same
// gap — `hipcc`, `-fsycl-host-compiler`, a generator that compiles what it
// emits — so the answer belongs to the engine and is stated once here.
std::string toolchainSysroot;
std::string toolchainBinutilsDir;
// WHICH COMPILER RESOLVED — "gcc" | "clang" | "msvc" | "".
//
// A package should never have to guess this, and until this field existed
// the only way to was to look at `toolchainDir` and recognise a directory
// name. The question is real and recurring: the routines a compiler emits
// calls to and no C library defines live in `libgcc.a` under one and in
// compiler-rt under another, and the tool that turns a `.def` into an
// import library is `dlltool` under one and `llvm-dlltool` under another.
//
// Measured 2026-08-22, both on the same day and both from the same
// missing answer: `openkal-musl` naming `-lgcc` on a link whose compiler was
// clang (`unable to find library -lgcc`), and `openkal-windows` running
// `llvm-dlltool` under a GCC toolchain (`sh: 1: llvm-dlltool: not found`).
// Each package had made the assumption its author's toolchain made true.
std::string compilerId;
// WHICH C++ STANDARD LIBRARY RESOLVED — "libstdc++" | "libc++" |
// "msvc-stl" | "".
//
// `compilerId` does not answer this. clang links libc++ on one machine and
// libstdc++ on another, both reporting "clang", and the two differ in what
// they accept: llama.cpp-m's Vulkan backend does not compile under libc++
// because upstream destroys a `unique_ptr` to an incomplete type, which
// libstdc++ accepts and libc++ rejects. A package that wants to refuse
// early, by name, has no other way to ask -- and refusing on the compiler
// name would also refuse clang with libstdc++, which works.
//
// The engine has resolved this value for a long time: it is in the cache
// key, the ABI tag, the toolchain fingerprint and `resolution.json`. It was
// simply never handed to the layer that had to decide on it.
std::string cxxStdlib;
// Three more answers a board-support package would otherwise hardcode.
//
// THE COUPLING THESE REMOVE IS INVISIBLE IN A MANIFEST. `riscv-virt-rt`
// declares no dependency on LLVM or on picolibc — #459 removed those — and
// yet it named `clang_rt.builtins-riscv64` (a compiler-rt fact, `libgcc`
// under GCC) and `rv64gc/lp64d` (picolibc's multilib convention). A
// declared dependency is visible; a hardcoded name is not, and it fails
// only when something is swapped.
//
// The division of labour is the same one the layering already uses:
// location is a target fact, selection is a board fact. Which builtins
// library exists is decided by the compiler, and no board chooses to go
// without one; where a profile's libraries live is the C library's
// convention. Both belong to the engine, which knows them already.
std::string targetBuiltinsLib; // "clang_rt.builtins-riscv64" | "gcc" | ""
std::string targetLibcProfile; // "rv64gc/lp64d" | ""
std::string targetLibc; // "picolibc-riscv" | "" (zero-libc tier)
// THE PROJECT'S FLOOR FOR THIS TRIPLE, IN THE PLATFORM'S OWN WORDS (#622
// A11): `14.0` on macOS, `18.0` on iOS, an API level on Android, empty
// elsewhere. The same `min_platform_version` (prepare.cppm) already
// computes for the compiler's `--target` flag and the fingerprint slot --
// this is that answer, handed to the build program instead of restated in
// a member's own options, where `dist/apple.cppm:140-143` measured it
// drifting.
std::string minPlatformVersion;
std::string profile; // effective profile name (dev/release/…)
std::vector<std::string> features; // active feature closure of the package
// The device axis of this build, in the wire form `mcpp.pack.abi_tag`
// reads (`cuda12.9+{sm_89} ptx>=89`); empty when the build asks for no
// accelerator. Already resolved -- `--accel` / `--no-accel` over
// `[build] accel` -- so a rule package derives its own spelling
// (`-gencode`, `--offload-arch`) from here and the architecture set is
// written once, in the manifest, and never again in a build program.
std::string accel;
// The package this program is building, from `[package]`. Reported because
// every name a rule generates is derived from it -- the module a consumer
// imports, the namespace its accessors sit in -- and a build program had no
// way to ask. See hostprogram::package_name for what it replaced.
std::string packageName;
std::string packageNamespace;
// THE REST OF `[package]`, FOR THE MEMBER OF THE COLLECTION THAT NEEDS IT.
//
// A rule generates a declaration and needs the package's NAME. A member
// that produces a DISTRIBUTABLE needs more: every installer format carries
// a version, and most carry a description, a licence and a maintainer.
// Without these a project has to restate them in the member's options,
// where they can drift from `[package]` with nothing able to detect it --
// the second copy of a value whose first copy mcpp has already parsed.
//
// `packageAuthors` is joined with ';' rather than ',' because an author
// entry is conventionally `Name <mail@host>` and a name may carry a comma.
// Empty under an engine that predates these, which a member reads as "fall
// back to whatever you did before".
std::string packageVersion;
std::string packageDescription;
std::string packageLicense;
std::string packageAuthors;
std::string packageRepo;
// ── The packaging pass this build is part of (mcpp 2026.9.11.1+) ────────
//
// Empty for every ordinary build, and that is the value that carries the
// meaning: a member which produces a distributable SUBMITS NOTHING unless
// the format it provides was asked for. `mcpp build` therefore has the
// graph it always had, and the dist edge exists only in the pass that
// wants it.
//
// The value is the `--format` argument verbatim -- `tar`, `dir`, or a name
// a package provides. It rides the same env vector as everything else here,
// so `contract_hash` folds it into the build program's re-run key: the
// second pass re-runs exactly the programs whose answer this changes.
std::string packFormat;
// Where `mcpp pack` has ALREADY STAGED the closure, absolute. Non-empty
// only in the second pass, and only then because a staged tree is produced
// by mcpp after the link -- so a graph generated before the link cannot
// name a directory that does not exist yet.
//
// This is the value `${mcpp.stage_dir}` expands to. A member reads it to
// decide the shape of the work (which of `bin/`, `lib/`, `share/` the tree
// actually has) and writes the placeholder into the action, so the two
// never disagree.
std::filesystem::path packStageDir;
// Whether this package builds C++ modules (`[language] modules`).
//
// Reported because a rule package that GENERATES a consumer-facing
// declaration has to choose between a module interface and a header, and
// the project has already stated which it uses. Deriving it any other way
// would be a second spelling of one decision. A rule that reads it can make
// the module surface its default without any project declaring anything,
// and an engine older than this one leaves the variable absent -- which a
// rule reads as "header", the behaviour every consumer had before.
bool languageModules = true;
// The rule modules a synthesised build program imports, from
// `BuildConfig::ruleModules`. When this package has no `build.mcpp` and
// this list is not empty, mcpp writes the program these entries describe.
// The program is the one the project would have written by hand, which is
// what makes the declaration a LAYER above `build.mcpp` rather than a
// second way of doing the same thing.
std::vector<std::string> ruleModules;
// The device-kind sources (`.cu`, `.hip`, ...) this package's effective
// source set matches, package-root-relative with `/` separators, one per
// line. The engine has no compile rule for them and hands the list to the
// build program, where the rule package the package imports turns each
// one into an `mcpp::action`. Already narrowed: a glob whose `accel`
// constraint the build does not satisfy contributes nothing.
std::vector<std::string> deviceSources;
// Artifact home (bin/cache/out). Empty → <root>/target/.build-mcpp (the
// root-project default). Dependencies MUST point this into the CONSUMING
// project's tree — a registry package root is shared and may be read-only.
std::filesystem::path artifactsDir;
// Base for resolving relative `mcpp:generated=` paths. Empty → root (the
// root-project contract, unchanged). Dependencies point this at OUT_DIR so
// a shared package root is never written to.
std::filesystem::path genBase;
// mcpp#241: this package's resolved dependencies, as (name → dir) pairs.
// The caller emits each dep under BOTH its canonical package name and its
// short (namespace-stripped) name, so a build.mcpp can locate a dependency's
// payload (e.g. a data-asset package) via either spelling. Emitted as
// MCPP_DEP_<SANITIZED_NAME>_DIR (same sanitizer as MCPP_FEATURE_) instead of
// reverse-engineering the store layout.
std::vector<std::pair<std::string, std::filesystem::path>> depDirs;
// Payload dirs of the packages this build declared in `[xlings] deps`,
// as (env var name → dir). Resolved by the caller through the xlings path
// helpers, so a build.mcpp asks `xpkg_dir("xim", "picolibc-riscv")`
// instead of reconstructing `<home>/data/xpkgs/<ns>-x-<name>/<version>`
// — the same reason depDirs exists for mcpp dependencies.
std::vector<std::pair<std::string, std::string>> xpkgDirs;
// #355: HOST tools this package asked its dependencies for, as
// (env var name → absolute path to the executable) pairs. The caller has
// already resolved them (built, taken from the store, or an override), so
// this is purely the delivery channel. Rides the same contract env, hence
// the same re-run key: a rebuilt tool re-runs the program that uses it,
// with no `rerun-if-changed` needed from the author.
std::vector<std::pair<std::string, std::string>> toolPaths;
// THE `bin` OF THIS PROJECT'S OWN SubOS, AT THE FRONT OF THE CHILD'S
// `PATH`. Empty for a project that has not declared one, and an empty
// value means the child's `PATH` is left exactly as mcpp received it.
//
// A build program that needs a tool has, until this field, had to ask
// `PATH` the way a shell script would — and `PATH` answers about the
// MACHINE, not about this build. Measured 2026-08-25: a probe for
// `qemu-system-riscv64` found one that answers, when executed,
//
// [error] qemu-system-riscv64 is not installed in this subos (_)
//
// — present, and unable to run — while the copy the project had declared
// sat in its own payload directory, reachable only by a path the program
// would have had to construct itself.
//
// THE PROJECT'S SubOS, NEVER A GLOBAL ONE. An earlier draft put this
// build system's shared `subos/default/bin` in front, which makes what a
// build sees depend on what else has been installed on the machine — two
// projects on one machine would agree with each other, and the same
// project on two machines would not. A declared `[xlings].subos` is a
// directory that belongs to the project and travels with it.
//
// WHO DECIDES IS NOT DECIDED HERE. `mcpp::xlings::runtime` is the sole
// project runtime-selection policy and `RuntimeBinding::subosDir` is its
// resolved answer; this field carries that answer to the child. Deriving
// it a second time — from the manifest, from `[xlings] deps`, from the
// config — is how a build ends up with two subos and no way to say which
// one it used.
//
// PREPENDED, NOT SUBSTITUTED. A build program legitimately calls `git`,
// `python3` or a shell, none of which arrive this way.
std::string toolsBin;
// #355 step 5: dependency-provided modules to compile FOR THE HOST and make
// importable from this build.mcpp — reusable build rules distributed as
// ordinary mcpp packages (`import mcpp.rules.protobuf;`) instead of a
// second, non-C++ rule DSL.
//
// Compiled with the SAME flags as build.mcpp itself, in the same directory,
// which is what makes the BMI usable at all — see DependencySpec::hostModule.
//
// ORDER IS LOAD-BEARING. The compile loop accumulates `moduleFlags` as it
// goes, so every entry sees the BMIs of the entries before it. That is the
// whole mechanism by which a rule can import another rule: the caller
// topologically sorts this list, and nothing else is required.
struct HostModuleRef {
std::string logical;
std::filesystem::path interface;
// False when this module is present ONLY because another rule imports
// it. Its BMI must exist for that rule to compile, but this build.mcpp
// never declared it, and a package's build-time provisions cross one
// further edge only on a `reexport = true` edge. GCC cannot enforce
// that — its BMIs are implicit under gcm.cache and reachable by name
// whatever the flags say — so mcpp enforces it, and does so on every
// platform rather than leaving the rule true only where the compiler
// happens to help.
bool importable = true;
};
std::vector<HostModuleRef> hostModules;
};
// The env-var name `hostprogram::xpkg_dir` reads back. One spelling of the
// sanitizer, shared by both sides — the two drifting apart would make the
// interface answer "" for a package that is right there.
inline std::string xpkg_env_var(std::string_view ns, std::string_view name) {
std::string out = "MCPP_XPKG_";
auto put = [&](std::string_view s) {
for (char c : s)
out += (c >= 'a' && c <= 'z') ? char(c - 'a' + 'A')
: ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) ? c : '_';
};
if (!ns.empty()) { put(ns); out += '_'; }
put(name);
out += "_DIR";
return out;
}
// Does a compiler's output say the program asked for something the bundled
// `mcpp` module does not have?
//
// This is the ONLY place an engine-too-old situation can be caught for the
// TYPED api, and it exists because the in-language probe does not:
//
// if constexpr (requires { mcpp::runner("x"); }) // ← hard error,
// mcpp::runner("x"); // measured
//
// A requires-expression over a qualified name that does not exist is
// ill-formed, not `false`, so a package CANNOT degrade gracefully across mcpp
// versions the way it could across, say, a header's feature macro. The wire
// protocol has its own answer for this (protocol_error() names `mcpp self
// update` for an unknown `mcpp:` key), but that answer needs the program to
// have COMPILED — and a package written against a newer mcpp does not get
// that far. So the raw compiler error is the message, and on its own it says
// only `'runner' is not a member of 'mcpp'`, which reads like the author's
// typo instead of the reader's out-of-date engine.
//
// Deliberately spelling-based, and deliberately broad across the three
// frontends (they phrase it three ways). A false positive costs one extra
// hint line under a genuine typo; a false negative costs a user an afternoon.
bool mentions_missing_mcpp_api(std::string_view compilerOutput);
// Compile + run `<root>/build.mcpp` (if present) with `hostCompiler` (the resolved
// HOST frontend — under a cross --target the caller resolves a host toolchain;
// the program always compiles AND runs on the host) and apply its directives to
// `m.buildConfig`. `tc` supplies the sysroot / runtime flags a fresh sandbox
// needs to compile + link a freestanding host program. No-op when absent.
// THE PART OF A BUILD PROGRAM'S ENVIRONMENT AN INSTALL HOOK ALSO RECEIVES
// (#613): `MCPP_COMPILER`, `MCPP_CXX_STDLIB`, `MCPP_TARGET` and its three
// segments, in that order. One function computes them for both, so a hook and a
// build program cannot be told different things about one build. Every value is
// present, and empty when it does not apply; `MCPP_TARGET` is the host triple
// when `env.targetTriple` is empty.
std::vector<std::pair<std::string, std::string>>
install_hook_env(const BuildProgramEnv& env);
std::expected<void, std::string> run_build_program(
mcpp::manifest::Manifest& m,
const std::filesystem::path& root,
const std::filesystem::path& hostCompiler,
const mcpp::toolchain::Toolchain& tc,
const mcpp::manifest::CppStandardConfig& cppStandard,
const BuildProgramEnv& env);
// Has any recorded build-program input changed since its build.mcpp cache was
// written: a glob's path SET (#359), a declared file's CONTENT, or a declared
// environment variable's value?
//
// The project-level fast path skips prepare_build entirely when no source is
// newer than build.ninja, and prepare is where the build.mcpp cache is
// normally consulted. A glob input is precisely an input whose change leaves
// every existing file's mtime alone — adding a .proto — so without this ask,
// the fast path would report "Finished dev in 0.00s" and the new file would
// never be generated. That is the same gap the fast path already closes for
// the build.mcpp source itself; a glob is one more kind of build-program input,
// so it belongs to the same question.
//
// A DECLARED FILE IS THE SAME QUESTION AND WAS NOT ASKED (2026.9.5.4). The
// mtime sweep that guards the fast path walks SOURCES, so a data file a build
// program reads -- `rerun_if_changed("data/table.csv")` -- is invisible to it,
// and this function used to skip a cache record that carried no glob at all.
// The result was that editing such a file left the generated header from the
// previous build in place: `Finished dev in 0.00s`, and the program compiled
// the previous bytes. `mcpp.tools.embed` is the case that found it. Contents,
// not mtime, exactly as the cache records them.
//
// Scans the caches under `<projectRoot>/target/.build-mcpp` (the root's own and
// each dependency's). Each cache records the root its entries were relative to,
// so a dependency's input is evaluated against the dependency's tree.
bool program_inputs_stale(const std::filesystem::path& projectRoot);
} // namespace mcpp::build
namespace mcpp::build {
// See the declaration for why this exists at all.
//
// Three frontends, three spellings of the same fact — and MSVC's does not even
// contain the word "member" in the same order, so each is matched literally
// rather than by a shared substring:
//
// gcc error: 'runner' is not a member of 'mcpp'
// clang error: no member named 'runner' in namespace 'mcpp'
// cl.exe error C2039: 'runner': is not a member of 'mcpp'
//
// The trailing `'mcpp'` is what keeps this off unrelated failures: a package's
// own missing symbol names its own namespace, not ours.
bool mentions_missing_mcpp_api(std::string_view out) {
static constexpr std::string_view kNeedles[] = {
"is not a member of 'mcpp'", // gcc, and cl.exe's tail
"in namespace 'mcpp'", // clang
};
for (auto n : kNeedles)
if (out.find(n) != std::string_view::npos) return true;
return false;
}
namespace {
namespace fs = std::filesystem;
namespace dirs = mcpp::build::directives;
// The directive model — what a directive IS, how it parses, how it is cached
// and applied — lives in mcpp.build.directives as a single table. This file
// only orchestrates: compile, run, cache, validate. See that module's header
// for why it is separate (both the nine-sites problem and the clang 22
// anonymous-namespace miscompile that forbids growing this one).
using Directives = dirs::Directives;
using dirs::Slot;
// Resolve a possibly-relative path against the project root, returning an
// absolute lexically-normal path (no filesystem touch, so it works for dirs that
// the program is about to create as well as existing ones).
std::string abs_against_root(const fs::path& root, std::string_view p) {
return dirs::abs_against(root, p);
}
std::string env_value(const std::string& name) {
const char* v = std::getenv(name.c_str());
return v ? std::string(v) : std::string();
}
// The host subset of flags.cppm's sysroot/runtime handling — enough to compile +
// link a freestanding host program on a fresh sandbox (where bare `g++ file -o x`
// can't find crt/libc). `tc` is always a HOST-targeting toolchain: under a cross
// --target, prepare.cppm resolves the spec a second time without the target axis
// (host_tc_for_build_program) and passes that here, so the native cases are the
// only ones needed. Passed as separate argv tokens (no shell).
std::vector<std::string> host_base_flags(const mcpp::toolchain::Toolchain& tc,
std::string_view macosDeploymentTarget) {
// One driver invocation compiles AND links build.mcpp, so it needs both
// sides. Both come from mcpp.toolchain.hostflags — the same producer
// flags.cppm and the std module build use. This function used to hand-write
// the whole assembly, which is how it kept missing what the main build
// already knew (quoting, the macOS deployment target, the MSVC dialect);
// see 2026-08-02-host-compile-single-producer-design.md.
mcpp::toolchain::HostFlagOptions opt;
// The host helper keeps TRUSTING clang's cfg on macOS/Windows: the macOS
// link needs the libc++abi/unwind handling the main build's
// needs_explicit_libcxx path owns, and duplicating it here produced
// undefined __cxa_* / __gxx_personality_v0.
opt.cfgBypass = mcpp::toolchain::HostFlagOptions::CfgBypass::LinuxOnly;
opt.clangStdlibSelect = true;
// `cAbiPrebuilt` is left at its default (true), and that is a statement
// rather than an omission: `tc` here is always HOST-targeting (see this
// function's header), so the helper's C library is the payload's whatever
// the project's target side turns out to be.
//
// It also corrects a latent defect. The predicate this replaced was
// `!tc.crossTargetFlag.empty()`, and a host toolchain resolved for a cross
// build could carry one — in which case the helper lost the payload's own
// headers for a reason that had nothing to do with it.
// binutils -B so the driver finds ld/as (GCC; musl and MinGW ship their own).
opt.binutilsPrefix = !mcpp::toolchain::is_musl_target(tc)
&& !mcpp::toolchain::is_mingw_target(tc);
// The helper is exec'd outside anything mcpp controls, so it must be able
// to find the toolchain's private runtime libs itself.
opt.runtimeLibDirs = true;
opt.macosDeploymentTarget = std::string(macosDeploymentTarget);
const mcpp::toolchain::PathEscape plain = mcpp::toolchain::no_escape;
auto f = mcpp::toolchain::host_compile_tokens(tc, opt, plain);
for (auto& t : mcpp::toolchain::host_link_tokens(tc, opt, plain))
f.push_back(t);
return f;
}
// ── Cache (line-based; one record per line, internal format) ───────────────
// epoch <n>
// program <hash>
// compiler <hash>
// ctx <hash>
// in <contenthash> <path>
// env <valuehash> <NAME>
// d <tag> <verbatim value to end of line>
// The leading epoch/program/compiler/ctx/in/env lines are the re-run key; the
// `d` lines are the directives to reapply on a hit. The `d` tag vocabulary is
// owned by mcpp.build.directives::kTable and is NOT spelled here — that list
// used to be duplicated in four places and drifted.
// build.mcpp artifacts live under target/ (the build output tree), not in the
// project: target/.build-mcpp/{build.mcpp.bin, build.mcpp.cache}. A stable subdir
// (not the fingerprint-keyed one — build.mcpp runs before the fingerprint exists)
// so the binary + cache survive across builds and aren't rebuilt needlessly.
// A dependency's artifacts are redirected into the CONSUMING project's tree
// via BuildProgramEnv::artifactsDir (a registry root may be read-only).
fs::path build_dir(const fs::path& root, const BuildProgramEnv& env) {
return env.artifactsDir.empty() ? root / "target" / ".build-mcpp"
: env.artifactsDir;
}
std::string cache_path(const fs::path& bdir) {
return (bdir / "build.mcpp.cache").string();
}
// MCPP_FEATURE_<NAME> spelling — same sanitizer as the compile-side
// -DMCPP_FEATURE_ macro (prepare.cppm): uppercase, non-alnum → '_'.
std::string sanitize_feature_env(std::string f) {
for (auto& c : f)
c = std::isalnum(static_cast<unsigned char>(c))
? static_cast<char>(std::toupper(static_cast<unsigned char>(c))) : '_';
return f;
}
// The injected contract values, as (NAME, value) pairs for the child process.
std::vector<std::pair<std::string, std::string>>
contract_env(const fs::path& root, const fs::path& outDir, const BuildProgramEnv& env) {
std::vector<std::pair<std::string, std::string>> e;
auto hostT = mcpp::toolchain::triple::host_triple().str();
// The toolchain and target names an install hook also receives, from the
// one function that computes them for both (#613). Emitted in the order
// they always had, so the re-run key of an existing program is unchanged.
const auto shared = install_hook_env(env);
auto shared_value = [&](std::string_view key) {
for (auto const& [k, v] : shared)
if (k == key) return v;
return std::string{};
};
e.emplace_back("MCPP_TARGET", shared_value("MCPP_TARGET"));
// THE SAME VALUE UNFILLED — EMPTY WHEN NOBODY NAMED A TARGET.
//
// `MCPP_TARGET` above answers "which machine is this for", and filling it
// in with the host is right for that question. It cannot answer a different
// one that a platform package has to ask: **was this build POINTED at a
// target**, or is it an ordinary native build?
//
// The two are not the same even when the triples are equal. `mcpp build
// --target aarch64-macos` on an arm64 Mac names the same machine the host
// is, and yet it is the graph that supplies the target side — so this tool
// puts no system SDK on the link, and the package that knows the system is
// the only thing that can name one. A native build on the same machine gets
// the SDK and needs nothing from the package.
//
// Measured 2026-08-23, `openkal-macos` trying to decide this from what
// was available. From the host: right for the cross, wrong for
// `--target aarch64-macos` ON a Mac (`library not found for -lSystem`).
// From `MCPP_TARGET`: right for the cross, wrong for the native build,
// because it is never empty (`undefined symbol: wcslen`, `strtoul`, … —
// the package's three-name stub had shadowed the vendor's complete one).
//
// An older mcpp sets neither, and that is the correct answer for it:
// it has no graph-supplied target side, so the system is always on the
// link and a package should supply nothing.
e.emplace_back("MCPP_TARGET_REQUESTED", env.targetTriple);
// Convenience splits of the resolved target (Cargo CARGO_CFG_TARGET_*
// parity): parsed ONCE, in install_hook_env, through the canonical triple parser so every
// build.mcpp stops hand-splitting MCPP_TARGET. MCPP_TARGET_ENV is "" when
// the triple has no env segment (macOS); all three are "" for an
// escape-hatch triple outside the canonical vocabulary. They ride the
// same env vector, so contract_hash folds them into the re-run key.
e.emplace_back("MCPP_TARGET_OS", shared_value("MCPP_TARGET_OS"));
e.emplace_back("MCPP_TARGET_ARCH", shared_value("MCPP_TARGET_ARCH"));
e.emplace_back("MCPP_TARGET_ENV", shared_value("MCPP_TARGET_ENV"));
e.emplace_back("MCPP_HOST", hostT);
// Always emitted, empty when they do not apply: a build program reads
// these through `env_or`, which cannot tell "absent" from "empty", and an
// absent variable would make the answer depend on whatever the parent
// process happened to export.
e.emplace_back("MCPP_TOOLCHAIN_DIR", env.toolchainDir);
e.emplace_back("MCPP_TOOLCHAIN_SYSROOT", env.toolchainSysroot);
e.emplace_back("MCPP_TOOLCHAIN_BINUTILS_DIR", env.toolchainBinutilsDir);
e.emplace_back("MCPP_COMPILER", shared_value("MCPP_COMPILER"));
e.emplace_back("MCPP_CXX_STDLIB", shared_value("MCPP_CXX_STDLIB"));
e.emplace_back("MCPP_TARGET_SYSROOT", env.targetSysroot);
e.emplace_back("MCPP_TARGET_BUILTINS_LIB", env.targetBuiltinsLib);
e.emplace_back("MCPP_TARGET_LIBC_PROFILE", env.targetLibcProfile);
e.emplace_back("MCPP_TARGET_LIBC", env.targetLibc);
// #622 A11. Always emitted, empty when `min_platform_version` returned
// empty (every non-Apple, non-Android target) — the same "absent and
// empty must not be the same observation" reason every other always-on
// contract value here is. Rides this vector, so it joins the re-run key
// like every other value contract_hash folds in.
e.emplace_back("MCPP_TARGET_MIN_PLATFORM_VERSION", env.minPlatformVersion);
e.emplace_back("MCPP_PROFILE", env.profile);
e.emplace_back("MCPP_ACCEL", env.accel);
e.emplace_back("MCPP_LANGUAGE_MODULES", env.languageModules ? "1" : "0");
{
std::string joined;
for (auto const& d : env.deviceSources) {
if (!joined.empty()) joined += '\n';
joined += d;
}
e.emplace_back("MCPP_DEVICE_SOURCES", joined);
}
e.emplace_back("MCPP_OUT_DIR", outDir.string());
e.emplace_back("MCPP_MANIFEST_DIR", root.string());
e.emplace_back("MCPP_PKG_NAME", env.packageName);
e.emplace_back("MCPP_PKG_NAMESPACE", env.packageNamespace);
e.emplace_back("MCPP_PKG_VERSION", env.packageVersion);
e.emplace_back("MCPP_PKG_DESCRIPTION", env.packageDescription);
e.emplace_back("MCPP_PKG_LICENSE", env.packageLicense);
e.emplace_back("MCPP_PKG_AUTHORS", env.packageAuthors);
e.emplace_back("MCPP_PKG_REPO", env.packageRepo);
e.emplace_back("MCPP_PACK_FORMAT", env.packFormat);
e.emplace_back("MCPP_PACK_STAGE_DIR", env.packStageDir.string());
std::string csv;
for (auto const& f : env.features) {
if (!csv.empty()) csv += ',';
csv += f;
e.emplace_back("MCPP_FEATURE_" + sanitize_feature_env(f), "1");
}
e.emplace_back("MCPP_FEATURES", csv);
// mcpp#241: per-dependency payload dir, under MCPP_DEP_<SANITIZED_NAME>_DIR
// (same sanitizer as MCPP_FEATURE_ — predictable from the manifest, not
// store internals). Two distinct dep names can sanitize to the same var
// (e.g. `foo.bar` vs `foo-bar`, or a bare `zlib` vs another dep's short
// `zlib`); guard so a silent last-wins can't hand one dep another's dir —
// keep the first and warn on a conflicting value.
std::map<std::string, std::string> depVarValue;
for (auto const& [name, dir] : env.depDirs) {
auto var = "MCPP_DEP_" + sanitize_feature_env(name) + "_DIR";
auto [it, inserted] = depVarValue.try_emplace(var, dir.string());
if (inserted) {
e.emplace_back(var, dir.string());
} else if (it->second != dir.string()) {
mcpp::ui::warning(std::format(
"build.mcpp: dependency name collides on {} (kept '{}', ignored "
"'{}') — rename one dependency to disambiguate", var,
it->second, dir.string()));
}
}
// The xlings side of the same question. Each declared entry is emitted
// under BOTH its namespaced and its bare spelling, because a manifest may
// write either and the build.mcpp asking should not have to know which
// one the author chose. Namespaced entries are emitted first, so a bare
// name that two namespaces claim resolves to the first DECLARED one rather
// than to whichever was seen last.
for (auto const& [var, dir] : env.xpkgDirs) {
auto [it, inserted] = depVarValue.try_emplace(var, dir);
if (inserted) e.emplace_back(var, dir);
}
// #355: MCPP_DEP_<PKG>_BIN_<TOOL> — absolute path to a host tool the
// consumer declared via `tools = [...]`. A PATH rather than a directory:
// the store keys an entry per (package, target), the typed reader can
// append the platform's exe suffix itself, and a tool's adjacent DATA
// (protoc's well-known .proto files, say) lives in the package tree, which
// dep_dir() already exposes.
// ── The child's PATH, with the project's own SubOS in front ────────────
//
// See `BuildProgramEnv::toolsBin` for why this exists, why it is a prefix
// rather than a replacement, and why the decision is not made here.
if (!env.toolsBin.empty()) {
std::string path = env.toolsBin;
// THE INHERITED VALUE IS READ HERE AND NOT ASSUMED. `extraEnv`
// replaces a variable outright in the child, so writing only the
// project's own directory would silently be the substitution this
// deliberately is not.
if (const char* inherited = std::getenv("PATH"); inherited && *inherited) {
path += mcpp::platform::env::path_list_separator();
path += inherited;
}
e.emplace_back("PATH", path);
}
for (auto const& [var, path] : env.toolPaths) {
auto [it, inserted] = depVarValue.try_emplace(var, path);
if (inserted) {
e.emplace_back(var, path);
} else if (it->second != path) {
mcpp::ui::warning(std::format(
"build.mcpp: tool name collides on {} (kept '{}', ignored '{}')",
var, it->second, path));
}
}
return e;
}
// The contract values are part of the re-run key UNCONDITIONALLY — a target /
// profile / feature change must re-run the program; that correctness cannot
// depend on the author remembering rerun-if-env-changed.
std::string contract_hash(const std::vector<std::pair<std::string, std::string>>& e) {
std::string s;
for (auto const& [k, v] : e) { s += k; s += '='; s += v; s += '\n'; }
return mcpp::toolchain::hash_string(s);
}
// The project-relative name of the build output tree ("target" by default), so
// a glob input never walks into what a previous run produced. Empty when the
// output lives outside the project, in which case nothing needs excluding.
std::string output_dir_name(const fs::path& root, const fs::path& bdir) {
std::error_code ec;
auto rel = bdir.lexically_relative(root);
if (rel.empty()) return {};
auto first = rel.begin();
if (first == rel.end()) return {};
auto name = first->string();
if (name == "..") return {}; // outside the project
return name;
}
void write_cache(const fs::path& bdir, const fs::path& root,
const std::string& programHash,
const std::string& compilerHash, const std::string& ctxHash,
const Directives& d) {
std::ofstream os(cache_path(bdir), std::ios::trunc);
if (!os) return; // best-effort: a failed cache write only loses the optimization
// The epoch guards against a semantics change: the `d` lines below are
// replayed verbatim on a hit, so if this mcpp interprets a directive
// differently than the one that wrote them, the entry must not be reused.
os << "epoch " << dirs::kCacheEpoch << '\n';
os << "program " << programHash << '\n';
os << "compiler " << compilerHash << '\n';
os << "ctx " << ctxHash << '\n';
// The root the relative entries below are resolved against. Recorded so a
// reader that is not prepare_build — the fast-path glob check — can
// evaluate a DEPENDENCY's cache against the dependency's own tree.
os << "root " << root.string() << '\n';
for (auto const& f : d.at(Slot::RerunFiles))
os << "in " << mcpp::toolchain::hash_file(abs_against_root(root, f)) << ' ' << f << '\n';
for (auto const& e : d.at(Slot::RerunEnv))
os << "env " << mcpp::toolchain::hash_string(env_value(e)) << ' ' << e << '\n';
// #359: a glob input's fingerprint is the SET of matching paths. Same
// record shape as `in`/`env`; the value is computed by the table's owner.
{
auto outName = output_dir_name(root, bdir);
for (auto const& g : d.at(Slot::RerunGlobs))
os << "glob " << dirs::glob_fingerprint(root, g, outName) << ' '
<< g << '\n';
}
dirs::serialize(os, d);
}
struct CacheRecord {
int epoch = 0; // 0 = pre-epoch entry (written before this guard existed)
std::string programHash;
std::string compilerHash;
std::string ctxHash; // contract env (target/profile/features/out-dir)
std::string rootPath; // what relative entries are resolved against
std::vector<std::pair<std::string, std::string>> inputs; // (hash, path)
std::vector<std::pair<std::string, std::string>> envs; // (hash, name)
std::vector<std::pair<std::string, std::string>> globs; // (hash, pattern)
Directives directives;
// A `d` record whose tag this mcpp does not know — the entry was written
// by a newer mcpp. Replaying the rest would apply a strict subset of what
// the program asked for, so the whole entry is discarded instead.
bool unknownRecord = false;
bool loaded = false;
};
CacheRecord read_cache(const fs::path& bdir) {
CacheRecord r;
std::ifstream is(cache_path(bdir));
if (!is) return r;
std::string line;
while (std::getline(is, line)) {
if (line.empty()) continue;
auto sp = line.find(' ');
if (sp == std::string::npos) continue;
std::string tag = line.substr(0, sp);
std::string rest = line.substr(sp + 1);
if (tag == "epoch") {
int n = 0;
if (std::from_chars(rest.data(), rest.data() + rest.size(), n).ec == std::errc{})
r.epoch = n;
}
else if (tag == "program") r.programHash = rest;
else if (tag == "compiler") r.compilerHash = rest;
else if (tag == "ctx") r.ctxHash = rest;
else if (tag == "root") r.rootPath = rest;
else if (tag == "in" || tag == "env" || tag == "glob") {
auto sp2 = rest.find(' ');
if (sp2 == std::string::npos) continue;
std::string h = rest.substr(0, sp2), name = rest.substr(sp2 + 1);
(tag == "in" ? r.inputs : tag == "env" ? r.envs : r.globs)
.emplace_back(h, name);
} else if (tag == "d") {
auto sp2 = rest.find(' ');
if (sp2 == std::string::npos) continue;
std::string kind = rest.substr(0, sp2), val = rest.substr(sp2 + 1);
if (!dirs::accept_cache_record(r.directives, kind, val))
r.unknownRecord = true;
}
}
r.loaded = true;
return r;
}
// Decide whether the cached run is still valid (so we can skip recompiling/running).
bool cache_fresh(const fs::path& root, const fs::path& bdir, const CacheRecord& c,
const std::string& programHash, const std::string& compilerHash,
const std::string& ctxHash) {
if (!c.loaded) return false;
if (c.epoch != dirs::kCacheEpoch) return false; // pre-epoch entries rerun once
if (c.unknownRecord) return false;
if (c.programHash != programHash) return false;
if (c.compilerHash != compilerHash) return false;
if (c.ctxHash != ctxHash) return false; // pre-G3 caches (no ctx line) rerun once
for (auto const& [h, path] : c.inputs)
if (mcpp::toolchain::hash_file(abs_against_root(root, path)) != h) return false;
for (auto const& [h, name] : c.envs)
if (mcpp::toolchain::hash_string(env_value(name)) != h) return false;
// #359: the path SET behind each declared glob. A file appearing or
// disappearing changes it; editing one does not (that is what the `in`
// entries above are for).
if (!c.globs.empty()) {
auto outName = output_dir_name(root, bdir);
for (auto const& [h, pattern] : c.globs)
if (dirs::glob_fingerprint(root, pattern, outName) != h) return false;
}
// A declared output that vanished invalidates the cache. Driven off the
// table's mustExistAfterRun so a future output-shaped directive is covered
// without editing this function.
for (auto const& def : dirs::kTable) {
if (!def.mustExistAfterRun) continue;
for (auto const& p : c.directives.at(def.slot))
if (!fs::exists(abs_against_root(root, p))) return false;
}
return true;
}
// The program a set of rule modules describes.
//
// It is the program a project writes by hand for the same rules, and that is
// the whole contract: the declaration is a layer ABOVE `build.mcpp`, not a
// second mechanism beside it, so a project that outgrows it copies this file
// into its root and edits it. Synthesis then stops, because a project that has
// its own program keeps it.
//
// The namespace comes from the module name with `.` exchanged for `::`, which
// is the convention `mcpp.rules.<x>` already follows and the one a third-party
// rule opts into by naming its module. Nothing here knows what any rule does.
std::string synthesised_rule_program(const std::vector<std::string>& modules) {
std::string s =
"// Generated by mcpp from the build rules this package's dependencies\n"
"// declare. Do not edit. To take it over, copy this file to `build.mcpp`\n"
"// in the project root; mcpp synthesises nothing once a project has one.\n"
"import std;\n"
"import mcpp;\n";
for (auto const& m : modules) s += "import " + m + ";\n";
s += "\nint main() {\n bool ok = true;\n";
for (auto const& m : modules) {
std::string ns;
for (char c : m) { if (c == '.') ns += "::"; else ns += c; }
// `&&` would stop at the first refusal, and a project with two rules
// wants both diagnostics rather than one and then silence.
s += " ok = " + ns + "::compile() && ok;\n";
}
s += " return ok ? 0 : 1;\n}\n";
return s;
}
} // namespace
std::vector<std::pair<std::string, std::string>>
install_hook_env(const BuildProgramEnv& env) {
mcpp::toolchain::triple::Triple t{};
std::string target;
if (env.targetTriple.empty()) {
t = mcpp::toolchain::triple::host_triple();
target = t.str();
} else {
target = env.targetTriple;
if (auto p = mcpp::toolchain::triple::parse(env.targetTriple)) t = *p;
}
return {
{"MCPP_COMPILER", env.compilerId},
{"MCPP_CXX_STDLIB", env.cxxStdlib},
{"MCPP_TARGET", target},
{"MCPP_TARGET_OS", t.os},
{"MCPP_TARGET_ARCH", t.arch},
{"MCPP_TARGET_ENV", t.env},
};
}
std::expected<void, std::string> run_build_program(
mcpp::manifest::Manifest& m,
const fs::path& root,
const fs::path& hostCompiler,
const mcpp::toolchain::Toolchain& tc,
const mcpp::manifest::CppStandardConfig& cppStandard,
const BuildProgramEnv& env) {
fs::path src = root / "build.mcpp";
std::error_code ec;
if (!fs::exists(src, ec)) {
// The layer above this file: when a dependency's rules claimed device
// sources in this package and the package wrote no program, mcpp writes
// the program those rules describe.
//
// SYNTHESISED ONLY IN THE ABSENCE. A project with its own `build.mcpp`
// keeps it, because the two would otherwise both submit the same
// actions and the second submission is one nobody asked for. That is
// also what makes the descent safe: copy the generated file into the
// project root, edit it, and synthesis stops.
if (env.ruleModules.empty()) return {}; // nothing to do
src = build_dir(root, env) / "build.mcpp";
fs::create_directories(src.parent_path(), ec);
const std::string text = synthesised_rule_program(env.ruleModules);
// Written only when it differs, so a package whose rule set did not
// change does not rebuild its build program on every configure.
bool same = false;
if (std::ifstream in(src, std::ios::binary); in) {
std::string prev((std::istreambuf_iterator<char>(in)),
std::istreambuf_iterator<char>());
same = (prev == text);
}
if (!same) {
// REFUSED, not skipped. Returning "nothing to do" here would leave
// the device sources uncompiled and the build otherwise successful,
// and the first symptom would be an unresolved name in a consumer
// that imported the interface this program was to generate.
std::ofstream out(src, std::ios::binary | std::ios::trunc);
if (!out)
return std::unexpected(std::format(
"cannot write the build program the rules describe: {}",
src.string()));
out << text;
out.close();
if (!out)
return std::unexpected(std::format(
"failed while writing the build program: {}", src.string()));
}
}
fs::path bdir = build_dir(root, env);
fs::path outDir = bdir / "out";
auto childEnv = contract_env(root, outDir, env);
std::string ctxHash = contract_hash(childEnv);
// ── Helper self-containment (the single decision point) ─────────────────
// The compiled helper is exec'd by the host OS, outside anything mcpp
// controls — no wrapper, no injected LD_LIBRARY_PATH, no PATH guarantee.
// Making it runnable is a per-platform mechanism, and only two of the four
// need a static link:
// Linux + glibc payload — host_base_flags bakes absolute payload paths
// into -Wl,-rpath; that already closes it, so leave it dynamic.
// Linux + musl (#295) — rpath cannot reach PT_INTERP, an absolute
// /lib/ld-musl-<arch>.so.1 that no payload installs and glibc distros
// do not ship. Only a static link removes the interpreter entirely.
// Windows PE (#299) — PE has no rpath, so a dynamic helper resolves
// libstdc++-6 / libgcc_s / libwinpthread through the process PATH and
// dies with STATUS_DLL_NOT_FOUND unless the user put the toolchain bin
// there by hand. A static link is the only PATH-independent answer.
// macOS — the system libc++ is always present.
// Keep this the ONLY place that decides; the flag is appended to the final
// link argv further down, never to `base`.
const bool muslStaticHelper = mcpp::platform::supports_full_static
&& mcpp::toolchain::is_musl_target(tc);
const bool mingwStaticHelper = mcpp::toolchain::is_mingw_target(tc);
const bool staticHostHelper = muslStaticHelper || mingwStaticHelper;
// Fold the policy into the compiler identity: a helper produced under an
// older link policy must be rebuilt, not reused from the cache.
std::string compilerIdentity = hostCompiler.string();
// Host modules change what the helper links, so they belong in the identity
// the cache keys on — otherwise adding or removing a rule package would
// replay a cached run compiled without it.
for (auto const& hm : env.hostModules) {
compilerIdentity += "\nhost-module=";
compilerIdentity += hm.logical;
compilerIdentity += "@";
compilerIdentity += mcpp::toolchain::hash_file(hm.interface);
}
compilerIdentity += "\nbuild-program-link=";
compilerIdentity += muslStaticHelper ? "musl-static-v1"
: mingwStaticHelper ? "mingw-static-v1"
: "default-v2"; // v2: DT_RPATH on Linux
std::string programHash = mcpp::toolchain::hash_file(src);
std::string compilerHash = mcpp::toolchain::hash_string(compilerIdentity);
// Read once, here, because the check below has to run BEFORE the cache
// fast path returns.
std::string srcText;
{ std::ifstream is(src); std::ostringstream ss; ss << is.rdbuf(); srcText = ss.str(); }
// A module that is present only as another rule's prerequisite is not part
// of this package's declared surface. Refusing the import rather than
// letting it work is the difference between a rule that travels and one
// that happens to build on whoever's machine: the same source stops
// compiling the moment the intermediate rule stops depending on it, or the
// moment a Clang user tries it, since only GCC makes the BMI reachable
// without the flags.
//
// AHEAD OF THE FAST PATH ON PURPOSE. Withdrawing `reexport = true` from
// an edge leaves the module SET and every interface hash identical, so
// nothing in `compilerIdentity` moves. Measured: the replay is prevented
// today only because `ctxHash` happens to change too — an incidental
// coupling, and the shape this codebase keeps paying for. Asking the
// question before the cache is consulted needs no key at all.
for (auto const& hm : env.hostModules) {
if (hm.importable) continue;
if (!imports_module(srcText, hm.logical)) continue;
return std::unexpected(std::format(
"build.mcpp imports '{}', which reaches this build only as a "
"prerequisite of another build rule.\n"
" A rule's build-time provisions cross one further edge only "
"when that edge says so.\n"