diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d13ddde0..3261493a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -820,3 +820,29 @@ repos: entry: python scripts/check_nested_comment.py language: system types: [c] + + # ─── SKILL.md 体积与 reference/ 指针门禁(第 52 条,2026-10-29 加)───────────────────── + # 为什么要有:dsh-mnemon 插件对技能正文有 65536 字节的**写侧**硬上限(validateSkillContent), + # 但**加载侧不查**——超限的 SKILL.md 照常可读、照常进技能目录,只有一件事会坏:自进化每次 + # 「生成草案」都在落盘那一步被拒,那一行永久卡在「生成中」、界面只剩转圈、连「忽略」都不出现 + # (Draft/Dismiss 只在 pending 渲染)。实测 2026-10-29 一天内两个技能各卡一次(第二个 72,601 + # 字节),每次白烧一个 30 分钟的草案 Agent。**在加这根之前,仓库里没有任何一处会告诉你某个 + # 技能已经废了**——文件能读、能改、能提交,全绿。 + # 判据:> 65536 FAIL(与插件同数、同口径:utf-8 字节数不是字符数);>= 49152(75%)WARN 不拦 + # (越线通常只隔一次草案采纳:实测 49821 → 62806);正文里 (reference/xxx.md) 指向的文件不在 + # → FAIL;带 UTF-8 BOM → FAIL(插件按 utf-8 解析,BOM 会让 frontmatter 读歪)。 + # 全树扫不只看变更文件:钩子拿到 filenames 只当触发用,判定始终扫整个 .dsh/skills/——因为改 + # reference/*.md 时 SKILL.md 没动,但它可能正是被那次外置撑过线/掏空的那一份。 + # 为什么 entry 敢用裸 python:AGENTS.md「钩子 entry 的裸 python 不等于 .venv」那条教训针对的是 + # 第三方依赖(fontTools);本脚本纯标准库,任意 python3 均可(实测 .venv 3.12 与 PATH 上 3.14 + # 都跑通),所以不需要脚本内再自解析解释器。 + # ⚠ 盲区:只管仓库内 .dsh/skills/;用户级 ~/.dsh/skills/ 不在仓库里,本钩子照不到(手工 + # `node "$HOME/.dsh/scripts/mnemon-skill-ledger-doctor.mjs" --root .` 或本脚本 --root 都只报, + # 要拦得另配 shell 钩子)。只认 markdown 链接形态的指针,纯文本里写的路径不算。 + # 自证:--selftest 五形态(干净 0 / 75% 线 0 带 WARN / 超上限 1 / 悬空指针+BOM 1 / 根不存在 2)。 + # 详解与限制 → docs/门禁说明/skill-size.md。 + - id: skill-size + name: "SKILL.md 不超插件写侧上限 65536 且 reference/ 指针不悬空" + entry: python scripts/check_skill_size.py + language: system + files: ^\.dsh/skills/ diff --git a/AGENTS.md b/AGENTS.md index f6ec8d7e..e37f9f11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,7 @@ - 怀疑乱码先统计 `EF BF BD` 字节数(勿信终端/Read 渲染);**不要急著 `git checkout`**(会丢弃全部未提交工作)——用 Edit 只修坏行 - 行尾由 `.gitattributes` 写死(`* text=auto eol=lf`;`*.bat`/`*.cmd` 强制 CRLF):**不依赖 `core.autocrlf`、不要把工作区文件改回 CRLF** → 权威口径 `docs/文档规范.md` §2 - 代码规范 V1.0:小写下划线命名(`g_`/`s_`/`p_`/`a_` 前缀)、头文件保护宏 `XXX_H`(禁 `__` 前缀)、常量在右、文件头模块注释块 → `docs/代码规范/` +- **手改 `.dsh/skills/**/SKILL.md` 有 65536 字节写侧硬上限**(dsh-mnemon 插件 `validateSkillContent()` 只在写侧校验,**加载侧不查**):超限的技能照常 `skill()` 可读、照常进技能目录,但自进化每次「生成草案」都在落盘那一步被拒 → 建议永久卡在「生成中」、界面只剩转圈、连「忽略」都点不动(实测 2026-10-29 两个技能同日各卡一次,第二次的文件 72,601 字节)。加内容前先看体积:`node "$HOME/.dsh/scripts/mnemon-skill-ledger-doctor.mjs" --root .`(只报不改);正文控制在 ~48 KB(≥75% doctor 就报 CLOSE)。**新增的深规程写进同目录 `reference/<篇名>.md`,正文只留可执行判据的摘要 + 指针,且不重排既有步骤号**。已卡死的取证与自愈 → **`mnemon-skill-ledger-stuck-repair` skill**。**已机械化:`skill-size` 钩子**(`scripts/check_skill_size.py`,纯标准库;`--selftest` 五形态、`--root` 可指到用户级 `~/.dsh/skills` 手工量) ## 构建与验证 @@ -92,7 +93,7 @@ ## 提交门禁(pre-commit) -- 任一失败即拦截,共 **51 个钩子**(**口径 = YAML 解析后的 hook 条目数**:自查命令与「声明 / 文档 / 实跑」三数同源核对见 `docs/门禁说明/README.md` §2 首条——⚠ 直接 grep 配置里的声明行会多算一条**注释掉的**候选钩子(= 52),不是口径;**配置注释里也别复述那个 grep 字面量**,它会让文本计数再虚高 1(2026-10-27 实测踩过);加一条钩子就同批改 AGENTS 与门禁索引两处计数。2026-10-25 道E 连加 `bom-guard` / `setview-check` / `uinav-selftest` 三条,2026-10-26 加 `generated-fresh` 一条,2026-10-27 加 `nested-comment` 一条;2026-10-28 加 `id-check-new` 一条并同批退役 `id-check`(口径 52 → 51:编号门禁换牙——新的只查新增行、零语料,旧的八项全库校验连语料一起移入 `scripts/archive/id_gate/`,路线见 `docs/编号规范.md` §8.5);**每种门禁一篇详解(查什么 / 拦下过什么真事 / 限制与盲区 / 基线口径)→ `docs/门禁说明/README.md` 索引**;通用机制与整体口径 → `docs/代码规范/pre-commit使用说明.md`):通用 5 + `gitlint` + `cppcheck` / `test-cppcheck` + `no-mojibake` + `coding-standard` + 文档三件套 `doc-links` / `task-table` / `mem-numbers` + `id-check-new`(**编号门禁**:只查 `git diff` 新增行里有没有未登记形态的「字母+数字」标识,零语料、零维护;白名单是前缀级两张表,见 `docs/门禁说明/id-check-new.md`。旧的八项全库校验 `id-check` 已于 2026-10-28 退役,脚本与两份语料移入 `scripts/archive/id_gate/`,只在手工复查存量时才跑;退役路线见 `docs/编号规范.md` §8.5)+ `lv-conf-sync`(两份 `lv_conf.h` 特性子集一致)+ `cubemx-survival` + `bom-guard` / `setview-check` / `uinav-selftest`(2026-10-25 道E 那三条:前半句的加钩流水里有它们、逐项枚举里漏了,补上后本行各项相加才等于 51)+ 主机侧单测 **22 根专属钩子**(`-unit-test` 后缀 = 有独立 `files:` 触发面、需要秒级归因的那个子集;2026-10-25 增补 adc-cal / prot-dim / vol-idle-sync / ui-data / meas / run-par 六根)——**这是口径不是缺口**:`test/host/` 实测 41 套带 `run_unit_test.py`,其余 **19 套**(含 `test/host/ui_sink`)没有专属钩子,但每次 CI / `scripts/ci_local.py` 都被 `test-coverage` 总闸全量真跑并守基线回退,**失败照样拦**,只是归因落在总闸输出上;**没有专属钩子 ≠ 没被跑**,19 套台账与补钩子的判据 → `docs/门禁说明/host-unit-tests.md` §6 第 4 条。**Lead 裁定 2026-10-27:不补第 23 根**——读「这套跑没跑」去看总闸输出,别数钩子(原写「23 个 / 含 ui-sink 七个」是双重错:既把 22 说成 23,又宣称 ui-sink 有钩子——配置历史里 `ui-sink-unit-test` 从未存在)+ `test-coverage`(**覆盖率总闸**:跑全部 41 套并守基线回退;缺 mscoverage 组件时按用户裁定 **FAIL**、不 skip,2026-10-24)+ `test-suite-switch` + `registration-check`(双端注册反向校验 + UI 页面描述符表接线,登记簿条目「rs485_task 白名单缺口」)+ `generated-fresh`(**生成器幂等性**:把 `tools/menu_gen/gen_menu.py`、中文字库链、`tools/arch_diagram/gen_*.py` 拷进**系统临时目录**重跑,与工作区**逐字节**必须一致——钩子只读、不写仓库也不 stash 别人在飞的改动;实证 `0f7b5a87` 改了模板文字没重跑生成物,接线前实测另有 5 张 `docs/images/` 配图已漂 → 钉成只减不增基线,**业务生成物零豁免面** → `docs/门禁说明/generated-fresh.md`)+ `nested-comment`(**块注释嵌套**:注释体内再出现 /* 会被 ARMCC 报 9-D,且**按包含者数量刷屏**、只在重编译该 TU 时才暴露;本仓诱因就是「在注释里写作用域/通配路径」,改法是写全目录或用尖括号 ;接线当日实扫 403 个 .c/.h,新发 0、存量 2 处(`test/host/common/` 两份假件头)钉只减不增基线 → `docs/门禁说明/nested-comment.md`)+ `mem-budget`(RAM 92% 预算门禁,登记簿条目「缩堆迁 CCM 后半(暂缓)」)+ `font-charset`(UI 字符集差集检查,防缺字白框)+ `ui-golden`(UI 像素回归,菜单 8 状态 + 内容页 60 张黄金参考)+ `layer-deps`(**分层方向**:`Drivers/BSP/**` 不得 include `App/**` 的头;存量 3 条基线只减不增(2026-10-25 记录 reader 注入削掉 UI 记录 3 条边,6→3))+ `bsp-err`(**BSP 错误码与超时命名约定**:公开 API 不得拿 `HAL_StatusTypeDef`/`bool` 当错误通道、`*_TIMEOUT` 宏不得取负值、非 void 须写「返回值:」、**返回错误码的函数不得裸丢弃返回值**(须接住或显式 `(void)`;实测编译器与 cppcheck 都不报这类,只有这条会响);约定正文 `Drivers/BSP/bsp_err.h`,存量基线只减不增(2026-10-23 首清点 19 条 → 已还 18;`consume-result` 上线又暴露 37 条存量丢弃 → **现 38 条**))。**提交期实跑 48 根**(2026-10-28 用户裁定):`cppcheck` / `test-cppcheck` / `test-coverage` 三根带 `stages: [manual]`——实测合计 ~165 s = 一次带代码提交耗时的三分之二,提交期只跑快的那些;这三根由**远端 CI** 强制(`.gitea/workflows/ci.yml` 的「手动阶段门禁」步,逐根 `--hook-stage manual`)与 `scripts/ci_local.py` 的 manual 段,本地手工复跑 `python -m pre_commit run --all-files --hook-stage manual `(一次只能一个 id) +- 任一失败即拦截,共 **52 个钩子**(**口径 = YAML 解析后的 hook 条目数**:自查命令与「声明 / 文档 / 实跑」三数同源核对见 `docs/门禁说明/README.md` §2 首条——⚠ 直接 grep 配置里的声明行会多算一条**注释掉的**候选钩子(比口径多 1),不是口径;**配置注释里也别复述那个 grep 字面量**,它会让文本计数再虚高 1(2026-10-27 实测踩过);加一条钩子就同批改 AGENTS 与门禁索引两处计数。2026-10-25 道E 连加 `bom-guard` / `setview-check` / `uinav-selftest` 三条,2026-10-26 加 `generated-fresh` 一条,2026-10-27 加 `nested-comment` 一条;2026-10-28 加 `id-check-new` 一条并同批退役 `id-check`(口径 52 → 51:编号门禁换牙——新的只查新增行、零语料,旧的八项全库校验连语料一起移入 `scripts/archive/id_gate/`,路线见 `docs/编号规范.md` §8.5);**每种门禁一篇详解(查什么 / 拦下过什么真事 / 限制与盲区 / 基线口径)→ `docs/门禁说明/README.md` 索引**;通用机制与整体口径 → `docs/代码规范/pre-commit使用说明.md`):通用 5 + `gitlint` + `cppcheck` / `test-cppcheck` + `no-mojibake` + `coding-standard` + 文档三件套 `doc-links` / `task-table` / `mem-numbers` + `id-check-new`(**编号门禁**:只查 `git diff` 新增行里有没有未登记形态的「字母+数字」标识,零语料、零维护;白名单是前缀级两张表,见 `docs/门禁说明/id-check-new.md`。旧的八项全库校验 `id-check` 已于 2026-10-28 退役,脚本与两份语料移入 `scripts/archive/id_gate/`,只在手工复查存量时才跑;退役路线见 `docs/编号规范.md` §8.5)+ `lv-conf-sync`(两份 `lv_conf.h` 特性子集一致)+ `cubemx-survival` + `bom-guard` / `setview-check` / `uinav-selftest`(2026-10-25 道E 那三条:前半句的加钩流水里有它们、逐项枚举里漏了,补上后本行各项相加才等于 52)+ 主机侧单测 **22 根专属钩子**(`-unit-test` 后缀 = 有独立 `files:` 触发面、需要秒级归因的那个子集;2026-10-25 增补 adc-cal / prot-dim / vol-idle-sync / ui-data / meas / run-par 六根)——**这是口径不是缺口**:`test/host/` 实测 41 套带 `run_unit_test.py`,其余 **19 套**(含 `test/host/ui_sink`)没有专属钩子,但每次 CI / `scripts/ci_local.py` 都被 `test-coverage` 总闸全量真跑并守基线回退,**失败照样拦**,只是归因落在总闸输出上;**没有专属钩子 ≠ 没被跑**,19 套台账与补钩子的判据 → `docs/门禁说明/host-unit-tests.md` §6 第 4 条。**Lead 裁定 2026-10-27:不补第 23 根**——读「这套跑没跑」去看总闸输出,别数钩子(原写「23 个 / 含 ui-sink 七个」是双重错:既把 22 说成 23,又宣称 ui-sink 有钩子——配置历史里 `ui-sink-unit-test` 从未存在)+ `test-coverage`(**覆盖率总闸**:跑全部 41 套并守基线回退;缺 mscoverage 组件时按用户裁定 **FAIL**、不 skip,2026-10-24)+ `test-suite-switch` + `registration-check`(双端注册反向校验 + UI 页面描述符表接线,登记簿条目「rs485_task 白名单缺口」)+ `generated-fresh`(**生成器幂等性**:把 `tools/menu_gen/gen_menu.py`、中文字库链、`tools/arch_diagram/gen_*.py` 拷进**系统临时目录**重跑,与工作区**逐字节**必须一致——钩子只读、不写仓库也不 stash 别人在飞的改动;实证 `0f7b5a87` 改了模板文字没重跑生成物,接线前实测另有 5 张 `docs/images/` 配图已漂 → 钉成只减不增基线,**业务生成物零豁免面** → `docs/门禁说明/generated-fresh.md`)+ `nested-comment`(**块注释嵌套**:注释体内再出现 /* 会被 ARMCC 报 9-D,且**按包含者数量刷屏**、只在重编译该 TU 时才暴露;本仓诱因就是「在注释里写作用域/通配路径」,改法是写全目录或用尖括号 ;接线当日实扫 403 个 .c/.h,新发 0、存量 2 处(`test/host/common/` 两份假件头)钉只减不增基线 → `docs/门禁说明/nested-comment.md`)+ `skill-size`(**SKILL.md 不超插件写侧上限 65536 字节**:dsh-mnemon 只在写侧校验、加载侧不查,超限技能照常可读却每次「生成草案」被拒、永久卡在「生成中」;另查 `reference/` 指针悬空与 BOM → `docs/门禁说明/skill-size.md`)+ `mem-budget`(RAM 92% 预算门禁,登记簿条目「缩堆迁 CCM 后半(暂缓)」)+ `font-charset`(UI 字符集差集检查,防缺字白框)+ `ui-golden`(UI 像素回归,菜单 8 状态 + 内容页 60 张黄金参考)+ `layer-deps`(**分层方向**:`Drivers/BSP/**` 不得 include `App/**` 的头;存量 3 条基线只减不增(2026-10-25 记录 reader 注入削掉 UI 记录 3 条边,6→3))+ `bsp-err`(**BSP 错误码与超时命名约定**:公开 API 不得拿 `HAL_StatusTypeDef`/`bool` 当错误通道、`*_TIMEOUT` 宏不得取负值、非 void 须写「返回值:」、**返回错误码的函数不得裸丢弃返回值**(须接住或显式 `(void)`;实测编译器与 cppcheck 都不报这类,只有这条会响);约定正文 `Drivers/BSP/bsp_err.h`,存量基线只减不增(2026-10-23 首清点 19 条 → 已还 18;`consume-result` 上线又暴露 37 条存量丢弃 → **现 38 条**))。**提交期实跑 49 根**(2026-10-28 用户裁定):`cppcheck` / `test-cppcheck` / `test-coverage` 三根带 `stages: [manual]`——实测合计 ~165 s = 一次带代码提交耗时的三分之二,提交期只跑快的那些;这三根由**远端 CI** 强制(`.gitea/workflows/ci.yml` 的「手动阶段门禁」步,逐根 `--hook-stage manual`)与 `scripts/ci_local.py` 的 manual 段,本地手工复跑 `python -m pre_commit run --all-files --hook-stage manual `(一次只能一个 id) - **范围 = 项目代码**:第三方(`Lib/`、`Middlewares/`、CMSIS、HAL、`lftpd`、`dhara`、`MDK-ARM`)排除;`cppcheck`/`coding-standard` 另排除 `test/`,规范检查再排除 CubeMX 生成的 `Src/`/`Inc/`。**`simulator/` 已纳入全部钩子**(2026-10-11)——生成物只靠 `.gitignore` 挡,**不在 `exclude` 里写目录名**(教训见 `docs/代码规范/pre-commit使用说明.md` §5.5) - ⚠ **pre-commit 4.x 已移除 `always: true`**(实测对 `local` 钩子只告警 `Unexpected key(s)` 并跳过)→ 想"总是跑"的钩子**必须让 `files:` 覆盖到所有会触发它的文件**,没有兜底开关 - **改写型钩子(`trailing-whitespace` + `end-of-file-fixer`)豁免 CubeMX/Keil 生成文件**(配置里 YAML 锚点 `&cubemx-generated`);⚠ **只读检查不受影响**:`cppcheck` / 乱码 / `cubemx-survival` / `task-table` / `mem-numbers` 仍覆盖 `Src/`、`Inc/` @@ -175,7 +176,7 @@ | ADC 采样节拍 / 通道映射 / V0.1 硬件缺陷 | `docs/子系统设计/ADC采样节拍设计说明.md`、`docs/硬件平台/ADC通道映射说明.md` | | 存储栈(FatFs / dhara / lftpd / 两条 FatFs 陷阱) | `docs/架构设计/STM32F4-Base存储架构说明.md` | | RAM / 栈预算 / 容量台账 | `docs/内存管理/RAM使用分析与优化方案.md`、`内存内容分配策略.md` | -| **每个提交门禁一篇详解**(查什么 / 拦下过什么真事 / 限制与盲区 / 基线口径) | `docs/门禁说明/README.md`(51 个钩子 → 28 篇索引,其中 1 篇是退役留档 + 1 指针) | +| **每个提交门禁一篇详解**(查什么 / 拦下过什么真事 / 限制与盲区 / 基线口径) | `docs/门禁说明/README.md`(52 个钩子 → 29 篇索引,其中 1 篇是退役留档 + 1 指针) | | **要不要给某个东西发编号**(默认:不发,写语义短语)/ 允许用号的 7 个族(`TC-*` 已停发——「停发」不等于「暂缓」,也不等于「可以清号」)/ 编号门禁现状(`id-check-new`:只查新增行;旧的八项校验已退役) | `docs/编号规范.md`(§1 先问三句、§4.1 族总表〔穷举〕与『已停发』栏、§8 门禁现状与退役路线) | | 提交门禁通用机制(阶段 / stash 陷阱 / 触发面 / 增量拦截三段式) | `docs/代码规范/pre-commit使用说明.md` §2 | | 代码规范 V1.0 / cppcheck 口径 | `docs/代码规范/嵌入式C语言代码规范_V1.0.md`、`cppcheck使用说明.md` | diff --git a/docs/CI/远程CI完整指南.md b/docs/CI/远程CI完整指南.md index ed934749..b3b621a0 100644 --- a/docs/CI/远程CI完整指南.md +++ b/docs/CI/远程CI完整指南.md @@ -1059,7 +1059,7 @@ date +%s # 对比 last_online | Runners 页 runner 显示 **`offline`** | daemon 进程不在:`gitea-runner` v4 的 `daemon` **首次 Declare 失败就直接退出**(Gitea 重启/机器重启后无人拉起) | §2.4 用 `ci_runner_bootstrap.ps1` 装看门狗 + 计划任务;`-Status` 复查 | | `static-gates` 报 `no Python with a SYSTEM-visible pre-commit` | runner 是 SYSTEM:PATH 上的 python 无 pre-commit、用户级 site 不可见、`.venv` 未入库(§4.1 三重坑) | `ci_runner_bootstrap.ps1 -SetEnv` 写机器级 `DTU_PRECOMMIT_PYTHON`,或把 pre-commit 装进机器级解释器;**改完必须重启 runner** | | `static-gates` 报 `runner missing cppcheck` | 机器 PATH 无 Cppcheck 且无机器级 `DTU_CPPCHECK` | `-SetEnv`(会自动发现 `C:\Program Files\Cppcheck`)或装 `Cppcheck.Cppcheck` | -| **钩子条数对不上**(CI 的 `--all-files` 报告条数 = 定义数 − 1 − 3 manual:定义数 **51** → 报告 **47** 行,另由「手动阶段门禁」步跑 3 根;2026-10-12 **当时**实测 `master` 18 定义 / 17 报告,`v2-on-ui` 26 定义 / 25 报告——**增删钩子的分支必须重算这个差值**) | 两处都不进 `--all-files` 报告:`gitlint` 的 `stages: [commit-msg]` 写在**上游** `.pre-commit-hooks.yaml`(项目 `.pre-commit-config.yaml` 里 grep 不到,别据此断定"没限制");`cppcheck` / `test-cppcheck` / `test-coverage` 带 `stages: [manual]`,只由「手动阶段门禁」步逐根 `--hook-stage manual` 跑。都会表现为全绿——静默空转 | commit-msg 阶段已由 `scripts/ci_gitlint_range.ps1` 单独校验、manual 三根已由「手动阶段门禁」步兜住;**以后增删钩子必须核对"定义数 == 日志条数 + gitlint + manual 三根"** | +| **钩子条数对不上**(CI 的 `--all-files` 报告条数 = 定义数 − 1 − 3 manual:定义数 **51** → 报告 **47** 行,另由「手动阶段门禁」步跑 3 根(2026-10-29 起声明数 52 → 报告 48 行,公式不变);2026-10-12 **当时**实测 `master` 18 定义 / 17 报告,`v2-on-ui` 26 定义 / 25 报告——**增删钩子的分支必须重算这个差值**) | 两处都不进 `--all-files` 报告:`gitlint` 的 `stages: [commit-msg]` 写在**上游** `.pre-commit-hooks.yaml`(项目 `.pre-commit-config.yaml` 里 grep 不到,别据此断定"没限制");`cppcheck` / `test-cppcheck` / `test-coverage` 带 `stages: [manual]`,只由「手动阶段门禁」步逐根 `--hook-stage manual` 跑。都会表现为全绿——静默空转 | commit-msg 阶段已由 `scripts/ci_gitlint_range.ps1` 单独校验、manual 三根已由「手动阶段门禁」步兜住;**以后增删钩子必须核对"定义数 == 日志条数 + gitlint + manual 三根"** | | gitlint 步骤 **Failed 但提交信息其实合规**(同一条命令在本地全绿),日志里是 `exit code: 254` + `Missing git configuration: please set user.name` | `actions/checkout` 把 HOME 临时指向每次任务的临时目录,而 SYSTEM 账号没有全局 git 身份 → gitlint **在读到任何规则之前**就因配置错误退出(**254 不是规则违规**)。旧脚本末尾那句 `::error::...rule: single-line Chinese title, < 100 chars` 是**写死的猜测文案**,把环境问题伪装成"提交信息不合格";且该步 `exit 1` 会让后面的 `--all-files` **整个不跑**——看着像"门禁拦住了",实际当时 26 个钩子一个都没执行 | `ci_gitlint_range.ps1` 先探测身份,缺失就用 `GIT_CONFIG_COUNT/KEY/VALUE` 注入一次性身份(**不写任何文件**,开发者机器不受影响),并把 254 与真实违规分开报(**2 = 门禁没跑起来**,1 = 信息确实违规)。实测同一命令:空 HOME 下改前 254、改后 `0 failed`(2026-10-12 task 31 首次暴露) | | push 后 Actions 页**空白/无任务** | ① 服务端开关未开 ② workflow 没 push 到远程 ③ 只改了 `docs/**`(被 `paths-ignore` 滤掉) ④ **新建分支的首次 push**(实测 2026-10-12:创建 `v2-on-ui-ci` 那次 push 没产生 run——runner 在线空闲、job 日志零新增;紧接着一次普通 push 立刻出 task。原因:`before` 是全零 SHA,变更文件集算不出来,`paths-ignore` 求值时被滤空) | ①②③ 逐条核对 §1 / §5.1 / §5.2;④ 用 `workflow_dispatch` 手动触发,或再推一个非文档路径的提交 | | 任务一直 **Waiting / 排队** | 没有 label 匹配 `windows` 的 runner | 检查 `--labels "windows:host"`;Settings→Runners 是否 Idle | diff --git a/docs/代码规范/cppcheck使用说明.md b/docs/代码规范/cppcheck使用说明.md index 636fcf87..ecd1ecaf 100644 --- a/docs/代码规范/cppcheck使用说明.md +++ b/docs/代码规范/cppcheck使用说明.md @@ -15,7 +15,7 @@ cppcheck 是开源 C/C++ 静态分析工具,**不运行代码**,通过语法 ### 日常速度 -- **提交期**:不跑(`stages: [manual]`),一次带代码的 `git commit` 只跑 48 根快钩子 +- **提交期**:不跑(`stages: [manual]`),一次带代码的 `git commit` 只跑 49 根快钩子 - **全库扫描**(约 20 s;2026-10-28 实测 `cppcheck` 20.5 s、`test-cppcheck` 38.1 s):手工复跑 `& '.venv\Scripts\python.exe' -m pre_commit run --all-files --hook-stage manual `——**一次只能给一个 id**(`pre_commit run a b c` 实测报 `unrecognized arguments`),故 `cppcheck` / `test-cppcheck` 各跑一次 --- diff --git a/docs/代码规范/pre-commit使用说明.md b/docs/代码规范/pre-commit使用说明.md index 1ec7e8fd..eaac9609 100644 --- a/docs/代码规范/pre-commit使用说明.md +++ b/docs/代码规范/pre-commit使用说明.md @@ -93,7 +93,7 @@ git commit | 机制 | 口径 | |---|---| -| **三个阶段** | pre-commit 阶段 **47** 个钩子 + commit-msg 阶段 `gitlint`(= 一次 `git commit` 实跑 **48**)+ `manual` 阶段 3 根(`cppcheck` / `test-cppcheck` / `test-coverage`:`git commit` 不跑,只在远端 CI 的「手动阶段门禁」步与 `scripts/ci_local.py` 的 manual 段跑;三根实测合计约 165 s ≈ 一次带代码提交耗时的三分之二)→ 口径 = YAML 解析后的 hook 条目数 **51**;自查命令与「声明 / 文档 / 实跑」三数同源核对 → `docs/门禁说明/README.md` §2;`default_install_hook_types: [pre-commit, commit-msg]` 是显式声明的——换机后 `pre-commit install` 才会带上 commit-msg 钩子 | +| **三个阶段** | pre-commit 阶段 **48** 个钩子 + commit-msg 阶段 `gitlint`(= 一次 `git commit` 实跑 **49**)+ `manual` 阶段 3 根(`cppcheck` / `test-cppcheck` / `test-coverage`:`git commit` 不跑,只在远端 CI 的「手动阶段门禁」步与 `scripts/ci_local.py` 的 manual 段跑;三根实测合计约 165 s ≈ 一次带代码提交耗时的三分之二)→ 口径 = YAML 解析后的 hook 条目数 **51**;自查命令与「声明 / 文档 / 实跑」三数同源核对 → `docs/门禁说明/README.md` §2;`default_install_hook_types: [pre-commit, commit-msg]` 是显式声明的——换机后 `pre-commit install` 才会带上 commit-msg 钩子 | | **一根钩子在输出里出现两次** | 未声明 `stages:` 的钩子**默认属于所有阶段**,pre-commit 与 commit-msg 两遍都会列出它;第二遍的"文件"是提交信息文件,几乎必然 `(no files to check)Skipped`。**只看输出结尾会把"实跑通过"读成"没跑"**——判某钩子跑没跑要认它**第一次**出现的那行(实测 2026-10-24:只改 `.pre-commit-config.yaml` 的提交里 `test-coverage` 与 `bsp-err` 都是 Passed,尾巴那一遍是 Skipped) | | **stash 陷阱** | pre-commit 跑钩子前会 stash 未暂存改动,钩子看到的是"没有你中间状态"的树。**改代码 + 同时更新基线/受管区的,必须先 `git add` 再跑钩子**,否则会拿到假报警(本仓踩过,`docs/门禁说明/bsp-err.md` §4 记了现场)。**它藏不住什么,见下一行** | | **stash 覆盖面:只覆盖已跟踪文件的未暂存改动**(2026-10-26 读源码定案,**对所有钩子通用**) | 机制在 pre-commit 的 `staged_files_only._unstaged_changes_cleared()`:`git diff-index ` 取补丁 → `git checkout -- .` → 跑完 `git apply` 还原。所以「钩子实跑 = 只看到 HEAD + 本笔 staged」这句**对已跟踪文件成立、对未跟踪文件不成立**:未跟踪文件压根不在 `diff-index` 里,**不会被藏**;`git add -N`(intent-to-add)也救不了——pre-commit 只 `git rm --cached` 把它撤下索引(`_intent_to_add_cleared()`),**文件本体仍在盘上**。后果:别人写了尚未入库的文件(新页面实例 / 新单测目录 / 新文档)躺在树里时,**任何一笔碰到该文件所属钩子 `files:` 的提交都会被这份半成品拖红**——红的内容没错,但账不该记在这笔提交上。实测:`ui_page_clock.c`、`ui_page_reset.c` 处于未跟踪且未绑 `#page` 期间,`generated-fresh` 对**所有人**的 UI 提交都会 FAIL(现场日志 `docs/门禁说明/generated-fresh.md` §6 第 8 条)。三条结论:① 提交排序——会让全树变红的半成品要么先落地(绑引用 + 重跑生成器)要么先挪出工作树;② 凡「判定基准取工作区」的钩子(重跑比对类、全库统计类)在多人同树下必须配**静默窗口**;③ 真要 `--no-verify` 抢跑,正文必须点名是哪几个**未跟踪**文件拖的红,别让它被读成「钩子不可信」 | @@ -332,7 +332,7 @@ $env:PATH = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Env | 文件 | 位置 | 说明 | |---|---|---| -| `.pre-commit-config.yaml` | 仓库根 | 钩子配置(**51 个钩子**声明数,一次 `git commit` 实跑 48 根;口径 = YAML 解析后的 hook 条目数,自查命令 → `docs/门禁说明/README.md` §2。声明数与一次提交的实跑数分列,勿混写;计数改动的完整台账只记在 AGENTS.md 提交门禁节与本目录 README §2,本行不重述)。逐项详解在 `docs/门禁说明/`(索引 `docs/门禁说明/README.md`;主机侧单测 **22 根**合一篇,含各套件来历与「19 套无专属钩子」台账 `docs/门禁说明/host-unit-tests.md` §6;UI 侧另有 `font-charset` 与 `ui-golden` 两篇) | +| `.pre-commit-config.yaml` | 仓库根 | 钩子配置(**52 个钩子**声明数,一次 `git commit` 实跑 49 根;口径 = YAML 解析后的 hook 条目数,自查命令 → `docs/门禁说明/README.md` §2。声明数与一次提交的实跑数分列,勿混写;计数改动的完整台账只记在 AGENTS.md 提交门禁节与本目录 README §2,本行不重述)。逐项详解在 `docs/门禁说明/`(索引 `docs/门禁说明/README.md`;主机侧单测 **22 根**合一篇,含各套件来历与「19 套无专属钩子」台账 `docs/门禁说明/host-unit-tests.md` §6;UI 侧另有 `font-charset` 与 `ui-golden` 两篇) | | `.gitlint` | 仓库根 | gitlint 规则(宽松,兼容中文) | | `.cubemx_survival_baseline.json` | 仓库根 | CubeMX 易失定制基线(哈希;`--write` 重建) | | `scripts/check_mojibake.py` | scripts/ | U+FFFD 检测脚本 | diff --git a/docs/测试说明/主机单测覆盖率口径.md b/docs/测试说明/主机单测覆盖率口径.md index 9b0c3d6c..d790043b 100644 --- a/docs/测试说明/主机单测覆盖率口径.md +++ b/docs/测试说明/主机单测覆盖率口径.md @@ -174,7 +174,7 @@ **已落地**:脚本、基线台账、口径(本文);`test-coverage` 已并进 `scripts/ci_local.py` 的 manual 段(`--coverage` 旗标保留只为兼容旧命令)。 **接线状态:已挂**(2026-10-24,`test-coverage` 落在 `.pre-commit-config.yaml` 第 8 节,用户裁定 **FAIL** 语义)。 -它带 `stages: [manual]`——**`git commit` 阶段不跑**(一次带代码的提交只跑 48 根快钩子),由远端 CI 的 +它带 `stages: [manual]`——**`git commit` 阶段不跑**(一次带代码的提交只跑 49 根快钩子),由远端 CI 的 「手动阶段门禁(覆盖率总闸 + 全量 cppcheck)」步与 `scripts/ci_local.py` 的 manual 段承担;手工复跑 `& '.venv\Scripts\python.exe' -m pre_commit run --all-files --hook-stage manual test-coverage`。 diff --git a/docs/环境与部署/并行任务工作树方案.md b/docs/环境与部署/并行任务工作树方案.md index 1f451c4e..66c3beb0 100644 --- a/docs/环境与部署/并行任务工作树方案.md +++ b/docs/环境与部署/并行任务工作树方案.md @@ -245,7 +245,7 @@ D:/Code/DTU Code/ | 道 | 资源 | 并行性 | 锁 / 机制 | |---|---|---|---| | **台架道** | COM6 / ST-LINK / 设备(§7.1) | **串行**——约束资源,**永不空转**(约束优先,TOC):代码道在跑时必须为下一个台架任务备料 | `bench.lock` | -| **代码道** | CPU / 源码 | **并行**——worktree 独立空间;前提 = 写范围互不相交 + 强门禁(`ci_local` 跑满声明的 51 根、含 manual 段,口径 → `docs/门禁说明/README.md` §2;同 Linux 内核子系统树并行而能合回的逻辑,见 `docs.kernel.org/process/2.Process.html` 的 subsystem tree 模型) | 无锁;分支 ≤2 天、绿即合(trunk-based 短分支) | +| **代码道** | CPU / 源码 | **并行**——worktree 独立空间;前提 = 写范围互不相交 + 强门禁(`ci_local` 跑满声明的 52 根、含 manual 段,口径 → `docs/门禁说明/README.md` §2;同 Linux 内核子系统树并行而能合回的逻辑,见 `docs.kernel.org/process/2.Process.html` 的 subsystem tree 模型) | 无锁;分支 ≤2 天、绿即合(trunk-based 短分支) | | **外部窗口** | 标准源 / 第三方主站等外部设备 | **Fixed Date 类**(Kanban 服务分类):窗口一到全道让路;窗口前 2 日特性冻结(只修稳定,Zephyr RC 语义 → `docs.zephyrproject.org/latest/project/release_process.html`) | `bench.lock` + 外部预约 | **四条规则**: diff --git a/docs/环境与部署/项目部署环境要求.md b/docs/环境与部署/项目部署环境要求.md index dff605d1..df00b2ee 100644 --- a/docs/环境与部署/项目部署环境要求.md +++ b/docs/环境与部署/项目部署环境要求.md @@ -198,7 +198,7 @@ python tools/font_gen/extract_cn.py # 任意 python3: | 项 | 要求 | 说明 | |---|---|---| -| pre-commit | 已安装并 `pre-commit install` | 提交门禁(`pre-commit` 阶段 47 根 + commit-msg 阶段 `gitlint`,一次提交实跑 48 根) | +| pre-commit | 已安装并 `pre-commit install` | 提交门禁(`pre-commit` 阶段 48 根 + commit-msg 阶段 `gitlint`,一次提交实跑 49 根) | | cppcheck | 在 PATH 中 | 钩子直接调 `cppcheck --enable=warning,performance,portability,style`(`language: system`)。⚠ `cppcheck` / `test-cppcheck` / `test-coverage` 三根带 `stages: [manual]`:`git commit` 不跑,由远端 CI 的「手动阶段门禁」步与 `scripts/ci_local.py` 的 manual 段跑(CI runner 缺 cppcheck 则该步失败) | | python | 在 PATH 中 | 本地钩子调 `scripts/check_mojibake.py`(U+FFFD 乱码)、`scripts/check_coding_standard.py`(规范 V1.0,纯标准库) | | **MSVC `cl.exe`** | **装了 VS 即有(§2.3)** | **`watchdog-unit-test` 钩子必需**:改 `App/util/task_watchdog.*` 或 `test/host/watchdog/**` 时触发,用 `cl.exe` 离线编译真实源码跑单测。**未装 VS 时该钩子报环境错误(退出码 2)→ 拦截提交** | diff --git a/docs/门禁说明/README.md b/docs/门禁说明/README.md index fb768419..581fa84e 100644 --- a/docs/门禁说明/README.md +++ b/docs/门禁说明/README.md @@ -1,7 +1,7 @@ # 门禁逐项说明(每种门禁一篇) -> 本目录逐项说明各门禁:**声明的 51 个钩子到底在干什么、拦谁、放谁、什么时候会骗人**——其中 3 根慢钩子带 -> `stages: [manual]`,一次 `git commit` 只实跑 48 根(口径见下条)。 +> 本目录逐项说明各门禁:**声明的 52 个钩子到底在干什么、拦谁、放谁、什么时候会骗人**——其中 3 根慢钩子带 +> `stages: [manual]`,一次 `git commit` 只实跑 49 根(口径见下条)。 > 每个门禁一篇,八段固定结构:查什么 / 为什么要它 / 触发面 / 报错怎么读 / 好处 / > **限制与盲区** / 豁免与基线 / 改门禁本身要注意什么。**最后一节是这套文档存在的理由**—— > 一份只看"拦了什么"的门禁文档,会让人以为绿就是对的。 @@ -9,18 +9,18 @@ - 配置事实源:`.pre-commit-config.yaml`。**钩子数的唯一口径 = YAML 解析后的 hook 条目数**,不是文件里的文本行数。 自查(打印声明数;加/删一条钩子后,这个数要与 **AGENTS.md 的「共 N 个钩子」**、**本目录 §2 标题**同时对齐): `& '.venv\Scripts\python.exe' -c "import yaml; d=yaml.safe_load(open('.pre-commit-config.yaml', encoding='utf-8')); print(sum(len(r['hooks']) for r in d['repos']))"` - → **51**(2026-10-26:49 → 50 加 `generated-fresh`;2026-10-27:50 → 51 加 `nested-comment`; - 2026-10-28:51 → 52 加 `id-check-new`、随即 52 → 51 摘掉 `id-check`(退役全库语料审计))。 + → **52**(2026-10-26:49 → 50 加 `generated-fresh`;2026-10-27:50 → 51 加 `nested-comment`; + 2026-10-28:51 → 52 加 `id-check-new`、随即 52 → 51 摘掉 `id-check`(退役全库语料审计);2026-10-29:51 → 52 加 `skill-size`(SKILL.md 写侧上限与 reference 指针,详见 [`skill-size.md`](skill-size.md)))。 ⚠ **别用 `grep -c` 去数配置里的声明行**:该文件里有一条**注释掉的**候选钩子(clang-format,第 65 行), 文本 grep 会多算 1 = 52——「声明 52 / 文档 51 / 实跑 51」这种三个数不同源的争执就是这么来的。真要 grep, - 用**锚定行首缩进**的模式(它天然跳过以 # 开头的注释行)2026-10-28 实测直接得 **51**,与 YAML 口径一致; - 会多算 1 = 52 的是**不锚定行首、按子串**匹配声明行的写法(那条被注释掉的候选也被它算进去)。 + 用**锚定行首缩进**的模式(它天然跳过以 # 开头的注释行)2026-10-28 实测直接得 **51**(当时口径),与 YAML 口径一致; + 会多算 1(比口径多 1)的是**不锚定行首、按子串**匹配声明行的写法(那条被注释掉的候选也被它算进去)。 ⚠ 反过来的坑也真踩过(2026-10-27):在配置**注释里复述那个 grep 字面量**,会让文本 grep 再虚高 1—— 数数只认 YAML 解析,注释与文档里都别写出那个字面量(`docs/门禁说明/nested-comment.md` §8 记了现场)。 实跑侧口径:`scripts/ci_local.py` 的静态门禁汇总行 `[SUMMARY] …: pre-commit hooks -- passed=A failed=B skipped=C`(方括号里那一段是**层级名**,脚本输出与文档层的语义名正分头迁移 → 抓这行**按 `passed=` 字段匹配**,别把层级名前缀写死进字面量,否则会 grep 不到而误判「没有实跑数」), - `A+B+C` = **commit 阶段**的钩子数(47 = 51 − 3 根 `stages: [manual]` − `gitlint`),再加 `gitlint` 1 条(它的 + `A+B+C` = **commit 阶段**的钩子数(48 = 52 − 3 根 `stages: [manual]` − `gitlint`),再加 `gitlint` 1 条(它的 commit-msg 阶段声明写在**上游**仓库自带的钩子清单里,本配置文件里查不到,见 `scripts/ci_local.py` 文件头) - → 47 + 1 = **48 = 一次 `git commit` 实跑的钩子数**;再加 3 根 manual 阶段的慢钩子才是声明数 51,三数同源。 + → 48 + 1 = **49 = 一次 `git commit` 实跑的钩子数**;再加 3 根 manual 阶段的慢钩子才是声明数 52,三数同源(2026-10-29 加 `skill-size` 之前这四个数是 51 / 47 / 48)。 枚举自查(不执行任何钩子,`SKIP` 列全部 id):`SKIP=<全部 id> -m pre_commit run --all-files --hook-stage commit` 输出 47 行、`--hook-stage manual` 输出 50 行(47 常规 + 3 慢的)。 - **三根慢钩子在 manual 阶段**:`cppcheck`、`test-cppcheck`、`test-coverage` 带 `stages: [manual]` → `git commit` @@ -44,7 +44,7 @@ ⚠ 增量拦截最容易误会的一点:**绿灯不等于没问题**,只等于"没有比上次更差"。 每个增量拦截门禁的基线数字都写进它自己的文档,读那篇时第一眼看的就是"现在在册多少条"。 -## 2. 索引(51 个 hook → 28 篇,其中 1 篇是已退役门禁的留档 + 1 根只有本表指针(专篇待补)+ 1 根详解在 docs/测试说明/) +## 2. 索引(52 个 hook → 29 篇,其中 1 篇是已退役门禁的留档 + 1 根只有本表指针(专篇待补)+ 1 根详解在 docs/测试说明/) > 带 `stages: [manual]` 的三根(`cppcheck` / `test-cppcheck` / `test-coverage`)**提交期不跑**,只在远端 CI 的 > 「手动阶段门禁」步与 `scripts/ci_local.py` 的 manual 段跑;下表对应两行的「文档」列已标注。 @@ -64,6 +64,7 @@ | [`task-table.md`](task-table.md) | `task-table` | README/AGENTS 里的任务表是否与 `Src/freertos.c` 一致 | `README.md`、`AGENTS.md`、`Src/freertos.c` | 已写 | | [`registration-check.md`](registration-check.md) | `registration-check` | 新 `.c` 是否双端注册(Keil 工程 + 模拟器 CMake);UI 页面描述符表是否接线 | `.c`、`.uvprojx`、`CMakeLists.txt` | 已写 | | [`nested-comment.md`](nested-comment.md) | `nested-comment` | 块注释体内再出现 /*(ARMCC 9-D 嵌套注释;诱因是注释里写路径通配) | 全仓 `.c/.h`(第三方与生成物除外) | 已写 | +| [`skill-size.md`](`skill-size.md`) | `skill-size` | SKILL.md 不超插件写侧上限 65536 字节(**只在写侧校验、加载侧不查**:超限的技能照常能读、每次「生成草案」却被拒、永久卡在「生成中」)+ `reference/` 指针不悬空 + 不带 BOM | `.dsh/skills/**`(filenames 只当触发,判定始终全树扫) | 已写 | | [`generated-fresh.md`](generated-fresh.md) | `generated-fresh` | 生成器幂等性:把生成器重跑到**系统临时目录**,与工作区逐字节必须一致(不一致 = 生成物比生成器旧) | `tools/**`、UI 源码、`ui_menu_tree.txt`、`ui_cn_chars.txt`、`docs/images/*.svg`、两份注册表 | 已写 | | [`lv-conf-sync.md`](lv-conf-sync.md) | `lv-conf-sync` | 两份 `lv_conf.h`(MCU / 模拟器)的特性子集是否一致 | 两份 lv_conf 或脚本 | 已写 | | [`cubemx-survival.md`](cubemx-survival.md) | `cubemx-survival` | CubeMX 重生成会不会把定制静默抹掉(人工目检审计兜底) | `.ioc`、`Src/`、`Inc/` | 已写 | @@ -95,7 +96,7 @@ ## 4. 现状与下一步 -- **本目录 28 篇已全部成文**(2026-10-27 实测当时 27 篇;2026-10-28 复核:`docs/门禁说明/` 下除 README 外 28 篇,与上表「文档」列 +- **本目录 29 篇已全部成文**(2026-10-29 加 `skill-size` 一篇 → 29;2026-10-27 实测当时 27 篇;2026-10-28 当时复核:`docs/门禁说明/` 下除 README 外 28 篇,与上表「文档」列 **一一对应**——「有页无行」与「有行无页」两个差集都是空集;一行覆盖多个 hook 的行(`cppcheck`+`test-cppcheck`、 22 个 `*-unit-test`)按行内列举计。上一句写「23 篇」是存量误差:2026-10-25 连加 `bom-guard` / `setview-check` / `uinav-selftest` 三根、本轮加 `generated-fresh` 都没同步它(本轮前实有 25 篇)。 diff --git a/docs/门禁说明/skill-size.md b/docs/门禁说明/skill-size.md new file mode 100644 index 00000000..b26c90d3 --- /dev/null +++ b/docs/门禁说明/skill-size.md @@ -0,0 +1,108 @@ +# SKILL.md 体积与指针(skill-size)— 别让"能读能改"骗过你 + +> 一句话:dsh-mnemon 插件对技能正文有 **65536 字节的写侧硬上限**,而它**只在写侧校验、加载侧不查**—— +> 超限的技能照常可读、照常进目录、照常提交,只有自进化会**永久卡在「生成中」**。本闸把这条隐性约束 +> 变成提交时的显式判定。脚本 \`scripts/check_skill_size.py\`(纯标准库、只读,2026-10-29 接线)。 + +## 1. 它在查什么 + +扫 \`.dsh/skills//SKILL.md\`(不是只扫变更文件,见 §3)。 + +| 判定 | 条件 | 依据 | +|---|---|---| +| **FAIL** | utf-8 **字节数** > 65536 | 与插件 \`MAX_SKILL_BYTES\` 同数、同口径(字节,不是字符;正文里中文多,字符数会少算近一半) | +| **FAIL** | 正文里 \`(reference/xxx.md)\` 指向的文件不存在 | 渐进展开的前提是"指针能兑现";悬空指针等于把规程删了却还写着"见那篇",比内联更坏 | +| **FAIL** | 文件带 UTF-8 BOM | 插件按 utf-8 解析,BOM 会让 frontmatter 首个键读歪(与 \`bom-guard\` 同源,但那根不管 \`.dsh/skills\`) | +| **WARN**(不拦提交) | 字节数 >= 49152(上限的 75%) | 越线通常只隔一次草案采纳(实测一次采纳把正文从 49821 顶到 62806 字节) | +| 不算 | 用户级 \`~/.dsh/skills/\` | 不在仓库里,本闸照不到(见 §6) | +| 不算 | 正文纯文本里写的 \`reference/…\` 路径(非 markdown 链接) | 只认链接形态;写散文里的那种靠评审 | + +## 2. 为什么要它 + +- **仓库里原本没有任何一处会说这句话**:「这个技能已经废了」。文件能读、能改、能提交,门禁全绿; + 坏的是——自进化每次点「生成草案」都在**落盘那一步**被写侧拒绝,而界面此时只剩一个转圈: + \`drafting\` 那一行既不回到 \`pending\`(\`Draft\` / \`Dismiss\` 只在 pending 渲染),也没有超时回收 + (插件只对回测 run 有 \`RUN_STALE_MS\` = 45 分钟,对建议行没有对应机制)。 +- **代价是静默的**:每次尝试白烧一个 25–35 分钟的草案 Agent。实测 2026-10-28 至 29 两天内卡死三次, + 其中两次同因(超上限):\`dsh-run-code-tool-invocation\` 涨到 **83194 字节**(超 17658), + \`task-worktree\` 涨到 **72601 字节**(超 7065,git HEAD 里还是 57917)——**都是手改/采纳堆上去的, + 没有一次是插件自己写的**,所以插件的写侧校验只在"下一次草案落盘"时才撞上,撞上就死循环。 +- 加这根之后:越线的文件**进不了仓库**;已经在仓库里的(本次全部 ≤42%)被 WARN 提前一格点出来。 + +## 2.1 接线当日实测(都可复现) + +| 测项 | 结果 | +|---|---| +| 全树实扫 | \`.dsh/skills\` 共 **17 个 SKILL.md:0 FAIL、0 WARN**,最大 \`task-worktree\` 28139 字节(42%);退出码 0 | +| \`--selftest\` 五形态 | **5/5**:干净树 0 / 75% 线 0(带 WARN)/ 65546 字节 1 / 悬空指针 + BOM 1 / 根不存在 2 | +| 钩子接线 | YAML 解析条目 **51 → 52**;\`stages: [manual]\` 仍 **3** 根(一次 \`git commit\` 实跑 49);\`pre-commit run skill-size --all-files\` 与 \`--files .dsh/skills/task-worktree/SKILL.md\` 两种触发都 **Passed** | +| 解释器面 | 钩子 entry 用裸 \`python\`:\`.venv\` 3.12 与 PATH 上的 3.14.7 都跑通——本脚本纯标准库,**不属于** AGENTS.md「裸 python 不等于 .venv」那条教训的适用面(那条针对 fontTools 这类第三方依赖) | +| 负例落点 | 在 \`--selftest\` 的**临时树**里造,不在 \`.dsh/skills/\` 下造探针目录:那是活动技能目录,加载器与自进化都会看见它(见 §8) | + +## 3. 触发面 + +- \`files: ^\\.dsh/skills/\` —— 覆盖 \`SKILL.md\` 与 \`reference/*.md\` 两类改动。**为什么必须覆盖到 + reference**:把某一节外置出去时,改的常常是"新增 reference 文件 + 主文变短",两根都要能触发; + 反过来只删主文、留下悬空指针,也只有 reference 目录参与触发才拦得住。 +- pre-commit 4.x **已移除 \`always: true\`**(对 local 钩子只告警并跳过),所以"想总是跑"只能靠 + \`files:\` 覆盖到触发面——这根的触发面就是整个 \`.dsh/skills/\`。 +- **判定始终全树扫**,忽略钩子传来的 filenames(只当触发用):因为 SKILL.md 越线时,变更文件可能只是 + 一个 \`reference/xxx.md\`,只看 filenames 就会绿着放过去。代价是每次提交多扫 17 个小文件(<20 ms 级)。 +- 阶段:默认 commit 阶段(\`git commit\` 与 \`ci_local.py\` 都会跑)。 + +## 4. 报错怎么读 + + FAIL .dsh/skills/task-worktree/SKILL.md 72601 字节 > 写侧上限 65536(超 7065):这个技能的自进化 + 每次「生成草案」都会被拒、永久卡在「生成中」。把深规程逐字搬进同目录 reference/<篇名>.md 并留 + 指针(别重排既有步骤号);已卡死的台账行按 mnemon-skill-ledger-stuck-repair 技能处理。 + +三种正解,按情况挑: + +1. **还没卡死** → 外置:把最深的整节**逐字**搬进 \`/reference/<篇名>.md\`,主文保留原小标题 + + 可执行判据的摘要 + 一行指针;**不要重排既有步骤号**(正文里有「见第 12 步」这类交叉引用)。 +2. **已经卡死** → 先外置把体积降回去,再按 \`mnemon-skill-ledger-stuck-repair\` 技能做取证与自愈; + 只复位台账行而文件仍超限 = 下一轮继续卡(插件会再烧一个草案 Agent)。 +3. **悬空指针** → 补文件,或者把指针连它承诺的内容一起搬回主文;**别只删指针行**(那是把规程删了)。 + +单独跑(两种都行): + + & '.venv\Scripts\python.exe' scripts/check_skill_size.py --verbose + & python scripts/check_skill_size.py --root . + +## 5. 好处 + +- 把插件的一条**只存在于写侧**的约束,搬到了提交期:越线在写它的那一次就被点名,而不是在下一次 + 有人点「生成草案」时以"界面转圈 30 分钟"的形式出现。 +- 让"渐进展开"有机器背书:\`reference/\` 指针不再只是文档约定,悬空即红。 +- 把一次静默失败(无日志、无报错、无按钮)换成一行可 grep 的提交期失败。 + +## 6. 限制与盲区 + +- **主体不同**:插件校验的是"要写进去的那份 content",本闸校验的是"仓库里的这份文件"。数值一致, + 但**草案采纳是插件写的,本闸拦不住那一次写入**——只能在下一个碰这个文件的提交里响。真正的写入侧 + 修复要插件自己做(上游 issue 草稿见 \`~/.dsh/scripts/dsh-mnemon-drafting-wedge-issue.md\`)。 +- **用户级技能照不到**:\`~/.dsh/skills/\` 不在仓库里。手工兜底两条:本脚本 \`--root\`,或 + \`node "$HOME/.dsh/scripts/mnemon-skill-ledger-doctor.mjs" --root .\`(后者还会扫出全机器所有工作区的 + \`SKILL.md\` 并顺带查台账卡死行)。都**只报不改**。 +- 只判字节数与指针存在性:**不判 frontmatter 是否可解析**(\`check-yaml\` 的 \`files:\` 不含 \`.md\`, + frontmatter 坏了目前只有加载器知道),也不判 U+FFFD——\`no-mojibake\` 的触发面同样不含 \`.dsh/skills\`。 +- 49152 是启发式,不是插件行为:内容密度决定头量消耗速度(实测一次草案采纳 +12985 字节)。 +- 名字里带 \`reference/\` 但用别的写法(尖括号、反引号裸路径、相对 \`../\`)的引用一律不算,静默放过。 + +## 7. 豁免与基线 + +- **无基线、无白名单**:新闸零语料,接线当日 17 个 SKILL.md 全绿,所以不需要"只减不增"的存量账。 + 将来若真出现不得不豁免的技能,**别加白名单**——那等于把 §2 的静默失败请回来;正确做法是外置那一节。 +- WARN 不是基线,它不需要还清;但 WARN 条数只减不增是好的工程信号(说明技能还在长胖)。 + +## 8. 改这套门禁本身 + +- **65536 必须跟插件同源**:改数之前先在插件包里确认 \`MAX_SKILL_BYTES\` / \`validateSkillContent\` 的 + 实际数值(\`~/.dsh/profiles/web/node_modules/dsh-mnemon-source-skills/lib/index.js\`)。两边不一致时, + **以插件写侧为准**,并把本文件 §1 的表一起改。 +- 别把 FAIL 降成 WARN、也别把 WARN 抬到 FAIL:前者回到静默,后者会把"内容多但没坏"的技能全拦在提交外。 +- 改判据必须同步加 \`--selftest\` 形态——那根是唯一的牙;\`--selftest\` 不过就别提交。 +- **别在 \`.dsh/skills/\` 下造探针目录做负例**:那目录是活动技能面,加载器会把它当技能、自进化可能给它 + 建建议行。负例一律在临时树里造(\`--selftest\` 就是这个做法)。 +- 加/删钩子要同批改三处计数:\`docs/门禁说明/README.md\` §1 与 §2 标题、\`AGENTS.md\` 的「共 N 个钩子」 + 与文末文档表(口径见 \`docs/门禁说明/README.md\` §2 首条:唯一口径是 YAML 解析条目数)。 diff --git a/scripts/README.md b/scripts/README.md index fca588ec..20f811a2 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -57,7 +57,7 @@ ```bash # 全库跑门禁(用仓库 .venv 的 python,其 pre-commit 随 requirements.txt 安装; # 其他装有 pre-commit 的解释器可用 DTU_PRECOMMIT_PYTHON 指定) -# 只覆盖 `pre-commit` 阶段的 47 根(`git commit` 实跑 = 47 + gitlint = 48 根) +# 只覆盖 `pre-commit` 阶段的 48 根(`git commit` 实跑 = 48 + gitlint = 49 根) & '.venv\Scripts\python.exe' -m pre_commit run --all-files # manual 阶段三根(带 stages: [manual],提交期不跑):一次只能给一个 id diff --git a/scripts/check_skill_size.py b/scripts/check_skill_size.py new file mode 100644 index 00000000..eea1c988 --- /dev/null +++ b/scripts/check_skill_size.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""SKILL.md 体积与指针门禁(skill-size)。 + +为什么要有这根(实测 2026-10-29,两个技能同日各卡死一次):dsh-mnemon 插件对技能正文有 +**65536 字节的写侧硬上限**(``validateSkillContent()``),但它**只在写侧校验、加载侧不查**—— +超限的 SKILL.md 照常 ``skill()`` 可读、照常进技能目录,唯独自进化每次「生成草案」都在落盘那一步 +被拒:那一行永久卡在「生成中」(drafting)、界面只剩转圈、连「忽略」都不出现(Draft/Dismiss 只在 +pending 渲染)。换句话说:**文件能读、能改、能提交,仓库里没有任何一处会告诉你这个技能已经废了**, +直到下一次卡死、白烧一个 30 分钟的草案 Agent。本门禁把这条隐性约束变成提交时的显式判定。 + +判据: +1. `.dsh/skills//SKILL.md` 的 utf-8 字节数 > 65536 → FAIL(与插件写侧上限同数、同口径: + 字节数不是字符数)。 +2. 字节数 >= 49152(上限的 75%)→ WARN,不拦提交。草案是在这个区间里被"下一次采纳"顶过线的 + (实测 49821 → 62806 只隔一次采纳),提前一格留头量比卡死后再拆便宜。 +3. 正文里 `(reference/xxx.md)` 指向的文件不存在 → FAIL。渐进展开的前提是"指针能兑现"; + 悬空指针等于把规程删了却还写着"见那篇",比内联更坏。 + +退出码:0 = 通过(可有 WARN);1 = 有 FAIL;2 = 用法/环境错误(根目录不存在、不在本仓库内)。 +纯标准库,任意 python3 均可(不吃 .venv 的第三方包,所以钩子 entry 用裸 python 也安全)。 +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +import tempfile + +CAP_BYTES = 65536 # 与 dsh-mnemon MAX_SKILL_BYTES 同数 +WARN_BYTES = 49152 # CAP 的 75% +SKILLS_SUBDIR = os.path.join(".dsh", "skills") +LINK_RE = re.compile(r"\(reference/([A-Za-z0-9._\-]+\.md)\)") + + +def _utf8_stdout() -> None: + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + + +def find_skills_dir(start: str): + """从 start 往上找含 .dsh/skills 的目录(钩子从仓库根跑,手工跑时可能在子目录)。""" + cur = os.path.abspath(start) + for _ in range(12): + cand = os.path.join(cur, SKILLS_SUBDIR) + if os.path.isdir(cand): + return cand + parent = os.path.dirname(cur) + if parent == cur: + break + cur = parent + return None + + +def rel(p: str) -> str: + try: + return os.path.relpath(p, os.getcwd()).replace(os.sep, "/") + except Exception: + return p.replace(os.sep, "/") + + +def check_tree(skills_dir: str, verbose: bool = False): + """返回 (fails, warns, checked)。fails/warns 是可直接打印的行。""" + fails = [] + warns = [] + checked = 0 + for name in sorted(os.listdir(skills_dir)): + d = os.path.join(skills_dir, name) + skill_md = os.path.join(d, "SKILL.md") + if not os.path.isfile(skill_md): + continue + checked += 1 + with open(skill_md, "rb") as fh: + raw = fh.read() + size = len(raw) + if size > CAP_BYTES: + fails.append( + "FAIL " + rel(skill_md) + " " + str(size) + " 字节 > 写侧上限 " + str(CAP_BYTES) + + "(超 " + str(size - CAP_BYTES) + "):这个技能的自进化每次「生成草案」都会被拒、" + "永久卡在「生成中」。把深规程逐字搬进同目录 reference/<篇名>.md 并留指针" + "(别重排既有步骤号);已卡死的台账行按 mnemon-skill-ledger-stuck-repair 技能处理。" + ) + elif size >= WARN_BYTES: + warns.append( + "WARN " + rel(skill_md) + " " + str(size) + " 字节 = 上限的 " + + str(size * 100 // CAP_BYTES) + "%(阈值 " + str(WARN_BYTES) + + "):再吃一次草案采纳就可能越线,建议先把某一节外置到 reference/。" + ) + if raw.startswith(b"\xef\xbb\xbf"): + fails.append("FAIL " + name + ": SKILL.md 带 UTF-8 BOM(插件按 utf-8 解析,frontmatter 会读歪)") + text = raw.decode("utf-8", errors="replace") + for target in sorted(set(LINK_RE.findall(text))): + if not os.path.isfile(os.path.join(d, "reference", target)): + fails.append("FAIL " + name + ": 指针 reference/" + target + " 悬空——正文引用了它,文件不在。" + "要么补文件,要么把指针连同它承诺的内容一起搬回正文。") + if verbose: + print(" ok " + name + " " + str(size) + " 字节 (" + str(size * 100 // CAP_BYTES) + "%)") + return fails, warns, checked + + +def run(paths, roots_extra, verbose: bool = False) -> int: + """paths:钩子传进来的文件(只用于触发;判定始终全树扫,这样只改 reference/*.md 也照样量体积)。""" + dirs = [] + for r in roots_extra: + if not os.path.isdir(r): + print("ERROR --root 不存在: " + r, file=sys.stderr) + return 2 + skills = r if os.path.basename(r) == "skills" else os.path.join(r, SKILLS_SUBDIR) + if not os.path.isdir(skills): + print("WARN 跳过 " + r + ":没有 " + SKILLS_SUBDIR.replace(os.sep, "/")) + continue + dirs.append(skills) + if not dirs: + base = find_skills_dir(os.getcwd()) + if base is None: + print("ERROR 找不到 .dsh/skills(不在本仓库内?用 --root 指过去)", file=sys.stderr) + return 2 + dirs.append(base) + all_fail = [] + all_warn = [] + total = 0 + for d in dirs: + fails, warns, checked = check_tree(d, verbose=verbose) + all_fail += fails + all_warn += warns + total += checked + if verbose or fails or warns: + print("== " + d.replace(os.sep, "/") + ":SKILL.md " + str(checked) + " 个") + for line in all_warn: + print(line) + for line in all_fail: + print(line) + if all_fail: + print("") + print("skill-size:" + str(len(all_fail)) + " 项不合格 / 共扫 " + str(total) + " 个 SKILL.md(上限 " + str(CAP_BYTES) + " 字节)") + return 1 + tail = "(" + str(len(all_warn)) + " 条 WARN)" if all_warn else ",无悬空指针" + print("skill-size:" + str(total) + " 个 SKILL.md 全在写侧上限内" + tail) + return 0 + + +def _make(tmp: str, skill: str, size: int, link=None, bom: bool = False) -> None: + d = os.path.join(tmp, SKILLS_SUBDIR, skill) + os.makedirs(os.path.join(d, "reference"), exist_ok=True) + body = "---\nname: t\ndescription: d\n---\n\n# t\n\n" + # 先把正文垫到恰好 size 字节(链接行加在 padding 之后,所以带链接的用例只多不少) + text = body + ("x" * max(0, size - len(body.encode("utf-8")))) + if link: + text += "\nsee [x](reference/" + link + ").\n" + data = text.encode("utf-8") + if bom: + data = b"\xef\xbb\xbf" + data + with open(os.path.join(d, "SKILL.md"), "wb") as fh: + fh.write(data) + + +def selftest() -> int: + """在临时目录造四种形态,验判定真有牙(不是"永远绿")。""" + ok = True + with tempfile.TemporaryDirectory() as tmp: + _make(tmp, "clean", 2000, link="present.md") + with open(os.path.join(tmp, SKILLS_SUBDIR, "clean", "reference", "present.md"), "w") as fh: + fh.write("# p\n") + rc = run([], [tmp]) + ok = ok and rc == 0 + print("selftest 干净树 -> " + str(rc) + "(期望 0)") + _make(tmp, "warnish", WARN_BYTES + 10) + rc = run([], [tmp]) + ok = ok and rc == 0 + print("selftest 75% 线 -> " + str(rc) + "(期望 0 带 WARN)") + _make(tmp, "fat", CAP_BYTES + 10) + rc = run([], [tmp]) + ok = ok and rc == 1 + print("selftest 超上限 -> " + str(rc) + "(期望 1)") + os.remove(os.path.join(tmp, SKILLS_SUBDIR, "fat", "SKILL.md")) + os.remove(os.path.join(tmp, SKILLS_SUBDIR, "warnish", "SKILL.md")) + _make(tmp, "dangling", 3000, link="gone.md") + _make(tmp, "bommed", 3000, bom=True) + rc = run([], [tmp]) + ok = ok and rc == 1 + print("selftest 悬空指针/BOM -> " + str(rc) + "(期望 1)") + rc = run([], [os.path.join(tmp, "nope")]) + ok = ok and rc == 2 + print("selftest 根不存在 -> " + str(rc) + "(期望 2)") + print("SELFTEST " + ("PASS" if ok else "FAIL")) + return 0 if ok else 1 + + +def main(argv=None) -> int: + _utf8_stdout() + ap = argparse.ArgumentParser(description="SKILL.md 体积与 reference/ 指针门禁") + ap.add_argument("paths", nargs="*", help="钩子传入的变更文件(仅用于触发;判定始终全树扫)") + ap.add_argument("--root", action="append", default=[], help="额外要扫的根(可重复),其下需有 .dsh/skills") + ap.add_argument("--selftest", action="store_true", help="在临时目录造形态自证判定有牙") + ap.add_argument("--verbose", action="store_true", help="逐个打印体积") + args = ap.parse_args(argv) + if args.selftest: + return selftest() + return run(args.paths, args.root, verbose=args.verbose) + + +if __name__ == "__main__": + sys.exit(main())