docs+tooling: no document may name a path that does not exist

Not even the retired registry — there a fictional path reads the same as a
real one, so nobody can tell "we decided against this" from "this is wrong".

docs
  design.md   external-repo citations now carry their owning prefix
              (stalwartlabs/jmap-client/src/client.rs), so a bare
              src/client.rs can no longer read as a local file; the LLM
              layer, the local-write red line and the missing-test-dir notes
              stop quoting directories that were never created
  retired.md  header states the rule; invented entries are rewritten as named
              proposals ("把渠道层建成目录模块") instead of as paths that
              look like files; docs/todo.md stays, git history proves it
  AGENTS.md   §7.4 records the rule as a gate requirement
  charter.md  §6.3 validator count and scope

tooling
  audit_paths, the sixth docs_check validator: every path-shaped token in
  inline code in docs/*.md and AGENTS.md must resolve against a real root,
  or carry an external owning prefix, or be provably deleted in git history,
  or sit on a short commented allowlist. Non-path shapes are excluded
  structurally — absolute paths and URIs, dotted hosts, media types, JMAP
  method names, numeric pairs, glob fragments — and a seg/ token only counts
  as a directory when it is the whole inline-code span, so Email/changes and
  application/json are never mistaken for dirs. run_all.sh is now 6/6.

Verified, not assumed: a scratch doc with three invented paths yields 3
errors and exit 1, while a real file, a git-deleted file, an
external-prefixed citation and the conventional-name allowlist all pass.
This commit is contained in:
anonsaber 2026-09-29 09:28:29 +08:00
parent 1ecba9d961
commit 4c7cbe66b9
7 changed files with 276 additions and 22 deletions

2
.gitignore vendored
View File

@ -7,5 +7,7 @@
*.pem
*.key
*.p12
# Python 校验器运行时会生成
__pycache__/
# 构建验证缓存(cargo registry + rustup 组件,1.5G,绝不能提交)
.gate-cache/

View File

@ -218,6 +218,9 @@
**它不能证明语义正确。**
- 报告校验结果时必须同时说明这个区分,不得把「0 error」说成「内容已验证」。
- **新增文档必须加入校验器的文档清单**,否则它永远不会被校验——这是静默失效。
- **文档不得出现不存在的文件路径**——`audit_paths` 逐个检查反引号内的路径型 token 是否真实存在,
`docs/retired.md` 同样在检查范围内。曾被讨论但从未落地的路径只以文字描述其形态,不逐字写出。
已删除但 git 历史可证的文件(如 `docs/todo.md`)可以引用;无法证明存在过的不能。
---

View File

@ -126,11 +126,18 @@ cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \
cd /home/okabe/Repo/messageweave && bash scripts/docs_check/run_all.sh
```
5 个校验器全过、exit 0 才算通过。只读,从不写文件。
6 个校验器全过、exit 0 才算通过。只读,从不写文件。
新增 `check_file_size`:`src/`、`web/`、`cloudflare-worker/src/` 下超过 500 行的源文件必须带
`SPLIT-EVAL:` 标记(AGENTS.md §2.1)。它证明标记存在,不证明写下的拆分理由站得住脚。
新增 `audit_paths`:`docs/*.md` 与 `AGENTS.md` 里反引号内的路径型 token 必须真实存在。
它存在的原因是:文档曾出现从未创建的路径,而虚构路径读起来和真实路径完全一样,读者无法分辨
「我们决定不做」和「这写错了」。`docs/retired.md` 同样在检查范围内——废弃登记里的路径也必须真实,
未落地的形态只以文字描述。豁免只有三类,都在脚本里可见:指向外部仓库的所有者前缀
(`stalwartlabs/...`)、git 历史可证已删除的文件、以及一份注明理由的知名文件名白名单。
它不检查反引号外的散文,裸文件名也只按同名校验解析。
**`0 error` 只证明「可达」**:行号存在、引用可解析、表格列数一致、标记就位。
**它不证明语义正确。** 报告校验结果时必须说明这个区分。

View File

@ -55,7 +55,7 @@
## 2. jmap-client 能力分析
来源:直接阅读 `stalwartlabs/jmap-client` 仓库 `src/lib.rs`、`src/client.rs`、`src/email/`、`src/email_submission/`、`src/event_source/`、`Cargo.toml`、`README.md`、`examples/`。
来源:直接阅读 `stalwartlabs/jmap-client` 仓库的 `stalwartlabs/jmap-client/src/lib.rs`、`stalwartlabs/jmap-client/src/client.rs`、`stalwartlabs/jmap-client/src/email/`、`stalwartlabs/jmap-client/src/email_submission/`、`stalwartlabs/jmap-client/src/event_source/`、`stalwartlabs/jmap-client/Cargo.toml`、`stalwartlabs/jmap-client/README.md`、`stalwartlabs/jmap-client/examples/`。
### 2.1 crate 概况
| 项 | 值 |
@ -100,7 +100,7 @@ client_ws/ WebSocket 客户端(feature = "websockets")
| 实时通知(Push + 对账兜底) | Push HTTPS 回调 → `StateChange`;外部 Cron 调用 `/reconcile` 使用 `Email/changes` 补差 | 需公网 HTTPS 入口(deployment.md `C-HTTPS-INBOUND`/`FLOW-NEW-MAIL`);Push 不是唯一可靠来源 |
| SSE / WebSocket(非目标) | `event_source` / `client_ws` | 本部署**不使用**(deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`);仅列 crate 能力供调研 |
### 2.4 认证机制(`client.rs`)
### 2.4 认证机制(`stalwartlabs/jmap-client/src/client.rs`)
- `Credentials::Basic { username, secret }` — 用户名/密码,Stalwart 原生支持。
- `Credentials::Bearer { token, .. }` — OAuth2 access_token;可选 `refresh_token` + `refresh_url` + `refresh_grace`,client 会在过期前自动刷新。
- `connect()` 作用:GET session URL → 解析 `accounts`/`capabilities`/`download_url`/`upload_url`/`event_source_url`,缓存 account_id。
@ -327,7 +327,7 @@ message-weave/
└── cloudflare-worker/ # 边缘反向代理,JS,不在 Rust 工程中
```
测试:**没有 `tests/` 集成测试目录**,也没有 `src/util/`。所有 Rust 测试是各模块内的 `#[cfg(test)]` 单元测试;前端唯一测试是 `web/config.test.mjs`。
测试:**没有集成测试目录**,也没有独立的 util 工具模块。所有 Rust 测试是各模块内的 `#[cfg(test)]` 单元测试;前端唯一测试是 `web/config.test.mjs`。
### 6.1 模块职责矩阵
@ -446,7 +446,7 @@ message-weave/
| 红线 ID | 含义 | 违反示例(禁止) |
|---|---|---|
| `C-NO-DB` | 生产不使用任何数据库;Redis 是唯一生产状态存储 | 加 SQLite 去重表、加 Postgres 会话 |
| `C-NO-LOCAL-WRITE` | 禁止本地文件/目录写入;不挂本地卷 | 写 `./logs/app.log`、写 `./tmp/…`、写本地 `sinceState.json` |
| `C-NO-LOCAL-WRITE` | 禁止本地文件/目录写入;不挂本地卷 | 写日志到本地文件、写临时目录、把会话游标落成本地状态文件 |
| `C-LOG-STDOUT-ONLY` | 日志只写 stdout/stderr;平台负责采集 | 引入 `tracing-appender`、`rolling-file`、`FileAppender` |
| `SAF-LOG-PURITY` | 日志与 Redis 写入内容仅限结构化事件、计数、时间戳、脱敏摘要 | 日志打印 JMAP 正文、AI prompt/completion、密钥原文、附件内容 |
| `C-NO-STATEFUL-RECOVERY` | 生产恢复不依赖进程内状态 | 用 `static Mutex<HashSet>` 存 dedup、用内存 LRU 存 sinceState 作为唯一恢复源 |
@ -513,7 +513,7 @@ pub enum BotError {
| 集成 | `src/channel.rs` 测试模块 | 用进程内 `tokio::net::TcpListener` 起 mock HTTP 服务,验证出站请求的路径、状态码与重试 |
| 真实(手动) | `src/domain/jmap/client.rs` | 标 `#[ignore]`,需显式配置 JMAP 测试服务器后 `cargo test -- --ignored` 才跑 |
**没有 `tests/` 集成测试目录**;前端唯一测试是 `web/config.test.mjs`(Node 原生断言)。
**没有集成测试目录**;前端唯一测试是 `web/config.test.mjs`(Node 原生断言)。
### 9.2 mock 策略
- `JmapService` 持有 trait `JmapBackend`(`list_emails` / `read_email` / `send_email` / `changes` 等),生产实现包装 `jmap_client::Client`(`src/domain/jmap/client.rs`),测试用 `MockBackend`(`src/domain/jmap.rs`)。
@ -594,7 +594,7 @@ pub enum BotError {
> 前置:`GATE-P0` 全项通过(`BOUND-STAGE1`)。
- 依赖 `jmap-client 0.4.2`(**已引入**,`ARCH-DEPS-STAGE1`;版本/features 以 `Cargo.toml` 为准,**禁用 WebSocket feature**);`config.rs` 复用既有 Redis 业务配置骨架(**非** `bot.example.toml`,见 `ARCH-CONFIG-ENV`)。
- 依赖 `jmap-client 0.4.2`(**已引入**,`ARCH-DEPS-STAGE1`;版本/features 以 `Cargo.toml` 为准,**禁用 WebSocket feature**);`config.rs` 复用既有 Redis 业务配置骨架(不引入独立的示例配置文件,见 `ARCH-CONFIG-ENV`)。
- `domain::jmap::client` = 真实只读 adapter(`MOD-JMAP-CLIENT`,**G1/D-G1-1 代码已实现,待真实 `cargo test -- --ignored jmap::` 验证;未实际运行前不得声称真机通过**):已实现 = 包装 `Client::connect`(Basic 认证 `C-AUTH-APP-BASIC`)、**URL 归一化**(`JMAP_SESSION_URL` 接受服务基地址或完整 `…/.well-known/jmap`,归一化为 origin/base 后传入,避免重复路径;仅 HTTPS、禁内嵌凭据与 query,`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)、**account 选择**:`ACCOUNT_ID` 留空取 session 主账户、显式值校验后使用(`REQ-SINGLE-ACCOUNT`)。
- `domain::jmap::JmapService::list_folders / list_emails / read_email`(query/get;`list_emails` 含 `limit` 边界;`received_at` 解析;`read_email` 多 part 拼接见 `REQ-JMAP-RAW-MULTIPART`)。
- **R1 入口鉴权已就绪**:`/reconcile`/`/webhook/tg`/`/push/jmap` 及 `/api/push/register` 鉴权(`SAF-AUTH-*`)已落地并通过单测;后续改动不得放宽或绕过鉴权。
@ -726,7 +726,7 @@ LLM 相关代码只有**一个文件**:
src/ai.rs # LlmClient(唯一实现):summarize() 打 OpenAI 兼容 /chat/completions
```
配置装载在 `src/config.rs` 的 `LlmConfig`,运行时参数在 `src/state.rs` 的 `OutboundConfig`(经 `RuntimeConfigProvider` 下发)。
**不存在** `src/domain/llm/` 目录,也不存在其中的 `mod.rs` / `client.rs` / `config.rs` / `policy.rs` / `fallback.rs` / `audit.rs`——熔断、规则回退、审计 span 全部未实现(见 §12.4–12.7)。
LLM 能力只存在于 `src/ai.rs` 一个文件;**没有**配置 / 回退 / 策略 / 审计分层——熔断、规则回退、审计 span 全部未实现(见 §12.4–12.7)。
调用方只有 `worker.rs` 的 `MetadataWorker`(已授权摘要)和 `notify.rs`(新邮件通知)。
### 12.2 LLM 变量清单(当前实现)
@ -790,8 +790,8 @@ src/ai.rs # LlmClient(唯一实现):summarize() 打 OpenAI 兼容 /chat/
---
## 附:调研依据(可复核)
- `stalwartlabs/jmap-client` main 分支:`src/lib.rs`(URI/Method/DataType/Error)、`src/client.rs`(认证/连接/event_source)、`src/email/`、`src/email_submission/helpers.rs`、`src/event_source/`、`src/push_subscription/`(create/verify/update_types/destroy)、`src/core/error.rs`、`Cargo.toml`、`README.md`、`examples/`。
- `stalwartlabs/jmap-client` main 分支(路径均相对该仓库根):`stalwartlabs/jmap-client/src/lib.rs`(URI/Method/DataType/Error)、`stalwartlabs/jmap-client/src/client.rs`(认证/连接/event_source)、`stalwartlabs/jmap-client/src/email/`、`stalwartlabs/jmap-client/src/email_submission/helpers.rs`、`stalwartlabs/jmap-client/src/event_source/`、`stalwartlabs/jmap-client/src/push_subscription/`(create/verify/update_types/destroy)、`stalwartlabs/jmap-client/src/core/error.rs`、`stalwartlabs/jmap-client/Cargo.toml`、`stalwartlabs/jmap-client/README.md`、`stalwartlabs/jmap-client/examples/`。
- crates.io:`jmap-client` 元数据。
- `stalwartlabs/mail-server` main 分支:`crates/common/src/auth/credential.rs`(Password/AppPassword/ApiKey)、`crates/http/src/auth/authenticate.rs`(AccessScope 权限裁剪)、`crates/jmap/src/push/`、`api/v1/openapi.yml`(`securitySchemes`: basicAuth/bearerAuth/liveToken 60s)。
- `stalwartlabs/mail-server` main 分支:`stalwartlabs/mail-server/crates/common/src/auth/credential.rs`(Password/AppPassword/ApiKey)、`stalwartlabs/mail-server/crates/http/src/auth/authenticate.rs`(AccessScope 权限裁剪)、`stalwartlabs/mail-server/crates/jmap/src/push/`、`stalwartlabs/mail-server/api/v1/openapi.yml`(`securitySchemes`: basicAuth/bearerAuth/liveToken 60s)。
- 项目目录 `/home/okabe/Repo/messageweave/`(工具链要求见 §10.0 与 `../AGENTS.md §3.3`)。
- 部署/平台相关调研依据(lambda_runtime/worker/aws-sdk 等)见 deployment.md。

View File

@ -1,9 +1,10 @@
# 废弃与未采用路线(Retired Routes)
> 本文只记录**两类**东西:①评估过但没采纳的路线;②曾经写进文档、实际上从未存在或从未实现的名字(虚构条目)。
> 本文只记录**三类**东西:①评估过但没采纳的路线;②真实存在过、后来被删掉的文档;③曾经写进文档、实际上从未存在或从未实现的名字。
> 目的是**留原因、防复发**——以后看到这些名字,能立刻知道该用什么。
> 当前真实实现与可核对事实见 `docs/reference.md`;缺口与阻塞见 `docs/roadmap.md`;当前设计见 `docs/design.md`。
> 本文**没有行动项**。若某条变成要做的事,去 `docs/roadmap.md` 登记,本文条目保留为决策依据。
> 本文**不引用任何不存在的路径**:凡路径,一律指向仓库中真实存在的文件;曾被写进文档但从未落地的路径只以文字描述其形态、不逐字复述,以免又被抄回正文或被人当成真实文件去查阅。
> 验证基线:`b2dbe7c`。
---
@ -20,8 +21,8 @@
| `BotError::Jmap` / `Storage` / `RateLimited` / `Unauthorized` / `Llm` | 目标形态变体,未落地 | 当时为统一承接 JMAP/Redis/限流/授权/LLM 五类错误而设计;现已由各模块自行处理 | 当前 `BotError` 仅 `Config` / `Io` / `Json` / `State` 四变体(`src/error.rs` 全文) |
| Telegram 服务端 30 msg/s 限流桶 + 429 专用分支 | 目标设计,未实现 | 无真实 Bot 压测数据,不预设实现 | `src/channel.rs` 的 `max_retries`(默认 3,硬上限 5)通用重试;缺口见 `docs/roadmap.md` |
| 多步对话 FSM(`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`) | 目标设计,未落地 | 当前 AI 授权只需一个布尔+过期时间,多步态属过度设计 | Redis TTL 授权态,键与 TTL 见 `docs/reference.md` 的 AI 授权态一节 |
| `src/domain/llm/`、`src/domain/command.rs`、`src/domain/notification.rs`、`src/domain/state.rs`、`src/channel/`、`src/notify/`、`src/util/`、`tests/` 子模块拆分 | 目标形态目录,未落地 | 代码量未到需要拆分的规模 | 实际结构见 `docs/design.md` 的工程结构一节:领域层是 `src/domain.rs` + `src/domain/jmap/`(仅 `client.rs`);LLM 在 `src/ai.rs`;通知在 `src/notify.rs`;**`src/util/` 与 `tests/` 根本不存在**(加密逻辑内联在 `src/state.rs` / `src/config.rs`) |
| `tests/telegram_dispatch.rs` | 目标形态测试,未落地 | 依附 teloxide 测试模式 | `src/channel.rs` 的单元测试(`#[test]`) |
| 把领域 / 渠道 / 通知 / util 各层拆成独立目录(子模块拆分方案) | 目标形态目录,未落地 | 代码量未到需要拆分的规模 | 实际结构见 `docs/design.md` 的工程结构一节:领域层是 `src/domain.rs` + `src/domain/jmap/`(仅 `client.rs`);LLM 在 `src/ai.rs`;通知在 `src/notify.rs`;加密与工具逻辑内联在 `src/state.rs` / `src/config.rs`,没有独立的 util 层,也没有集成测试目录 |
| teloxide 风格的出站消息派发集成测试 | 目标形态测试,未落地 | 依附 teloxide 测试模式 | `src/channel.rs` 的单元测试(`#[test]`) |
| docker-compose `healthcheck` 示例(`message-weave health --addr ...`) | 已删除的示例 | 运行镜像 `debian:bookworm-slim` 内无 `curl`/`wget`,且 `src/main.rs` 无 CLI 子命令解析,示例照抄必失败 | 由平台 ingress 探测 `/ready`;说明见 `docs/deployment.md` 的就绪探测一节 |
| SPA 管理凭据 = `REDIS_URL` 的 Redis ACL 密码 | 未采用路线 | 混淆基础设施凭据与 UI 管理密码;Redis 无 ACL 密码(TLS-only 托管 Redis)时 `bootstrap_token` 的 `.is_empty()` 守卫让 SPA 永久 401 | 改用 `CONFIG_ENCRYPTION_KEY`(启动必填的 32 字节高熵 hex,常数时间比较) |
@ -72,7 +73,7 @@
| 条目 | 类型 | 原因 | 替代或现状 |
|---|---|---|---|
| `channel/telegram/{mod,commands,session,render}.rs` | 虚构文件 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 |
| 把 Telegram 接入拆成「模块入口 / 命令解析 / 会话状态 / 回复渲染」四个文件的目录方案 | 虚构目录方案 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 |
| `delivery:pending:{stream}` | 虚构键 | 与 Redis Streams 的 pending-entries list(PEL)混淆——PEL 由 Redis 内部维护,不是可写键 | 真实键:`delivery:inflight:{stream}:{id}`(EX 60)与 `delivery:committed:{stream}:{id}`(EX 604_800),见 `docs/reference.md` 的投递流水线键一节 |
| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:718`) |
| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:898-901`) |
@ -81,9 +82,9 @@
---
| `src/domain/llm/` 整个模块族 | 虚构模块树 | `domain/` 下只有 `jmap.rs` 与 `jmap/client.rs`;LLM 只有 `src/ai.rs` 一个文件(`LlmClient`) | 无 |
| 把 LLM 能力建成领域子模块族(配置 / 回退 / 策略 / 审计分层) | 虚构模块树 | `domain/` 下只有 `jmap.rs` 与 `jmap/client.rs`;LLM 只有 `src/ai.rs` 一个文件(`LlmClient`) | 无 |
| `ai::config` / `ai::fallback` / `ai::policy` / `ai::audit` | 虚构模块 | LLM 配置在 `config.rs::LlmConfig`,运行时参数在 `state.rs::OutboundConfig`(经 `RuntimeConfigProvider` 下发),无 policy/audit 概念 | 无 |
| `channel/mod.rs` | 虚构文件 | 渠道层就是 `src/channel.rs`,不是 `channel/` 目录 | 无 |
| 把渠道层建成目录模块(含模块入口文件) | 虚构目录 | 渠道层就是 `src/channel.rs` 一个文件 | 无 |
| `notify::push_handler` / `notify::worker` / `notify::reconcile` 作为模块路径 | 虚构模块路径 | 这些是 `src/notify.rs` 内的自由函数(`jmap_push` / `worker` / `reconcile`),不是模块路径 | 无 |
| `mod_dedup` / `mod_streams` / `mod_sincestate` | 虚构模块名 | `state.rs` / `notify.rs` 都是平铺文件,无子模块;去重与 Streams 逻辑以自由函数存在 | 无 |
| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:885`)内联处理回调 URL 与验证码回写 | 无 |
@ -97,7 +98,7 @@
| 附件下载(`send_document` / `Blob/get` / 下载按钮) | 未实施 | JMAP 侧只读;邮件附件仅以 `has_attachment: bool` 形式出现 | 无 |
| `/flag` / `/unseen` / 发信命令 | 未实施 | 当前识别 6 个意图(帮助 / 同意 / 摘要 / 搜索 / 普通消息 / 未识别);其中 `/search` 已实现(`worker.rs:533`) | 无 |
| `Identity` 概念 | 未实施 | 账号识别只依赖 `ACCOUNT_ID`,无身份层抽象 | 无 |
| `run_mode.rs`(作为单独文件) | 虚构文件 | 不存在该文件;`RUN_MODE` 曾由 `config.rs` 读取并在 `src/main.rs` 校验取值,**该变量与校验块现已一并删除**(见 §4);从未存在 `validate_env_or_exit` 这类函数 | 无 |
| 把 `RUN_MODE` 抽成独立配置文件 | 虚构拆分方案 | `RUN_MODE` 曾由 `config.rs` 读取并在 `src/main.rs` 校验取值,**该变量与校验块现已一并删除**(见 §4);从未存在 `validate_env_or_exit` 这类函数 | 无 |
| docker-compose `message-weave health --addr` 示例 | 虚构命令 | 应用无 CLI 子命令;健康检查端点是 `GET /healthz` 与 `GET /ready` | 见 `docs/deployment.md` 的 Health-check 表 |
| 早期设计辩论问题(消息格式 / 长邮件阈值 / 附件策略 / Identity / 监控 / LLM 供应商 / 熔断等 17 条) | 已由代码回答 | 均已被实现的代码给出答案,不再属于待确认项 | 见 `docs/design.md` 的「已由代码回答的早期问题」一节 |
@ -106,7 +107,7 @@
| 条目 | 类型 | 原因 | 替代或现状 |
|---|---|---|---|
| `docs/todo.md` | 已取代(已删除) | 结构不清,与 design/deployment 重叠,且曾承载「本轮已收口」这类历史叙述 | `docs/roadmap.md`(只放缺口、阻塞、阶段目标、决策待定、验收待办) |
| `HANDOFF.md`(根目录) | 临时交接件(已删除,**从未进入 git 历史**) | 交接内容应归位到常驻文档 | 内容并入 `docs/design.md`、`docs/deployment.md`、`docs/reference.md`、`docs/roadmap.md` |
| 根目录临时交接件(不留此类文件) | 临时交接件,**从未进入 git 历史** | 交接内容应归位到常驻文档,不留根目录临时件 | 内容并入 `docs/design.md`、`docs/deployment.md`、`docs/reference.md`、`docs/roadmap.md` |
---

View File

@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""PATH AUDIT — no document may name a file or directory that does not exist.
Why this exists
---------------
The docs used to name directories, modules and tests that were never created.
A reader who acts on a documented path loses trust in the whole document, and
nobody can tell "we decided against this" from "this is wrong". So the rule is:
**every path-shaped token written in backticks in a document must resolve**,
including in the retired/deprecated registry (`docs/retired.md` is scanned too)
— there, a fictional path reads the same as a real one. The invented names are
deliberately not repeated here, so the examples cannot be copied back in;
`git log -p docs/retired.md` shows what they were.
What this check proves
----------------------
Every path-shaped token in inline code in the scanned documents is one of:
(a) resolvable — it exists in this repo (see ROOT_DIRS, a token is resolved
against several real roots because docs cite files as `config.rs`, not
always `src/config.rs`);
(b) attributed to an external repo by an owning prefix (EXEMPT_EXTERNAL),
e.g. `stalwartlabs/jmap-client/src/client.rs`;
(c) provably deleted — the file is gone but `git log --all --diff-filter=A`
shows it was committed at some point (e.g. `docs/todo.md`);
(d) on the commented CONVENTIONAL allowlist — a well-known filename that the
prose explicitly says this repo does not have.
Anything else is reported as an error.
What it does NOT prove
----------------------
- That the path means what the prose says, same limitation as audit_anchors.
- Anything outside inline code. Backticks are the contract: if you write a
path in prose without backticks, this check cannot see it.
- Bare filenames unambiguously: `client.rs` resolves against any file with
that basename in the repo.
- Anything in scripts. Code legitimately contains path-like string literals
(this check is defined by string patterns, after all); those are not
documentation claims.
Non-path shapes that look like paths are excluded by is_noise(), and a
`seg/` token only counts as a directory reference when it is the whole
inline-code span. Both rules are structural rather than denylisted: is_noise
recognises the shapes that cannot be repo-relative paths (absolute paths and
URIs, dot-prefixed URL parts, dotted hosts, media types, JMAP method names,
all-numeric pairs, shell and glob fragments), and the whole-span rule means
`Email/changes`, `application/json` and `method/path/origin/失败类别` are never
mistaken for a directory. Adding a special case to either should make you
suspect the document instead.
"""
import os
import re
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
DOCS = [
os.path.join(ROOT, "docs", f) for f in sorted(os.listdir(os.path.join(ROOT, "docs")))
if f.endswith(".md")
]
AGENTS = os.path.join(ROOT, "AGENTS.md")
TARGETS = [t for t in DOCS + [AGENTS] if os.path.isfile(t)]
# Directories a doc legitimately roots a path at. Every entry is real.
ROOT_DIRS = [
".",
"src",
"src/domain",
"src/domain/jmap",
"docs",
"scripts",
"scripts/docs_check",
"cloudflare-worker",
"cloudflare-worker/src",
"web",
]
EXTS = "rs|js|mjs|ts|tsx|json|toml|ya?ml|md|sh|sql|cfg|lock|html|css|proto|txt"
# A file token (ends in a known extension) or a directory token (ends in '/').
TOKEN = re.compile(
r"(?<![\w./:-])(?:"
r"(?:[A-Za-z0-9_.{}\-]+/)*[A-Za-z0-9_.{}\-]+\.(?:" + EXTS + r")"
r"|(?:[A-Za-z0-9_.{}\-]+/){1,}"
r")"
)
INLINE_CODE = re.compile(r"`([^`\n]+)`")
# --- exemptions -------------------------------------------------------------
# (b) An owning-prefix that is not a directory in this repo. The prefix itself
# must be documented in prose on the same line as the citation.
EXEMPT_EXTERNAL = ("stalwartlabs/",)
# (d) Well-known filenames that are named only to say "this repo does not
# have it". Each entry carries its justification so the list cannot grow
# silently.
CONVENTIONAL = {
"docker-compose.yml": "docker compose's default filename; deployment.md "
"states the repo ships no compose file and gives the "
"image-based command instead",
}
# --- non-path shapes that look like paths -----------------------------------
# Two-segment `Media/type` content types: application/json, text/html, ...
MIME_MEDIA = ("application", "text", "multipart", "message", "image", "audio",
"video", "font")
# JMAP / REST method names, always CapitalisedName/lowercaseVerb, 2 segments.
JMAP_METHOD = re.compile(r"^[A-Z][A-Za-z]*\/[a-z][A-Za-z]*$")
# Dotted domains (api.telegram.org, crates.io) and numeric code pairs
# (200/503) are classified structurally inside is_noise.
#
# Shell or glob placeholders that are not real paths.
PLACEHOLDER = re.compile(r"[\s()*,?]")
def is_noise(tok):
if tok.startswith("/") or "://" in tok:
return "absolute path or URI"
if tok.startswith("./"):
return None # './x' IS a real relative-path candidate
if tok.startswith("."):
return "dot-prefixed URL part" # .well-known/jmap
first, _, rest = tok.partition("/")
if rest and "." in first:
return "domain" # api.telegram.org, crates.io
if rest and first in MIME_MEDIA:
return "MIME type" # application/json
if JMAP_METHOD.match(tok):
return "JMAP method" # Email/changes
if all(s.isdigit() for s in tok.rstrip("/").split("/")):
return "code pair" # 200/503
if PLACEHOLDER.search(tok):
return "shell or glob"
return None
def variants(tok):
"""Expand {a,b,c} brace alternatives so globs cannot hide a failure."""
m = re.search(r"\{([^{}]*)\}", tok)
if not m:
return [tok]
head, tail = tok[:m.start()], tok[m.end():]
return [head + a + tail for a in m.group(1).split(",")]
def resolve(tok):
t = tok.lstrip("./")
if not t:
return None
for d in ROOT_DIRS:
if os.path.exists(os.path.join(ROOT, d, t)):
return "resolves"
return None
def was_deleted(tok):
t = tok.lstrip("./")
try:
out = subprocess.run(
["git", "log", "--all", "--diff-filter=A", "--name-only",
"--format=", "--", t],
cwd=ROOT, capture_output=True, text=True, timeout=30,
)
except (OSError, subprocess.SubprocessError):
return None
return "deleted but committed before" if out.stdout.strip() else None
def is_dir_token(tok):
"""A bare `seg/seg/` reference, as opposed to a `name.ext` file reference."""
return tok.endswith("/") and "." not in tok.rsplit("/", 1)[-1]
def main():
checked = 0
errors = []
for path in TARGETS:
rel = os.path.relpath(path, ROOT)
with open(path, encoding="utf-8") as fh:
lines = fh.read().splitlines()
fenced = False
for i, line in enumerate(lines, 1):
if re.match(r"\s*(```|~~~)", line):
fenced = not fenced
continue
if fenced:
continue
for code in INLINE_CODE.findall(line):
for tok in TOKEN.findall(code):
# A `seg/` token is only a directory reference when it IS the
# whole span. Otherwise it is a prefix of something else that
# just happens to contain a slash — a JMAP method
# (`Email/changes`), a content type (`application/json`), a
# template (`{jmap_origin}/.well-known/jmap`), or a log
# format (`method/path/origin/失败类别`). None of those are
# filesystem paths, and trying to classify them by pattern
# produces either false alarms or a growing denylist.
if is_dir_token(tok) and code.strip() != tok:
continue
if is_noise(tok):
continue
checked += 1
for v in variants(tok):
verdict = (
resolve(v)
or ("external repo" if v.startswith(EXEMPT_EXTERNAL) else None)
or was_deleted(v)
or ("conventional name" if v in CONVENTIONAL else None)
)
if not verdict:
errors.append(f"{rel}:{i} `{v}` does not resolve "
f"(ROOT_DIRS, git history, or CONVENTIONAL)")
break
print(f"=== PATH AUDIT ({len(errors)} errors, "
f"{checked} path-refs checked) ===")
for e in errors:
print(" " + e)
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -22,11 +22,25 @@
# check_file_size every source file over 500 lines carries the SPLIT-EVAL
# marker required by AGENTS.md §2.1. LIMITATION: it proves
# the marker exists, not that the written reason is sound.
# audit_paths every path-shaped token written in backticks in the docs
# (docs/*.md and AGENTS.md) resolves to a real file or
# directory. It exists so no document can name a path that
# was never created — including in docs/retired.md, where a
# fictional path reads the same as a real one. Exemptions,
# all visible in the script: an owning prefix that attributes
# the path to an external repo (stalwartlabs/...), a file
# that git history proves was deleted, and a short allowlist
# of well-known filenames the prose says this repo lacks.
# LIMITATION: it only sees paths inside backticks, and a
# bare filename resolves against any same-named file.
#
# audit_anchors and check_sec_refs only scan prose: lines inside fenced code
# blocks are skipped, so examples and tables holding code do not raise noise.
# audit_anchors, check_sec_refs and audit_paths only scan prose: lines inside
# fenced code blocks are skipped, so examples and tables holding code do not
# raise noise.
# check_file_size only scans src/, web/ and cloudflare-worker/src/ — docs and
# tests are out of scope.
# audit_paths scans docs/*.md and AGENTS.md, and only tokens inside inline
# backticks — a path written as plain prose is invisible to it.
#
# Root is derived from this file's own location, so the whole suite works from a
# clean clone without editing any path.
@ -37,7 +51,7 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
rc=0
fail=0
for script in validate_docs check_tables audit_anchors check_sec_refs check_file_size; do
for script in validate_docs check_tables audit_anchors check_sec_refs check_file_size audit_paths; do
out="$(python3 "$here/$script.py" 2>&1)"
code=$?
# Each check prints "=== NAME (n errors) ===" as its summary, but not
@ -54,7 +68,7 @@ for script in validate_docs check_tables audit_anchors check_sec_refs check_file
done
if [ "$fail" -eq 0 ]; then
echo "GATE-DOCS: PASS (5/5 checks clean)"
echo "GATE-DOCS: PASS (6/6 checks clean)"
else
echo "GATE-DOCS: FAIL ($fail check(s) reported errors)"
fi