diff --git a/README.md b/README.md index 9df7d02..88595a0 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Key capabilities: ## Quick start -A prebuilt Agent-Notification.dmg is available on the [Releases](../../releases) page (double-click and drag into Applications). To build from source, from the repository root: +A prebuilt Agent-Notification.dmg is available on the [Releases](https://github.com/SnowsonZ/Agent-Notification/releases) page (double-click and drag into Applications). To build from source, from the repository root: ```sh python3 -m venv scratch/iterm-probe-venv diff --git a/README.zh-CN.md b/README.zh-CN.md index 070c9c6..fe9f315 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -49,7 +49,7 @@ Agent Notification 是运行于 macOS 的本地会话收件箱:汇总 Codex、 ## 快速上手 -已打包版本从 [Releases](../../releases) 下载 Agent-Notification.dmg(双击拖入「应用程序」即装);从源码构建则克隆仓库后,在项目根目录执行: +已打包版本从 [Releases](https://github.com/SnowsonZ/Agent-Notification/releases) 下载 Agent-Notification.dmg(双击拖入「应用程序」即装);从源码构建则克隆仓库后,在项目根目录执行: ```sh python3 -m venv scratch/iterm-probe-venv diff --git a/docs/plans/backlog.md b/docs/plans/backlog.md index c1779e3..0ca0563 100644 --- a/docs/plans/backlog.md +++ b/docs/plans/backlog.md @@ -33,7 +33,6 @@ | B18 | 下次实质修订调研报告时,在 Claude 编辑源补 METR 样本与时间视界版本、`pass^k` 在异质任务上的限定、自动合并下「无修改合并率」的口径,再重新导出快照 | 随报告下次修订 | 评审方 | Codex 复评「调研文章」部分 | | B23 | 停机演练:按规范 §8 停机 → R0 测试 PR 不被自动合并 → 恢复 → 重跑后被自动合并,结果记入规范 §6(步骤已在 P2 写入) | 进入无人值守前一次,之后每季度 | 用户演练,评审方记录 | 全链路审计 G4、1.5,设计第十七节 | | B27 | R2+ PR 增加独立上下文评审(另一个设计评审方按评审模板),先在 3 个 PR 上试行,再与用户判断对照校准 | 中;流程成本 | 评审方执行,用户决定是否长期保留 | 全链路审计 G5、2.4 | -| B29 | 质量棘轮纳入 Swift 复杂度;加轻量的文档链接与状态新鲜度检查 | 小到中 | 评审方 | 全链路审计 G8、G10、3.2 | ## 3. 持续进行或暂缓 @@ -71,3 +70,4 @@ | B3 | R1 自动合并后的抽样审计,改为机制:K3 按 PR 编号哈希三抽一,合并后自动开 audit 议题(`policy.py`、auto-merge) | 2026-09-28,PR #50(目标态设计阶段一 P2) | | B14 | R1 保持自动合并并加强判定(2026-09-28 决定 2):签名不变、无新依赖与迁移、不超规模由 `r1_checks.py` 核对,变异不降由 build 的 harness job 核对,已有测试与黄金快照零改动沿用 risk.py | 2026-09-28,PR #50(目标态设计阶段一 P2) | | B22 | 任务书验收挂规格编号或写明不挂的原因(`harness/taskbook.py`,复用 `acceptance.py` 解析);计划并入任务书「步骤与提交顺序」,删去「R2+ 先交计划」,计划模板归档 | 2026-09-28,PR #49(目标态设计阶段一 P1) | +| B29 | 质量棘轮纳入 Swift(`quality.py`:超长文件、超长函数、嵌套过深函数只降不升);`docs_check.py` 拦断链、报告陈旧状态,进 verify 默认档与完整档 | 2026-09-28,PR #62(目标态设计阶段二 P8) | diff --git a/docs/plans/session-inbox.md b/docs/plans/session-inbox.md index e5d1300..66b985a 100644 --- a/docs/plans/session-inbox.md +++ b/docs/plans/session-inbox.md @@ -67,7 +67,7 @@ Stop 是停止阶段信号,其他 hook 可要求继续,因此不是任务成 ### Pi / iTerm2 -本机包 `@earendil-works/pi-coding-agent` 0.85.1。[随包扩展文档](/opt/homebrew/lib/node_modules/@earendil-works/pi-coding-agent/docs/extensions.md)明确包含 session_start、sessionManager.getSessionId()、agent_end 和 agent_settled。优先 agent_settled,因为其语义包含没有待重试、压缩、后续轮次;仍须结合错误/中断状态,不能直接叫成功。 +本机包 `@earendil-works/pi-coding-agent` 0.85.1。随包扩展文档(包内 `docs/extensions.md`)明确包含 session_start、sessionManager.getSessionId()、agent_end 和 agent_settled。优先 agent_settled,因为其语义包含没有待重试、压缩、后续轮次;仍须结合错误/中断状态,不能直接叫成功。 用原生 extension 上报事件,并登记当前 iTerm 会话;恢复/切换 Pi session 时更新映射。扩展目录和装载方式以当前版本实测为准。 diff --git a/docs/review/2026-09-27-harness-audit.md b/docs/review/2026-09-27-harness-audit.md index cd5c360..eccf754 100644 --- a/docs/review/2026-09-27-harness-audit.md +++ b/docs/review/2026-09-27-harness-audit.md @@ -50,7 +50,7 @@ | 节点 | 当前实现 | 差距 | 可实现性 | |---|---|---|---| -| 6.1 研究→计划→实现,人审前两步 | 规范要求 R2 及以上先按[计划模板](../templates/plan.md)写计划交评审 | **G9**:计划模板从未使用,仓库里没有一份计划;任务 003、004 为 R3 也没有计划。实际由任务书承担计划(任务 004 写死了三个提交的顺序),效果尚可 | 建议并入任务书而非强制单独计划(小) | +| 6.1 研究→计划→实现,人审前两步 | 规范要求 R2 及以上先按[计划模板](../templates/archive/plan.md)写计划交评审 | **G9**:计划模板从未使用,仓库里没有一份计划;任务 003、004 为 R3 也没有计划。实际由任务书承担计划(任务 004 写死了三个提交的顺序),效果尚可 | 建议并入任务书而非强制单独计划(小) | | 6.2 一次只做一件事 | 一个任务一个 PR;PR 中位 66 行 | 无 | — | | 6.3 任务粒度 | 任务均在一次执行方运行内完成 | 运行时长没有记录 | 随度量处理 | | 6.4 确定性编排 + 智能体节点 | CI、风险判定、证据、自动合并全为确定性脚本;Agent 只做实现与评审 | 派发本身是手工命令,没有脚本 | 随 G1/G3 解决 | diff --git a/docs/specs/delivery-harness.md b/docs/specs/delivery-harness.md index 7ea792c..a4825e1 100644 --- a/docs/specs/delivery-harness.md +++ b/docs/specs/delivery-harness.md @@ -8,14 +8,15 @@ harness 只依赖 Python 标准库,放在仓库顶层 `harness/`,不进发 | 命令 | 作用 | 谁在何时运行 | |---|---|---| -| `bin/verify` | 工具版本、git 守卫、lint、仓库卫生、Python 测试、Swift 测试(仅 macOS) | 所有人;pre-push 自动运行 | +| `bin/verify` | 工具版本、git 守卫、lint、仓库卫生、质量棘轮、文档链接、Python 测试、Swift 测试(仅 macOS) | 所有人;pre-push 自动运行 | | `bin/verify --quick` | 工具版本、lint、仓库卫生、验收映射、任务书准入 | pre-commit 自动运行 | | `bin/verify --full` | 默认档 + 事故回放 | macOS CI(`--strict --full`);改动测试或产品逻辑后 | | `bin/verify --strict` | 被跳过的检查算失败 | macOS CI | | `python3 harness/acceptance.py [--manual]` | 规格验收编号 ↔ 测试映射检查;列出人工验收清单 | verify 各档;写 PR 的人工验收部分时 | | `python3 harness/taskbook.py [任务书 --on-main]` | 任务书准入:YAML 头部(类别、风险、设计方、规模、架构级、规格编号、预算、回滚)、类别与风险相容、验收每行挂规格编号、中大任务的必备章节、按「步骤与提交顺序」所列文件交叉核对;`--on-main` 另要求任务书已合并到 main(派发前) | verify 各档;派发前 | | `python3 harness/review_pack.py --base origin/main` | 评审证据包:风险等级、修复证据、verify、涉及的验收编号 | 评审方评审前 | -| `python3 harness/quality.py [--update]` | 熵治理棘轮:复杂度超标函数数、超长文件数只降不升 | verify 默认档;每周 quality workflow | +| `python3 harness/quality.py [--update]` | 熵治理棘轮:Python 复杂度超标函数数、超长文件数只降不升;Swift(`native/`,轻量解析)超过 500 行的文件数、函数体超过 60 行的函数数、大括号嵌套超过 6 层的函数数只降不升 | verify 默认档;每周 quality workflow | +| `python3 harness/docs_check.py` | 文档熵治理:已跟踪的 README*.md、AGENTS.md、docs/、harness/README.md 中相对链接指向的文件必须存在(外链、纯锚点不查,`文件#锚点` 只查文件),断链即失败;「状态:」写着待执行、进行中、草案等且超过 30 天未提交的文档只报告 | verify 默认档与完整档 | | `python3 harness/metrics.py --base origin/main [--github]` | 交付度量:修复数、声称已修、修复带回放比例、新增测试、CI 轮次,并列 v0.8.0 基线 | CI harness job;试跑记录 | | `python3 harness/replay.py [--list]` | 把历史缺陷注入工作区副本,对应测试必须失败;列出基线覆盖 | `verify --full` | | `python3 harness/mutate.py [--check / --update]` | 定向变异测试,得分与 `harness/mutation-baseline.json` 比较(只升不降) | 每周 quality workflow;补测试后 | @@ -158,6 +159,7 @@ Agent 层按角色接入: | 派发脚本(P4) | 单测(`tests/test_harness_dispatch.py`,假执行方与假 GitHub、真实临时 git 远端):成功路径写运行记录、开 PR、主目录无改动、槽位归还;执行方环境无 GitHub 令牌;认领冲突与任务书不在 main 上时不运行执行方;超时、卡死、打转、澄清、停机各自升级;CI 失败带摘要重试、超预算打 `budget-exceeded`;槽位互斥与过期锁回收;守卫取自 origin/main,main 上的守卫失效时停止派发;Pi 参数与事件解析。2026-09-28 真实 Pi 实测 `-na -e` 的守卫加载与拦截 。2026-09-28 真实 K2 任务端到端(B21):任务书 T005(PR #56,R0、K0)由 auto-merge 自动合并;`bin/dispatch` 派发 Pi(glm-5.3-flash)一轮完成、无重试、无守卫拒绝,执行约 12.6 分钟,派发脚本在执行方之外 verify 通过,运行记录与提示词随推送入库,PR #57 描述含运行记录摘要,CI 通过后按 R0、K2 自动合并(合并路由四条均写明理由);主目录前后 git status 无改动;合并后在 main 独立重跑变异,`热力金额分级` 21/21,基线由 90% 升到 100%。发现槽位按启动目录命名(从设计方 worktree 启动得到 `…-sync-slot-1`),已改为按主工作目录命名 | ✅ 单测、守卫实测、真实任务端到端 | | CI 侧复核(P5) | 单测(`tests/test_harness_run_check.py`):派发产生的 PR 通过;设计方分支不适用;带 `Task:` 的任意分支适用;缺记录、提交缺 `Task:`、记录 exit 非 ok、缺字段、task 或分支不符、提示词被改各被标出;以序号最大的记录为准;CI 轮次超预算或读不到时标出,同一提交的重跑不算新轮次;合并路由在缺记录与超预算时转用户、合格时自动合并。T005 的真实运行记录(PR #57)按同一口径合格 | ✅ 单测;首个经合并路由第 5 条判定的真实任务 PR 待下次派发 | | 周报与台账(P6) | 单测(`tests/test_harness_weekly.py`,GitHub 替身):报告含 14.1 全部指标,缺数据标「样本不足」;PR、运行记录与议题算出的各项与手算一致;注入的突增数据在顶部标红,第一期不标、0→2 不标;历史往返跳过当周;试跑记录含历史任务与派发任务;发布时先建后改同一议题;创建标签不带 --force。2026-09-28 用真实数据预演一次(不发布) | ✅ 单测与真实数据预演;首次发布待合并后手动触发 quality | +| 熵治理(P8) | 单测(`tests/test_harness_entropy.py`):字符串(含多行、原始、转义)与注释(含嵌套块注释)里的大括号不计入;协议要求不算函数;人为加长的 Swift 函数(`test_lengthened_swift_function_fails_ratchet`)、加深嵌套与加长文件使棘轮失败,缩减通过;断链(相对、引用式、以 / 开头)被拦,外链、邮件、纯锚点、行内代码与代码块不误报,`文件#锚点` 缺文件仍拦;陈旧的未完成状态只报告不失败,近期的与正文顺带提到的不报。Swift 基线(2026-09-28,332 个函数):`swift_files_over_500` 2、`swift_long_functions` 9、`swift_deep_functions` 13。首跑本仓库修正 4 处断链,陈旧状态无 | ✅ 单测;CI 以本 PR 为准 | | 验收映射 | 6 份规格 69 条编号:可自动化 54 条中 53 条有测试、1 条登记缺口,19 条进入人工清单;检查器在 verify 各档运行 | ✅ | | Swift 回放 | macOS CI `--strict --full`(run 36152060546)通过,Swift 注入在 strict 下不可跳过 | ✅ | | zcode 自检、质量棘轮、交付度量 | 本地实跑;zcode 自检与 CI 中的度量步骤随本 PR 首次在 CI 运行 | ⚠️ 待本 PR 的 CI | diff --git a/harness/README.md b/harness/README.md index 5fcf17b..0030ea2 100644 --- a/harness/README.md +++ b/harness/README.md @@ -33,7 +33,7 @@ | 5. 小批量、可回滚 | 一个任务一个 PR,按 R0–R3 分级;回滚方式写进任务书 | | 6. 状态外置、上下文从简 | 任务书(机器可读的 YAML 头部)与模板(`docs/templates/`)、[待办清单](../docs/plans/backlog.md)、决定记录;中断后从文件恢复 | | 7. 自治度按「任务类别 × 风险」 | `policy.py` 按 `autonomy.toml` 的类别自治等级与误差预算路由:满足条件的 R0/R1 由 App 批准后自动合并,超预算自动停该类,其余请用户评审;停机用 Disable workflow(规范 §8) | -| 8. 每次失败都沉淀为 harness 改进 | 缺陷编号全局唯一,修复带 `Defect:` 与回归测试;`replay_cases.py` 事故回放集;变异与质量基线只升不降;每周周报与错误分析把异常沉淀为待办 | +| 8. 每次失败都沉淀为 harness 改进 | 缺陷编号全局唯一,修复带 `Defect:` 与回归测试;`replay_cases.py` 事故回放集;变异与质量基线只升不降(质量含 Swift),文档断链由 `docs_check.py` 拦下;每周周报与错误分析把异常沉淀为待办 | ## 组成 @@ -48,7 +48,8 @@ | `base_tests.py`、`base_tests_runner.py` | 已有测试按 base 版本在新代码上运行,防止执行方改测试迁就代码 | | `replay.py`、`replay_cases.py` | 事故回放:把历史缺陷注入代码副本,对应检查必须失败 | | `mutate.py`、`mutation-baseline.json` | 关键函数的变异测试,得分只升不降 | -| `quality.py`、`quality-baseline.json` | 熵治理:复杂度与体量只降不升 | +| `quality.py`、`quality-baseline.json` | 熵治理:Python 复杂度(ruff C901)与体量、Swift 超长文件、超长函数与嵌套过深函数(标准库轻量解析)只降不升 | +| `docs_check.py` | 文档熵治理:已跟踪 Markdown 的相对链接断链即失败;未完成状态超过 30 天未更新的文档只报告 | | `hygiene.py` | 禁止提交的路径、超大文件、凭据、本机真实路径 | | `risk.py`、`r1_checks.py` | 按改动路径判定 R0–R3;任务书按头部类别判定,模板与待办清单为 R2;声明 R1 时另核对签名不变、无新依赖与迁移、不超规模 | | `policy.py`、`autonomy.toml` | 合并路由:风险、类别自治等级、误差预算、规模、任务 PR 的运行记录逐条判定并写理由;K3 三抽一抽审;放权只改 `autonomy.toml`(R3) | diff --git a/harness/docs_check.py b/harness/docs_check.py new file mode 100644 index 0000000..fdbe9bf --- /dev/null +++ b/harness/docs_check.py @@ -0,0 +1,137 @@ +"""文档熵治理:已跟踪 Markdown 的相对链接必须指向存在的文件;报告状态陈旧的文档。 + +范围:仓库根的 README*.md 与 AGENTS.md、docs/ 下全部 .md、harness/README.md(只看 git 已跟踪的文件)。 + + 断链(失败) 行内链接 `[文字](目标)`、图片与引用式定义 `[名字]: 目标` 中的相对路径,按所在文件的目录解析 + (以 / 开头的按仓库根解析),目标文件或目录不存在即失败。http(s)、mailto 等带协议的链接与纯锚点 + `#…` 不检查;`文件#锚点` 只核对文件存在,不核对锚点。代码块与行内代码里的内容不算链接。 + 陈旧状态(只报告) 「状态:」一行写着待执行、进行中、草案等未完成字样,且该文件最近一次 git 提交早于 30 天。 + 它提示文档可能已与实际脱节,由评审方收尾时核对(待办清单「定期核对」),不阻塞合并。 + + python3 harness/docs_check.py +""" + +from __future__ import annotations + +import argparse +import re +import sys +import time +from pathlib import Path +from urllib.parse import unquote + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from common import ROOT, git + +PATTERNS = ("README*.md", "AGENTS.md", "docs/*.md", "harness/README.md") # git 路径通配里 * 可跨目录 +STALE_DAYS = 30 +UNFINISHED = ("待执行", "进行中", "草案", "待实施", "实施中", "待派发", "未开始", "待评审") + +_FENCE = re.compile(r"^\s*(```|~~~)") +_INLINE_CODE = re.compile(r"(`+)(?:(?!\1).)+?\1") +_INLINE_LINK = re.compile(r"!?\[(?:[^\[\]]|\[[^\]]*\])*\]\(\s*(<[^>]*>|[^\s)]+)(?:\s+[\"'(][^)]*)?\)") +_REFERENCE = re.compile(r"^\s{0,3}\[[^\]]+\]:\s*(<[^>]*>|\S+)") +_SCHEME = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:") +# 状态行:行首(可带引用号、加粗),或跟在同一行的「日期:… · 」之后;正文与列表里顺带提到的「状态:」不算。 +_STATUS = re.compile(r"^(?:[^\n]*·\s*)?(?:>\s*)?\**(?:当前)?状态\**\s*[::]\s*(.+)") + + +def tracked_markdown(root: Path = ROOT) -> list[str]: + output = git("ls-files", "--", *PATTERNS, cwd=root, isolate=True) + return sorted(line for line in output.splitlines() if line.endswith(".md")) + + +def _content_lines(text: str): + """逐行给出(行号,去掉行内代码后的内容),跳过围栏代码块。""" + fence = None + for number, line in enumerate(text.splitlines(), 1): + opener = _FENCE.match(line) + if fence: + if opener and opener.group(1) == fence: + fence = None + continue + if opener: + fence = opener.group(1) + continue + yield number, _INLINE_CODE.sub("", line) + + +def link_targets(text: str) -> list[tuple[int, str]]: + targets = [] + for number, line in _content_lines(text): + found = [match.group(1) for match in _INLINE_LINK.finditer(line)] + reference = _REFERENCE.match(line) + if reference: + found.append(reference.group(1)) + targets.extend((number, target.strip("<>")) for target in found) + return targets + + +def broken_links(root: Path, relative: str) -> list[str]: + source = root / relative + problems = [] + for number, target in link_targets(source.read_text(encoding="utf-8")): + if not target or target.startswith("#") or _SCHEME.match(target): + continue + path = unquote(target.split("#", 1)[0].split("?", 1)[0]) + if not path: + continue + resolved = root / path.lstrip("/") if path.startswith("/") else source.parent / path + if not resolved.exists(): + problems.append(f"{relative}:{number}: 断链 {target}") + return problems + + +def unfinished_status(text: str) -> str | None: + for _number, line in _content_lines(text): + match = _STATUS.search(line) + if not match: + continue + # 只看状态值的第一句,免得把后文提到的词当成状态。 + value = re.split(r"[。;;]", match.group(1), maxsplit=1)[0] + if any(word in value for word in UNFINISHED): + return value.strip("* ") + return None + + +def stale_documents(root: Path, files: list[str], now: float | None = None) -> list[str]: + now = time.time() if now is None else now + stale = [] + for relative in files: + status = unfinished_status((root / relative).read_text(encoding="utf-8")) + if not status: + continue + stamp = git("log", "-1", "--format=%ct", "--", relative, cwd=root, isolate=True, check=False) + if not stamp: + continue + days = int((now - int(stamp)) // 86400) + if days > STALE_DAYS: + stale.append(f"{relative}:状态「{status}」,最近一次提交在 {days} 天前") + return stale + + +def main(argv: list[str] | None = None, root: Path = ROOT, now: float | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.parse_args(argv) + files = tracked_markdown(root) + broken = [problem for relative in files for problem in broken_links(root, relative)] + stale = stale_documents(root, files, now) + print(f"检查了 {len(files)} 份 Markdown") + for problem in broken: + print(f"✗ {problem}") + if stale: + print(f"状态可能陈旧(超过 {STALE_DAYS} 天未提交,只报告):") + for item in stale: + print(f" - {item}") + else: + print("状态陈旧:无") + if broken: + print(f"✗ {len(broken)} 处断链:修正链接或补回目标文件") + return 1 + print("✓ 相对链接均指向存在的文件") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/quality-baseline.json b/harness/quality-baseline.json index c73e188..9087844 100644 --- a/harness/quality-baseline.json +++ b/harness/quality-baseline.json @@ -1,5 +1,8 @@ { "complex_functions": 20, "files_over_800": 1, - "largest_file_lines": 1693 + "largest_file_lines": 1696, + "swift_deep_functions": 13, + "swift_files_over_500": 2, + "swift_long_functions": 9 } diff --git a/harness/quality.py b/harness/quality.py index af806fd..99c6478 100644 --- a/harness/quality.py +++ b/harness/quality.py @@ -4,6 +4,18 @@ complex_functions ruff C901 超标函数数(Python,圈复杂度 > 10) 只降不升 files_over_800 超过 800 行的源文件数 只降不升 largest_file_lines 最大源文件行数 只报告(往大文件里加小修复不应被拦) + swift_files_over_500 native/ 下超过 500 行的 Swift 文件数 只降不升 + swift_long_functions 函数体超过 60 行的 Swift 函数数 只降不升 + swift_deep_functions 大括号嵌套深度超过 6 的 Swift 函数数 只降不升 + +Swift 没有 ruff 这样的现成工具,这里用轻量解析(只用标准库):先把注释(含嵌套块注释)与字符串字面量 +(普通、多行三引号、带 # 的原始字符串)抹成空格,再配对大括号。「函数」指 func、init、deinit 与计算属性 +(`var x: T {`,SwiftUI 的 body 即此类);函数体行数从 `{` 所在行数到匹配的 `}` 所在行,嵌套深度以函数体 +自身为 1,闭包与控制流都计入。解析不求完美(如 #if 分支、正则字面量不处理),只需对同一份代码稳定。 + +阈值取自 2026-09-28 native/ 的分布(332 个函数):函数体行数中位 6、P90 29、P95 48、P99 88, +取 60 行约为前 3%;嵌套深度 1–4 占 92%,SwiftUI 视图常见 5–6 层,超过 6 层只剩 13 个,都是可以 +拆出子视图的大 body。文件 500 行:现有 Swift 文件多在 400 行上下,超过 500 行的只有 2 个。 python3 harness/quality.py 打印指标并与 harness/quality-baseline.json 比较,棘轮指标上升即失败 python3 harness/quality.py --update 指标下降后写回基线(上调基线须直接改文件,属 R3,由评审确认) @@ -13,6 +25,7 @@ import argparse import json +import re import subprocess import sys from pathlib import Path @@ -24,7 +37,134 @@ BASELINE = ROOT / "harness" / "quality-baseline.json" SOURCES = [("scripts", "*.py"), ("native", "**/*.swift")] LONG_FILE = 800 -RATCHETED = ("complex_functions", "files_over_800") +SWIFT_LONG_FILE = 500 +SWIFT_LONG_FUNCTION = 60 +SWIFT_DEEP_NESTING = 6 +RATCHETED = ( + "complex_functions", + "files_over_800", + "swift_files_over_500", + "swift_long_functions", + "swift_deep_functions", +) + +_STRING_START = re.compile(r'(#*)("""|")') +_SWIFT_DECL = re.compile( + r"(?[^\s(<]+)|(?Pinit|deinit)\b[?!]?|var\s+(?P\w+)\s*:\s*[^=\n{};]+?\{)" +) +_DECL_KEYWORD = re.compile(r"(? str: + """把注释与字符串字面量抹成空格(保留换行,行号不变),其余字符原样保留。""" + out: list[str] = [] + i, n = 0, len(source) + block_depth = 0 + while i < n: + ch = source[i] + if block_depth: # Swift 的块注释可以嵌套 + if source.startswith("/*", i) or source.startswith("*/", i): + block_depth += 1 if source[i] == "/" else -1 + out.append(" ") + i += 2 + else: + out.append("\n" if ch == "\n" else " ") + i += 1 + continue + if source.startswith("//", i): + end = source.find("\n", i) + end = n if end < 0 else end + out.append(" " * (end - i)) + i = end + continue + if source.startswith("/*", i): + block_depth = 1 + out.append(" ") + i += 2 + continue + match = _STRING_START.match(source, i) + if match and (match.group(1) or ch == '"'): + hashes, quote = match.group(1), match.group(2) + closing = quote + hashes + j = match.end() + while j < n: + if not hashes and source[j] == "\\": + j += 2 + continue + if source.startswith(closing, j): + j += len(closing) + break + if quote == '"' and source[j] == "\n": + break + j += 1 + out.append("".join("\n" if c == "\n" else " " for c in source[i:j])) + i = j + continue + out.append(ch) + i += 1 + return "".join(out) + + +def _body_open(text: str, index: int) -> int | None: + """从声明名之后找函数体的 `{`:跳过参数表与泛型;先遇到 `}`、`=` 或下一个声明说明没有函数体(协议要求)。""" + parens = 0 + while index < len(text): + ch = text[index] + if ch in "([": + parens += 1 + elif ch in ")]": + parens -= 1 + elif parens == 0: + if ch == "{": + return index + if ch in "};=" or _DECL_KEYWORD.match(text, index): + return None + index += 1 + return None + + +def swift_functions(source: str) -> list[tuple[str, int, int, int]]: + """返回 (名字, 声明行号, 函数体行数, 最大嵌套深度)。""" + text = strip_swift(source) + closing: dict[int, int] = {} + stack: list[int] = [] + for index, ch in enumerate(text): + if ch == "{": + stack.append(index) + elif ch == "}" and stack: + closing[stack.pop()] = index + functions = [] + for decl in _SWIFT_DECL.finditer(text): + open_at = decl.end() - 1 if decl.group("var") else _body_open(text, decl.end()) + if open_at is None or open_at not in closing: + continue + close_at = closing[open_at] + depth = deepest = 0 + for ch in text[open_at : close_at + 1]: + if ch == "{": + depth += 1 + deepest = max(deepest, depth) + elif ch == "}": + depth -= 1 + name = decl.group("func") or decl.group("init") or decl.group("var") + body_lines = text.count("\n", open_at, close_at) + 1 + functions.append((name, text.count("\n", 0, decl.start()) + 1, body_lines, deepest)) + return functions + + +def measure_swift(root: Path = ROOT) -> dict[str, int]: + files = long_functions = deep_functions = 0 + for path in sorted((root / "native").glob("**/*.swift")): + source = path.read_text(encoding="utf-8") + files += len(source.splitlines()) > SWIFT_LONG_FILE + for _name, _line, body_lines, deepest in swift_functions(source): + long_functions += body_lines > SWIFT_LONG_FUNCTION + deep_functions += deepest > SWIFT_DEEP_NESTING + return { + "swift_files_over_500": files, + "swift_long_functions": long_functions, + "swift_deep_functions": deep_functions, + } def measure(root: Path = ROOT) -> dict[str, int]: @@ -44,6 +184,7 @@ def measure(root: Path = ROOT) -> dict[str, int]: "complex_functions": complex_functions, "largest_file_lines": max(sizes, default=0), "files_over_800": sum(size > LONG_FILE for size in sizes), + **measure_swift(root), } diff --git a/harness/verify.py b/harness/verify.py index 445850d..1112bc6 100644 --- a/harness/verify.py +++ b/harness/verify.py @@ -1,6 +1,6 @@ """统一验证入口:本机、云端、CI 用同一条命令、同一组检查。 - bin/verify 默认档:工具版本、lint、仓库卫生、Python 测试、Swift 测试(仅 macOS) + bin/verify 默认档:工具版本、lint、仓库卫生、质量棘轮、文档链接、Python 测试、Swift 测试(仅 macOS) bin/verify --quick 快速档:工具版本、lint、仓库卫生(pre-commit 用) bin/verify --full 完整档:默认档 + 事故回放(注入历史缺陷,对应测试必须失败) bin/verify --strict 任何被跳过的检查都算失败(macOS CI 用,防止 Swift 检查被静默跳过) @@ -109,6 +109,8 @@ def build_checks(strict: bool = False) -> list[Check]: Check("hygiene", ("quick", "default", "full"), command=[py, "harness/hygiene.py", "--tracked"]), # 熵治理棘轮(harness/quality.py):复杂度超标函数数与超长文件数只降不升。 Check("quality", ("default", "full"), command=[py, "harness/quality.py"]), + # 文档熵治理(harness/docs_check.py):已跟踪 Markdown 的相对链接断链即失败;陈旧状态只报告。 + Check("docs", ("default", "full"), command=[py, "harness/docs_check.py"]), # 规格验收编号 ↔ 测试映射(harness/acceptance.py):引用失效或新增无测试条目即失败。 Check("acceptance", ("quick", "default", "full"), command=[py, "harness/acceptance.py"]), # 任务书准入(harness/taskbook.py):头部、类别与风险、验收挂规格编号、步骤交叉核对。 diff --git a/tests/test_harness_entropy.py b/tests/test_harness_entropy.py new file mode 100644 index 0000000..702fd53 --- /dev/null +++ b/tests/test_harness_entropy.py @@ -0,0 +1,198 @@ +"""熵治理(目标态设计阶段二 P8):Swift 质量棘轮与文档链接、状态新鲜度检查。 + +全部在临时目录里构造样例,不读写本仓库的文件。 +""" + +import io +import sys +import tempfile +import time +import unittest +from contextlib import redirect_stdout +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "harness")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import docs_check +import quality +from test_harness import TempRepo + +SHORT_VIEW = """\ +import SwiftUI + +struct Row: View { + let title: String + var body: some View { + Text(title) + } +} + +func helper(_ value: Int) -> Int { + value + 1 +} +""" + + +def long_function(lines: int) -> str: + body = "\n".join(f" total += {index}" for index in range(lines)) + return f"func grown() -> Int {{\n var total = 0\n{body}\n return total\n}}\n" + + +def nested_function(depth: int) -> str: + opening = "".join(" " * level + "if flag {\n" for level in range(1, depth)) + closing = "".join(" " * level + "}\n" for level in reversed(range(1, depth))) + return f"func nested(flag: Bool) {{\n{opening}{' ' * depth}print(flag)\n{closing}}}\n" + + +class SwiftParseTest(unittest.TestCase): + def test_counts_functions_inits_and_computed_properties(self): + source = SHORT_VIEW + "final class Box {\n init(value: Int) {\n print(value)\n }\n}\n" + names = [name for name, _line, _body, _depth in quality.swift_functions(source)] + self.assertEqual(names, ["body", "helper", "init"]) + + def test_protocol_requirements_have_no_body(self): + source = "protocol Runner {\n func run() -> Int\n var name: String { get }\n}\nfunc real() {\n}\n" + functions = {name: body for name, _line, body, _depth in quality.swift_functions(source)} + self.assertNotIn("run", functions) + self.assertEqual(functions["real"], 2) + + def test_braces_in_strings_and_comments_are_ignored(self): + source = ( + "func tricky() {\n" + ' let a = "{ not a block {{"\n' + " // } 注释里的括号\n" + " /* { /* 嵌套 } */ } */\n" + ' let b = #"raw } "quoted" {"#\n' + ' let c = """\n multi { line\n """\n' + ' let d = "escaped \\" { quote"\n' + "}\n" + "func after() {\n print(1)\n}\n" + ) + functions = {name: (body, depth) for name, _line, body, depth in quality.swift_functions(source)} + self.assertEqual(functions["tricky"], (10, 1)) + self.assertEqual(functions["after"], (3, 1)) + + def test_nesting_depth_counts_closures_and_control_flow(self): + functions = {name: depth for name, _line, _body, depth in quality.swift_functions(nested_function(8))} + self.assertEqual(functions["nested"], 8) + + +class SwiftRatchetTest(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.root = Path(self._tmp.name) + self.source = self.root / "native" / "Sample.swift" + self.source.parent.mkdir() + self.source.write_text(SHORT_VIEW + long_function(10)) + + def test_lengthened_swift_function_fails_ratchet(self): + baseline = quality.measure_swift(self.root) + self.assertEqual(baseline["swift_long_functions"], 0) + self.source.write_text(SHORT_VIEW + long_function(quality.SWIFT_LONG_FUNCTION)) + current = quality.measure_swift(self.root) + self.assertEqual(current["swift_long_functions"], 1) + regressions = quality.compare(current, baseline) + self.assertEqual(len(regressions), 1) + self.assertIn("swift_long_functions", regressions[0]) + + def test_deeper_nesting_and_longer_file_fail_ratchet(self): + baseline = quality.measure_swift(self.root) + padding = "\n" * quality.SWIFT_LONG_FILE + self.source.write_text(SHORT_VIEW + nested_function(quality.SWIFT_DEEP_NESTING + 1) + padding) + current = quality.measure_swift(self.root) + failed = {line.split(" ")[0] for line in quality.compare(current, baseline)} + self.assertEqual(failed, {"swift_deep_functions", "swift_files_over_500"}) + + def test_shrinking_passes(self): + self.source.write_text(SHORT_VIEW + long_function(quality.SWIFT_LONG_FUNCTION)) + baseline = quality.measure_swift(self.root) + self.source.write_text(SHORT_VIEW) + self.assertEqual(quality.compare(quality.measure_swift(self.root), baseline), []) + + def test_swift_metrics_are_ratcheted(self): + for name in ("swift_files_over_500", "swift_long_functions", "swift_deep_functions"): + self.assertIn(name, quality.RATCHETED) + + +class DocsCheckTest(unittest.TestCase): + def setUp(self): + self.repo = TempRepo() + self.addCleanup(self.repo.close) + self.repo.write("docs/specs/target.md", "# 目标\n") + self.repo.write("scripts/tool.py", "") + + def run_check(self, now=None): + output = io.StringIO() + with redirect_stdout(output): + code = docs_check.main([], root=self.repo.path, now=now) + return code, output.getvalue() + + def test_broken_relative_link_fails(self): + self.repo.write("docs/plans/a.md", "见[规格](../specs/missing.md)与[目标](../specs/target.md)。\n") + self.repo.commit("docs") + code, output = self.run_check() + self.assertEqual(code, 1) + self.assertIn("docs/plans/a.md:1: 断链 ../specs/missing.md", output) + self.assertNotIn("target.md", output) + + def test_reference_and_root_relative_links_are_checked(self): + self.repo.write("README.md", "[坏]: docs/nothing.md\n见 [脚本](/scripts/tool.py),[根坏](/scripts/gone.py)。\n") + self.repo.commit("docs") + code, output = self.run_check() + self.assertEqual(code, 1) + self.assertIn("README.md:1: 断链 docs/nothing.md", output) + self.assertIn("README.md:2: 断链 /scripts/gone.py", output) + self.assertNotIn("/scripts/tool.py", output) + + def test_anchors_external_links_and_code_are_not_reported(self): + self.repo.write( + "AGENTS.md", + "[外链](https://example.com/x.md) [邮件](mailto:a@example.com) [锚点](#小节)\n" + "[带锚点](docs/specs/target.md#一节) [带空格](docs/specs/target.md \"标题\")\n" + "行内代码 `[不是链接](nowhere.md)` 不算。\n" + "```\n[代码块](nowhere.md)\n```\n", + ) + self.repo.commit("docs") + code, output = self.run_check() + self.assertEqual(code, 0, output) + + def test_anchor_on_missing_file_still_fails(self): + self.repo.write("docs/a.md", "[x](gone.md#一节)\n") + self.repo.commit("docs") + code, output = self.run_check() + self.assertEqual(code, 1) + self.assertIn("gone.md#一节", output) + + def test_untracked_and_out_of_scope_markdown_is_ignored(self): + self.repo.write("docs/untracked.md", "[x](gone.md)\n") + self.repo.write("scripts/notes.md", "[x](gone.md)\n") + self.repo.commit("scope", "scripts/notes.md", "docs/specs/target.md") + code, _output = self.run_check() + self.assertEqual(code, 0) + + def test_stale_unfinished_status_is_reported_without_failing(self): + self.repo.write("docs/plans/old.md", "# 旧计划\n\n状态:**待执行**(2026-08-01 定稿)。\n") + self.repo.write("docs/plans/done.md", "# 完成\n\n状态:已完成。后续不再「进行中」。\n") + self.repo.write("docs/plans/list.md", "- 某行里提到状态:进行中,不是文档状态\n") + self.repo.commit("docs") + future = time.time() + 31 * 86400 + code, output = self.run_check(now=future) + self.assertEqual(code, 0) + self.assertIn("docs/plans/old.md:状态「待执行", output) + self.assertNotIn("done.md", output) + self.assertNotIn("list.md", output) + + def test_recent_unfinished_status_is_not_reported(self): + self.repo.write("docs/plans/new.md", "日期:2026-09-28 · 状态:草案\n") + self.repo.commit("docs") + code, output = self.run_check() + self.assertEqual(code, 0) + self.assertIn("状态陈旧:无", output) + self.assertEqual(docs_check.unfinished_status("日期:2026-09-28 · 状态:草案\n"), "草案") + + +if __name__ == "__main__": + unittest.main()