接手一个陌生项目,报表配置里写着 limit: 6,跑出来却只有四条。项目里有命令行入口、配置加载器、几个服务和一堆测试。全部读一遍当然能找到原因,但没必要:盯住决定输出条数的那个值,顺着它往下找就行。

先把问题问具体

“搞懂报表模块”没有终点。换成一个能回答的问题:

配置文件写的是 limit: 6,环境变量是 REPORT_LIMIT=4,结果输出四条。到底是谁生效了?如果 REPORT_LIMIT 是空字符串,又会怎样?

顺手记下运行的命令、所在目录和代码版本。还要确认源数据多于四条:如果数据本来就只有四条,输出四条什么也说明不了。

手里只有一条线索,先找到起始文件

最先找到的文件可能是启动脚本、输出排版函数,也可能是一份测试,还不是选择条数的函数。先用能复现的线索走到那里,并记清每一步是怎么确认的。

下载报表入口练习,解压后进入 report-entry-lab。这个小项目是为教程从零构造的,给下文相同的优先级规则补上了真实命令、处理函数注册和本地文件加载。需要 Node.js 22 或更新版本、npm,以及 ripgrep(rg);不用安装依赖或连接模型。下面的 Shell 命令适用于 macOS 和 Linux。

REPORT_LIMIT=4 npm run --silent report
Report rows: 4
Aster
Birch
Clover
Dahlia

配置样例写着 limit: 6,数据样例有八条。第一行是标题,后面才是四条数据。因此条数差异确实需要解释,不是因为输入太少。程序只读取包内的本地样例并输出到终端;测试会启动本地 Node 子进程,不请求网络,也不写文件。

线索 查找起点 还需核对
命令 package 脚本或实际可执行文件 哪个参数选中了哪个处理函数
输出 拼出这段文字的代码 命中的是生成位置、样例还是断言;谁调用了生成函数
测试 测试的 import 和实际调用 它启动了真实命令,还是自己传入替代依赖
配置 读取并转换这个值的地方 哪个调用方会走到这里,配置由哪个加载器提供

在 Yenpo 中阅读同一个练习

带着“哪一个入口输出了这四行?”打开刚下载的 report-entry-lab。这次阅读的成果是一份简短记录:启动命令、选中的处理函数、实际输入文件和输出调用。

  1. 在 Yenpo 打开解压后的 report-entry-lab 文件夹,选择包含 package.json 的根目录。按 Command-P(前往文件),依次打开 package.json、src/cli.mjs 和 src/commands.mjs,核对启动脚本及 report 注册的处理函数。
  2. 在侧栏的全文搜索中搜索 Report rows:,关闭正则表达式。对照 src/format-report.mjs 与测试中的匹配:前者构造输出,后者断言预期结果。继续检查 src/report.mjs 的 import 和调用,才能把格式化函数连到这条命令上。
  3. 把 src/report.mjs、src/limits.mjs 和相关测试留在标签页中,记录每一步的文件与行号,再用下文的 Terminal 检查区分“直接调用函数的测试”与“经过真实 CLI 的运行”。

文件浏览和文本搜索不需要连接模型或安装 JavaScript 语言服务器。想跟做,可以获取 Mac 版 Yenpo并打开同一个解压目录;阅读帮助说明了两种搜索工具的区别。ZIP 包含源码文件,不含 Git 历史,因此这里阅读的是这些文件,不是固定提交比较。

已知终端命令

先读启动脚本,不要只凭目录名猜入口:

npm pkg get scripts.report
rg -n 'process.argv|commands\[command\]' src/cli.mjs
rg -n 'report:|runReport|report-config.json' src/commands.mjs

第一条输出 "node src/cli.mjs report"。在 cli.mjs 中,process.argv.slice(2) 让 report 成为命令名,剩下的参数交给选中的处理函数。commands[command] 是一次查表,找到启动文件还不够:继续打开 commands.mjs,确认 report 属性调用 runReport,并给它提供读取 report-config.json 和 rows.json 的加载器。旁边的 help 属性只返回帮助文字。

接着通过同一条启动路径,只改变一个输入:

REPORT_LIMIT=4 npm run --silent report -- --limit 2

输出变成 Report rows: 2,后面是 Aster 和 Birch。npm 把双短横线分隔符后面的参数交给脚本。先读查表和注册代码,确认选中了谁;再用变化后的输出检查这条命令确实走到了条数处理。仅凭输出,还不能确认中间每一层调用。

只有一段输出文字

rg -n -F 'Report rows:' src tests
rg -n 'renderReport|runReport' src

第一条会找到 format-report.mjs,也会找到测试里的断言。前者拼标题,后者比较预期文字,两种命中都不能直接当成命令入口。在 report.mjs 中同时检查 renderReport 的 import 和实际调用,确认传入的是筛选后的名称。再沿 runReport 回到 commands.mjs 的 report 注册处,最后核对 cli.mjs 选择处理函数和写入 stdout 的位置。

这样就有了一条可以用前面命令验证的路径。真实项目的输出如果由多段文字拼成,可以先搜稳定的片段,再读周围表达式怎样组合它。共用排版函数若有多个调用方,先把它们当作候选;等启动参数或针对性测试选中一个,再继续追,不要把所有调用方混成一条运行路径。

已知一条测试名称

rg -n -F 'environment wins over config' tests
node --test --test-name-pattern='environment wins over config' tests/report.test.mjs

这条测试直接导入 runReport,自己传入环境变量对象、配置加载器和数据加载器。它能确认函数内“四覆盖六”,但没有启动 cli.mjs,也没有读取配置文件,所以不能说明真实命令到底加载哪份文件。

沿 import 找到行为后,还要读实际的 commands.report 绑定。另一条测试会走过缺少的这一步:

node --test --test-name-pattern='CLI uses REPORT_LIMIT' tests/cli.test.mjs

它启动真实 CLI 子进程,传入 REPORT_LIMIT=4,核对退出状态和 stdout。两条选定的测试都能通过。笔记里应区分:前一条自己选依赖,后一条走真实启动和文件加载。直接调用函数的测试通过,不能代替后一种检查。

只知道配置名

rg -n -F 'REPORT_LIMIT' src tests
rg -n 'resolveLimit|loadConfig|report-config.json' src

源码中读取变量的地方在 limits.mjs,其余命中是在准备测试输入。沿 report.mjs 中的 resolveLimit 调用往回读,再确认 commands.report 传入的 loadConfig。其中的 new URL(..., import.meta.url) 从模块位置定位包内样例;仅仅搜到文件名,还不能说明这次处理函数会用它。

到这里,已经找到了继续追配置值的起点:命令、选中的处理函数、实际输入文件和输出位置都已核对,可以暂时放下 help 和无关测试。如果真实命令使用另一份检出、包装脚本或环境来源,先解释这个差异;否则下一步就查值的优先级,不用把范围扩大到整个仓库。

进入选中的处理函数

练习里的 report.mjs 依次解析参数、加载配置、选择条数、读取数据,再调用 renderReport 加上标题,由 CLI 打印返回的字符串。下面保留的早期单文件例子只返回数据行,方便先看值的流向。两个版本都把加载器作为参数传入,测试就能观察执行顺序,不必真的读文件:

export async function runReport({ args, env, loadConfig, loadRows }) {
  const { values } = parseArgs({
    args,
    options: { limit: { type: 'string' } },
    strict: true,
  });
  const config = await loadConfig();
  const limit = resolveLimit(values, env, config);
  const rows = await loadRows();
  return rows
    .slice(0, limit)
    .map((row) => row.name)
    .join('\n');
}

还没点开任何辅助函数,这段代码已经交代了执行顺序:解析参数、加载配置、选出条数、读取数据、输出。还有一个容易漏掉的细节:只有 loadConfig() 成功了,resolveLimit() 才会执行。后面会用到这一点。

在真实项目里,要顺着 import 找到实现。叫 loadConfig 的函数,可能只读一个 JSON,可能合并好几个文件,也可能去请求远程服务,光看名字判断不了。

条数是怎么选出来的

export function resolveLimit(cli, env, config) {
  const raw = cli.limit ?? env.REPORT_LIMIT ?? config.limit ?? 10;
  const limit = Number(raw);
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
    throw new Error('limit must be an integer from 1 to 100');
  }
  return limit;
}

这里做了三件事:选来源、转数字、查范围。笔记里最好分开记。只写一句“环境变量覆盖配置文件”,很容易脑补出一条代码里没有的规则:环境变量不合法时会退回配置文件。实际上不会。

命令行 --limit REPORT_LIMIT 文件中的 limit 实际结果
2 4 6 使用命令行的 2
未提供 4 6 使用环境变量的 4
未提供 未提供 6 使用文件的 6
未提供 未提供 未提供 使用默认值 10
未提供 空字符串 6 校验失败
0 4 6 校验失败

?? 只跳过 null 和 undefined。空字符串也算有值,所以 REPORT_LIMIT='' 照样会被选中。Number('') 得到 0,范围检查直接抛错,函数不会再去看配置文件。

把 ?? 换成 ||,空字符串就会继续往下找,但 0 也会被当成“没设置”。这等于改了规则,不是顺手的小重构。动手之前先想清楚:有人明确传了一个非法值,应该报错,还是悄悄回退?

追到值被用掉的地方

条数选对了,输出不一定就对。顺着 limit 找到 rows.slice(0, limit),再看 rows 从哪来。

示例里的 loadRows() 会把所有行都读出来,处理函数再取前几条。数据源那一侧没有排序、过滤,也没有分页。所以这个限制只管打印多少条,管不了读了多少数据、占了多少内存。

如果拿到的问题是“明明加了 --limit 2,报表还是把内存撑爆了”,那优先级规则就不是该查的地方,应该进 loadRows 看看限制要不要下推到数据源。同一个处理函数,问题变了,下一步要看的文件也跟着变。

故意弄坏一样东西

现在把配置文件弄坏,同时传 --limit 2。命令行优先级最高,程序应该还能跑吧?

跑不了。配置在选条数之前加载,loadConfig() 一抛错,后面都不会执行。优先级只决定用哪个值,管不了选值之前有哪些步骤必须先成功。

反过来也值得测一下:配置加载成功,但选出来的条数不合法,这时数据根本不该被读取。数一下读取次数就能确认:

let reads = 0;
await assert.rejects(
  runReport({
    args: [],
    env: { REPORT_LIMIT: '' },
    loadConfig: async () => ({ limit: 6 }),
    loadRows: async () => {
      reads++;
      return [];
    },
  }),
  /integer/,
);
assert.equal(reads, 0);

如果只断言“最后抛错了”,一个先把数据全读完、再拒绝参数的实现也能通过。reads === 0 才真正卡住了执行顺序。

跑一下示例

下载 configuration-flow.mjs,用 Node.js 22 或更新版本运行,不需要装任何依赖:

node --test configuration-flow.mjs

原来的单文件下载继续保留,其中六个测试覆盖了优先级顺序、空值和非法值、最终输出、非法输入时不读数据,以及配置损坏的情况。这些测试只针对示例里的函数;放到自己的项目里,还得看真实入口怎么读环境变量、怎么显示错误。

新的入口练习还覆盖了真实命令的连接。在它的目录里运行:

node --test tests/*.test.mjs
REPORT_LIMIT='' npm run --silent report

九个测试全部通过。第二条命令会故意以状态码 1 退出,在 stderr 输出 limit must be an integer from 1 to 100,stdout 为空。函数测试还统计了数据读取次数,用空值案例核对上文的执行顺序。ZIP 附有 SHA-256 清单,压缩包及解压后的测试都已核验。

留一份笔记

几行就够。下次再碰到这个问题,读它比重读代码快得多:

runReport
  parseArgs → loadConfig → resolveLimit → loadRows → slice → output

limit:命令行 > 环境变量 > 配置文件 > 10
选中的值非法:报错,不回退,不读取数据
配置加载失败:即使有 --limit 也会停止
限制范围:输出条数,不是数据加载器的工作量

真实项目里,在每一步旁边补上文件和行号,再把还没确认的事写下来:loadConfig 读的是哪个文件、父进程有没有设置 REPORT_LIMIT、命令行怎么展示错误。能解释清楚为什么是四条,也知道哪些还没查,第一轮就可以收工了,不必先把整个项目读懂。

把阅读结果交给下一位维护者核验

下一位维护者如果不用翻你的聊天记录,就能复现笔记中的事实,这份记录才更便于交接。可以试用海报投稿盒交接练习 ZIP,先读简短交接简报。代码、功能承诺和交接情境均为独立构造的离线教学材料,不是真实 AI 生成项目或真实交接事故。

接收者按同一份固定源码核对四项承诺:新进程能读回本地卡片;相同内容按顺序重试不重复;回执只是 fixture 预览;部署替换后能否保留数据仍未知。implemented 只覆盖所测条件,顺序重试不代表并发安全;mock 没有发送,unknown 则缺少实际部署存储证据。

按简报执行,把 RECEIVER.md 复制到解压包外填写,记录自己的命令、结果、源码依据和待查项。保留包内原模板,填完后再次校验原包。

ZIP SHA-256
c39d19b210c05f4667cd74db4934459a079408d14c5e7678c24cd087e1df5185

在 Yenpo 里,可以用前往文件和工作区搜索把处理函数、选值函数和测试并排打开。问 AI Reader 时,也只问一个值:“从读取 REPORT_LIMIT 的地方追到它被使用的地方,附上行号。”这样拿到的是一条能逐步核对的路径,而不是又一份项目概述。

接下来可以按手头的问题选择阅读路线: