diff --git a/.agents/docs/2026-09-08-consolidated-design.md b/.agents/docs/2026-09-08-consolidated-design.md index da1af06e7..a6820107c 100644 --- a/.agents/docs/2026-09-08-consolidated-design.md +++ b/.agents/docs/2026-09-08-consolidated-design.md @@ -5,6 +5,7 @@ > 本文档**替代** `.agents/` 下全部旧设计/计划/分析文档 (清单见 §11)。 > **旧文档最后存在提交: `03f616196468f71ae6bf7f22010777450356d3ec`** > (refactor(scripts): reapply_hand_edits — string patches as git .patch files)。 +> (旧仓库 [ZheFeng7110/boost-module](https://github.com/ZheFeng7110/boost-module), 本仓库不含该提交; 旧文档见 [该提交的 `.agents/`](https://github.com/ZheFeng7110/boost-module/tree/03f616196468f71ae6bf7f22010777450356d3ec/.agents))。 > 矛盾与不明确之处集中列于 §10, 在后续对话中逐项修正。 ## 1. 项目定位与目标 @@ -267,7 +268,7 @@ boost.boost = { path = "..", features = ["all"] } # 全 | C4 (+C4.1) | bind/lambda/lambda2 重新模块化 | T3 宏面误判纠正; lambda 占位符 TU-local → inline constexpr (vendored 3 处); bimap 的 boost.iterator 边丢失 → 定点钉定 (全局修法两案否决); C4.1: gcc 16 重复符号 (constant_null_type CMI 重复发射) → inline constexpr | | C5 | `boost.version` 模块形式化 + `macros.hpp` 移除 + core 去重 | `src/version.cppm` 手写特例转 LIBS_SPECIAL (新 tier, `boost_common.py`), 经 gen_features.py 流程; `include/boost-module/macros.hpp` 与 `tests/macros.cpp` 删除; `core.cppm` 内冗余常量移除; 默认闭包 48 → 49; 详见 plan/2026-09-09-boost-version-module-promotion.md + docs/2026-09-09-boost-version-module.md | | M13 | 外部依赖/asm 11 库 | **用户决策暂缓 (2026-09-05), 持续暂缓 (2026-09-08, 2026-09-09 维持)** | -| M14 | 发布 | **下一步: 发布预览版 (2026-09-08 决策, 见 plan/2026-09-08-release-preview-plan.md)** | +| M14 | 发布 | **下一步: 发布预览版 (2026-09-08 决策, 见 docs/2026-09-08-release-preview-plan.md)** | 用户历史决策 (2026-08-13): 动态汇总模块 / 精简核心默认集 / 宏驱动库 include-only / 外部依赖库单列里程碑 / 每库最小 smoke。 @@ -484,6 +485,8 @@ dump_avx2/ssse3 不入包; atomic sse41 走探测失败回退; type_erasure `any ## 11. 被本文替代的旧文档 (最后存在提交 `03f616196468f71ae6bf7f22010777450356d3ec`) +旧文档在旧仓库 [ZheFeng7110/boost-module](https://github.com/ZheFeng7110/boost-module) 的该提交中: 。 + plan/: boost-mcpp-module-plan.md (M0–M7 主线)、boost-mcpp-all-libs-features-plan.md (M8–M14 全库计划)、2026-08-08-m0-spike-results.md、 2026-09-06-usage-reclassification-and-include-only-adjustment-plan.md。 diff --git a/.agents/plan/2026-09-08-release-preview-plan.md b/.agents/docs/2026-09-08-release-preview-plan.md similarity index 100% rename from .agents/plan/2026-09-08-release-preview-plan.md rename to .agents/docs/2026-09-08-release-preview-plan.md diff --git a/.agents/docs/2026-09-19-mingw-sysroot-bootstrap.md b/.agents/docs/2026-09-19-mingw-sysroot-bootstrap.md index 1e203e35a..2a2a66758 100644 --- a/.agents/docs/2026-09-19-mingw-sysroot-bootstrap.md +++ b/.agents/docs/2026-09-19-mingw-sysroot-bootstrap.md @@ -16,7 +16,7 @@ libstdc++),并不需要 MinGW 的 gcc/ld/binutils。 - 维护机(非 Windows)没有 mingw sysroot 时,默认 mingw target 的 gate 与 libclang parse 会因找不到头文件而失败,只能改用 host target - (`.agents/plan/2026-09-19-var-template-initializer-deps.md` §4), + (`.agents/docs/2026-09-19-var-template-initializer-deps-plan.md` §4), 无法复现已提交快照。 - 此前 `BOOST_MODULE_GEN_SYSROOT` 只能由使用者手动指向一份"本地解包"的 sysroot,来源、版本、校验都没有固化。 diff --git a/.agents/plan/2026-09-19-var-template-initializer-deps.md b/.agents/docs/2026-09-19-var-template-initializer-deps-plan.md similarity index 100% rename from .agents/plan/2026-09-19-var-template-initializer-deps.md rename to .agents/docs/2026-09-19-var-template-initializer-deps-plan.md diff --git a/.agents/docs/2026-11-08-design-records-management-design.md b/.agents/docs/2026-11-08-design-records-management-design.md new file mode 100644 index 000000000..827d6983d --- /dev/null +++ b/.agents/docs/2026-11-08-design-records-management-design.md @@ -0,0 +1,174 @@ +--- +subject: docs +status: landed +--- + +# 设计记录的管理:只增不删、按年归档、生成的索引与 `tools/doc-tools/` + +> 日期:2026-11-08 · 分支 `docs/agents-records` +> +> 参考:[mcpp-community/mcpp](https://github.com/mcpp-community/mcpp) 的 +> `.agents/docs/`(约 350 篇记录、生成的索引)与 `docs/specs/` + +## 0. 结论 + +1. `.agents/docs/` 下的记录**只增不删**。一篇记录描述它写成的那一刻,落地后只允许改 + front matter 的 `status` / `superseded_by`,以及由脚本完成的路径修正。结论变了就写 + 新记录,并把旧记录标为 `superseded`。 +2. 目录变大的问题由**归档与索引**解决,不靠删除:往年且不处于 `active` 的记录移入 + `.agents/docs/archive//`,`.agents/docs/README.md` 由脚本从记录生成。 +3. 2026-11-08 及以后的记录必须声明 front matter(`subject`、`status`,必要时 + `superseded_by`)。更早的 20 篇不补写,按日期和标题进入索引,检查也不覆盖它们。 +4. `.agents/` 下只有 `docs/` 与 `skills/`。原 `.agents/plan/` 的两篇计划并入 + `.agents/docs/`,未完成的工作项以后放在 `.agents/docs/todos/`。 +5. 管理、检查、移动文档的脚本统一放在 `tools/doc-tools/`。建议只用 Python 标准库, + 但不强制;需要第三方库时写进 `tools/pyproject.toml` 的 `dependencies`。 + CI 是一个独立的工作流 `.github/workflows/check-docs.yml`。 + +## 1. 背景:删旧文档换一篇汇总的代价 + +[2026-09-08 汇总](2026-09-08-consolidated-design.md)替代并删除了此前全部 19 篇 +计划与设计文档,只留下一个提交哈希 `03f6161` 作为出处。实测发现: + +- 本仓库的历史从 2026-10-26 的 `Initial commit` 开始,共 29 个提交, + `git cat-file -t 03f6161…` 报错,**该提交不在本仓库中**。它在旧仓库 + `ZheFeng7110/boost-module` 的 `b1.91.0wdev` 分支上。本次给汇总文档加上了旧仓库与 + 该提交 `.agents/` 的链接,这是删除式汇总唯一还能补救的部分。 +- 汇总只保留结论。例如 + [两遍扫描](2026-09-08-dep-graph-two-pass-body-edge-completion.md) + 要求「不得复现 C4 §4.2 两案否决记录中的副作用」,C4 原文已不在本仓库。 +- 汇总文档此后又被修订(C5),§10 列着「待后续对话修正」的问题。它同时承担历史、现状和 + 待办三种角色,越写越长,并与 `docs/architecture.md` 产生漂移。 + +这些代价都来自**删除**和**把现状写进记录**,与记录的数量无关。mcpp 有约 350 篇记录 +仍然可用,原因有两点:读者不需要通读记录,现状另有一处回答;每篇记录有状态,索引可以 +按状态和主题过滤。 + +## 2. 约束 + +- 旧记录不改写。为补 front matter 而修改 20 篇旧记录,本身就违反第 1 条。 +- 脚本必须跨平台:CI 只在 Linux 上运行,但维护者也在 Windows(MinGW)上工作。 +- 不扩大 `verify-scripts.yml`。它的触发路径和 240 分钟的生成校验与文档无关。 + +## 3. 方案 + +### 3.1 布局 + +| 位置 | 内容 | +|---|---| +| `.agents/docs/YYYY-MM-DD-.md` | 当年的记录,以及往年仍处于 `active` 的记录 | +| `.agents/docs/archive//` | 往年且已落地、被取代或放弃的记录 | +| `.agents/docs/todos/` | 尚未完成的工作项,不是决定的记录 | +| `.agents/docs/README.md` | 生成的索引:进行中、按主题、按月、归档按年、待办 | + +slug 只用 `a-z 0-9 . -`。`-design` / `-plan` / `-analysis` 后缀是建议,不做检查。 +因此 `2026-09-19-var-template-initializer-deps.md` 移入时改名为 `…-plan.md`, +另一篇原本就以 `-plan` 结尾,未改名。 + +**归档而不是按年建目录。** 另一个可选方案是所有记录从一开始就写进 `/`, +这样文件永不移动,引用永不改写。没有采用它,原因有二:其一,当年的记录直接放在 +`.agents/docs/` 下,写记录的人和 agent 不需要知道目录规则;其二,移动发生在每年一次、 +由脚本执行,引用改写也由同一个脚本完成(§3.3)。付出的代价是:仓库外部指向 +`blob/main/.agents/docs/` 的链接在归档后失效。 + +**`superseded_by` 写文件名而不是路径。** 这样后继记录被归档时,这个字段不需要改写。 +因此检查要求所有记录的文件名唯一(含归档)。 + +### 3.2 front matter + +```yaml +--- +subject: generator # 简短、可复用的主题词:a-z 0-9 - +status: landed # active | landed | superseded | abandoned +superseded_by: 2026-12-01-....md # 仅 superseded +--- +``` + +是否必须声明 front matter 由文件名中的日期决定:不早于 `FRONT_MATTER_SINCE` +(2026-11-08)就必须声明。考虑过列出旧记录的豁免名单,但日期判据不需要维护名单, +而且 agent 总是按当天日期命名。旧记录若事后加了 front matter(例如标为 `superseded`), +同样按上述格式校验。 + +不从文件名推断 subject:猜错的分组比不分组更误导读者。 + +### 3.3 `tools/doc-tools/` + +| 脚本 | 作用 | +|---|---| +| `records.py` | 共用库:记录的发现与分类、front matter、标题、链接解析、移动与引用改写 | +| `gen_index.py` | 生成索引;`--check` 在过期时以 1 退出 | +| `check_docs.py` | 八项检查,见文件头 | +| `move_record.py` | 移动或改名任意文档,改写引用,必要时重建索引 | +| `archive_records.py` | 把往年非 `active` 的记录移入 `archive//`;拒绝归档当年 | + +引用改写分两类: + +- Markdown 内联链接按**两端的新位置**重新计算相对路径。被移动的文件自己的链接也会重算, + 例如 `../../docs/x.md` 在归档后变为 `../../../../docs/x.md`。fenced code 中的链接不改。 +- 任意被跟踪文本文件中出现的仓库相对路径(如 `.agents/docs/`,常见于注释和 + code span)整体替换。 + +这两类之外的写法不会被识别。例如汇总文档按 `.agents/` 相对写的 `plan/x.md`,本次是 +手工修正的;移动后应再按文件名搜索一次。 + +跨平台的做法: + +- 路径一律用 `pathlib`,并以 `/` 输出; +- 读写使用 UTF-8 和 `newline=""`,改写时保留文件原有的 CRLF; +- 索引写出时固定为 LF,比较前把 CRLF 归一化为 LF,因此 `core.autocrlf=true` + 的 Windows 检出不会被误判为过期; +- 移动优先用 `git mv`,不可用时退回 `Path.rename`。 + +### 3.4 检查的边界 + +`check_docs.py` 检查: + +- 文件放在哪里,以及文件名是否合规; +- front matter 的格式; +- 索引是否最新; +- 新记录与用户文档(`docs/**`、顶层 `README*.md`)里的相对链接是否能打开; +- 用户文档不引用 `.agents/`。用户文档不应把读者送去一份不保证稳定的记录。 + +旧记录不检查链接:它们链接的 `docs/zh/architecture.md`、 +`docs/github-release-process.md` 等文件已经不存在,而这些记录本身不改写。 + +以下几项**不检查**: + +- 已落地的记录事后是否被改过。区分「允许的改动」和「改写结论」需要读者, + 留给 review; +- 文档是否与实现一致。 + +### 3.5 CI + +`.github/workflows/check-docs.yml`:只在 `.agents/**`、`docs/**`、`README*.md`、 +`tools/doc-tools/**` 或该工作流自身变化时触发,在 `ubuntu-latest` 上使用 Python 3.11 +(与 `tools/pyproject.toml` 的下限一致),先跑 `tools/doc-tools/tests`,再跑 +`check_docs.py`。 + +## 4. 本次的改动 + +- `.agents/plan/` 的两篇计划由 `move_record.py` 移入 `.agents/docs/`, + `2026-09-19-mingw-sysroot-bootstrap.md` 中的路径被自动改写。这次移动也是脚本在真实 + 仓库上的第一次使用。 +- 汇总文档:在提交哈希后和 §11 中加了旧仓库链接;把 `plan/2026-09-08-release-preview-plan.md` + 改为 `docs/…`。 +- 未改动:`tools/boost_common.py`、`tools/gen_features.py` 和 + `2026-09-09-boost-version-module.md` 引用的 + `.agents/plan/2026-09-09-boost-version-module-promotion.md`。查旧仓库全部历史, + 这份文件从未被提交过,这些引用在迁移前就已失效。 + +## 5. 验证 + +- `python3 -m unittest discover -s tools/doc-tools/tests`:13 项全部通过。覆盖索引生成、 + CRLF 归一化、新旧记录的 front matter 判据、`superseded_by` 解析、放置规则、 + 用户文档的引用方向、双向链接改写、保留 CRLF、跳过 fenced code,以及归档时保留 + `active` 记录并拒绝当年。 +- `python3 tools/doc-tools/check_docs.py` 在本分支上通过。 +- `archive_records.py --dry-run --today 2027-01-02` 列出了 2026 年的全部记录。 + +## 6. 后续 + +- 把汇总文档中仍然有效的约束迁入 `docs/architecture.md` 或新建的 `docs/specs/`, + 然后把汇总文档标为 `superseded`。 +- 把反复出现的流程写成 `.agents/skills/`(上游升级、发布、重新生成导出),并加入口文件 + `AGENTS.md`。 diff --git a/.agents/docs/README.md b/.agents/docs/README.md new file mode 100644 index 000000000..21cb48767 --- /dev/null +++ b/.agents/docs/README.md @@ -0,0 +1,70 @@ +# 设计记录 + + + +本目录保存每次改动的理由:调研了什么、测到了什么、决定了什么、否决了什么。 +一篇记录描述的是它写成的那一刻,**落地后不再修改**,因此这里的内容都不是对 +现状的陈述;现状见 [docs/](../../docs/)。 + +## 规则 + +- 文件名为 `YYYY-MM-DD-.md`,slug 只用小写字母、数字、`.` 与 `-`; + 建议按性质以 `-design`、`-plan` 或 `-analysis` 结尾。 +- 2026-11-08 及以后的记录必须以 front matter 开头(更早的记录不补): + + ```yaml + --- + subject: generator # 简短、可复用的主题词 + status: active # active | landed | superseded | abandoned + superseded_by: 2026-12-01-....md # 仅 status 为 superseded 时,写文件名 + --- + ``` + +- 落地后只允许修改 front matter 的 `status` / `superseded_by`,以及由脚本完成的 + 路径修正。结论变了就写一篇新记录,并把旧记录标为 `superseded`。 +- **不删除记录。** 往年且不处于 `active` 的记录由 + `tools/doc-tools/archive_records.py` 移入 `archive//`,引用随之改写。 +- `todos/` 放尚未完成的工作项,它们不是决定的记录。 +- 移动或改名用 `tools/doc-tools/move_record.py`,它同时改写仓库中对该文件的引用。 + +共 21 篇记录(其中 0 篇已归档),0 项待办。 + +## 按主题 + +只列出声明了 subject 的记录。 + +### docs + +- [设计记录的管理:只增不删、按年归档、生成的索引与 `tools/doc-tools/`](2026-11-08-design-records-management-design.md) — landed + +## 按日期 + +### 2026-11 + +- [设计记录的管理:只增不删、按年归档、生成的索引与 `tools/doc-tools/`](2026-11-08-design-records-management-design.md) — landed +- [封装层生成原理改造:clang JSON AST + 声明式策略 + `--check`](2026-11-05-json-ast-generation.md) + +### 2026-10 + +- [与 Boost 上游兼容](2026-10-28-upstream-compat.md) +- [mcpplibs `mwrap` 模块封装层生成原理技术报告](2026-10-28-mwrap-module-wrapper-generation.md) +- [Boost 1.92.0 迁移到 mwrap 规范](2026-10-26-boost-mwrap-migration.md) +- [生成脚本幂等性 CI](2026-10-18-generated-scripts-ci.md) +- [macOS LLVM exception unwinding workaround](2026-10-10-macos-lld-unwind-workaround.md) + +### 2026-09 + +- [macOS-llvm 腿 program_options 测试 SIGABRT 修复 (Mach-O typeinfo 兜底回归)](2026-09-25-macos-typeinfo-catch-regression.md) +- [发布后自动登记 mcpp-index 并开 PR (2026-09-23)](2026-09-23-mcpp-index-auto-register.md) +- [GitCode 镜像同步与 Release 发布 (2026-09-23)](2026-09-23-gitcode-mirror-release.md) +- [Boost 1.92.0 上游升级 (vendored 重导 + 产物重生成)](2026-09-23-boost-1.92-upgrade.md) +- [特性宏 profile (`backend-*`) 设计 (2026-09-21)](2026-09-21-feature-macro-profiles.md) +- [内部链接对象导出补全 — range / multi_array / accumulators / mqtt5](2026-09-20-internal-linkage-export-completion.md) +- [变量模板初始化器跨模块 `.deps` 边补全 (A1+A2) + 消费者 smoke (D)](2026-09-19-var-template-initializer-deps-plan.md) +- [MinGW sysroot 一键引导 (scripts/fetch_mingw_sysroot.py)](2026-09-19-mingw-sysroot-bootstrap.md) +- [变量模板导出 (libclang UNEXPOSED_DECL 分类)](2026-09-17-variable-template-export.md) +- [gen_exports.py 增量缓存设计(方案 A + C)](2026-09-09-gen-exports-caching.md) +- [`boost.version` 模块设计 (C5, 2026-09-09)](2026-09-09-boost-version-module.md) +- [计划 — 发布预览版 (Preview Release)](2026-09-08-release-preview-plan.md) +- [dep_graph 结构性缺陷修复 — 两遍扫描体引用边补全 (pass 2)](2026-09-08-dep-graph-two-pass-body-edge-completion.md) +- [boost-module 总体设计汇总 (2026-09-08 重写版)](2026-09-08-consolidated-design.md) diff --git a/.github/workflows/check-docs.yml b/.github/workflows/check-docs.yml new file mode 100644 index 000000000..4f37a4b0c --- /dev/null +++ b/.github/workflows/check-docs.yml @@ -0,0 +1,50 @@ +# Check where the documents live and what the design records declare. +# +# tools/doc-tools/ holds the scripts that manage the documents: gen_index.py +# derives .agents/docs/README.md from the records, check_docs.py checks the +# layout, front matter, links and citation direction, and move_record.py / +# archive_records.py move records while rewriting references to them. They use +# only the standard library today (a third-party package would be declared in +# tools/pyproject.toml) and run on any host; CI runs them on Linux, with the +# oldest Python the repository's tools support. +name: Check Documents + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - ".github/workflows/check-docs.yml" + - ".agents/**" + - "docs/**" + - "README*.md" + - "tools/doc-tools/**" + pull_request: + branches: [main] + paths: + - ".github/workflows/check-docs.yml" + - ".agents/**" + - "docs/**" + - "README*.md" + - "tools/doc-tools/**" + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.11" + + - name: Test the document tools + run: python3 -m unittest discover -s tools/doc-tools/tests -v + + - name: Check documents and the design-record index + run: python3 tools/doc-tools/check_docs.py diff --git a/tools/doc-tools/archive_records.py b/tools/doc-tools/archive_records.py new file mode 100644 index 000000000..d5ce77f41 --- /dev/null +++ b/tools/doc-tools/archive_records.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Archive the design records of past years: .agents/docs/ -> archive//. + +Records are never deleted. Once a year is over, its records leave the top of +.agents/docs/ so that the directory a reader opens holds the current year. +A record whose status is `active` stays until it lands; running this again +later archives it then. References are rewritten as move_record.py does, and +the index is regenerated. + +Usage: + python3 tools/doc-tools/archive_records.py # every past year + python3 tools/doc-tools/archive_records.py --year 2026 # one past year + python3 tools/doc-tools/archive_records.py --dry-run +""" + +import argparse +import datetime +import sys +from pathlib import PurePosixPath, Path + +import gen_index +import records as rc + + +def plan(root, years): + moves, kept = {}, [] + for r in rc.collect(root): + if r.kind != "current" or not r.date or r.date[:4] not in years: + continue + if r.status == "active": + kept.append(r) + continue + moves[r.rel] = PurePosixPath(rc.ARCHIVE) / r.date[:4] / r.name + return moves, kept + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--year", help="archive this year only (it must be over)") + ap.add_argument("--dry-run", action="store_true", help="list the moves, change nothing") + ap.add_argument("--today", help=argparse.SUPPRESS) + ap.add_argument("--root", type=Path, default=rc.ROOT, help=argparse.SUPPRESS) + args = ap.parse_args(argv) + + today = args.today or datetime.date.today().isoformat() + this_year = today[:4] + if args.year: + if args.year >= this_year: + ap.error(f"{args.year} is not over; only past years are archived") + years = {args.year} + else: + years = {r.date[:4] for r in rc.collect(args.root) if r.date and r.date[:4] < this_year} + + moves, kept = plan(args.root, years) + for r in kept: + print(f"kept (active): {r.rel}") + if not moves: + print("nothing to archive") + return 0 + for old, new in moves.items(): + print(f"{'would move' if args.dry_run else 'moved'} {old} -> {new}") + if args.dry_run: + return 0 + changed = rc.relocate(args.root, moves, rc.tracked_text_files(args.root)) + for rel in changed: + print(f"rewrote references in {rel}") + return gen_index.main(["--root", str(args.root)]) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/doc-tools/check_docs.py b/tools/doc-tools/check_docs.py new file mode 100644 index 000000000..61a54e107 --- /dev/null +++ b/tools/doc-tools/check_docs.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Check where documents live and what the design records declare. + + 1. .agents/ holds only docs/ and skills/ + 2. every file under .agents/docs/ is a record in an allowed place: + a dated record in .agents/docs/ or .agents/docs/archive//, + or a work item in .agents/docs/todos/ + 3. no two records share a file name (superseded_by names a file) + 4. front matter, when present, is well formed; records dated + FRONT_MATTER_SINCE or later must declare it + 5. an archived record is not active + 6. the generated index is current + 7. relative links resolve in docs/, the top-level READMEs and every record + that must declare front matter + 8. user documentation does not cite the design records + +Records older than the front matter convention are exempt from 4 and 7: they +describe a moment, and some of the files they link to have since gone. + +What this does not check: whether a landed record was edited afterwards, +and whether the documentation matches the implementation. Those need a reader. + +Usage: + python3 tools/doc-tools/check_docs.py +""" + +import argparse +import sys +from pathlib import Path, PurePosixPath + +import gen_index +import records as rc + +ALLOWED_UNDER_AGENTS = {"docs", "skills"} + + +def user_docs(root): + root = Path(root) + out = [PurePosixPath(p.name) for p in root.glob("README*.md")] + docs = root / "docs" + if docs.is_dir(): + out += [PurePosixPath(p.relative_to(root).as_posix()) for p in docs.rglob("*.md")] + return sorted(out) + + +def check(root=rc.ROOT): + root = Path(root) + errors = [] + + def bad(where, msg): + errors.append(f"{where}: {msg}") + + # 1 + agents = root / rc.AGENTS + if agents.is_dir(): + for p in sorted(agents.iterdir()): + if p.name not in ALLOWED_UNDER_AGENTS: + bad(f"{rc.AGENTS}/{p.name}", "only docs/ and skills/ belong under .agents/; " + "plans and analyses are records in .agents/docs/") + + base = root / rc.RECORDS + if base.is_dir(): + for p in sorted(base.rglob("*")): + if p.is_file() and p.suffix != ".md": + bad(p.relative_to(root).as_posix(), "only markdown belongs under .agents/docs/") + + recs = rc.collect(root) + names = {} + for r in recs: + where = r.rel.as_posix() + parts = r.rel.parts[2:] # below .agents/docs/ + # 2 + if r.kind == "todo": + if len(parts) != 2: + bad(where, "todos/ holds files, not directories") + elif not r.date: + bad(where, "a record is named YYYY-MM-DD-.md (slug: a-z 0-9 . -)") + elif r.kind == "archived": + if len(parts) != 3 or parts[1] != r.date[:4]: + bad(where, f"an archived record lives in archive/{r.date[:4] or ''}/") + elif len(parts) != 1: + bad(where, "a record lives directly in .agents/docs/ or in archive//") + # 3 + if r.kind != "todo": + if r.name in names: + bad(where, f"same file name as {names[r.name]}") + names.setdefault(r.name, where) + + for r in recs: + where = r.rel.as_posix() + # 4 + if r.front_error: + bad(where, r.front_error) + if r.needs_front_matter and not r.front: + bad(where, f"a record dated {rc.FRONT_MATTER_SINCE} or later opens with front " + "matter declaring subject and status") + if r.front: + unknown = sorted(set(r.front) - {"subject", "status", "superseded_by"}) + if unknown: + bad(where, f"unknown front matter keys: {', '.join(unknown)}") + if not rc.SUBJECT.match(r.subject): + bad(where, "subject is one short lowercase word (a-z 0-9 -)") + if r.status not in rc.STATUSES: + bad(where, f"status is one of {' | '.join(rc.STATUSES)}") + if r.status == "superseded": + if not r.superseded_by: + bad(where, "a superseded record names its successor in superseded_by") + elif r.superseded_by not in names: + bad(where, f"superseded_by names no record: {r.superseded_by}") + elif r.superseded_by == r.name: + bad(where, "a record cannot supersede itself") + elif r.superseded_by: + bad(where, "superseded_by is only for status: superseded") + if not r.title or r.title == r.rel.stem: + if r.needs_front_matter: + bad(where, "a record has a '# ' title") + # 5 + if r.kind == "archived" and r.status == "active": + bad(where, "an active record is not archived") + + # 6 + if not gen_index.is_current(root): + bad(rc.INDEX, "stale; run: python3 tools/doc-tools/gen_index.py") + + # 7 + linked = user_docs(root) + [r.rel for r in recs if r.needs_front_matter] + for rel in linked: + for line, target in rc.broken_links(root, rel): + bad(f"{rel.as_posix()}:{line}", f"link does not resolve: {target}") + + # 8 + for rel in user_docs(root): + text = rc.read_text(root / rel) + for n, line in enumerate(text.splitlines(), start=1): + if ".agents/" in line: + bad(f"{rel.as_posix()}:{n}", "user documentation does not cite design records; " + "state what the reader needs here instead") + + return errors + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--root", type=Path, default=rc.ROOT, help=argparse.SUPPRESS) + args = ap.parse_args(argv) + errors = check(args.root) + for e in errors: + print(f"FAIL: {e}") + if errors: + print(f"{len(errors)} problem(s)", file=sys.stderr) + return 1 + print("documents OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/doc-tools/gen_index.py b/tools/doc-tools/gen_index.py new file mode 100644 index 000000000..7fea696e1 --- /dev/null +++ b/tools/doc-tools/gen_index.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Generate .agents/docs/README.md, the index of the design records. + +The index is derived from the records alone: the date from the file name, the +title from the first `#` heading, and subject and status from front matter +when a record declares it. A record that predates the convention is listed by +date and title only; a subject guessed from its name would misfile it. + +check_docs.py compares the committed index with what this produces, so the +index cannot drift from the records. + +Usage: + python3 tools/doc-tools/gen_index.py # write + python3 tools/doc-tools/gen_index.py --check # exit 1 if stale +""" + +import argparse +import sys +from pathlib import Path + +import records as rc + +HEADER = f"""\ +# 设计记录 + + + +本目录保存每次改动的理由:调研了什么、测到了什么、决定了什么、否决了什么。 +一篇记录描述的是它写成的那一刻,**落地后不再修改**,因此这里的内容都不是对 +现状的陈述;现状见 [docs/](../../docs/)。 + +## 规则 + +- 文件名为 `YYYY-MM-DD-.md`,slug 只用小写字母、数字、`.` 与 `-`; + 建议按性质以 `-design`、`-plan` 或 `-analysis` 结尾。 +- {rc.FRONT_MATTER_SINCE} 及以后的记录必须以 front matter 开头(更早的记录不补): + + ```yaml + --- + subject: generator # 简短、可复用的主题词 + status: active # {" | ".join(rc.STATUSES)} + superseded_by: 2026-12-01-....md # 仅 status 为 superseded 时,写文件名 + --- + ``` + +- 落地后只允许修改 front matter 的 `status` / `superseded_by`,以及由脚本完成的 + 路径修正。结论变了就写一篇新记录,并把旧记录标为 `superseded`。 +- **不删除记录。** 往年且不处于 `active` 的记录由 + `tools/doc-tools/archive_records.py` 移入 `archive//`,引用随之改写。 +- `todos/` 放尚未完成的工作项,它们不是决定的记录。 +- 移动或改名用 `tools/doc-tools/move_record.py`,它同时改写仓库中对该文件的引用。 +""" + + +def row(r): + note = f" — {r.status}" if r.status else "" + if r.status == "superseded" and r.superseded_by: + note += f",由 {r.superseded_by} 取代" + return f"- [{r.title}]({r.rel.relative_to(rc.RECORDS).as_posix()}){note}" + + +def render(root=rc.ROOT): + recs = rc.collect(root) + current = sorted((r for r in recs if r.kind == "current"), + key=lambda r: (r.date, r.name), reverse=True) + archived = sorted((r for r in recs if r.kind == "archived"), + key=lambda r: (r.date, r.name), reverse=True) + todos = sorted((r for r in recs if r.kind == "todo"), key=lambda r: r.name) + + out = [HEADER] + out.append(f"共 {len(current) + len(archived)} 篇记录(其中 {len(archived)} 篇已归档)," + f"{len(todos)} 项待办。") + out.append("") + + active = [r for r in current + archived if r.status == "active"] + if active: + out += ["## 进行中", ""] + [row(r) for r in active] + [""] + + subjects = {} + for r in current + archived: + if r.subject: + subjects.setdefault(r.subject, []).append(r) + if subjects: + out += ["## 按主题", "", "只列出声明了 subject 的记录。", ""] + for subject in sorted(subjects): + out += [f"### {subject}", ""] + [row(r) for r in subjects[subject]] + [""] + + if current: + out += ["## 按日期", ""] + month = None + for r in current: + if r.date[:7] != month: + month = r.date[:7] + if out[-1]: + out.append("") + out += [f"### {month}", ""] + out.append(row(r)) + out.append("") + + if archived: + out += ["## 归档", ""] + year = None + for r in archived: + if r.date[:4] != year: + year = r.date[:4] + if out[-1]: + out.append("") + out += [f"### {year}", ""] + out.append(row(r)) + out.append("") + + if todos: + out += ["## 待办", ""] + [row(r) for r in todos] + [""] + + return "\n".join(out).rstrip("\n") + "\n" + + +def is_current(root=rc.ROOT): + path = Path(root) / rc.INDEX + if not path.is_file(): + return False + return rc.read_text(path).replace("\r\n", "\n") == render(root) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--check", action="store_true", help="exit 1 if the index is stale") + ap.add_argument("--root", type=Path, default=rc.ROOT, help=argparse.SUPPRESS) + args = ap.parse_args(argv) + if args.check: + if is_current(args.root): + return 0 + print(f"{rc.INDEX} is stale; run: python3 tools/doc-tools/gen_index.py", file=sys.stderr) + return 1 + (args.root / rc.INDEX).parent.mkdir(parents=True, exist_ok=True) + with open(args.root / rc.INDEX, "w", encoding="utf-8", newline="\n") as f: + f.write(render(args.root)) + print(f"wrote {rc.INDEX}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/doc-tools/move_record.py b/tools/doc-tools/move_record.py new file mode 100644 index 000000000..ab5a95d18 --- /dev/null +++ b/tools/doc-tools/move_record.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Move or rename a document and rewrite the repository's references to it. + +Markdown links to the file, and the file's own relative links, are recomputed +for the new location; a literal repository-relative path to it in any tracked +text file (a comment, a code span) is replaced. A path written relative to +something else in prose (`plan/x.md`, `docs/x.md`) is not recognised and is +reported by nothing, so search for the bare file name after a move. + +The index is regenerated when a record is involved. + +Usage: + python3 tools/doc-tools/move_record.py SRC DST [SRC DST ...] +""" + +import argparse +import sys +from pathlib import Path, PurePosixPath + +import gen_index +import records as rc + + +def repo_rel(root, p): + path = Path(p) + if not path.is_absolute(): + path = Path.cwd() / path + return PurePosixPath(path.resolve().relative_to(Path(root).resolve()).as_posix()) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("paths", nargs="+", metavar="SRC DST") + ap.add_argument("--root", type=Path, default=rc.ROOT, help=argparse.SUPPRESS) + args = ap.parse_args(argv) + if len(args.paths) % 2: + ap.error("give SRC DST pairs") + pairs = list(zip(args.paths[::2], args.paths[1::2])) + moves = {repo_rel(args.root, s): repo_rel(args.root, d) for s, d in pairs} + changed = rc.relocate(args.root, moves, rc.tracked_text_files(args.root)) + for old, new in moves.items(): + print(f"moved {old} -> {new}") + for rel in changed: + print(f"rewrote references in {rel}") + if any(str(p).startswith(rc.RECORDS + "/") for p in list(moves) + list(moves.values())): + gen_index.main(["--root", str(args.root)]) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/doc-tools/records.py b/tools/doc-tools/records.py new file mode 100644 index 000000000..fad62a548 --- /dev/null +++ b/tools/doc-tools/records.py @@ -0,0 +1,297 @@ +"""The design records under .agents/docs/: where they live, what they declare, +and how a move rewrites the references to them. + +A record is `YYYY-MM-DD-.md`. It lives either in `.agents/docs/` or, once +its year is over and it is no longer active, in `.agents/docs/archive//`. +`.agents/docs/todos/` holds work items that are not records of a decision. + +Records dated FRONT_MATTER_SINCE or later must open with front matter: + + --- + subject: generator # a short, reused word + status: landed # active | landed | superseded | abandoned + superseded_by: 2026-12-01-....md # a file name, only when superseded + --- + +Older records are not rewritten to add it; they are indexed by date and title. +`superseded_by` names a file, not a path, so archiving the successor does not +break it. + +Only the standard library is used (a third-party package would be declared in +tools/pyproject.toml), and every path is handled with pathlib and +written out with "/" separators, so the tools behave the same on Windows. +""" + +import os +import re +import subprocess +from pathlib import Path, PurePosixPath +from urllib.parse import unquote + +ROOT = Path(__file__).resolve().parents[2] + +AGENTS = ".agents" +RECORDS = ".agents/docs" +ARCHIVE = ".agents/docs/archive" +TODOS = ".agents/docs/todos" +INDEX = ".agents/docs/README.md" + +# The day the front matter convention started. Records dated earlier predate it. +FRONT_MATTER_SINCE = "2026-11-08" + +STATUSES = ("active", "landed", "superseded", "abandoned") +DATED = re.compile(r"^(\d{4})-(\d{2})-(\d{2})-([a-z0-9][a-z0-9.-]*)\.md$") +SUBJECT = re.compile(r"^[a-z0-9][a-z0-9-]*$") + +# Markdown inline links and images: [label](target) / ![alt](target "title"). +LINK = re.compile(r"(!?\[(?:[^\]\\]|\\.)*\]\()([^)\s]+)((?:\s+\"[^\"]*\")?\))") +FENCE = re.compile(r"^\s*(```|~~~)") + + +class Record: + """One markdown file under .agents/docs/ other than the index.""" + + def __init__(self, root, rel): + self.root = Path(root) + self.rel = PurePosixPath(rel) + text = read_text(self.root / rel) + self.front, self.front_error = parse_front_matter(text) + self.title = title_of(text) or self.rel.stem + m = DATED.match(self.rel.name) + self.date = f"{m.group(1)}-{m.group(2)}-{m.group(3)}" if m else "" + + @property + def name(self): + return self.rel.name + + @property + def kind(self): + """'todo', 'archived' or 'current', from where the file lives.""" + parts = self.rel.parts + if len(parts) > 3 and parts[2] == "todos": + return "todo" + if len(parts) > 3 and parts[2] == "archive": + return "archived" + return "current" + + @property + def status(self): + return self.front.get("status", "") + + @property + def subject(self): + return self.front.get("subject", "") + + @property + def superseded_by(self): + return self.front.get("superseded_by", "") + + @property + def needs_front_matter(self): + return self.kind != "todo" and self.date >= FRONT_MATTER_SINCE + + +def read_text(path): + # newline="" keeps CRLF intact, so a rewrite changes only what it means to. + with open(path, encoding="utf-8", newline="") as f: + return f.read() + + +def write_text(path, text): + with open(path, "w", encoding="utf-8", newline="") as f: + f.write(text) + + +def parse_front_matter(text): + """Return (fields, error). A file without front matter gives ({}, "").""" + text = text.replace("\r\n", "\n") + if not text.startswith("---\n"): + return {}, "" + end = text.find("\n---\n", 3) + if end < 0 and text.endswith("\n---"): + end = len(text) - 4 + if end < 0: + return {}, "front matter is not closed by a '---' line" + fields = {} + for n, line in enumerate(text[4:end].split("\n"), start=2): + bare = re.sub(r"\s+#.*$", "", line).strip() + if not bare: + continue + if ":" not in bare: + return fields, f"front matter line {n} is not 'key: value'" + key, value = bare.split(":", 1) + fields[key.strip()] = value.strip() + return fields, "" + + +def title_of(text): + in_front = text.startswith("---") + in_fence = False + for i, line in enumerate(text.replace("\r\n", "\n").split("\n")): + if in_front: + if i > 0 and line.strip() == "---": + in_front = False + continue + if FENCE.match(line): + in_fence = not in_fence + elif not in_fence and line.startswith("# "): + return line[2:].strip() + return "" + + +def record_paths(root): + """Every markdown file under .agents/docs/ except the index, sorted.""" + base = Path(root) / RECORDS + if not base.is_dir(): + return [] + out = [] + for p in base.rglob("*.md"): + rel = PurePosixPath(p.relative_to(root).as_posix()) + if str(rel) != INDEX: + out.append(rel) + return sorted(out) + + +def collect(root=ROOT): + return [Record(root, rel) for rel in record_paths(root)] + + +# ── links ───────────────────────────────────────────────────────────────── + + +def iter_links(text): + """Yield (match, line_number) for each inline link outside fenced code.""" + in_fence = False + offset = 0 + for n, line in enumerate(text.split("\n"), start=1): + if FENCE.match(line): + in_fence = not in_fence + elif not in_fence: + for m in LINK.finditer(line): + yield m, n, offset + offset += len(line) + 1 + + +def is_local(target): + return not (target.startswith(("#", "/", "<")) or re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", target)) + + +def split_target(target): + path, sep, anchor = target.partition("#") + return path, sep + anchor + + +def resolve(source_rel, target_path): + """The repository-relative POSIX path a relative link points at.""" + joined = os.path.normpath(os.path.join(str(PurePosixPath(source_rel).parent), unquote(target_path))) + return PurePosixPath(joined.replace(os.sep, "/")) + + +def relative(from_rel, to_rel): + rel = os.path.relpath(str(to_rel), str(PurePosixPath(from_rel).parent) or ".") + return rel.replace(os.sep, "/") + + +def broken_links(root, rel): + """[(line, target)] for relative links in `rel` whose file does not exist.""" + text = read_text(Path(root) / rel).replace("\r\n", "\n") + out = [] + for m, n, _ in iter_links(text): + path, _ = split_target(m.group(2)) + if not path or not is_local(path): + continue + if not (Path(root) / resolve(rel, path)).exists(): + out.append((n, m.group(2))) + return out + + +# ── moves ───────────────────────────────────────────────────────────────── + + +def tracked_text_files(root): + """Tracked files that may name a record: git ls-files, minus vendored trees.""" + out = subprocess.run(["git", "ls-files", "-z"], cwd=root, check=True, + capture_output=True).stdout.decode("utf-8") + keep = [] + for rel in filter(None, out.split("\0")): + if rel.startswith(("upstream/", "generated/", "patches/")): + continue + if Path(rel).suffix.lower() in {".md", ".py", ".toml", ".yml", ".yaml", ".sh", + ".cppm", ".cpp", ".hpp", ".json", ".txt", ".mcpp"}: + keep.append(PurePosixPath(rel)) + return keep + + +def rewrite_references(text, source_old, source_new, moves): + """Return `text` with every reference to a moved file updated. + + `source_old`/`source_new` are where the file containing `text` was and + will be; `moves` maps old repository-relative paths to new ones. Markdown + links are recomputed relative to the new location of both ends (only in + markdown files). Any literal repository-relative path, such as + `.agents/docs/` in a comment or a code span, is replaced as well. + """ + if PurePosixPath(source_old).suffix == ".md": + pieces = [] + last = 0 + for m, _, offset in iter_links(text): + path, anchor = split_target(m.group(2)) + if not path or not is_local(path): + continue + target_old = resolve(source_old, path) + target_new = moves.get(target_old, target_old) + if target_new == target_old and source_new == source_old: + continue + new_target = relative(source_new, target_new) + anchor + if new_target == m.group(2): + continue + start = offset + m.start(2) + pieces.append(text[last:start]) + pieces.append(new_target) + last = offset + m.end(2) + pieces.append(text[last:]) + text = "".join(pieces) + for old, new in moves.items(): + text = text.replace(str(old), str(new)) + return text + + +def relocate(root, moves, files): + """Move files and rewrite references to them in `files`. + + `moves` maps repository-relative paths (old -> new); `files` lists the + repository-relative paths whose contents may refer to them. Returns the + paths, after the move, of the files whose contents changed. + """ + root = Path(root) + moves = {PurePosixPath(k): PurePosixPath(v) for k, v in moves.items()} + for old, new in moves.items(): + if not (root / old).is_file(): + raise FileNotFoundError(old) + if (root / new).exists(): + raise FileExistsError(new) + changed = [] + for rel in files: + rel = PurePosixPath(rel) + if not (root / rel).is_file(): + continue + text = read_text(root / rel) + new_text = rewrite_references(text, rel, moves.get(rel, rel), moves) + if new_text != text: + write_text(root / rel, new_text) + changed.append(moves.get(rel, rel)) + for old, new in moves.items(): + (root / new).parent.mkdir(parents=True, exist_ok=True) + if git_mv(root, old, new): + continue + (root / old).rename(root / new) + return changed + + +def git_mv(root, old, new): + try: + subprocess.run(["git", "mv", str(old), str(new)], cwd=root, check=True, + capture_output=True) + return True + except (OSError, subprocess.CalledProcessError): + return False diff --git a/tools/doc-tools/tests/test_doc_tools.py b/tools/doc-tools/tests/test_doc_tools.py new file mode 100644 index 000000000..c745bff92 --- /dev/null +++ b/tools/doc-tools/tests/test_doc_tools.py @@ -0,0 +1,170 @@ +"""The design-record tools on a scratch repository: index, checks, moves.""" +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path, PurePosixPath + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import archive_records # noqa: E402 +import check_docs # noqa: E402 +import gen_index # noqa: E402 +import records as rc # noqa: E402 + +NEW = "2026-11-09" + + +def front(subject="docs", status="landed", extra=""): + return f"---\nsubject: {subject}\nstatus: {status}\n{extra}---\n\n" + + +class Repo: + def __init__(self): + self.dir = tempfile.mkdtemp() + self.root = Path(self.dir) + + def write(self, rel, text): + p = self.root / rel + p.parent.mkdir(parents=True, exist_ok=True) + with open(p, "w", encoding="utf-8", newline="") as f: + f.write(text) + + def read(self, rel): + return rc.read_text(self.root / rel) + + def index(self): + gen_index.main(["--root", str(self.root)]) + + def errors(self): + return check_docs.check(self.root) + + def git_init(self): + run = lambda *a: subprocess.run(["git", *a], cwd=self.root, check=True, capture_output=True) + run("init", "-q") + run("add", "-A") + + def tracked(self): + return rc.tracked_text_files(self.root) + + def close(self): + shutil.rmtree(self.dir, ignore_errors=True) + + +class DocToolsTest(unittest.TestCase): + def setUp(self): + self.repo = Repo() + self.addCleanup(self.repo.close) + self.repo.write("docs/usage.md", "# Usage\n") + self.repo.write(".agents/docs/2026-09-01-old.md", "# 旧记录\n\n[gone](../../docs/gone.md)\n") + + def assertClean(self): + self.assertEqual(self.repo.errors(), []) + + def assertFails(self, needle): + errors = self.repo.errors() + self.assertTrue(any(needle in e for e in errors), errors) + + # ── index ── + def test_index_lists_records_and_is_checked(self): + self.repo.write(f".agents/docs/{NEW}-a-design.md", front("generator", "active") + "# A\n") + self.assertFails("README.md: stale") + self.repo.index() + self.assertClean() + idx = self.repo.read(rc.INDEX) + self.assertIn("## 进行中", idx) + self.assertIn("### generator", idx) + self.assertIn("- [旧记录](2026-09-01-old.md)\n", idx) + self.assertIn(f"- [A]({NEW}-a-design.md) — active", idx) + + def test_index_check_ignores_crlf(self): + self.repo.index() + p = self.repo.root / rc.INDEX + p.write_bytes(p.read_bytes().replace(b"\n", b"\r\n")) + self.assertTrue(gen_index.is_current(self.repo.root)) + + # ── front matter ── + def test_old_records_are_exempt(self): + self.repo.index() + self.assertClean() # no front matter, broken link: both tolerated + + def test_new_record_needs_front_matter(self): + self.repo.write(f".agents/docs/{NEW}-b.md", "# B\n") + self.repo.index() + self.assertFails("opens with front matter") + + def test_bad_status_and_subject(self): + self.repo.write(f".agents/docs/{NEW}-b.md", front("Big Word", "done") + "# B\n") + self.repo.index() + self.assertFails("status is one of") + self.assertFails("subject is one short") + + def test_superseded_needs_existing_successor(self): + self.repo.write(f".agents/docs/{NEW}-b.md", + front(status="superseded", extra="superseded_by: 2026-12-01-x.md\n") + "# B\n") + self.repo.index() + self.assertFails("names no record") + self.repo.write(".agents/docs/2026-12-01-x.md", front() + "# X\n") + self.repo.index() + self.assertClean() + + def test_new_record_links_must_resolve(self): + self.repo.write(f".agents/docs/{NEW}-b.md", front() + "# B\n\n[x](../../docs/nope.md)\n") + self.repo.index() + self.assertFails("link does not resolve: ../../docs/nope.md") + + # ── placement ── + def test_only_docs_and_skills_under_agents(self): + self.repo.write(".agents/plan/2026-09-02-p.md", "# P\n") + self.repo.index() + self.assertFails("only docs/ and skills/") + + def test_record_name_and_archive_year(self): + self.repo.write(".agents/docs/Notes.md", "# N\n") + self.repo.write(".agents/docs/archive/2025/2026-01-01-y.md", "# Y\n") + self.repo.index() + self.assertFails("Notes.md: a record is named") + self.assertFails("lives in archive/2026/") + + def test_user_docs_do_not_cite_records(self): + self.repo.write("README.md", "see .agents/docs/2026-09-01-old.md\n") + self.repo.index() + self.assertFails("README.md:1: user documentation") + + # ── moves ── + def test_relocate_rewrites_links_both_ways(self): + self.repo.write(".agents/docs/2026-09-02-b.md", + "# B\n\n[old](2026-09-01-old.md#s) [u](../../docs/usage.md)\n" + "```\n[skip](2026-09-01-old.md)\n```\n") + self.repo.write("tools/x.py", "# see .agents/docs/2026-09-01-old.md\r\n") + self.repo.git_init() + moves = {".agents/docs/2026-09-02-b.md": ".agents/docs/archive/2026/2026-09-02-b.md"} + moves[".agents/docs/2026-09-01-old.md"] = ".agents/docs/archive/2026/2026-09-01-old.md" + rc.relocate(self.repo.root, moves, self.repo.tracked()) + b = self.repo.read(".agents/docs/archive/2026/2026-09-02-b.md") + self.assertIn("[old](2026-09-01-old.md#s)", b) + self.assertIn("[u](../../../../docs/usage.md)", b) + self.assertIn("[skip](2026-09-01-old.md)", b) # fenced code untouched + self.assertEqual(self.repo.read("tools/x.py"), + "# see .agents/docs/archive/2026/2026-09-01-old.md\r\n") + + def test_archive_moves_past_years_but_keeps_active(self): + self.repo.write(f".agents/docs/{NEW}-c.md", front(status="active") + "# C\n") + self.repo.write(".agents/docs/2026-12-30-d.md", front() + "# D\n\n[o](2026-09-01-old.md)\n") + self.repo.index() + self.repo.git_init() + self.assertEqual(archive_records.main(["--root", str(self.repo.root), "--today", "2027-01-05"]), 0) + base = self.repo.root / rc.ARCHIVE / "2026" + self.assertEqual(sorted(p.name for p in base.iterdir()), + ["2026-09-01-old.md", "2026-12-30-d.md"]) + self.assertTrue((self.repo.root / rc.RECORDS / f"{NEW}-c.md").is_file()) + self.assertClean() + + def test_archive_refuses_the_current_year(self): + with self.assertRaises(SystemExit): + archive_records.main(["--root", str(self.repo.root), "--year", "2027", "--today", "2027-01-05"]) + + +if __name__ == "__main__": + unittest.main()