diff --git a/.agents/docs/2026-06-18-per-target-build-config-design.md b/.agents/docs/2026-06-18-per-target-build-config-design.md new file mode 100644 index 0000000..70e004b --- /dev/null +++ b/.agents/docs/2026-06-18-per-target-build-config-design.md @@ -0,0 +1,170 @@ +# Per-Target 构建配置设计:配置发散归编译单元,目标只携本地标志 + +> 2026-06-18 · 状态: 已实施(0.0.55,①②③④⑤;⑥ 明确不做) · 代码锚点基于 main@47d026f (v0.0.54) +> +> **实施结果**:`[targets.]` 新增 `defines`/`cxxflags`/`cflags`(入口作用域,④)与 +> `required_features`(门禁,⑤);不支持键发 warning/`--strict` error(②);`mcpp test` 接 +> `--profile`/`--features`/`--strict`(①);docs/05(及 zh)新增决策指引(③)。 +> 单测 `tests/unit/test_manifest.cpp` 覆盖 ②④;e2e `57/58/59` 覆盖 ④⑤①。 +> 验证:`mcpp build` 自举通过;`mcpp test` 18/18;新 e2e 三项 + 多目标/workspace/静态/共享/ +> 包级 flags 回归集全过。 +> 关联: [GitHub Issue #131](https://github.com/mcpp-community/mcpp/issues/131)(`[targets.]` 求 cxxflags 覆盖) +> `.agents/docs/2026-06-04-manifest-schema-ownership.md`(语法封闭·词汇开放) +> `.agents/docs/2026-05-30-package-owned-build-flags-plan.md`(per-unit flags 管线由此建立) + +## 0. 问题 + +Issue #131 请求 `[targets.]` 支持按目标覆盖 `cxxflags`,类比 xmake `target:add("cxflags")` / +CMake `target_compile_options` / Cargo `[profile.*.package.*]`。两个动机示例: + +- **场景 A**:`[targets.server] cxxflags=["-DBUILD_SERVER=1"]` / `[targets.client] cxxflags=["-DBUILD_CLIENT=1"]` + —— 一个工程出两个二进制,各带各的宏。 +- **场景 B**:`[targets.test_contracts] cxxflags=["-fcontract-evaluation-semantic=observe"]` + —— 测试目标要在不同求值语义下编译。 + +本设计的结论:**#131 不应按字面("通用 per-target cxxflags over 共享编译池")实现**。该字面诉求 +与 mcpp 的 compile-once 模型冲突,且需求已被现有三轴(workspace / features / profile)基本覆盖; +真正缺的是两处小改 + 文档,外加两个可选的、严格受限的便利原语。 + +## 1. 设计判据(判定法) + +> **配置发散在"编译单元(包)"边界,不在"链接单元(target)"边界。** +> target 只能携带它**独占、且不影响共享镜像一致性**的本地标志;凡是会沿模块图传染、 +> 或影响 ABI/链接一致性的设置,禁止 per-target。 + +依据: + +- **target 是链接期概念**(哪些 `.o` 链在一起);**编译 flags 是编译期概念**(一个 `.o` 怎么编出来)。 + #131 的别扭本质是把编译期配置挂到链接期实体上、还跨越共享编译池——阻抗失配。 +- **Cargo 用规则编码了同一条边界**:`[profile.*.package.*]` 允许 `opt-level`/`codegen-units` + 这类**本地优化**,却**明令禁止** `panic`/`lto`/`rpath` 这类必须全局一致的项,且任意 `rustflags` + 至今只在 nightly `-Z profile-rustflags`(#10271,未稳定)。Cargo `[[bin]]` 也**不支持** per-bin + features/flags(只有 `required-features` 门禁)。#7916 是 feature resolver v2 的 `dev_dep` + 追踪(已闭),属 feature 并集机制,**不是** per-target features。 +- 与本仓 `manifest-schema-ownership` 的铁律 C 一致:**包级旋钮统一收敛进 features**,糖键入核需 + 领域中立 + 1:1 脱糖。per-target 任意 flags 不满足此约束。 + +## 2. 现状代码盘点(main@47d026f) + +| 机制 | 代码锚点 | 现状 | +|---|---|---| +| `Target` 结构(name/kind/main/soname) | `src/manifest.cppm:55-60` | **无任何 flags 字段** | +| `[targets.]` 解析(只读 kind/main) | `src/manifest.cppm:560-579` | 其余键**静默忽略** | +| `[build].cxxflags` 解析(全局一份) | `src/manifest.cppm:895-896` / `BuildConfig:104` | 仅项目级 | +| compile-once:每源一 CompileUnit | `src/build/plan.cppm:295-322` | 共享源**编一次**,被多 target 共享 | +| per-unit flags 管线(已建) | `CompileUnit{packageCflags,packageCxxflags}` `plan.cppm:23-24`;ninja 边变量 `unit_cxxflags` `ninja_backend.cppm:494,541-545,588-592` | #131 可复用 | +| 入口 main 按 target 单独建 CU(**④ 挂载点**) | `src/build/plan.cppm:495-509`(`main_cu`,已灌 `packageCxxflags`) | 入口天然 target 独占 | +| target 独占性:仅入口 main | `plan.cppm:336-348`(entryFilesAcrossTargets);`:427-441`(非入口对象链进每个 target) | 非入口源**不可** per-target 独占 | +| features:additive、按包全局、只出宏 | `src/build/prepare.cppm:2078-2152`(`-DMCPP_FEATURE_*` 推入 `buildConfig.cxxflags` `:2097-2102`) | 共享 lib **编一份** | +| profile:整构建模式(含 cxxflags 逃生舱) | `BuildConfig` 优化旋钮;`prepare.cppm` 合入 active profile | build 可选,见缺口 | +| `mcpp build` 解析 `--profile/--features` | `src/cli/cmd_build.cppm:29-31`;`BuildOverrides` `prepare.cppm:281-287` | OK | +| `mcpp test` **不解析任何 flag** | `cmd_test` `src/cli/cmd_build.cppm:66-71`;`run_tests(passthrough)` `src/build/execute.cppm:406` | **缺口①** | +| fingerprint 含 compile-flags hash | `src/toolchain/fingerprint.cppm:7` | per-target flags 自动覆盖 | + +**为什么共享源做不到 per-target 不同 flag**:一个 `.cppm` 编出**一个 `.o` + 一个 BMI**,被多个 +target 链接。要让它按 target 带不同宏,就得把编译节点**复制成多份** `obj//`,且 BMI 按 +`(模块,变体)` 索引——变体会沿 import 图**传染**整片子图。这正是 compile-once 要避免的成本,也是 +本设计拒绝 ⑥ 的原因。 + +## 3. 需求拆解(把 #131 还原成三类真实需求) + +1. **变体二进制**(场景 A):宏若只碰各 app **自己的代码** → workspace 拆包;若必须穿透共享 core + → 那是 core 的互斥变体,features(并集)给不了同构建两版,需分次构建或运行期分发。 +2. **测试换模式**(场景 B):求值语义/sanitizer 是**整构建模式**,须到达被测库 → profile,而非 target。 +3. **目标独占源的本地标志**:仅作用于该 target 入口/独占源(`-Wno-x`、`-DVERSION=`)→ 唯一真正 + 适合 per-target flags 的窄缝(入口独占,无 compile-once 冲突)。 + +## 4. 方案 + +### ① [P0·小] `mcpp test` 接受 `--profile` / `--features` + +- **解决**:场景 B 的规范解是 profile,但 `mcpp test` 今天丢弃所有 flag(`cmd_build.cppm:66-71`), + profile 机器无法用于测试构建。 +- **改动**:`cmd_test` 照搬 `cmd_build` 的 overrides 解析(`cmd_build.cppm:29-31`); + `run_tests`(`execute.cppm:406`)增收 `BuildOverrides` 并透传至 `prepare_build`(已有 `includeDevDeps=true` 路径)。 +- **语义正确性**:profile 整构建统一 → observe/sanitizer **到达被测库**;复用 fingerprint;不破 compile-once。 +- **用法**:`mcpp test --profile contracts`,其中 `[profile.contracts] cxxflags=["-fcontracts","-fcontract-evaluation-semantic=observe"]`。 + +### ② [P0·小] `[targets.]` 不支持键给显式提示 + +- **解决**:今天写 `[targets.x] cxxflags=[...]` 被静默丢弃(`manifest.cppm:560-579`),零反馈—— + 正是 #131 的 footgun。 +- **改动**:target 解析识别 `cxxflags`/`cflags`/`defines`/`features`/`required_features` 等键。 + - 若实施 ④/⑤ → 对应键变为受限支持; + - 其余未支持键 → warning(`--strict` 下报错,沿用现有 strict 通道),提示指向正确机制 + (workspace / features / profile)。 + +### ③ [P1·小] docs 增"Per-target / per-binary 构建配置"决策指引 + +- 在 `docs/05-mcpp-toml.md` 增一节(紧接 §2.2 targets),给三轴决策: + - 多二进制不同配置(宏在各自代码)→ **workspace member**(docs/06); + - 共享 lib 的可选能力 → **features**(§2.8,additive、按包、编一份); + - 整构建模式 → **profile + `--profile`**(§2.9,build 与 ① 后的 test); + - 并说明**为何不是 per-target cxxflags**(compile-once / BMI 一致性 / 与 Cargo `[[bin]]` 同款限制)。 + +### ④ [P2·中] 严格"入口/独占源作用域"的 per-target `cxxflags` / `defines` + +- **解决**:场景 A 中"宏只碰各自 main"的便利——免拆 workspace。入口源 target 独占 → + 编一次只进一个 target → **无共享、无 compile-once 冲突**。 +- **挂载点(现成)**:`plan.cppm:495-509` 已按 target 构造 `main_cu` 并灌 `packageCxxflags`。 + 改动 = `Target` 加 `cxxflags`/`defines`(`manifest.cppm:55-60` + 解析 `:560-579`); + 构造 `main_cu` 时把 `t.cxxflags`(及 `defines` 脱糖成 `-D`)**追加**到 `main_cu.packageCxxflags`; + ninja 边下发与 fingerprint **零额外改动**(复用 `unit_cxxflags` + compile-flags hash)。 +- **铁律护栏**: + - 只作用于该 target **独占源**(今日 = 入口 main); + - 若标志会落到**被多 target 共享的对象** → **报错**,绝不静默半生效; + - `defines` 设为一等键(可校验、跨工具链可移植);`cxxflags` 为裸标志逃生舱。 + - 文档明写"不穿透共享模块"。 +- **局限(诚实标注)**:今日独占源仅入口 main;要覆盖"server 专属 helper 模块"需再上 + `[targets.X] sources=[...]`(per-target 源归属),规模更大,**本设计不含**。 + +### ⑤ [P2·小-中] `[targets.] required_features = [...]`(借 Cargo 门禁) + +- **是什么**:目标门禁——仅当所列 feature 全部激活时才构建该 target,否则跳过(不报错)。 + **只门控,不激活**(feature 仍靠 `--features`/`default`)。 +- **解决**:① 可选工具/example/平台专属二进制(缩小默认构建面);② 场景 A 的**互斥变体**—— + `mcpp build --features server` 只出 server,且 `server` 宏(按包全局)**穿透共享 core**, + client 被门禁挡掉;互斥变体分次构建是正确模型。 +- **不解决**:同构建内两 target 不同配置(features 并集,撞 compile-once)。 +- **改动**:`Target` 加 `requiredFeatures`(`manifest.cppm:55-60` + 解析); + link-unit 循环(`plan.cppm:464`)按**激活 feature 集**(`prepare.cppm:2078-2152` 算出)过滤 target; + **不碰 CompileUnit/fingerprint**——纯链接期选择,零变体成本。 +- **与 ④ 互补**:④ = 一次构建多 bin、各自 main 上本地标志(到不了共享); + ⑤ = 互斥变体分次构建、配置穿透共享 core。 + +### ⑥ [❌·大] 通用 per-target cxxflags over 共享池 / 变体分区 —— 不做 + +- 即 #131 字面诉求。代价:BMI 按 `(模块,变体)` 翻倍 + 沿 import 图传染整片子图。 +- 需求已被 workspace(场景 A)+ features(共享可选开关)+ profile(整构建模式)+ ④/⑤ 覆盖。 +- 与判据(§1)及 schema-ownership 铁律 C 冲突。**除非出现 workspace+feature 都解不掉的硬实例,否则不上。** + +## 5. 实施清单 + +最小闭环 = **① + ② + ③**(全小改),即可让 #131 两场景都有规范、正确、今日可用的答案,且消灭静默坑。 +④/⑤ 视产品是否要给"单目录多二进制"再加便利,可后续独立 PR。 + +- [ ] **①** `run_tests` 增收 `BuildOverrides`(`execute.cppm:406`);`cmd_test` 解析 `--profile/--features/--strict`(`cmd_build.cppm:66-71`)并透传 +- [ ] **①** e2e:`mcpp test --profile ` 下被测库以该 profile 编译(对照默认 enforce) +- [ ] **②** target 解析识别未支持键并 warning(`--strict` 报错),文案指向 workspace/features/profile(`manifest.cppm:560-579`) +- [ ] **②** 单测:`test_manifest` 覆盖 `[targets.x] cxxflags=[...]` 触发提示 +- [ ] **③** `docs/05-mcpp-toml.md` 新增决策节;`docs/zh` 同步 +- [ ] **④**(可选)`Target{cxxflags,defines}` + 解析;`main_cu` 追加 target flags(`plan.cppm:495-509`);共享源冲突时报错 +- [ ] **④**(可选)e2e:两 bin 各带不同 `-D`,各自 main `#ifdef` 断言;验证共享对象不受影响 +- [ ] **⑤**(可选)`Target{requiredFeatures}` + 解析;link-unit 按激活集过滤(`plan.cppm:464`) +- [ ] **⑤**(可选)e2e:`--features X` 只出对应 target + +## 6. 验证 + +```bash +mcpp test # 单测 + e2e 全过 +mcpp build --profile contracts # ① 前置:build 侧 profile 可用 +mcpp test --profile contracts # ① 后:测试在 observe 下编(被测库受影响) +``` + +## 7. 设计原则沉淀 + +- **配置发散归编译单元(包),不归链接单元(target)**;target 只携独占、不影响共享一致性的本地标志。 +- **可发散的(本地优化/独占标志)与必须一致的(ABI/链接/共享 BMI)划清界**——照抄 Cargo + `[profile.*.package.*]` 允许 opt-level 却禁 panic/lto/rpath 的规矩。 +- **包级变体收敛进 features**(additive、并集、按包编一份);跨构建选择用 `required_features` 门禁; + 整构建模式用 profile。三者皆不触碰 compile-once。 diff --git a/CHANGELOG.md b/CHANGELOG.md index bbc4e4b..c536095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [0.0.55] — 2026-06-18 + +### 新增 + +- `[targets.]` 新增按目标的键 `defines` / `cxxflags` / `cflags`,作用于该目标 + **独占的入口源**(它的 `main`)。用于二进制入口私有的标志(如 `-DBUILD_SERVER=1`、 + 局部告警抑制),不影响共享模块/实现对象(compile-once 模型不变)。需要穿透共享代码的 + 差异请用 workspace member 或 `[features]`(#131)。 +- `[targets.]` 新增 `required_features`:仅当列出的 feature 全部激活时才构建该目标, + 否则静默跳过。是构建选择门禁,不激活 feature。 +- `mcpp test` 现在接受 `--profile` / `--features` / `--strict`,让被测代码与测试二进制 + 在所选 profile/feature 下编译(适合 sanitizer、契约求值语义等整次构建模式)。 + +### 变更 + +- `[targets.]` 下的不支持键不再被静默丢弃,而是产生 warning(`--strict` 下为 error), + 并指引到正确的机制(workspace / features / profile)。 +- 文档 `docs/05-mcpp-toml.md`(及 `docs/zh`)新增"构建配置该放哪"的决策指引。 + 设计记录见 `.agents/docs/2026-06-18-per-target-build-config-design.md`。 + ## [0.0.54] — 2026-06-10 ### 修复 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index a8d24d6..512cc46 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -81,6 +81,54 @@ downstream programs can load the library via its standard ABI name through `DT_NEEDED` or `dlopen()`. This field only applies to `kind = "shared"`, and the value must be a filename basename. +#### Per-target keys + +```toml +[targets.server] +kind = "bin" +main = "src/server.cpp" +defines = ["BUILD_SERVER=1", "PORT=8080"] # -D macros, applied to this target's entry only +cxxflags = ["-Wno-deprecated-declarations"] # extra C++ flags for this target's entry (no -std=...) +cflags = ["-DPURE_C"] # extra C flags for this target's entry + +[targets.gui] +kind = "bin" +main = "src/gui.cpp" +required_features = ["gui"] # only built when feature `gui` is active +``` + +| Key | Meaning | +|---|---| +| `defines` | Preprocessor macros (`name` or `name=value`); desugar to `-D` on both the C and C++ entry compile. | +| `cxxflags` / `cflags` | Extra compile flags for this target. Do **not** put `-std=...` here — use `[package].standard`. | +| `required_features` | The target is emitted only when **every** listed feature is active in the build; otherwise it is silently skipped. A gate only — it does not activate features (use `--features` / `[features].default`). | + +> **Scope (important):** `defines` / `cxxflags` / `cflags` on a target apply **only to that +> target's exclusive entry source** (its `main`) — never to shared module/impl objects, which +> are compiled once and linked into every target (mcpp's compile-once model). They are the right +> tool when the flag only needs to affect a single binary's (or test's) own entry — for example a +> per-test contract evaluation semantic (`-fcontract-evaluation-semantic=observe`) for a test whose +> `main` exercises the violation, a feature macro the entry alone reads, or a local warning +> suppression. If a flag must reach **shared** code, it does not belong here — split into a +> [workspace](06-workspace.md) member or use `[features]`, or for a whole-build mode use a +> `[profile.*]` (`mcpp test --profile ` builds the whole test image, code-under-test +> included, under that profile). +> +> Unsupported keys under `[targets.]` are reported as a warning (an error under `--strict`). + +**Choosing where build configuration goes** — when more than one binary must differ: + +| You want | Use | +|---|---| +| Different macros/flags on a binary's **own entry** | per-target `defines` / `cxxflags` (above) | +| Two products that differ in code they **share** | split into [workspace](06-workspace.md) members, each with its own `[build]` flags over a shared `lib` | +| To **select a variant** of a shared library (e.g. a backend) | `[features]` on that library (§2.8) — additive, reaches the library's own compile | +| A **whole-build mode** (sanitizers, contract semantics, opt level) | `[profile.]` (§2.9) + `--profile`; also honored by `mcpp test --profile ` | + +mcpp deliberately does not compile a shared source two different ways within one build: a source +maps to one object (and one BMI for modules), so divergence that must reach shared code belongs at +the package/feature boundary, not on an individual target. + ### 2.3 `[build]` — Build Configuration ```toml @@ -258,7 +306,8 @@ cxxflags = ["-fno-plt"] ldflags = [] ``` -- Selection: `mcpp build --profile `, defaulting to `release`. +- Selection: `mcpp build --profile ` (and `mcpp test --profile `, which builds the + code-under-test plus the test binaries under that profile), defaulting to `release`. - Built-in profiles: `release` (-O2) / `dev`, `debug` (-O0 -g) / `dist` (-O3 + strip; **LTO is not enabled by default**). `[profile.]` can override a built-in definition wholesale. diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index d22b477..2aedad6 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -80,6 +80,51 @@ soname = "libmylib.so.1" # 可选: ELF/Mach-O ABI 名称,运行时会生成同 让下游程序可通过标准 ABI 名称 `DT_NEEDED` 或 `dlopen()` 加载该库。 该字段只对 `kind = "shared"` 有效,值必须是文件名 basename。 +#### 按目标的键(per-target keys) + +```toml +[targets.server] +kind = "bin" +main = "src/server.cpp" +defines = ["BUILD_SERVER=1", "PORT=8080"] # -D 宏,只作用于该目标的入口 +cxxflags = ["-Wno-deprecated-declarations"] # 该目标入口的额外 C++ 标志(不要放 -std=...) +cflags = ["-DPURE_C"] # 该目标入口的额外 C 标志 + +[targets.gui] +kind = "bin" +main = "src/gui.cpp" +required_features = ["gui"] # 仅当 feature `gui` 激活时才构建 +``` + +| 键 | 含义 | +|---|---| +| `defines` | 预处理宏(`name` 或 `name=value`),脱糖为 `-D`,作用于该目标入口的 C 与 C++ 编译。 | +| `cxxflags` / `cflags` | 该目标的额外编译标志。**不要**放 `-std=...`——用 `[package].standard`。 | +| `required_features` | 仅当列出的 feature **全部**激活时才生成该目标,否则静默跳过。只是门禁——不激活 feature(用 `--features` / `[features].default`)。 | + +> **作用域(重要):** 目标上的 `defines` / `cxxflags` / `cflags` **只作用于该目标独占的入口源** +> (它的 `main`)——**绝不**作用于共享的模块/实现对象(那些只编译一次、被每个目标链接,即 mcpp 的 +> compile-once 模型)。当标志只需影响某个二进制(或测试)**自己的入口**时,这正是合适的工具 —— +> 例如某个测试的 `main` 里触发契约违规、需要按测试设置契约求值语义 +> (`-fcontract-evaluation-semantic=observe`),或入口独享的 feature 宏、局部告警抑制。 +> 若标志必须穿透**共享**代码,就不该放在这里 —— 改用 [workspace](06-workspace.md) member 或 +> `[features]`;若是整次构建的模式,用 `[profile.*]`(`mcpp test --profile ` 会让包括被测 +> 代码在内的整个测试镜像都在该 profile 下编译)。 +> +> `[targets.]` 下的不支持键会产生 warning(`--strict` 下为 error)。 + +**构建配置该放哪** —— 当多个二进制需要不同配置时: + +| 你想要 | 用 | +|---|---| +| 某二进制**自己入口**上的不同宏/标志 | per-target `defines` / `cxxflags`(见上) | +| 两个产品差异在它们**共享**的代码里 | 拆成 [workspace](06-workspace.md) member,各自 `[build]` 标志,共享一个 `lib` | +| **选择**某共享库的变体(如某后端) | 在该库上用 `[features]`(§2.8)——additive,作用到库自己的编译 | +| **整次构建的模式**(sanitizer、契约语义、优化档) | `[profile.]`(§2.9)+ `--profile`;`mcpp test --profile ` 同样支持 | + +mcpp 刻意不在一次构建里把同一个共享源编译成两份:一个源对应一个对象(模块还对应一个 BMI), +所以"必须穿透共享代码"的差异应放在包/feature 边界,而非单个目标上。 + ### 2.3 `[build]` — 构建配置 ```toml @@ -246,7 +291,8 @@ cxxflags = ["-fno-plt"] ldflags = [] ``` -- 选择:`mcpp build --profile `,默认 `release`。 +- 选择:`mcpp build --profile `(以及 `mcpp test --profile `,会让被测代码与测试二进制 + 都在该 profile 下编译),默认 `release`。 - 内置档案:`release`(-O2)/ `dev`、`debug`(-O0 -g)/ `dist`(-O3 + strip; **不默认开 lto**)。`[profile.<内置名>]` 可整体覆盖内置定义。 diff --git a/mcpp.toml b/mcpp.toml index cadb82e..9350725 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "0.0.54" +version = "0.0.55" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/execute.cppm b/src/build/execute.cppm index fd01edc..01fb4c8 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -403,7 +403,8 @@ export int build_run_target(const std::optional& targetName, // `mcpp test` driver: discover tests/**/*.cpp, synthesize targets, build // with dev-deps, run each test binary, summarize. -export int run_tests(std::span passthrough) { +export int run_tests(std::span passthrough, + BuildOverrides overrides = {}) { auto root = mcpp::project::find_manifest_root(std::filesystem::current_path()); if (!root) { mcpp::ui::error("no mcpp.toml found in current directory or any parent"); @@ -439,7 +440,8 @@ export int run_tests(std::span passthrough) { // 3. prepare_build with dev-deps enabled + synthetic targets. auto ctx = prepare_build(/*print_fp=*/false, /*includeDevDeps=*/true, - std::move(testTargets)); + std::move(testTargets), + std::move(overrides)); if (!ctx) { mcpp::ui::error(ctx.error()); return 2; } // 4. "Compiling test_X (test)" lines for the test binaries. diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 0e6d530..60895b0 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -540,6 +540,27 @@ BuildPlan make_plan(const mcpp::manifest::Manifest& manifest, plan.compileUnits.push_back(main_cu); } lu.objects.push_back(main_cu.object); + + // Per-target entry-scoped flags (issue #131). Applied to the compile + // unit that actually builds this target's entry — which may be the + // one just inserted, or a unit the source scan already produced when + // main was globbed into [build].sources. SCOPE: a target's entry is + // exclusive to it (distinct `main` per target, and foreign entries + // are excluded from every other target's object set below), so these + // flags never color a shared module/impl object. `defines` desugar + // to -D on both the C and C++ entry compile. + if (!t.defines.empty() || !t.cflags.empty() || !t.cxxflags.empty()) { + for (auto& cu : plan.compileUnits) { + if (cu.source != main_cu.source) continue; + for (auto const& d : t.defines) { + cu.packageCflags.push_back("-D" + d); + cu.packageCxxflags.push_back("-D" + d); + } + for (auto const& f : t.cflags) cu.packageCflags.push_back(f); + for (auto const& f : t.cxxflags) cu.packageCxxflags.push_back(f); + break; + } + } } // Also include implementation .cpp/.cc/.cxx/.c units, but EXCLUDE any diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index b595a75..46112c2 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -405,6 +405,14 @@ prepare_build(bool print_fingerprint, // Inject synthetic targets (e.g. test binaries from `mcpp test`). for (auto& t : extraTargets) m->targets.push_back(t); + // Surface non-fatal manifest schema warnings (e.g. unsupported [targets.*] + // keys). Under --strict they become errors — same policy as the + // feature/platform schema checks below. + for (auto const& w : m->schemaWarnings) { + if (overrides.strict) return std::unexpected(w); + std::println(stderr, "warning: {}", w); + } + // ─── Toolchain resolution (docs/21) ──────────────────────────────── // Priority chain: // 1. mcpp.toml [toolchain]. → resolve_xpkg_path → abs path @@ -2068,6 +2076,9 @@ prepare_build(bool print_fingerprint, // Implied features expand transitively. Each active feature becomes // -DMCPP_FEATURE_ on that package's compile flags. // (Transitive dep→dep feature requests are not yet propagated.) + // Also captured here: the root package's active feature set, reused below + // for the [targets.*] required_features gate. + std::set activeRootFeatures; { auto sanitize = [](std::string f) { for (auto& c : f) @@ -2130,6 +2141,7 @@ prepare_build(bool print_fingerprint, std::println(stderr, "warning: {}", msg); } apply(packages[0], rootReq); + for (auto& f : activate(*m, rootReq)) activeRootFeatures.insert(f); } for (std::size_t i = 1; i < packages.size(); ++i) { auto& pname = packages[i].manifest.package.name; @@ -2152,6 +2164,16 @@ prepare_build(bool print_fingerprint, } } + // [targets.*] required_features gate: a target is emitted only when ALL its + // required features are active in this build; otherwise it is silently + // skipped. A pure build-selection knob — it runs before the modgraph/plan + // so gated-out targets cost nothing. + std::erase_if(m->targets, [&](const mcpp::manifest::Target& t) { + for (auto const& rf : t.requiredFeatures) + if (!activeRootFeatures.contains(rf)) return true; + return false; + }); + // Modgraph: regex scanner by default; opt-in to compiler-driven P1689 // scanner via env var MCPP_SCANNER=p1689 (see docs/27). auto scan = [&] { diff --git a/src/cli.cppm b/src/cli.cppm index cdbc907..2691baf 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -231,6 +231,12 @@ int run(int argc, char** argv) { }))) .subcommand(cl::App("test") .description("Build + run all tests/**/*.cpp (after `--`, args go to each test binary)") + .option(cl::Option("profile").takes_value().value_name("NAME") + .help("Build profile for the test build: release (default) | dev | dist | <[profile.*] name>")) + .option(cl::Option("features").takes_value().value_name("LIST") + .help("Activate root-package features for the test build (comma-separated)")) + .option(cl::Option("strict") + .help("Treat manifest schema warnings (unknown feature/platform) as errors")) .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { return cmd_test(p, std::span(passthrough)); }))) diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index 187dbe8..340fc51 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -63,11 +63,18 @@ export int cmd_run(const mcpplibs::cmdline::ParsedArgs& parsed, return mcpp::build::build_run_target(targetName, passthrough); } -export int cmd_test(const mcpplibs::cmdline::ParsedArgs& /*parsed*/, +export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed, std::span passthrough) { - // `mcpp test` takes no pre-`--` flags or positionals; post-`--` args - // go to each test binary. - return mcpp::build::run_tests(passthrough); + // Pre-`--` flags select the build mode for the test build (so e.g. + // `mcpp test --profile contracts` compiles the code-under-test plus the + // test binaries under that profile — a whole-build mode, the right + // granularity for sanitizers / contract evaluation semantics). Post-`--` + // args go to each test binary. + mcpp::build::BuildOverrides ov; + if (auto pr = parsed.value("profile")) ov.profile = *pr; + if (auto fs = parsed.value("features")) ov.features = *fs; + ov.strict = parsed.is_flag_set("strict"); + return mcpp::build::run_tests(passthrough, ov); } export int cmd_clean(const mcpplibs::cmdline::ParsedArgs& parsed) { diff --git a/src/manifest.cppm b/src/manifest.cppm index a361945..875d5c3 100644 --- a/src/manifest.cppm +++ b/src/manifest.cppm @@ -57,6 +57,21 @@ struct Target { enum Kind { Library, Binary, SharedLibrary, TestBinary } kind; std::string main; // for binary / test std::string soname; // ABI name for shared libraries, e.g. libfoo.so.1 + // Per-target compile flags. SCOPE: applied ONLY to this target's exclusive + // entry source (its `main`) — never to shared module/impl objects, which are + // compiled once and linked into every target (the build's compile-once model; + // see src/build/plan.cppm). `defines` are sugar desugared to `-D` at plan + // time and applied to both the C and C++ entry compile. Use these for flags + // that are private to a binary's own entry (e.g. `-DBUILD_SERVER=1`, + // `-Wno-deprecated`); for divergence that must reach shared code, use a + // workspace member or a [features] knob instead. + std::vector cflags; + std::vector cxxflags; + std::vector defines; + // Build gate: this target is emitted ONLY when every listed feature is + // active in the current build (otherwise it is silently skipped). Gate + // only — it does not activate features (use --features / [features].default). + std::vector requiredFeatures; }; // `DependencySpec` and `kDefaultNamespace` have moved to mcpp.pm.dep_spec. @@ -248,6 +263,11 @@ struct Manifest { bool usesModules = true; // refined by scanner bool usesImportStd = true; // refined by scanner std::vector inferredNotes; // for `Inferred ...` banner + + // Non-fatal schema warnings collected during parse (e.g. unsupported keys + // under [targets.]). The caller (prepare_build) prints these and, under + // --strict, escalates them to errors — mirroring the feature/platform path. + std::vector schemaWarnings; }; struct ManifestError { @@ -588,6 +608,48 @@ std::expected parse_string(std::string_view content, if (auto msg = validate_target_soname(t, std::format("targets.{}.", tname))) { return std::unexpected(error(origin, *msg)); } + + // Per-target flags (entry-scoped) + required-features gate. + auto read_list = [&](const char* key, std::vector& out) { + if (auto it = tt.find(key); it != tt.end() && it->second.is_array()) + for (auto& v : it->second.as_array()) + if (v.is_string()) out.push_back(v.as_string()); + }; + read_list("cflags", t.cflags); + read_list("cxxflags", t.cxxflags); + read_list("defines", t.defines); + read_list("required_features", t.requiredFeatures); + // Guard: -std=... belongs to [package].standard, not per-target flags + // (same rule as [build].cxxflags). Reject early with a clear message. + for (auto const& flag : t.cxxflags) { + if (starts_with_std_flag(flag)) { + return std::unexpected(error(origin, std::format( + "targets.{}.cxxflags contains '{}'; use [package].standard to " + "configure the C++ language standard", tname, flag))); + } + } + + // Surface unsupported keys instead of silently dropping them — the + // historic footgun behind issue #131 (a `[targets.x] cxxflags` typo on + // an older mcpp just vanished). Per-target arbitrary build config that + // must reach SHARED code is intentionally not a target key; point users + // at the right axis (workspace / features / profile). + static constexpr std::string_view kKnownTargetKeys[] = { + "kind", "main", "soname", + "cflags", "cxxflags", "defines", "required_features", + }; + for (auto& [key, _] : tt) { + bool known = false; + for (auto k : kKnownTargetKeys) if (key == k) { known = true; break; } + if (!known) { + m.schemaWarnings.push_back(std::format( + "[targets.{}] has unsupported key '{}' (ignored). Per-target keys: " + "kind, main, soname, cflags, cxxflags, defines, required_features. " + "For config that must affect shared code, split into a workspace " + "member or use [features]; for a whole-build mode use [profile.*].", + tname, key)); + } + } m.targets.push_back(std::move(t)); } } // close `if (targets_table && !targets_table->empty())` diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index b300627..41987b8 100644 --- a/src/toolchain/fingerprint.cppm +++ b/src/toolchain/fingerprint.cppm @@ -18,7 +18,7 @@ import mcpp.toolchain.detect; export namespace mcpp::toolchain { -inline constexpr std::string_view MCPP_VERSION = "0.0.54"; +inline constexpr std::string_view MCPP_VERSION = "0.0.55"; struct FingerprintInputs { Toolchain toolchain; diff --git a/tests/e2e/70_per_target_flags.sh b/tests/e2e/70_per_target_flags.sh new file mode 100755 index 0000000..35e9b8d --- /dev/null +++ b/tests/e2e/70_per_target_flags.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# requires: gcc +# Per-target entry-scoped flags (issue #131): two bin targets in ONE package +# each carry their own `defines`/`cxxflags`, applied ONLY to that target's +# exclusive entry. A shared source compiled once must see NEITHER target's +# macro (compile-once stays neutral). +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +mkdir -p proj/src +cd proj + +# Shared source — globbed, compiled once, linked into both binaries. +# It must NOT see either per-target macro. +cat > src/common.cpp <<'EOF' +#if defined(ROLE_SERVER) || defined(ROLE_CLIENT) +#error "per-target macro leaked into a shared compile unit" +#endif +extern "C" int shared_answer(void) { return 7; } +EOF + +# server entry: requires its own macro. +cat > src/server.cpp <<'EOF' +#ifndef ROLE_SERVER +#error "server target did not receive its per-target define" +#endif +extern "C" int shared_answer(void); +int main() { return (shared_answer() == 7 && ROLE_SERVER == 1) ? 0 : 1; } +EOF + +# client entry: requires a different macro + a per-target cxxflag define. +cat > src/client.cpp <<'EOF' +#ifndef ROLE_CLIENT +#error "client target did not receive its per-target define" +#endif +#ifndef VIA_CXXFLAG +#error "client target did not receive its per-target cxxflag" +#endif +extern "C" int shared_answer(void); +int main() { return (shared_answer() == 7 && ROLE_CLIENT == 2) ? 0 : 1; } +EOF + +cat > mcpp.toml <<'EOF' +[package] +name = "proj" +version = "0.1.0" + +[targets.server] +kind = "bin" +main = "src/server.cpp" +defines = ["ROLE_SERVER=1"] + +[targets.client] +kind = "bin" +main = "src/client.cpp" +defines = ["ROLE_CLIENT=2"] +cxxflags = ["-DVIA_CXXFLAG"] +EOF + +"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } + +ninja_file="$(find target -name build.ninja | head -1)" +[[ -n "$ninja_file" ]] || { echo "no build.ninja"; exit 1; } +grep -q -- "-DROLE_SERVER=1" "$ninja_file" || { echo "server define missing from ninja"; exit 1; } +grep -q -- "-DROLE_CLIENT=2" "$ninja_file" || { echo "client define missing from ninja"; exit 1; } +grep -q -- "-DVIA_CXXFLAG" "$ninja_file" || { echo "client cxxflag missing from ninja"; exit 1; } + +# Both binaries built and each behaves per its own macro. +"$MCPP" run server > /dev/null 2>&1 || { echo "server run failed (wrong/missing macro)"; exit 1; } +"$MCPP" run client > /dev/null 2>&1 || { echo "client run failed (wrong/missing macro)"; exit 1; } + +echo "OK" diff --git a/tests/e2e/71_required_features_gate.sh b/tests/e2e/71_required_features_gate.sh new file mode 100755 index 0000000..78b5545 --- /dev/null +++ b/tests/e2e/71_required_features_gate.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# requires: gcc +# Per-target required_features gate (issue #131): a target is emitted only when +# all its required features are active. `--features X` builds only the matching +# target; the gated-out target's entry is never compiled (no main() clash). +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +mkdir -p proj/src proj/bin +cd proj + +# Shared code (globbed). Entry mains live outside src/ so a gated-out target's +# main is not globbed into the shared pool. +cat > src/common.cpp <<'EOF' +extern "C" int shared_answer(void) { return 0; } +EOF +cat > bin/server.cpp <<'EOF' +extern "C" int shared_answer(void); +int main() { return shared_answer(); } +EOF +cat > bin/client.cpp <<'EOF' +extern "C" int shared_answer(void); +int main() { return shared_answer(); } +EOF + +cat > mcpp.toml <<'EOF' +[package] +name = "proj" +version = "0.1.0" + +[build] +sources = ["src/**/*.cpp"] + +[features] +server = [] +client = [] + +[targets.server] +kind = "bin" +main = "bin/server.cpp" +required_features = ["server"] + +[targets.client] +kind = "bin" +main = "bin/client.cpp" +required_features = ["client"] +EOF + +binpath() { find target -path '*/bin/'"$1" 2>/dev/null | head -1; } + +# --features server → only the server target is emitted. +"$MCPP" build --features server > s.log 2>&1 || { cat s.log; echo "server build failed"; exit 1; } +[[ -n "$(binpath server)" ]] || { echo "server binary not built under --features server"; exit 1; } +[[ -z "$(binpath client)" ]] || { echo "client binary should be gated out under --features server"; exit 1; } + +# --features client → only the client target is emitted. +"$MCPP" build --features client > c.log 2>&1 || { cat c.log; echo "client build failed"; exit 1; } +[[ -n "$(binpath client)" ]] || { echo "client binary not built under --features client"; exit 1; } + +echo "OK" diff --git a/tests/e2e/72_test_profile_features.sh b/tests/e2e/72_test_profile_features.sh new file mode 100755 index 0000000..d62b751 --- /dev/null +++ b/tests/e2e/72_test_profile_features.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# requires: gcc +# `mcpp test` honors --profile / --features (issue #131 scenario B). The flag +# selects a whole-build mode for the test build, so it reaches the CODE-UNDER- +# TEST (a shared src/ unit), not just the test entry. Plain `mcpp test` must +# fail; `mcpp test --profile observe` must pass. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +mkdir -p proj/src proj/tests +cd proj + +# Code under test — a shared library source, compiled once and linked into the +# test binary. Its behavior depends on a macro supplied only by the profile. +cat > src/probe.cpp <<'EOF' +extern "C" int probe_mode(void) { +#ifdef OBSERVE_MODE + return 1; +#else + return 0; +#endif +} +EOF + +cat > tests/test_mode.cpp <<'EOF' +extern "C" int probe_mode(void); +int main() { return probe_mode() == 1 ? 0 : 1; } +EOF + +cat > mcpp.toml <<'EOF' +[package] +name = "proj" +version = "0.1.0" + +[build] +sources = ["src/**/*.cpp"] + +[profile.observe] +cxxflags = ["-DOBSERVE_MODE=1"] +EOF + +# Default profile: the code-under-test is built WITHOUT the macro → test fails. +if "$MCPP" test > plain.log 2>&1; then + cat plain.log + echo "expected default 'mcpp test' to fail (macro should be absent)" + exit 1 +fi + +# --profile observe: the macro reaches src/probe.cpp → test passes. +"$MCPP" test --profile observe > observe.log 2>&1 || { + cat observe.log + echo "expected 'mcpp test --profile observe' to pass" + exit 1 +} + +echo "OK" diff --git a/tests/e2e/73_issue131_per_target_cxxflag.sh b/tests/e2e/73_issue131_per_target_cxxflag.sh new file mode 100755 index 0000000..3a07cae --- /dev/null +++ b/tests/e2e/73_issue131_per_target_cxxflag.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# requires: gcc +# Issue #131 scenario 1, verbatim shape: a single bin target carries a per-target +# *codegen* cxxflag that must affect ONLY its own entry `main`, not shared code: +# +# [targets.test_contracts] +# kind = "bin" +# main = "tests/test_contracts.cpp" +# cxxflags = ["-fcontract-evaluation-semantic=observe"] +# +# We use `-fno-exceptions` as a portable stand-in for the contracts semantic +# flag (which needs a contracts-enabled toolchain not guaranteed across the +# Linux/macOS/Windows CI matrix). The mechanism under test is identical: a real +# per-target codegen flag scoped to that target's exclusive entry. Its effect is +# preprocessor-observable via `__EXCEPTIONS` (defined iff exceptions are on), so +# both "reached the entry" and "did NOT leak to shared" are checked at compile +# time, plus the flag is asserted on the target's main edge in build.ninja. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +mkdir -p proj/src proj/tests +cd proj + +# Shared source — globbed, compiled once with DEFAULT flags (exceptions on). +# If the per-target flag leaked here, __EXCEPTIONS would be undefined → error. +cat > src/shared.cpp <<'EOF' +#ifndef __EXCEPTIONS +#error "per-target cxxflag leaked into a shared compile unit" +#endif +extern "C" int shared_val(void) { return 42; } +EOF + +# The target's own entry — compiled WITH the per-target flag (-fno-exceptions), +# so __EXCEPTIONS must be undefined here. +cat > tests/test_contracts.cpp <<'EOF' +#ifdef __EXCEPTIONS +#error "per-target cxxflag did not reach this target's own entry" +#endif +extern "C" int shared_val(void); +int main() { return shared_val() == 42 ? 0 : 1; } +EOF + +cat > mcpp.toml <<'EOF' +[package] +name = "proj" +version = "0.1.0" + +[targets.test_contracts] +kind = "bin" +main = "tests/test_contracts.cpp" +cxxflags = ["-fno-exceptions"] +EOF + +# Compiles only if the flag reached the entry (entry check) AND did not leak to +# the shared unit (shared check) — both are #error guards above. +"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } + +ninja_file="$(find target -name build.ninja | head -1)" +[[ -n "$ninja_file" ]] || { echo "no build.ninja"; exit 1; } +grep -q -- "-fno-exceptions" "$ninja_file" || { echo "per-target cxxflag missing from build.ninja"; exit 1; } + +"$MCPP" run test_contracts > /dev/null 2>&1 || { echo "run failed"; exit 1; } + +echo "OK" diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 80c4322..ca396a5 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -909,6 +909,67 @@ version = "0.0.2" EXPECT_FALSE(mcpp::manifest::has_lib_target(*m)); } +TEST(Manifest, ParsesPerTargetFlagsAndRequiredFeatures) { + constexpr auto src = R"( +[package] +name = "app" +version = "0.1.0" +[targets.server] +kind = "bin" +main = "src/server.cpp" +defines = ["BUILD_SERVER=1", "PORT=8080"] +cxxflags = ["-fno-exceptions"] +cflags = ["-DPURE_C"] +required_features = ["server"] +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->targets.size(), 1u); + auto& t = m->targets[0]; + ASSERT_EQ(t.defines.size(), 2u); + EXPECT_EQ(t.defines[0], "BUILD_SERVER=1"); + EXPECT_EQ(t.defines[1], "PORT=8080"); + ASSERT_EQ(t.cxxflags.size(), 1u); + EXPECT_EQ(t.cxxflags[0], "-fno-exceptions"); + ASSERT_EQ(t.cflags.size(), 1u); + EXPECT_EQ(t.cflags[0], "-DPURE_C"); + ASSERT_EQ(t.requiredFeatures.size(), 1u); + EXPECT_EQ(t.requiredFeatures[0], "server"); + EXPECT_TRUE(m->schemaWarnings.empty()); +} + +TEST(Manifest, WarnsOnUnsupportedTargetKey) { + constexpr auto src = R"( +[package] +name = "app" +version = "0.1.0" +[targets.app] +kind = "bin" +main = "src/main.cpp" +cxxfalgs = ["-DTYPO"] +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->schemaWarnings.size(), 1u); + EXPECT_NE(m->schemaWarnings[0].find("cxxfalgs"), std::string::npos); + EXPECT_NE(m->schemaWarnings[0].find("unsupported key"), std::string::npos); +} + +TEST(Manifest, RejectsStdFlagInTargetCxxflags) { + constexpr auto src = R"( +[package] +name = "app" +version = "0.1.0" +[targets.app] +kind = "bin" +main = "src/main.cpp" +cxxflags = ["-std=c++20"] +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_FALSE(m.has_value()); + EXPECT_NE(m.error().message.find("[package].standard"), std::string::npos); +} + TEST(ListXpkgVersions, IgnoresCommentedEntries) { constexpr auto src = R"( package = {