Skip to content

fix(scripts): check:nul-bytes 按载体扫描所有被跟踪的文本文件 (#4890) - #4907

Merged
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-4890-nul-gate-covers-all-text
Aug 3, 2026
Merged

fix(scripts): check:nul-bytes 按载体扫描所有被跟踪的文本文件 (#4890)#4907
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-4890-nul-gate-covers-all-text

Conversation

@xuyushun441-sys

Copy link
Copy Markdown
Contributor

Fixes #4890

为什么按载体(所有文本文件)而不是按用途(源代码)划范围

这道门禁自己的报错文案已经把理由写清楚了:

A raw NUL makes grep/ripgrep treat the entire file as binary and silently return ZERO matches, so the file drops out of code search and out of every grep-based lint.

这个后果是 grep 的行为,不是 JavaScript 的行为。它落在 markdown、YAML workflow、.env.example 上完全一样,落在一个扩展名还没被发明出来的文件上也一样。但它的扫描面此前是一份 JS/TS 扩展名清单 —— 范围(用途)与它自己声明的理由(载体)对不上

对不上的代价,是 .claude/ 下的全部 markdown 同时落在三道门禁之外:

门禁 扫描范围 .claude/skills/*.md
check:nul-bytes JS/TS 扩展名清单
check:doc-authoring ROOTS = ['skills', 'content'](顶层 skills/)
eslint files glob 只有 JS/TS 扩展名

#4890 正是这么暴露的:PR #4885 要写的规则之一就是「不要写裸 NUL」,写的过程中 Edit 工具把转义写成了一个真的裸 NUL,落在 .claude/skills/pm-dispatch/SKILL.md,而这道门禁报 OK —— 靠一次额外的、非常规的控制字符扫描才发现。一条关于裸 NUL 的规则,在写的过程中产生了一个裸 NUL,而负责拦裸 NUL 的门禁看不见它。 一份带裸 NUL 的 SKILL.md 对 grep -r 隐形,agent 拿不到它本该遵守的规则,且没有任何信号。

方向 2(显式把 .claude/**/*.md 加进扩展名清单)成本更低,但把「为什么恰好是这些文件」原地留着 —— 下一个新目录照漏一遍,只是把同一个问题往后挪一格。

二进制判据:内容判断,不是扩展名白名单

扩展名白名单就是上面那个缺陷换个地方留着,所以判据全部走内容。一个被跟踪的 blob 默认被扫,只有以下三条按顺序命中才跳过:

  1. 不是常规文件(symlink、submodule gitlink)或工作区里不存在 —— 没有东西可读。用 lstat 而不是 stat:本仓根目录的 core 就是一个 symlink,跟进去在某些检出下直接抛异常。
  2. 以 UTF-16/UTF-32 BOM 开头 —— 那种编码里 NUL 是结构性的,本门禁对它无话可说。本仓今天 0 个这样的文件,属于 belt-and-braces。
  3. 先剔除 NUL 字节、再对整个文件按 UTF-8 严格解码(fatal: true),解不通 —— 才算二进制。

第 3 条是全部判据,其中两个性质是关键:

  • NUL 在判断之前被剔除,所以一个裸 NUL 永远不能成为自己的不在场证明。「文件里有 NUL,所以它是二进制,所以我们不检查它有没有 NUL」正是 git 掉进去的那个循环,而打破这个循环正是本门禁存在的理由。(多字节 UTF-8 序列内部永不含 0x00,所以剔除 NUL 不会破坏任何合法序列。)
  • 解码读的是整个文件,不是前缀窗口。git 只嗅前 8000 字节,而这正是本文件文案里一直记着的盲区(protocol.ts 的 NUL 在第 147230 字节)。在这里复用同一个窗口等于把盲区原样复制一份。实现过程中这一点被实测打过脸:第一版用 64 KiB 前缀窗口,packages/formula/CHANGELOG.mdplugin-approvals/src/approval-service.ts 被误判成二进制 —— 窗口边界把一个多字节汉字切成了两半。自检里留了一个跨窗口的长中文 fixture 盯这条。

由此得到的性质,正是扩展名清单不可能有的:一个扩展名从没见过的新文本文件默认被扫到 —— 它能按 UTF-8 解码,所以它是文本。自检里的 config/weird.frobnicate 就是这条的常驻证据。今天真正掉出去的只有 4 个 PNG + 1 个 ICO。

另外刻意没有加逐文件豁免口子:本仓没有任何被跟踪文件带合法的裸 NUL;真有一天需要,那应当是一次摆到明面上的决定,而不是 skip-list 里悄悄多一行。

双向证明

一个只观察到绿的门禁,和一个什么都匹配不上的门禁,从外部无法区分。所以两个方向都跑了。测试文件用脚本在运行时生成(编辑器里绝不写出真的裸 NUL —— 那正是本单在防的事,而且会污染 diff),验完即删,没有提交进仓库。

### od -c(证明那个字节是真的):
0000100   t   o   r   :      \0       <   -       t   h   a   t       i

### BEFORE —— main 上的 scripts/check-nul-bytes.mjs:
check-nul-bytes: OK (2953 tracked source file(s), no raw NUL bytes).
exit=0

### AFTER —— 本分支:
check-nul-bytes: 1 file contains a raw NUL byte (0x00)

  • .claude/skills/nul-gate-probe/SKILL.md:3:52 -- 1 occurrence, first at byte offset 69

exit=1

同一个被 git 跟踪的 .claude/skills/*.md:改前判绿(exit 0),改后判红并点名文件与 line:column:byte offset

自检接进脚本 + 耗时实测

按仓内既有惯例(check:route-envelope / check:published-files / check:wildcard-fallthrough 等 9 条都是这个形状),把 --self-test 接进了 check:nul-bytes:

"check:nul-bytes": "node scripts/check-nul-bytes.mjs --self-test && node scripts/check-nul-bytes.mjs",

这是本 PR 唯一动到根 package.json 的一行(本仓最热的冲突点之一,故显式说明);.github/workflows/ 没有改动 —— lint.yml 早已调用 pnpm check:nul-bytes,自检因此自动进了 CI。

自检在临时目录里 git init 一个真仓库、写好 fixture、git add 之后调用main() 同一个 scan() 函数(#4868 的教训:自检与真实调用路径必须是同一条),16 条断言覆盖:.claude/ markdown、TS 源、未知扩展名、PNG/ICO、UTF-16、悬空 symlink、dist/ 排除、跨窗口长中文、NUL 的 line:col 定位、classify() 三条直判。

✓ check-nul-bytes --self-test: 16 assertions over a temp git repo (real scan() path)
check-nul-bytes: OK (scanned 4974 tracked text file(s); skipped 5 binary, 1 non-regular; no raw NUL bytes).

耗时(同一容器,各跑 3 次取范围):

扫描文件数 耗时
改前(JS/TS 扩展名) 2953 148 / 156 / 166 ms
改后(所有跟踪文本文件) 4974(+5 二进制 +1 symlink 跳过) 504 / 505 / 514 ms

扫描面 +68%,耗时约 3.3×,绝对值仍在半秒级 —— 对一条 CI 门禁完全可接受,不需要退回方向 2。pnpm check:nul-bytes(含自检、含 pnpm 启动开销)端到端 1.67s。

main 上没有任何既存文件被判红 —— 全仓 4974 个文本文件零裸 NUL,--list 显示掉出扫描面的就是那 4 个 PNG、1 个 ICO 与根目录的 core symlink。

明确不在本 PR 范围内

方向 3(check:doc-authoringROOTS.claude)按分派要求不做,判断写在报告里由 PM 另立单。


Generated by Claude Code

这道门禁自己的报错文案写着它拦的是什么:一个裸 NUL 让 grep/ripgrep 把整个
文件当成二进制、静默返回零匹配。那是 grep 的行为,与文件是什么语言无关 ——
但扫描面此前是一份 JS/TS 扩展名清单,范围(用途)与理由(载体)对不上。

代价是 .claude/ 下的全部 markdown 同时落在三道门禁之外。#4890 就是这么暴露
的:PR #4885 要写的规则正是「不要写裸 NUL」,而写的过程中一个真的裸 NUL 落进
了 .claude/skills/pm-dispatch/SKILL.md,这道门禁报 OK。

改为扫描所有被 git 跟踪的文本文件。二进制判据是内容判断而非扩展名清单:
非常规文件(symlink / gitlink)跳过;UTF-16/32 BOM 开头跳过(那种编码里 NUL
是结构性的);其余先剔除 NUL 字节、再整文件按 UTF-8 严格解码,解不通才算
二进制。先剔除 NUL 是为了打破「有 NUL 所以是二进制所以不查 NUL」这个 git 掉
进去的循环;整文件解码而非只看前缀,是因为 git 只嗅前 8000 字节正是本文案里
记着的盲区。

同时按仓内惯例把 --self-test 接进 check:nul-bytes 脚本,自检在临时 git 仓库
里跑真实的 scan() 路径。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 3, 2026 3:32pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file tooling size/m labels Aug 3, 2026
@xuyushun441-sys
xuyushun441-sys marked this pull request as ready for review August 3, 2026 15:45
@xuyushun441-sys
xuyushun441-sys added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit 049686a Aug 3, 2026
23 checks passed
@xuyushun441-sys
xuyushun441-sys deleted the claude/issue-4890-nul-gate-covers-all-text branch August 3, 2026 15:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/m tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.claude/skills/** 的 markdown 不被任何门禁扫描 —— check:nul-bytes 只看 JS/TS,check:doc-authoring 的 ROOTS 不含 .claude/

2 participants