Scanner backend abstraction: per-package opt-in, P1689 as lingua franca, plan-vs-ddi reconciliation (Design)
Sister doc to 2026-07-08-index-version-semantics-and-descriptor-grammar-design.md
(D1–D4 there; this doc is the scanner track). Goal: packages whose module units
legitimately need preprocessing to scan (upstream src/fmt.cc with
#ifdef FMT_IMPORT_STD import std;) can declare it, instead of the index
maintaining generated_files rewrites — while the default build keeps the
zero-cost deterministic text scan, and scan/build consistency becomes
machine-verified rather than rule-trusted.
- Two scanners exist and already share one result shape:
scan_packages(M1 regex/text) andscan_packages_p1689(GCC-fdeps-format=p1689r5/ clang-scan-deps) — "Same ScanResult shape",src/modgraph/scanner.cppm:81-84. - Selection is global only:
MCPP_SCANNER=p1689env var, one lambda insrc/build/prepare.cppm:2544-2554. No manifest/descriptor surface. - Verified empirically (0.0.81): the same
#ifdef-guardedimport std;that M1 rejects builds fine underMCPP_SCANNER=p1689with-Dset, and correctly drops the import edge without-D— the P1689 path evaluates macros truthfully. dyndep.cppmis translation-only (.ddi → ninja dyndep file); the planned graph is never compared against what the compiler actually saw.- Hazard: the descriptor mcpp-segment parser silently skips unknown
top-level keys (
manifest.cppmfinalelseof the key loop — this is whyschema = "0.1""works"). Ascanner = "p1689"key on an old client is ignored, then the text scanner rejects the source with a confusing error. Schema evolution here is invisible, not loud.
C++20 was deliberately shaped for text scanning (P1857R3): import/module
directives must be literal, line-initial, and cannot be produced by macro
expansion. The preprocessor's only levers over the import set are
conditional blocks (M1 bans them textually) and #include-carried text
(caught by §5). So "scan OK + any -D combination" yields the same phase-4
import set the text scan saw — M1 consumes a language property, it does not
gamble. The rule, restated: the module graph may not depend on information
the planner cannot evaluate. Conditions must live in a layer the planner
understands:
| condition kind | belongs in | resolution |
|---|---|---|
platform/config (#ifdef _WIN32 import w;) |
manifest data | lift to cfg() / features — planner evaluates natively |
toolchain capability (fmt's FMT_IMPORT_STD) |
preprocessor | declared per-package preprocess scan (this doc) |
| dead branches / compat shims | text | rewrite (generated_files) or upstream convergence |
For index packages the sources are sha256-pinned — their import set is a constant. Recomputing a constant with compiler processes at every user's build is the wrong cost placement; the scan belongs to packaging time, declared as data:
mcpp = {
sources = { "*/src/fmt.cc" }, -- upstream file, verbatim
cxxflags = { "-DFMT_IMPORT_STD" }, -- author configures the macros
scan_overrides = { -- author asserts the scan result
["*/src/fmt.cc"] = { provides = { "fmt" }, imports = { "std" } },
},
}Files matching an override are not text-scanned; the declared unit enters the graph directly. No preprocessing, no toolchain capability requirement, no flag-keyed cache, identical behavior on every platform. Assertion + verification replaces computation — sound because every lying-declaration failure mode is loud at build (missing BMI / wrong BMI path / unresolvable extra edge; no silent path), and three auditors stand behind it:
- index CI builds every package — a wrong declaration cannot merge;
- plan-vs-ddi reconciliation (§3d) is mandatory for override files: the compiler's own P1689 output verifies the assertion every build, catching even upstream-version drift precisely;
mcpp xpkg parse(sister doc D2) validates the declaration's shape.
scan_overrides is the recommended fix for the fmt case and its class
(pinned third-party module units with guarded imports). It also works in a
project's own mcpp.toml for stable files. The p1689 backend below remains
the right tool when the source is mutable (a project's own conditional
imports under active development) or when maintaining declarations stops
being worth it — it is the evolution path, not the first move.
Necessity ordering that follows: index floor (sister doc D3, prerequisite for
adding ANY new descriptor key — old clients silently skip unknown keys) →
single-source lint (D2) → scan_overrides + reconciliation (this doc) →
long brackets (D1, ergonomics, deferrable) → per-package p1689 backend
(deferred until demanded; the seam below documents where it plugs in).
ScannerBackend
name : "text" | "p1689" | (future "exec:<tool>")
preconds : text → M1 rules (unconditional imports, no header units)
p1689 → toolchain has a P1689 producer (same capability check
dyndep uses, ninja_backend.cppm:258-263)
scan(batch of PackageRoot, ScanContext) -> ScanResult // shared shape today
- "text" — the built-in zero-cost producer with strict preconditions; the default forever.
- "p1689" — one backend, multiple drivers picked per toolchain
(GCC built-in
-fdeps-*, clang-scan-deps, MSVC/scanDependencieslater); all emit P1689 JSON. - future "exec:" — external producer protocol: given file list +
flags, emit P1689 JSON. P1689 as lingua franca means third-party /
self-developed scanners plug in without touching the internal model;
ScanResult/modgraph stay the only internal truth.
MCPP_SCANNERenv (whole-build debug override, unchanged semantics)- package descriptor
mcpp = { scanner = "p1689" }/ packagemcpp.toml[build] scanner - root project
[build] scanner - global
~/.mcpp/config.toml - default
"text"
Granularity is per-package (the owner of conditional imports is the package; it declares its own need). Per-source/per-target is deliberate non-scope — the partition below just gets finer if ever needed.
The prepare.cppm scan lambda becomes: resolve backend per package →
partition → run each backend's batch → concatenate ScanResults → one
modgraph::validate over the merged graph. Graphs are logical-name-keyed;
merge is trivial; a text-scanned consumer importing a module provided by a
p1689-scanned package needs nothing special.
Cache keys differ per backend and must not cross-pollute:
- text units → content hash only (graph independent of flags — the whole point of the default);
- p1689 units → content hash + canonical flag fingerprint (reuse the plan's
canonical_package_build_metadataencoding — single source, scan-time flags provably equal compile-time flags) + toolchain fingerprint (toolchain/fingerprint.cppm).
Precondition failure is a first-class error: "package X declares
scanner = "p1689"; toolchain has no P1689 producer (clang needs
clang-scan-deps)" — not a silent fallback to text.
Dyndep already makes the compiler emit per-TU P1689 .ddi files at build
time. Upgrade dyndep.cppm from pure translation to assertion: per TU,
diff planned (provides, requires) against the .ddi's. Equal → the build is
certified consistent; different → hard error naming the file and the delta
("scanner assumed imports {std}; compiler saw {std, fmt} — conditional or
include-carried import?"). Zero extra compiler invocations; catches every
divergence class (include-carried imports, compiler extensions, future
language drift) without enumerating them. This closes the loop for BOTH
backends: text's construction-based guarantee and p1689's flags-keyed scan
both get observation-based verification.
- The silent-unknown-key skip makes
scanner =invisible to old clients → index packages may only use it once the index floor (index.toml min_mcpp) is ≥ the version shipping this feature. Same "floor first" rule as the long-bracket grammar. mcpp xpkg parse(D2) learns the field, and gains--strict: warn on unknown mcpp-segment keys, turning the silent-skip hazard into a loud lint signal for all future schema evolution.- Trust/cost: a descriptor opting into p1689 causes compiler-driven scans of its own sources on the consumer's machine — no new trust boundary (building compiles those sources anyway); cost scales with the opt-in set only.
pkgs/f/fmtlib.fmt.lua drops its 3.4 KB generated_files copy entirely:
mcpp = {
language = "c++23",
import_std = true,
modules = { "fmt" },
scanner = "p1689",
include_dirs = { "*/include", "*/src" },
sources = { "*/src/fmt.cc" }, -- upstream file, verbatim
cxxflags = { "-DFMT_IMPORT_STD" },
targets = { ["fmt"] = { kind = "lib" } },
}(D1's long brackets make generated_files readable where still needed;
this makes it unnecessary where the only edit was un-guarding an import.)
- Global default flip to p1689 — costs a compiler-ish pass per TU for everyone and makes every graph flags-dependent; the text scan's determinism and cache-friendliness stay the default.
- Partial macro evaluation in the text scanner — a guessing evaluator fails silently; worse than refusing loudly (see sister doc's D1 rationale and the M1 verification experiments).
- Per-source scanner overrides — speculative; partition granularity can be refined later without model change.
manifest.cppm: parsescan_overrides(mcpp segment + mcpp.toml); validate shape.scanner.cppm/prepare.cppm: override-matched files bypass the text scan, declared units enter the graph.dyndep.cppm: plan-vs-ddi reconciliation — mandatory for override files, flag-gated elsewhere at first (MCPP_VERIFY_MODGRAPH=1), default-on once stable.- D2
xpkg parse --strictunknown-key warning + override shape check. - Index side (after floor covers the release): migrate
fmtlib.fmtoffgenerated_filesontoscan_overrides; e2e over a workspace mixing scanned and override packages. - Deferred (on demand):
scanner = "p1689"per-package backend via the partition + merge seam (§3a–3c).