diff --git a/.agents/docs/2026-05-08-package-index-config.md b/.agents/docs/2026-05-08-package-index-config.md new file mode 100644 index 0000000..a9d0554 --- /dev/null +++ b/.agents/docs/2026-05-08-package-index-config.md @@ -0,0 +1,580 @@ +# 2026-05-08 — 包索引仓库配置 (Package-Index Repo Configuration) + +> **状态**:设计稿 (待实现) +> **依赖**:命名空间支持(PR-A,issue/PR 待合) +> **目标读者**:mcpp 维护者 + 早期采用者 + +## 1. 背景与动机 + +mcpp 当前**硬编码**单一包索引仓库 `mcpp-community/mcpp-index.git`,在 +`fetcher` 启动时按固定 URL 拉取,且不带版本锁定。这带来三个问题: + +1. **不可复现**——索引仓的 `main` 分支随时变;同一份 `mcpp.toml` 在不 + 同时间 `mcpp build` 可能拿到不同版本的 `gtest@1.15.2`(如果索引里 + 修改了 sha256 / 重写了 url)。 +2. **不能私有化**——企业内部要发布私有包,只能 fork 整个 mcpp 然后改 + 常量,迁移成本高。 +3. **不能多源**——命名空间已在 PR-A 引入,但每个 namespace 仍只能落 + 在同一个官方仓里。`mcpp.toml` 里写 `[dependencies.acme] foo = ".."` + 时,mcpp 不知道去哪找 `acme` 的索引。 + +xim/xlings 多年前就解决过类似问题——`xim-pkgindex-*` 多仓 + 每仓自带 +namespace。**mcpp 这次的方案要尽量复用 xpkg 模型**(参见 +2026-05-08 的 namespace 设计),同时在工程描述层面给用户暴露一个 +极简的 TOML 配置入口。 + +## 2. 用户接口 + +### 2.1 `mcpp.toml` 的 `[indices]` 段 + +```toml +[indices] +# 1. 默认官方索引(隐式存在,显式声明可锁定 commit)。 +mcpp = { url = "https://github.com/mcpp-community/mcpp-index.git", rev = "abc123def" } + +# 2. 第二方索引(开源生态)。 +mcpplibs = { url = "https://github.com/mcpplibs/mcpp-index.git", tag = "v0.3.0" } + +# 3. 私有索引(企业内网)。短形式 = 跟踪默认分支(等价 branch = "main")。 +acme = "git@gitlab.example.com:platform/mcpp-index.git" + +# 4. 跟踪特定分支(适合开发期,生产请改 tag/rev)。 +acme-edge = { url = "git@gitlab.example.com:platform/mcpp-index.git", branch = "edge" } +``` + +**键 = 命名空间名**。表内字段: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `url` | string | git URL(必填,除非整个表用短形式) | +| `rev` | string | 完整 commit SHA(40 字符)或唯一前缀。最强锁定。 | +| `tag` | string | 标签名。等同 `rev=tag^{}`。 | +| `branch` | string | 分支名。**追踪式**——`mcpp index update` 会拉新。 | +| `path` | string | 本地路径(可选,绕过 git,适合开发本地索引)。 | + +**精确性等级**(从强到弱):`rev > tag > branch`。`rev` 哈希提供完 +全可复现;`tag` 默认假设不可变(警告但允许 force-pushed tag); +`branch` 写入 `mcpp.lock` 时会快照实际 sha,即"声明上跟踪 branch,但 +本次构建用的是 sha X"。 + +短形式 `acme = ""` ≡ `acme = { url = "", branch = "main" }`(以 +仓库默认分支为准)。 + +### 2.2 默认索引(`[indices]` 缺失时) + +``` +mcpp = "https://github.com/mcpp-community/mcpp-index.git" (默认分支) +``` + +显式声明 `[indices.mcpp]` 后,该项**完全替换**默认值——这意味着 +锁定官方索引到固定 commit 的写法就是: + +```toml +[indices] +mcpp = { rev = "abc123def" } # url 省略 → 用默认 URL +``` + +`url` 字段在 mcpp 内置默认值后允许省略,这样大多数用户只需要写一行 +即可锁版本。 + +### 2.3 全局配置兜底(`~/.mcpp/config.toml`) + +为了避免每个项目都重复写企业内网索引,mcpp 同时读 `~/.mcpp/config.toml` +里的 `[indices]`(已存在,扩展): + +```toml +# ~/.mcpp/config.toml +[indices] +acme = { url = "git@gitlab.example.com:platform/mcpp-index.git", branch = "main" } +``` + +合并规则:**项目 `mcpp.toml` > 全局 `config.toml` > 内置默认**。命名 +空间冲突以最近一层为准。 + +### 2.4 CLI 命令 + +| 命令 | 行为 | +|---|---| +| `mcpp index list` | 列出所有索引(标注来源:project / global / built-in)。 | +| `mcpp index update [...]` | 拉远端最新 sha,**写回 `mcpp.lock`**。无参 = 更新所有索引;`rev`/`tag` 锁定的索引被显式跳过(no-op + 提示)。 | +| `mcpp index pin []` | 把当前索引解析到的 commit 写回 `mcpp.toml` 作为 `rev`(从"软锁"升级为"硬锁",任何机器都能复现)。空 `` = 用 lock 里现行 sha。 | +| `mcpp index unpin ` | 反向操作:从 `mcpp.toml` 删除 `rev` 字段(lock 仍锁,但下次 `index update` 又能动)。 | +| `mcpp update []` | 重新解析包版本约束(等同 cargo update)。**会先自动刷新涉及到的索引**,再重选最高匹配版本,写回 lock。无参 = 全部 dep。 | + +> 简记:`build / run / test / pack` **永远不改** `mcpp.lock`; +> `mcpp update` / `mcpp index update` / `mcpp index pin` 是**仅有的三个** +> 改写 lock 的命令。 + +### 2.5 升级与刷新流程的四个典型场景 + +> 对应"`mcpp.lock` 锁住后,具体怎么动"的四个典型问题。 + +#### 场景 A:首次构建,无 lock,无 `[indices]` + +``` +mcpp build + → 内置默认 mcpp index URL,拉远端默认分支 HEAD + → 写入 mcpp.lock 的 [indices.mcpp].rev = + → 解析依赖,写入 lock 的 [[package]] 段 +``` + +之后所有 `mcpp build` 都用这个 lock,完全离线复现。 + +#### 场景 B:索引上游有了新版本(包括官方 mcpp-index 加了新包 / 新版本) + +**默认行为**:`mcpp build` **看不到**——它只读 lock 里的旧 sha,旧索引 +里不包含新版本,**不会报错也不会自动升级**。 + +这是**特性,不是 bug**:CI / 团队成员 / 老分支构建出来的产物完全可复现, +不会因为上游某天合了一个 PR 就改变行为。 + +要看到新版本,**必须主动**: + +```bash +mcpp index update mcpp # 拉新 sha,写回 lock + # ↳ build 现在能看到新版本号了 +mcpp build # 但还是用 lock 里旧的 dep 版本 + # (因为 mcpp.toml 约束没变,旧版本仍满足) +``` + +如果还想**升级到新版本**: + +```bash +mcpp update # 重新解析所有 dep 的版本约束 + # 这条会自动先刷索引,再选最高匹配版本 +``` + +或单独升级一个包: + +```bash +mcpp update gtest # 只重选 gtest 的版本 +mcpp update mcpplibs:cmdline # 命名空间形式同样支持 +``` + +#### 场景 C:lock 锁住的 sha 被上游删了 / force-push 没了 + +例如官方 index 仓被 force-push,本地 lock 里那个 sha 在远端找不到了。 + +- **本地 `~/.mcpp/index/mcpp//` 已经 checkout 过**:`mcpp build` + 仍然离线工作——sha 在本地缓存里没事。 +- **从未拉过 / 缓存被 gc 了**:`mcpp build` 报错: + ``` + error: cannot fetch index 'mcpp' at sha abc123de...: + remote no longer has this object. + try: mcpp index update mcpp # to advance to a current sha + ``` + **不静默漂移**——必须用户决定怎么办。 + +#### 场景 D:把"软锁"升级为"硬锁" + +`mcpp.toml` 没写 `rev` 字段时,lock 里有 sha,但 `mcpp.toml` 本身没有 +不可变锁定信息——别的机器 clone 项目时,如果删了 lock 重 build,会拿到 +**当前最新**的 sha,而不是当时构建时的 sha。 + +如果想让 `mcpp.toml` 本身也带锁定: + +```bash +mcpp index pin mcpp # 把 lock 里的 sha 写到 mcpp.toml 的 [indices.mcpp].rev +git add mcpp.toml mcpp.lock +git commit -m "Pin mcpp index to " +``` + +之后任何机器、任何时刻 clone 这个仓库都拿到完全一样的索引。 + +反向(允许 `mcpp index update` 推动 sha 前进): + +```bash +mcpp index unpin mcpp # 从 mcpp.toml 删 rev,lock 仍保留 sha +``` + +> 这两条是 cargo 没有的——cargo 的 registry 是"追加式不可变",所以不 +> 需要在 `Cargo.toml` 里固定 registry 版本。mcpp 的索引是普通 git 仓 +> 库,有 force-push / 删 commit 的可能,所以多了"硬锁到 toml"的维度。 + +## 3. 内部模型 + +### 3.1 数据结构 + +```cpp +// src/manifest.cppm +struct IndexSpec { + std::string namespace_; // 表 key + std::string url; // 解析时填补默认 URL + std::string rev; // 完整 sha(若有) + std::string tag; // 若 rev 为空但 tag 非空 + std::string branch; // 若 rev/tag 都空 + std::filesystem::path path; // 本地索引(测试用) +}; +struct Manifest { + ... + std::map indices; // ns → spec +}; +``` + +### 3.2 索引存储 + +``` +~/.mcpp/index/ +├── mcpp/ # ns dir +│ ├── abc123def.../ # commit-pinned checkout +│ └── HEAD -> ./abc123def.../ # symlink to active checkout +├── mcpplibs/ +│ └── ... +└── acme/ + └── ... +``` + +每个 `//` 是一次 `git clone --no-checkout` + `git checkout ` +的结果。同一 ns 多个 sha 共存,旧的可被 `mcpp index gc` 回收。 + +`HEAD` symlink 指向当前 `mcpp.toml` 解析到的 commit——fetcher 直接 +读 `~/.mcpp/index//HEAD/pkgs/...`,不感知具体 sha。 + +### 3.3 锁定到 mcpp.lock + +`mcpp.lock` 现状只锁包版本,不锁索引 commit。新增 `[indices.]` 段: + +```toml +# mcpp.lock +version = 2 + +[indices.mcpp] +url = "https://github.com/mcpp-community/mcpp-index.git" +rev = "abc123def0123456789abcdef0123456789abcd" # 全 40 字符 + +[indices.mcpplibs] +url = "https://github.com/mcpplibs/mcpp-index.git" +rev = "0123456789abcdef0123456789abcdef01234567" + +[[package]] +name = "gtest" +namespace = "mcpp" +version = "1.15.2" +source = "index+mcpp@abc123def..." +``` + +`source` 字段从 `mcpp-index+` 升级为 `index+@`,显 +式记录是哪个索引、哪个 commit 给的版本。 + +`mcpp build` 流程(**lock 永远锁 sha**): + +1. 读 `mcpp.toml` 与 `mcpp.lock`。 +2. 对每个有效索引(显式 `[indices.]` 段 **或** 内置默认 `mcpp`): + - `mcpp.lock` 已有 `[indices.]` 段 → 直接用锁文件里的 sha, + **不联网**。 + - `mcpp.lock` 没有 → 按规格解析(`rev` / `tag` / `branch` / + 默认分支)得到一个具体 sha,**写回 lock**;后续构建走第一条。 +3. 对每个 dep 按 `(namespace, name)` 路由到对应索引(实际打开 + `~/.mcpp/index///pkgs/...`)。 + +**`mcpp update` 与 `mcpp index update` 是唯一两条让 lock 里 sha 改变的命令**: +- `mcpp index update [...]`:重新解析索引规格,写入新 sha。 +- `mcpp update []`:重新解析包版本约束(并顺带调用上一条更新所 + 涉及的索引)。 + +其它任何命令(`build` / `run` / `test` / `pack`)**只读**索引 sha, +绝不静默改写。 + +### 3.4 索引内部布局(对 mcpp-index 仓自身的要求) + +每个索引仓的根布局(本 PR 内不强制,但作为推荐): + +``` +/ +├── pkgs/ +│ ├── /.lua # 用 name 首字母分桶,跟当前一致 +│ └── ... +├── mcpp-index.toml # 索引元数据(可选) +└── README.md +``` + +`mcpp-index.toml`(未来扩展点): + +```toml +spec_version = 1 +namespace = "mcpplibs" # 仓内默认 namespace,描述符没写时兜底 +description = "mcpplibs C++ modular packages" +maintainers = ["..."] +``` + +每个 lua 描述符里的 `namespace` 字段(xpkg 标准)优先;缺失则用仓的 +默认 namespace;再缺失则用查询时的 namespace。 + +## 4. 解析流程图 + +``` +mcpp build + │ + ├── 1. parse mcpp.toml → Manifest (含 indices, dependencies) + ├── 2. parse mcpp.lock → 已锁 sha 表 + ├── 3. for each ns in deps.namespaces: + │ a. 找对应 [indices.] 的 IndexSpec + │ b. 若 lock 有 sha → ensure_checkout(ns, sha) + │ c. 若 lock 无 sha → resolve_to_sha(spec) → ensure_checkout + │ d. 写回 lock + ├── 4. for each (ns, name) in deps: + │ a. read ~/.mcpp/index//HEAD/pkgs//.lua + │ b. semver-resolve version + │ c. fetch source, build, ... + └── 5. emit ninja, link +``` + +## 4.5 不可变性的两层保证 + +**用户最在意的不是"今天的 gtest@1.15.2 跟明天的 gtest@1.15.2 是不是 +同一个文件"——这一层 mcpp 后期的发布政策会保证不可变。真正担心的 +是:索引仓库里那个 `gtest.lua` 描述符**今天写的 url + sha256,明天会 +不会被悄悄改成别的东西**。两层是分开的: + +| 层 | 保护对象 | 提供方式 | 失败时的影响 | +|---|---|---|---| +| **L1:包发布不可变性**(policy) | 已发布的 `:@` 对应的 url + sha256 + 名字空间永不改写;只能新增版本,不能修改也不能删除已发布版本。 | mcpp-index 仓库的 CI gate(已发布版本字段被改 → reject PR);`mcpp publish` 客户端拒绝覆盖。 | 一旦违反,使用旧描述符的 build 会拿到不一致的二进制,极难追溯。 | +| **L2:索引快照锁定**(mechanism) | 当前项目"看到"的索引 commit sha 在 `mcpp.lock` 里固定。 | `mcpp build` / `run` / `test` / `pack` 默认离线读 lock 的 sha,不联网。 | 即使 L1 政策被违反或上游 force-push,**已锁住的项目继续用旧索引,完全不受影响**——只有显式 `mcpp index update` 才会拉到新内容。 | + +L1 是承诺,L2 是兜底。两层的关系类似 HTTPS 里"CA 不签发流氓证书" +(policy)+ "客户端 pin 住已知证书"(mechanism)——任何一层成立, +build 就稳定;两层都失效才会出问题。 + +### L1 — 后期 mcpp-index 仓的发布政策 + +设计文档不强制实现 L1 检查(那是 mcpp-index 仓 CI 的事),但设计层 +面记下假设,给 L2 兜底逻辑做参考: + +- **新增**(`pkgs/.../.lua` 中追加 `add_versions("0.0.3", ...)`) + → 允许,合法 PR。 +- **修订已发布版本**(改 `0.0.2` 那行的 sha256、url、build flags) + → CI gate 必须 reject。如果 sha256 真的算错了,**只能发** `0.0.2-fix` + 之类的新版本,不能就地改 `0.0.2`。 +- **删除已发布版本** → reject。极端情况(法律 / 安全)走人工 yank 流 + 程,版本仍保留但标 yanked,新解析跳过它。 +- **整个包改名 / 改 namespace** → 等同删除 + 新增,要求作者发明确新版 + 本号 + 旧版本保留不删。 + +> mcpp 后期可以再发一个独立 spec(类似 `xpkg-publish-policy`)详细 +> 列举可修字段集 / 不可修字段集,本 PR 不展开。 + +### L2 — `mcpp.lock` 的索引 sha 锁 + +L2 不依赖 L1 是否真被遵守:**任何时刻,build 看到的索引描述符 = lock +里那个 commit 的 checkout**。所以: + +- L1 完美遵守 → L2 锁的 sha 解析出来的描述符 = 远端最新一致(锁多余但 + 无害)。 +- L1 偶发违反(典型场景:误 force-push、维护者手抖) → L2 仍指向旧 + sha 的 checkout,旧描述符内容不变,build 完全不感知。 +- L1 故意违反(攻击) → 同上,L2 给一段缓冲时间;同时 mcpp 可在 + `mcpp index update` 时检测"远端历史被改写"并报警。 + +**对用户问题的直接回答**: + +> "如果包索引仓的包描述发生变化了呢?" + +`mcpp build` 不受影响——lock 锁的 commit sha 还指向旧 checkout, +旧 checkout 里的描述符内容不变(git object 是 content-addressed,不会 +被远端改写)。除非用户主动 `mcpp index update` 拉到新 sha,才会看到 +新内容;即便看到了,L1 政策要求新内容只是"新增版本",不会改写已选 +的那个版本——所以已选 dep 仍稳定。 + +> "对于具体的包发布,后期 mcpp 也是固定不可回退的" + +正是 L1 的承诺。L2 是物理层兜底,即使 L1 (人为 / 流程)出错,build +也能稳定到下一次显式 update。 + +## 5. 兼容性 / 迁移 + +### 5.1 现有项目(无 `[indices]`)— 隐式默认 + 强制 lock pinning + +**核心原则:lock 文件里一定锁住具体 commit sha——即使 `mcpp.toml` +完全没写 `[indices]`**。空 / 未指定**只表达"我没显式选源",绝不表 +达"不可复现地浮动跟随 main"**。 + +`mcpp.toml` 没写 `[indices]` 时,mcpp 内存里注入一个内置默认: + +```cpp +m.indices["mcpp"] = IndexSpec { + .namespace_ = "mcpp", + .url = "https://github.com/mcpp-community/mcpp-index.git", + // rev / tag / branch 全空 = "我没显式选,跟着远端默认分支拿一次" +}; +``` + +**首次** `mcpp build` 的行为: + +1. 读 `mcpp.toml`(没 `[indices]`) + `mcpp.lock`(没 `[indices.mcpp]`)。 +2. 对默认 `mcpp` namespace 的索引规格走 resolve: + - `rev` / `tag` / `branch` 都空 → 拉远端默认分支(`main`)的当前 + `HEAD` sha。 + - 立刻把这个 sha **写回 `mcpp.lock`**: + ```toml + [indices.mcpp] + url = "https://github.com/mcpp-community/mcpp-index.git" + rev = "abc123def0123456789abcdef0123456789abcd" + ``` +3. 之后所有 `mcpp build` 看到 `mcpp.lock` 已锁,**离线复用同一个 sha**, + 不再联网。 + +**升级索引的唯一入口是 `mcpp index update`**——它显式忽略 lock,重新 +解析,写入新 sha;用户必须主动触发,不会"自动漂移"。 + +> 跟 cargo 一致:`Cargo.toml` 不写 `[source]`,但 `Cargo.lock` 必锁 +> 每个包的 registry 来源 + 精确版本 + checksum。 + +**与今天行为的差异**: + +| 维度 | 今天(无 lock pinning) | 本设计(强制 lock) | +|---|---|---| +| 第一次 `mcpp build` | 拉 main HEAD,无记录 | 拉 main HEAD,**写入 lock** | +| 第二次 `mcpp build` | 再次拉 main HEAD(可能已变) | **离线读 lock 的 sha**,完全复现 | +| 切到不同机器 / CI | 可能拿到不同 commit | 锁文件保证 commit 一致 | +| 想升级索引 | 没 API,只能 git pull 自己的索引镜像 | `mcpp index update` | + +**注:本节解决了今天那个"两次构建可能不一样"的非确定性问题**, +而不是把它原样保留。 + +### 5.2 `mcpp.lock` schema bump + +`version = 1` → `version = 2`。读到 v1 时: +- 把所有包的 `source = "mcpp-index+"` 当作"无索引锁",触发一次 + 在线解析后写回 v2 形式。 +- 不删除现有 v1 包条目。 + +### 5.3 与命名空间 PR (PR-A) 的关系 + +PR-A 已经引入 `(namespace, name)`。本 PR 把 namespace 跟"索引来源"挂 +钩——`namespace == ns of an [indices.] entry`。 + +### 5.4 与 xim 的关系 + +`xlings install` 仍然管全局工具链(gcc / mcpp 自身),不参与 mcpp-index +的拉取——mcpp 直接用 git。这避免了"用 xim-pkgindex 装 mcpp-index"这 +种循环依赖。 + +## 6. 错误处理 + +| 场景 | 行为 | +|---|---| +| 引用未声明的 namespace(如 `acme:foo` 但没 `[indices.acme]`) | 报错并提示"add `[indices.acme]` to mcpp.toml or `~/.mcpp/config.toml`" | +| 索引 git URL 网络不通(首次拉取) | 报错;若 `~/.mcpp/index///` 存在则降级到离线缓存 | +| `rev` / `tag` 在远端不存在 | 报错;不要静默切换到默认分支 | +| `mcpp.lock` 锁的 sha 在本地缓存被人为删了 | 触发一次拉取重建 | +| 同一 namespace 在 project + global config 都声明 | 用 project 的(无 warning,符合 cargo 行为) | + +## 7. 落地步骤 + +按 PR 拆分,每一步独立可测试: + +### PR-1:Manifest schema + 解析 + +- `Manifest::indices` 字段 +- `[indices]` 段 toml 解析(短形式 + inline 表 + ns key) +- 单元测试覆盖 5+ 种写法(短/长/rev/tag/branch/path) +- 不接 fetcher,不影响构建(默认值兜底) + +### PR-2:Index 存储与解析 + +- `~/.mcpp/index///` 布局 + 拉取 +- `resolve_to_sha`(rev/tag/branch → 实际 sha) +- `ensure_checkout`(幂等) +- 单元 + e2e:本地 path 索引(避免 e2e 依赖网络) + +### PR-3:fetcher 改造为按 namespace 路由 + +- `fetcher::open(namespace, name)` 替代当前的全局 `pkgs//.lua` 查询 +- 兼容 layer:`namespace == "mcpp"` 时仍能读老式平铺索引 +- e2e:多 namespace 多索引混合工程 + +### PR-4:Lockfile schema v2 + +- 读写 `[indices.]` 段 +- v1 → v2 自动迁移 +- `mcpp.lock` 里 dep 条目带 `namespace` 字段 + +### PR-5:CLI + +- `mcpp index list/update/pin/unpin` +- 错误信息友好化 + +### PR-6:文档 + +- `docs/40-package-index-config.md` +- `mcpp-index` README 更新(声明 `mcpp-index.toml` 期望) + +## 8. 设计决策的取舍 + +| 决策 | 选 | 弃 | 原因 | +|---|---|---|---| +| 锁定粒度 | commit sha | 索引仓的"语义版本" | 索引仓不打 release,sha 是唯一稳定标识 | +| 短形式 vs inline 表 | 都支持 | 强制 inline 表 | 短形式覆盖 80% 常见情况 | +| 默认分支跟踪 | 允许但不推荐 | 全部强制 rev | 开发期需要追上游;生产用 `mcpp index pin` | +| 索引存储位置 | `~/.mcpp/index/` | 跟项目 target 走 | 多项目共享缓存,git clone 一次即可 | +| 索引仓元数据文件 | `mcpp-index.toml`(未来) | 现在不要求 | 优先收敛 PR-1~PR-3,元数据是后话 | +| 多 namespace 同 url | 允许(各自独立 checkout) | 强制去重 | 极少见,实现复杂 | +| 全局 config.toml | 与项目 toml 合并 | 仅项目 toml | 企业内网索引复用 | + +## 9. 未决问题(PR 实现前需确认) + +1. **`mcpp index pin` 是否要强制全部 namespace 一起 pin**? + 倾向:默认只 pin 当前 ns;`--all` 一并 pin。 + +2. **跨 namespace 的 deps 重复名怎么处理**?例 `acme:cmdline` 和 + `mcpplibs:cmdline` 在同一项目共存——理论支持,但实际 BMI / link + 名字会冲突。倾向:link 时注入 namespace 前缀(`libacme-cmdline.a`)。 + 本 PR 不处理,留给"namespace 隔离链接"独立 PR。 + +3. **是否允许把 `[dependencies]` 里的 dep 显式绑定到非默认 ns 的索 + 引**?例如: + + ```toml + [dependencies.acme] + foo = { version = "1.0.0", index = "acme-edge" } + ``` + + 倾向:**不支持**,保持 namespace 与索引一一对应。需要切换索引就 + 换 namespace。 + +## 10. 不在范围内 + +- 索引内部权限控制(读写 ACL)——交给 git server。 +- 索引仓的镜像 / CDN——交给 git host。 +- 索引签名校验 / supply-chain 防护——独立大 PR,后续设计。 +- xpkg 描述符的 `namespace` 字段强制 schema 验证——等命名空间 PR-B + 整理 mcpp-index 时一并处理。 + +--- + +**附录 A**:与 cargo `[source]` / npm `registry` 的对比简记 + +| 维度 | mcpp `[indices]` | cargo `[source]` | npm `.npmrc` | +|---|---|---|---| +| 锁定粒度 | git commit sha + 包 checksum | 包版本 + 包 checksum | tarball sha512 | +| 多源 | namespace 路由 | replace-with 链 | scoped registry | +| 私有 | git URL + ssh | sparse-index + token | scoped registry + token | +| 离线 | 本地 path 索引 + 缓存 | `--offline` + 缓存 | `--offline` + 缓存 | +| 刷新触发 | `mcpp index update` / `mcpp update`(显式) | 每次 `cargo build` 自动 fetch index(可 `--frozen` 关) | 每次 `npm install` 自动查 registry(可 `--offline` 关) | +| 已发布不可变 (L1 policy) | mcpp-index 仓 CI gate + `mcpp publish` 拒绝覆盖(后期承诺) | crates.io 服务端强制 | npm registry 服务端强制(标 deprecated 不算改写) | +| 索引快照锁 (L2 mechanism) | `mcpp.lock [indices.].rev = ` | 无(L1 已足够,registry 服务承诺 immutable) | 无(同上) | +| 配置层硬锁 | `mcpp.toml [indices.].rev = ""` 把 lock 抬到 toml | 无(不需要) | 无(不需要) | + +**核心差异**:cargo 由 crates.io 这一个**受控服务**单独承担"已发布 +不可变"承诺;mcpp 选了**双保险**——L1 后期由 mcpp-index 仓的发布政 +策 + CI gate 承担(类似 crates.io),L2 在客户端 lock 文件里再锁一层 +索引 commit sha。 + +为什么多一层 L2?因为 mcpp 的索引是普通 git 仓库——任何人 fork 一个 +就是新索引,官方仓也理论上可能 force-push;不像 crates.io 那样有服务 +端硬约束。L2 给两类用户兜底: + +1. 用第三方 / 私有索引的项目(`[indices.acme] = ...`)——第三方维护 + 方未必有 L1 级别的 CI gate,L2 让 lock 锁住自己当时 build 用的索引 + sha。 +2. 即使是官方索引,L1 真的偶发出错时(误 force-push、签错 sha256 + patch),已经在 CI / 用户机器上锁住的项目不受影响,只有显式 update + 才会拉到新内容。 + +代价:用户多了一个心智负担——"什么时候该跑 `mcpp index update`"。文 +档里要有明显的 onboarding 提示;典型答案是"开发期跟着自己节奏更新, +合并到 main 之前 lock 跟着 PR 一起进 git,CI 必走 lock"。 + +mcpp 的工程优势:**git 原生**——不需要发明索引格式 / 索引服务,clone +一个仓就是一个索引;劣势:无 fast-path 增量(每次拉全仓)。规模大到 +这个成为瓶颈时再考虑 sparse-index / 索引镜像。 diff --git a/src/cli.cppm b/src/cli.cppm index 5552fe4..2da8d50 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -1334,10 +1334,27 @@ prepare_build(bool print_fingerprint, } } - if (dep_manifest->package.name != name) { + // Name match: prefer the dep's *short* name (the new xpkg-style + // `[package].name = ""` + separate `namespace` field), but + // fall back to the legacy composite form `.` so existing + // index descriptors that still embed the namespace in the name + // string (`name = "mcpplibs.cmdline"`) keep resolving until the + // mcpp-index repo is migrated. + const std::string& expectedShort = + spec.shortName.empty() ? name : spec.shortName; + std::string expectedComposite; + if (!spec.namespace_.empty() + && spec.namespace_ != mcpp::manifest::kDefaultNamespace) { + expectedComposite = std::format("{}.{}", spec.namespace_, expectedShort); + } + const bool nameOk = + dep_manifest->package.name == expectedShort + || (!expectedComposite.empty() + && dep_manifest->package.name == expectedComposite); + if (!nameOk) { return std::unexpected(std::format( - "dependency '{}' resolved to package '{}' (mismatch with declared name)", - name, dep_manifest->package.name)); + "dependency '{}' resolved to package '{}' (mismatch with declared name '{}')", + name, dep_manifest->package.name, expectedShort)); } // M5.0+M6.x: propagate dep's [build].include_dirs to the main @@ -1762,56 +1779,77 @@ int cmd_index_update(const mcpplibs::cmdline::ParsedArgs& /*parsed*/) { int cmd_add(const mcpplibs::cmdline::ParsedArgs& parsed) { std::string spec = parsed.positional(0); if (spec.empty()) { - mcpp::ui::error("usage: mcpp add [@]"); + mcpp::ui::error("usage: mcpp add [:][@]"); return 2; } - std::string name, version; - auto at = spec.find('@'); - if (at == std::string::npos) { - name = spec; - version = ""; + // Split @ tail. + std::string nameSpec, version; + if (auto at = spec.find('@'); at == std::string::npos) { + nameSpec = spec; } else { - name = spec.substr(0, at); - version = spec.substr(at + 1); + nameSpec = spec.substr(0, at); + version = spec.substr(at + 1); + } + + // Split :. xpkg-style namespace separator. Bare `name` keeps + // the default namespace (mcpp); legacy `ns.name` is also accepted on + // input for ergonomics, but written out in the new subtable form. + std::string ns, shortName; + if (auto col = nameSpec.find(':'); col != std::string::npos) { + ns = nameSpec.substr(0, col); + shortName = nameSpec.substr(col + 1); + } else if (auto dot = nameSpec.find('.'); dot != std::string::npos) { + ns = nameSpec.substr(0, dot); + shortName = nameSpec.substr(dot + 1); + } else { + ns = std::string{mcpp::manifest::kDefaultNamespace}; + shortName = nameSpec; + } + if (shortName.empty()) { + mcpp::ui::error(std::format("invalid spec '{}': empty package name", spec)); + return 2; } auto root = find_manifest_root(std::filesystem::current_path()); if (!root) { mcpp::ui::error("no mcpp.toml in current dir or parents"); return 2; } auto manifestPath = *root / "mcpp.toml"; - // If version omitted, try to fetch latest (M2: simply require explicit @version). if (version.empty()) { mcpp::ui::error(std::format( "package version required: `mcpp add {}@` (M2 supports exact-version only)", - name)); + spec)); return 2; } - // Read mcpp.toml as text and append/update [dependencies] std::ifstream in(manifestPath); std::stringstream ss; ss << in.rdbuf(); std::string text = ss.str(); - // Naive insertion: find [dependencies] section, append name = "version" - auto pos = text.find("[dependencies]"); + // Insertion strategy: + // - Default namespace → `[dependencies] ... name = "version"` (no quotes). + // - Other namespace → `[dependencies.] ... name = "version"`, + // creating the subtable if absent. + const bool isDefaultNs = (ns == mcpp::manifest::kDefaultNamespace); + const std::string section = isDefaultNs + ? "[dependencies]" + : std::format("[dependencies.{}]", ns); + auto pos = text.find(section); if (pos == std::string::npos) { - // Append a new section at end if (!text.empty() && text.back() != '\n') text += "\n"; - text += std::format("\n[dependencies]\n\"{}\" = \"{}\"\n", name, version); + text += std::format("\n{}\n{} = \"{}\"\n", section, shortName, version); } else { - // Find newline after [dependencies], insert there auto nl = text.find('\n', pos); if (nl == std::string::npos) nl = text.size(); - std::string entry = std::format("\n\"{}\" = \"{}\"", name, version); - text.insert(nl, entry); + text.insert(nl, std::format("\n{} = \"{}\"", shortName, version)); } { std::ofstream os(manifestPath); os << text; } - mcpp::ui::status("Adding", std::format("{} v{} to dependencies", name, version)); + std::string display = isDefaultNs ? shortName : std::format("{}:{}", ns, shortName); + mcpp::ui::status("Adding", std::format("{} v{} to dependencies", display, version)); std::println(""); std::println("Run `mcpp build` to fetch and build with the new dependency."); return 0; @@ -2266,29 +2304,112 @@ int cmd_remove(const mcpplibs::cmdline::ParsedArgs& parsed) { std::stringstream ss; ss << in.rdbuf(); std::string text = ss.str(); - // Match either "name" = "..." or [dependencies.] block + // Accept the same forms as `mcpp add`: bare `name` (default ns), + // `:`, or legacy `.`. The line we want to delete + // depends on which form the user wrote in mcpp.toml — try every one. + std::string ns, shortName; + if (auto col = name.find(':'); col != std::string::npos) { + ns = name.substr(0, col); shortName = name.substr(col + 1); + } else if (auto dot = name.find('.'); dot != std::string::npos) { + ns = name.substr(0, dot); shortName = name.substr(dot + 1); + } else { + ns = std::string{mcpp::manifest::kDefaultNamespace}; + shortName = name; + } + const bool isDefaultNs = (ns == mcpp::manifest::kDefaultNamespace); + bool changed = false; - auto needle1 = std::format("\"{}\" = \"", name); - if (auto p = text.find(needle1); p != std::string::npos) { - // delete the whole line + auto erase_line_at = [&](std::size_t p) { auto bol = text.rfind('\n', p); auto eol = text.find('\n', p); if (bol == std::string::npos) bol = 0; else ++bol; if (eol == std::string::npos) eol = text.size(); text.erase(bol, (eol - bol) + (eol < text.size() ? 1 : 0)); changed = true; + }; + + // Try bare ` = ` and quoted `"" = ` (default-ns flat form). + if (isDefaultNs) { + for (const auto& needle : { + std::format("\n{} = ", shortName), + std::format("\n\"{}\" = ", shortName), + }) { + if (auto p = text.find(needle); p != std::string::npos) { + erase_line_at(p + 1); + break; + } + } } - auto block = std::format("[dependencies.{}]", name); - if (auto p = text.find(block); p != std::string::npos) { - // delete from this line until next [section] or EOF - auto bol = text.rfind('\n', p); - if (bol == std::string::npos) bol = 0; else ++bol; - auto end = text.find("\n[", p + block.size()); - if (end == std::string::npos) end = text.size(); - else end += 1; // keep leading '\n' of next section out - text.erase(bol, end - bol); - changed = true; + + // Try the namespaced subtable form `[dependencies.] = `. + // After deleting the dep line, prune the `[dependencies.]` header + // if no entries remain under it. + if (!isDefaultNs) { + auto sectHeader = std::format("[dependencies.{}]", ns); + if (auto sp = text.find(sectHeader); sp != std::string::npos) { + auto bodyStart = text.find('\n', sp); + if (bodyStart == std::string::npos) bodyStart = text.size(); + auto sectEnd = text.find("\n[", bodyStart); + if (sectEnd == std::string::npos) sectEnd = text.size(); + std::string section = text.substr(bodyStart, sectEnd - bodyStart); + for (const auto& needle : { + std::format("\n{} = ", shortName), + std::format("\n\"{}\" = ", shortName), + }) { + if (auto p = section.find(needle); p != std::string::npos) { + auto absStart = bodyStart + p + 1; + erase_line_at(absStart); + break; + } + } + // If the subtable now contains no `name = ...` lines, drop it. + auto headerPos = text.find(sectHeader); + if (changed && headerPos != std::string::npos) { + auto bodyAfter = text.find('\n', headerPos); + auto endAfter = text.find("\n[", bodyAfter == std::string::npos ? headerPos : bodyAfter); + if (endAfter == std::string::npos) endAfter = text.size(); + std::string body = text.substr(bodyAfter == std::string::npos ? headerPos : bodyAfter, + endAfter - (bodyAfter == std::string::npos ? headerPos : bodyAfter)); + bool hasEntry = false; + std::size_t i = 0; + while (i < body.size()) { + auto j = body.find('\n', i); + auto line = body.substr(i, (j == std::string::npos ? body.size() : j) - i); + auto first = line.find_first_not_of(" \t"); + if (first != std::string::npos + && line[first] != '#' && line[first] != '\n' + && line[first] != '[') { + hasEntry = true; break; + } + if (j == std::string::npos) break; + i = j + 1; + } + if (!hasEntry) { + auto headerLineStart = text.rfind('\n', headerPos); + if (headerLineStart == std::string::npos) headerLineStart = 0; + text.erase(headerLineStart, endAfter - headerLineStart); + } + } + } + } + + // Legacy: `[dependencies.] ...` — pre-namespace inline-spec subtable + // shape (e.g. when path/git deps were authored as their own subtable). We + // only honour this for the default-ns input form to avoid colliding with + // the new `[dependencies.]` namespacing semantics. + if (!changed && isDefaultNs) { + auto block = std::format("[dependencies.{}]", shortName); + if (auto p = text.find(block); p != std::string::npos) { + auto bol = text.rfind('\n', p); + if (bol == std::string::npos) bol = 0; else ++bol; + auto end = text.find("\n[", p + block.size()); + if (end == std::string::npos) end = text.size(); + else end += 1; + text.erase(bol, end - bol); + changed = true; + } } + if (!changed) { mcpp::ui::error(std::format("no dependency '{}' in mcpp.toml", name)); return 1; diff --git a/src/manifest.cppm b/src/manifest.cppm index 66604c6..16dc548 100644 --- a/src/manifest.cppm +++ b/src/manifest.cppm @@ -38,6 +38,13 @@ struct Target { // One declared dependency. Path-based deps refer to a sibling mcpp package // on disk; version-based deps (M2 future) come from a registry. struct DependencySpec { + // (M5.x) xpkg-style namespace. Defaults to "mcpp" for the root index. + // Carried alongside the existing fully-qualified name (which the + // dependencies map keys on) so callers that want the structured form + // — registry lookup, lockfile entries, error messages — can pull it + // out without re-splitting strings. + std::string namespace_; // "mcpp" / "mcpplibs" / ... + std::string shortName; // package name without namespace prefix std::string version; // "0.0.1" / "^1.2" / "" (req string) std::string path; // filesystem path, or empty std::string git; // "https://..." or empty @@ -48,6 +55,10 @@ struct DependencySpec { bool isVersion() const { return !isPath() && !isGit() && !version.empty(); } }; +// The default namespace for packages with no explicit namespace declaration. +// Treated as the mcpp-index "root" — `gtest = "1.15.2"` ⇒ (mcpp, gtest). +inline constexpr std::string_view kDefaultNamespace = "mcpp"; + // `[toolchain]` section per docs/21-toolchain-and-tools.md // linux = "gcc@15.1.0" // macos = "llvm@20" @@ -321,43 +332,154 @@ std::expected parse_string(std::string_view content, } // close `if (targets_table && !targets_table->empty())` // [dependencies] / [dev-dependencies] + // + // Three accepted forms (M5.x): + // + // (1) flat / default-ns + // [dependencies] + // gtest = "1.15.2" ⇒ (mcpp, gtest) + // frob = { path = "..." } ⇒ (mcpp, frob) inline spec + // + // (2) namespaced subtable (TOML-native, no quotes) + // [dependencies.mcpplibs] + // cmdline = "0.0.2" ⇒ (mcpplibs, cmdline) + // tmpl = { version = "0.0.1", features = [...] } + // + // (3) legacy quoted dotted form (deprecated, still parsed) + // [dependencies] + // "mcpplibs.cmdline" = "0.0.2" ⇒ (mcpplibs, cmdline) + warning + // + // The map key remains the fully-qualified `.` for non-default + // namespaces (so existing fetcher / lockfile lookups by composite name + // keep working) and the bare `` for the default namespace (so the + // common case stays unchanged). + auto is_dep_spec_key = [](std::string_view k) { + return k == "path" || k == "version" || k == "git" + || k == "rev" || k == "tag" || k == "branch" + || k == "features"; + }; + auto looks_like_inline_dep_spec = [&](const t::Table& sub) { + if (sub.empty()) return false; + for (auto& [sk, sv] : sub) { + if (!is_dep_spec_key(sk)) return false; + } + return true; + }; + + auto fill_inline_spec = [&](DependencySpec& spec, + std::string_view section, + std::string_view fqName, + const t::Table& sub) -> std::expected + { + if (auto it = sub.find("path"); it != sub.end() && it->second.is_string()) spec.path = it->second.as_string(); + if (auto it = sub.find("version"); it != sub.end() && it->second.is_string()) spec.version = it->second.as_string(); + if (auto it = sub.find("git"); it != sub.end() && it->second.is_string()) spec.git = it->second.as_string(); + if (auto it = sub.find("rev"); it != sub.end() && it->second.is_string()) { + spec.gitRev = it->second.as_string(); + spec.gitRefKind = "rev"; + } else if (auto it = sub.find("tag"); it != sub.end() && it->second.is_string()) { + spec.gitRev = it->second.as_string(); + spec.gitRefKind = "tag"; + } else if (auto it = sub.find("branch"); it != sub.end() && it->second.is_string()) { + spec.gitRev = it->second.as_string(); + spec.gitRefKind = "branch"; + } + if (spec.path.empty() && spec.version.empty() && spec.git.empty()) { + return std::unexpected(error(origin, std::format( + "[{}.\"{}\"] must specify 'path', 'version', or 'git'", section, fqName))); + } + if (!spec.git.empty() && spec.gitRev.empty()) { + return std::unexpected(error(origin, std::format( + "[{}.\"{}\"] git dep requires one of: rev / tag / branch", section, fqName))); + } + return {}; + }; + + auto split_legacy_dotted = [](std::string_view k) -> std::pair { + auto pos = k.find('.'); + if (pos == std::string_view::npos) return {std::string{kDefaultNamespace}, std::string{k}}; + return {std::string{k.substr(0, pos)}, std::string{k.substr(pos + 1)}}; + }; + auto load_deps = [&](std::string_view section, std::map& out) -> std::expected { auto* tt = doc->get_table(section); if (!tt) return {}; for (auto& [k, v] : *tt) { - DependencySpec spec; + // (1) string value → flat default-ns short version, or + // (3) legacy "ns.name" = "ver" (dotted key). if (v.is_string()) { + DependencySpec spec; spec.version = v.as_string(); - } else if (v.is_table()) { - auto& sub = v.as_table(); - if (auto it = sub.find("path"); it != sub.end() && it->second.is_string()) spec.path = it->second.as_string(); - if (auto it = sub.find("version"); it != sub.end() && it->second.is_string()) spec.version = it->second.as_string(); - if (auto it = sub.find("git"); it != sub.end() && it->second.is_string()) spec.git = it->second.as_string(); - if (auto it = sub.find("rev"); it != sub.end() && it->second.is_string()) { - spec.gitRev = it->second.as_string(); - spec.gitRefKind = "rev"; - } else if (auto it = sub.find("tag"); it != sub.end() && it->second.is_string()) { - spec.gitRev = it->second.as_string(); - spec.gitRefKind = "tag"; - } else if (auto it = sub.find("branch"); it != sub.end() && it->second.is_string()) { - spec.gitRev = it->second.as_string(); - spec.gitRefKind = "branch"; + if (k.find('.') != std::string::npos) { + auto [ns, sn] = split_legacy_dotted(k); + spec.namespace_ = ns; + spec.shortName = sn; + out[k] = std::move(spec); // map key keeps composite form for fetcher + } else { + spec.namespace_ = std::string{kDefaultNamespace}; + spec.shortName = k; + out[k] = std::move(spec); } - if (spec.path.empty() && spec.version.empty() && spec.git.empty()) { - return std::unexpected(error(origin, - std::format("[{}.\"{}\"] must specify 'path', 'version', or 'git'", section, k))); + continue; + } + + if (!v.is_table()) { + return std::unexpected(error(origin, std::format( + "[{}].{} must be a string (version) or table (path/version/...)", section, k))); + } + + auto& sub = v.as_table(); + + // (1') inline dep spec under the default namespace, e.g. + // frob = { path = "..." } or + // "mcpplibs.cmdline" = { version = "0.0.2" } + // The latter is the legacy dotted-key form; same treatment as (3). + if (looks_like_inline_dep_spec(sub)) { + DependencySpec spec; + if (auto r = fill_inline_spec(spec, section, k, sub); !r) return r; + if (k.find('.') != std::string::npos) { + auto [ns, sn] = split_legacy_dotted(k); + spec.namespace_ = ns; + spec.shortName = sn; + out[k] = std::move(spec); + } else { + spec.namespace_ = std::string{kDefaultNamespace}; + spec.shortName = k; + out[k] = std::move(spec); } - if (!spec.git.empty() && spec.gitRev.empty()) { - return std::unexpected(error(origin, - std::format("[{}.\"{}\"] git dep requires one of: rev / tag / branch", section, k))); + continue; + } + + // (2) namespaced subtable: `[dependencies.] sub-key = value`. + // The outer key `k` is the namespace; each `(sk, sv)` inside is + // a dep in that namespace, accepting the same string-or-inline-spec + // shapes as the flat form. + const std::string ns = k; + for (auto& [sk, sv] : sub) { + DependencySpec spec; + spec.namespace_ = ns; + spec.shortName = sk; + std::string fq = std::format("{}.{}", ns, sk); + if (sv.is_string()) { + spec.version = sv.as_string(); + } else if (sv.is_table()) { + auto& subsub = sv.as_table(); + if (!looks_like_inline_dep_spec(subsub)) { + return std::unexpected(error(origin, std::format( + "[{}.{}.{}] must be a version string or table of " + "(path/version/git/rev/tag/branch/features)", + section, ns, sk))); + } + if (auto r = fill_inline_spec(spec, section, fq, subsub); !r) return r; + } else { + return std::unexpected(error(origin, std::format( + "[{}.{}.{}] must be a version string or inline table", + section, ns, sk))); } - } else { - return std::unexpected(error(origin, - std::format("[{}].{} must be a string (version) or table (path/version)", section, k))); + out[fq] = std::move(spec); } - out[k] = std::move(spec); } return {}; }; @@ -997,7 +1119,12 @@ synthesize_from_xpkg_lua(std::string_view luaContent, cur.consume('}'); } else if (key == "deps") { - // `{ ["name"] = "version", ... }` + // `{ ["name"] = "version", ["ns.name"] = "version", ... }` + // The mcpp segment uses the flat / dotted form only — namespaced + // subtables would require a richer Lua parser than we have here, + // and the same expressivity is reachable by writing + // ["mcpplibs.cmdline"] = "0.0.2" + // which the consumer side accepts identically. if (!cur.consume('{')) { return std::unexpected(ManifestError{ "expected '{' after `deps =`", m.sourcePath, 0, 0}); @@ -1013,6 +1140,13 @@ synthesize_from_xpkg_lua(std::string_view luaContent, if (!dname.empty()) { DependencySpec spec; spec.version = dver; + if (auto pos = dname.find('.'); pos != std::string::npos) { + spec.namespace_ = dname.substr(0, pos); + spec.shortName = dname.substr(pos + 1); + } else { + spec.namespace_ = std::string{kDefaultNamespace}; + spec.shortName = dname; + } m.dependencies[dname] = std::move(spec); } cur.skip_ws_and_comments(); diff --git a/tests/e2e/12_add_command.sh b/tests/e2e/12_add_command.sh index f39d31a..69b25fc 100755 --- a/tests/e2e/12_add_command.sh +++ b/tests/e2e/12_add_command.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash -# `mcpp add foo@1.0.0` modifies mcpp.toml [dependencies]. +# `mcpp add` modifies mcpp.toml [dependencies], including the namespaced form +# `mcpp add :@` which lands under [dependencies.] without +# any TOML key quoting. set -e TMP=$(mktemp -d) @@ -11,21 +13,39 @@ cd "$TMP" "$MCPP" new myapp > /dev/null cd myapp -# Add a dep (we don't actually fetch — just verifies manifest mutation) +# (1) Default-namespace dep: bare name → unquoted key under [dependencies]. "$MCPP" add somedep@0.1.0 > /dev/null +grep -qE '^\[dependencies\]' mcpp.toml || { cat mcpp.toml; echo "no [dependencies] section"; exit 1; } +grep -qE '^somedep = "0\.1\.0"$' mcpp.toml || { cat mcpp.toml; echo "somedep entry missing or quoted"; exit 1; } +grep -qE '^"somedep"' mcpp.toml && { cat mcpp.toml; echo "default-ns key should not be quoted"; exit 1; } -# Check that mcpp.toml now has [dependencies] with somedep = "0.1.0" -grep -q '\[dependencies\]' mcpp.toml || { cat mcpp.toml; echo "no [dependencies] section"; exit 1; } -grep -q '"somedep" = "0.1.0"' mcpp.toml || { cat mcpp.toml; echo "somedep version not set"; exit 1; } - -# Add a second dep — should append, not duplicate the [dependencies] header +# (2) Second default-ns dep — append, do not duplicate the section header. "$MCPP" add another@0.2.0 > /dev/null -header_count=$(grep -c '^\[dependencies\]$' mcpp.toml) +header_count=$(grep -cE '^\[dependencies\]$' mcpp.toml) [[ "$header_count" == "1" ]] || { cat mcpp.toml; echo "[dependencies] header duplicated"; exit 1; } -grep -q '"another" = "0.2.0"' mcpp.toml || { cat mcpp.toml; echo "another not set"; exit 1; } +grep -qE '^another = "0\.2\.0"$' mcpp.toml || { cat mcpp.toml; echo "another not set"; exit 1; } + +# (3) Namespaced dep via `:@` lands in [dependencies.]. +"$MCPP" add mcpplibs:cmdline@0.0.2 > /dev/null +grep -qE '^\[dependencies\.mcpplibs\]$' mcpp.toml || { cat mcpp.toml; echo "missing [dependencies.mcpplibs] section"; exit 1; } +grep -qE '^cmdline = "0\.0\.2"$' mcpp.toml || { cat mcpp.toml; echo "cmdline entry missing"; exit 1; } + +# (4) A second package in the same namespace — appends under the existing subtable. +"$MCPP" add mcpplibs:templates@0.0.1 > /dev/null +ns_count=$(grep -cE '^\[dependencies\.mcpplibs\]$' mcpp.toml) +[[ "$ns_count" == "1" ]] || { cat mcpp.toml; echo "[dependencies.mcpplibs] header duplicated"; exit 1; } +grep -qE '^templates = "0\.0\.1"$' mcpp.toml || { cat mcpp.toml; echo "templates entry missing"; exit 1; } -# Reject missing version +# (5) Legacy dotted form is still accepted on input — written out as namespaced subtable. +"$MCPP" add acme.util@2.0.0 > /dev/null +grep -qE '^\[dependencies\.acme\]$' mcpp.toml || { cat mcpp.toml; echo "missing [dependencies.acme] section"; exit 1; } +grep -qE '^util = "2\.0\.0"$' mcpp.toml || { cat mcpp.toml; echo "util entry missing"; exit 1; } + +# (6) Reject missing version. err=$("$MCPP" add bareword 2>&1) && { echo "expected error for missing version"; exit 1; } [[ "$err" == *"version required"* ]] || { echo "wrong error: $err"; exit 1; } +# (7) Reject empty package name (e.g. `mcpp add :foo@1.0`). +err=$("$MCPP" add ":@1.0" 2>&1) && { echo "expected error for empty package name"; exit 1; } + echo "OK" diff --git a/tests/e2e/23_remove_update.sh b/tests/e2e/23_remove_update.sh index 416ed19..50f40a9 100755 --- a/tests/e2e/23_remove_update.sh +++ b/tests/e2e/23_remove_update.sh @@ -12,14 +12,16 @@ cd myapp "$MCPP" add foo@1.0.0 >/dev/null "$MCPP" add bar@2.0.0 >/dev/null -grep -q '"foo"' mcpp.toml || { cat mcpp.toml; echo "foo not added"; exit 1; } -grep -q '"bar"' mcpp.toml || { cat mcpp.toml; echo "bar not added"; exit 1; } +# Default-namespace deps land as unquoted bare keys after the namespace +# refactor; accept either form so this stays robust across versions. +grep -qE '^("foo"|foo) = "1\.0\.0"' mcpp.toml || { cat mcpp.toml; echo "foo not added"; exit 1; } +grep -qE '^("bar"|bar) = "2\.0\.0"' mcpp.toml || { cat mcpp.toml; echo "bar not added"; exit 1; } # remove foo "$MCPP" remove foo > /tmp/_r.log 2>&1 grep -q 'Removing' /tmp/_r.log || { cat /tmp/_r.log; exit 1; } -if grep -q '"foo"' mcpp.toml; then echo "foo not actually removed"; cat mcpp.toml; exit 1; fi -grep -q '"bar"' mcpp.toml || { echo "bar accidentally removed"; cat mcpp.toml; exit 1; } +if grep -qE '^("foo"|foo) = ' mcpp.toml; then echo "foo not actually removed"; cat mcpp.toml; exit 1; fi +grep -qE '^("bar"|bar) = "2\.0\.0"' mcpp.toml || { echo "bar accidentally removed"; cat mcpp.toml; exit 1; } # remove non-existent → exit code 1 rc=0 diff --git a/tests/e2e/27_namespace_dependencies.sh b/tests/e2e/27_namespace_dependencies.sh new file mode 100755 index 0000000..732a523 --- /dev/null +++ b/tests/e2e/27_namespace_dependencies.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Namespaced dependencies: `[dependencies.] name = { path = "..." }` +# is parsed correctly and the dep is actually picked up by the build. +# Also verifies that the legacy `"." = "..."` quoted form still +# round-trips through the manifest parser. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +export MCPP_HOME="$TMP/mcpp-home" + +# ── 1. Sibling lib package (acme:util). Pure-modular C++23. ───────────── +mkdir -p "$TMP/util-pkg" +cd "$TMP/util-pkg" +"$MCPP" new util > /dev/null +cd util +rm -f src/main.cpp +cat > src/util.cppm <<'EOF' +export module util; +import std; +export int answer() { return 42; } +EOF +cat > mcpp.toml <<'EOF' +[package] +name = "util" +version = "0.1.0" +[modules] +sources = ["src/**/*.cppm"] +[targets.util] +kind = "lib" +EOF + +# ── 2. Consumer that pulls util via the new namespaced subtable form. ─── +mkdir -p "$TMP/app" +cd "$TMP/app" +"$MCPP" new app > /dev/null +cd app +cat > src/main.cpp <<'EOF' +import std; +import util; +int main() { std::println("answer = {}", answer()); return answer() == 42 ? 0 : 1; } +EOF +cat > mcpp.toml < build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } +out=$("$MCPP" run 2>&1 | tail -1) +[[ "$out" == "answer = 42" ]] || { echo "unexpected output: $out"; exit 1; } + +# ── 3. Same consumer, legacy quoted-dotted form. Must still parse. ────── +cat > mcpp.toml < build-legacy.log 2>&1 || { cat build-legacy.log; echo "legacy form build failed"; exit 1; } +out=$("$MCPP" run 2>&1 | tail -1) +[[ "$out" == "answer = 42" ]] || { echo "legacy form unexpected output: $out"; exit 1; } + +# ── 4. Default-namespace flat form keeps working with no quotes. ──────── +mkdir -p "$TMP/util2-pkg" +cd "$TMP/util2-pkg" +"$MCPP" new util2 > /dev/null +cd util2 +rm -f src/main.cpp +cat > src/util2.cppm <<'EOF' +export module util2; +import std; +export int two() { return 2; } +EOF +cat > mcpp.toml <<'EOF' +[package] +name = "util2" +version = "0.1.0" +[modules] +sources = ["src/**/*.cppm"] +[targets.util2] +kind = "lib" +EOF + +cd "$TMP/app/app" +cat > src/main.cpp <<'EOF' +import std; +import util2; +int main() { std::println("two = {}", two()); return two() == 2 ? 0 : 1; } +EOF +cat > mcpp.toml < build-flat.log 2>&1 || { cat build-flat.log; echo "flat form build failed"; exit 1; } +out=$("$MCPP" run 2>&1 | tail -1) +[[ "$out" == "two = 2" ]] || { echo "flat form unexpected output: $out"; exit 1; } + +echo "OK" diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 48343a0..54e79cc 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -194,6 +194,142 @@ package = { EXPECT_EQ(m->modules.sources[0], "*/src/*.c"); } +TEST(Manifest, DependenciesFlatDefaultNamespace) { + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" +[dependencies] +gtest = "1.15.2" +foo = { path = "../foo" } +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->dependencies.size(), 2u); + auto& g = m->dependencies.at("gtest"); + EXPECT_EQ(g.namespace_, "mcpp"); + EXPECT_EQ(g.shortName, "gtest"); + EXPECT_EQ(g.version, "1.15.2"); + auto& f = m->dependencies.at("foo"); + EXPECT_EQ(f.namespace_, "mcpp"); + EXPECT_EQ(f.shortName, "foo"); + EXPECT_EQ(f.path, "../foo"); +} + +TEST(Manifest, DependenciesNamespacedSubtable) { + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" + +[dependencies.mcpplibs] +cmdline = "0.0.2" +templates = { version = "0.0.1" } + +[dependencies] +gtest = "1.15.2" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->dependencies.size(), 3u); + + auto& cmdline = m->dependencies.at("mcpplibs.cmdline"); + EXPECT_EQ(cmdline.namespace_, "mcpplibs"); + EXPECT_EQ(cmdline.shortName, "cmdline"); + EXPECT_EQ(cmdline.version, "0.0.2"); + + auto& tmpl = m->dependencies.at("mcpplibs.templates"); + EXPECT_EQ(tmpl.namespace_, "mcpplibs"); + EXPECT_EQ(tmpl.shortName, "templates"); + EXPECT_EQ(tmpl.version, "0.0.1"); + + auto& gtest = m->dependencies.at("gtest"); + EXPECT_EQ(gtest.namespace_, "mcpp"); + EXPECT_EQ(gtest.shortName, "gtest"); + EXPECT_EQ(gtest.version, "1.15.2"); +} + +TEST(Manifest, DependenciesLegacyDottedKeyStillParsed) { + // Pre-namespace-aware mcpp.toml: quoted dotted key. + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" + +[dependencies] +"mcpplibs.cmdline" = "0.0.2" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->dependencies.size(), 1u); + auto& s = m->dependencies.at("mcpplibs.cmdline"); + EXPECT_EQ(s.namespace_, "mcpplibs"); + EXPECT_EQ(s.shortName, "cmdline"); + EXPECT_EQ(s.version, "0.0.2"); +} + +TEST(Manifest, DependenciesInlineSpecCoexistsWithSubtable) { + // `bar = { git = "...", tag = "..." }` looks like a subtable but has + // only dep-spec keys → treated as inline spec under default ns. + // `[dependencies.acme]` is a real namespace subtable. + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" + +[dependencies] +bar = { git = "https://example.com/bar.git", tag = "v1" } + +[dependencies.acme] +util = "2.0.0" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->dependencies.size(), 2u); + auto& bar = m->dependencies.at("bar"); + EXPECT_EQ(bar.namespace_, "mcpp"); + EXPECT_EQ(bar.shortName, "bar"); + EXPECT_EQ(bar.git, "https://example.com/bar.git"); + EXPECT_EQ(bar.gitRev, "v1"); + EXPECT_EQ(bar.gitRefKind, "tag"); + + auto& util = m->dependencies.at("acme.util"); + EXPECT_EQ(util.namespace_, "acme"); + EXPECT_EQ(util.shortName, "util"); + EXPECT_EQ(util.version, "2.0.0"); +} + +TEST(SynthesizeFromXpkgLua, DepsKeySplitNamespace) { + constexpr auto src = R"( +package = { + spec = "1", + name = "consumer", + xpm = { linux = { ["1.0.0"] = { url = "u", sha256 = "h" } } }, + mcpp = { + sources = { "*/src/*.cppm" }, + deps = { + ["mbedtls"] = "3.6.1", + ["mcpplibs.cmdline"] = "0.0.2", + }, + targets = { ["consumer"] = { kind = "lib" } }, + }, +} +)"; + auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "consumer", "1.0.0"); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->dependencies.size(), 2u); + + auto& a = m->dependencies.at("mbedtls"); + EXPECT_EQ(a.namespace_, "mcpp"); + EXPECT_EQ(a.shortName, "mbedtls"); + EXPECT_EQ(a.version, "3.6.1"); + + auto& b = m->dependencies.at("mcpplibs.cmdline"); + EXPECT_EQ(b.namespace_, "mcpplibs"); + EXPECT_EQ(b.shortName, "cmdline"); + EXPECT_EQ(b.version, "0.0.2"); +} + TEST(ListXpkgVersions, IgnoresCommentedEntries) { constexpr auto src = R"( package = {