upstream-audit · git:20261023.bf5949f · 2026-10-23 · sha256 9eed568b806e7729

upstream-audit git:20261023.bf5949fA

Immutable. This exact content never changes and is served at /api/v1/blob/9eed568b806e7729.

---
name: upstream-audit
description: 审计一个自建软件包能否安全地提交到上游 nixpkgs——**只读,到 dry-run 为止**。做四件事:先分流(首次贡献 vs 再次贡献 × 全新添加 vs 版本更新)、再评估可行性(是否已在 master、有无在途 PR、许可依据能否在**那个 tag 里**指认、维护意愿、依赖冲突)、再审计当前要求(by-name 结构、nixpkgs-vet 的 12 条检查与 3 条棘轮、meta 必填项、nixfmt、commit 前缀驱动 CI、名单 handle 必须匹配 commit 作者)、最后对着**一棵真的 nixpkgs 树**做 dry-run(构建成功不算数,要跑产物;并逐行通读要提交的那份 diff)。**本技能不执行任何写操作**:不 push、不建 PR、不以你的名义评论或回复——它产出「可提交/不可提交 + 证据」,提交动作属于另一层。
---

# 上游贡献审计(到 dry-run 为止)

**本技能的边界**:它**只读**。不 `git push`、不 `gh pr create`、不以任何人的名义评论或回复。
它回答的是「**这个包能不能提交,证据是什么**」,而不是替谁提交。

需要真正提交时,那些动作属于**带授权与账号约束的那一层**(本机是本机的一份私有预设
(不随本仓库分发)),不在这里。

**顺序不能换**:评估 → 审计 → dry-run。跳步的代价见陷阱清单。

---

## 第 0 步:先同步,再采信

**任何「现在的 nixpkgs 是什么样」的结论,都要现取。**

```bash
gh api repos/NixOS/nixpkgs/commits --jq '.[0].sha'   # 取 HEAD,写进你的报告
```

> 走过的坑:本地那份 nixpkgs 快照的 `CONTRIBUTING.md` 里**没有** AI 政策一节,
> 而 upstream master 里**有**。凭快照下结论就会漏掉整条最关键的硬要求。
> **文档结构会漂移**——`doc/contributing/` 目录现在已不存在,
> 只剩一个跳转存根 `doc/contributing.md`。路径必须实测,不能凭记忆。

同时把基线写进报告:**哪一刻、哪个 commit**。这决定了你的结论什么时候过期。

---

## 第 0.5 步:先确定走哪条路(四条,别走错)

**这一步放在评估之前** —— 因为你走的路决定了**后面哪几节根本不用读**,
也决定**这次要不要动 `maintainer-list.nix`**。

两个正交的轴:

| | **全新添加(init)** | **版本更新(update)** |
|---|---|---|
| **首次贡献**<br>(名单里还没有你) | ① 两个 commit:<br>`maintainers: add <handle>`<br>`<包名>: init at <版本>`<br>走全部评估与审计 | ② 一个 commit:<br>`<包名>: <旧> -> <新>`<br>**除非你想顺便加入名单,否则不动它** |
| **再次贡献**<br>(名单里已有你) | ③ **一个** commit:<br>`<包名>: init at <版本>`<br>名单**不要再动** | ④ **一个** commit:<br>`<包名>: <旧> -> <新>`<br>最短路径 |

### 关键:维护者条目是**一次性**的

**名单是「你是谁」的登记,不是「这次做了什么」的记录。**
一旦 `maintainers: add <handle>` 被合并,**以后每次贡献都不再碰这个文件** ——
再往列表里插一遍是重复登记,会与已合并的那条冲突。

判断方法(别靠记):

```bash
gh api "repos/NixOS/nixpkgs/contents/maintainers/maintainer-list.nix?ref=master" \
  -H 'Accept: application/vnd.github.raw' | grep -n '^  <你的handle> = {' \
  || echo "不在名单里 → 走首次贡献"
```

### init 与 update 的差异不止 commit 标题

| | init | update |
|---|---|---|
| 可行性六问(§1) | **必做** | 跳过——包已在树上,位置由既有文件决定 |
| 放哪(§2.1) | **必做** | 跳过——文件已存在,只改里面的字段 |
| `meta` 必填项(§2.2) | **必做** | 只在顺手修 meta 时看 |
| 取源与 hash(§2.3) | 要,且要选对 fetcher | 要,但**只换 hash 与 version** |
| 维护者条目 | 首次贡献要 | **不要** |
| commit 标题 | `<包名>: init at <版本>` | `<包名>: <旧> -> <新>` |
| commit 正文 | 为什么需要这个包、特殊选择的理由 | **必须给 release notes / changelog 链接**(`pkgs/README.md` 明写) |
| CI 自动构建 | 由包名前缀驱动 | 同左 |
| 版本语义变化时 | — | **文档要重写,不是机械替换**(见姊妹技能 `nix-flake-update-check`) |

> **最常见的一种走法**:包是我们先提交上去的,之后自己跟版本。
> 那就是 **③④ 那一行** —— **一个 commit,名单不碰**,比首次贡献少一半工序。
> 别把首次那套(评估 → 审计 → 双 commit)不假思索地重放一遍。

---

## 第 1 步:评估可行性(每个包一条,逐条给证据)

六问,任一答「否」就先别做:

**① 它已经在 nixpkgs 里了吗?**

```bash
gh api "repos/NixOS/nixpkgs/contents/pkgs/by-name/${shard}/${name}/package.nix?ref=master"
# 404 = 不在。200 = 在,那就只能提 update,不能提 init。
```

`shard` = 包名前两个字符的小写。**别只查这一条路径**——
包也可能在类别目录里(`pkgs/<类别>/<包名>/default.nix`)。
再用属性名求值一次最稳:

```bash
nix eval --raw "path:<nixpkgs>#${name}.name"   # 报 NOT-A-PACKAGE 就是没有
```

**② 曾经进过 nixpkgs、后来被删了吗?**

```bash
gh api -X GET search/commits -f q="repo:NixOS/nixpkgs ${name}" --jq .total_count
gh api -X GET search/issues  -f q="repo:NixOS/nixpkgs ${name}" --jq .total_count
```

被删过的要**先找删除理由**(通常 `包名: remove`),理由可能就是现在仍然成立的理由。
搜索全 0 也不能反过来说「绝不存在过」——
**证据为负不是证明**,要在报告里把这个区别写清楚。

> 走过的坑:一个包定义里的注释写着「nixpkgs 已不再提供它」,
> 而九条独立取证全是 0。真实来源是**我们自己仓库的 CI workflow** 记混了。
> **仓库里的自述会过期**,它不是证据。

**③ 有在途 PR 吗?** 这一条最容易白干。

```bash
gh api -X GET search/issues -f q="repo:NixOS/nixpkgs is:pr ${name}" --jq '.total_count, .items[].title'
```

**要按「上游项目名」而不是「我们的包名」搜。** 同一个软件的包名可能是别的写法——
见过的实例:我们叫 `dsh`,nixpkgs 里三个在途 PR 都叫 `deepseek-harness`。

**④ 许可能指认吗?——必须是那个 tag 里的文件**

这是最容易被跳过、也最容易被审阅者当场卡住的一条。

**要检查的是「我们实际取源的那个 tag/rev」里有没有许可,不是仓库的 main 分支。**

> 走过的坑:某个包的 `main` 上有 LICENSE(最近才加的),
> 而我们取源的 tag 比那次提交早十八天——**tag 里没有许可文件**。

**但「没有 LICENSE 文件」不等于「没有许可」。** 先找这三处,再决定要不要向上游要:

1. **源文件头**:`# SPDX-License-Identifier: <SPDX 表达式>`。
   直接在**构建出来的产物**里 grep(`grep -rl SPDX-License-Identifier $out`),
   比读仓库可靠——它证明的是我们真正取到的那份源码。
2. **清单文件**:Python 的 `pyproject.toml`、Blender 插件的 `blender_manifest.toml`、
   Node 的 `package.json` 等,常带 `license` / SPDX 字段。
3. **上游 issue 里有没有人问过**:搜 `license`,看维护者怎么答的。
   先例:某包有人提过「仓库没有许可文件」,维护者回
   「licence is in the individual files」并随后补了 LICENSE——**那条 issue 就是最有力的依据**,
   引它比引 main 上那份后加的文件强得多,而且**说明不需要再开一条**(有人做过的事不要重复做)。

**顺序是**:先查这三处 → 证据足就照提并在 PR 里写清依据 → 不足才向上游要。
**装没看见是最坏的选项。**

顺带核许可类型:nixpkgs 的 `lib.licenses` 只收自由许可;
`unfree` 进不了 channel,`unfreeRedistributable` 可再分发但不能改二进制。

**⑤ 有维护意愿吗?**

新包会被问:「你愿意维护它吗,至少一个完整的 Nixpkgs 发布周期?」
(原文:one complete Nixpkgs release life-cycle)

这不是客套。**在 `maintainer-list.nix` 里写下自己的 handle,就是接受这个承诺。**

**⑥ 依赖会不会与 nixpkgs 共用包集冲突?**

这是最硬的一条技术门槛,也是最容易被忽略的:

- 如果包对某个依赖**锁死精确版本**(运行期 fail-closed 校验那种),
  而 nixpkgs 里那个依赖是**全仓共用**的一份——**冲突无法调和**。
  抬版本会牵动整个仓库,审阅者不会为了一个包动它。
- Python 包集、Node 包集、Qt 包集都是共用的。
- 依赖**已经在 nixpkgs 里**且版本满足约束的,是最好的情况:第一个 PR 不需要连带任何东西。

顺手核上游健康度(活跃度、issue 响应、有没有归档):
「太不成熟」和「马上要被放弃」的项目,nixpkgs 的 Quick Start 明说不收。

---

## 第 2 步:审计当前要求

### 2.1 文件放哪

```
pkgs/by-name/<两字母小写前缀>/<包名>/package.nix
```

**不需要改 `all-packages.nix`**。补丁文件**直接放包目录里**,不需要 `patches/` 子目录。

`nixpkgs-vet` 在 CI 里强制 12 条检查
(出处:<https://github.com/NixOS/nixpkgs-vet/blob/master/README.md#validity-checks>)。
其中三条是**棘轮(ratchet,只禁新增)**,新包必须满足:

- 用 `pkgs.callPackage` 的新顶层包**必须**走 package directory,进去就不能搬回类别目录;
- 新顶层包必须 `strictDeps = true`,且不能回退成 `false`;
- 新顶层包必须 `__structuredAttrs = true`,且不能回退成 `false`。

**`__structuredAttrs` 到底改了什么 —— 实测过的 A/B**:

| | 不启用 | 启用 |
|---|---|---|
| `strictDeps` | `env` 里的**字符串** `"1"` | `structuredAttrs` 里的**布尔** `True` |
| `cmakeFlags` | **空格拼成的一个字符串** | **JSON 真数组** |
| `nativeBuildInputs` | env 里的字符串 | store 路径**列表** |
| `postInstall` 等 | env 变量 | 结构化字段 |

三条实测结论:

- **对产物没有任何影响** —— 同一个包在启用前后,`.so` **逐字节相同**
  (`4e308a2793ae…`),`config.json`、`en-US.ini` 也相同。store 路径变了、
  里面装的东西没变。**所以它是纯粹「更正确」,零附带改动。**
- **数组形态更抗错**:原来的空格拼接在「某个标志里带空格」时会裂开,
  结构化数组不会。**所以原本用 list 的 `cmakeFlags` 不需要改成字符串。**
- **`env` 只放要导出的**:属性搬进 JSON 后 `env.strictDeps`、`env.cmakeFlags`
  都不见了 —— 想让某个变量真的出现在环境里,得写进 `env`。`passAsFile` 同时被禁用。

`doc/stdenv/stdenv.chapter.md` 的原话:**"is the preferred default"**,以及
**"All new top level packages must enable `__structuredAttrs`"**。
**后半句限定「顶层包」** —— 住在具名集合里的包(`obs-studio-plugins.*`、
`python3Packages.*`)**不受 nixpkgs-vet 强制**,但审阅者仍会提
(原话通常是 "Might also try setting `__structuredAttrs`?"),因为那是全仓方向。

其余要记住的:

- `name` 只能用 ASCII `a-z A-Z 0-9 - _`,**不能以数字或 `-` 开头**;
- `shard` 必须是 `toLower (substring 0 2 name)`;
- 每个包目录必须有 `package.nix`;
- **包目录不得引用自身目录之外的文件**(symlink 或 Nix 路径表达式都不行)——
  这条把「多版本包共享文件」挡在门外;
- 求值结果必须是**一个 derivation**(`lib.isDerivation` 为 true),不能是包的集合。

by-name 的两条限制:只收 `pkgs.callPackage` 风格的包(**排除** `python3Packages.callPackage` 那种写法)、
只收顶层包。

> 注意一个常见误解:**用 `python3Packages.buildPythonApplication` 是可以放 by-name 的**。
> nixpkgs 里有大量这样的包(例如 `pkgs/by-name/in/input-remapper/package.nix`)。
> 被排除的是「用 `python3Packages.callPackage` 去调用」,不是「用了 python 的 builder」。

不满足 by-name 时退回类别目录(`pkgs/<类别>/<包名>/default.nix` + `all-packages.nix`),
但类别目录是**部分弃用、会逐步迁走**的,优先级更低。

#### 2.1.1 第三种去向:包属于某个**具名集合**

by-name 与类别目录不是全部。有一类包住在**自己的属性集**里,由一份专门的
`*.nix` 组装 —— `obs-studio-plugins`、`vimPlugins`、`python3Packages` 都是。
这类包**不需要、也无处**往 `all-packages.nix` 加一行,但前提是你要**先确认那个
集合的发现机制是什么**。

**唯一可信的判据是对着一棵真的树求值:**

```bash
nix eval --impure --raw --expr '
let pkgs = import /path/to/nixpkgs { system = "x86_64-linux"; };
in if pkgs.obs-studio-plugins ? obs-bilibili-stream
   then "可达:${pkgs.obs-studio-plugins.obs-bilibili-stream.name}"
   else "不可达"'
```

**为什么非要用求值**:机制会变,而且变得很彻底。OBS 插件曾经要求在
`plugins/default.nix` 里手写一行;2026-06-26 的 commit `4a317fa67073`
("obs-studio-plugins: migrate to packagesFromDirectoryRecursive")**把那个文件整个删掉**
(120 行 → 8 行),换成:

```nix
# pkgs/applications/video/obs-studio/plugins.nix(全文)
{ lib, callPackage }:
lib.packagesFromDirectoryRecursive {
  inherit callPackage;
  directory = ./plugins;
}
```

此后平铺的 `plugins/<名字>.nix` 自动变成属性(函数文档:
"If a regular file's name has the `.nix` extension, it is turned into attribute where
the attribute name is the file name without the `.nix` extension"),
**再没有任何可加的入口**。

> **踩过的坑(2026-10-16)**:狐莉翻到一条 2026-03 的旧 PR(#500675),它改的正是
> `plugins/default.nix` —— 从形状上看我们的提交「确实缺了入口」。但那条 PR 是
> **未合并就关闭**的,而它改的文件三个月后已被删除。**旧 PR 是可靠的历史,
> 不是可靠的现状。**
>
> 复核顺序:**别读旧 PR,读现在组装这个集合的那份文件**,然后求值确认。
> 三条独立证据一起用:① 求值可达;② `nixpkgs-review` 报 `1 package added`;
> ③ CI 的 `Eval Summary` 报 `Package: added 1` —— 后两条都要求属性存在,
> 属性不在的话它们会静默地报 0。

### 2.2 meta 必填项

| 项 | 要求 |
|---|---|
| `description` | 一句话、首字母大写、不以冠词开头、**不以包名开头**、**不以句点结尾**、陈述事实 |
| `license` | **必须**设置,且与上游一致;无上游许可时默认 `unfree` |
| `sourceProvenance` | 非从源码构建时**必须**设置(重打包 `.deb`/`.rpm`/`.whl`/AppImage → `binaryNativeCode`) |
| `mainProgram` | 主可执行文件名(**硬编码字符串**,别用 `pname`);多个且无主次时不设 |
| `maintainers` | **新包必须设置** |

`meta` 属性集**放在 derivation 最后**,`passthru` 之类的写在它前面。

`meta.platforms` 在文档之间措辞不一致(必填清单里没有它,但评审检查表说 should set)——
**按要设来准备**,不确定就问 committer。

### 2.3 取源与 hash

- **总是**用 nixpkgs 的 fetcher,`sha256`(SRI 格式);
- 从 GitHub 取源**必须用完整 commit hash**(短 hash 会被解析成别的);
- 自带 forge(Gitea / Forgejo):`fetchFromGitea` 要写 `domain`,
  字段名是 **`tag`** 而不是 `rev`;
- **能远程取的补丁不要 vendored**(用 `fetchpatch2` / `fetchpatch`);
- npm 包用 `fetchNpmDeps` / `importNpmLock` 一类固定依赖,**构建期不得联网**。

### 2.4 commit 与 CI

格式 `包名: init at 版本`。**冒号前缀不是风格问题——它决定 CI 会不会自动构建这个包。**

- 摘要行末尾**不加句点**;
- 每个逻辑单元一个 commit;
- 加维护者身份**必须单独一个 commit**,标题固定 `maintainers: add <handle>`,
  且**排在包那个 commit 之前**;
- 条目必填 `name` / `github` / `githubId`,`github` 与 `githubId` 要能对上
  (审阅者会访问 `https://api.github.com/user/<githubId>` 核 `login`);
- ⚠️ **名单里的 `github` 必须等于 commit 作者的 GitHub 账号。**
  原文(`maintainers/README.md`):

  > First, make sure that the listed GitHub handle matches the author of the commit.

  **这条决定了「用哪个账号提交」——它是硬约束,不是偏好。**
  不是先挑个账号、再往名单里填个名字;是**名单写谁,就得谁提交**。
  「想让自动化账号当联络人、又用人类账号提交」这种组合**不可能同时成立**。

  关联依据是 **`noreply` 邮箱里的数字前缀**,名字字符串随便写:

  ```bash
  # 作者行这么写,GitHub 就把 commit 关联到 Kihara777
  狐莉 <24633616+Kihara777@users.noreply.github.com>
  #                     ^^^^^^^^^ 这个数字 = api.github.com/users/Kihara777 的 id
  ```

  核验方式(推送后读回来,别只看本地):

  ```bash
  gh api 'repos/<owner>/<repo>/commits?sha=<branch>&per_page=2' \
    --jq '.[] | "\(.commit.author.name) → GitHub: \(.author.login // "(未关联)")"'
  ```

  > **代价实测**:2026-10 的一次提交正是因为忽略了这条 —— 名单写 `Kihara777`、
  > commit 由另一个账号做,审阅者第二遍 `CHANGES_REQUESTED` 直接引用这条规则,
  > 并同时援引行为准则里「禁止用低质量贡献浪费他人时间」的条款,PR 被关闭。
  > **打包本身没问题**(审阅者原话 "The PR itself seems technically fine to me")——
  > 死在的是这条身份规则上。
  >
  > 修法不是改包,是**换提交账号**:审阅者(K900)的结论就是
  > "please just use your personal account as a point of contact."。
- **不需要 `--signoff`,nixpkgs 没有 DCO**;
- **不需要先开 issue**——package request 模板的原文就是
  「Package requests are no longer accepted. Please open a Pull Request with your desired package instead.」;
- 所有 Nix 文件必须过 **nixfmt**(RFC 166),CI 由 treefmt job 强制。

### 2.5 AI 贡献政策(先读这条,再决定怎么分工)

出处:nixpkgs `CONTRIBUTING.md` 的 `#automationai-policy` 一节。

要点:

- 每份贡献**必须有一位在环里、对内容负责的人**,在提交前审阅它;
- LLM 产出**不能只靠信任工具本身**——只允许「人工审阅产物」或「用另一个工具验证产物」。
  原文:**vibe coding without review is not permitted**;
- 披露**必须**写成 commit trailer:`Assisted-by: <工具名> <模型名与版本>`。
  **`Co-authored-by:` 不算披露**;
- PR 摘要与评审评论要**各自单独披露**,不能只写在 commit 里;
- 贡献者要**能自己回答关于这份贡献的问题**,不能把评审意见来回转发给工具;
- 例外:AI 用在**被打包的上游软件本身**不在范围内;
  用在研究、测试、调试、私有审阅也范围外(有实质技术影响时仍要负责)。

**⚠️ 模型名写哪一个——有过一次教训**:

同一件事有三层名字,**披露要写给人看的那一层**:

| 层 | 例子 | 用途 |
|---|---|---|
| API / 配置里的 id | `deepseek-flash` | 写 DSH 配置、代码里的 `model = "…"` |
| **后端的显示名** | **`DeepSeek-V41-Flash`** | **写进 `Assisted-by:` 与披露正文** |
| 产品线名 | `DeepSeek V4 Flash` | 营销文案 |

踩过的坑:我先是**从仓库作者行抄了产品线名**(没核过),
后来改成**从系统提示词里读到的短 id** —— 两次都不是显示名。
最后是狐莉给的准。

**规矩**:披露里的名字**问一下,不要自己挑**。
三个来源(提示词、仓库文档、界面)可能给三个不同的串,而**只有一个是写给人看的**。

> 这条与第 3.5 节的逐行四问是同一族:**我写的字要经得起别人按字面读。**
> 披露的价值全在准确——写错名字的披露,比不写更难看。

**执行方式要分两层说,别合成一句「没人看」**:

- **不强制**:`ci/github-script/lint-commits.ts` 不校验 AI trailer,缺了不会让构建变红;
- **但会被认出来**:`ci/github-script/bot.ts` 里 `const assistedByPattern = /Assisted-by: (?!nix-init)/i`
  命中即打 **`llm-assisted`** 标签(实测:本仓第一个 PR 就是这样被标上的)。

**不阻断 ≠ 无人看见。** 写下「没有机器看着这条政策」是错的——机器在看,只是不拦。
提议时就把合规形态做出来:commit trailer 写全,PR 摘要里独立写一段披露。

### 2.6 自套一遍审阅者的清单(提交前)

`pkgs/README.md` 的 **New packages → Reviewed points** 是审阅者会逐条走的表。
**提交前自己先走一遍**,每条都要能指出现场证据:

| 审阅者会看 | 你要能回答 |
|---|---|
| 文件路径合不合规 | 为什么放这里(`by-name` / 类别目录 / 具名集合,见 §2.1) |
| 包名、版本合不合规 | `pname` 是否等于上游名、`version` 是否数字开头 |
| 能不能在目标架构上构建 | 建过哪个架构,**没建的不要勾** |
| **可执行文件都跑了吗** | 有 `bin/` 就跑;**没有 `bin/` 就说清没有**,并说明改用什么判据 |
| `description` / `license` / `platforms` | 与上游一致,且理由**写在注释里** |
| **`mainProgram` 该不该设** | 有主可执行文件才设;没有就不设 |
| 构建期依赖是否都在 `nativeBuildInputs` | |
| 取源是否官方、是否用了合适的 fetcher | |
| **特殊打包选择的动机有没有写下来** | 非默认构建标志、被删掉的安装步骤、被禁用的检查 |
| `phases` 有没有被覆盖 | 覆盖了的话,里面有没有 `runHook preInstall` / `postInstall` |
| 补丁是否有说明、是否该远程取 | |

**「报告拼写错误」也在清单里** —— 通读一遍拼写与措辞,不是可选项。

> 清单里最容易被当成形式主义、实际最容易翻车的是**「特殊选择的动机」**那一格:
> 它问的不是「你有没有做过特殊的事」,而是**别人将来能不能安全地把它改掉**。
> 写了「上游 CMakePresets 打开了这两个开关、而 CMakeLists 默认关」,
> 后来者才知道什么时候可以删掉这两行。


### 2.7 依赖 qtbase 就必须声明包装意图(否则构建在最早期就失败)

**任何 `buildInputs` 里有 `qt6.qtbase` 的包,必须在「应用」与「库/插件」之间表明身份**,
否则 Qt 的钩子直接拒绝构建 —— 实测报错原文:

```
Running phase: qtPreHook
Error: this derivation depends on qtbase, but no wrapping behavior was specified.
  - If this is an application, add wrapQtAppsHook to nativeBuildInputs
  - If this is a library or you need custom wrapping logic, set dontWrapQtApps = true
```

| 你打包的是 | 要写 |
|---|---|
| **应用**(自己有 `bin/`,要带上 Qt 的运行时环境) | `nativeBuildInputs` 里加 `wrapQtAppsHook` |
| **库 / 插件**(OBS 插件、KDE 的 `.so`、Qt 插件) | `dontWrapQtApps = true;` |

> **这是「必需」不是「可选」** —— 删掉 `dontWrapQtApps = true;`,构建立刻红。
>
> **⚠️ 一个我差点写错的推断**:曾想过「这个包没有 `bin/`,包装钩子无事可做,
> 所以删掉也没影响」。**错了** —— 钩子在 `qtPreHook` 阶段(早于任何安装)
> 就检查这个前提,**它不管你有没有 `bin/`**。
>
> **判据的教训**:「没量过」不等于「没影响」。要验证某个标志是否被读到,
> **把它注释掉再构建** —— 报错就是被读到了。这个实验比读源码推理快且可靠。

---

## 第 3 步:Dry-run(不动 GitHub)

### 3.1 怎么对着一棵真的 nixpkgs 树构建

**不要复制 nixpkgs 树**,更不要对它递归 `chmod`(见第六节)。
直接让 Nix 用 flake 语法读它:

```bash
# 先确认那棵树能当 flake 用
nix eval --raw "path:<nixpkgs-path>#hello.name"
```

然后在一个临时目录里写包文件与一个求值入口:

```nix
# /tmp/draft/build.nix
let
  pkgs = import (builtins.getFlake "path:<nixpkgs-path>") { system = "x86_64-linux"; };
in
pkgs.callPackage /tmp/draft/package.nix { }
```

```bash
nix eval  --impure --raw --expr '(import /tmp/draft/build.nix).drvPath'   # 先只求值
nix build --impure --no-link --print-build-logs --expr 'import /tmp/draft/build.nix'
```

**好处**:nixpkgs 树只读,`/tmp` 里是想改就改的草稿路径,出不来 `chmod` 事故。

### 3.2 判据(四层,缺一层都可能是假绿)

1. **求值通过** —— 依赖都能在 nixpkgs 里解析到;
2. **构建通过** —— 退出码 0;
3. **产物对** —— 核验 `$out/bin/` 里有该有的可执行文件、`$out/share/` 里有该有的数据文件;
4. **产物能跑** —— **真的敲一下它**。

第 4 层最关键。构建成功只说「编译过了」,不说「能用」——
历史上验过:某个包的 CI 长期靠缓存「成功」,日志里一行构建都没有,
而它一旦真去构建又会一构建就崩。

第 4 层怎么做取决于包的类型,思路是**喂一个最小的真请求**:

- CLI:`--version` / `--help`,最好再跑一次真实子命令;
- MCP / RPC 服务:喂一条最小的协议帧(例如 JSON-RPC `initialize`),看回包;
- 库:`pythonImportsCheck` / 写个最小调用方;
- **宿主会加载的插件**:让宿主真的把它装起来跑一遍,读**宿主自己写的日志**;
- **带原生模块的应用**:加载那个模块并**做一次真操作**(开库 → 建表 → 写入 → 读回)。

#### 第 4 层必须带一条**同强度**的反证臂

「日志里有它」「模块加载成功」这类断言,**不加对照臂就什么都没证明** ——
它可能只是宿主念了一遍目录名,或者那个模块本来就在别处被加载过。

做法:用 `overrideAttrs` 造一份**故意坏掉**的派生,让同一条断言**必须失败**。

```nix
# 例:把插件真正依赖的那份预编译删掉 / 把 postInstall 去掉
base.overrideAttrs (_old: { postInstall = ""; })
```

**反证臂必须与正臂同强度**,否则它给出的安心是假的:

- 正臂「构造实例 + 建表」而反证臂只写 `require()` —— **`require` 是懒的**,
  它自己成功、不碰绑定,于是反证臂报「仍然成功」。**那是反证臂太弱,不是判据坏了。**
- 正臂跑 `doctor` 而反证臂只跑 `--help` —— 强度不同,测的不是一件事。

**别手工改 store 里的副本去造假样本。** store 是只读的,`cp` 出来的副本 `rm` 会
全部 Permission denied(那个「反证」什么都没改却报 ok);而 `chmod -R` 会顺着
符号链接打到 `/nix/store`。**用 `overrideAttrs` 让 nix 自己产出那一份。**

### 3.2.1 测试:判据是**条数**,不是退出码

如果上游带测试套件,把它接进构建是**最便宜的一层判据**。但有两个坑:

**① 退出码证明不了测试跑过。** 上游的测试配置里常见这类开关:

```
# vitest.config.ts
test: { passWithNoTests: true }
```

于是「一条都没收集到」也是**绿的**。**必须看条数,并把它写成基线。**

**② 条数要从日志的**最后一行**读。** vitest 这类跑边打进度:

```
Tests  0 passed (0)        ← 进度
Tests  252 passed (633)    ← 进度
…
Tests  2747 passed | 7 skipped (2754)   ← **只有这行是总结**
```

第一次 grep 抓到第一行,就以为一条没跑 —— **同一个日志,两个方向都能读错。**

**先例**(两个包,都是「构建全绿而测试一个没跑」):

- 一个 python 包**没有 `nativeCheckInputs`**,pytest 从不启动;
  接上后 `102 passed, 9 skipped`。
- 一个 npm 包**没有 `doCheck`/`checkPhase`**;接上后
  `201 files / 2754 cases`,`2747 passed`。

**两处都是「构建通过」完全看不出来的。**

### 3.2.2 跨 nixpkgs 版本比对产物:**比字节会骗你**

拿「上游 CI / `nixpkgs-review` 建出来的产物」与「你自己对着另一棵 nixpkgs 树的 dry-run
产物」比 sha256,**常常不相等** —— 而这是**预期**,不是缺陷。

原因:Nix 的路径哈希包含完整依赖闭包。两棵树里 `glibc` 从 `2.42-84` 变到 `2.44-25`、
`gcc` 从 15 变到 16,同样的源码就会编出**不同的字节**(实测:44.6 万字节里 38.3 万不同,
但 `NEEDED` 集合与文件清单完全一致)。

**正确的等价判据是「产物路径相同」,而不是「字节相同」:**

```bash
# 对着与对方**同一棵** nixpkgs 重建,看产物路径是否一致
NIXPKGS_PATH=/path/to/that/same/tree nix build --impure --no-link --print-out-paths ...
# 与对方报出的 /nix/store/<hash>-<名字> 逐字比
```

产物路径相同 ⇒ 派生相同 ⇒ **内容相同**。这是同一棵树内部的比较,所以判据成立;
问题只出在「我以为两边是同一棵树」。

> **追问链(这次实际走了一遍)**:字节不同 → 是缺陷吗?→ 看 `NEEDED` 集合
> → 相同,只有 `RUNPATH` 指向不同的 glibc/gcc → 猜测是 nixpkgs 差异
> → **不猜,用同一棵树重建** → 产物路径完全相同 → **证明**差异 100% 来自 nixpkgs。
>
> **「解释得通」和「证明是它」是两件事。** 停在前者的代价是:将来真出问题时,
> 你会拿同一个解释把自己劝住。

### 3.3 格式与结构

```bash
nix run <nixpkgs-path>#nixfmt -- --check /tmp/draft/package.nix   # 退出码 0 = 已符合
```

by-name 的结构检查要**人肉对照**第 2.1 节那 12 条
(本地跑 `./ci/nixpkgs-vet.sh master` 需要完整 nixpkgs 仓与 base 分支,草稿阶段不划算)。

### 3.4 把 dry-run 产物留在仓库里

dry-run 的 `package.nix`、PR 摘要草案、披露文本要**进版本库**,
这样它们可复核、也能被自检盯住。留在 `/tmp` 里等于没有。

**单一来源,禁止副本。** 提交时要用哪份内容(commit 正文、PR 正文),
就让那个文件**就是**那一份,用 `git commit -F <文件>` 直接喂。

> 走过的坑:我在 `/tmp` 里用 `sed` 改了模型名与一个数字,
> **没写回仓库的源文件**。于是仓库那份与已发布的那份不一致,
> 而仓库那份里还留着一个错数字,下一次谁用它谁就带走那个错。

### 3.5 **逐行通读你要提交的那份 diff**(硬闸门)

**这一步不能跳过,而且它不是「再跑一遍判据」。**

判据抓不到的东西,全在这一步:

| 评审者真提过的问题 | 为什么判据不响 |
|---|---|
| `meta.maintainers` 是空的 | 构建不关心 `meta`,`nixpkgs-vet` 也不关心 |
| 注释里引用了**本 PR 里不存在**的文件 | 注释不参与任何检查 |
| 注释语言是中文,而 nixpkgs 要英文 | 没有判据管语言 |
| `nativeBuildInputs` 里留了**已无人使用**的 `makeWrapper` | 多一个输入不影响构建 |
| `doCheck = true` 其实是冗余的 | 设不设都一样跑 |
| commit 正文里的数字与实测不符 | 数字写在正文里,没人核 |

**做法**:把两份文件渲染成评审者会看到的样子,然后**逐行读**:

```bash
# 改动版与 base 的 diff
diff -u <base 那份> <改动版>
# 新增文件全文(带行号读)
awk '{printf "%3d| %s\n", NR, $0}' package.nix
```

**逐行问四个问题**(顺序即优先级):

1. **这一行里有没有指向「本 PR 之外」的东西?** 路径、文件名、仓库名、内部术语——
   审阅者看不到它们,看到就会问。**内部决策史、给下一个自己的提示,一律不进去。**
2. **这段注释是写给谁的?** 写给 nixpkgs 审阅者的用**英文**;
   写给本仓的搬回本仓文档。**本仓惯例是中文,nixpkgs 要英文——提交时必然冲突,
   必须在提交前换掉。**
3. **这一项是必须的吗?** 每个输入、每个设置都要答得出「不写会怎样」。
   答不出就删(`buildPythonApplication` 的 wrap hook 已带 `makeWrapper`;
   python 包的 `doCheck` 被强制 `false`、`doInstallCheck` 默认 `true`)。
4. **这句话/这个数字,我现在能验证吗?** 不能就别写,或者写「未验」。

**并且把「哪两个文件、什么关系」写下来**:改的文件 vs 新增的文件、
维护者条目 vs 包里的引用——**同一 PR 内必须自洽**
(加了 `grg41` 条目,包就必须引用它;否则审阅者会指出你加了个没人用的条目)。

> 评审者的原话值得记住:
> **"Using AI assistance does not replace your responsibility to review the generated changes.
> Please ensure the entire diff is properly reviewed before requesting another review."**
>
> 「能不能构建」不是判据。**构建不关心注释、不关心 `meta`、不关心你说的是不是实话。**

### 3.6 用 `nixpkgs-review` 复核(它有三个会骗人的坑)

`nixpkgs-review` 是审阅者会看的那份证据,但它**有两处会给出误导性的绿**。

**坑一:报告为空 ≠ 没有问题。**

它只遍历**求值成功**的属性。包求值失败时,那个属性直接从列表里消失,
于是报告是「没有任何受影响的包」+ 退出码 0 —— **一次失败的求值,读起来像一次干净的通过**。

判据:**你明明新增了一个包,报告里却没有它 = 求值炸了**,不是「没什么可测的」。

**坑二:绿了但全是缓存 —— 那次运行没有验证力。**

产物已在 store 里时不会重建,日志是**空的**。看日志:

```bash
L=~/.cache/nixpkgs-review/<rev-…>/logs/<attr>-x86_64-linux.log
wc -l "$L"                       # 0 行 = 没重建,这次的绿是缓存
grep -c "building '/nix/store" "$L"   # 有这几行才算真建过
```

**坑三(对本仓库最要命):`pr` 子命令审不了 fork 里的 PR。**

`nixpkgs-review pr <n>` 把 PR 号**硬查在 `NixOS/nixpkgs`** 上;
`-r` 只改 git 取源的 remote,不改查询目标。实测:

```bash
nixpkgs-review pr 1 -r Kihara777/nixpkgs
# → 取到的是 NixOS/nixpkgs#1(别人的 glibc PR),因为 -r 只是换取源
```

**fork PR 要用 `rev`,喂它 PR 的 head sha:**

```bash
cd /path/to/bare-nixpkgs.git          # 需要是**裸仓**;它会验证 nixos/release.nix
export GITHUB_TOKEN=$(gh auth token)
HEAD=$(gh pr view <n> --repo <owner>/nixpkgs --json headRefOid --jq .headRefOid)
nixpkgs-review rev "$HEAD" --no-shell
```

**等价性要说清楚**:`rev` 用的是本地那个 commit 对象,而它与远端 head **sha 相同**
(同一个 sha 即同一份内容,这是密码学保证)。所以「`rev` 绿了」= 「PR 这个 commit 绿了」。
反过来,若只想审 fork PR 而当 PR 号不可用,**这条等价性就是你的依据**。

### 3.7 写 PR 正文:**短**,而且三层披露各就各位

**⚠️ 这条来自一次批准时附带的反馈。** 审阅者 `jm5905938` 给 `APPROVED` 时写的是:

> **LGTM, thanks for your work on this !**
>
> One minor suggestion: **the verification section could be a little shorter.**
>
> Hope to see more contributions from you :)

**他批准了,然后建议你把验证那一段写短点。** 这条要当硬约束看待,不是客套:

- **审阅者是扫读的。** 他要在几十条 PR 里判断「这个能不能合」,
  **不是来读你的证据链全文**。你写得多长,成本就落在他身上。
- **PR 正文不是验证报告。** 它只需回答:**这是什么 / 为什么要 / 有没有踩到规则**。
- **细则有更好的家**:判据推理、报错原文、每一步怎么跑的 → **commit message**、
  仓库里的 draft 目录、你自己的日记。**这些地方才是它们的家。**

**该留的:**

| 留 | 为什么 |
|---|---|
| 这个包是什么、上游在哪 | 审阅者要判断有没有重复、是不是恶意包 |
| 许可依据(**一句**,指向证据) | 他会查;给他一条最短路径 |
| 特殊打包选择(**一两条**,每条一句) | 他最关心「你为什么和默认不一样」 |
| 验证结论(**几行**,不是几段) | 「建过 / 跑过 / 对照臂干净」就够;原始日志不必贴 |
| **三层 AI 披露**(见 §2.5) | 政策要求,不能省——**但可以短** |

**该删的:**

- 把每条判据逐步展开叙述(「第 1 层……第 2 层……」);
- 重复 commit message 里已经写清楚的东西;
- 「我做了什么」的流水账。

> **和写维护日志是同一个形状**:「**摘要**是摘要,不是事态描述」。
> 判据很直白:**如果你在正文里解释「这个报错为什么是这么来的」,那一段就不该在这里。**
>
> 这次的正文里,「How the plugin was verified」+ 披露里那个三段清单 **是重复的** ——
> 同一件事说了两遍,两遍都不短。**先数一遍:同一件事实出现了几次。**

---


---

## 第 3.5 步:预演 PR(在 fork 内建,不碰上游)

第 3 步的 dry-run 证明「包能构建、产物能跑」。这一步证明另一件事:
**这个改动作为 PR 是干净的**——一个文件、一个 commit、可合并。

两者不能互相替代:dry-run 不知道你的 git 历史长什么样,而 reviewer 第一眼看的是 diff。

### 为什么值得做

- 把「包对不对」与「提交形态对不对」分开验证;
- 全程不碰上游:PR 建在**自己的 fork** 里,base 也是 fork 里的分支;
- upstream PR 真开出来时,预演过的分支可以直接用,不用重来。

### 步骤

```bash
# ① 本地:基于最新 master 建分支并提交
cd /path/to/nixpkgs
git fetch origin master --depth=1 && git checkout -B <branch> FETCH_HEAD
# 写 pkgs/by-name/<前缀>/<pkg>/package.nix
git add -A && git commit -m "<pkg>: init at <ver>"

# ② fork 侧:先建一个与 head 同源的基准分支
git push fork <base-sha>:refs/heads/staging

# ③ 推 head 分支
git push fork <branch>:<branch>

# ④ 在 fork 内建 PR(--repo 指 fork,不是 NixOS/nixpkgs)
gh pr create --repo <owner>/nixpkgs --base staging --head <branch> \
  --title "<pkg>: init at <ver> (dry-run, do not merge)" \
  --body "Dry-run PR inside the fork only. Not intended for upstream."
```

### 判据:看 diff 大小,不是「PR 建成功」

```bash
gh pr view <号> --repo <owner>/nixpkgs \
  --json baseRefName,changedFiles,commits,mergeable \
  --jq '{base: .baseRefName, changedFiles, commits: (.commits | length), mergeable}'
```

- ✅ `changedFiles: 1`、`commits: 1`、`mergeable: MERGEABLE`
- ❌ `changedFiles: 6907` —— **base 与 head 不同源**。实测:fork 的 `staging` 停在旧
  master 时,PR 把 upstream 的全部提交都算进了 diff,显示 6907 个文件、`CONFLICTING`

### ⚠️ 这一步**不能**用 `nixpkgs-review`

实测(2026-10-23,七次尝试):

| 尝试 | 结果 |
|---|---|
| `rev HEAD`(本地 commit) | 浅克隆下 `git merge` 失败(exit 128);补全历史后仍报若干「removed」误报,且**不构建新包** |
| `rev -b staging` | 它去 **upstream** 拉同名分支(`git fetch https://github.com/NixOS/nixpkgs staging`),base 不是我们的 |
| `wip` | 只看 unstaged;`git add -N` 后能应用 diff,但仍只报 removed |
| `pr <号>` | 写死查 `NixOS/nixpkgs/pulls/<号>`,看不到 fork 内的 PR |

**结论**:`nixpkgs-review` 在设计上只服务 upstream PR。fork 内的预演 PR,它能提供的
唯一证据是「diff 干净」——而那由上面的 `changedFiles` 判据给出,不需要 bot。

**真正跑 review bot 的时机**:upstream PR 建好之后,`nixpkgs-review-gha` 会自动跑
(obs 的先例:`1 package built`、`failed: []`)。

### 收尾

预演 PR 不要留着让人误会。可以关闭,也可以当记录留着——它带
`(dry-run, do not merge)` 标题,且只存在于 fork 里:

```bash
gh pr close <号> --repo <owner>/nixpkgs --comment "Dry-run finished; closing."
```

## 附:陷阱清单(审计阶段就要知道)

## 第六节:陷阱清单

**① 对符号链接树用递归 chmod,会打到 nix store。**

为了「让副本可写」而 `chmod -R u+w <副本>`——如果副本里是**指向别处的符号链接**,
`-R` 会顺着链接一路 chmod 到 `/nix/store` 里的**真实目录**。

实测后果与出口:内容哈希未变(`nix-store --verify-path` 退出码 0),
存储目录权限能回到只读,临时树能清干净——**但这是运气,不是设计**。
入口做法:**根本不要改权限**(用第 3.1 节的 `builtins.getFlake "path:…"`);
真要复制,先确认树里没有外指链接。

**② 许可要看「我们取源的那个 tag」,不是仓库的 main。**
main 上新加的 LICENSE 可能比 tag 晚。**但先找源文件头的 SPDX、清单文件的 license 字段、
以及上游 issue 里有没有人问过**——见第 1 步第 ④ 问。
**证据必须在被取的那份源码里**,而「没有 LICENSE 文件」不等于「没有许可」。

**③ 仓库里关于 nixpkgs 的自述会过期。**
「nixpkgs 已经不再提供 X」「上游删掉了 Y」这类句子,**先取证再采信**。
本仓自己写过的错误结论就有先例。

**④ 同一个软件在不同仓库可能叫不同名字。**
按**上游项目名**搜在途 PR,不要只按我们自己的包名搜。

**⑤ 文档漂移是常态。**
`CONTRIBUTING.md` 里描述的 PR 模板勾选项与 `.github/PULL_REQUEST_TEMPLATE.md`
实际内容已经不一致;`doc/contributing/` 目录已不存在。
**以源文件实测为准**,不要以记忆或二手描述为准。

**⑥ 构建成功不等于产物能跑**,也不等于「nixpkgs 会收」。
四层判据见 3.2。

**⑦ 预编译二进制不是绝对禁止,但位置不利。**
nixpkgs 的立场是「Source-available software should be built from source where possible」。
二进制包要走 `autoPatchelfHook` + `sourceProvenance = [ binaryNativeCode ]` 这条路,
而且**任何关掉内核加固的东西**(例如改写 `prctl(PR_SET_NO_NEW_PRIVS)` 的拦截器)
不可能过安全评审。

**⑧ 「依赖钉死精确版本」的包不要往 nixpkgs 提。**
nixpkgs 里所有包共用一份语言包集,抬一个版本会牵动全仓。

**⑨ 打开 `doCheck` 前先想清楚「测试在什么环境里跑过」——上游只在 venv 里试过。**

先例:某包的测试把服务端起成子进程,构造子进程环境时写了

```python
env = os.environ.copy()
env["PYTHONPATH"] = some_dir        # 覆盖
```

**在 venv 里这无害**(依赖在解释器自己的 site-packages 里),
**在没有 venv 的构建环境里等于把依赖整个丢掉**——症状是一片
`McpError('Connection closed')` 加 `ModuleNotFoundError`,
看起来像打包坏了,其实是上游测试对环境的假设。

处置:用 `substituteInPlace … --replace-fail` 把覆盖改成追加
(`os.pathsep.join([原值, env.get("PYTHONPATH", "")])`)。
用 `--replace-fail` 而不是静默 patch:上游改了那一行,构建立即失败,
不会变成「测试少跑了一半还没人知道」。

**并且给它撞一次反证**:把 patch 摘掉,测试必须当场翻脸。
说不清「摘掉会坏成什么样」的 patch,不该留在包里。

**永远跑不了的测试要显式排除,并在 PR 里说明范围**:
需要真实 GUI 程序、真实设备、网络的测试在构建沙箱里跑不了,
用 `disabledTestPaths` / `disabledTests` 点名,别用 `doCheck = false` 把整层判据扔掉。

**⑩ Nix 对「声明了没用的参数」不报警——而查它的量具很容易骗你。**

`package.nix` 的参数表里留一个用不上的参数(比如为未来准备的可选依赖),
构建照样通过、求值照样成功,**没有任何东西会说话**。
审阅者可能会问,但机器不会。

所以要自己查。**但注意查的方式**——实测连撞两次假警报:

- 用文本正则找「参数表结束」的位置,切错行 → 报出「五个参数全死」(其实都在用);
- 用 `\bblender\b` 数引用 → 命中了 **`blender-mcp`** 里的 `blender`
  (连字符算词边界)→ 报出「引用 12 次」(其实只有 `postFixup` 那几处)。

**可靠做法:参数名从求值结果读,不要从源码文本读。**

```bash
nix-instantiate --eval --json --strict --expr '
  let pkgs = import <nixpkgs-path> {}; f = import <package.nix>;
  in builtins.mapAttrs (n: _: n) (builtins.functionArgs f)'
```

拿到参数名之后,再数它们在正文里的**独立**出现
(正则前后都要排除字母数字、下划线**与连字符**)。

> 这属于「仪器类的错」:我要判的是包,结果两次都在判我自己的正则。
> **判据的灵敏度要拿对照臂试**——比如故意把参数名换成它自己加个后缀,看它会不会漏。

**⑪ 参数签名里的默认值挡不住 `callPackage` 的自动绑定——想「可选」就得删掉参数。**

先例:为了让包里某个可执行文件能找到可选的上游程序,写了

```nix
{ blender ? null, ... }:
...
postFixup = lib.optionalString (blender != null) ''
  wrapProgram "$out/bin/x" --set-default X_PATH "${blender}/bin/x"
'';
```

**意图**是「默认 null ⇒ 不把那个程序拉进闭包」。**实测推翻**:

| 调用方式 | 参数是否被绑 | 闭包里有它 |
|---|---|---|
| `callPackage { }` | **是** | **有** |
| `callPackage { blender = null; }` | 否 | 无 |

**`callPackage` 只看「这个名字在它自己的作用域里存不存在」**——
存在就填,参数签名里写没写默认值它不管(`builtins.functionArgs` 也照样把它列成可绑定)。

所以「可选依赖」在 nixpkgs 里的正确形态是**根本不要那个参数**:
不写它,需要的人自己设环境变量(或自己 `.override` 一份)。
写一个 `? null` 只会**掩盖**实际发生的事,还会让审阅者以为它真是可选的。

**判据**:`nix-store -q --references <drv> | grep -c <那个程序>`,删前删后各跑一次。

**⑫ 自动检查的假警报比漏报更花时间——先怀疑检查器,再怀疑文件。**

先例:在一次提交里,我用来核对文件的脚本连出**四次假警报**:

- 找「参数表结束」的位置,正则**切错行** → 报出「五个参数全死了」(其实都在用);
- 用 `\bblender\b` 数引用 → 命中了 **`blender-mcp`** 里的 `blender`(连字符算词边界)
  → 报出「引用 12 次」;
- 比对披露文本时正则**写得比内容窄** → 报出「PR 正文缺模型名」(其实有);
- 比对反证数字时没考虑**反引号** → 报出「数字不一致」(其实一致)。

**四次里没有一次是文件的问题。**

规则:

- **假警报要当场定性**,不要「重跑一次看看」——重跑不会让错的正则变对;
- 判据**先拿反例试**(故意喂一个已知的坏样本,看它响不响),
  和**拿真样本试**(看它误不误报);
- 报告里分开写「检查器说不行」与「我看了,确实不行」——**前者不是结论**。

**⑬ 本地那棵 nixpkgs 能用来「构建」,不能用来「判断上游长什么样」。**

nixpkgs 的结构一直在动,而本地树通常是某个时点的快照。**凡属于结构的问题,
必须现取 master 核实。** 属于结构的是:

- 这个包该放哪个目录(`pkgs/by-name/…`?类别目录?还是某个 scope 下的文件?)
- 要不要另改一个「接线文件」把包登记进去
- 用哪个 `callPackage`(`pkgs.callPackage` / `qt6Packages.callPackage` / …)
  —— **它决定参数名怎么写**(`qtbase` 还是 `qt6.qtbase`)
- 有没有自动发现机制

先例:按本地九月快照,我断定 OBS 插件要在 `plugins/default.nix` 里手工加一行、
Qt 写 `qtbase`。取当前 master 一看,两处都变了 ——
接线改成了 `lib.packagesFromDirectoryRecursive` 的**自动发现**(不用加行),
`callPackage` 也换回普通的 `pkgs.callPackage`(于是要写 `qt6.qtbase`)。
**差一点带着过时的接线方式去提交。**

做法:

```bash
# 结构问题一律现取
gh api "repos/NixOS/nixpkgs/contents/<路径>?ref=master" -H 'Accept: application/vnd.github.raw'
gh api 'repos/NixOS/nixpkgs/contents/<目录>?ref=master' --jq '.[].name'   # 看目录现状
```

判据:**引用的每一个上游文件路径,都要有一次带 `ref=master` 的取回记录**;
只凭本地 `ls` 得出的结论,写下来时要标「基于快照,未核 master」。

---

---


---

## 提交之后的部分不在这里

原技能的第 4 步(fork/分支/commit/push/PR)、第 4.5 步(回应 review)、
第五步之后(删分支/跟版本)已移到带授权的那一层。**审计技能不做这些,也不给步骤**
——不给步骤,才不会有人照着半套步骤去做。