车东 Che, Dong

Fri 06 November, 2026

20:44 docs(plan): perldoc 不进索引 —— 与 man 的 3pm/3perl 重叠 3,888/4,437,净增 98 条 » Recent Commits to phpman:master
docs(plan): perldoc 不进索引 —— 与 man 的 3pm/3perl 重叠 3,888/4,437,净增 98 条

生产实测:@INC 6,419 个模块名,Perl 的 man page 4,437 条,两边都有
3,888。只在 @INC 里、没有 man page 的 2,531 个里,2,404 个文件根本没有
POD,127 个有 NAME 行,98 个有真正的 "名字 - 说明"。

Debian 把 .pm 的 POD 同时装成 3perl/3pm man page,所以 perldoc File::Find
和 man File::Find 渲染同一份文档(18,425B vs 18,443B)。补 6,419 行索引,
3,729 行重复,有效新增 98 条 —— 不值得,而且描述还得读 .pm 才有内容。

Co-Authored-By: Claude code 2.1.311 with deepseek-v4.1-flash-1017 <noreply@taotoken.net>
19:53 fix(search): 分节列表不再跑 pydoc/ri 的 FTS 查询 (#280) » Recent Commits to phpman:master
fix(search): 分节列表不再跑 pydoc/ri 的 FTS 查询 (#280)

/search/(1) 除了整段 1 区 man page,末尾还挂着三条 Ruby 类:
ActiveRecord::Migration::Compatibility::V5_1、Regexp::Syntax::V1_9_1、
Regexp::Syntax::V2_4_1。

原因:html / markdown 分支无条件调用 searchFtsBySource(),而分节列表
到 FTS 那里就是一个关键词 "1"* —— 任何以 1 开头的名字都会命中。
json 分支从一开始就 gate 了($sectionOnly ? [] : …),html / markdown
只 gate 了 shell 回退,没 gate FTS 查询本身。

pydoc 和 ri 没有 man 分区,对一个分节列表本来就该贡献零条。

RENDERER_VERSION 4 → 5:已缓存的分节列表页面还带着那个块,而页面
缓存不 bump 就继续发旧内容。

Co-Authored-By: Claude code 2.1.311 with deepseek-v4.1-flash-1017 <noreply@taotoken.net>
18:41 fix(search): MATCH 收窄到 {name description},pydoc/ri 不再存常量描述 (#280) » Recent Commits to phpman:master
fix(search): MATCH 收窄到 {name description},pydoc/ri 不再存常量描述 (#280)

裸 MATCH 会搜 search_fts 的全部四列,其中两列在这里是负担:section 是每行
一个来源常量(pydoc / ri / 3pm …),body 按设计永远是空串。再加上 pydoc
存了 330 份 "Python 3 module"、ri 存了 3,699 份 "Ruby class/module",
/search/python 会返回 100 个任意模块。

生产只读实测(收窄前 → 收窄后):

    ri       3,720 → 23
    1        3,198 → 255
    python     352 → 352    ← 收窄治不了它,那些命中全是常量描述
    module   4,295 → 4,295  ← 同上

所以两半都做:scopeFtsQuery() 把查询包成 {name description} : (…);
重建时 pydoc/ri 的 description 写 ''(清单命令本来就没有摘要)。

没有只收窄到 name:name : ("copy"* AND "files"*) 命中 0 行,而
{name description} : (…) 命中 cp(1)。/search 的语义是 apropos —— 查
NAME 段(名字 + 一句摘要),只留 name 会把每个多词查询重新推回 shell,
正是 #280 要消灭的东西。

顺带:pydoc/ri 的页面标题不再带那句常量;getTitleDescription() 对两者
返回空串。§7.5 的 source_state 复核后不建 —— 它的「点号子模块」例子
根本走不到 shell(pydocSearchWorthScanning() 对含点号直接返回)。

RENDERER_VERSION 3 → 4:已缓存的 /search/python 页面还挂着旧的 100 条。

生产索引里仍是旧常量,要 `make reindex` 跑一次才生效。

Co-Authored-By: Claude code 2.1.311 with deepseek-v4.1-flash-1017 <noreply@taotoken.net>
17:37 docs(plan): §7 补实现状态 —— 并记下 §7.5 的反对意见是对的(#280) » Recent Commits to phpman:master
docs(plan): §7 补实现状态 —— 并记下 §7.5 的反对意见是对的(#280)

实现(d01333d / v4.12.20)与 §7.5 的设计有出入,如实记进去:

- 索引:pydoc/ri 的代码从 v4.4.0 就在,缺的只是重建 —— 已重建,
  man 10,093 / pydoc 330 / ri 3,699;
- 门控:用的是 sourceIsIndexed()(有没有行),不是 §7.5 设计的 source_state;
- pydoc 解析 bug("Please wait…" 被当成模块名)已修,重建后清零;
- perldoc 未做。

§7.5 的第 1 条反对意见经实测确认:MATCH python 命中 330 行(全部 pydoc 行,
因为 description 是常量 "Python 3 module"),而 name : python* 只命中 1 行。
所以「按行数开门控」确实会被描述列带偏 —— 这是粗糙版,不是终版。

三条待补写进文档:source_state、MATCH 收窄到 name 列、perldoc 读侧。

Co-Authored-By: Claude code 2.1.308 with deepseek-flash <noreply@taotoken.net>
02:50 docs(plan): 按当前代码复核并修正 9 个 open issue 的设计 » Recent Commits to phpman:master
docs(plan): 按当前代码复核并修正 9 个 open issue 的设计

对 `docs/10-OPEN-ISSUES-PLAN.md` 逐个设计对照 HEAD (`d01333d`) 复核。没有改
代码,只把不可实现或不成立的部分改成能落地的形式。

## #258 RequestBudget

- **单位写错会杀掉 pydoc3 -k。** `phpManExec()` 第 4 个参数是**秒**,唯一显式
  调用点传的是 `PHPMAN_PYDOC_K_TIMEOUT` = 20。原文按毫秒处理
  (`min($timeoutMs ?? PHPMAN_CMD_TIMEOUT*1000, …)`),20 会变成 20ms,
  `ceil(20/1000)` = 1 秒,10–14 秒的扫描在 1 秒时被杀。改为保持秒单位、只在
  边界换算。
- **0 必须是「不限」。** `remainingMs() = max(0, deadline-now)` 把
  `PHPMAN_REQUEST_TIMEOUT_MS=0` 算成 deadline 已到,于是每条命令都不 spawn;
  同样会破坏现有 `PHPMAN_CMD_TIMEOUT=0` 的契约。改为 0 → `PHP_INT_MAX`。

## #254 RenderResult

- `INDETERMINATE` 的「永远 `cacheable() === false`」与 #258 D 的「保留 600s 短
  TTL 行」矛盾。改为:不写最终负行,但可写 600s 超时行;接口用
  `ttl()`/`cacheStatus()`,不用二值 `cacheable()`。
- 不变量 3 要求 `rc=124` 不产生负行,但 `getManPage()` 目前把 rc=124 收敛成
  `''`,man 分支会写 24h 行。迁移必须给 `getManPage()` 加 indeterminate 出参,
  否则不变量在 man 上不成立。
- `ALTER TABLE cache` 打错了库:`cache` 自 v4.11 起只在
  `phpman_cache_<mode>.db`,中央 `cacheDb()` 没有这张表。必须按分片执行,且
  `fetchRow()` 的 SELECT 要带上新列。

## #259 并发闸

- 闸只包 `cacheOrExecute()` 的 primary generator 不够:真正贵的是
  `cacheOrSearchFallback()` 在它返回**之后**才跑的 `$fallbackGenerator()`
  (pydoc3 -k 8–14s),以及 man 未命中的 `apropos`。这两族突发完全不取 token。
  取槽点应放进每个会 spawn 的入口(或直接放 `phpManExec()`)。
- 闸满时不能返回 `''` —— 那会被 miss 分支写成 24h 负行;必须走显式 503。

## §7 / #280 搜索全链路

- **「pydoc/ri 各 0 行」不等于「代码没灌」。** `rebuildSearchIndex()` 已经在跑
  `pydoc3 modules` 与 `ri -l`(`src/search_index.php:652,716`);0 行是索引没
  重建。先跑 `make reindex` 再谈代码;真正缺的只有 perldoc。
- `pydoc3 modules` 只列 top-level(约 331 行),不是 6,197;`walk_packages` 的
  onerror/截断说法不适用于现有实现。含点子模块仍然不在索引里。
- 常量描述会污染 `MATCH`:搜 `python` 命中所有 `Python 3 module` 行,级联据此
  跳过 `pydoc3 -k`。回退开关不能只数行数,要用完整性标记 + `name` 列过滤。
- perldoc 行会被 `parseAproposLine()` 静默丢弃(只接受数字/n 形式的 section),
  灌了也显示不出来。
- `search_fts` 是**普通** FTS5 表,不是 external-content;只有 `cache_fts` 是。

## 其余

- #253:验证写成 `release-reindex` 的 prerequisite 会在 rebuild **之前**跑;
  移到 recipe 里、rebuild 之后。
- #255:`xmllint --valid` 在 DTD 拉不到时照样 exit 0;golden 需关 TLDR、
  归一化时间戳/日期。
- #256:p95 用了有偏样本(机器格式只记慢的/大的,HTML 是不等概率抽样);
  分位数只能用等概率样本。
- #257:`h()` 用 ENT_SUBSTITUTE,非法 UTF-8 不能 round-trip;
  `phpManLogSafeField()` 上限是 maxLen+3 字节,且 `substr` 会切断多字节。
- 头部:9 个 open issue 里已不含 #253/#265(均已关闭),补上 #280/#281,
  基线更新到 `d01333d`。

验证:代码块围栏 26 个配平;docs/ 相对链接全部可解析;
php test/run_all.php 843 passed, 0 failed。纯文档改动。

Co-Authored-By: Claude code 2.1.311 with deepseek-v4.1-flash-1017 <noreply@taotoken.net>

Thu 05 November, 2026

14:31 fix(search): 索引已覆盖的来源不再回退到命令行;pydoc 索引里那句广告删掉 (#280) » Recent Commits to phpman:master
fix(search): 索引已覆盖的来源不再回退到命令行;pydoc 索引里那句广告删掉 (#280)

两件事。

一、索引其实一直有,只是没建

rebuildSearchIndex() 从 v4.4.0 起就索引 pydoc3 modules 与 ri -l,但生产的
索引里这两个来源**各 0 行** —— 所以每个关键词搜索都退到 pydoc3 -k(8–14s)
和 ri。跑一次重建:

  man 10,093 / pydoc 331 / ri 3,699,14,138 行,9.3 秒

线上那条 22:13 的 last_indexed 是 indexAproposLines() 写的(关键词搜索顺手
灌 man 行),不是完整重建 —— 这就是「代码有、数据没有」的原因。

二、sourceIsIndexed():索引覆盖了就别再 shell

回退原来只看「FTS 返回空」。这在索引没建的机器上是对的,但索引建好之后,
FTS 空的意思是「没有这个模块」,不是「答不了」—— 而回退要花 8–14 秒把整个
Python stdlib import 一遍来确认同一个「没有」。

开关取「search_index_meta 里该来源有没有行」,不写死列表 —— 新装的机器索引
没建时仍然保留回退,而不是搜索什么都搜不到。

man 的 apropos 回退**保留**:0.03 秒,且能兜住上次重建之后新装的 man page。

验证(临时给 phpManExec 加日志,看真实子进程):

  /search/zzz_no_such_thing_xyz →  apropos 'zzz…'          ← 只有 man 回退
                                   (没有 pydoc3 -k,没有 ri)
  /search/json                  →  没有 apropos、没有 shell

三、顺带修 pydoc 解析:索引里有一句广告

原来是「遇到第一个空行就当成正文开始」,而 `pydoc3 modules` **第一行就是空行**,
于是那句 "Please wait a moment while I gather a list of all available modules..."
被当成模块名灌进了生产索引 —— 搜 "wait" 能搜到它。

改成先等 "Please wait" 横幅出现,再开始收模块。

测试:新增 test/unit/test_source_indexed.php(7 条,子进程跑,因为函数按来源
做了静态记忆)。全套 843 passed / 0 failed。

Refs: #280
Co-Authored-By: Claude code 2.1.308 with deepseek-flash <noreply@taotoken.net>
14:14 docs(plan): 9 个 open issue 的解决方案设计 + 搜索全链路(#280) » Recent Commits to phpman:master
docs(plan): 9 个 open issue 的解决方案设计 + 搜索全链路(#280)

`docs/10-OPEN-ISSUES-PLAN.md`:依赖图、四阶段落地、逐 issue 设计
(RenderResult / RequestBudget / 并发闸 / 指标与质量门),以及新增的 §7 ——
把 pydoc / ri / perldoc 灌进 search_fts 后拆掉回退,对应 #280。

其中 #256 一节补了实现状态(A/B/C 已实现,D 未做)、B 里「5xx 分族复用
logcheck 配对」没有照做的原因(logcheck 从 stdin 进来、无法 include 兄弟
文件),以及新增的 §E:`.prom` 的落盘与消费 —— 决定暂不加 cron。

`docs/00-INDEX.md` 加上这份文档的索引条目。

Co-Authored-By: Claude code 2.1.308 with deepseek-flash <noreply@taotoken.net>
08:11 docs: 去掉误引的 #266 —— 那是安装器 PR,不是这次的分节列表修复 » Recent Commits to phpman:master
docs: 去掉误引的 #266 —— 那是安装器 PR,不是这次的分节列表修复

上一个提交(17b3251)的 CHANGELOG、util.php 注释和测试标题都把这次修复挂在
#266 上,而 #266 是已合并的安装器 PR("harden installer updates, webroot
deploys and data ownership")。仓库里没有对应的 issue,所以不引用编号比引用
错误的编号好。

提交信息本身(Refs: #266)已推送,无法在不 force-push master 的前提下修改 ——
以本提交为准。

Co-Authored-By: Claude code 2.1.308 with deepseek-flash <noreply@taotoken.net>
07:00 fix(search): 分节列表不再跑 pydoc/ri 级联 —— search/(1) 从 21s 降到 apropos 的 0.0… » Recent Commits to phpman:master
fix(search): 分节列表不再跑 pydoc/ri 级联 —— search/(1) 从 21s 降到 apropos 的 0.03s (#266)

24 小时窗口里 mode=search 的 14 次渲染 **100% 超过 3s、p50 21.6s**,是全场最慢的
一族,其中 12 次是分节列表。

慢的不是 apropos:`apropos -s 1 .` 在服务器上 0.03 秒返回整节。慢的是它之后跑的
两件事 ——

  pydoc3 -k '(1)'   7.97s,0 行
  ri '(1)'          0.39s,0 行

分节列表不是关键词,这两条命令对它**不可能**命中,纯属白跑。

改法:新增 isSectionListingQuery(),在三种格式分支里给 pydoc/ri 的回退各加一道闸。
顺带修掉一处不一致 —— 原先只有 HTML 分支在「apropos 有结果」时才跳过 pydoc,
markdown/json 则无条件回退,所以同一页的机器格式比 HTML 慢得多。

验证方式是把 phpManExec 临时加了一行日志,记录实际跑了哪些命令:

  NEW /search/(1)    → apropos -s '1' .            (没有 pydoc3、没有 ri)
  NEW /search/talk   → apropos 'talk'; ri 'talk'    (关键词级联照旧)

有一处差点写错:JSON 分支原来用 `else` 接回退,而分节列表同样会得到空的 FTS
结果 —— 直接 `else` 会把它又推回 shell。改成 `elseif (!$sectionOnly)`。

测试:test_normalize.php 加 8 条((1)/(9)/(n)/空白/关键词/(10)/(1) gcc/(pydoc)/空)。
其中 (10) 断言为 true —— 它和 getSearchPage() 自己的 [0-9n]+ 同口径,当成列表是
「更快的错」:apropos -s 10 找不到东西,而 pydoc3 -k '(10)' 要 8 秒才确认找不到。

全套 836 passed / 0 failed。

Refs: #266
Co-Authored-By: Claude code 2.1.308 with deepseek-flash <noreply@taotoken.net>
01:03 docs(corpus): 黄金语料与参考实现差分设计(#255) » Recent Commits to phpman:master
docs(corpus): 黄金语料与参考实现差分设计(#255)

阶段 2 的 L2 层:把 man/perldoc 的真实输出冻结成语料,让"渲染是否退化"
变成机器判定。

核心决定:
- 语料在生产机采集,连同工具版本与 sha256 存档;CI 只做纯函数比对,
  不执行 man/groff/perl —— 否则裁判会随镜像版本漂移。
- 三条独立检查:黄金 Markdown、文本完整性、结构指纹 + XHTML 合法。
- perldoc 语料用 pod2text -w 100 管线(生产实际路径),不是 perldoc <module>。
- TLDR 在语料渲染中关闭:它是外部内容,不属于"原始输出 → 渲染"这条链路。
- 基准 URL 与 PHPMAN_HOME 固定;XHTML 验整页,DTD 随仓库离线解析;
  黄金由 PHP 8.3 生成,语料作业只在 8.3 腿运行。

经独立评审修掉五个实现歧义,结论见文档 §6.1。

Co-Authored-By: Claude code 2.1.311 with deepseek-v4.1-flash-1017 <noreply@taotoken.net>

Wed 04 November, 2026

08:36 feat(metrics): HTML 进遥测(1% 抽样)+ cli/metrics.php 出指标与 SLO (#256) » Recent Commits to phpman:master

Tue 03 November, 2026

23:00 fix(release): 验证器加重试;计时项降级为告警 » Recent Commits to phpman:master
22:50 fix(release): 缓存检查改与静态文件对照,不再用绝对阈值 » Recent Commits to phpman:master
20:24 fix(pcre): 正则里的 \x00 不再被 PHP 展开成 NUL 字节 —— 8.1 上整站 404 » Recent Commits to phpman:master
20:12 fix(release): 验证脚本按入口 URL 拼地址,缓存检查排除握手 » Recent Commits to phpman:master
19:54 fix(security): 安装器权限/路径、Host 回退、CSP nonce(#271 #272 #273) » Recent Commits to phpman:master
19:46 ci: 探针直接调 normalizeParameter(),别再从断言反推 » Recent Commits to phpman:master
19:22 ci: 加一条 php sanity 探针 —— mbstring 坏掉时 normalizeParameter 全返回空 » Recent Commits to phpman:master
19:04 ci: 显式装 mbstring/sqlite3;失败的断言要打出 expected/actual » Recent Commits to phpman:master
18:31 ci: 补一个占位的 .deploy.mk —— 没有它 make test 直接 Stop » Recent Commits to phpman:master