Codex 改 API 前,先证明搜到了哪些文件

从一次旧 API 改名出发,把候选文件、文本匹配与尚待证明的调用关系分别留作证据。

你能带走什么
  • 让搜索候选集合能被复查
  • 已跟踪文件也可能被默认 rg 过滤
  • 类型与 glob 的结果数量相同仍需比较路径
  • 在明确允许根内补查,再核对文本语义

1. 改名之前,先让调查范围能被复查

准备把一个旧 API 从 oldFetch 改成 newFetch,你让 Codex 先查影响范围。它返回三个文件,里面确实都有旧名字。此时最值得追问的是:这三个文件是从哪一批候选文件里找到的?如果没有那份清单,少一个结果可能来自没有引用,也可能来自文件根本没进入搜索。

这篇用一个很小的原创仓库把区别展开。里面有普通 TypeScript、TSX、隐藏的 CI 目录、被忽略的复现代码、生成物,以及一个已经进入 Git 索引却匹配忽略规则的旧文件。每个名字和每行内容都是为本文编写的合成数据。实验会显示一个容易漏掉的事实:Git 跟踪状态不能替代 ripgrep 的候选文件清单。

读完后可以把一次“帮我搜一下”交付为几件能回查的东西:获准检查的目录、实际枚举出的路径集合、文本匹配位置、遗漏原因,以及尚未证实的调用关系。后续是否改代码,要在这份调查上另作判断。

本文于 2026 年 10 月 7 日核对主源,并在 Linux 上使用预装的 ripgrep 15.2.0 与 Git 2.52.0 执行实验。本文没有运行真实 Codex 会话、调用模型或安装软件;文末提示词是待在你的工作环境中验证的调查任务。结果不能用来评价任何模型的准确率。

2. 为什么把 rg 的文件集合拿出来单独检查

OpenAI 的 Codex Prompting Guide 所列示例提示词,偏好用 rg 查文本、用 rg --files 查文件。这为本文选择检索工具提供了一个具体背景;它不意味着所有 Codex 版本、客户端或每次任务都使用相同命令。你应以实际执行记录为准。

先把两个输出分开读。rg --files 枚举在当前搜索规则下的候选文件,并不执行正文匹配。rg -l -F oldFetch 搜索固定文本,并输出至少有一处匹配的文件名。前者回答哪些路径有资格参与,后者回答哪些候选文件出现了这个词。语义上的 API 归属,还需要导入关系、符号解析或其他工程证据。

ripgrep 官方指南说明了默认过滤隐藏文件、忽略路径与符号链接的行为。下面不靠记忆推断整个项目的覆盖率,而是保留命令与输出,逐次比较同一个练习仓库中的集合。

3. 先在空目录运行,再读固定预期

准备 Python 3.10 或更新版本,以及已经安装的 rg 和 git。将本文末尾的完整源码分别保存成 src/search_lab.py 和 tests/test_search_lab.py。在 src 与 tests 两个目录的共同父目录运行以下命令。不要把本文宽范围对照命令粘贴到真实业务仓库。

python -m unittest discover -s tests -v
python src/search_lab.py --output report.json

生成器只新建随机临时仓库,不接收现有仓库路径。它使用空 HOME、空 XDG 配置目录、禁用的 Git 系统与全局配置,以及空 Git 模板目录;传给子进程的环境没有继承 RIPGREP_CONFIG_PATH 或外部 GIT_DIR。这样做是为了让实验不受个人配置影响,并限制操作对象。它仍然会调用你当前 PATH 中已有的 Git 和 rg,因此请先确认它们是你信任的程序。

所有仓库文件在执行结束后清理,报告写到指定的新文件;已有同名报告时会拒绝覆盖。报告保留命令参数、标准输入、退出码和原始输出,其中 NUL 写成 JSON 转义。临时目录名每次会变,文件集合的固定预期不变。测试通过时输出 Ran 17 tests 与 OK。这些测试覆盖本文的命令行为、集合差异和解析方式,不代表真实项目的检索覆盖率或安全保证。

基础仓库有 12 个普通文本文件和 1 个内部符号链接。核心对照结束后,生成器另加四个纯合成文件名,专门检查空格、换行、制表符和连字符能否被完整处理;这些附加文件不计入正文的八个候选文件。

4. 默认搜索已经漏掉了一个已跟踪文件

仓库根目录的 .gitignore 只有三行:

generated/
local-fixtures/
legacy/tracked.ts

生成器先加入普通文件,再用 git add -f legacy/tracked.ts 把最后一个文件强制加入索引。没有伪造提交,也不需要创建提交:本实验要验证的就是路径已被 Git 跟踪时,rg 的默认递归搜索是否把它列入候选。

默认 rg --files -0 得到七个候选文件:README、两个 src 文件加一个不含旧词的文件、测试、脚本和迁移说明。随后默认文本搜索命中五个文件:

docs/migration.md
scripts/smoke
src/client.ts
src/widget.tsx
tests/client.test.ts

legacy/tracked.ts 不在其中,但 git ls-files -z 明确包含它。读 Git 的 gitignore 文档 时,要把“已跟踪文件不受 Git 忽略机制影响”放回 Git 的语境。rg 这次按忽略模式过滤了路径,并没有把 Git 索引清单当作必须全部搜索的文件集。

legacy/tracked.ts 出现在 Git 索引中,但不在默认 rg 候选中。默认 check-ignore 不解释它,no-index 显示第三行规则。
图 1 同一个已跟踪文件,分别检查索引、搜索候选与忽略模式。

如果 Codex 的回答是“旧 API 只在这五个文件中”,证据还不够。它可以准确地说“这条默认命令命中了五个文件”,接着解释候选中缺少了哪些获准检查的路径。对没有参与检索的文件,不能用空结果证明里面没有旧 API。

5. check-ignore 没有输出,要先问它检查了什么

面对遗漏的 legacy/tracked.ts,很自然会运行 git check-ignore -v。把它和 local-fixtures/repro.ts 一起检查,默认命令只解释后者:它匹配根目录 .gitignore 的第二行。tracked.ts 没有出现在输出里。

git-check-ignore 官方文档明确说明默认不展示已跟踪文件。增加 --no-index 后,两条路径都会得到模式诊断;tracked.ts 对应第三行。这里的参数只是让诊断不看索引状态,并没有将文件取消跟踪或改变任何忽略规则。

printf 'local-fixtures/repro.ts\0legacy/tracked.ts\0' | git check-ignore -v -z --stdin
printf 'local-fixtures/repro.ts\0legacy/tracked.ts\0' | git check-ignore -v -z --no-index --stdin

加了 -z 的 verbose 输出,每条记录有四个 NUL 分隔字段:来源文件、规则行号、模式、被检查的路径。源码中的 ignore_records 按四个字段分组,不拿冒号或制表符猜文件名边界。如果输出被截断,解析器会失败,而不会默默把半条记录算作证据。

本例只写了正向忽略模式。真实仓库若有以 ! 开头的否定规则,匹配规则并不自动意味着最终排除;报告必须保留模式本身及其含义。不要只看 verbose 命令是否有输出。

6. 三个结果对三个结果,仍然可能搜错范围

为了缩小到 TypeScript,代理可能选择 -t ts,也可能写 -g '*.ts'。在这个仓库中,两条命令都命中三个文件。若验收只有“找到了三处”,差异就被掩盖了。

rg -l -0 -F oldFetch -t ts .
rg -l -0 -F oldFetch -g '*.ts' .
rg --type-list

实际类型定义包含 *.ts 和 *.tsx。因此 -t ts 命中 client.ts、widget.tsx 和 client.test.ts。显式 glob 只匹配 *.ts,没有 widget.tsx;同时它把匹配该 glob 的 legacy/tracked.ts 纳入了这次检索。

这是两个叠加的差异:扩展名集合不同,过滤优先级也不同。本机 rg --help 的 glob 说明指出它可覆盖其他忽略逻辑;type 过滤的优先级低于忽略规则。本例结果正好把二者分开呈现。不能把 -g '*.ts' 当作与 -t ts 可以随手互换的写法,也不能从这一个文件推广出任意复杂忽略目录都会被相同 glob 遍历。

类型搜索包含 widget.tsx,glob 搜索包含 tracked.ts;各有三个命中文件。
图 2 总数相同,路径差集揭示了两个独有文件。

让 Codex 给出完整路径集合,会比让它复述文件数更有帮助。这个检查成本很低,却能直接指出哪个 UI 文件被漏掉、哪个旧目录被重新纳入。需要按语言限制时,先查看当前安装版本的类型定义,再解释这个限制是否符合任务。

7. 放宽过滤之后,新增结果要逐个解释

基础默认匹配有五个文件。只加 --hidden 并排除 .git/**,新增隐藏 CI 目录中的 probe.ts,变为六个。再加 --no-ignore,新增 generated/client.ts、legacy/tracked.ts 和 local-fixtures/repro.ts,合计九个。这组宽范围对照只运行在生成器创建的无秘密合成仓库。

其中 generated/client.ts 首行写着由生成器产生、不要手改。旧词出现可以提示后续检查生成源及再生成步骤,无法据此决定直接修改产物。docs/migration.md 则是一句迁移说明,scripts/smoke 中只是 echo oldFetch。扩大过滤会带回有价值的复现代码,也会带回不同用途的文本,调查仍需读上下文。

还有一组独立对照:rg -L 跟随内部符号链接后,多出 linked-client.ts 这条路径。生成器定义它指向 src/client.ts,因此这次多出来的是同一目标文件的另一条路径。不能把路径数量增加直接解释成新增业务调用。实际调查如果允许链接,还要单独核对目标与授权范围;本文的 Codex 任务明确不跟随链接。

直接把两个被忽略的文件作为参数传给 rg,不加 --no-ignore 也能找到它们。本例因此能直观说明忽略规则会影响搜索便利性,却不构成访问授权。授权范围应由人和执行环境明确确定;不能先越界读取,再靠过滤结果声称没有接触范围外内容。

8. 把补查限制在六个已知目录

这次改名前的调查允许读取 src、tests、scripts、.ci、legacy 和 local-fixtures。最后一个目录是练习仓库里公开的复现代码,没有私有数据。generated 与 docs 暂不纳入待修改代码;根目录 README 和 .gitignore 只作为辅助说明读取。使用这个已知范围,可以把“补齐被过滤文件”的动作落到明确目录上。

rg --hidden --no-ignore --files -0 src tests scripts .ci legacy local-fixtures
rg --hidden --no-ignore -l -0 -F oldFetch src tests scripts .ci legacy local-fixtures
rg --hidden --no-ignore --json -F oldFetch src tests scripts .ci legacy local-fixtures

第一条枚举八个候选文件。第二条命中其中七个,唯一没有匹配的是 src/no-call.ts。第三条输出包含路径、行号与行内容的 JSON 事件,便于进一步检查。三条命令使用完全相同的目录与过滤参数,避免枚举一种范围、正文搜索另一种范围。

六个允许根内的八个候选文件,其中 no-call.ts 不命中;生成物、文档、根目录链接未纳入调查内容。
图 3 候选八个、命中七个;每一项都能对应到固定路径。

显式目录还有一个值得记录的细节:不加补查选项,只运行 rg --files src tests scripts .ci legacy local-fixtures,本例也会纳入显式给出的隐藏目录 .ci 和被忽略目录 local-fixtures,但 legacy 下的 tracked.ts 仍然被过滤。传入目录本身,并不等于其所有子项都不受忽略规则影响。完整测试为这个现象保留了固定预期。

真实项目可能在允许目录下混放不该读取的文件,因此不能照抄这六个名字就认为授权完成。应先确认目录用途和可读范围,必要时缩到具体获准文件。本文也没有提供默认 -uuu 或全盘跟随链接的方案。一个更宽的搜索结果,只有在范围获准且新增文件得到解释时,才对改动决策有帮助。

9. 文件名里有换行,清单也必须保持完整

按行拆分路径清单很方便,但换行本身可以属于文件名。把输出传给 splitlines()、按空白切分,或用未妥善引用的 shell 循环,都可能把一个真实文件当成多个名字。本文的候选与命中文件命令统一使用 -0,Git 清单使用 -z。

生成器以 bytes 接收子进程输出,用 os.fsdecode 保留文件系统解码信息,然后按 NUL 分隔符解析;写报告时使用 JSON 转义。因此 src/line 后的换行和 break.ts 仍属于同一路径,而不是两条结果。verbose 忽略诊断同样按 NUL 的四字段结构处理,不能混用普通按行输出的解析方法。

文件名测试在基础对照之后加入 src/two words.ts、带换行的 line/break 文件名、带制表符的 tab/name 文件名和 src/-option.ts。断言比较完整路径集合:候选由八个增加到十二个,命中由七个增加到十一个,Git 索引仍保持原样。额外测试还会拒绝缺失终止 NUL 的输出。这几项能验证本实现如何处理这些名字,没有声称覆盖所有平台或全部文件系统编码情况。

正文为方便阅读,把结果展示成普通路径列表。机器处理仍以 report.json 中保留的 NUL 流及解析函数为准。若在真实工具链中读取 rg --json,还需处理字段可能采用的 bytes 表示;本例基础文本使用 UTF-8,测试只对这批已知数据读取 text 字段。

10. 给 Codex 的任务,以调查报告收尾

以下任务适合在有持久练习仓库的会话中使用。本文自动生成器为了隔离测试,会在返回后删除临时仓库;若要做真实模型练习,可在你自己新建并确认无敏感内容的练习目录中,按完整源码的 FILES 内容重建这些文件、建立内部链接并初始化索引。不要把提示词的授权描述当作对现有业务仓库的事实认定。

请对这个练习仓库做一次只读调查,为 oldFetch 改名为 newFetch 建立影响范围,本轮不要修改任何文件。

允许读取内容的代码目录是 src、tests、scripts、.ci、legacy、local-fixtures。local-fixtures 是本练习公开的合成复现代码,可以补查被忽略的文件。可以查看根目录中的 .gitignore 和 README.md,以及 Git 的文件清单与忽略匹配信息。generated 与 docs 暂不作为待修改代码;若只凭文件名不能确定用途,记录未验证。不要读取凭据、私有配置、Git 对象内容或仓库外文件,也不要跟随符号链接。若真实目录内容与此约定不一致,先停下确认范围。

先记录工作目录以及 rg、Git 版本。分别用文件枚举与内容检索回答哪些文件参与搜索、哪些文件有 oldFetch 文本匹配。在相同允许根下比较默认过滤和显式补查的路径集合;检查获准范围内的 Git 跟踪文件是否被搜索到。对遗漏给出命令、原因、规则来源及行号。若 git check-ignore 不输出已跟踪文件,继续区分索引状态与忽略模式,不直接宣称没有匹配规则。查看实际类型定义,不把 *.ts 当成全部 TypeScript 文件,不把 glob 与 -t ts 当作等价过滤。

使用适合机器解析的文件名分隔方式,并检查每条命令的退出状态与错误输出。读取匹配附近上下文,区分可能的调用表达式、测试、普通字符串,以及已知的文档、生成物或链接别名。没有符号解析等证据时,不把文本匹配写成确定的 API 调用关系。

最后提交实际命令及退出状态、候选文件和命中文件的准确清单、各遗漏与纳入原因、需要继续语义核对的位置,以及建议的后续修改范围与验证步骤。当前轮次的完成标准是调查报告。若命令失败、没有枚举到文件或权限不足,明确写出限制,不能用空结果证明整个项目不存在旧 API。

收到报告后,先检查它是否把八个候选路径完整列出,再看是否把七个命中文件标为待语义核对。六个文件的合成源码中有看起来像调用的表达式,scripts/smoke 只是输出文字;此仓库没有编译、导入图或真实 API 定义,连这些调用表达式是否指向同一个符号都未证明。

下一轮如果决定实施改名,应该补齐接口定义、导入关系、测试环境和生成步骤,再给出允许修改的路径。完成文本调查并不自动授权实现,也不能替代类型检查、行为测试和 diff 审查。

11. 完整命令清单与退出状态的读法

下面列出生成器在基础仓库中运行的全部检索与版本命令。两条 check-ignore 命令使用前述 NUL 标准输入;这里不重复列其 printf 前缀。初始化与 git add 是生成器内部创建合成仓库的准备步骤,完整实现紧接在后。

rg --version
git --version
git ls-files -z
rg --files -0
rg -l -0 -F oldFetch .
rg --hidden -l -0 -F oldFetch -g '!.git/**' .
rg --hidden --no-ignore -l -0 -F oldFetch -g '!.git/**' .
rg -l -0 -F oldFetch -t ts .
rg -l -0 -F oldFetch -g '*.ts' .
rg --type-list
rg -l -0 -F oldFetch -L .
rg -l -0 -F oldFetch local-fixtures/repro.ts legacy/tracked.ts
git check-ignore -v -z --stdin
git check-ignore -v -z --no-index --stdin
rg --files -0 src tests scripts .ci legacy local-fixtures
rg --hidden --no-ignore --files -0 src tests scripts .ci legacy local-fixtures
rg --hidden --no-ignore -l -0 -F oldFetch src tests scripts .ci legacy local-fixtures
rg --hidden --no-ignore --json -F oldFetch src tests scripts .ci legacy local-fixtures

宽范围命令以及 -L 只用于这个合成仓库的对照实验。真实任务请使用已经确认的允许根与选项。报告会分别保存每条命令的返回值和 stderr,不能因为屏幕上没有出现文字就跳过错误检查。

对 rg 的正文搜索,常见返回值是 0 表示找到匹配、1 表示没找到匹配、2 表示发生错误。没有匹配只能描述这次成功执行的搜索范围,不能证明未读目录的内容。本文基础实验刻意让每条正文查询都有命中;测试要求这些固定查询返回 0,因而版本或环境变化导致过滤行为不同,会使测试显式失败。不要为追求“通过”直接改掉预期,先核查命令与差集。

12. 完整生成器:src/search_lab.py

以下文件可直接保存运行。临时仓库与干净配置在一个上下文中创建和清理,报告数据在清理前收集完成。脚本不接受任意仓库输入。

"""Synthetic ripgrep/Git experiment. Python 3.10+, preinstalled rg and Git.
Only creates a new temporary repository; never accepts an existing repo path.
"""
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile

FILES = {
    '.gitignore': 'generated/\nlocal-fixtures/\nlegacy/tracked.ts\n',
    'src/client.ts': 'export const result = oldFetch("primary");\n',
    'src/widget.tsx': 'export const Widget = () => oldFetch("ui");\n',
    'tests/client.test.ts': 'assert(oldFetch("test"));\n',
    'scripts/smoke': '#!/bin/sh\necho oldFetch\n',
    '.ci/probe.ts': 'oldFetch("ci");\n',
    'generated/client.ts': '// generated; do not edit\noldFetch("generated");\n',
    'local-fixtures/repro.ts': 'oldFetch("reproduction");\n',
    'legacy/tracked.ts': 'oldFetch("tracked-before-ignore");\n',
    'docs/migration.md': 'Replace oldFetch with newFetch.\n',
    'src/no-call.ts': 'export const unrelated = true;\n',
    'README.md': 'Synthetic public code only. No secrets.\n',
}
ROOTS = ['src', 'tests', 'scripts', '.ci', 'legacy', 'local-fixtures']
COMMANDS = {
    'rg_version': ['rg', '--version'],
    'git_version': ['git', '--version'],
    'tracked': ['git', 'ls-files', '-z'],
    'default_files': ['rg', '--files', '-0'],
    'default_hits': ['rg', '-l', '-0', '-F', 'oldFetch', '.'],
    'hidden_hits': ['rg', '--hidden', '-l', '-0', '-F', 'oldFetch', '-g', '!.git/**', '.'],
    'wide_hits': ['rg', '--hidden', '--no-ignore', '-l', '-0', '-F', 'oldFetch', '-g', '!.git/**', '.'],
    'type_hits': ['rg', '-l', '-0', '-F', 'oldFetch', '-t', 'ts', '.'],
    'glob_hits': ['rg', '-l', '-0', '-F', 'oldFetch', '-g', '*.ts', '.'],
    'types': ['rg', '--type-list'],
    'follow_hits': ['rg', '-l', '-0', '-F', 'oldFetch', '-L', '.'],
    'explicit_hits': ['rg', '-l', '-0', '-F', 'oldFetch', 'local-fixtures/repro.ts', 'legacy/tracked.ts'],
    'ignore_default': ['git', 'check-ignore', '-v', '-z', '--stdin'],
    'ignore_no_index': ['git', 'check-ignore', '-v', '-z', '--no-index', '--stdin'],
    'bounded_default_files': ['rg', '--files', '-0', *ROOTS],
    'bounded_files': ['rg', '--hidden', '--no-ignore', '--files', '-0', *ROOTS],
    'bounded_hits': ['rg', '--hidden', '--no-ignore', '-l', '-0', '-F', 'oldFetch', *ROOTS],
    'bounded_lines': ['rg', '--hidden', '--no-ignore', '--json', '-F', 'oldFetch', *ROOTS],
}
IGNORE_INPUT = b'local-fixtures/repro.ts\0legacy/tracked.ts\0'


def nul_fields(text):
    if not text:
        return []
    if not text.endswith('\0'):
        raise ValueError('Expected a NUL-terminated stream')
    return text[:-1].split('\0')


def paths(result):
    return {p.removeprefix('./') for p in nul_fields(result['stdout'])}


def ignore_records(result):
    fields = nul_fields(result['stdout'])
    if len(fields) % 4:
        raise ValueError('Incomplete check-ignore verbose record')
    return [dict(zip(('source', 'line', 'pattern', 'path'), fields[i:i+4]))
            for i in range(0, len(fields), 4)]


def run_lab(filename_probe=False):
    # Resolve executables before constructing a minimal, isolated child environment.
    executables = {name: shutil.which(name) for name in ('git', 'rg')}
    if not all(executables.values()):
        raise RuntimeError('Requires preinstalled Git and ripgrep; no installation attempted')
    with tempfile.TemporaryDirectory(prefix='codex-search-lab-') as temp:
        base = Path(temp)
        repo = base / 'repo'
        repo.mkdir()
        home = base / 'home'
        home.mkdir()
        template = base / 'empty-template'
        template.mkdir()
        env = {
            'PATH': os.defpath, 'HOME': str(home), 'XDG_CONFIG_HOME': str(home / 'config'),
            'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8',
            'GIT_CONFIG_NOSYSTEM': '1', 'GIT_CONFIG_GLOBAL': os.devnull,
            'GIT_CONFIG_SYSTEM': os.devnull, 'GIT_OPTIONAL_LOCKS': '0',
            'GIT_CEILING_DIRECTORIES': str(base),
        }

        def run(command, stdin=None):
            completed = subprocess.run([executables[command[0]], *command[1:]],
                                       cwd=repo, env=env, input=stdin, capture_output=True)
            return {'command': command, 'exit': completed.returncode,
                    'stdin': None if stdin is None else os.fsdecode(stdin),
                    'stdout': os.fsdecode(completed.stdout),
                    'stderr': os.fsdecode(completed.stderr)}

        setup = [run(['git', 'init', '-q', '--template=' + str(template)])]
        if setup[0]['exit']:
            raise RuntimeError(setup[0])
        for name, content in FILES.items():
            target = repo / name
            target.parent.mkdir(parents=True, exist_ok=True)
            target.write_text(content, encoding='utf-8')
        (repo / 'linked-client.ts').symlink_to('src/client.ts')
        setup += [run(['git', 'add', '.']), run(['git', 'add', '-f', 'legacy/tracked.ts'])]
        if any(r['exit'] for r in setup):
            raise RuntimeError(setup)
        results = {name: run(command, IGNORE_INPUT if name.startswith('ignore_') else None)
                   for name, command in COMMANDS.items()}
        report = {'files': FILES, 'symlink': {'linked-client.ts': 'src/client.ts'},
                  'setup': setup, 'results': results,
                  'scope': ROOTS, 'model_run': False}
        if filename_probe:
            # All synthetic. Newlines/tabs/spaces must remain inside one pathname.
            strange = ['src/two words.ts', 'src/line\nbreak.ts',
                       'src/tab\tname.ts', 'src/-option.ts']
            for name in strange:
                (repo / name).write_text('oldFetch("filename-probe");\n', encoding='utf-8')
            report['filename_probe'] = {
                'added': strange,
                'files': run(COMMANDS['bounded_files']),
                'hits': run(COMMANDS['bounded_hits']),
                'tracked': run(['git', 'ls-files', '-z']),
            }
        return report


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--output', default='report.json')
    args = parser.parse_args()
    report = run_lab(filename_probe=True)
    # Refuse to replace an existing result. All repository files are temporary.
    with open(args.output, 'x', encoding='utf-8') as output:
        json.dump(report, output, ensure_ascii=True, indent=2)
        output.write('\n')
    print('Wrote', args.output, '(temporary synthetic repository removed; no model run)')

13. 完整测试:tests/test_search_lab.py

预期集合独立写在测试中,未从当前输出反向生成。断言比较集合而非输出顺序,同时核对具体规则来源、行号、匹配行和文件名解析。它们只约束这个教学样例。

"""Fixed expected sets; execute with python -m unittest discover -s tests -v."""
import json
from pathlib import Path
import sys
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
from search_lab import ignore_records, nul_fields, paths, run_lab

BASE = {'src/client.ts', 'src/widget.tsx', 'tests/client.test.ts',
        'scripts/smoke', 'docs/migration.md'}
BOUNDED = {'src/client.ts', 'src/widget.tsx', 'src/no-call.ts',
           'tests/client.test.ts', 'scripts/smoke', '.ci/probe.ts',
           'legacy/tracked.ts', 'local-fixtures/repro.ts'}


class SearchLabTest(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.report = run_lab(filename_probe=True)
        cls.r = cls.report['results']

    def test_commands_succeeded(self):
        for name, result in self.r.items():
            with self.subTest(name=name):
                self.assertEqual(result['exit'], 0)
                self.assertEqual(result['stderr'], '')

    def test_default_candidates(self):
        self.assertEqual(paths(self.r['default_files']), BASE | {'src/no-call.ts', 'README.md'})

    def test_default_hits(self):
        self.assertEqual(paths(self.r['default_hits']), BASE)

    def test_hidden_hits(self):
        self.assertEqual(paths(self.r['hidden_hits']), BASE | {'.ci/probe.ts'})

    def test_wide_hits(self):
        self.assertEqual(paths(self.r['wide_hits']), BASE | {'.ci/probe.ts',
                         'generated/client.ts', 'legacy/tracked.ts', 'local-fixtures/repro.ts'})

    def test_same_count_different_sets(self):
        typed = paths(self.r['type_hits'])
        globbed = paths(self.r['glob_hits'])
        self.assertEqual(typed, {'src/client.ts', 'src/widget.tsx', 'tests/client.test.ts'})
        self.assertEqual(globbed, {'src/client.ts', 'tests/client.test.ts', 'legacy/tracked.ts'})
        self.assertEqual(len(typed), len(globbed))
        self.assertNotEqual(typed, globbed)

    def test_type_definition(self):
        definition = next(s for s in self.r['types']['stdout'].splitlines() if s.startswith('ts:'))
        self.assertIn('*.ts', definition.split(': ', 1)[1].split(', '))
        self.assertIn('*.tsx', definition.split(': ', 1)[1].split(', '))

    def test_tracked_but_not_default_searched(self):
        self.assertIn('legacy/tracked.ts', paths(self.r['tracked']))
        self.assertNotIn('legacy/tracked.ts', paths(self.r['default_files']))

    def test_ignore_explanation(self):
        self.assertEqual(ignore_records(self.r['ignore_default']), [
            {'source': '.gitignore', 'line': '2', 'pattern': 'local-fixtures/',
             'path': 'local-fixtures/repro.ts'}])
        self.assertEqual(ignore_records(self.r['ignore_no_index']), [
            {'source': '.gitignore', 'line': '2', 'pattern': 'local-fixtures/',
             'path': 'local-fixtures/repro.ts'},
            {'source': '.gitignore', 'line': '3', 'pattern': 'legacy/tracked.ts',
             'path': 'legacy/tracked.ts'}])

    def test_explicit_files(self):
        self.assertEqual(paths(self.r['explicit_hits']), {'local-fixtures/repro.ts', 'legacy/tracked.ts'})

    def test_follow_adds_alias(self):
        self.assertEqual(paths(self.r['follow_hits']), BASE | {'linked-client.ts'})
        self.assertEqual(self.report['symlink'], {'linked-client.ts': 'src/client.ts'})

    def test_explicit_roots_do_not_disable_all_ignores(self):
        self.assertEqual(paths(self.r['bounded_default_files']), BOUNDED - {'legacy/tracked.ts'})

    def test_bounded_candidates(self):
        self.assertEqual(paths(self.r['bounded_files']), BOUNDED)

    def test_bounded_hits(self):
        self.assertEqual(paths(self.r['bounded_hits']), BOUNDED - {'src/no-call.ts'})

    def test_json_match_lines(self):
        events = [json.loads(s) for s in self.r['bounded_lines']['stdout'].splitlines()]
        matches = [e['data'] for e in events if e['type'] == 'match']
        self.assertEqual({m['path']['text'] for m in matches}, BOUNDED - {'src/no-call.ts'})
        self.assertEqual(len(matches), 7)
        smoke = next(m for m in matches if m['path']['text'] == 'scripts/smoke')
        self.assertEqual(smoke['line_number'], 2)
        self.assertEqual(smoke['lines']['text'], 'echo oldFetch\n')

    def test_nul_handles_filename_characters(self):
        probe = self.report['filename_probe']
        added = {'src/two words.ts', 'src/line\nbreak.ts', 'src/tab\tname.ts', 'src/-option.ts'}
        self.assertEqual(set(probe['added']), added)
        self.assertEqual(paths(probe['files']), BOUNDED | added)
        self.assertEqual(paths(probe['hits']), (BOUNDED - {'src/no-call.ts'}) | added)
        self.assertEqual(paths(probe['tracked']), paths(self.r['tracked']))
        self.assertEqual(len(paths(probe['files'])), 12)

    def test_nul_rejects_incomplete_output(self):
        self.assertEqual(nul_fields(''), [])
        with self.assertRaises(ValueError):
            nul_fields('src/incomplete.ts')
        with self.assertRaises(ValueError):
            ignore_records({'stdout': 'source\0line\0'})


if __name__ == '__main__':
    unittest.main()

14. 这份报告可以怎样用于下一步

到这里,最有用的产物是一份能被其他人重跑和核查的搜索范围说明。它解释了默认命令为何遗漏 tracked.ts,为什么两个“各有三个结果”的筛选并不等价,以及补查之后的七个文本命中文件如何从八个候选文件中得到。后续审阅者不必只相信代理的一句“都查过了”。

如果换成真实项目,保留这套做法即可:把获准范围写清,枚举候选,保存文本命中,再解释两者的差异。项目用了其他检索工具,也应要求相同层级的证据。一个文件没出现时,先区分范围、过滤、读取失败和确实无匹配;词出现时,再判断它在代码中承担什么角色。

本例未验证 Windows、PowerShell 或 macOS,没有测试外部符号链接、子模块、稀疏检出、二进制策略以及 .ignore/.rgignore 的组合优先级。真实 Codex 会话、模型对提示词的遵循情况、TypeScript 编译与 API 行为也都未验证。把这些限制保留在报告里,会让下一步补查更明确。

主源与核对日期:2026 年 10 月 7 日。过滤细节还可在你本机的 rg --help 中核对;实际路径集合以随稿实验为证据。

15. 补充:换了 HOME,为什么仍少一个文件

还有一种遗漏,规则根本不在仓库里。假设你已经把 HOME 换到另一处目录,又确认 Git 没有解释出 src/inherited.ts 的忽略原因,它却仍然不出现在 rg 的候选清单。此时值得检查的是仓库在磁盘上的上级目录:修改环境变量,不会把仓库搬离原来的祖先路径。

这份补充另建一个小型合成仓库。workspace/.ignore 只有 inherited.ts 一行,workspace/repo/.gitignore 只有 generated/ 一行。visible.ts、inherited.ts 和 generated/cache.ts 都写有 oldFetch();另一个 no-call.ts 只写普通常量。所有递归搜索都在 repo 根目录执行,并显式使用路径参数“.”。

<fixture>/
  empty-home/                    # 不在 repo 的祖先路径上
  workspace/
    .ignore                      # inherited.ts
    repo/                        # 命令工作目录
      .gitignore                 # generated/
      src/visible.ts             # oldFetch()
      src/inherited.ts           # oldFetch()
      src/no-call.ts             # export const value = 1;
      generated/cache.ts         # oldFetch()

先让 HOME 指向 workspace,再指向 empty-home,默认结果都只有 visible.ts 一个文本命中文件。增加 --no-config 也一样。这个参数控制参数配置文件;它不会让目录树上的 .ignore 消失。本例的目标文件始终处在 workspace/.ignore 的作用路径里。

前文 17 项测试及其限制对应原来的搜索实验;下面是 2026 年 10 月 13 日另行验证的上级忽略文件实验。新增结果只覆盖这里明列的目录关系与参数,不把前文未测试的 .ignore/.rgignore 组合优先级改写成已被全面验证。

同一个上级 .ignore 在更换 HOME 后仍过滤 inherited.ts;仅禁用父目录规则可恢复该源文件并保留 generated 排除。
图 4 HOME 变量、物理祖先路径与两种忽略来源,要分别检查。

16. Git 没有解释时,让 rg 指出实际规则来源

在这份仓库中运行下面的 Git 诊断,退出码为 1,标准输出和标准错误都为空。它表示没有找到适用的 Git 忽略结果,并不保证其他工具也会搜索这个文件。增加 --no-index 只是改变是否查看 Git 索引,不能让 Git 识别 rg 所使用的 .ignore 文件。

git check-ignore -v --no-index src/inherited.ts

为了排除“诊断命令根本没有正常工作”的可能,程序也检查 generated/cache.ts。这个正对照返回退出码 0,指出仓库内 .gitignore 的第 1 行 generated/。完整源码使用 --stdin -z 保存 NUL 分隔输入输出,避免把路径分隔方式混进结果解释。

rg --sort path --debug -l -0 -F oldFetch .

随后查看 rg 的调试输出。正文命中仍然只有 visible.ts,但标准错误中关于 inherited.ts 的排除记录,包含实际的 workspace/.ignore 路径和 inherited.ts 模式。报告把随机临时目录前缀记为 <fixture>,其余目录关系保持原样。测试检查这一条来源归属,不把内部源码行号、日志排列顺序当作稳定接口。

真实任务应先确认是否获准读取上级规则文件和新纳入的路径;上级目录超出范围时,请有权限的人提供规则。调试日志可能带出本机路径,转发前也应核对内容。过滤参数改变候选集合,不能代替读取授权。

17. 三种放宽参数,带回的是三组不同文件

知道规则来自哪里后,再比较一次只改变一个过滤选项的结果。下列命令均由脚本在已知、无秘密的合成仓库内运行,候选清单与文本搜索使用相同的过滤选项。

rg --sort path --no-ignore-vcs --files -0 .
rg --sort path --no-ignore-parent --files -0 .
rg --sort path --no-ignore --files -0 .

--no-ignore-vcs 选择了错误的来源:它停用了版本控制忽略规则,于是 generated/cache.ts 被加入候选;上级 .ignore 仍然排除 inherited.ts。结果确实更多了,但缺失的源文件还没有回来。只比较数量,会把这个失败误读成搜索范围已经补齐。

--no-ignore-parent 暂时不使用父目录中的忽略文件规则。本例从 repo 根目录开始搜索,因此 workspace/.ignore 不再过滤 inherited.ts,而 repo 自己的 .gitignore 仍排除 generated/cache.ts。它针对的是父目录规则这一类来源,并非只豁免某一个 .ignore 文件。这里把它作为已知范围内的诊断对照,而不是需要永久加入所有搜索命令的默认参数。

--no-ignore 带回了 inherited.ts,也带回 generated/cache.ts。对“补回源文件、保留生成物排除”这个验收要求来说,它放宽得过头。这里讨论的是忽略文件规则;隐藏文件和二进制内容还有各自的过滤行为,不能把这个选项解释成什么都不再过滤。

no-call.ts 在上述每组候选中都存在,但任何一组 oldFetch 文本搜索都不会命中它。候选与命中要分别保存:前者缺失可能是过滤,后者缺失也可能只是文件中没有这个词。三个文件中的 oldFetch() 均为练习文字,本实验不判断真实工程里的符号归属。

18. 用反例确认参数确实解决了这个原因

补充程序还做了三组边界检查。第一组把同样的仓库放到 workspace 之外,HOME 仍指向 workspace。这时 inherited.ts 恢复为默认候选,说明本例依靠的是祖先路径关系,而不是“HOME 中的 .ignore 必然全局生效”。名为 empty-home 的非祖先 HOME 在这组测试中也故意放有排除 visible.ts 的 .ignore,因此它实际并非空目录;它也没有改变原仓库结果。

第二组真正设置 RIPGREP_CONFIG_PATH,让参数配置中的 --glob=!visible.ts 生效。此时候选只剩 no-call.ts,枚举退出码为 0,文本搜索因为没有命中而返回 1。增加 --no-config 后 visible.ts 恢复,但 inherited.ts 仍受上级 .ignore 排除。这样就能确认 --no-config 本身有效,只是它控制的来源不同。

第三组临时在 repo 当前目录写入 .ignore,用它排除 visible.ts。此时即使使用 --no-ignore-parent,visible.ts 仍未进入候选,generated/cache.ts 也仍被本地 .gitignore 排除。脚本随后删除自己创建的这份临时规则,其他实验不受影响。

最后,测试故意把 --no-ignore-vcs 和 --no-ignore 的实际输出交给同一个窄范围验收函数。前者一边漏掉目标一边带回生成物,后者多带回生成物,两者都必须被拒绝;只有“命令执行成功”还不算验收通过。

将下面三个文件按标题保存,或下载附带的源码 ZIP,然后在它们的共同父目录执行命令。脚本只使用 Python 标准库和已有的 Git、rg,建立并清理自己的临时仓库,不接收业务仓库路径;报告必须写到新文件。临时目录仍有物理祖先,清理 HOME 并不保证任何机器上的祖先规则都为空。若固定断言不一致,应查明差异,不要为让测试通过而删改外部忽略文件。

python -m unittest discover -s tests -v
python src/ancestor_ignore_lab.py --output report.json

本次验证环境为 Linux、Python 3.12.14、ripgrep 15.2.0 与 Git 2.52.0。新增的 14 项测试通过;源码文件、正文提取副本与 ZIP 解压副本产生相同的固定标准输出。报告另外保留版本、命令、退出码和调试来源证据。下面的 expected-output.txt 是路径集合的固定预期,不要求随机临时目录名相同。

下载完整源码、测试与固定预期 ZIP

19. 补充源码:src/ancestor_ignore_lab.py

脚本内的文件内容、过滤参数与预期集合均为固定输入。参数列表直接交给子进程,不经过 shell 展开;每条外部命令有 10 秒超时。

"""Synthetic ancestor-ignore lab. Python 3.10+, installed Git and ripgrep.

Run: python src/ancestor_ignore_lab.py --output report.json
No input repository, network request, package install, or persistent setting edit.
All fixtures are disposable. The optional report refuses to overwrite a file.
"""
from __future__ import annotations

import argparse
import json
import os
from pathlib import Path
import platform
import shutil
import subprocess
import tempfile

FILES = {
    ".gitignore": "generated/\n",
    "src/visible.ts": "oldFetch()\n",
    "src/inherited.ts": "oldFetch()\n",
    "src/no-call.ts": "export const value = 1;\n",
    "generated/cache.ts": "oldFetch()\n",
}
BASE = ["src/no-call.ts", "src/visible.ts"]
NARROW = ["src/inherited.ts", "src/no-call.ts", "src/visible.ts"]
BROAD = ["generated/cache.ts", *NARROW]


def nul_paths(raw: bytes) -> list[str]:
    """Decode the complete NUL-delimited path stream, including empty output."""
    if not raw:
        return []
    if not raw.endswith(b"\0"):
        raise ValueError("truncated NUL path stream")
    paths = [part.decode("utf-8") for part in raw[:-1].split(b"\0")]
    if any(not path for path in paths):
        raise ValueError("empty path in NUL stream")
    paths = [path.removeprefix("./") for path in paths]
    if len(set(paths)) != len(paths):
        raise ValueError("duplicate path in NUL stream")
    return sorted(paths)


def require_set(actual: list[str], expected: list[str], label: str) -> None:
    if actual != sorted(expected):
        raise AssertionError(f"{label}: expected {sorted(expected)!r}, got {actual!r}")


def run_lab() -> dict:
    rg = shutil.which("rg")
    git = shutil.which("git")
    if not rg or not git:
        raise RuntimeError("Install trusted Git and ripgrep before running this lab")
    records: list[dict] = []
    scenarios: dict[str, dict] = {}
    with tempfile.TemporaryDirectory(prefix="rg-ancestor-lab-") as temporary:
        root = Path(temporary).resolve()
        workspace = root / "workspace"
        repo = workspace / "repo"
        sibling = root / "sibling-repo"
        home = root / "empty-home"
        template = root / "empty-template"
        for directory in (repo, sibling, home, template):
            directory.mkdir(parents=True)
        (workspace / ".ignore").write_text("inherited.ts\n", encoding="utf-8")
        # This HOME contains a rule but is not a filesystem ancestor of repo.
        (home / ".ignore").write_text("visible.ts\n", encoding="utf-8")
        config = root / "arguments.conf"
        config.write_text("--glob=!visible.ts\n", encoding="utf-8")
        env = {
            "PATH": os.environ.get("PATH", os.defpath),
            "HOME": str(home),
            "XDG_CONFIG_HOME": str(home),
            "GIT_CONFIG_NOSYSTEM": "1",
            "GIT_CONFIG_GLOBAL": os.devnull,
            "GIT_TEMPLATE_DIR": str(template),
            "LC_ALL": "C",
        }

        def normalized(value: str) -> str:
            return value.replace(str(root), "<fixture>")

        def run(label: str, argv: list[str], *, cwd=repo, changes=None, stdin=None):
            current_env = dict(env, **(changes or {}))
            binary = {"rg": rg, "git": git}[argv[0]]
            result = subprocess.run(
                [binary, *argv[1:]], cwd=cwd, env=current_env,
                capture_output=True, timeout=10, check=False, input=stdin,
            )
            records.append({
                "label": label, "argv": [normalized(a) for a in argv],
                "cwd": normalized(str(cwd)),
                "HOME": normalized(current_env["HOME"]),
                "RIPGREP_CONFIG_PATH": normalized(current_env.get("RIPGREP_CONFIG_PATH", "")),
                "stdin": None if stdin is None else stdin.decode("utf-8"),
                "returncode": result.returncode,
                "stdout": normalized(result.stdout.decode("utf-8")),
                "stderr": normalized(result.stderr.decode("utf-8")),
            })
            return result

        for directory in (repo, sibling):
            for name, content in FILES.items():
                target = directory / name
                target.parent.mkdir(exist_ok=True)
                target.write_text(content, encoding="utf-8")
            initialized = run("git_init", ["git", "init", "-q"], cwd=directory)
            if initialized.returncode:
                raise RuntimeError(initialized.stderr.decode("utf-8"))
        rg_version = run("rg_version", ["rg", "--version"])
        git_version = run("git_version", ["git", "--version"])
        if rg_version.returncode or git_version.returncode:
            raise RuntimeError("Version command failed")

        def scenario(label: str, flags: list[str], expected: list[str], *, cwd=repo, changes=None):
            files = run(label + ":files", ["rg", "--sort", "path", *flags, "--files", "-0", "."],
                        cwd=cwd, changes=changes)
            matches = run(label + ":matches", ["rg", "--sort", "path", *flags,
                          "-l", "-0", "-F", "oldFetch", "."], cwd=cwd, changes=changes)
            expected_matches = [name for name in expected if name != "src/no-call.ts"]
            actual_files, actual_matches = nul_paths(files.stdout), nul_paths(matches.stdout)
            require_set(actual_files, expected, label + " candidates")
            require_set(actual_matches, expected_matches, label + " matches")
            if files.returncode != (0 if expected else 1):
                raise AssertionError(f"{label}: unexpected enumeration status {files.returncode}")
            if matches.returncode != (0 if expected_matches else 1):
                raise AssertionError(f"{label}: unexpected match status {matches.returncode}")
            if files.stderr or matches.stderr:
                raise AssertionError(f"{label}: unexpected stderr")
            scenarios[label] = {"candidates": actual_files, "matches": actual_matches,
                                "files_exit": files.returncode, "matches_exit": matches.returncode}

        scenario("home_is_ancestor", [], BASE, changes={"HOME": str(workspace)})
        scenario("home_moved", [], BASE)
        scenario("no_config", ["--no-config"], BASE)
        scenario("wrong_vcs_layer", ["--no-ignore-vcs"], ["generated/cache.ts", *BASE])
        scenario("ignore_parents_only", ["--no-ignore-parent"], NARROW)
        scenario("ignore_every_rule", ["--no-ignore"], BROAD)
        # The same HOME is no longer an ancestor of this otherwise equal repo.
        scenario("home_not_ancestor", [], NARROW, cwd=sibling, changes={"HOME": str(workspace)})
        scenario("config_enabled", [], ["src/no-call.ts"],
                 changes={"RIPGREP_CONFIG_PATH": str(config)})
        scenario("config_disabled", ["--no-config"], BASE,
                 changes={"RIPGREP_CONFIG_PATH": str(config)})
        (repo / ".ignore").write_text("visible.ts\n", encoding="utf-8")
        try:
            scenario("local_ignore_still_applies", ["--no-ignore-parent"],
                     ["src/inherited.ts", "src/no-call.ts"])
        finally:
            (repo / ".ignore").unlink()

        ancestor_check = run("git_check_ancestor", ["git", "check-ignore", "-v", "-z",
                             "--no-index", "--stdin"], stdin=b"src/inherited.ts\0")
        if (ancestor_check.returncode, ancestor_check.stdout, ancestor_check.stderr) != (1, b"", b""):
            raise AssertionError("Git unexpectedly diagnosed the synthetic ancestor .ignore")
        local_check = run("git_check_local", ["git", "check-ignore", "-v", "-z",
                          "--no-index", "--stdin"], stdin=b"generated/cache.ts\0")
        local_expected = b".gitignore\x001\x00generated/\x00generated/cache.ts\x00"
        if (local_check.returncode, local_check.stdout, local_check.stderr) != (0, local_expected, b""):
            raise AssertionError("Git local-rule positive control failed")
        debug = run("debug_parent_source", ["rg", "--sort", "path", "--debug",
                    "-l", "-0", "-F", "oldFetch", "."])
        require_set(nul_paths(debug.stdout), ["src/visible.ts"], "debug matches")
        debug_text = debug.stderr.decode("utf-8")
        # Assert the attribution record, not internal Rust line numbers or log order.
        attribution = [line for line in debug_text.splitlines()
                       if "ignoring ./src/inherited.ts" in line
                       and str(workspace / ".ignore") in line
                       and 'original: "inherited.ts"' in line]
        if debug.returncode != 0 or len(attribution) != 1:
            raise AssertionError("Debug did not attribute inherited.ts to the ancestor rule")

        rejected = []
        for name in ("wrong_vcs_layer", "ignore_every_rule"):
            try:
                require_set(scenarios[name]["candidates"], NARROW, name)
            except AssertionError:
                rejected.append(name)
            else:
                raise AssertionError(f"Negative control {name} was incorrectly accepted")
        facts = {
            "scenarios": scenarios,
            "git_ancestor": {"exit": 1, "stdout": ""},
            "git_local": {"exit": 0, "source": ".gitignore", "line": 1,
                          "pattern": "generated/", "path": "generated/cache.ts"},
            "debug_source": "<fixture>/workspace/.ignore",
            "debug_pattern": "inherited.ts",
            "rejected_narrow_scope_mutants": rejected,
        }
        report = {
            "schema": 1,
            "versions": {"python": platform.python_version(), "system": platform.system(),
                         "ripgrep": rg_version.stdout.decode("utf-8").splitlines()[0],
                         "git": git_version.stdout.decode("utf-8").strip()},
            "fixture": {"workspace/.ignore": "inherited.ts\n", "repo_files": FILES,
                        "nonancestor_home/.ignore": "visible.ts\n",
                        "arguments.conf": "--glob=!visible.ts\n"},
            "facts": facts,
            "records": records,
        }
    return report


def summary(report: dict) -> str:
    lines = []
    for label, result in report["facts"]["scenarios"].items():
        paths = json.dumps(result["candidates"], ensure_ascii=False)
        hits = json.dumps(result["matches"], ensure_ascii=False)
        lines.append(f"{label}: candidates={paths}; matches={hits}")
    lines += ["git_ancestor: exit=1; stdout=empty",
              "git_local: .gitignore:1:generated/ -> generated/cache.ts",
              "debug_source: <fixture>/workspace/.ignore; pattern=inherited.ts",
              "rejected_narrow_scope_mutants: wrong_vcs_layer, ignore_every_rule"]
    return "\n".join(lines) + "\n"


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--output", type=Path, help="write a new JSON report; never overwrite")
    args = parser.parse_args()
    report = run_lab()
    if args.output is not None:
        with args.output.open("x", encoding="utf-8") as stream:
            json.dump(report, stream, ensure_ascii=False, indent=2)
            stream.write("\n")
    print(summary(report), end="")


if __name__ == "__main__":
    main()

20. 补充测试:tests/test_ancestor_ignore_lab.py

测试覆盖候选与命中的差别、Git 正反诊断、配置开关、父目录规则边界,以及已有报告不可覆盖。程序内部也逐一核对每组命令的集合和退出码。

"""Fixed assertions for the original synthetic ancestor-ignore lab."""
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from ancestor_ignore_lab import nul_paths, require_set, run_lab, summary


class AncestorIgnoreTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.report = run_lab()
        cls.facts = cls.report["facts"]
        cls.scenarios = cls.facts["scenarios"]

    def test_home_move_does_not_remove_physical_ancestor(self):
        self.assertEqual(self.scenarios["home_is_ancestor"], self.scenarios["home_moved"])
        self.assertEqual(self.scenarios["home_moved"]["candidates"],
                         ["src/no-call.ts", "src/visible.ts"])

    def test_no_config_is_wrong_control_for_ancestor(self):
        self.assertEqual(self.scenarios["no_config"], self.scenarios["home_moved"])

    def test_vcs_override_adds_generated_but_misses_target(self):
        self.assertEqual(self.scenarios["wrong_vcs_layer"]["candidates"],
                         ["generated/cache.ts", "src/no-call.ts", "src/visible.ts"])

    def test_parent_override_keeps_project_exclusion(self):
        self.assertEqual(self.scenarios["ignore_parents_only"]["candidates"],
                         ["src/inherited.ts", "src/no-call.ts", "src/visible.ts"])
        self.assertEqual(self.scenarios["ignore_parents_only"]["matches"],
                         ["src/inherited.ts", "src/visible.ts"])

    def test_broad_override_also_adds_generated(self):
        self.assertEqual(self.scenarios["ignore_every_rule"]["matches"],
                         ["generated/cache.ts", "src/inherited.ts", "src/visible.ts"])

    def test_home_ignore_requires_ancestry(self):
        self.assertEqual(self.scenarios["home_not_ancestor"],
                         self.scenarios["ignore_parents_only"])

    def test_real_config_is_disabled_but_parent_rule_survives(self):
        self.assertEqual(self.scenarios["config_enabled"],
                         {"candidates": ["src/no-call.ts"], "matches": [],
                          "files_exit": 0, "matches_exit": 1})
        self.assertEqual(self.scenarios["config_disabled"], self.scenarios["home_moved"])

    def test_parent_override_keeps_local_dot_ignore(self):
        self.assertEqual(self.scenarios["local_ignore_still_applies"]["candidates"],
                         ["src/inherited.ts", "src/no-call.ts"])

    def test_git_negative_and_positive_controls(self):
        self.assertEqual(self.facts["git_ancestor"], {"exit": 1, "stdout": ""})
        self.assertEqual(self.facts["git_local"],
                         {"exit": 0, "source": ".gitignore", "line": 1,
                          "pattern": "generated/", "path": "generated/cache.ts"})

    def test_debug_names_actual_parent_source(self):
        self.assertEqual(self.facts["debug_source"], "<fixture>/workspace/.ignore")
        self.assertEqual(self.facts["debug_pattern"], "inherited.ts")
        record = next(r for r in self.report["records"] if r["label"] == "debug_parent_source")
        self.assertIn('original: "inherited.ts"', record["stderr"])

    def test_wrong_and_broad_mutants_fail_narrow_acceptance(self):
        expected = ["src/inherited.ts", "src/no-call.ts", "src/visible.ts"]
        for name in ("wrong_vcs_layer", "ignore_every_rule"):
            with self.subTest(name=name), self.assertRaises(AssertionError):
                require_set(self.scenarios[name]["candidates"], expected, name)
        self.assertEqual(self.facts["rejected_narrow_scope_mutants"],
                         ["wrong_vcs_layer", "ignore_every_rule"])

    def test_empty_stream_is_valid_but_truncation_is_rejected(self):
        self.assertEqual(nul_paths(b""), [])
        with self.assertRaises(ValueError):
            nul_paths(b"./src/inherited.ts")
        with self.assertRaises(ValueError):
            nul_paths(b"a\0\0")
        with self.assertRaises(ValueError):
            nul_paths(b"a\0a\0")

    def test_summary_is_fixed(self):
        expected = Path(__file__).resolve().parents[1] / "expected-output.txt"
        self.assertEqual(summary(self.report), expected.read_text(encoding="utf-8"))

    def test_existing_report_is_not_overwritten(self):
        script = Path(__file__).resolve().parents[1] / "src" / "ancestor_ignore_lab.py"
        with tempfile.TemporaryDirectory(prefix="rg-report-test-") as directory:
            output = Path(directory) / "report.json"
            output.write_text("KEEP\n", encoding="utf-8")
            completed = subprocess.run([sys.executable, str(script), "--output", str(output)],
                                       capture_output=True, timeout=30)
            self.assertNotEqual(completed.returncode, 0)
            self.assertIn(b"FileExistsError", completed.stderr)
            self.assertEqual(output.read_text(encoding="utf-8"), "KEEP\n")


if __name__ == "__main__":
    unittest.main()

21. 补充固定输出:expected-output.txt

三种运行副本都应输出以下内容。JSON 报告比这份摘要更完整:它还保存每条实际命令的 NUL 分隔输出和调试日志。

home_is_ancestor: candidates=["src/no-call.ts", "src/visible.ts"]; matches=["src/visible.ts"]
home_moved: candidates=["src/no-call.ts", "src/visible.ts"]; matches=["src/visible.ts"]
no_config: candidates=["src/no-call.ts", "src/visible.ts"]; matches=["src/visible.ts"]
wrong_vcs_layer: candidates=["generated/cache.ts", "src/no-call.ts", "src/visible.ts"]; matches=["generated/cache.ts", "src/visible.ts"]
ignore_parents_only: candidates=["src/inherited.ts", "src/no-call.ts", "src/visible.ts"]; matches=["src/inherited.ts", "src/visible.ts"]
ignore_every_rule: candidates=["generated/cache.ts", "src/inherited.ts", "src/no-call.ts", "src/visible.ts"]; matches=["generated/cache.ts", "src/inherited.ts", "src/visible.ts"]
home_not_ancestor: candidates=["src/inherited.ts", "src/no-call.ts", "src/visible.ts"]; matches=["src/inherited.ts", "src/visible.ts"]
config_enabled: candidates=["src/no-call.ts"]; matches=[]
config_disabled: candidates=["src/no-call.ts", "src/visible.ts"]; matches=["src/visible.ts"]
local_ignore_still_applies: candidates=["src/inherited.ts", "src/no-call.ts"]; matches=["src/inherited.ts"]
git_ancestor: exit=1; stdout=empty
git_local: .gitignore:1:generated/ -> generated/cache.ts
debug_source: <fixture>/workspace/.ignore; pattern=inherited.ts
rejected_narrow_scope_mutants: wrong_vcs_layer, ignore_every_rule
Git 已跟踪的文件,rg 默认一定会搜到吗?

本例不会。legacy/tracked.ts 已加入索引,但匹配忽略模式而未进入默认 rg 候选。应比较实际清单。

-t ts 和 -g '*.ts' 能互换吗?

不能直接互换。本例中类型过滤包含 tsx,而显式 glob 纳入了被忽略的 tracked.ts;两者各命中三个文件,集合不同。

七个文本命中文件就是七个 API 调用吗?

不是这个结论。脚本中的 echo 是文字,其他表达式也需要符号与导入关系证据。本文没有构建调用图。