Skip to content

Commit f17e425

Browse files
authored
feat: 0.0.97 架构级修复批次 (#224-#235 + R6) (#236)
feat: 0.0.97 架构级修复批次 — scanner/flag/build-graph/cfg/nasm/workspace(#224-#235 + R6)
2 parents 42198fb + 78bb1c9 commit f17e425

35 files changed

Lines changed: 3948 additions & 224 deletions

.agents/docs/2026-07-18-issue-triage-215plus-architectural-remediation.md

Lines changed: 259 additions & 0 deletions
Large diffs are not rendered by default.

.agents/docs/2026-07-18-v0.0.97-architectural-remediation-implementation-plan.md

Lines changed: 475 additions & 0 deletions
Large diffs are not rendered by default.

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,31 @@
33
> 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。
44
> 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
55
6+
## [0.0.97] — 2026-07-18
7+
8+
> 架构级修复批次(单 PR,逐簇 commit):ffmpeg-m/opencv-m 全源码直编暴露的第二层缺口 + workspace 测试基建语法空洞。
9+
10+
### 新增
11+
12+
- **`[[build.flags]]` 数组表写法**(#227):`[build].flags` 现同时接受 TOML 标准数组表 `[[build.flags]]` 与内联表数组两种等价写法(声明顺序=应用顺序不变),长 per-glob flags 条目不再挤单行。纯解析层扩展(自研 TOML parser 补全 array-of-tables);并加清单层 closed-grammar 守卫——非白名单段落误写 `[[x]]`(如 `[[dependencies]]` 手滑)现**硬报错**而非静默丢数据。
13+
- **glob 花括号交替 `{a,b}`**(#228):sources/flags glob 支持 `libavcodec/{aac,bsf,hevc}/**` 笛卡尔展开(嵌套/多组均可),vendored 大库 per-glob 声明不再重复。
14+
- **默认命名空间索引重定向**(R6):`[indices] default = { path = "..." }`(亦接受空引号键 `""`)可把默认命名空间(`namespace = ""` 的模块包)指向本地 checkout,补上此前只能重定向具名命名空间的语法空洞——使 index 仓能以 `mcpp test --workspace` 声明式验证 imgui/ffmpeg/opencv 等模块包,替代逐包 smoke shell。(`url` 形式的默认命名空间重定向暂不支持,解析期显式报错而非静默失效。)
15+
- **`mcpp run -p <member>`**:run 命令支持选择 workspace 成员并运行其二进制(与 `build`/`test``-p` 对齐)。
16+
17+
### 修复
18+
19+
- **源发现全树遍历致 `mcpp run` 前慢 + 缓存复用**(#225):glob 遍历改从字面前缀起(`src/**``src/` 起走,不再从项目根扫全树),并排除 `.git`/`target`/git 子模块边界;`mcpp run` 复用 `mcpp build` 已解析的缓存,不再每次重扫大子模块(此前含大 `compat/` 子模块时每次 ~9.5s)。
20+
- **相对 include 族 flag 未按项目根重写 + 含空格值未转义**(#226 #234):`-iquote`/`-isystem`/`-idirafter`/`-iprefix`/`-L` 现与 `-I` 一样按项目根绝对化(joined 与 separated 两拼写);`defines = ["T=long long"]` 等含空格值发射时 shell 引号保护,不再被拆成孤立参数。含 `[build] include_dirs` 在 MSVC 方言下的相对路径绝对化亦一并修正。
21+
- **对象路径按父目录名折叠致同名源冲突**(#233):不同目录同名源(`a/src/util.cpp` vs `b/src/util.cpp`,OpenCV/LLVM 式 `modules/<mod>/src/*` 布局)对象路径改按碰撞时镜像源**相对路径**消歧 + 构建后唯一性断言;非碰撞项路径字节不变。
22+
- **模块 purview 内文本 `#include` 改动不触发重编**(#235):编译边补 depfile 追踪(过滤 GCC `-fmodules` 注入的反向规则以规避 ninja `inputs may not also have inputs`),purview/GMF/普通头改动均正确触发重编——顺带根治此前非 MSVC 下普通头改动也不重编的潜伏问题。
23+
- **依赖包 cfg 条件 sources 被消费时不展开**(#229):path/git 依赖的 `[target.'cfg(...)'.build].sources` 现与 root/version-dep 同样按已解析 target 求值(`mcpp build``mcpp test` 双路径),不再 undefined reference(与 #218 同类,收敛为统一 per-package 求值)。
24+
- **nasm 冷环境惰性自举时序**(#232):nasm 供给改走工具链同款同步门 `Fetcher::resolve_xpkg_path("xim:nasm@3.02", autoInstall=true)`(索引刷新前置 + 硬报错 + payload 校验),不再先判死后台补装;config 自举错误不再被 `if(cfg)` 门吞成误导性的 "no usable nasm"。
25+
- **workspace 根配置无法一次声明全员可用**(#224):`[workspace.dependencies]` 的 path 依赖可被成员经 `.workspace = true` 继承;根 `[indices]` 的相对 `path`**workspace 根**解析(而非消费成员目录),成员不再需重复声明各自 `../` 相对路径。
26+
27+
### 备注
28+
29+
- #230(windows workspace 崩溃)已于 0.0.96 修复。#215(cppfly Clang 反射行)待上游 Clang 落地 P2996,不排期。
30+
631
## [0.0.96] — 2026-07-18
732

833
### 修复

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mcpp"
3-
version = "0.0.96"
3+
version = "0.0.97"
44
description = "Modern C++ build & package management tool"
55
license = "Apache-2.0"
66
authors = ["mcpp-community"]

src/build/execute.cppm

Lines changed: 295 additions & 72 deletions
Large diffs are not rendered by default.

src/build/flags.cppm

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export module mcpp.build.flags;
1313

1414
import std;
1515
import mcpp.build.plan;
16+
import mcpp.modgraph.scanner;
1617
import mcpp.platform;
1718
import mcpp.toolchain.clang;
1819
import mcpp.toolchain.detect;
@@ -66,6 +67,21 @@ CompileFlags compute_flags(const BuildPlan& plan);
6667
std::string atomic_link_flag(const std::vector<std::filesystem::path>& linkDirs,
6768
bool staticLink);
6869

70+
// mcpp#234: quote a single flag-vector token for safe embedding in a shell
71+
// command line. Every element of a flags `vector<string>` is already one
72+
// argv token (e.g. `apply_glob_flags` pushes `"-D" + d`, so a define like
73+
// `T=long long` arrives as the single element `-DT=long long`) — but the
74+
// emission choke points (`join_flags` in ninja_backend.cppm, and the global
75+
// blob assembly below) historically joined tokens with a bare space and no
76+
// quoting, so a token containing a space silently split into two shell
77+
// words once ninja handed the resolved command line to the shell. Only
78+
// tokens that actually contain whitespace or a shell-significant character
79+
// are quoted — plain framework flags (`-std=c++23`, `-O2`, `-I/abs/path`)
80+
// come back unchanged, byte-for-byte. POSIX: wrap in single quotes (embedded
81+
// `'` escaped as `'\''`). Windows: wrap in double quotes (embedded `"`
82+
// escaped as `\"`) — cmd.exe/CreateProcess argv convention.
83+
std::string shell_quote_arg(std::string_view arg);
84+
6985
} // namespace mcpp::build
7086

7187
namespace mcpp::build {
@@ -123,6 +139,39 @@ std::string atomic_link_flag(const std::vector<std::filesystem::path>& linkDirs,
123139
return {};
124140
}
125141

142+
std::string shell_quote_arg(std::string_view arg) {
143+
// Characters that split/alter a word when unquoted in POSIX sh or
144+
// cmd.exe: whitespace plus the common shell metacharacters. Anything
145+
// NOT in this set (e.g. `-std=c++23`, `-O2`, `-I/abs/path`, `-DFOO=1`)
146+
// returns untouched — no quoting where none is needed.
147+
constexpr std::string_view kNeedsQuote = " \t\n\"'\\$`;&|<>()*?[]#~!{}";
148+
if (arg.find_first_of(kNeedsQuote) == std::string_view::npos)
149+
return std::string(arg);
150+
151+
if constexpr (mcpp::platform::is_windows) {
152+
// cmd.exe / CreateProcess argv convention: wrap in double quotes,
153+
// escape embedded `"` as `\"`.
154+
std::string out = "\"";
155+
for (char c : arg) {
156+
if (c == '"') out += "\\\"";
157+
else out.push_back(c);
158+
}
159+
out += "\"";
160+
return out;
161+
} else {
162+
// POSIX sh: wrap in single quotes (nothing is special inside single
163+
// quotes except `'` itself), escaping an embedded `'` as `'\''`
164+
// (close quote, literal quote, reopen quote).
165+
std::string out = "'";
166+
for (char c : arg) {
167+
if (c == '\'') out += "'\\''";
168+
else out.push_back(c);
169+
}
170+
out += "'";
171+
return out;
172+
}
173+
}
174+
126175
CompileFlags compute_flags(const BuildPlan& plan) {
127176
CompileFlags f;
128177

@@ -157,11 +206,28 @@ CompileFlags compute_flags(const BuildPlan& plan) {
157206
}
158207
std::string pic_flag = (need_pic && !isMsvcDialect) ? " -fPIC" : "";
159208

160-
// Include dirs
161-
std::string include_flags;
209+
// Include dirs — this is the TYPED PATH channel (bare paths from the
210+
// manifest; the dialect prefix is applied here at emission), not the
211+
// FLAG-STRING channel that `normalize_include_flags` serves (cflags/
212+
// cxxflags, where the -I/-iquote/... prefix is already embedded in the
213+
// string by the scanner). `normalize_include_flags`'s prefix table only
214+
// knows GNU spellings, so routing dialect-prefixed tokens through it
215+
// silently no-ops under MSVC (`/Iinclude` matches nothing and is never
216+
// rewritten against plan.projectRoot — but ninja runs with cwd = output
217+
// dir, so a relative include dir stops resolving). Absolutize the path
218+
// directly instead (dialect-agnostic), then prepend the prefix, then
219+
// ninja-$-escape and shell-quote per token (#234) so an include dir
220+
// whose name contains a space can't silently split into two shell words
221+
// once ninja hands the resolved command line to the shell.
222+
std::vector<std::string> includeTokens;
162223
for (auto& inc : plan.manifest.buildConfig.includeDirs) {
163-
auto abs = inc.is_absolute() ? inc : (plan.projectRoot / inc);
164-
include_flags += std::format(" {}{}", d.includePrefix, escape_path(abs));
224+
std::filesystem::path p = inc.has_root_path() ? inc : (plan.projectRoot / inc);
225+
includeTokens.push_back(std::string(d.includePrefix) + p.string());
226+
}
227+
std::string include_flags;
228+
for (auto& t : includeTokens) {
229+
include_flags += ' ';
230+
include_flags += shell_quote_arg(escape_path(std::filesystem::path(t)));
165231
}
166232

167233
// Sysroot / payload paths — resolved ONCE by the toolchain link model

src/build/ninja_backend.cppm

Lines changed: 105 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,33 @@ std::string local_include_flags(const CompileUnit& cu) {
9797
}
9898

9999
std::string join_flags(const std::vector<std::string>& flags) {
100+
// mcpp#234: a manifest `defines = ["T=long long"]` arrives as the single
101+
// element `-DT=long long` (pushed whole by apply_glob_flags) — a space
102+
// that is genuinely PART of one argv token. Joining with a bare space let
103+
// the shell split it into two words (`-DT=long` + `long`) once ninja
104+
// handed the command line to the shell, so a define value with a space
105+
// must be shell-quoted.
106+
//
107+
// But quote ONLY that case. Every OTHER flag element is passed through
108+
// verbatim, because two other kinds of element legitimately contain a
109+
// space that MUST stay a token boundary, and quoting them breaks the build:
110+
// • raw descriptor flags pack multiple argv tokens into one string
111+
// (compat.lua's `-include mcpp_lua_platform_config.h` — the space
112+
// separates `-include` from its argument; quoting made gcc see one
113+
// malformed arg → "No such file"; broke aarch64/macos/windows builds);
114+
// • mcpp-generated link flags are already shell-quoted + ninja-escaped
115+
// (`-Wl,-rpath,'$$ORIGIN'` — single quotes stop shell $-expansion,
116+
// `$$` is ninja's literal `$`); re-quoting baked a literal `'$ORIGIN'`
117+
// into RUNPATH so dependency .so's next to the exe couldn't resolve.
118+
// The `defines` channel is the ONLY producer of a `-D`/`/D`-prefixed token
119+
// whose space is intra-value, so gate the quoting on exactly that shape.
100120
std::string out;
101121
for (auto const& flag : flags) {
102122
out += ' ';
103-
out += flag;
123+
const bool defineWithSpace =
124+
(flag.starts_with("-D") || flag.starts_with("/D"))
125+
&& flag.find(' ') != std::string::npos;
126+
out += defineWithSpace ? shell_quote_arg(flag) : flag;
104127
}
105128
return out;
106129
}
@@ -396,6 +419,69 @@ std::string emit_ninja_string(const BuildPlan& plan) {
396419
auto append_deps = [&] {
397420
if (msvcDeps) append(" deps = msvc\n");
398421
};
422+
// mcpp#235: cxx_module/cxx_object had NO depfile at all on non-MSVC —
423+
// only the msvcDeps branch tracked header deps (via /showIncludes). So
424+
// editing a file #include'd inside a module's purview (or a plain
425+
// header pulled in by a .cpp) never invalidated the compile edge: the
426+
// P1689 scan already emits a `.dep`/`.d`-shaped list of textual
427+
// includes, but it was generated and discarded.
428+
//
429+
// Naively mirroring the nasm rule below (`-MD $out.d` + `deps = gcc` +
430+
// `depfile = $out.d`) does NOT work here: GCC's `-fmodules` bolts extra
431+
// "reversed" rules onto ANY -M*/-MF depfile for a TU that imports or
432+
// provides a module — e.g. for a module interface unit:
433+
// obj/m.m.o gcm.cache/m.gcm: src/m.cppm src/vals.inc
434+
// m.c++-module: gcm.cache/m.gcm
435+
// .PHONY: m.c++-module
436+
// gcm.cache/m.gcm:| obj/m.m.o
437+
// and for an importing TU:
438+
// obj/main.o: src/main.cpp gcm.cache/std.gcm gcm.cache/m.gcm
439+
// obj/main.o: m.c++-module std.c++-module
440+
// CXX_IMPORTS += m.c++-module std.c++-module
441+
// Those extra records describe a Make-style dependency graph WITHIN the
442+
// depfile itself (e.g. gcm.cache/m.gcm "having its own inputs"), which
443+
// collides with gcm.cache/m.gcm already being a declared ninja-graph
444+
// OUTPUT of this same edge (`| gcm.cache/m.gcm`) — ninja's depfile
445+
// loader rejects that outright ("inputs may not also have inputs"),
446+
// confirmed empirically (e2e 118 failed the fresh build this way before
447+
// the filter below was added). GCC has no flag to suppress this.
448+
//
449+
// Fix: compile to a scratch `$out.d.raw`, then keep only the FIRST
450+
// record (target + its indented continuation lines — the plain textual
451+
// #include graph, which is all #235 needs) as `$out.d`; module BMI
452+
// deps stay tracked independently via the existing per-edge `dyndep`
453+
// binding, so dropping the reversed/module lines here loses nothing.
454+
// POSIX-only (`awk`): native Windows has no POSIX shell/toolset here
455+
// (see the existing "Windows: skip BMI restat optimization" branch
456+
// below), so a non-MSVC Windows toolchain keeps the pre-#235
457+
// behavior (no depfile) rather than depend on an unavailable filter —
458+
// msvcDeps (cl.exe) is unaffected either way (deps=msvc, no -MMD).
459+
//
460+
// GCC-only: the awk filter strips the "reversed" make-rules that GCC's
461+
// `-fmodules -MMD` bolts onto a module-TU depfile (`gcm.cache/<m>.gcm: |
462+
// <obj>` etc., which ninja rejects as "inputs may not also have inputs").
463+
// Clang's module system (.pcm) does not emit those and its module-TU
464+
// depfile shape is different; applying the GCC-shaped filter there is
465+
// untested and could yield a wrong dep set. Until Clang is verified,
466+
// scope this to GCC — Clang keeps the pre-#235 behavior (no compile-edge
467+
// depfile; header/purview rebuild tracking on Clang is a follow-up, same
468+
// as it was on 0.0.96, so this is not a regression). e2e 118 declares
469+
// `# requires: gcc` and skips where GCC is not the toolchain.
470+
const bool posixDepfile = !msvcDeps && !mcpp::platform::is_windows
471+
&& plan.toolchain.compiler == mcpp::toolchain::CompilerId::GCC;
472+
const std::string mmd_flag = posixDepfile ? "-MMD -MF $out.d.raw " : "";
473+
const std::string mmd_filter = posixDepfile
474+
? " && awk 'NR==1{print;next} /^[^ ]/{exit} {print}' "
475+
"\"$out.d.raw\" > \"$out.d\" && rm -f \"$out.d.raw\""
476+
: "";
477+
auto append_cxx_deps = [&] {
478+
if (posixDepfile) {
479+
append(" deps = gcc\n");
480+
append(" depfile = $out.d\n");
481+
} else {
482+
append_deps();
483+
}
484+
};
399485
// cl.exe needs /TP (our module interfaces are .cppm, unknown to cl) and
400486
// /interface to treat the TU as a module interface unit.
401487
const std::string module_src_flags = msvcDeps ? " /interface /TP" : "";
@@ -405,31 +491,38 @@ std::string emit_ninja_string(const BuildPlan& plan) {
405491
append(std::format(" command = "
406492
"$cxx $local_includes $cxxflags $unit_cxxflags{}{} {}\n",
407493
module_output_flag, module_src_flags, compile_tail));
408-
append_deps();
494+
append_cxx_deps();
409495
} else {
410496
append(std::format(" command = "
411497
"if [ -n \"$bmi_out\" ] && [ -f \"$bmi_out\" ]; then "
412498
"cp -p \"$bmi_out\" \"$bmi_out.bak\"; "
413499
"fi && "
414-
"$cxx $local_includes $cxxflags $unit_cxxflags{} {} && "
500+
"$cxx $local_includes $cxxflags $unit_cxxflags{} {}{}{} && "
415501
"if [ -n \"$bmi_out\" ] && [ -f \"$bmi_out.bak\" ] && "
416502
"cmp -s \"$bmi_out\" \"$bmi_out.bak\"; then "
417503
"mv \"$bmi_out.bak\" \"$bmi_out\"; "
418504
"else "
419505
"rm -f \"$bmi_out.bak\"; "
420-
"fi\n", module_output_flag, compile_tail));
506+
"fi\n", module_output_flag, mmd_flag, compile_tail, mmd_filter));
507+
append_cxx_deps();
421508
}
422509
append(" description = MOD $out\n");
423510
if (dyndep)
424511
append(" restat = 1\n");
425512
append("\n");
426513

427514
append("rule cxx_object\n");
428-
append(std::format(
429-
" command = $cxx $local_includes $cxxflags $unit_cxxflags {}\n",
430-
compile_tail));
515+
if constexpr (mcpp::platform::is_windows) {
516+
append(std::format(
517+
" command = $cxx $local_includes $cxxflags $unit_cxxflags {}\n",
518+
compile_tail));
519+
} else {
520+
append(std::format(
521+
" command = $cxx $local_includes $cxxflags $unit_cxxflags {}{}{}\n",
522+
mmd_flag, compile_tail, mmd_filter));
523+
}
431524
append(" description = OBJ $out\n");
432-
append_deps();
525+
append_cxx_deps();
433526
if (dyndep)
434527
append(" restat = 1\n");
435528
append("\n");
@@ -606,9 +699,10 @@ std::string emit_ninja_string(const BuildPlan& plan) {
606699
// ── Phase 1: scan edges (one .ddi per TU). ──────────────────────
607700
// .ddi is placed beside the object so multi-version mangling can
608701
// namespace by package without producing two `build` rules with
609-
// the same `.ddi` output (plan.cppm switches `cu.object` from
610-
// `obj/<file>.o` to `obj/<pkg>/<file>.o` whenever a basename
611-
// collides across packages — `.ddi` follows that placement).
702+
// the same `.ddi` output (mcpp#233: plan.cppm switches `cu.object`
703+
// from the flat `obj/<file>.o` to a path mirroring the source's
704+
// relative directory under a sanitized-package prefix whenever a
705+
// basename collides — `.ddi` follows that placement).
612706
// Skip .c files: they have no `import`s and don't need P1689 scan;
613707
// running them through cxx_scan would route them through g++ /
614708
// -fmodules which is exactly what C support is here to avoid.

0 commit comments

Comments
 (0)