diff --git a/.agents/docs/2026-10-21-resource-ownership-module-scan-and-lock-write-design.md b/.agents/docs/2026-10-21-resource-ownership-module-scan-and-lock-write-design.md
new file mode 100644
index 00000000..5436bb43
--- /dev/null
+++ b/.agents/docs/2026-10-21-resource-ownership-module-scan-and-lock-write-design.md
@@ -0,0 +1,357 @@
+---
+subject: build
+status: active
+---
+
+# 依赖 image 的资源归属、模块扫描边界与锁文件稳定写入
+
+状态:设计已确认;实现位于 [PR #827](https://github.com/mcpp-community/mcpp/pull/827),跨平台 CI 验收中。
+下文保留设计决策,已完成的本地验证见第 11 节;发布完成以实际流水线和安装证据为准。
+
+日期:2026-10-21。分析基线:`cdc8daa7c3166188a28a338ce1cb66b3e39f0719`。
+
+关联:[mcpp#826](https://github.com/mcpp-community/mcpp/issues/826)、
+[mcpp#816](https://github.com/mcpp-community/mcpp/issues/816)、
+[mcpp#807](https://github.com/mcpp-community/mcpp/issues/807)。
+
+读者:评审和实现这批修复的维护者。本文裁定现有契约的缺陷、合理优化与能力扩展的边界,
+给出实现接点和验收条件。实现采用单一 PR,以连续 commit 作为 checkpoint;
+不引入统一三类子系统的新框架。
+
+## 1. 裁定依据与结论
+
+裁定顺序是现行规范、用户文档、设计记录、实现与实测。实现中的过滤条件不是行为正确的
+充分依据;合法 C++ 代码也不是默认扫描器承诺支持其全部发现方式的充分依据。
+设计记录中未落地的扩展不作为当前能力承诺。
+
+| 项目 | 分类 | 本次决定 |
+| --- | --- | --- |
+| #826:依赖自己的 EXE、DLL 丢失资源 | 现有资源所有权契约的实现缺陷 | 修复,资源按 image 的所属包规划 |
+| #816:include 携带的 import 未进入文本扫描图 | 默认文本扫描的设计边界,不是本例所声称的通用传递映射缺陷 | 不实现自动兼容;本 PR 补齐边界说明和受支持路径验证,合入后按 not planned 关闭 |
+| #807:emit 把自己写出的 host 锁文件列入 watch | SPEC-005 R6.4 的契约缺陷 | 修复 watch 的输入与输出边界 |
+| 内容相同的 mcpp.lock 仍被覆盖写入 | 公共写入层缺少幂等性 | 补充 write-if-changed,普通 build 与 host 子构建共用 |
+
+依据:
+
+- [SPEC-005 §2、§6](../../docs/specs/build-database.md):emit 不写工程目录,
+ 不写入 watch 列出的文件;inputs-fingerprint 描述输入内容。
+- [SPEC-004](../../docs/specs/manifest-semantics.md):声明按键的作用域生效,
+ 包的资源描述该包的产物。
+- [资源设计 A3、A5](2026-08-07-windows-resources-and-version-identity-design.md):
+ 资源进入所属包的 PE image,依赖资源不传播给消费者。
+- [扫描器设计 §2、§3-pre、§4](2026-07-08-scanner-backend-abstraction-design.md):
+ 默认保持文本扫描;发现不足时允许声明并由编译器审计;不做部分预处理求值。
+- [BMI 设计 B1 与被排除方案](2026-10-01-pack-drive-and-selection-independent-compile-design.md):
+ BMI 按提供包放置,模块按导入关系解析,避免目录搜索选中错误提供者。
+- [scan_overrides 用法(§2.8.3)](../../docs/zh/04-mcpp-toml.md):
+ 已有声明入口,不新增 manifest 键。
+
+## 2. 证据与验证边界
+
+源码检查基于上述提交。运行探针使用已安装的 mcpp `2026.10.19.1` 与 LLVM `23.1.3`,
+Linux x86_64;没有用该提交重新编译 mcpp,因此源码判断与发布二进制结果分开记录。
+
+| 项目 | 已验证内容 | 尚未验证内容 |
+| --- | --- | --- |
+| #826 | windows_resources.cpp 只收集根包或 selectedMember,且跳过 artifactOf 非空的 link unit | 本次没有在 Windows 提取修复前后的 PE 资源 |
+| #816 | 本地 path 模块依赖下,头文件内 import 失败,直接 import 成功;为源文件声明 imports 后成功 | 未验证所有编译器及可变宏配置 |
+| #807 | emit 的 watch 包含合成 host 的 mcpp.lock;公共 writer 无条件截断写入 | 本次 path 和 Git 简化探针没有生成 host 锁文件,未完成同内容 mtime 变化的运行复现 |
+
+#816 的编译器 .ddi 正确记录 `mylib`,但失败 TU 的编译参数没有 modmap。
+规划器看不到 include 内的 import,导致已有的传递闭包计算没有起点;不是所有传递导入都失效。
+
+## 3. R:按 image 所属包规划 Windows 资源
+
+### 3.1 保持的语义
+
+每个实际参与源码构建的包,其资源只进入它自己拥有的程序或共享库。
+依赖包是否被命令行直接选择,不影响它自己的 image 是否兑现自己的资源声明。
+artifactOf 表示交付关系,不表示该 image 不需要资源。
+
+资源不沿依赖边传播。不把静态依赖的 manifest、图标或版本块合并到消费者。
+不修改已交付的预编译 EXE、DLL。非 PE 目标仍不编译资源;声明文件的存在性校验
+保持现有跨目标规则。测试 image 保持现有规则:只接收显式请求的应用 manifest,
+不接收普通图标与版本资源。
+
+### 3.2 实现接点
+
+1. 在 `src/build/prepare/windows_resources.cpp` 中按包身份收集 subject:保留根包与
+ selected member 的现有校验,再纳入本计划实际源码构建且拥有程序或共享库 link unit 的依赖包。
+ 按限定包名去重,不遍历未参与本计划的 workspace 成员,不重新解析其他闭包。
+ subject 及声明文件校验不按 PE 设门;只有资源编译按 PE 设门,避免同一声明的有效性依赖宿主或目标。
+2. 使用 `LinkUnit::package` 与 subject 的限定包名精确匹配。普通构建的根包也有明确身份,
+ 不再以空 owner 表示“拥有所有 image”。移除以 shared target 名字猜测所属包的分支。
+3. artifact image 通过同一归属判断进入资源流程,移除无条件排除 artifactOf 的判断。
+ 已有 artifact 部署步骤复制最终链接产物,不增加部署后嵌入步骤。
+4. 资源相对路径、include 搜索与合成元数据全部读取所属包。资源输出按包隔离;
+ 同包多个 image 可复用作者脚本编译结果,合成 OriginalFilename 等信息仍按 image 生成。
+5. image 的 target 属性也从同一所属包解析,避免同名 target 导致 code page、long paths
+ 或 manifest 冲突检查使用另一个包的声明。
+ 冲突检查只考虑本计划实际生成的相关 image,不让未启用 target 的属性错误拒绝当前构建。
+6. 保持资源工具惰性解析、缺工具硬错误、资源输入跟踪和 no-image 诊断语义。
+ 依赖的 image 已存在时不得误报该依赖“没有 image”。
+
+不引入新的 owner 字段;`LinkUnit::package` 已提供所需身份。
+输出命名的改动限定在解决包间冲突所需范围,不附带全局目录重排。
+
+### 3.3 验收
+
+- 直接构建与 artifact 构建均能从 updater.exe 提取其声明的 manifest。
+- 普通依赖 DLL 携带自己的资源,消费者 EXE 保留自己的资源。
+- 不同包同名 shared target 的资源、manifest 与合成元数据不串用。
+- `.rc`、`.manifest`、icon 或 extra-inputs 改变后,相关资源与 image 正确增量重建。
+- 冷构建、依赖对象缓存命中以及 `-p`/`--workspace` 切换均保持归属正确。
+- 静态依赖不传播资源;非 PE 和测试 image 的现有规则不回归。
+
+在资源 E2E 与 workspace 资源测试基础上增加依赖 image 场景,检查 Ninja 链接输入和
+最终 PE 内容。Windows MSVC ABI 行与 MinGW 行分别验证,不用“构建退出 0”代替资源检查。
+
+## 4. M:#816 的处理边界
+
+### 4.1 不以探针可通过作为支持承诺
+
+本次裁定:不实现 #816 请求的自动兼容,不保留待定的运行时修复任务。
+默认文本扫描不展开 include,这与既有确定性和成本边界一致。issue 的复现把直接 import
+放在头文件中,超出该默认发现机制的范围;不是规划器对已知导入边遗漏传递遍历。
+本 PR 明确该边界并验证既有传递映射契约,合入后将 #816 按 not planned 关闭。
+
+此前探针通过直接 import 或 scan_overrides 构建成功,只证明当前实现存在这两条可执行路径。
+后者不自动成为 #816 的推荐 workaround,也不据此增加兼容测试来长期固定这项用法。
+scan_overrides 保持原有用途与声明审计契约,不承担绕过架构边界的专用修复责任。
+
+控制用例为:源文件中存在文本可见的直接 import,提供者与依赖关系符合公开接口及闭包规则,
+规划器应传递解析其可达模块。源码已有闭包遍历,同一探针把 import 放回源文件后构建通过,
+两者共同支持本次裁定。因此本方案没有模块映射运行时改动,也不列出“以后决定”的子任务。
+
+### 4.2 本批交付与排除事项
+
+本 PR 的 #816 交付固定为两项:
+
+1. 在默认扫描器的中英用户文档中明确:不展开 include;需要自动发现的 import 写在被扫描的
+ 源文件中。不新增头文件 import 兼容教程,不把 scan_overrides 描述成该 issue 的专用补丁。
+2. 增加或复用文本可见 import 的传递映射控制测试,覆盖“消费 TU → 本包模块 → 依赖模块”,
+ 检查模块映射与实际编译结果。按已有编译器 CI 行验证,确认该契约没有平台特例。
+
+本 PR 不改模块扫描和审计的运行时接受规则,不实施错误字符串识别或额外诊断补偿。
+测试或实现若与上述证据出现矛盾,视为当前 PR 验收失败,先解决矛盾再合入,
+不得用“后续处理 #816”通过验收。
+
+保留现有 scan_overrides 强制审计和可选全量审计行为,不默认收紧全部 TU 的接受规则,
+也不把 P1689 调试入口提升为跨编译器生产配置。PR 描述记录本次“不实现”的依据与交付结果。
+不使用 `Closes #816` 自动将其标成 completed;合入后显式以 not planned 原因关闭,
+这属于本次 PR 的完成清单,不是新的评估任务。
+
+### 4.3 不采纳的方案
+
+| 方案 | 排除依据 |
+| --- | --- |
+| 所有依赖 BMI 扁平化或增加平铺软链接 | 破坏按提供者隔离,同名模块冲突 |
+| 给所有依赖目录追加 BMI 搜索路径 | 旧 BMI 可能回答错误提供者,已有设计明确排除 |
+| 所有 TU 注入全图或整包闭包的 modmap | 引入不可达模块,破坏按 TU 导入关系及选择稳定性 |
+| 默认扫描递归展开 include 或部分求值预处理条件 | 改变文本扫描的成本与确定性边界 |
+| dyndep 发现额外 import 后直接修补规划图 | 引入规划后的第二条建图通道,扩大调度、缓存和数据库一致性负担,本方案不采用 |
+
+不将 #816 泛化为“传递模块不可用”,也不以某个平台偶然能从 BMI 内记录的路径找到依赖
+作为跨平台兼容承诺。关闭理由是本次已明确的设计边界,不是声称 issue 中的原始写法已被修好。
+
+## 5. L:公共锁文件内容相同则不写
+
+### 5.1 历史与语义
+
+已完成的稳定性修复包括 [#369](https://github.com/mcpp-community/mcpp/pull/369) 的
+build/test 内容一致、[#676](https://github.com/mcpp-community/mcpp/pull/676) 的跨平台哈希、
+[#743](https://github.com/mcpp-community/mcpp/pull/743) 的包身份去重。
+这些修复保证相同语义不会产生不必要的内容差异,没有保证文件不被重写。
+
+`src/pm/lock_io.cppm::write` 仍直接打开 ofstream。其覆盖写入实现自 2026 年 5 月迁入
+该模块后保留。本地可见历史中没有公共 writer 曾实现 write-if-changed 后又回退的证据。
+既有 E2E 844 的 cmp 只能证明内容相同,不能证明 mtime 未变化。
+
+本次只改变落盘行为。是否重新解析依赖、版本选择、部分 workspace 合并、devOnly 排除、
+`--locked` 断言及锁文件权威范围均保持原有语义;不增加“完全冻结依赖”的承诺。
+
+### 5.2 写入流程
+
+在公共 `mcpp::pm::write` 实现,`mcpp.lockfile` shim 和全部调用者共享,不只改 prepare 调用点。
+
+1. 将 Lockfile 序列化一次,形成待发布的完整字节。
+2. 二进制读取已有文件;字节相同则直接返回成功,不创建临时文件,不改 mtime。
+3. 比较时仅将 CRLF 视为 LF 等价的行结束形式;相同内容的旧 CRLF 文件保留原字节与 mtime。
+ 不解析 TOML 后比较,不忽略字段、注释或顺序,不清理其它空白。该规则对所有平台相同,
+ 避免为了新增优化先制造一次无意义改写。
+4. 文件不存在则创建;内容不同则沿用现有写入方式,检查打开、写入和关闭结果,返回路径明确的错误。
+ 继续采用现有文本输出约定,不在本批强制统一已有文件的物理行结束符。
+5. 已有文件读取失败不能被当作“内容相同”。区分不存在与其他 I/O 错误;返回带路径的错误。
+ writer 的失败不得在 prepare 中被 `(void)` 静默丢弃,相关调用者按现有诊断契约处理。
+
+本批撤回原方案附带的临时文件原子替换:该改动不是同内容不写的必要条件,还涉及符号链接、
+文件权限和 Windows 占用语义。保留现有文件对象行为,避免为一个幂等性优化增加平台依赖或文件框架。
+现有非原子更新与多进程并发合并的局限不在本批扩大为新保证。
+错误传播只覆盖现有 writer 的调用者,不重构包管理命令的事务模型。
+
+### 5.3 验收
+
+- 首次创建和内容更新输出正确;同内容再次写入保留原字节与 mtime,LF/CRLF 均覆盖。
+- 测试先将文件时间设为可区分的旧值,再写同内容,避免文件系统时间粒度造成假通过。
+- I/O 失败可见;同内容分支不要求写权限,不因只读但可读的已正确锁文件而失败。
+- 已有符号链接、权限与路径编码的行为不因优化改变;Windows 由真实平台测试验证。
+- 普通 build 以及强制进入规划路径的第二次 build 均保留未变化锁文件的 mtime。
+ 不能用整个构建的快速返回代替 writer 验证。
+- 原有 build/test 内容稳定、跨平台摘要、workspace 去重与 `--locked` 测试继续通过。
+
+writer 单测覆盖相同字节和错误路径;扩展 E2E 844 或增加专门 E2E,验证真实规划调用。
+跨平台运行包括 Windows,覆盖行结束符、只读文件与非 ASCII 路径差异。
+
+## 6. W:emit 的 watch 只报告外部输入
+
+### 6.1 边界修复
+
+`src/build/build_database.cppm::render` 当前对每个 member 无条件 watch
+`ctx.projectRoot / "mcpp.lock"`。buildPrograms member 的 projectRoot 是合成 host 目录,
+该锁文件由同一次规划产生,因此违反 SPEC-005 R6.4。
+
+按来源而非路径字符串过滤:真实工程/workspace 锁文件继续监视;合成 host 锁文件
+不作为外部输入。不要仅使用“路径在 MCPP_HOME 下”作为过滤条件,真实可编辑依赖也可能
+位于该目录中。
+
+同一 host 图的合成清单也是输出,随此次变更审计其 watch 来源并排除合成项。
+保持真实源码包 mcpp.toml、build.mcpp、声明输入与源文件 glob 的监视。
+引用真实的 path build dependency 时,该依赖的可编辑输入仍需出现在 watch 中。
+稳定存储与 Git 检出继续遵守 SPEC-005 R6.2,不因 host 图加入而扩大监视范围。
+
+过滤规则同时约束 inputs-fingerprint 的输入集合,不仅从最终 watch 数组删除字符串。
+生成目录可以被 emit 写入,工程目录仍不能写入;不得借此绕过根锁只读规则。
+
+### 6.2 与 write-if-changed 的关系
+
+L 与 W 各有独立责任。L 稳定同内容写入;W 保证“输出不进入输入集合”。
+只做 L 时,解析变化仍会写入被监视的输出,不满足 R6.4;只做 W 时,普通 build 仍可能
+无意义更新锁文件时间戳。语言服务器按内容去重可以防御重复事件,但不替代引擎契约修复。
+
+### 6.3 验收
+
+1. 使用版本型 build dependency,确认 host 锁文件真实存在且非空,再连续执行 emit。
+ path-only 夹具和被 host 图转换为 path 的 Git 夹具不足以覆盖本缺陷。
+2. watch 与 fingerprint 输入不包含合成 host 锁文件或合成清单,仍包含真实项目和
+ 可编辑 build dependency 的输入。
+3. 同输入重复 emit 的 fingerprint 稳定,工程锁文件内容与 mtime 不变。
+4. 修改真实依赖声明、build.mcpp 或声明输入后,相关 fingerprint 与模型按实际变化更新。
+5. 版本约束改变导致合成 host 锁变化时,它仍不进入 watch;不产生自触发反馈环。
+
+扩展 build-database 渲染测试与 E2E 688/913。现有 913 的 path dependency 不能作为
+“同内容 host 锁文件不重写”的证明。测试文件名按仓库编号约定选择,不预占 issue 编号。
+
+## 7. 单 PR 与 commit checkpoint
+
+用户明确要求本方案通过单 PR 实现,本节取代按问题分 PR 的原安排。
+同一分支上按以下顺序形成可审查的 commit;各 commit 连同对应测试构成 checkpoint,
+不先提交一个明知不能构建的实现,再在后续 commit 补齐必要接线。
+
+| Checkpoint | commit 内容 | 主要范围 | 完成条件 |
+| --- | --- | --- | --- |
+| C0 | 评审后的设计基线 | 本文件 | 裁定、范围和验收明确 |
+| C1 | L:锁文件幂等写入 | lock_io、现有 writer 调用者、单测与规划 E2E | 同内容不写,变化正确写入,错误可见 |
+| C2 | W:build-database 输入边界 | render 的输入来源、版本型 host 夹具 | 满足 R6.4,真实输入变化仍可观察 |
+| C3 | R:资源归属 | subject 与 image 匹配、PE 与跨目标 E2E | 依赖 image 兑现自身资源且不传播 |
+| C4 | M:边界裁定和整体收尾 | 可见 import 控制用例、默认扫描边界的双语说明、规范复核 | 无兼容补丁,受支持路径不回归,三个 issue 的关闭结论明确 |
+
+单 PR 覆盖三个 issue 的最终处置:#807、#826 修复完成;#816 明确不实现自动兼容。
+合入后的完成清单:
+
+- #807、#826:在修复及验收完成后以 completed 关闭,可由 PR 关闭关键字完成。
+- #816:显式以 not planned 关闭,命令为
+ `gh issue close 816 --repo mcpp-community/mcpp --reason "not planned"`,不附加评论。
+- 检查三个 issue 均已关闭,且关闭原因与本设计一致;只合入 PR、留下 #816 未处置不算完成。
+
+上述关闭动作在未来实际实现、验证和 PR 合入后执行,本次设计更新不提前关闭 issue。
+checkpoint 用于分支内审查、定位和必要时回退;按后续用户授权,最终以 squash 合入单 PR。
+checkpoint commit 保留在 PR 历史中。commit message 与 PR 标题按项目规范使用英文。
+
+实现前在本分支同步最新 main,不丢弃已评审设计。各 checkpoint 使用 fresh binary 验证其相关行为;
+最终提交再完成组合验证和 PR required checks。资源修复覆盖 Windows MSVC ABI 与 MinGW 行,
+锁文件与 emit 覆盖 Linux、macOS、Windows;工具链缺失明确记录,不以跳过作为通过。
+2026-10-21 实施授权:完成单 PR 实现、测试、CI、自审、squash 合入、release 和 xlings 生态验证。
+不在 issue 下发表评论;#816 合入后按 not planned 关闭。release 资产生成后即可本地 gtc 补传,
+不等待远程 mirror leg;校验下载字节与摘要后再推进索引与真实安装。
+
+用户文档只陈述已经实现的能力与限制,英文和简体中文同步;若实现改变 SPEC 的保证或
+输入定义,同 PR 更新对应规范。设计决策保留在本文件,落地后按文档规范追加状态记录。
+
+## 8. 多视角自审与修订结果
+
+| 视角 | 原方案风险 | 修订后的约束与判据 |
+| --- | --- | --- |
+| 规范符合性 | 把 #816 探针成功用法当成应支持能力,或一直保留待定结论 | #816 明确不实现自动兼容;本 PR 完成边界说明和控制测试,合入后按 not planned 关闭 |
+| 架构简洁 | 引入新的 owner、文件发布框架或扫描补偿通道 | 复用 LinkUnit::package、公共 writer 与 render 输入收集;不加 manifest 键和配置开关 |
+| 全局一致性 | 资源补丁只修 artifact,或 watch 只删最终字符串 | 所有本计划源码构建 image 按所属包处理;watch 与 fingerprint 共用输入来源判断 |
+| 跨平台 | PE gate 提前导致非 PE 不校验依赖声明;Windows 换行引发首次重写 | subject/校验与资源编译分离;LF/CRLF 仅作行结束等价,已有相同文件不重写 |
+| 稳定性 | 原子替换扩大到权限、符号链接与占用语义;默认全量审计改变接受范围 | 删除非必要原子发布,保持文件对象行为;不默认收紧扫描审计 |
+| 兼容性 | 以兼容头文件 import 为目标固定 workaround,或为所有 TU 注入模块 | 不承诺该用法;不修补规划图、不增加搜索目录、不改变 BMI 放置和模块可见性 |
+| 缓存与增量 | 只验证热构建或整个 build 快速返回 | 同时验证冷构建、强制规划、对象缓存命中、成员选择切换与真实资源重建 |
+| 可观测性 | 版本型 host 锁不存在也宣布复现;写入错误仍被忽略 | fixture 先断言非空锁存在;writer 调用错误可见;所有结论标注平台与证据范围 |
+| 变更控制 | 分 PR 与用户交付要求冲突 | 单 PR,C0—C4 连续 commit checkpoint,保留审查与回退边界 |
+
+全局组合验收至少包括:build → emit → build,build → test → build,以及 `-p` →
+`--workspace` → `-p`。仅由选择或命令顺序改变造成的锁内容抖动、资源串用和模块参数污染均不接受。
+这些序列分别验证其既有契约,不要求不同命令输出的整个数据库或测试依赖模型完全相同。
+
+资源缓存特别核对:资源计划仍在已有 P13 路径完成,不能因 C++ 对象命中缓存漏掉资源边;
+只读锁特别核对:相同内容不需写权限,实际内容变化仍按原写入语义报告失败。
+watch 特别核对:不以 buildPrograms 一刀切丢掉 host 图引用的真实可编辑包输入。
+
+文档属于待评审设计,以上是源码与设计层面的自审结论;跨平台运行验收留待实现阶段完成,
+不把本次文档检查表述为运行时验证通过。
+
+## 9. 评审决策点
+
+- 接受 R:资源归属使用现有 package 身份,覆盖依赖自己的 EXE/DLL,不传播静态依赖资源。
+- 接受 M:#816 不实现自动兼容;本 PR 完成双语边界说明与控制测试,合入后按 not planned 关闭。
+ 不将 override 成功探针升级为推荐 workaround 或支持承诺,不保留未定实现任务。
+- 接受 L:公共 writer 同内容不写,LF/CRLF 等价比较不改已有字节;保留现有更新方式与文件对象语义。
+- 接受 W:生成的 host 锁文件与清单不作为 watch 输入,保留可编辑 build dependency 的真实输入。
+- 接受单 PR、连续 commit checkpoint,最终 squash 合入;全量审计默认开启、自动预处理扫描、
+ 原子发布与并发锁文件事务均不纳入本批。
+
+## 10. 实施任务依赖与发布验收
+
+依赖关系:C0 → C1 → C2;C0 → C3;C0 → C4。所有实现与双语文档完成后,
+执行组合测试、跨平台 CI 与综合自审,再 squash 合入。版本 bump 与 CHANGELOG 在同 PR 完成,
+bootstrap pin 保持已发布值;发布后更新不是发布前 CI 的前提。
+
+发布依赖:合入且 CI 通过 → tag/release canaries → 四平台资产 → GitHub/GitCode 摘要验证
+→ 生态索引 PR 及传播 → 精确版本与 latest 的真实 xlings 安装和运行验证。
+资产可用后本地 gtc 上传与其余 release job 可并行;release CI 最终仍需全部成功。
+真实验证包括普通构建、重复规划锁文件 mtime、build-database 输入边界和可见 import 的模块链。
+最后核对 AUR、Homebrew、Nix、PyPI 与各宿主安装检查中实际启用的发布 job,记录可核验结果。
+
+## 11. 实现与本地自审记录
+
+候选二进制由本分支自举构建,报告版本 `2026.10.21.1`。完整 root 单测为 149 个测试目标,
+0 失败;锁文件测试包括 LF/CRLF 原字节与时间保留、只读文件、更新、错误路径及符号链接。
+
+| 验证 | 修复前证据 | 候选结果 |
+| --- | --- | --- |
+| E2E 844 强制重复规划 | 已发布二进制使未变锁文件的 mtime 改变,新增断言失败 | 通过 |
+| E2E 921 真实版本型 host 锁 | 已发布二进制的 watch 含合成 host 锁和清单,新增断言失败 | 通过;保留 editable 输入,修改后 fingerprint 变化 |
+| E2E 923 MinGW PE 资源 | 已发布二进制的依赖 DLL 缺少自身 manifest,新增断言失败 | 通过;依赖 EXE/DLL、同名 target、单包根、成员切换、manifest 重建与缺失输入校验 |
+| E2E 924 可见 import 控制 | 原分析探针的直接 import 路径成功 | 候选实际编译和执行成功,不增加隐藏 import 支持 |
+| E2E 01、688、913 | 现有命令与数据库契约 | 最终版本通过 |
+
+源码自审确认:资源归属不依赖选择、target 名称或 artifact 标记;资源编译与存在性校验分离。
+watch 与 fingerprint 在同一输入收集路径排除生成项;旧式项目目录内的版本包按解析来源过滤。
+锁文件的失败由现有调用者处理;不增加文件发布抽象,不改变符号链接对象及默认扫描器。
+两处版本值同步,bootstrap pin 保持已发布版本。新增测试在对应宿主分片或 cross job 有执行路径。
+
+本记录不将 Linux 本地结果外推为 Windows/macOS 验证;最终 PR head 的完整 CI、合入前综合自审、
+release 四平台与生态安装仍是后续完成门槛。
+
+### 11.1 CI 快照夹具与本地资源约束
+
+2026-10-21 的完整 CI 发现 upgrade snapshot 夹具沿用 `self env` 隐式创建 home 的旧假设;
+上一版已经使该命令只读,导致快照 tar 的目标目录为空、安装实际使用当前索引。
+本 PR 改为显式执行旧版 `self init`,并在索引目录、快照写入或 LLVM revision 0 前置条件
+不成立时立即失败。原有“旧载荷可继续使用、更新后报告新打包”的断言保留。
+
+本地执行按后续用户要求串行:MCPP_JOBS=2,并对重型命令施加 CPUQuota=200%、
+MemoryMax=8G、MemorySwapMax=0 与较低调度优先级;不同时运行大型 canary。
+跨平台和大型生态的整套测试优先使用 CI,避免本机叠加内存与 CPU 压力。
diff --git a/.agents/docs/README.md b/.agents/docs/README.md
index 0e2584b5..0f714175 100644
--- a/.agents/docs/README.md
+++ b/.agents/docs/README.md
@@ -18,12 +18,16 @@ superseded_by: 2026-09-07-....md # when status is superseded
---
```
-334 records.
+335 records.
## By subject
Records that declare one. Everything else is listed by date below.
+### build
+
+- [依赖 image 的资源归属、模块扫描边界与锁文件稳定写入](2026-10-21-resource-ownership-module-scan-and-lock-write-design.md) — active
+
### build-program
- [构建依赖的一条管线:配置与产物两轴、包的实例、库接口、兼容系统与工作空间收尾(#797)](2026-10-11-build-dependencies-one-pipeline-design.md) — active
@@ -134,6 +138,7 @@ Records that declare one. Everything else is listed by date below.
### 2026-10
+- [依赖 image 的资源归属、模块扫描边界与锁文件稳定写入](2026-10-21-resource-ownership-module-scan-and-lock-write-design.md) — active
- [首次初始化、home 重定向与网络诊断优化设计](2026-10-19-first-run-home-and-network-design.md) — landed
- [升级的连续性:打包修订不在使用路径上替换载荷、替换失败不拆掉可用状态、home 中的悬空记录自愈、真实的升级 lane](2026-10-17-upgrade-continuity-design.md) — active
- [mcpp 对 xlings 的依赖:Provider 端口、归属与位置、上游复用、系统级安装、payload 不可变、文件系统能力与长路径(#809)](2026-10-16-xlings-provider-port-and-shared-store-design.md) — active
diff --git a/.github/tools/upgrade_lane.sh b/.github/tools/upgrade_lane.sh
index 74f13315..fce1287f 100644
--- a/.github/tools/upgrade_lane.sh
+++ b/.github/tools/upgrade_lane.sh
@@ -103,15 +103,33 @@ new_project() { #
# ── the previous release builds the home ───────────────────────────────
run old-env "$W" "$OLD" self env || fail "the previous release does not start"
if [ "$VARIANT" = snapshot ]; then
+ # self env is read-only since 2026.10.19.1. Establish the old home
+ # explicitly before replacing its index; otherwise tar's empty -C argument
+ # leaves the live index in use and this lane never installs old packaging.
+ run old-init "$W" "$OLD" self init || { fail "the previous release cannot initialize its home"; exit 1; }
git clone -q --filter=blob:none https://github.com/openxlings/xim-pkgindex "$W/xpi" \
&& git -C "$W/xpi" checkout -q "$SNAPSHOT" || { echo "::error::cannot fetch the snapshot index"; exit 1; }
idx="$REG/data/xim-pkgindex"
[ -d "$idx" ] || idx=$(find "$MCPP_HOME" -type d -name xim-pkgindex | head -1)
+ [ -n "$idx" ] && [ -d "$idx/pkgs" ] \
+ || { fail "the previous release created no index directory"; exit 1; }
find "$idx" -mindepth 1 -maxdepth 1 ! -name .xlings-index-version -exec rm -rf {} +
- (cd "$W/xpi" && git archive HEAD) | tar x -C "$idx"
+ (cd "$W/xpi" && git archive HEAD) | tar x -C "$idx" \
+ || { fail "the snapshot index was not materialized"; exit 1; }
+ [ -f "$idx/pkgs/l/llvm.lua" ] \
+ || { fail "the snapshot has no LLVM descriptor"; exit 1; }
echo "llvm.lua revisions now: $(grep -o 'revision = [0-9]*' "$idx/pkgs/l/llvm.lua" | sort -u | tr '\n' ' ')"
fi
run old-tc "$W" "$OLD" toolchain install "$TC" || fail "the previous release cannot install $TC"
+if [ "$VARIANT" = snapshot ]; then
+ "$PY" - "$REG/data/xpkgs/xim-x-llvm/23.1.3/.xpkg-install.json" <<'PY' \
+ || { fail "the snapshot did not install LLVM revision zero"; exit 1; }
+import json, sys
+with open(sys.argv[1]) as f:
+ installed = json.load(f)
+assert installed.get("revision", 0) == 0, installed
+PY
+fi
[ "$VARIANT" = orphan ] && { run old-tc-gcc "$W" "$OLD" toolchain install gcc@16.1.0 || fail "the previous release cannot install gcc"; }
new_project p-old "$OLD"
run old-run "$W/p-old" "$OLD" run || fail "the previous release cannot run a new project"
diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml
index ef0dc1e9..8ae46e68 100644
--- a/.github/workflows/cross-build-test.yml
+++ b/.github/workflows/cross-build-test.yml
@@ -368,6 +368,7 @@ jobs:
run: |
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
bash tests/e2e/198_windows_resources_cross.sh
+ bash tests/e2e/923_cross_dependency_images_keep_their_resources.sh
# Packaging a Windows program FROM LINUX — and this job is the only
# place that can happen, for the same reason as the two above.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 235b49d5..703aa1f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,26 @@
> Each `## []` section is that release's notes. Entries are written in English
> from 2026.9.28.3 on; earlier entries remain as written.
+## [2026.10.21.1] - 2026-10-21
+
+### Fixed
+
+- Dependency-owned executable artifacts and shared libraries embed their own
+ Windows resources. Resource ownership follows package identity, including
+ targets with the same name in different packages; consumer resources no
+ longer enter dependency images.
+- Repeated planning preserves the contents and modification time of unchanged
+ lockfiles, including read-only files and either LF or CRLF line endings.
+ Lockfile read and write failures are reported to the caller.
+- Build-database watch lists and input fingerprints exclude synthesized host
+ manifests and lockfiles while retaining editable build-dependency inputs.
+
+### Documentation
+
+- Clarify that the default module scanner reads source files without expanding
+ includes. Visible imports retain their transitive module mappings; no new
+ implicit-import compatibility mode is introduced.
+
## [2026.10.19.1] - 2026-10-19
### Added
diff --git a/docs/04-mcpp-toml.md b/docs/04-mcpp-toml.md
index a3817342..23c1cffe 100644
--- a/docs/04-mcpp-toml.md
+++ b/docs/04-mcpp-toml.md
@@ -1399,6 +1399,10 @@ set `MCPP_VERIFY_MODGRAPH=1` when generating the build.
### 2.8.4 What The Default Scanner Reads
+The default scanner reads each source file without expanding `#include`. Imports
+that it discovers belong in the scanned source file itself. A header containing
+`import` does not add an edge to this default module graph.
+
The scanner answers two questions per file — what does this unit provide, and
what does it require — and nothing else decides them.
@@ -1778,6 +1782,11 @@ generates the resource script automatically.
**not** need (and cannot use) a `cfg(windows)` predicate — write it once,
unconditionally.
+Resources belong to the package that owns the executable or shared library.
+Dependency-owned executable artifacts and shared libraries embed their own
+resources, including when built through a consumer (2026.10.21.1+). Resources
+do not propagate from a static dependency to its consumer.
+
**The resource object is the target's.** rc.exe and llvm-rc produce a `.res`,
which carries no machine and is linked as it is. windres produces a COFF object,
which does: mcpp gives LLVM's windres the target triple and GNU windres the BFD
diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md
index 42cece9e..698e3427 100644
--- a/docs/05-dependencies.md
+++ b/docs/05-dependencies.md
@@ -83,6 +83,11 @@ package declares `[features]` but does not include the requested feature (includ
the result of backend desugaring), a warning is issued by default, and an error
under `mcpp build --strict`.
+An unchanged `mcpp.lock` retains its contents and modification time when a build
+records the same resolution (2026.10.21.1+). LF and CRLF line endings are accepted
+without rewriting an otherwise identical file. This does not skip dependency
+resolution or change which dependencies the lock pins.
+
**Git dependencies and `mcpp.lock`**: a `tag` or `rev` already names a fixed point
in history, but a `branch` moves. The first build resolves the branch to a commit
and records it in `mcpp.lock`, and every later build rebuilds **that** commit — the
diff --git a/docs/specs/build-database.md b/docs/specs/build-database.md
index a67f0e9c..de6d8866 100644
--- a/docs/specs/build-database.md
+++ b/docs/specs/build-database.md
@@ -4,10 +4,10 @@
|---|---|
| 规范编号 | SPEC-005 |
| 标题 | mcpp 输出的构建数据库:内容、取值规则与不写工程目录的保证 |
-| 状态 | 评审中 v1.8 |
-| 版本 | 1.8 |
-| 最后修改 | 2026-10-11 |
-| 对应实现 | mcpp >= 2026.9.15.1;v1.3 修改的 R2.5、R3.7、R3.8、R4.1、R5.2 为 mcpp >= 2026.9.26.2;v1.4 修改的 R2.5 为 mcpp >= 2026.9.27.1;v1.5 修改的 R3.7、R3.12、R5.1、R5.2 为 mcpp >= 2026.9.28.1;v1.6 修改的 R2.1、R3.3、R3.4、R3.5、R4.1、R5.2 为 mcpp >= 2026.9.29.5;v1.7 新增的 R3.8a 为 mcpp >= 2026.9.30.2;v1.8 新增的 R3.13 为 mcpp >= 2026.10.11.1 |
+| 状态 | 评审中 v1.9 |
+| 版本 | 1.9 |
+| 最后修改 | 2026-10-21 |
+| 对应实现 | mcpp >= 2026.9.15.1;v1.3 修改的 R2.5、R3.7、R3.8、R4.1、R5.2 为 mcpp >= 2026.9.26.2;v1.4 修改的 R2.5 为 mcpp >= 2026.9.27.1;v1.5 修改的 R3.7、R3.12、R5.1、R5.2 为 mcpp >= 2026.9.28.1;v1.6 修改的 R2.1、R3.3、R3.4、R3.5、R4.1、R5.2 为 mcpp >= 2026.9.29.5;v1.7 新增的 R3.8a 为 mcpp >= 2026.9.30.2;v1.8 新增的 R3.13 为 mcpp >= 2026.10.11.1;v1.9 明确的 R6.1a 为 mcpp >= 2026.10.21.1 |
| 相关设计文档 | `.agents/docs/2026-09-14-636-build-database-and-the-latest-xlings.md`
`.agents/docs/2026-09-26-compile-database-and-issue-699-design.md` |
| 相关 issue | #636, #648, #655, #699, #702, #707, #732, #797 |
| 依据的外部规范 | S1「C++ Build Database: IDE Profile」profile 0.3.0(§7.2 的 `generated`,Sunrisepeak/mcpp-language-server#28;此前为 0.2.0)与 S2 0.2.0 §3.4,取自 https://github.com/Sunrisepeak/lsp-mcpp-private 提交 `b82859d`(schema 自提交 `28ecd6e` 起未变);S2 0.3.0 §3.4 的部分回答(S2-3.4-12、S2-3.4-13,Sunrisepeak/mcpp-language-server#25);JSON Compilation Database |
@@ -239,6 +239,9 @@ mcpp 输出的 S1 文档满足 S1 等级 2,不输出 `ide.options`。等级 3
`tests/**/*.cpp`);构建程序声明的输入文件与 glob;`$MCPP_HOME/config.toml`。位于
工作区根之下的条目写成相对工作区根、以 `/` 分隔的路径或 glob;之外的条目写成绝对
路径,其 glob 展开为运行时匹配到的文件。**已实现**
+- **R6.1a** 合成构建程序图的根与成员清单、锁文件属于规划输出,**禁止**进入 `watch`
+ 与 `inputs-fingerprint` 的文件输入集合。该图引用的真实可编辑包的清单、源文件与声明输入
+ 仍按 R6.1 监视。**已实现**(mcpp >= 2026.10.21.1,#807)
- **R6.2** 不监视:环境变量,包括 `MCPP_TOOLCHAIN`、`MCPP_HOME` 与构建程序声明的
环境变量;存储中的包与 git 依赖的检出,它们的版本或提交写在已监视的清单与锁中。
**已实现**
@@ -261,3 +264,4 @@ mcpp 输出的 S1 文档满足 S1 等级 2,不输出 `ide.options`。等级 3
| 1.6 | 2026-09-29 | 工作区按配置规划,与 `mcpp build` 相同(R2.1、R3.3、R3.4、R5.2):成员共用的包在一个配置中只描述一次;集合名的前缀由 `<成员>/` 改为只在文档描述多个配置时出现的 `<配置>/`;一个配置的规划失败时逐成员规划。R3.5:被选成员的集合按其目标给出 `ide.kind`。R4.1:同一文件与输出一条条目。 |
| 1.7 | 2026-09-30 | 新增 R3.8a:同一配置中两个包提供同一个模块名时,两个单元都列出它,可能导入它的单元的 `arguments` 带有构建所用的绑定(#732)。 |
| 1.8 | 2026-10-11 | 新增 R3.13:构建程序、构建程序实例的库与引擎接口以 `<包>:build`、`<包>:host`、`mcpp:core`、`mcpp:std:host` 集合描述,`ide.configuration` 为 `build-program`,只进入 S1 文档(#797)。 |
+| 1.9 | 2026-10-21 | R6.1a 明确合成 host 清单与锁文件不作为 watch 或 fingerprint 输入,保留可编辑依赖输入(#807)。 |
diff --git a/docs/specs/manifest-semantics.md b/docs/specs/manifest-semantics.md
index 0ee48aec..04ed787c 100644
--- a/docs/specs/manifest-semantics.md
+++ b/docs/specs/manifest-semantics.md
@@ -5,8 +5,8 @@
| **规范编号** | SPEC-004 |
| **标题** | `mcpp.toml` 的平面划分、条件化形状、解析轴与命名规约 |
| **状态** | **草案(Draft)** |
-| **版本** | 1.15 |
-| **最后修改** | 2026-10-11 |
+| **版本** | 1.16 |
+| **最后修改** | 2026-10-21 |
| **最低实现版本** | 条件化形状:mcpp **2026.8.29.1**(`[target..build-dependencies]` 起齐备);目标轴:mcpp **2026.9.6.4** |
| **作者/维护** | mcpp-community |
| **相关设计文档** | `.agents/docs/2026-09-07-mcpp-toml-unified-semantics-design.md`
`.agents/docs/2026-06-04-manifest-schema-ownership.md`
`.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md`
`.agents/docs/2026-09-25-issue-690-workspace-build-inheritance-consistency.md`
`.agents/docs/2026-09-27-eight-reports-by-home-and-one-optimisation-plan.md`
`.agents/docs/2026-10-05-std-module-pair-msvc-lto-and-export-discovery-design.md`
`.agents/docs/2026-10-06-windows-x86-arch-vocabulary-and-rc-follow-ups-design.md`
`.agents/docs/2026-10-10-one-source-one-meaning-design.md`
`.agents/docs/2026-10-11-build-dependencies-one-pipeline-design.md` |
@@ -707,6 +707,18 @@ mcpp 2026.10.10.1,#785、#786;第 14 至 16 条与第 13 条的依赖位置为 m
**状态:已实现(mcpp 2026.10.11.1,#797)。**
+## 13. Windows 资源的归属
+
+1. `[resources]` **必须**只作用于声明包自己拥有的可执行程序与共享库,包括未被直接选择、
+ 通过 `artifacts` 构建的程序以及普通依赖共享库。image 的归属按限定包身份决定,
+ **禁止**按 target 名称推断或将根包视为所有 image 的拥有者。
+2. 依赖资源**禁止**传播到消费者 image。静态库没有自己的 PE image,其资源不自动合并进
+ 消费者。测试 image 只接收现有测试 manifest 规则所允许的内容。
+3. 声明文件的存在性校验与资源编译分开:校验对所有目标生效;编译只对 PE 目标生效。
+ 本次计划未生成的 target 不参与 image 的 manifest 冲突检查。
+
+**已实现**(mcpp >= 2026.10.21.1,#826)。
+
## 变更记录
| 版本 | 日期 | 变更 |
@@ -727,3 +739,4 @@ mcpp 2026.10.10.1,#785、#786;第 14 至 16 条与第 13 条的依赖位置为 m
| 1.13 | 2026-10-06 | mcpp 2026.10.5.3:§4.6 陈述架构段的等同拼写,`x86` 即 `i686`;`i386` 至 `i586` 不与 `i686` 相互替代。 |
| 1.14 | 2026-10-10 | mcpp 2026.10.10.1(#785、#786):§2 陈述键的作用域与来源;§9 新增第 12 条前缀即语义(工作空间层 `[workspace.X]`、按键合并、W7)与第 13 条按位置读取的兼容期;第 7 条发布写出工作空间层;第 8 条改为 `[workspace.xlings]`;第 10 条按求值后的配置分组。 |
| 1.15 | 2026-10-11 | mcpp 2026.10.11.1(#797):新增 §12 依赖的库与配置(两条轴、实例、一个包一个版本、按配置激活特性、`host-module` 的含义与默认值、构建程序图、构建插件、兼容读法);§9 第 13 条的移除版本改为 mcpp 2027.1,并补依赖位置;新增 §9 第 14 至 16 条(`[xlings.overrides]` 属于构建的根、`[workspace.dependencies]` 的继承、虚拟根旁的 `build.mcpp`)。 |
+| 1.16 | 2026-10-21 | 新增 §13,明确依赖 EXE、DLL 的资源只进入其所属包的 image,且按计划内目标校验 manifest 冲突(#826)。 |
diff --git a/docs/zh/04-mcpp-toml.md b/docs/zh/04-mcpp-toml.md
index 4605b5ea..1121e2ff 100644
--- a/docs/zh/04-mcpp-toml.md
+++ b/docs/zh/04-mcpp-toml.md
@@ -1305,6 +1305,9 @@ override),在生成构建时设置 `MCPP_VERIFY_MODGRAPH=1`。
### 2.8.4 默认扫描器读到的东西
+默认扫描器逐个读取源文件,不展开 `#include`。需要由它发现的 import 写在被扫描的
+源文件本身;头文件中的 `import` 不为默认模块图增加导入边。
+
扫描器为每个文件回答两个问题——这个单元提供什么、这个单元需要
什么——没有别的东西决定这两个答案。
@@ -1663,6 +1666,9 @@ icon = "assets/app.ico"
没有资源单元、没有诊断、构建逐字节相同。**不需要**(也不能用)一个
`cfg(windows)` 谓词——写一次,无条件生效即可。
+资源属于拥有可执行程序或共享库的包。依赖的 executable artifact 与共享库在通过
+消费者构建时也嵌入自己的资源(2026.10.21.1+)。静态依赖的资源不传播给消费者。
+
**资源对象属于目标。** rc.exe 与 llvm-rc 产出 `.res`,它不带机器类型,
按原样参与链接。windres 产出 COFF 对象,它带机器类型:mcpp 交给 LLVM 的
windres 目标三元组,交给 GNU windres BFD 格式名(`pe-i386`、`pe-x86-64`),
diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md
index c28d4e80..0c493944 100644
--- a/docs/zh/05-dependencies.md
+++ b/docs/zh/05-dependencies.md
@@ -78,6 +78,9 @@ widget = { version = "1.0", backend = "glfw_opengl3" } # Sugar for: features=["
一族 `backend-*`)。若目标包声明了 `[features]` 但不含所请求的 feature
(包括 backend 脱糖的结果),默认给出 warning,`mcpp build --strict` 下报错。
+构建记录相同的解析结果时,`mcpp.lock` 保留原内容与修改时间(2026.10.21.1+)。
+仅 LF 与 CRLF 行结束形式不同不会触发改写。这不跳过依赖解析,也不改变锁定范围。
+
**Git 依赖与 `mcpp.lock`**:`tag` 和 `rev` 已经指向历史中的一个固定点,而
`branch` 是会移动的。首次构建把分支解析成一个 commit 并写进 `mcpp.lock`,
此后每次构建都重新构建**那个** commit —— lock 是权威来源而不是缓存提示,
diff --git a/mcpp.toml b/mcpp.toml
index f25c9df9..122b836b 100644
--- a/mcpp.toml
+++ b/mcpp.toml
@@ -1,6 +1,6 @@
[package]
name = "mcpp"
-version = "2026.10.19.1"
+version = "2026.10.21.1"
description = "Modern C++ build & package management tool"
license = "Apache-2.0"
authors = ["mcpp-community"]
diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm
index d7e6e36d..b92c656d 100644
--- a/modules/versioning/src/version.cppm
+++ b/modules/versioning/src/version.cppm
@@ -31,6 +31,6 @@ import std;
export namespace mcpp {
-inline constexpr std::string_view MCPP_VERSION = "2026.10.19.1";
+inline constexpr std::string_view MCPP_VERSION = "2026.10.21.1";
} // namespace mcpp
diff --git a/src/build/build_database.cppm b/src/build/build_database.cppm
index 5ee0132d..a3440149 100644
--- a/src/build/build_database.cppm
+++ b/src/build/build_database.cppm
@@ -588,8 +588,12 @@ Rendered render(std::span members,
sets.push_back(std::move(setJson));
}
- watch_file(ctx.projectRoot / "mcpp.lock");
+ // A host graph's root and members are manifests we synthesized, not
+ // editable inputs. Watching their lock makes emit trigger itself (#807).
+ // Keep the real path libraries of that graph: their sources can change.
+ if (!member.buildPrograms) watch_file(ctx.projectRoot / "mcpp.lock");
for (auto const& sp : ctx.sourcePackages) {
+ if (member.buildPrograms && relative_to(sp.root, ctx.projectRoot)) continue;
watch_file(sp.root / "mcpp.toml");
std::error_code ec;
if (std::filesystem::exists(sp.root / "build.mcpp", ec))
diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp
index 474ff3f8..ad82c10e 100644
--- a/src/build/prepare/plan.cpp
+++ b/src/build/prepare/plan.cpp
@@ -164,7 +164,11 @@ static std::expected step13_source_packages(PrepareState& sta
[&](const BuildContext::SourcePackage& sp) {
return sp.root.lexically_normal() == normalized;
});
- if (!known)
+ // Legacy project-local index payloads can lie outside storeRoots.
+ // Their version, not an editable source tree, is the input (#807).
+ const bool versionPayload = i > 0 && i - 1 < state.dep_cache_identities.size()
+ && state.dep_cache_identities[i - 1].sourceKind == "version";
+ if (!known && !versionPayload)
ctx.sourcePackages.push_back({qualified(state.packages[i].manifest),
normalized,
state.packages[i].manifest.modules.sources});
diff --git a/src/build/prepare/records.cpp b/src/build/prepare/records.cpp
index ee6942de..6c05f468 100644
--- a/src/build/prepare/records.cpp
+++ b/src/build/prepare/records.cpp
@@ -312,7 +312,8 @@ std::expected step13_lockfile(PrepareState& state, BuildConte
return std::unexpected(msg);
}
}
- (void)mcpp::lockfile::write(lock, lockPath);
+ if (auto written = mcpp::lockfile::write(lock, lockPath); !written)
+ return std::unexpected(written.error().message);
}
// Same data, second consumer: the "Compiling v" banner.
diff --git a/src/build/prepare/windows_resources.cpp b/src/build/prepare/windows_resources.cpp
index 8fada107..098fde0d 100644
--- a/src/build/prepare/windows_resources.cpp
+++ b/src/build/prepare/windows_resources.cpp
@@ -143,11 +143,9 @@ std::expected step13_windows_resources(PrepareState& state, B
// ─── Windows resources: [resources] → a tracked link input (mcpp#365) ──
//
// Four rules, in this order:
- // 1. Only the [resources] of the package being built is read: the root,
- // or in a workspace plan each selected member, whose resources reach
- // that member's images only. A dependency's version resource would
- // fight its consumer's for ordinal 1, and a dependency that produces
- // no PE image of its own has nothing to embed into.
+ // 1. Resources belong to the package that owns an image, including a
+ // dependency's shared library or executable artifact (#826). They
+ // never propagate to a consumer or a different package's image.
// 2. A DECLARED FILE THAT DOES NOT EXIST IS AN ERROR — on EVERY target.
// Whether a path exists is a fact about the working tree, not about
// the target; gating it on is_pe() meant a Linux or macOS CI could not
@@ -173,22 +171,27 @@ std::expected step13_windows_resources(PrepareState& state, B
// A DECLARED `utf-8` beside such a manifest is refused below instead.
//
// A SUBJECT is one package whose resources are planned: its manifest, the
- // directory its paths were written in, and the images it owns. Outside a
- // workspace plan the root is the only subject and owns every image that
- // is not a dependency's program, which is the historical rule.
+ // directory its paths were written in, and the images it owns. Selection
+ // decides the graph, not the ownership of an image within that graph.
struct Subject {
const mcpp::manifest::Manifest* m;
std::filesystem::path dir;
- std::string owner; // empty: the root
+ std::string owner; // qualified package identity
};
std::vector subjects;
- if (!state.workspacePlan()) {
- subjects.push_back({&*state.m, *state.root, {}});
- } else {
- for (auto const& pkg : state.packages)
- if (pkg.selectedMember)
- subjects.push_back({&pkg.manifest, pkg.root,
- mcpp::build::qualified_package_name(pkg.manifest)});
+ std::set seenSubjects;
+ auto add_subject = [&](const auto& manifest, const auto& root) {
+ auto owner = mcpp::build::qualified_package_name(manifest);
+ if (seenSubjects.insert(owner).second)
+ subjects.push_back({&manifest, root, std::move(owner)});
+ };
+ if (!state.workspacePlan()) add_subject(*state.m, *state.root);
+ for (auto const& pkg : state.packages) {
+ const auto owner = mcpp::build::qualified_package_name(pkg.manifest);
+ const bool ownsImage = std::ranges::any_of(ctx.plan.linkUnits, [&](const auto& lu) {
+ return lu.package == owner && (lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::SharedLibrary);
+ });
+ if (pkg.selectedMember || ownsImage) add_subject(pkg.manifest, pkg.root);
}
const bool hostToolBuild = state.overrides.tool_depth > 0;
const auto trip = mcpp::toolchain::triple::parse(state.tc->targetTriple)
@@ -198,6 +201,13 @@ std::expected step13_windows_resources(PrepareState& state, B
for (auto const& S : subjects) {
const auto& M = *S.m;
+ auto owns = [&](const LinkUnit& lu) { return lu.package == S.owner; };
+ auto active_target = [&](const mcpp::manifest::Target& t) {
+ return std::ranges::any_of(ctx.plan.linkUnits, [&](const auto& lu) {
+ return owns(lu) && lu.targetName == t.name
+ && (lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary);
+ });
+ };
const bool ownManifest = hostToolBuild
&& std::ranges::any_of(M.resources.files, [&](const auto& f) {
const auto abs = (f.is_absolute() ? f : (S.dir / f)).lexically_normal();
@@ -226,12 +236,12 @@ std::expected step13_windows_resources(PrepareState& state, B
return codePageOf(t) == "utf-8" || longPathsOf(t);
};
const bool anyUtf8Image = std::ranges::any_of(M.targets, [&](const auto& t) {
- return takesCodePage(t) && manifested(t);
+ return active_target(t) && takesCodePage(t) && manifested(t);
});
// Author scripts reach programs and shared libraries, never a test
// program, so only a program's manifest can collide with one.
const bool anyUtf8Program = std::ranges::any_of(M.targets, [&](const auto& t) {
- return t.is_program() && manifested(t);
+ return active_target(t) && t.is_program() && manifested(t);
});
if (M.resources.declared() || anyUtf8Image) {
namespace rsrc = mcpp::build::resources;
@@ -283,7 +293,9 @@ std::expected step13_windows_resources(PrepareState& state, B
const std::string_view outExt = msvcStyle ? ".res" : ".o";
// A member's resources are compiled in a directory of its own, so
// two members' scripts and synthesised scripts never share a name.
- const auto resRel = S.owner.empty() ? std::filesystem::path("res")
+ const bool rootSubject = !state.workspacePlan()
+ && S.owner == mcpp::build::qualified_package_name(*state.m);
+ const auto resRel = rootSubject ? std::filesystem::path("res")
: std::filesystem::path("res") / S.owner;
const auto resDir = ctx.plan.outputDir / resRel;
// Where the resource compiler looks for a script's includes and
@@ -303,21 +315,9 @@ std::expected step13_windows_resources(PrepareState& state, B
// call, for the opposite reason — see its note above.)
// In a workspace plan a member's images are its link units and
// its shared libraries, which are linked with the graph's.
- auto owns = [&](const mcpp::build::LinkUnit& lu) {
- if (S.owner.empty()) return true;
- if (lu.memberOf == S.owner) return true;
- return lu.kind == mcpp::build::LinkUnit::SharedLibrary
- && std::ranges::any_of(M.targets, [&](const auto& t) {
- return t.kind == mcpp::manifest::Target::SharedLibrary
- && t.name == lu.targetName;
- });
- };
std::vector peUnits;
for (std::size_t i = 0; i < ctx.plan.linkUnits.size(); ++i) {
auto k = ctx.plan.linkUnits[i].kind;
- // A dependency's program (mcpp#711) carries its own package's
- // identity, not this one's.
- if (!ctx.plan.linkUnits[i].artifactOf.empty()) continue;
if (!owns(ctx.plan.linkUnits[i])) continue;
if (k == mcpp::build::LinkUnit::Binary ||
k == mcpp::build::LinkUnit::SharedLibrary)
@@ -431,7 +431,7 @@ std::expected step13_windows_resources(PrepareState& state, B
auto targetOf = [&](const mcpp::build::LinkUnit& lu) -> const mcpp::manifest::Target* {
if (lu.kind != mcpp::build::LinkUnit::Binary
&& lu.kind != mcpp::build::LinkUnit::TestBinary) return nullptr;
- if (!lu.artifactOf.empty()) return nullptr;
+ if (!owns(lu)) return nullptr;
for (auto const& t : M.targets)
if (t.name == lu.targetName && takesCodePage(t)) return &t;
return nullptr;
diff --git a/src/pm/commands.cppm b/src/pm/commands.cppm
index 413eb479..e71f5f0f 100644
--- a/src/pm/commands.cppm
+++ b/src/pm/commands.cppm
@@ -539,7 +539,10 @@ inline int cmd_remove(const mcpplibs::cmdline::ParsedArgs& parsed) {
if (auto lock = mcpp::lockfile::load(lockPath); lock) {
std::erase_if(lock->packages,
[&](const auto& p) { return p.name == canonicalSelector; });
- (void)mcpp::lockfile::write(*lock, lockPath);
+ if (auto written = mcpp::lockfile::write(*lock, lockPath); !written) {
+ mcpp::ui::error(written.error().message);
+ return 1;
+ }
}
}
return 0;
@@ -601,7 +604,10 @@ inline int cmd_update(const mcpplibs::cmdline::ParsedArgs& parsed) {
if (lock) {
std::erase_if(lock->packages,
[&](const auto& p) { return p.name == *only; });
- (void)mcpp::lockfile::write(*lock, lockPath);
+ if (auto written = mcpp::lockfile::write(*lock, lockPath); !written) {
+ mcpp::ui::error(written.error().message);
+ return 1;
+ }
}
}
mcpp::ui::status("Updating", std::format("{} in mcpp.lock", *only));
diff --git a/src/pm/lock_io.cppm b/src/pm/lock_io.cppm
index d1f0f568..fafb3e5b 100644
--- a/src/pm/lock_io.cppm
+++ b/src/pm/lock_io.cppm
@@ -188,11 +188,36 @@ std::string serialize(const Lockfile& lock) {
}
std::expected write(const Lockfile& lock, const std::filesystem::path& path) {
+ const auto text = serialize(lock);
std::error_code ec;
- std::filesystem::create_directories(path.parent_path(), ec);
+ const bool exists = std::filesystem::exists(path, ec);
+ if (ec) return std::unexpected(LockError{std::format("cannot inspect '{}'", path.string())});
+ if (exists) {
+ // Reading is enough for an unchanged lock, including a read-only one.
+ // Accept either line ending without rewriting a checkout on another host.
+ std::ifstream in(path, std::ios::binary);
+ if (!in) return std::unexpected(LockError{std::format("cannot read '{}'", path.string())});
+ std::string prior;
+ for (char c; in.get(c); ) prior.push_back(c);
+ if (in.bad() || !in.eof())
+ return std::unexpected(LockError{std::format("cannot read '{}'", path.string())});
+ std::string normalized;
+ normalized.reserve(prior.size());
+ for (std::size_t i = 0; i < prior.size(); ++i) {
+ if (prior[i] == '\r' && i + 1 < prior.size() && prior[i + 1] == '\n') continue;
+ normalized.push_back(prior[i]);
+ }
+ if (normalized == text) return {};
+ }
+ if (!path.parent_path().empty()) {
+ std::filesystem::create_directories(path.parent_path(), ec);
+ if (ec) return std::unexpected(LockError{std::format("cannot create directory for '{}'", path.string())});
+ }
std::ofstream os(path);
if (!os) return std::unexpected(LockError{std::format("cannot write '{}'", path.string())});
- os << serialize(lock);
+ os << text;
+ os.close();
+ if (!os) return std::unexpected(LockError{std::format("cannot write '{}'", path.string())});
return {};
}
diff --git a/tests/e2e/844_a_planned_build_keeps_one_lock_entry_per_identity.sh b/tests/e2e/844_a_planned_build_keeps_one_lock_entry_per_identity.sh
index bb006b8d..e0a58113 100644
--- a/tests/e2e/844_a_planned_build_keeps_one_lock_entry_per_identity.sh
+++ b/tests/e2e/844_a_planned_build_keeps_one_lock_entry_per_identity.sh
@@ -161,10 +161,19 @@ cmp -s lock.before mcpp.lock || {
fail "(b) the surviving entry is not the one 2026.9.28.3 wrote"
}
-# (c) another planned build leaves it byte-identical.
+# (c) another planned build leaves bytes AND mtime unchanged (#807).
+python3 - <<'PYTIME'
+import os
+os.utime("mcpp.lock", ns=(1000000000000000000, 1000000000000000000))
+open("lock.mtime", "w").write(str(os.stat("mcpp.lock").st_mtime_ns))
+PYTIME
echo "// touched again" >> src/main.cpp
"$MCPP" build > build3.log 2>&1 || fail "the third planned build did not succeed" build3.log
cmp -s lock.before mcpp.lock || fail "(c) a repeated planned build changed the lock" mcpp.lock
+python3 - <<'PYTIME' || fail "(c) a repeated planned build rewrote an unchanged lock"
+import os
+assert os.stat("mcpp.lock").st_mtime_ns == int(open("lock.mtime").read())
+PYTIME
# --locked reads the same identities.
echo "// and again" >> src/main.cpp
diff --git a/tests/e2e/921_emit_watches_inputs_not_host_outputs.sh b/tests/e2e/921_emit_watches_inputs_not_host_outputs.sh
new file mode 100755
index 00000000..e563bbbb
--- /dev/null
+++ b/tests/e2e/921_emit_watches_inputs_not_host_outputs.sh
@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+# requires: python3
+# #807: a version build dependency produces a real host lock; path-only
+# libraries still contribute editable inputs, never synthesized host files.
+set -e
+source "$(dirname "$0")/_host_path.sh"
+TMP=$(mktemp -d)
+trap 'rm -rf "$TMP"' EXIT
+fail() { echo "FAIL: $1"; shift; for f in "$@"; do cat "$f" 2>/dev/null; done; exit 1; }
+export MCPP_HOME="$TMP/home"
+source "$(dirname "$0")/_inherit_toolchain.sh"
+mkdir -p "$TMP/app/src" "$TMP/editable/src" "$TMP/index/pkgs/f"
+INDEX_HOST=$(host_path "$TMP/index")
+cat > "$TMP/index/pkgs/f/fixture.lockdep.lua" <<'LUA'
+package = {
+ spec = "1", namespace = "fixture", name = "fixture.lockdep",
+ description = "Host lock fixture", licenses = {"MIT"}, type = "package",
+ xpm = {
+ linux = {["1.0.0"] = {url = "https://example.invalid/lockdep.tar.gz", sha256 = "0000000000000000000000000000000000000000000000000000000000000000"}},
+ windows = {["1.0.0"] = {url = "https://example.invalid/lockdep.zip", sha256 = "0000000000000000000000000000000000000000000000000000000000000000"}},
+ macosx = {["1.0.0"] = {url = "https://example.invalid/lockdep.tar.gz", sha256 = "0000000000000000000000000000000000000000000000000000000000000000"}},
+ },
+ mcpp = { language = "c++23", import_std = false,
+ sources = {"src/dep.cpp"}, targets = {lockdep = {kind = "lib"}}, deps = {} },
+}
+LUA
+# Seed both the project's legacy layout and the home-owned store; the host
+# graph resolves the same version under its own working directory.
+for base in "$TMP/app/.mcpp/.xlings/data/xpkgs" "$MCPP_HOME/xpkgs"; do
+ mkdir -p "$base/fixture-x-lockdep/1.0.0/src"
+ printf 'int lockdep_value() { return 42; }\n' > "$base/fixture-x-lockdep/1.0.0/src/dep.cpp"
+done
+printf '[package]\nname = "editable"\nversion = "0.1.0"\n[build]\nsources = ["src/*.cpp"]\n[targets.editable]\nkind = "lib"\n' > "$TMP/editable/mcpp.toml"
+printf 'int editable_value() { return 7; }\n' > "$TMP/editable/src/lib.cpp"
+cat > "$TMP/app/mcpp.toml" < "$TMP/app/src/main.cpp"
+printf '#include \nint main() { std::puts("cxxflag=-DHOST_PROBE=1"); }\n' > "$TMP/app/build.mcpp"
+cd "$TMP/app"
+"$MCPP" emit build-database --format json > first.json 2> first.err || fail "first emit" first.err
+python3 - "$MCPP_HOME" <<'PY' || fail "host fixture must produce a nonempty lock"
+import pathlib, sys, os, json
+locks = list(pathlib.Path(sys.argv[1]).glob('cache/build-database/*/target/.build-mcpp/host/*/mcpp.lock'))
+assert locks and all('[package.' in p.read_text() for p in locks), locks
+snapshot = {}
+for p in locks:
+ os.utime(p, ns=(1000000000000000000, 1000000000000000000))
+ snapshot[str(p)] = [p.stat().st_mtime_ns, p.read_text()]
+pathlib.Path('snapshot.json').write_text(json.dumps(snapshot))
+PY
+"$MCPP" emit build-database --format json > second.json 2> second.err || fail "second emit" second.err
+python3 - <<'PY' || fail "watch or host lock stability"
+import json, pathlib
+first, second = (json.load(open(p))['data'] for p in ('first.json', 'second.json'))
+assert first['inputs-fingerprint'] == second['inputs-fingerprint']
+watch = [p.replace('\\', '/') for p in second['watch']]
+assert 'mcpp.lock' in watch and 'mcpp.toml' in watch and 'build.mcpp' in watch, watch
+assert any(p.endswith('editable/mcpp.toml') for p in watch), watch
+assert any(p.endswith('editable/src/lib.cpp') for p in watch), watch
+assert not any('/.build-mcpp/host/' in p for p in watch), watch
+assert not any('fixture-x-lockdep/' in p for p in watch), watch
+for p, (mtime, content) in json.load(open('snapshot.json')).items():
+ f = pathlib.Path(p)
+ assert f.stat().st_mtime_ns == mtime and f.read_text() == content, p
+PY
+printf '\n// editable input changed\n' >> "$TMP/editable/src/lib.cpp"
+"$MCPP" emit build-database --format json > third.json 2> third.err || fail "changed editable input" third.err
+python3 - <<'PY' || fail "editable host input did not invalidate fingerprint"
+import json
+assert json.load(open('second.json'))['data']['inputs-fingerprint'] != json.load(open('third.json'))['data']['inputs-fingerprint']
+PY
+echo 'PASS: 921 emit watches real inputs; repeated host lock write is stable'
diff --git a/tests/e2e/922_dependency_images_keep_their_resources.sh b/tests/e2e/922_dependency_images_keep_their_resources.sh
new file mode 100755
index 00000000..a81f1360
--- /dev/null
+++ b/tests/e2e/922_dependency_images_keep_their_resources.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+# requires: windows python3
+RESOURCE_TARGET=''
+source "$(dirname "$0")/_dependency_resources_body.sh"
diff --git a/tests/e2e/923_cross_dependency_images_keep_their_resources.sh b/tests/e2e/923_cross_dependency_images_keep_their_resources.sh
new file mode 100755
index 00000000..b2c53b16
--- /dev/null
+++ b/tests/e2e/923_cross_dependency_images_keep_their_resources.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+# requires: mingw-cross python3
+RESOURCE_TARGET='--target x86_64-windows-gnu'
+source "$(dirname "$0")/_dependency_resources_body.sh"
diff --git a/tests/e2e/924_visible_imports_resolve_transitive_modules.sh b/tests/e2e/924_visible_imports_resolve_transitive_modules.sh
new file mode 100755
index 00000000..19bfc3d9
--- /dev/null
+++ b/tests/e2e/924_visible_imports_resolve_transitive_modules.sh
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+# requires: python3
+# #816 control: visible direct imports already determine a transitive module
+# map. No include expansion, hidden import or compatibility fallback is used.
+set -e
+TMP=$(mktemp -d)
+trap 'rm -rf "$TMP"' EXIT
+export MCPP_HOME="$TMP/home"
+source "$(dirname "$0")/_inherit_toolchain.sh"
+fail() { echo "FAIL: $1"; shift; for f in "$@"; do cat "$f" 2>/dev/null; done; exit 1; }
+mkdir -p "$TMP/probe/src" "$TMP/dependency/src"
+cat > "$TMP/dependency/mcpp.toml" <<'TOML'
+[package]
+name = "dependency"
+version = "0.1.0"
+[language]
+standard = "c++23"
+modules = true
+import_std = false
+[modules]
+sources = ["src/*.cppm"]
+exports = ["dependency"]
+[targets.dependency]
+kind = "lib"
+TOML
+printf 'export module dependency;\nexport int dep_value() { return 42; }\n' > "$TMP/dependency/src/dependency.cppm"
+cat > "$TMP/probe/mcpp.toml" <<'TOML'
+[package]
+name = "probe"
+version = "0.1.0"
+[language]
+standard = "c++23"
+modules = true
+import_std = false
+[modules]
+sources = ["src/*.cppm", "src/*.cpp"]
+exports = ["probe"]
+[dependencies]
+dependency = {path = "../dependency"}
+[targets.probe]
+kind = "bin"
+main = "src/main.cpp"
+TOML
+printf 'export module probe;\nimport dependency;\nexport int value() { return dep_value(); }\n' > "$TMP/probe/src/probe.cppm"
+printf 'import probe;\nint main() { return value() == 42 ? 0 : 1; }\n' > "$TMP/probe/src/main.cpp"
+cd "$TMP/probe"
+"$MCPP" build > first.log 2>&1 || fail 'visible transitive module build' first.log
+"$MCPP" run > run.log 2>&1 || fail 'transitive module program' run.log
+python3 - <<'PY' || fail 'consumer map does not reach dependency' first.log
+import pathlib
+maps = list(pathlib.Path('target').glob('**/modmap/probe-*.map'))
+assert any('dependency ' in p.read_text() and 'probe ' in p.read_text() for p in maps), maps
+PY
+"$MCPP" build > second.log 2>&1 || fail 'warm transitive module build' second.log
+echo 'PASS: visible imports resolve the transitive module on the active compiler'
diff --git a/tests/e2e/_dependency_resources_body.sh b/tests/e2e/_dependency_resources_body.sh
new file mode 100644
index 00000000..bb2fa35e
--- /dev/null
+++ b/tests/e2e/_dependency_resources_body.sh
@@ -0,0 +1,154 @@
+# Shared native-Windows and MinGW-cross resource ownership checks (#826).
+set -e
+source "$(dirname "${BASH_SOURCE[0]}")/_host_path.sh"
+TMP=$(mktemp -d)
+trap 'rm -rf "$TMP"' EXIT
+export MCPP_HOME="$TMP/home"
+source "$(dirname "${BASH_SOURCE[0]}")/_inherit_toolchain.sh"
+fail() { echo "FAIL: $1"; shift; for f in "$@"; do cat "$f" 2>/dev/null; done; exit 1; }
+cd "$TMP"
+mkdir -p app updater library
+printf '[workspace]\nmembers = ["app", "updater", "library"]\n' > mcpp.toml
+cat > app/mcpp.toml <<'TOML'
+[package]
+namespace = "fixture"
+name = "app"
+version = "0.1.0"
+[dependencies]
+"fixture.updater" = {path = "../updater", artifacts = ["updater"]}
+"fixture.library" = {path = "../library"}
+[resources]
+files = ["owner.rc"]
+extra-inputs = ["owner.manifest"]
+[targets.image]
+kind = "bin"
+main = "main.cpp"
+TOML
+printf 'int main() { return 0; }\n' > app/main.cpp
+cp app/main.cpp updater/main.cpp
+cat > updater/mcpp.toml <<'TOML'
+[package]
+namespace = "fixture"
+name = "updater"
+version = "0.1.0"
+[resources]
+files = ["owner.rc"]
+extra-inputs = ["owner.manifest"]
+[targets.updater]
+kind = "bin"
+main = "main.cpp"
+windows_code_page = "legacy"
+TOML
+cat > library/mcpp.toml <<'TOML'
+[package]
+namespace = "fixture"
+name = "library"
+version = "0.1.0"
+[build]
+sources = ["lib.cpp"]
+[resources]
+files = ["owner.rc"]
+extra-inputs = ["owner.manifest"]
+[targets.image]
+kind = "shared"
+TOML
+printf 'extern "C" __declspec(dllexport) int library_value() { return 42; }\n' > library/lib.cpp
+for owner in app updater library; do
+ printf '1 24 "owner.manifest"\n' > "$owner/owner.rc"
+ printf '\n\n' "$owner" > "$owner/owner.manifest"
+done
+# The app and library targets share a name; it cannot determine ownership.
+"$MCPP" build -p app $RESOURCE_TARGET > app.log 2>&1 || fail 'dependency images' app.log
+check_images() {
+ python3 - "$1" <<'PY'
+import pathlib, struct, sys
+root = pathlib.Path('target')
+expected = {'app': 'Fixture.Owner.app', 'updater': 'Fixture.Owner.updater', 'library': 'Fixture.Owner.library'}
+seen = set()
+for f in root.rglob('*'):
+ if f.name not in ('image.exe', 'image.dll', 'libimage.dll', 'updater.exe'): continue
+ b = f.read_bytes()
+ if b[:2] != b'MZ': continue
+ pe = struct.unpack_from(' direct.log 2>&1 || fail 'direct updater' direct.log
+check_images direct || fail 'direct and dependency images differ' direct.log
+"$MCPP" build --workspace $RESOURCE_TARGET > workspace.log 2>&1 || fail 'workspace resources' workspace.log
+check_images workspace || fail 'workspace selection changed resource ownership' workspace.log
+# A tracked manifest change must reach the linked artifact and deployment.
+python3 - <<'PY'
+from pathlib import Path
+p = Path('updater/owner.manifest')
+p.write_text(p.read_text().replace('Fixture.Owner.updater', 'Fixture.Owner.updater.updated'))
+PY
+"$MCPP" build -p app $RESOURCE_TARGET > changed.log 2>&1 || fail 'manifest rebuild' changed.log
+python3 - <<'PY' || fail 'artifact manifest stayed stale' changed.log
+from pathlib import Path
+files = list(Path('target').glob('*/**/obj/fixture_updater/updater.exe'))
+assert files and all(b'Fixture.Owner.updater.updated' in f.read_bytes() for f in files), files
+PY
+
+# A standalone root must not attach its resource to a dependency DLL either.
+mv mcpp.toml workspace.toml
+mkdir single
+cp app/main.cpp app/owner.rc app/owner.manifest single/
+cat > single/mcpp.toml <<'TOML'
+[package]
+name = "single"
+version = "0.1.0"
+[dependencies]
+"fixture.library" = {path = "../library"}
+[resources]
+files = ["owner.rc"]
+[targets.image]
+kind = "bin"
+main = "main.cpp"
+TOML
+cd single
+"$MCPP" build $RESOURCE_TARGET > single.log 2>&1 || fail 'standalone resources' single.log
+python3 - <<'PYRESOURCE' || fail 'standalone resource ownership' single.log
+from pathlib import Path
+exes = list(Path('target').glob('**/bin/image.exe'))
+dlls = list(Path('target').glob('**/bin/*image.dll'))
+assert exes and dlls, (exes, dlls)
+for f in exes:
+ b = f.read_bytes()
+ assert b'Fixture.Owner.app' in b and b'Fixture.Owner.library' not in b, f
+for f in dlls:
+ b = f.read_bytes()
+ assert b'Fixture.Owner.library' in b and b'Fixture.Owner.app' not in b, f
+PYRESOURCE
+cd ..
+# File existence remains a target-independent fact: even a native non-PE
+# configure must reject an image dependency's missing declared resource.
+rm library/owner.manifest
+mv workspace.toml mcpp.toml
+if "$MCPP" build -p app --configure-only > missing.log 2>&1; then
+ fail 'missing dependency resource was accepted' missing.log
+fi
+grep -q 'owner.manifest' missing.log || fail 'missing resource was not attributed' missing.log
+
+echo 'PASS: dependency EXE and DLL embed only their own resources and rebuild'
diff --git a/tests/unit/test_pm_lock_io.cpp b/tests/unit/test_pm_lock_io.cpp
index 201edb94..b1ac74f5 100644
--- a/tests/unit/test_pm_lock_io.cpp
+++ b/tests/unit/test_pm_lock_io.cpp
@@ -119,3 +119,97 @@ TEST(PmLockIo, IndexPackageDigestIsFnv1aOnEveryHost) {
EXPECT_EQ(mcpp::pm::index_package_digest("acme", "acme.gadget", "2.1.0"),
"fnv1a:2adea846f70078bc");
}
+
+
+namespace {
+struct LockDirectory {
+ std::filesystem::path root = std::filesystem::temp_directory_path()
+ / std::format("mcpp_lock_write_{}", std::random_device{}());
+ LockDirectory() { std::filesystem::create_directories(root); }
+ ~LockDirectory() {
+ std::error_code ec;
+ std::filesystem::permissions(root / "mcpp.lock", std::filesystem::perms::owner_all,
+ std::filesystem::perm_options::add, ec);
+ std::filesystem::remove_all(root, ec);
+ }
+};
+std::string lock_bytes(const std::filesystem::path& file) {
+ std::ifstream in(file, std::ios::binary);
+ return {std::istreambuf_iterator(in), {}};
+}
+}
+
+TEST(PmLockIo, UnchangedLfAndCrlfLocksKeepTheirBytesAndTimestamp) {
+ LockDirectory t;
+ const auto file = t.root / "mcpp.lock";
+ mcpp::pm::Lockfile lock;
+ lock.packages.push_back({"fixture", "example", "1.0.0", "path", "digest"});
+ const auto text = mcpp::pm::serialize(lock);
+ for (bool crlf : {false, true}) {
+ std::string bytes;
+ for (char c : text) {
+ if (crlf && c == '\n') bytes += '\r';
+ bytes += c;
+ }
+ { std::ofstream out(file, std::ios::binary); out << bytes; }
+ const auto old = std::filesystem::file_time_type::clock::now() - std::chrono::hours(24);
+ std::filesystem::last_write_time(file, old);
+ const auto recorded = std::filesystem::last_write_time(file);
+ ASSERT_TRUE(mcpp::pm::write(lock, file));
+ EXPECT_EQ(lock_bytes(file), bytes);
+ EXPECT_EQ(std::filesystem::last_write_time(file), recorded);
+ }
+}
+
+TEST(PmLockIo, CreatesMissingParentsAndPublishesChangedResolution) {
+ LockDirectory t;
+ const auto file = t.root / "nested" / "mcpp.lock";
+ mcpp::pm::Lockfile lock;
+ lock.packages.push_back({"fixture", "example", "1.0.0", "path", "digest"});
+ ASSERT_TRUE(mcpp::pm::write(lock, file));
+ lock.packages[0].version = "2.0.0";
+ ASSERT_TRUE(mcpp::pm::write(lock, file));
+ auto loaded = mcpp::pm::load(file);
+ ASSERT_TRUE(loaded);
+ ASSERT_EQ(loaded->packages.size(), 1u);
+ EXPECT_EQ(loaded->packages[0].version, "2.0.0");
+}
+
+TEST(PmLockIo, UnchangedReadOnlyLockDoesNotNeedWriteAccess) {
+ LockDirectory t;
+ const auto file = t.root / "mcpp.lock";
+ mcpp::pm::Lockfile lock;
+ ASSERT_TRUE(mcpp::pm::write(lock, file));
+ std::filesystem::permissions(file, std::filesystem::perms::owner_read);
+ const auto recorded = std::filesystem::last_write_time(file);
+ EXPECT_TRUE(mcpp::pm::write(lock, file));
+ EXPECT_EQ(std::filesystem::last_write_time(file), recorded);
+}
+
+TEST(PmLockIo, AnUnreadableFileOrUncreatableParentReturnsAnError) {
+ LockDirectory t;
+ const auto blocker = t.root / "file";
+ { std::ofstream out(blocker); out << "not a directory"; }
+ mcpp::pm::Lockfile lock;
+ EXPECT_FALSE(mcpp::pm::write(lock, blocker / "mcpp.lock"));
+ EXPECT_FALSE(mcpp::pm::write(lock, t.root));
+}
+
+
+TEST(PmLockIo, UpdatingALinkedLockKeepsTheLinkAndUpdatesItsTarget) {
+ LockDirectory t;
+ const auto target = t.root / "target.lock";
+ const auto link = t.root / "mcpp.lock";
+ mcpp::pm::Lockfile lock;
+ ASSERT_TRUE(mcpp::pm::write(lock, target));
+ std::error_code ec;
+ std::filesystem::create_symlink(target, link, ec);
+ if (ec) GTEST_SKIP() << "this host does not permit file symlinks: " << ec.message();
+ lock.packages.push_back({"fixture", "example", "2.0.0", "path", "digest"});
+ ASSERT_TRUE(mcpp::pm::write(lock, link));
+ EXPECT_TRUE(std::filesystem::is_symlink(link));
+ auto loaded = mcpp::pm::load(target);
+ ASSERT_TRUE(loaded);
+ ASSERT_EQ(loaded->packages.size(), 1u);
+ EXPECT_EQ(loaded->packages[0].version, "2.0.0");
+}