fix(router): 不使用 section 的模式下,section 段按垃圾段收敛 (#241) /perldoc/File::Find/notaformat 会带着段名渲染出 200(标题 perldoc(notaformat)), /perldoc/File::Find/mcp 同理 —— 站点为一个无限大的 URL 空间返回同一内容的不同 标题。原因是 normalizeSection() 只要求 ^[A-Za-z0-9_]+$,所以这类段在语法上都是 「合法 section」,malformedSegmentRedirect() 不会处理;而 perldoc/info/pydoc/ri 的 getXPage() 根本不使用 section(仅显示),页面照常找到,也就不会触发 man 那条 resolveManSection() 的 301。 - src/util.php: malformedSegmentRedirect() 增加 $mode 参数;对 man/search 之外的 模式,section 位上的非格式标记一并视为垃圾段,交给既有的规范化 + 301 机制。 尾部格式标记恢复等既有行为不变(/perldoc/Foo/mcp → /perldoc/Foo, /perldoc/Foo/3pm/json → /perldoc/Foo/json)。路径里本来也无法表达 perldoc 真正的 section 值(-f/-q 含 '-',normalizeSection() 判空后已由既有逻辑处理),所以不存在 误删。$mode 省略时保持保守(不新增任何 301)。 - phpMan.php: 调用点把 path mode 传进去。 - test/unit/test_malformed_segment.php: 补 12 条断言(四种模式各一例、带真实格式 后缀的一例、man/search 不受影响、无 section 不跳转、mode 省略时保守)。 测试 498 → 515 通过。
fix(config): tools_config.php 的兜底改为「未定义就定义」,部署改为原子替换 (#240) 部署用 `php cli/detect-tools.php > tools_config.php` 就地重写该文件:shell 先 截断、PHP 再写入,中间存在「文件存在但内容为空」的瞬间。此刻到达的请求会让 config.php 的 `file_exists()` 为真、`require` 却什么都没定义,而兜底只在文件 **不存在**时才跑 —— 于是 PHPMAN_HAS_* 全部未定义,请求在 showForm() 里 fatal (生产日志 2026-10-08 那条 `Undefined constant "PHPMAN_HAS_PERLDOC"`)。 - src/config.php:无论文件是否存在,require 之后统一补齐未定义的常量。空文件、 只写了头部的文件都退化成「假设工具齐全」,不再 fatal。 - Makefile(staging + production 两处):先写 .tmp 再 mv 原子替换,读者永远看不到 半写状态。这一层是必需的:若写入恰好截断在语句中间,那是解析错误,兜底救不了。 - test/unit/test_tools_config_fallback.php:5 条断言,用子进程逐个加载 config.php (常量不能在同进程内重定义),覆盖缺失/空/仅头部/部分定义/完整定义。对修复前的 config.php 有 3 条失败(部分定义那例返回 `0uuu`,正是未定义常量的症状)。
fix(cache): bump RENDERER_VERSION 到 2 —— 页脚改动没能到达已缓存页面 (#238) 39acf22 删掉了页脚那条死链,代码部署上去了,但线上页面**仍在下发 /mcp 链接**: HTML 是缓存的,而这次改的正是渲染输出,却没有 bump RENDERER_VERSION。如果不 处理,死链要等到 210 天 TTL 到期才会消失——与 AGENTS.md 记录的 OSC-8 / JSON-markdown 那次「改完在线上看不见」是同一类问题。 showHeader()/showFooter() 的输出和渲染器一样属于缓存内容,改它们同样需要 bump。 - src/config.php: RENDERER_VERSION 1 → 2
fix(search): 重建把清除并入事务,并拒绝提交空索引 (#239) rebuildSearchIndex() 原本在 BEGIN IMMEDIATE 之前就 DELETE 掉 search_fts 与 search_index_meta,713 行的注释却写着「DELETE 在事务内,靠 ROLLBACK 恢复」—— 代码与注释不符。中断(进程被杀、磁盘满、DB 锁)会留下空 FTS 与空 meta: search_fts_old 兜底已在 v4.9.25 删除,而空 meta 还会连带废掉 resolveManSection(),所以损害不止于搜索,且只能靠重跑那次刚被打断的长重建恢复。 - src/search_index.php:两处 DELETE 移入 BEGIN IMMEDIATE 之内;FTS5 建表检查 留在事务外且先于任何删除,于是磁盘满/只读库的早退不会再造成清除(这是报告 没提到的一条不需崩溃即可踩到的路径:先前 CREATE 失败走的是 return,到不了 ROLLBACK)。v4.9.25「用 DELETE 而非 DROP+RENAME 以免 shadow table 留下幽灵行」 的性质与是否在事务内无关,仍然成立。代价是 WAL 变大,量级受中央库限制(实测 约 18MB)。 - 搜索分片缓存的清空留在事务外并加注说明:它是另一个数据库文件,无法并入事务; 那只是派生数据,回滚后按需重建即可。 - 新增提交前校验:事务内统计 search_index_meta,为 0 则 ROLLBACK 并报错,而不是 静默提交一个空索引(source_search.php 遇到空 meta 只会安静退回 apropos)。 - test/integration/test_search_rebuild_atomicity.php:用 PATH 里的 apropos/pydoc3/ri 打桩让「没索引到任何东西」确定化,跑真实函数,断言被拒的重建会完整保留原有索引。 验证:该测试对修复前的代码有 4 条失败、修复后全绿(已用 git stash 对照); 测试 482 → 498 通过。
fix(footer): 去掉已失效的 /{mode}/{param}/mcp 链接 (#238)
页脚只要 $isDetail && $hasContent 就无条件输出 MCP 链接,但 mcp 已不在
PHPMAN_OUTPUT_FORMATS 里,这个链接是死的。实测点击后果比报告里推测的更糟:
man 页 301 回 HTML,而 perldoc/info 会被当成 section 渲染成
"perldoc(mcp)" —— 一张看起来正常、章节信息却是错的页面,不是 404。
选择删链接而不是改指向真端点:/mcp 是 POST-only 的 JSON-RPC,浏览器打开只会
拿到 405/400;机器侧的发现路径已经齐备(web_header.php 的
Link: </mcp>; rel="mcp-server" 与 /.well-known/mcp.json),页脚是人读的
每页格式列表,MCP 不属于那里。删掉后剩下的 Markdown/JSON 两个链接都来自
baseUrl(),页脚里不再有相对地址的例外。
- src/web_footer.php: 删除 mcp href 构造与链接
- test/unit/test_footer_format_links.php: 9 条断言(无 /mcp、无 MCP 标签、
两个 href 原样输出、无内容/索引页不出格式链接)
fix(test): info 发现的期望值改到与被测实现同一环境,去掉主机相关失败 `info --where dir` 那一支的期望值是在带 INFOPATH 的环境里算的,而实现是用 `env -u INFOPATH` 跑的。在 INFOPATH 与编译期前缀不一致的主机上(macOS 本机: INFOPATH=/opt/homebrew/share/info 而 texinfo 的编译期前缀是 /usr/local/share/info), 两侧回答的是不同的安装,测试因此失败,且原因与代码无关。 现在两侧都在 INFOPATH 未设置的环境里、从同一组目录(两个编译期默认 + 该主机的 `info --where dir`)构造期望,断言改为集合严格相等;主机上确实没有 .info 文件时 打印跳过原因而不是失败。这样测试与主机无关,有数据的地方依然真断言。 验证:macOS 本机跳过并通过(482 passed / 0 failed,此前 2 条失败);把整套测试 拷到生产(Linux,/usr/share/info 84 个文件)实跑——8 passed / 0 failed,断言执行, "discovery matches the directories the implementation consults (47 names)"。
feat(logcheck): 按日与按类型汇总错误日志,不再只看 tail `make logcheck` 原来只 tail 每个日志的最后 10 行,看不出趋势:2026-10-03 一次 12,597 行的爆发(其中 12,107 条 "ETag DB query failed: no such table: cache")尾部十行看起来完全正常,值班时据此判断"没问题"。访问日志同样如此—— 只查最后 100 行的 5xx 会报"无 5xx",而最近 2000 行里其实有 4 个。 - cli/logcheck.sh(新):phpMan 错误日志给出总行数、最新一条、按日条目数与 fatal 数(窗口内计数,不是全量)、最近 N 行的消息类型聚合(URL 与 CLI 路径折叠,便于同类归并)、最后 5 条原文;Apache 错误日志统计 ModSecurity 噪音占比并单独列出非噪音条目;访问日志给出 5xx 计数与样例。只读检查,恒 返回 0,不会把发布本身报成失败。 - Makefile: 用 `ssh ... 'bash -s' < cli/logcheck.sh` 执行。脚本从 stdin 进入, 检查逻辑留在仓库里(不随代码部署),也免掉 Makefile 里多层引号转义。 验证:对生产实跑——按日表立刻显出 03-Oct 的 12597 行与 485 条 fatal; Apache 日志显示"最近 2000 行 28 条、其中 24 条是 ModSecurity"并列出 4 条真实条目; 访问日志报出老版本看不到的 4 个 5xx。
docs: 去掉 CHANGELOG 与测试里错引用的 (#45) (#45) 指向的是 chedong/phpman#45「getInfoPage()/getInfoIndex() 未检查 exec() 返回码」—— 一个 2026-06-01 就已关闭的无关 issue。本次「format 位置 上的垃圾段静默降级成 200 HTML」的修复从来没有对应的 GitHub issue (phpman 当前 open issue 数为 0),这个写法会误导以后查证的人。 去掉引用,不建新 issue。改动只涉及注释与 CHANGELOG,无行为变化。 Co-Authored-By: Claude code 2.1.285 with deepseek-flash <noreply@taotoken.net>
fix(router): format 位置上的垃圾段 301 回规范 URL,不再静默降级成 200 HTML
phpMan 的 markdown 输出用 [ls](https://.../man/ls/1/markdown) 形式
(src/search_index.php:196、src/source_ri.php:27、src/source_search.php:313),
客户端拿 https?:\S+ 这类朴素正则抓 URL 时会把右括号一起带上,于是产生
/markdown)、/markdown).、/markdown)($ssl 这类请求。
这些 URL 匹配不到 format 白名单(phpMan.php:140-153),整段被当成 section;
而 normalizeSection() 把标点抹成 "",于是「format 位置上是垃圾」和「没给
section」变得无法区分 —— getManPage("ls", "", "html") 返回整页,200 返回
21.7KB HTML,而不是客户端要的 8.9KB markdown。10/05 一天 7,206 条如此。
新增 malformedSegmentRedirect():format 可选位置上「非空、却不是合法
section」的段即坏段,在任何内容查询之前 301 到规范 URL —— 丢掉坏段;若坏段
在末位且以当前有效的 format token 开头,则保留该 token。
/man/ls/1/markdown) → /man/ls/1/markdown
/man/ls/1/!! → /man/ls/1
/man/ls/!!/json → /man/ls/json
已退役的 format token(mcp)不恢复:它的规范 URL 不再存在,恢复只会多一跳。
安全性来自「合法 section 就不碰」这条 —— markdowns、html5 这类以 format
token 开头的真 section 名不会被前缀匹配吃掉;/man/html2text/1、
/man/json_pp/1、/man/htmlclean/1p 不受影响。>5 段的 junk 由 validatePathInfo
的 tooDeep 先 403(全量日志 144 条),到不了这里。
不改 FastCGI 调用次数(客户端跟随后仍是一次 PHP);省掉的是整页渲染 +
PageCache 写。真正省槽的是 .htaccess 那层。
测试:新增 test/unit/test_malformed_segment.php(38 条,含反例)与
test/e2e/test_spider_scenarios.php S10。单元套件 214 → 252 passed;
8 个文件仍 0 passed,是本地 CLI 缺 mbstring/sqlite3 的既有环境问题,
stash 掉本次改动后同样失败。
Co-Authored-By: Claude code 2.1.285 with deepseek-flash <noreply@taotoken.net>
refactor(cache): 删掉回滚设置——两条迁移梯子、CACHE_SCHEMA_VERSION、版本戳 两个建库函数此前只在文件「不存在」时建 schema,否则走迁移:中央库一条 v1→v2→v3 级联,分片一条以 PRAGMA user_version 为键的 v5→v6→v7→v8 梯子。 它们存在的唯一理由是伺候「更旧的代码写的库」——包括刻意支持的回滚:旧部署 回滚上来会把 meta.schema_version 盖回旧值,下次前滚再迁一遍。这就是回滚 设置,不值得留着: - 分片前的中央库已不存在; - 六个线上分片本来就都在 v8(删之前已核实,PRAGMA user_version 全为 8); - 这个假设一旦破了,代价是请求路径上的 fatal,不是缓存降级——上一版删掉的 future-schema guard 正是这么炸的。 现在两个 open 都无条件跑 CREATE ... IF NOT EXISTS,增量改列不再需要迁移, CACHE_SCHEMA_VERSION 随版本戳一起消失:meta 不再写 schema_version,分片不再 写 user_version。顺带一个小收益:旧的 file_exists() 判断做不到这点——截断或 只建了一半的库文件现在能自愈,以前会走迁移分支、出来一张表都没有(新增 0 字节文件的用例钉住它)。 RENDERER_VERSION 不动:get() 会读它,这正是区别所在。 代价说清楚:没有版本戳,将来若要做「CREATE ... IF NOT EXISTS 加不了」的改列, 得连机制一起加。而把这份代码部署到 v8 之前的分片上会因缺 renderer_version 列而报错——所以上面那次核实是前提,不是走过场。 一并删掉 search_index.php 里的 search_fts_old 交换:v4.9.25 把 DROP+RENAME 改成事务内 DELETE FROM search_fts 之后它就死了,没有任何代码再创建这张表, 三处引用都不可达——重建前的「清理上次中断的残留」DROP、成功后 COMMIT 后的 DROP,以及真正要紧的那个 #180 恢复分支(把 search_fts_old 改名盖回 search_fts)。它声称提供的恢复,现在是事务自己的 ROLLBACK。 中央库里淘汰的表已按此手工清掉: - 线上 ~/.phpman/db/phpman_cache.db:search_fts_old(250 行,对面 search_fts 是 14123 行)连同 5 张 FTS5 影子表;顺带删掉没人再读的 meta.schema_version 行。删前已 .backup 到 ~/.phpman/backups/。 - staging ~/.phpman_test/db/phpman_cache.db:分片前遗留的 cache / cache_fts (各 52 行)与 4 个 idx_cache_* 索引。 测试:test/unit/test_cache_db.php 去掉 5 个迁移用例、加 3 个幂等用例; 套件 451 → 446 断言,0 失败(run_all.php 全绿)。 Co-Authored-By: Claude code 2.1.285 with deepseek-flash <noreply@taotoken.net>