Skip to content

mcpp 2026.9.27.1: #704-#716, feature tools, artifacts, payload revision, prepare.cppm decomposition - #719

Merged
speak-agent merged 33 commits into
mainfrom
release/2026.9.27.1
Sep 27, 2026
Merged

speak-agent merged 33 commits into
mainfrom
release/2026.9.27.1

Conversation

@speak-agent

@speak-agent speak-agent commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

Summary

mcpp 2026.9.27.1: the defect reports #704 to #716 and the feature requests #707 to #711, the mcpp side of openxlings/xlings#620 and #621, and the decomposition of src/build/prepare.cppm. Commits:

Commit Content
85c9037 #704, #705, #707, #708, #710, #712, #713, #714, #715, #716
aac072c #709 feature tools, #711 artifacts dependency edge
c136000 xlings install_targets record and recipe revision; LOCPATH/GCONV_PATH in bundle-all packs
bba733a tests, specs, docs, CHANGELOG, version 2026.9.27.1, xlings version vectors, faster ELF reads
6b6cfbd e2e 804 (#710 for a path host tool); e2e 802 pins gcc; CHANGELOG compatibility notes
af4c96f #708 on Windows: an action's cwd is a plain absolute path, not the \\?\ form
290bb46 test_workspace_inheritance compiles under clang and the MSVC STL
16c9b67 pin xlings 2026.9.27.1 (released)
24 commits after 16c9b67 prepare.cppm decomposition and its review, see below

Defects

Features

Payload revision and self-contained locale (openxlings/xlings#620, #621)

  • A dependency whose installed revision differs from its index entry's revision reads as not installed.
  • A runtime payload with revision above 0 enters the runtime contract (revision=<n>). With revision 0 the contract text is unchanged.
  • Bundle-all packs copy the glibc payload's lib/locale and lib/gconv and export LOCPATH and GCONV_PATH, keeping values the user already set.

Specs and docs

  • SPEC-001 v1.5, SPEC-004 v1.8, SPEC-005 v1.4, SPEC-007 v0.3.
  • docs 04, 05, 06, 07, 30 and 50, in English and Chinese.
  • CHANGELOG entry for 2026.9.27.1.

Tests

prepare.cppm decomposition

prepare_build was one function of about 13,740 lines, 85% of a 16,105-line file, with ~180 top-level locals and dozens of by-reference closures sharing one frame.

File Lines Holds
src/build/prepare.cppm 777 primary interface: exported types, exported inline functions, declarations
prepare/state.cppm ~620 implementation partition :state: PrepareState, phase and helper declarations
prepare/driver.cpp ~125 prepare_build: runs the phases in order
manifest.cpp, toolchain.cpp, xlings.cpp, graph_load.cpp, graph.cpp, toolchain_decision.cpp, features.cpp, target_side.cpp, scan.cpp, plan.cpp 570 to 2,400 the phases P0 to P13
config.cpp, options.cpp, toolchain_env.cpp, fetch.cpp 170 to 870 the helpers the phases share
  • Organisation. Only implementation units and an implementation partition imported by implementation units: any new interface unit in this module's import chain makes GCC 16.1 segfault (GCC 16.1 internal compiler error in add_imported_namespace when mcpp.build.prepare gains an interface unit in its import chain #721, measured). The interface imports no partition; :state imports the interface.
  • Behaviour. Code moved, statements not reordered. The normalised resolution.json, build.ninja and emit build-database outputs of seven fixtures (single package, workspace, git dependency, host tool, build program, musl cross target, emit) are byte-identical to the pre-decomposition binary.
  • Safety. Closures stored in PrepareState and called by a later phase were checked for captures of a finished phase's locals, statically and under AddressSanitizer with detect_stack_use_after_return=1 (golden fixtures and 12 e2e scripts): none.
  • Build time. An edit in P13 rebuilds plan.o and relinks, 12 s; the same edit in the monolith rebuilt prepare and 12 downstream interface units, 71 s. Each unit imports only what it uses (835 import lines removed).
  • Run time. emit build-database on the workspace fixture: 0.47 s per run for both binaries.
  • Tests. test_prepare_helpers covers the pure helpers the phases share, now exported for it. .github/tools/check_file_lengths.sh keeps every file at or below 2,500 lines.
  • Next step, not in this PR. Several phase functions are still 1,900 to 2,300 lines each; splitting them into sub-steps is prepare: split the longest phase functions into sub-steps #722.

Verification status

  • Local, gcc 16.1, this branch before the decomposition: mcpp build passes; mcpp test 129 passed, 0 failed (non-root); per-member tests pass; e2e 788, 798 to 804 pass.
  • Decomposition: GCC 16.1 and LLVM 22.1.8 builds, 130 unit tests, golden fixtures byte-identical, e2e 799 and 802 to 804.

Known red lane

macOS ARM64 — xlings LLVM end-to-end (xcode-27) and e2e suite (macOS ARM64, self-host, xcode-27) fail on main as well since 2026-09-23: ld64.lld cannot read the Xcode 27 SDK's TAPI files (#669, upstream LLVM).

Issues addressed: #704, #705, #707, #708, #709, #710, #711, #712, #713, #714, #715, #716. They are commented on and closed after the release is verified in an xlings subos sandbox.

claude and others added 5 commits September 26, 2026 23:46
#712 An `[xlings]` address resolves the payload xlings selected. A new
module, mcpp.xpkg_version, ports xlings' version grammar (N segments,
alpha segments, written-prefix equality for three or more segments,
prefix ranges for one or two, operator ranges), and xpkg_payload_at
selects among installed directories with it. `libglvnd@1.7` finds
1.7.0.1.

#716 A provisioning stamp counts only while every address still resolves
to a payload. A removed payload is re-provisioned online and refused
offline, naming it. The build cache records the payload directories a
build read, so the fast path declines when one is gone. xlings reports
what each request resolved to (install_targets, protocol 1.1); mcpp
records that per address and answers from the record first.

#705 A host tool's tree stamp skips directories that hold their own
mcpp.toml, and hashes paths as UTF-8.

#710 The requesting build decides a host tool's toolchain once --
`--toolchain`, else the tool package's own (its workspace's, for a
member), else the requesting build's host toolchain -- passes it to the
sub-build as an override, and records it in the store key. A member built
as a tool inherits the workspace root's root-position keys.

#713 A member inherits the workspace root's `[xlings.workspace]`
entries, conditional rows included; its own declaration of a package
wins.

#714 An unresolved `x.workspace = true` is refused at every position,
naming the entry. `[build] sources = []` infers no library target. A
rooted workspace resolves its own `workspace = true` entries.

#715 A rule reaches a package's synthesised build program only through a
device source whose extension it declared.

#704 A host build applies its own `[target.<host-triple>]` row
(toolchain, linkage, cxx_runtime), as `--target <host>` did.

#707 `emit build-database` builds no host tool; one missing from the
store is deferred with the note MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED
(SPEC-005 v1.4 R2.5).

#708 `mcpp::action::env(name, value)` and `cwd(dir)` (protocol 13),
applied by the engine's action wrapper in its named form `__action`. An
action with neither keeps the `__action-stamp` command line byte for
byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MajuX4J8ewFeWvZJjzRJt5
#709 `[features.<f>] tools = ["<bin>"]` names the package's own programs
a feature needs on the build machine. On every edge into the package,
the active features' tools are requested as if the edge had written
`tools = [...]`: built once for the host, reachable through
`mcpp::dep_bin`. A name that is not a `kind = "bin"` target of the
package is refused at load, naming the bin targets.

#711 `x = { ..., artifacts = ["<bin>"] }` asks for the dependency's
program built for the consumer's target and profile, as a link unit of
the consumer's own plan, written to `bin/` beside its programs. The
unit links the dependency's objects and those of every package it
reaches; a package reached only through artifact edges is kept out of
the consumer's images, since the edge takes the program and not the
code. A package asked for artifacts is scanned and configured here even
when all its targets are programs. `${mcpp.artifact:<package>/<target>}`
names the output in actions, `mcpp pack` stages it beside the program,
and `mcpp run` and the program-selection sites ignore it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MajuX4J8ewFeWvZJjzRJt5
The packaging revision xlings records for a payload
(`.xpkg-install.json`, openxlings/xlings#620) is read in one place,
`mcpp::xlings::paths::installed_revision`, and used twice.

The runtime contract (fingerprint field 11) includes the revision of
each runtime payload whose revision is 1 or more, so a C library
reinstalled under the same version with changed content selects a new
output directory. Revision 0 contributes nothing, so no existing key
changes.

An index dependency whose recorded revision differs from its
descriptor's entry is not installed (#524 A): mcpp's descriptor reader
now reads `revision` the way libxpkg does, and a stale payload goes back
through xlings, which reinstalls it. A payload with no xlings record is
judged by the marker, as before.

`mcpp pack --mode self-contained` copies the bundled C library's
`lib/locale` and `lib/gconv` from its payload when present and exports
LOCPATH and GCONV_PATH in the launcher, keeping a value the user set
(openxlings/xlings#621).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MajuX4J8ewFeWvZJjzRJt5
…ors; faster ELF reads

Tests. e2e 802 (#704: a host build applies its host row, under either
spelling) and 803 (#716: a recorded payload that is gone is refused
offline, naming the address and the record). Unit tests for #713 (a member
inherits the root's [xlings.workspace] entries and rows; its own wins), #714
(an unresolved `workspace = true` is named in every dependency table) and
#710 (a member tool reads its workspace's toolchain; its own wins), for
which prepare exports host_tool_declared_toolchain.

Version selection. mcpp.xpkg_version gains select_installed, the one rule
`mcpp::xpkg_dir` answers with (a literal name, then the xlings grammar, then
the highest), and runs the conformance vectors xlings publishes, vendored as
modules/versioning/tests/data/semver-vectors.tsv.

ELF inspection reads a file with one sized read; filling the vector through
an istreambuf_iterator made the post-link check of `mcpp test` take tens of
minutes.

Specs: SPEC-001 v1.5 (version answers follow xlings; packaging revision),
SPEC-004 v1.8 (host row, workspace clauses 8-10, a dependency's programs),
SPEC-007 v0.3 (action env/cwd, planning defers host tools, feature tools,
artifacts). Docs 04, 05, 06, 07 and 30 in English and Chinese. CHANGELOG
and version 2026.9.27.1.

Not built or run in this session; the handover lists the verification that
remains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MajuX4J8ewFeWvZJjzRJt5
… pins gcc; compatibility notes

e2e 804 builds a host tool reached through a plain path dependency, which
belongs to no workspace: without a [toolchain] of its own the tool is built
by the consumer's build-program compiler (llvm 22.1.8 in the test, while the
global default on Linux is gcc), and with one it is built by that compiler.
The released 2026.9.26.2 fails the first criterion; this branch passes both.

e2e 802 read libstdc++'s NEEDED entry under whatever toolchain the machine
defaults to, so it failed where the user's default is llvm; the manifest now
names gcc@16.1.0.

CHANGELOG: the changes an existing project can observe -- an unresolved
`workspace = true` is refused (#714), `sources = []` no longer infers a
library, and the build-database note code was renamed without an alias.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The wrapper entered the action's directory in `extended_length`'s `\\?\`
spelling. Windows accepts that as the current directory, but a child started
there does not recognise it: on the Windows e2e job the MSYS shell of e2e 799
ran in C:\Windows while the variable reached it. The wrapper now enters the
absolute, lexically normal path; POSIX is unaffected, since extended_length
returns its argument unchanged there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
speak-agent and others added 21 commits September 27, 2026 13:24
With clang 20.1.7 and the MSVC STL, `std::optional<std::string>("gcc@...")`
failed to instantiate in this translation unit, which includes gtest's
headers and imports std; the Windows unit-test job reported the only failure
of its 129 tests as that compile error. The test now checks has_value() and
compares the string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kXlingsVersion and every .github pin check_version_pins.sh reads move to
xlings 2026.9.27.1 (openxlings/xlings#622), released on GitHub and GitCode
and published in the xim index. It is the release that emits install_targets
and records a payload's revision, which this mcpp reads first. The vendored
semver-vectors.tsv already matches that release's file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
First step of the prepare.cppm decomposition (behaviour-preserving):
PrepareState holds print_fingerprint, includeDevDeps, extraTargets,
overrides and the derived toolPurpose. prepare_build now constructs one
and reads these five through it instead of as bare locals/parameters.
Everything else is still one function, unchanged. Golden fixture diff
against the pre-refactor baseline is empty; mcpp test: 129/129.
P0 (manifest and workspace resolution, the first ~490 lines of
prepare_build) produces root, m, effective, wsManifest,
runtimeWorkspaceRoot, runtimeSelection, workRoot, planNotes,
gitLockAnchors, packageIdentityLockAnchors, cacheMode and a dozen
diagnosis-carrying strings/bools that later phases read. All of these
move to PrepareState; every read/write throughout the rest of
prepare_build now goes through state.<name>. Three classes of false
positive from the mechanical rename were found and reverted: unrelated
struct members/namespaced functions sharing the same short name
(GitClone::root, mcpp::home::root()), a genuinely shadowing local
(iface.lexically_normal() bound to a block-scoped root), and the word
appearing inside string literals and prose comments. Still one
function; still behaviour-preserving. Golden fixture diff against the
baseline is empty; mcpp test: 129/129.
…nction

Cuts P1 -- toolchain spec resolution (docs/21), the get_cfg/runtime-
binding/fixup-reporting helpers, the L1 cfg() context, and the target
axis/platform derivation -- out of prepare_build into
phase1_toolchain_spec_and_axes(PrepareState&), called in sequence from
prepare_build. static: PrepareState is TU-local, so a non-static
namespace-scope function taking it by reference would otherwise expose
a TU-local type through a reachable declaration (the same class of GCC
16.1 constraint as the anonymous-namespace fix for PrepareState
itself).

Several of P1's locals are closures called by name from much later
phases (get_cfg from as far as P13's line ~15300): get_cfg,
provide_runtime_payload, report_fixup, msvc_usable_either_origin,
native_first_run_spec, tcSpecSource, resolvedAccel, cfgCtx and
add_once. Each captured its enclosing locals by reference before this
change, which is safe only because prepare_build was one stack frame
for its whole call; extracted verbatim they would dangle the moment a
later phase called them after P1's frame returned. Fixed by promoting
every local such a closure touches into PrepareState (including two,
bootstrap_checked and runtimePayloadProvided, that no later code
referenced by name and so were not caught by the compiler -- only by
reading each closure's body) so the default by-reference capture now
closes over the state reference itself, which outlives every phase.
report_fixup's capture of fixupNoticed is by value (a shared_ptr) and
stays as-is: copying it is already safe regardless of lifetime.
get_cfg's default argument does not survive type-erasure into
std::function; its 31 call sites that relied on the default now pass
it explicitly. targetPlatform has no default constructor (PlatformKey's
is protected) and is held as optional<TargetPlatform>, dereferenced at
each of its 7 use sites. kCurrentPlatform is a pure constant with no
state dependency; hoisted to file scope instead of PrepareState.

Golden fixture diff against the baseline is empty; mcpp test: 129/129.
…unction

P2 is two statements: declare resolve_target_toolchain and assign its
~1000-line closure body (defined here, called from P5 -- and once,
recursively, from inside itself on the Windows first-run pass). Cut
into phase2_define_toolchain_resolver(PrepareState&). The closure
itself becomes a PrepareState member for the same reason as P1's
helpers: it is called by name from P5, long after P2's own stack frame
is gone. Its internal references were already state.<name> from the
P1 promotion pass (that rename covered this whole range); the only new
member is the closure itself.

No new compiler errors and no manual capture audit needed beyond that:
every local this closure declares is declared INSIDE its own body (a
fresh allocation on each call, never captured from an enclosing
scope), so nothing else here has the P1-style escaping-capture hazard.

Golden fixture diff against the baseline is empty; mcpp test: 129/129.
… function

P3 resolves the host toolchain build.mcpp itself compiles/runs with
(host_spec_for_build_program, host_tc_for_build_program -- both called
from as far as P9/P11 -- and their hostTcCache), the target sysroot,
and the project's .mcpp/ directory (custom indices, [xlings] deps for
the root). Cut into phase3_xlings_before_graph(PrepareState&).

Same closure-capture treatment as P1/P2: host_tc_for_build_program's
only new capture beyond already-promoted state is hostTcCache, which
no later CODE names directly (only the closure's own body touches it)
and so needed a manual read of its body rather than a compiler error
to find. runtimeOwnerManifest was `const auto&` aliasing wsManifest or
m; PrepareState members cannot be references, so it is now a raw
pointer (both referents already outlive every phase), dereferenced at
its call sites. targetSysroot and materializeRootRuntime are read only
within the same block that computes them and stay ordinary locals.

Golden fixture diff against the baseline is empty; mcpp test: 129/129.
The very first rename (print_fingerprint/includeDevDeps/extraTargets/
overrides/toolPurpose -> state.<name>) was a plain sed word-boundary
substitution with no protection for string literals or comments,
unlike every rename since. A second, targeted pass found and fixed
what it corrupted: "overrides" used as a plain English verb inside
seven user-facing error/diagnostic strings and comments (e.g. "a
per-target C++ runtime contract state.overrides the project default"),
and "root" meaning "the root package/manifest" -- a different word
from the state.root PATH -- in ten more diagnostic strings ("declare
'{}' in the state.root to settle it"). None of these are exercised by
the golden fixture corpus, so this was found by scanning every string
literal in the file for a state.<name> token and reading each hit, not
by the diff. Rebuilt clean; golden fixture diff against the baseline
is empty; mcpp test: 129/129.
The largest phase (~3400 lines): packages[], the worklist walk over
[dependencies]/[dev-dependencies]/[build-dependencies] with its cycle
detection, xlings address unification, feature-dep forwarding,
provisioning (fillXpkgDirs/fillDepDirs), and directive-tail folding.
Cut into phase4_dependency_graph(PrepareState&).

Escaping-closure audit (the design's flagged risk for this phase): of
roughly 50 lambdas P4 declares, 8 are called again from P6/P7/P13
(graph_xlings_split, computeUsageRequirements, adoptActionOutputs,
fillXpkgDirs, fillDepDirs, markDirectiveTail, bareBindingsFor,
compilesHere) and needed promoting. Six more (isProgramOnlyPackage,
appendUniqueFlags, appendUniquePaths, appendUniquePath,
isArtifactPackage, publishedNamesFor) never escape by name themselves
but are captured by reference from inside one of the eight -- exactly
the hazard the compiler cannot see, found by reading each escaping
closure's body rather than by a build error. The other ~35 lambdas
(loadVersionDep and the rest of the per-dependency resolution machinery)
are declared and fully consumed inside this one phase and stay local.

Five local struct types backing promoted members (DepCacheIdentity,
GitLockIdentity, DependencyEdge, GraphRequest, DependencyLinkForm,
ResolvedKey, ResolvedRecord) moved to file scope, before PrepareState,
since a struct defined inside a phase function cannot be named by a
member declared earlier in the file. Two namespace aliases (prov, dg)
used again in P7/P9 moved the same way, for the same reason.

Three real local shadows of promoted names, found only by re-checking
every occurrence rather than a sample (`resolved` in particular has 114
matches, nearly all the English past participle in comments/messages):
`root`-style path-DFS locals literally named `state` in the cycle-
detection block (renamed to visitState -- shadowing the PrepareState
parameter is legal C++ but this block also reads state.packages/
state.dependencyEdges, which resolved to the wrong `state` and produced
"vector<int> has no member packages"), and two/three independent
`resolved` locals from resolve_semver/resolve_package_name whose tiny
scopes were reverted to plain `resolved`. get_cfg-style default-argument
loss recurred for fillDepDirs (one call site relied on its third
parameter's default); fixed by passing nullptr explicitly. A generic
lambda (foldDirectiveTailIntoPrivateBuild, `auto& pkg`) cannot be a
std::function member; concretized to PackageRoot&, its only call-site
type.

Golden fixture diff against the baseline is empty; mcpp test: 129/129.
…ase function

P5 decides whether the graph supplies a target row's system, folds a
dependency's compiler requirement into tcSpec, and refuses the
convention/capability conflicts (#704's target matrix). No new
cross-phase state: it only reads members P0-P4 already promoted and
calls state.resolve_target_toolchain(). Zero compile errors on first
extraction.

Golden fixture diff against the baseline is empty; mcpp test: 129/129.
…rst rename

The recursive prepare_build(...) call for a host tool sub-build named
its own arguments in /*param=*/value comments; the original word-
boundary rename touched these too (block comments were never
excluded, only // comments and later hardened against). A file-wide
scan for /*...state.<name>...*/ found exactly these three and nothing
else.

Golden fixture diff against the baseline is empty.
tools) as one phase function

Cuts feature activation (Cargo-style, additive), capability/ABI
requirement accumulation and binding, the target side resolved once
against the graph, and host-tool provisioning (including the
recursive prepare_build(...) sub-build for a dependency's tool) into
phase6_features_and_host_tools(PrepareState&). Combined into one
phase function rather than three, matching the design's own file
grouping for this range (features.cpp covers P6-P8); the exact phase
count is not load-bearing, only that each cut is behaviour-preserving
and every resulting file stays under the line cap.

The recursive call needed a genuine forward declaration of
prepare_build itself: every phase function is textually inserted
before prepare_build's own definition, so a call to it (not through a
PrepareState closure, unlike resolve_target_toolchain) has nothing to
resolve against without one. First attempt put the declaration inside
PrepareState's anonymous (TU-local) namespace, which cannot hold an
`export` declaration; moved it to right after that namespace closes.

Twelve more members promoted, all newly exposed because P6-P8 crossing
into its own function separates them from P9-P13, which still read
them inline: activeRootFeatures, capProviders/capRequires/capExclusive,
abiRequires/abiRequiresExceptions, deviceSourcesByPackage,
checkVersionFloors (a closure, but a non-escaping one -- promoted only
because P13 calls it by name), resolvedTargetSide, targetSideResolved,
targetSideUsage, cxxLayerProviderIndex. None had a further escaping-
closure capture beyond what P0-P5 already promoted.

Golden fixture diff against the baseline is empty; mcpp test: 129/129.
…print) as one phase function

Cuts target-side resolution (the layer providers publish once, applied
to every package), #519 per-dependency linkage form, the root and
dependency build.mcpp runs that happen after resolution, the modgraph
scan and validate() pass, and the C++ standard module description into
phase9_target_side_and_scan(PrepareState&). One combined function for
the same reason as P6-P8: the design groups this range into two files
(target_side.cpp, scan.cpp) but the phase-function count itself is not
load-bearing.

Ten more members promoted (fp, stdBmiPath/stdObjectPath/
stdCompatBmiPath/stdCompatObjectPath, describedStdModule,
stdFlagAndDialect, graph_package_entry, scan, report): P13, still
inline in prepare_build's tail, reads the fingerprint and the
compiled/scanned module graph this phase produces. graph_package_entry
is a closure (used for resolution.json's package entries) called again
from deep inside P13.

Found and fixed, by a systematic diff against the last commit rather
than by the compiler alone (these five did not surface as build
errors on the first pass because the corrupted call sites still type-
checked or only failed transitively): the declaration-stripping script
used to promote a member from `Type name = init;` to `state.name =
init;` matched some continuation lines of unrelated multi-argument
calls that happen to read `<identifier>, state.<name>` -- indistin-
guishable from a real `Type name` prefix by the same regex -- and
dropped the leading identifier. Five call sites lost an argument this
way: mcpp::platform::macos::deployment_target's targetIsMacos boolean
(four sites: cAbiTargetIsMacos once, stdTargetIsMacos three times) and
predefines::define_tokens's targetOs. All five were caught either by a
genuine compile error (wrong arity/type) or by re-reading the format
string argument counts; restored from the pre-rename text. A full
diff-based rescan against both the last commit and this step's own
starting point found no further instances of the pattern.

One cosmetic note: this phase function landed textually before phase1
rather than after phase6, because the extraction script's marker
search matched the (identical) line in prepare_build's own forward
declaration rather than the one immediately before its real
definition. Harmless -- nothing calls a phase function before
prepare_build itself does, in order -- but out of the intended reading
order; left for the file-separation step, where each phase becomes its
own translation unit and source order stops being a question.

Golden fixture diff against the baseline is empty; mcpp test: 129/129.
P13 -- filling BuildContext (plan, assembly, Windows resources, the
global cache, mcpp.lock, resolution.json) -- returns BuildContext
directly, so phase13_finish(PrepareState&) returns
std::expected<BuildContext, std::string> rather than the void every
other phase returns; its call site is prepare_build's own final
`return phase13_finish(state);`. No new state members: everything it
needs was already promoted for P9-P12's sake.

P0 (manifest and workspace resolution) was promoted to PrepareState in
an early commit but never actually cut into its own phase function --
an omission from that commit, caught here by checking prepare_build's
remaining size after every other phase was cut. Extracted into
phase0_manifest_and_workspace(PrepareState&), called first. Zero new
compile errors: every local P0 declares was already state.<name> from
that earlier pass.

prepare_build is now its intended shape: construct PrepareState, clear
the global flag-words buffer, call nine phase functions in order,
return the last one's result. 21 lines.

Golden fixture diff against the baseline is empty; mcpp test: 129/129.
…/graph split

Preparation for splitting phase4_dependency_graph across two files
(graph_load.cpp, graph.cpp), matching the design's own grouping:
loadVersionDep -- the git/path/version dependency loader, ~500 lines,
recursive -- is defined once and called from the worklist engine that
follows it, so it and everything it closes over (the same escaping-
closure pattern as every other promoted member) must survive past its
own definition point once that split happens. Promoted: worklist,
identityBySource, declaringManifest, adoptionsReported,
gitCloneBySource, selectorMigrationWarnings, preinstallStack/Done,
and the closures cache_index_name, gitMemberDeclaring, qualifiedKey,
stateAdoptedIdentity, reportAdoption, index_route,findIndexForNs,
resolveSemver, readStrictLuaForCandidate, xpkgLuaMatchesCandidate,
dependencyCoordinates, selectDependencyCandidate, loadVersionDep
itself. Three more local struct types (WorkItem, DeclaringManifest,
GitClone) moved to file scope for the same reason as P4's first five.
index_route's default argument (cfg = nullptr) does not survive into
std::function; its three no-arg call sites now pass nullptr.

Still one phase4_dependency_graph function -- the actual file split
follows. Golden fixture diff against the baseline is empty; mcpp
test: 129/129.
…rklist

Required by the file-size cap: phase4_dependency_graph was ~3300
lines, over the 2500 hard cap for whatever file its definition lands
in. Split at loadVersionDep's own boundary (matches the design's
graph_load.cpp / graph.cpp grouping): phase4a holds the git/path/
version dependency loader; phase4b holds the worklist engine and
cycle detection that call it (already state.loadVersionDep from the
previous commit, so the split itself needed only kMainConsumer -- a
pure constant used by both halves -- hoisted to file scope.

Golden fixture diff against the baseline is empty; mcpp test: 129/129.
Required by the file-size cap (phase9_target_side_and_scan was ~2600
lines): split right before the modgraph scan begins, matching the
design's target_side.cpp / scan.cpp grouping. No new cross-boundary
state needed -- everything on both sides already reads and writes
through state.<name>, so this split (unlike P4's) needed no further
promotion.

Golden fixture diff against the baseline is empty; mcpp test: 129/129.
The preamble's internal helpers (warn_unknown_xpkg_keys through
with_index_cause, and the ToolPurpose enum among them) sat in an
anonymous namespace, giving them TU-local (internal) linkage. That was
fine while every phase using them lived in the same file; once the
phases move to their own implementation units, an internal-linkage
name is invisible outside the file that declares it (anonymous-
namespace scoping is per translation unit, modules or not). Dropped
the wrapper: these are now plain module-linkage declarations in
prepare.cppm, which -- like pending_flag_words_notes(), already
declared that way -- every implementation unit of the module can see
without needing to be exported. No behaviour change: nothing outside
the module could name these either way.

Golden fixture diff against the baseline is empty; mcpp test: 129/129.
…ation files

The physical split (design step 4-5 combined, given the time this
decomposition has already taken): src/build/prepare.cppm keeps only
the module preamble, the exported helper functions (with bodies --
splitting each into a bare declaration plus a definition in a separate
exports.cpp was not done; at 2414 lines the file is under the 2500
hard cap without it, so this was deferred rather than risk a
mechanical error for no required outcome), and prepare_build's own
exported declaration with its default arguments. Everything else moved
under src/build/prepare/:

  state.cppm             implementation partition :state -- PrepareState,
                          the five structs P4 needed hoisted, and every
                          phase function's declaration.
  driver.cpp              prepare_build's definition.
  manifest.cpp            P0.
  toolchain.cpp           P1 + P2.
  toolchain_decision.cpp  P5 (split out of toolchain.cpp: P1+P2+P5
                          together were 2526 lines, over the hard cap).
  xlings.cpp              P3.
  graph_load.cpp          P4a (loadVersionDep).
  graph.cpp               P4b (the worklist engine).
  features.cpp            P6-P8.
  target_side.cpp         P9.
  scan.cpp                P11.
  plan.cpp                P13.

Two visibility problems, both from the same cause -- an implementation
partition does not implicitly see its module's primary interface the
way an implementation unit does, so it must import it explicitly:
  - The preamble helpers moved out of their anonymous namespace in the
    previous commit for exactly this reason, but ToolPurpose (a type,
    not a module-linkage function) still needed an explicit `export`:
    :state names it in PrepareState's own member list, and a partition
    only sees what its imports export.
  - state.cppm added `import mcpp.build.prepare;` for BuildOverrides,
    BuildContext, PlanNote, CacheMode and TcOrigin, all exported types
    PrepareState's declaration itself names.
Every other file compiled against the split on the first attempt: the
extensive state.<name> threading done in every prior commit was
already correct, this just gave it its own files.

All twelve source files are at or under the 2500-line hard cap; nine
are at or under the 2000-line target. prepare.cppm (2414) and three
implementation files (target_side.cpp 1968, features.cpp 2052,
toolchain.cpp 2136, graph.cpp 2345, plan.cpp 2392) sit between the two
because splitting them further was not required to satisfy the cap
and each remaining cut point would have needed its own escaping-
closure audit.

GCC 16.1 builds this cleanly: the primary interface imports no
partition, :state is an implementation partition imported only by
implementation units, satisfying the constraint verified in
.agents/docs/2026-09-27-mcpp-2026.9.27.1-ecosystem-plan.md §4.3.

Golden fixture diff against the baseline is empty; mcpp test:
129/129 (root) plus every member with tests (versioning, platform,
manifest, toolchain-model, buildmcpp) individually green.
check_file_lengths.sh fails the build if src/build/prepare.cppm or any
file directly under src/build/prepare/ exceeds 2,500 lines, wired into
ci-linux.yml next to the other pure-text, no-toolchain check_*.sh
steps (check_version_pins.sh, check_modules_wiring.sh). Currently
passes: 13 files, all at or under the cap (the largest, plan.cpp, is
2,392).

CHANGELOG.md: one entry under 2026.9.27.1 describing the internal
decomposition -- no behavior change, so it sits in the existing
'其他' (other) section alongside the ELF-read-once note, not as its
own dated change.
The comment originally attached to the PrepareState struct definition
stayed behind in prepare.cppm when the struct itself moved to
state.cppm (it was written above the struct's enclosing namespace, not
the struct itself), leaving state.cppm's copy undocumented. Restored,
updated for the current file layout. Also fixed a comment that had
gone stale during the P9/P11 split: fp/stdBmiPath/scan/report and
their siblings are computed inside P9 (target_side.cpp) and P11
(scan.cpp) themselves, not inside P2's resolve_target_toolchain closure
as an earlier commit's comment claimed.

No code change; golden fixture diff against the baseline is empty.
speak-agent and others added 5 commits September 27, 2026 16:29
The primary interface still carried the former preamble's definitions,
about 1,700 lines of helpers. They move into four implementation units by
concern -- config.cpp (conditional merges, build flags and defines,
workspace inheritance, feature requests, std-module detection), options.cpp
(the MSVC guidance, the cache mode, the profile), toolchain_env.cpp (target
rows, sysroots, the MSVC binding, build-program environments, the toolchain
a host tool chose) and fetch.cpp (git remotes, network retries, xlings
addresses and their provisioning). Exported functions keep their documented
declaration, default arguments included, in prepare.cppm; the other helpers
are declared in `:state`, grouped by the file that defines them, and keep
their design notes beside their definitions.

prepare.cppm goes from 2,414 to 777 lines: exported types, exported inline
functions and declarations. An edit to a helper now recompiles its own unit
and relinks, instead of changing the interface every importer depends on.

Moved verbatim; the normalised resolution.json, build.ninja and
emit build-database outputs of the seven golden fixtures are byte-identical
to the pre-decomposition binary, and the 129 unit tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
feature_request_tokens, parse_feature_request and
feature_forward_request_tokens (the --features grammar, #649 E8),
define_name, previous_release_words (the previous release's reading of a
flag element) and is_local_git_remote (including a Windows drive letter's
colon) were reachable only through whole builds while they were internal to
one unit. They are exported from prepare.cppm for this file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t note

- Every phase file opens with what it holds (P0 to P13) and the helper files
  with their concern, as the rest of the tree does.
- `:state` declares the phase functions in the order the driver runs them.
- prepare.cppm's layout note lists all sixteen files as they are, states
  that the interface defines nothing beyond its exported inline functions,
  and cites #721 for the GCC 16.1 constraint.
- check_file_lengths.sh no longer points at a document outside this
  repository or counts the units.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The decomposition gave every implementation unit a copy of the former
file's 87 imports. Each unit, the primary interface and `:state` now import
only the modules whose namespaces they name, plus every module that exports
into mcpp::build itself (usable unqualified from here) or only re-exports
others; 835 import lines go. A unit's import list is again a statement of
what it depends on.

Verified: GCC 16.1 and LLVM 22.1.8 builds, 130 unit tests, and the golden
fixtures byte-identical to the pre-decomposition binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The entry states the result rather than the plan: a primary interface of
exported types, inline functions and declarations; implementation units and
the `:state` partition, each importing what it uses and capped at 2,500
lines by CI; byte-identical planning output over the seven golden fixtures;
an edit in P13 rebuilding in 12 s instead of 71 s; and #721 for the GCC 16.1
constraint. test_prepare_helpers joins the list of tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hat produced it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@speak-agent
speak-agent marked this pull request as ready for review September 27, 2026 09:17
@speak-agent
speak-agent merged commit b439fd9 into main Sep 27, 2026
40 of 42 checks passed
This was referenced Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants