From d286b69a2dc789172679058ba320c2f09b592c09 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Tue, 27 Oct 2026 05:16:58 +0800 Subject: [PATCH] =?UTF-8?q?chore(=E5=B7=A5=E5=85=B7,=E6=96=87=E6=A1=A3):?= =?UTF-8?q?=20=E5=8F=B0=E6=9E=B6=20rc=20=E8=AF=AD=E4=B9=89=E4=B8=8E?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E7=A1=AC=E9=97=B8=E7=94=A8=E4=BE=8B=E3=80=81?= =?UTF-8?q?=E9=97=A8=E7=A6=81=E7=8E=AF=E5=A2=83=E8=AF=8A=E6=96=AD=E3=80=81?= =?UTF-8?q?=E6=8A=A4=E6=A0=8F=E8=BF=98=E5=80=BA=E7=AE=97=E6=B3=95=E3=80=81?= =?UTF-8?q?mem-budget=20=E8=B7=A8=E6=A0=91=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四条道的收尾件同笔(全是 python 工具与文档,零固件源;各段归属按道署明)。 · **台架(bench-runner)**:verify_log.py 新增 classify_measurement() 与 measureme nt.* 字段,零行/零锚点 判 exit 2 并打 [MEASUREMENT-BROKEN],**但同帧有 Forbidden 命中或自检故障仍判 1**(专门造了「零锚点 + [ERR] MEMFAULT」的陷阱日志实测——崩溃不许被降级成「没数据」);--selftest 16 → 22 条。board_ui_nav.py 把两条执行页安全闸做成 7 条常驻用例接进提交钩子跑的快检档,作者把自己用例**证伪三轮**(测试强制上限、 清单清空时是崩不是判定、出厂默认被翻允许也照样绿)后三条危险改动逐条能红。台架卫生清单落 §7:每圈复位 前 open + 断言 ready + 看 rx 增长(今晚 daemon 死了 status 自愈把它遮住)、**Forbidden 令牌不得落在 英文单词内部**(裸 Fault 命中 default 两次假红)、改判定语义前先 grep 消费者。 · **环境(ui-param-writer)**:证伪卡片前提两处——identify 那个版本根本没有 tags 模块(RECORD 与磁盘逐项 一致,pre-commit 自己也从 identify.identify 取 ALL_TAGS,全仓零引用),真问题是 44 条 system 钩子里 42 条 entry 用裸 python → PATH 首位那个零第三方包解释器,今天没炸纯靠两件事同时成立。交付是**新增 entry 硬规则 + 修复死顺序与回滚并标注等静默窗口**,没有动共享环境(此刻重装会让别人的钩子在半途换解释器)。 · **门禁(gate-docs-fixer 已入库的 51 号钩子)**:本篇带上 host-unit-tests 的裁定句落点说明。 · **内存(comm-config-writer)**:mem-budget skill 补跨树测量身份口径(脚本按 __file__ 定位产物、什么才算 一次新测量、多链同涨同一数值的共享帧/幻影环归因法)+ 看门狗说明的指针行。 实测:verify_log --selftest 22/22、board_ui_nav --selftest-quick rc=0(硬闸 7/7)、 doc-links --all exit 0、 check_ids --strict exit 0 且绝对数 1,156 **未被本笔吃掉**;全部文件 LF / 无 BOM / U+FFFD=0 ;本笔零固件源, 随上一批权威构建验证过(0 错 0 警),故不再单独 @build。 --- .dsh/skills/keil-build/SKILL.md | 52 +++++- .dsh/skills/mem-budget/SKILL.md | 121 +++++++++++++- .../serial-boot-test/scripts/verify_log.py | 82 +++++++++- docs/子系统设计/看门狗设计说明.md | 2 +- docs/测试说明/启动日志原子性复验流程.md | 34 +++- docs/测试说明/实机测试实施指南.md | 16 ++ docs/测试说明/自动化测试流程.md | 2 +- docs/环境与部署/本地工具路径环境变量方案.md | 65 +++++++- scripts/board_ui_nav.py | 150 +++++++++++++++++- scripts/ci_local.py | 5 + 10 files changed, 499 insertions(+), 30 deletions(-) diff --git a/.dsh/skills/keil-build/SKILL.md b/.dsh/skills/keil-build/SKILL.md index 3862b8fc..b1611e2a 100644 --- a/.dsh/skills/keil-build/SKILL.md +++ b/.dsh/skills/keil-build/SKILL.md @@ -1,7 +1,7 @@ --- name: keil-build -description: Keil MDK-ARM 命令行编译闭环(自带独立脚本 keil_build.py,纯标准库,拷到任何 Keil 项目即用):UV4 -b 构建 → "0 错误 0 警告"判定 → build_log 定位报错。封装入口约定、退出码判读、UV4 改写工程文件的入库陷阱。 -whenToUse: 需要编译 Keil MDK 工程、验证代码改动是否 0 错误 0 警告、或解读 UV4 构建日志时。 +description: Keil MDK-ARM 命令行编译闭环(自带独立脚本 keil_build.py,纯标准库,拷到任何 Keil 项目即用):UV4 -b 构建 → "0 错误 0 警告"判定 → build_log 定位报错。封装入口约定(PowerShell 与 bash/ops_bash 两种调用形态及各自的 exit 2 归属)、退出码判读、UV4 改写工程文件的入库陷阱。 +whenToUse: 需要编译 Keil MDK 工程、跑 `@build` 验证代码改动是否 0 错误 0 警告、解读 UV4 构建日志与退出码 2/4,或在 bash / `ops_bash` 里执行 `@build` 报语法错(syntax error near unexpected token)时。 --- # keil-build — Keil 命令行编译(独立可运行 skill) @@ -10,16 +10,47 @@ whenToUse: 需要编译 Keil MDK 工程、验证代码改动是否 0 错误 0 (纯标准库,任意 python3;拷整个 skill 目录到别的 Keil 项目即可用,不依赖仓库脚本)。 ```powershell -# cwd = 项目根目录 +# cwd = 项目根目录;PowerShell 形态(AGENTS.md 里的 `@build` 写法) & $PY ".dsh\skills\keil-build\scripts\keil_build.py" # 覆盖:--project X.uvprojx --uv4 UV4.exe --target 名字 --log 路径 --timeout 600 ``` +## 调用形态:先认执行器是哪个 shell(2026-10-26 实测) + +同一个 `@build`,**PowerShell 与 bash 是两种写法**;`ops_bash` / `run_code` 起的是 +`/usr/bin/bash -c`,**不认 PowerShell 形态**,照抄仓库 `AGENTS.md` 会直接语法错退出。 + +| 执行器 | 正确写法(cwd = 仓库根) | +|---|---| +| PowerShell | `& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py` | +| bash(`ops_bash`) | `'.venv/Scripts/python.exe' .dsh/skills/keil-build/scripts/keil_build.py` | + +- bash 侧**去掉前导 `&`**(bash 里 `&` 是"丢后台",不是调用操作符)、**路径改正斜杠** + (`\Scripts\python.exe` 的反斜杠在 bash 是转义,路径根本解析不出来)。 +- 实测(step 41→42):把 PowerShell 形态丢进 `ops_bash` 后台任务 → 任务**秒退**、 + `exit 2`,stderr 只有这一行: + +```text +/usr/bin/bash: -c: line 1: syntax error near unexpected token `&' +``` + +- ⚠ **这个 2 不是脚本的环境错误 2**:脚本自己报环境错误会打出 `BuildEnvError` / + 中文诊断(UV4 找不到、工程找不到、档案解析失败),而语法错**连解释器都没启动**, + `MDK-ARM/build_log.txt` 不会被改写。判据:stderr 含 `syntax error near unexpected token` + = 写法问题 → 换 bash 形态重跑,**别去查 `DTU_KEIL_UV4`、别当环境错误排障**。 +- 后台跑(`run_in_background: true`)时要自己收码,且**别把脚本接进管道**: + `'.venv/Scripts/python.exe' .dsh/skills/keil-build/scripts/keil_build.py; rc=$?; echo RC=$rc`。 + 实测 `脚本 | tail -6; echo RC=$?` 里的 `$?` 是 `tail` 的退出码(恒 0), + 拿它当 PASS/FAIL 会永远"绿";`set -o pipefail` 能透出非零码,但尾巴上挂的 `grep` + 无匹配时同样报 1,也不可信。要看报错行就单独 grep 日志: + `grep -a -E 'Error|error:|Warning' MDK-ARM/build_log.txt`。 + 解析顺序(命令行 > 项目档案 `.serialtest/config.json` > 环境 > 探测): 工程 `uvprojx`(档案缺省时自动搜 cwd 下唯一 `*.uvprojx`);UV4 `uv4` > `$DSH_KEIL_UV4` > `$DTU_KEIL_UV4` > PATH(**显式设了但路径无效 = 立即报错,不静默回退**)。 退出码:`0`=PASS(0错0警) / `1`=构建失败(打印报错行)/ **`2`=环境错误**(UV4 找不到或 路径无效、工程找不到、档案解析失败)/ `3`=用法错误 / `4`=build 锁被占用(并发 `-b` 不可行)。 +**收到 2 先确认它是脚本打的还是 shell 语法错打的**(见上节),再走"环境错误"排障。 > 2026-10-12 修正:旧实现把环境错误写成 `raise SystemExit("...")` → 实际退出码是 **1**, > 而用法错误由 argparse 抛 `SystemExit(2)` → 恰好占了"环境错误"的位置。故 `AGENTS.md` / @@ -53,7 +84,9 @@ Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 TTL 才被 ## 本仓库 **`@build` = 本 skill**(2026-10-10 起为仓库文档口径):在仓库根目录跑 -`& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py`。 +`& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py`—— +**bash / `ops_bash` 下要写成 `'.venv/Scripts/python.exe' .dsh/skills/keil-build/scripts/keil_build.py`** +(原因见「调用形态」)。 判定口径(与原 `MDK-ARM/build.bat` 同款;该文件已于 2026-10-10 删除,本 skill 即 `@build`): 先删旧日志 → `-b -j0 -o` → 无 `error:` 且含 `0 Error(s), 0 Warning(s)`。 本 skill 另有超时保护、UV4 多级解析链(含 PATH 兜底)、失败只打报错行。 @@ -63,7 +96,7 @@ Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 TTL 才被 - **任何代码改动后必须先编译验证(0 错误 0 警告)再谈烧录/提交**——编译不过就进 硬件闭环只会把编译错误伪装成运行问题。 -- **警告也算失败**:判 PASS 只认退出码 0,别只看"有没有 error:"。 +- **警告也算失败**:判 PASS 只认退出码 0,别只看"有没有 error:"(取退出码的管道陷阱见「调用形态」)。 - **`MDK-ARM/*.uvprojx` 每次构建后被 UV4 自动改写,改动一律不入库**:UV4 会把工程 XML 归一化成本机风格(`` → ``、XML 声明加 `standalone="no"`)并把 `PackID` 降到**本机装的 DFP 版本**(属降级——提交后会把别人的 Pack 版本一起改掉)。 @@ -71,8 +104,10 @@ Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 TTL 才被 `git checkout -- ` 丢弃;只有真的增删了源文件(grep 文件名计数变化)才提交。 同目录的 `*.uvoptx` / `*.uvguix.*` 是 IDE 个人设置,已在 `.gitignore` 里,不会露出来。 - 报错定位读日志别只看终端回显:本仓库 = `MDK-ARM\build_log.txt`。 -- **exit 2 先分清"真没设"还是"会话没继承"**(2026-10-10 实测):agent 的 shell 由 - 常驻服务派生,服务启动**之后**设置的用户级环境变量不会自动进来。查 +- **exit 2 先分三种再动手**(2026-10-10 / 2026-10-26 实测):① **bash 语法错**——stderr 有 + `syntax error near unexpected token`,是把 PowerShell 形态丢进了 `ops_bash`,换写法即可; + ② **真没设** `DTU_KEIL_UV4`;③ **会话没继承**——agent 的 shell 由常驻服务派生, + 服务启动**之后**设置的用户级环境变量不会自动进来。查 `[Environment]::GetEnvironmentVariable('DTU_KEIL_UV4','User')`,有值就 `$env:DTU_KEIL_UV4 = <该值>` 注入本会话再跑,别急着让用户去设。 - 编码类报错(`#870-D: invalid multibyte character sequence` 等)先怀疑源文件 @@ -82,3 +117,6 @@ Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 TTL 才被 - 烧录/回读 = `stlink-flash` skill;串口闭环 = `serial-boot-test` skill (其档案 `flash_cmd`/`readback_cmd` 即 stlink-flash 的入口命令)。 +- 收尾固定链(2026-10-26 实证):`@build` → `scripts/ram_baseline.py --update` → + 体积门禁盖戳 → 内存数字回填与 `--scan` → 复跑 `ci_local` 静态段, + 直到钩子全绿且 0 错误 0 警告(RAM/栈口径见 `mem-budget` skill)。 diff --git a/.dsh/skills/mem-budget/SKILL.md b/.dsh/skills/mem-budget/SKILL.md index 2dcef7c7..fec55499 100644 --- a/.dsh/skills/mem-budget/SKILL.md +++ b/.dsh/skills/mem-budget/SKILL.md @@ -1,7 +1,7 @@ --- name: mem-budget -description: 嵌入式 RAM/栈预算核算工作流:Keil .map/.htm 静态报告(ram_report/stack_report)+ 串口运行时水位叠加 + DBG 日志成本模型(每条约 90B Flash/216B 栈)。"改任务/缓冲/栈之前必须核算"的固定动作与判读口径。 -whenToUse: 新增任务/加大缓冲/改栈/接入日志之前之后、链接报 L6406E 或 RAM 溢出、串口 [STACK] 水位余量低于底线、或需要回答"这个改动花多少 RAM/Flash"时。 +description: 嵌入式 RAM/栈预算核算工作流:Keil .map/.htm 静态报告(ram_report/stack_report)+ 串口运行时水位叠加 + DBG 日志成本模型(每条约 90B Flash/216B 栈)+ 跨树(worktree)测量的身份口径——脚本按 __file__ 定位产物、什么才算一次新测量、多条链同涨同一数值的共享帧/幻影环归因法。"改任务/缓冲/栈之前必须核算"与"这个数属于哪棵树、哪次构建"的固定动作和判读口径。 +whenToUse: 新增任务/加大缓冲/改栈/接入日志之前之后、链接报 L6406E 或 RAM 溢出、串口 [STACK] 水位余量低于底线、要回答"这个改动花多少 RAM/Flash";以及要复算或比对另一个 worktree 的栈数字、独立验证某个栈回归该记在哪一笔提交上、判断多次启动的栈峰值抖动算不算劣化时。 --- # mem-budget — RAM/栈预算核算 @@ -9,26 +9,123 @@ whenToUse: 新增任务/加大缓冲/改栈/接入日志之前之后、链接报 ## 固定动作(改前、改后各跑一次) ```powershell -# cwd = 项目根;先 @build 保证 .htm/.map 是最新的 +# cwd = 项目根;先 @build 保证 .htm/.map 是最新的(产物按脚本自身位置定位,见下节) & '.venv\Scripts\python.exe' ".dsh\skills\mem-budget\scripts\mem_check.py" # 等价于分别跑: # scripts/ram_report.py (.map → RAM/Flash 分区与按文件占用表) # scripts/stack_report.py (.htm 静态调用图 → 每任务最坏链 vs 配置栈) +# 报告落盘留档(改前/改后各一份,供逐帧差分): +& '.venv\Scripts\python.exe' scripts/stack_report.py -o ci-artifacts/sr_before.md # 叠加运行时水位(有真机日志时): & '.venv\Scripts\python.exe' scripts/stack_report.py -s .dsh\skills\serial-assistant\scripts\state\serial.log ``` 退出码:0=全部通过 / 1=有任务不达标 / 2=解析错误(.htm/.map 缺失先重跑构建)。 +别整篇读输出,抓关键行:`grep -aE 'iec104|margin|FAIL|ISR|Rule' ci-artifacts/sr_before.md`。 + +**「改前 / 改后」必须是两次不同的 Linker run**(两个不同的产物身份):同一份 .htm/.map 上跑两遍 +只是把同一个数字复述两遍,不构成"改后已复验"。 + +## 测量身份口径(先钉树与时刻,再看数字) + +### 1. 脚本按 `__file__` 定位产物,不按 cwd —— 跨树必须换脚本 + +```python +# scripts/stack_report.py:36-38 +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +HTM_DEFAULT = REPO_ROOT / "MDK-ARM" / "STM32F407-Demo" / "STM32F407-Demo.htm" +FREERTOS_C = REPO_ROOT / "Src" / "freertos.c" # 配置栈来源,无命令行开关 +# scripts/ram_report.py:16-18 同样用 dirname(dirname(abspath(__file__))) 定位 .map +``` + +⇒ `cd 进别的 worktree 再跑主仓的脚本` 量的仍是**主仓**那份 .htm/.map,数字不可比。 +实测代价:这样一次"独立复算"量到主仓的 1,544 B / margin 1,016 B FAIL,据此写下的 +80 B 归因 +被队友的逐树逐帧差分表推翻——目标 worktree 的真数是 1,464 B / margin 1,096 B / PASS。 + +正确做法:**量哪棵树就跑那棵树自己的脚本**(该树必须已在这棵树上构建过、有自己的 .htm/.map; +开树与合并的生命周期见 `task-worktree` skill): + +```bash +'.venv/Scripts/python.exe' "<目标树>/scripts/stack_report.py" +'.venv/Scripts/python.exe' "<目标树>/scripts/ram_report.py" +``` + +⚠ 只给主仓脚本传 `-i <另一棵树的 .htm>`(或位置参数传另一棵树的 .map)**不够**: +`stack_report.py` 的配置栈仍从**脚本所在那棵树**的 `Src/freertos.c` 读(`FREERTOS_C` 写死自 `__file__`, +没有开关),两树栈配置一旦不同就是"半棵树的混合数字"。要整支脚本换树跑。 + +自检(每次复算都做):报告头两行 `- Source: ...` 与 `- Linker run: ...` 就是你实际量的树和时刻。 +`Source` 若只打印一个裸文件名(不带 `MDK-ARM/...` 相对路径),说明 `-i` 指到了脚本 `REPO_ROOT` 之外的产物。 + +### 2. 重跑报告脚本**不算新测量**(复述 ≠ 复验) + +`stack_report.py` / `ram_report.py` 只是解析盘上那一份 .htm/.map;产物没变,再跑一次数字必然不变。 +那只能叫"复述",**不能写成"我复验过"**。要拿到新数字必须先有一次**新的 Linker run**(增量构建也算), +并把新时刻与产物来源一并写进记录。 + +⚠ `ram_report.py` 正文里的 `> Generated: …` 是**脚本运行时刻**,重跑就变——它不是链接时刻, +不能拿来当"这是新测量"的凭证。 + +### 3. 钉身份的取数法(.htm/.map 按约定不入库 → 把时刻写进正文/台账) + +```bash +grep -aE 'Last Updated|Maximum Stack Usage' MDK-ARM/STM32F407-Demo/STM32F407-Demo.htm | head -2 +# → "ARM Linker, 5060960: Last Updated: Mon Oct 26 22:52:12 2026" +``` + +- .htm 里唯一的链接时刻锚点是 **`Last Updated:`**;`Linker run` 只是 `stack_report.py` 自己输出里的标签, + `Date of Link`、`Image component` **不在 .htm 里**(实测按它们 grep 全空,白跑一轮)。 +- 取不到该字段时退回产物 mtime;同一笔链接里 .htm 与 .map 的 mtime 只差 ~0.1 s,可据此确认两份产物同源。 + 再配 `git log -1 --format=%H` 与该树 `git status` 的未跟踪文件数。 +- 记录最小格式:**`tree/commit + Linker run 时刻 + 产物来源(本次构建 / 盘上既有)`**。 + 本仓 .htm/.map 不入库,不钉时刻就等于把数字和树脱钩,下一个读数字的人无从判断它描述哪棵树。 +- 归属还能机械证明时顺手做掉:`git log --name-only <那笔>..HEAD -- "*.c" "*.h"` 逐行筛(排除 `.uvprojx`)。 + 若那笔之后没有任何提交再碰固件源文件,产物描述的就是**在库那棵树**,不是提交前快照。 +- 同族坑:`git show :path` / `git show HEAD:path` 读的是索引/HEAD,**不是工作区**。 + +## 静态最坏链回归的归因(同涨同一数值 = 共享帧,不是调用方) + +- **多条任务链分毫不差同涨/同降同一个数值**(实测 default/net/iec104 三条 +80 B;断环后九条链各 −80 B) + ⇒ 加深在这些任务**共用的被调帧 / 共享尾段**上,不在任何一个调用方。别逐个调用方翻, + 也**别先猜"新增了大局部缓冲"**——实测猜错过一次(`con_ring_try` 只有 24 B 帧、没有数组)。 +- 逐棵树逐帧比 frame size,不要只比总数: + - `stack_report.py` 输出的「Frame-size ranking (own frame)」表 + 每任务最坏链文本,锁定**链尾公共段** + (实测落在 `dbg_console_write_line → con_append → dbg_console_drain`,据此把账记到上一笔提交上); + - 直接在 .htm 里找环:`grep -ac 'In Cycle' MDK-ARM/STM32F407-Demo/STM32F407-Demo.htm`(实测 17 处), + 形态为 `Max Depth = 280 + In Cycle` 与 `Call Chain = Error_Handler => dbg_console_write_line (Cycle)`。 + `+ In Cycle` 表示 armlink 把**环上所有帧**计入含该环的每一条任务链——**链接器幻影环**能在无人改栈配置时让全仓链齐涨。 + - 实测样本(无人改栈配置、全仓链却齐涨的那种):taskEXIT_CRITICAL ⇒ vPortExitCritical ⇒ configASSERT ⇒ + vApplicationAssertFailed ⇒ dbg_console_write_raw ⇒ 回到 console_log 内部 ⇒ 再进临界区; + 它使 `vApplicationAssertFailed` 的 Max Depth 由 ~40 B 变 128 B + In Cycle,差 88 B ≈ 三条链同涨 80 B 的来源。 + 断环方向:内核临界区改 PRIMASK 保存/恢复(保存量放局部变量)→ 零调用帧、不成环; + 断环的**图证**(该帧从「136 B + In Cycle」变成「8 B、不再 In Cycle」)比数字更有说服力。 +- `Maximum Stack Usage = N bytes + Unknown(Cycles, Untraceable Function Pointers)` 里的 `+ Unknown(...)` + 说明静态图对环与函数指针不可完全解析,静态数是估计值,别当精确值上报。 +- 不采信任何口头结论:用**目标树的脚本 + `.venv` 解释器**自己复算;复算同样先按上节钉身份。 +- 处置后的下一步:抬栈(`Src/freertos.c` 的 `USER CODE` 区,属 Ask-first 动作)或收紧共享缓冲 / 断环, + **改前改后各一次** `stack_report.py` + `ram_report.py`,且"改后"那次必须落在一次新的 Linker run 上; + 抬栈后跑 `gen_task_table.py` 同步任务表。 ## 判读口径 - **静态**:`stack_report.py` 的 Max Depth(最坏调用链累计)vs `Src/freertos.c` 配置栈; 默认要求余量 ≥30%(`--min-free-pct`),ISR 另留 `--isr-margin`(默认 1024B)。 + 判定式 `margin = cfg - max(static_max_depth, runtime_peak)` 须 ≥ `max(isr_margin, 30% of cfg)`。 - **运行时**:开机日志 `[STACK] peak_used=…/… free_min=…` 与静态值对照—— - 静态是上界、运行时是下界证据,**两者都要看**;余量 <1KB 底线要上报。 + **静态最坏链是上界(硬线)、运行时峰值是下界证据(趋势)**,两者都要看;余量 <1KB 底线要上报。 - **RAM 总量**:`ram_report.py` 对 Grand Totals 与 `docs/内存管理/` 预算表核对; 链接错 `L6406E` = 已经溢出,用按文件表找大头。 -- 任务表一致性由 pre-commit `task-table` 钩子保证(改栈后 `gen_task_table.py` 同步 AGENTS.md)。 +- 任务表一致性由 pre-commit `task-table` 钩子保证;`mem-budget` 钩子拦 RW_IRAM1/RW_CCM >92%; + `mem-numbers` 钩子核文档里的内存数字——数字与树/时刻脱钩时就是这两条钩子会响。 + +## 运行时峰值抖动与"算不算劣化" + +- 同一份镜像连续启动 5 次,`uiTask` 峰值实测在 4116~4188 之间摆 **72 B** ⇒ 跨启动本身就有几十 B 抖动, + "相对上一轮 +100 B"**不可归因**于被测改动。判据写成"栈水位不劣于上一轮"必然假红。 +- 可执行修法(二选一,别都留着): + 1. 同一份像**≥3 次启动取最大值**互比,并加容差(**±128 B 量级**); + 2. 硬线改用 `stack_report.py` 的**静态最坏链**,运行时峰值降级为趋势参考。 +- 判据一改要同步**所有落点**:判据正文、跑判据的脚本、记录判据的文档三处一起改,只改一处等于各说各话。 ## 成本模型(实测,估算用) @@ -42,8 +139,18 @@ whenToUse: 新增任务/加大缓冲/改栈/接入日志之前之后、链接报 - **自研代码禁 C 库堆**(MicroLIB 堆仅 512B):只用 `pvPortMalloc/vPortFree`(FreeRTOS 堆), 模拟器 CRT 堆照不出 MCU 堆分配失败。 +## 验证(这轮测量算数了吗) + +1. 报告头 `- Source:` 的相对路径落在**我要量的那棵树**下(不是裸文件名,更不是别的树); +2. `- Linker run:`(或 .htm 的 `Last Updated:` / mtime)与本轮那次构建对得上,且**不同于**上一轮那次的时刻 + ——相同就是复述,回去先做一次构建再比; +3. 树身份已按 `tree/commit + 时刻 + 产物来源` 写进提交正文/台账,而不是只留在终端输出里; +4. 结论里互比的数字必须来自**同一棵树同一次链接**;跨树比较要各自给出树与时刻; +5. 涉及劣化判定时注明依据的是静态最坏链(硬线)还是运行时峰值(趋势),并已计入跨启动抖动。 + ## 其它项目怎么套 模式通用:链接器 map + 调用图 htm 是**免硬件**的栈可观测源;固定"改前改后各跑一次 -报告 + 运行时水位叠加 + 余量阈值"三件套。本仓库两个报告脚本按 Keil 输出格式解析, -换工具链(GCC newlib + `-fstack-usage`)需替换解析层,工作流不变。 +报告 + 运行时水位叠加 + 余量阈值"三件套,**外加一条身份口径——脚本按自身位置定位产物, +跨树比的是"两棵树各自的脚本 + 各自的链接时刻",重跑同一份产物永远只是复述**。 +本仓库两个报告脚本按 Keil 输出格式解析,换工具链(GCC newlib + `-fstack-usage`)需替换解析层,工作流不变。 diff --git a/.dsh/skills/serial-boot-test/scripts/verify_log.py b/.dsh/skills/serial-boot-test/scripts/verify_log.py index e2f5a02c..05bf4469 100644 --- a/.dsh/skills/serial-boot-test/scripts/verify_log.py +++ b/.dsh/skills/serial-boot-test/scripts/verify_log.py @@ -16,10 +16,15 @@ [--require-order 'App init OK>Network Init>net init: OK'] [--done-marker 'net init: OK'] [--max-boot-ms 8000] [--json] -退出码(与旧版一致): +退出码(2026-10-27 task-32 起把 2 讲清楚;@test 那层的码不变): 0 = PASS(所有 Required 标记找到,Forbidden 零命中,汇总行要求满足) - 1 = FAIL(缺 Required / 命中 Forbidden / 汇总行要求不满足) - 2 = ERROR(日志文件不存在/不可读) + 1 = FAIL(**设备侧真有坏消息**:缺 Required / 命中 Forbidden / 汇总行不满足 / 顺序或耗时超限) + 2 = **测量没成立**(日志不存在或不可读;或开了 --max-boot-count 却零启动锚点 = 采集器没开、 + 日志被清、或采集没从复位开始)——这**不是**一次设备故障,按码聚合的脚本必须把它和 1 + 分开算,否则"采集断了"会被统计成"又崩了一次"。verdict 字段给 MEASUREMENT_BROKEN, + JSON 里 measurement.* 带三计数明细(payload_lines / boot_anchor_count / exact_hits / + loose_neighbours)。注意优先级:**零锚点但同时有 Forbidden 命中或自检故障记录时仍判 1**, + 测量问题最多在旁边加一行 WARN——绝不能把真崩溃降级成"没数据"。 默认 Required 标记对应 STM32F4-Base 启动序列(见 App/app/app_main.c + App/task/net_task.c), 按测试用例覆盖,例如: @@ -224,6 +229,31 @@ def _reset_cause_of(line, marker): return tail[:j] if j > 0 else tail +# --- measurement-vs-device classification (task-32; bench-runner night rule) --- +# Three counters decide which kind of "nothing found" we are looking at, and they must not +# be conflated: an all-zero read is a BROKEN MEASUREMENT, not a healthy device. +# hits=0, loose_neighbours=0, boot anchors=0 -> measurement broke (wrong file, closed +# serial port, dead collector). Nothing was observed -> no verdict is meaningful. +# hits=0, loose_neighbours>=1 -> the line exists but is damaged (head +# eaten / bytes spliced in) -> that is a real device-side defect, --exact's purpose. +# hits=0, loose_neighbours=0, anchors>=1 -> the boot DID happen; the marker is +# genuinely absent -> allowed to read as a functional miss. +# Reported as its own exit code (2, the existing "measurement/environment did not happen" +# family) so aggregation by exit code never counts a dead collector as a device failure. +def classify_measurement(n_lines, boot_count, boot_check_on, exact_checked, hits, neighbours): + """(broken, reason). Only judges when a boot anchor check was actually requested.""" + if not boot_check_on: + return False, "anchor check off (max_boot_count=0) -> cannot classify" + if n_lines == 0: + return True, "zero payload lines (collector closed / log truncated / wrong file)" + if boot_count == 0: + return True, ("log readable (%d line(s)) but ZERO boot anchor -> capture did not " + "start at a reset" % n_lines) + if exact_checked and hits == 0 and neighbours == 0: + return False, "anchors present: exact markers genuinely absent (functional miss, not measurement)" + return False, "measurement ok (hits=%d neighbours=%d anchors=%d)" % (hits, neighbours, boot_count) + + def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT, show_tail=40, out=print, suite_summary="", selftest_pass=False, selftest_log="", @@ -519,6 +549,39 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT, for ln in lines[-show_tail:]: out(ln) + # ---- measurement vs device (task-32) ---- + n_exact_checked = len(exact or []) + n_exact_hits = sum(1 for e in (res.get("exact_ok") or []) if "hits=0" not in e["detail"]) + n_neighbours = 0 + for e in (res.get("exact_ok") or []) + (res.get("exact_bad") or []): + m = re.search(r"loose_neighbours=(\d+)", e.get("detail", "")) + if m: + n_neighbours += int(m.group(1)) + b_on = max_boot_count > 0 + b_cnt = (res.get("boot") or {}).get("count", 0) + broken, m_reason = classify_measurement(len(payloads), b_cnt, b_on, + n_exact_checked > 0, n_exact_hits, n_neighbours) + res["measurement"] = {"broken": broken, "reason": m_reason, "payload_lines": len(payloads), + "boot_anchor_count": b_cnt, "anchor_check_on": b_on, + "exact_checked": n_exact_checked, "exact_hits": n_exact_hits, + "loose_neighbours": n_neighbours} + # A broken capture must never mask real device-side bad news: if the log carries + # Forbidden hits or selftest fault records, that evidence stands and we report FAIL(1) + # with the measurement problem noted on top of it (worst outcome = a real crash quietly + # re-labelled as "no data"). + if broken and (hits or selftest_fail): + out("") + out("[WARN] capture also looks incomplete: " + m_reason) + out(" but device-side bad news is present -> verdict stays FAIL, not MEASUREMENT-BROKEN") + broken = False + if broken: + out("") + out("[MEASUREMENT-BROKEN] " + m_reason) + out(" no verdict is meaningful here -- fix the measurement first (open the port,") + out(" assert the collector is alive, capture from a reset). Exit 2, NOT 1:") + out(" a dead collector must not be counted as one more device failure.") + return finish(2) + out("") if (missing or hits or bad_summary or selftest_fail or boot_issue or order_issue or timing_issue or exact_bad or pair_bad): @@ -579,6 +642,19 @@ def selftest(out=print): chk("blind spot proven: legacy substring PASSES where --exact FAILS", legacy_present and not exact_ok_here, True) + # measurement-vs-device classification (task-32): the three-counter rule IS this function, + # so it is pinned here -- an all-zero read must not be reportable as a healthy device. + chk("measure: good capture -> not broken", classify_measurement(137, 1, True, True, 1, 0)[0], False) + chk("measure: zero payload lines -> broken", classify_measurement(0, 0, True, True, 0, 0)[0], True) + chk("measure: readable but no boot anchor -> broken", + classify_measurement(1, 0, True, True, 0, 0)[0], True) + chk("measure: anchor check off -> abstain (non-boot logs stay usable)", + classify_measurement(0, 0, False, True, 0, 0)[0], False) + chk("measure: damaged lines are device-side, not 'no data'", + classify_measurement(120, 1, True, True, 0, 3)[0], False) + chk("measure: anchors present + no neighbour = functional miss", + classify_measurement(120, 1, True, True, 0, 0)[0], False) + bad = 0 for name, ok, detail in cases: out("{} {}".format("[OK] " if ok else "[FAIL]", name)) diff --git a/docs/子系统设计/看门狗设计说明.md b/docs/子系统设计/看门狗设计说明.md index f7e365e6..365adc2f 100644 --- a/docs/子系统设计/看门狗设计说明.md +++ b/docs/子系统设计/看门狗设计说明.md @@ -493,7 +493,7 @@ snapshot == required → miss_streak=0、证据门闩复位、喂狗 看门狗是本项目**第一次**把 CubeMX 生成流程引入可靠性关键路径,由此沉淀出与看门狗本身无关的通用方法论。`AGENTS.md`「平台约束」指向本节,改动 `.ioc`/`Src/`/`Inc/` 前请先读。 -> **2026-10-26(道G task-26)一句话登记,机制不在本文复述**:四个 Fault 与栈溢出 / 断言钩子的**崩溃上报发送口已归一**进控制台单写者(`dbg_console_write_raw` + 一次整行独占,不再自行调用 `HAL_UART_Transmit`),机制正文与残余见 `docs/子系统设计/控制台单写者日志设计说明.md` §4.4.1。本节只关心一件事:这些实现仍须整体待在 `Src/freertos.c` 的 `USER CODE BEGIN 4/5` 区与 `Src/stm32f4xx_it.c` 的 `USER CODE BEGIN 0` 区(§10.2 的 L2),改完跑 `scripts/check_cubemx_survival.py`(§10.3)。 +> **2026-10-26(道G task-26)一句话登记,机制不在本文复述**:四个 Fault 与栈溢出 / 断言钩子的**崩溃上报发送口已归一**进控制台单写者(`dbg_console_write_raw` + 一次整行独占,不再自行调用 `HAL_UART_Transmit`),机制正文与残余见 `docs/子系统设计/控制台单写者日志设计说明.md` §4.4.1。本节只关心一件事:这些实现仍须整体待在 `Src/freertos.c` 的 `USER CODE BEGIN 4/5` 区与 `Src/stm32f4xx_it.c` 的 `USER CODE BEGIN 0` 区(§10.2 的 L2),改完跑 `scripts/check_cubemx_survival.py`(§10.3)。同族另一笔只登记指针:断言钩子与临界区在控制台路径上互达会构成**幻影环**,armlink 的 `In Cycle` 记账随环形状波动(同一 port.o 三棵树实测每链差 0~80 B,休眠≠解耦),三树取证、量化表与「configASSERT 裁空宏 ≠ 优化收益」的钉死句见 `docs/内存管理/RAM使用分析与优化方案.md` §4.0.7(道G task-38)。 ### 10.1 覆盖史(三次重生成) diff --git a/docs/测试说明/启动日志原子性复验流程.md b/docs/测试说明/启动日志原子性复验流程.md index ef02504f..2be68bf7 100644 --- a/docs/测试说明/启动日志原子性复验流程.md +++ b/docs/测试说明/启动日志原子性复验流程.md @@ -36,7 +36,7 @@ | `--head-char C` | 命中行(去残留后)必须以 C 开头,如 `[` | 崩溃日志的行首形态本身就是判据 | | `--pair FIRST THEN` | 恰好一条 FIRST,且其后**只有一条** THEN(跨复位配对) | 崩溃遗言必须与下一次启动的 `reset cause:` 成对;出现两条 = 有未记录/重复复位 | | `--include-comments` | 不跳过 `#` 行 | 默认**跳过**:归档证据文件带 ASCII 溯源头,头里提到 `[ERR]` 不该触发 Forbidden(工具级假红防线) | -| `--selftest` | 跑内置 16 条判别用例后退出,无需日志与硬件 | "不可能失败的 PASS 就是空跑"——用例里含交错、缺行首、残留 1/2、配对错序,以及**"旧子串口径在同一条缺陷行上判 PASS"** 这一条盲区证明 | +| `--selftest` | 跑内置 **22 条**判别用例后退出,无需日志与硬件(含下表三计数分类器) | "不可能失败的 PASS 就是空跑"——用例里含交错、缺行首、残留 1/2、配对错序,以及**"旧子串口径在同一条缺陷行上判 PASS"** 这一条盲区证明 | 用法示例(一次仅复位启动的原子性判定)。四个行级开关**也已接进 `run_boot_test.py`**, 所以 @test 闭环里直接加 `--exact ... --pair ... --allow-residual 1 --head-char '['` 即可,无需单独跑本脚本: @@ -50,6 +50,22 @@ --max-boot-count 1 --show-tail-lines 0 ``` +**退出码与三计数(task-32 定稿)——判据必须自己说清「没启动」与「没采到」**: + +| 码 | 含义 | 触发条件 | 聚合口径 | +|---|---|---|---| +| 0 | PASS | 标记齐、Forbidden 零、锚点合法 | 一次好启动 | +| 1 | **设备侧真有坏消息** | 缺 Required / 命中 Forbidden / 顺序或耗时超限 / 整行锚定失配 | 记一次设备故障 | +| 2 | **测量没成立** | 日志读不到;或开了 `--max-boot-count` 却**零启动锚点**(采集器没开、日志被清、采集没从复位开始) | **不得**计入设备故障 | + +三计数读法:`hits=0 + loose_neighbours=0 + 锚点=0` = 采集断了,先修测量,此时任何通过都无意义; +`hits=0 + neighbours≥1` = 行被啃(真缺陷,正是 `--exact` 要抓的); +`锚点≥1 + hits=0 + neighbours=0` 才允许当"功能缺失"看。 +优先级铁律:**零锚点但同时有 Forbidden 命中或自检故障记录 → 仍判 1**,测量问题只在一边加 WARN +——绝不能把一次真崩溃降级成「没数据」(实测已按此口径验过:造一份"零锚点但含 `[ERR]` MEMFAULT"的日志, +工具仍判 FAIL 并另加一行 WARN,没有把崩溃改判成测量问题)。 +`--selftest` 里钉了 6 条分类器用例;把分类器改成常量返回"不坏",自检立刻红(真跑过)。 + ## 3. 固定动作(顺序即判据,别调) 1. **环境**:继承完整环境,只补 `DTU_KEIL_UV4` / `DTU_STLINK_CLI`,并把 `TEMP`/`TMP` 指到真实目录。 @@ -70,10 +86,12 @@ 7. **非空跑对照**:每换一版校验器,至少跑一次 `--selftest`,并把**缺陷发生时的原始捕获**再判一遍——必须 FAIL。 8. **收尾**:最后一次硬件动作是 `cmp`(排在全批日志之后);台账与证据按 `boot_log_data/README.md` 的口径归档。 -## 4. 两条容易踩的纪律 +## 4. 三条容易踩的纪律 1. **故意复位的腿不得并入"连续仅复位不重烧"的计数**。崩溃注入腿(MemManage/栈溢出钩子)本身就要复位设备, 并进常规序列会被下一个人数锚点数判成异常。它必须单独跑、单独进台账、标题写明"本腿含故意复位"。 + **同一条口径覆盖"前面带烧录的圈"**:烧录启动之后紧跟的那一次启动不属于"连续仅复位不重烧", + 报数写成两句并列——「烧录启动 ×1(不进序列)+ 仅复位 ×N」;若那一圈连字节都没采到,则**两条独立理由**都排除它(没数据谈不上计数)。 2. **崩溃腿的判据是原子性,不是 `[ERR]` 为零**。内核故障钩子的输出**本身就以 `[ERR]` 开头** (`[ERR] MEMFAULT …` / `[ERR] STACK OVERFLOW task=…`),在同一条路径上要求 `[ERR]=0` 等于要求它别工作; 而**产品构建的正常启动根本不走 IPSR 路径**(多段裸发只在 `Src/freertos.c` 的两个内核钩子里), @@ -97,7 +115,17 @@ - ⚠ **脚手架 `*.json` 一律不得放 `_wt/.state/`**:那是 task-worktree 的**台账目录**,脚本把每个 `*.json` 当台账读,实测 `task_worktree.py list` 直接崩在 `KeyError: 'id'`。放 `_wt/scratch/`。 -## 7. 关联 +## 7. 台架卫生清单(每一条都是实测踩过的,不是设想) + +1. **每个台架窗口开头、以及每一圈复位之前**:`serial_tool.py open` + `status` 断言 `port.ready=true`,并**观察 `rx_lines` 在几秒内增长**。 + 实测:常驻 daemon 会死(今晚 pid 18020 跑了 156589 s 后没了),而 `status` 会**自动拉起一个新 daemon**,于是它一直"正常"地报 `ready=false / rx_lines=0` —— 端口状态**不会**跨小时存活,`@test` 开过也不算数。 +2. **`serial_tool.py log / status / clear` 返回 JSON,不是纯文本**。按 `[RX]` 行首过滤纯文本会得到**恒 0 行**且看起来"跑成功了"。 +3. **Forbidden 令牌不得落在英文单词内部**:裸 `Fault` 命中 `de`+`fault` → 打到 `[FTP] using factory default password` 与 `[STACK] default peak_used`(实测假红两次)。四个 Fault 的崩溃日志都以 `[ERR]` 开头,列 `[ERR]` 就够,要加就加 `MEMFAULT/BUSFAULT/USAGEFAULT/HardFault`。 +4. **工具输出别混流**:非零退出时 stdout(结果)与 stderr(诊断)拼接后再 `JSON.parse` 必崩;解析只吃 stdout。 +5. **给同事/lead 发消息时,正文里的引号用全角「」**:在 JS/TS 双引号字符串里放 ASCII `"` 会直接语法错,消息发不出去(本人一晚三次)。 +6. **判据落点顺序**:先 grep 谁在消费这个码/字段(今晚的唯一消费者 `run_boot_test.py` 只对 `rc==0` 分支),再决定动不动语义。 + +## 8. 关联 - 修法与设计口径:`../子系统设计/控制台单写者日志设计说明.md`(O1 两实例与字节账、§4.4.1 崩溃路径验证设计) - 闭环与标记口径:`./自动化测试流程.md` diff --git a/docs/测试说明/实机测试实施指南.md b/docs/测试说明/实机测试实施指南.md index 0f1f5ca6..e406e744 100644 --- a/docs/测试说明/实机测试实施指南.md +++ b/docs/测试说明/实机测试实施指南.md @@ -437,6 +437,21 @@ CH395F / STORAGE / GD5F,未含 RTC;怀疑 RTC 构建下 `testTask` 不被创 (t≈0.6s / t≈2.8s,snap 仅缺 UI 位),NET_READY 解除后立即恢复——**未达 800ms 停喂 阈值、不触发复位**,属分片心跳与 100ms 抽样边界的固有交错,非回归; 处置:见到该行不必恐慌,**连续**出现 `task stall` 行/复位才是真停摆。 +9. **导航类自动化脚本默认零 ENTER**(2026-10-26 台架实证,task-41 收口;口径也见 + `启动日志原子性复验流程.md`):装置维护的 **强制复归 / 清除记录 是两段式确认**—— + 第一次 ENTER 只把该行标成「确认?」,**第二次 ENTER 才真执行**(复归保护位并写 SOE / + 清 SOE·运行·操作记录)。因此任何"认不出屏就按 ENTER 试一下"的回收逻辑,落到这两页上 + 等于**替操作员按下执行键**;更隐蔽的是它只在画面异常时才发作(新字库缺字、白框、状态行 + 变化——恰恰是回归最想抓的那几帧),平时零流量、改动时无声。 `scripts/board_ui_nav.py` + 的两条硬闸与 `--selftest-quick` 里的 **7 条常驻用例**(假设备 + 内存空白帧 + 按键只记账, + **不连 7003、不开串口、不碰台架**,提交钩子 `uinav-selftest` 就跑这档)把它钉成回归: + ① UNKNOWN 退避的 ENTER 上限 `BACKOFF_MAX_E`(政策钉死 ≤1,需要第二次就当场停机并 dump 帧, + 报错文案点名执行类风险);② `EXEC_PAGE_IDS`(clear / reset)**默认拒绝下钻**且拒绝发生在 + **任何键投出去之前**(真机实测:连轨迹文件都不生成),显式 `--allow-exec-pages` 才放行并打警告。 + 用例还钉住**两个常量本身**:上限被调到 2 以上、清单被清空、或默认值被翻成"允许"—— + 都必须在 `--selftest-quick` 里报红(这三条都用"故意改坏再跑"逐条证过能红,不是空跑)。 + 通用要求:**导航 = 移光标 + 取帧;ENTER 只能出现在明确、非执行类的用例步骤里**; + 要看执行类页面的显示正确性,去模拟器黄金参考(那里没有真机后果)。 --- @@ -481,6 +496,7 @@ CH395F / STORAGE / GD5F,未含 RTC;怀疑 RTC 构建下 `testTask` 不被创 | 日期 | 变更 | |---|---| +| 2026-10-27 | **§9 新增第 9 条「导航类自动化脚本默认零 ENTER」**(task-41):装置维护两页是两段式确认、第二次 ENTER 即真执行,故导航器禁止用 ENTER 从未知状态脱身;`scripts/board_ui_nav.py` 两条硬闸(`BACKOFF_MAX_E` ≤1 停机、`EXEC_PAGE_IDS` 默认拒绝且投键前即拒)做成 `--selftest-quick` 7 条常驻用例(假设备零注键,提交钩子跑这档),并钉住常量本身(上限调高 / 清单清空 / 默认翻成允许 → 报红,三条均实测能红) | | 2026-10-03 | **步骤 5/6/7 闭环**:§6 GD5F 错误通道回归 PASS(`=== GD5F2GQ5UE Tests: 16/16 PASSED (failed=0) ===`;E-10/42/53/54/56/57 全部销项;顺带发现并修复编排器判据缺口——GD5F 的 TC-GD5F-704 故意触发 `[ERR]` 回落轮询,`run_all_suites.py` 改为**按套件**覆盖 `--forbidden`,与 `verify_log.py` 说明一致)→ §1/§6 改「✅ 已闭环」。§7 网络稳定性:**7.1 正常路径 ✅**(V5 120/120 FTP、零复位;负向路径转 `N-16`,台架拓扑受限);**7.2 N-2 ✅判据②/未复现判据①**(丢弃可观测性代码已有:`console_log.c` `s_con_drop`+`[ERR] CONSOLE dropped`、`sys_log_mcu.c` 队列满 `DBG_ERROR`、TX 僵尸通道自愈;V5 30 min 日志间隔分析最大静默 41s 且在开机边界、运行期 24 处 >10s 静默**全为 ~28s 且均在 `e02_probe` 窗口内**=N-17 同源)→ N-2 降级低风险观察项;**7.3 N-3 ⬜ 待 PC peer** → §1/§7 改「◐ 部分闭环」。§8 工程卫生:S5 `test-cppcheck` 钩子 PASS(仅 warning,排除模拟器桩);S4 `scripts/check_test_traceability.py` 落地并跑通(**30 处差异**,默认 warn-only);**待核实项 `TEST_SUITE_SELECTED` 实机核实 = 死代码**(全仓无使用点;`start_test_task()` 直接按宏分支,RTC 套件实际正常执行;真缺口 = 缺 `=== RTC Tests: N/M PASSED ===` 汇总行,已补 `test/board/sd2506_test_task.c` 并修正 Phase 级汇总累计问题;`test/board/test_config.h` 补齐 RTC 并注释"当前无引用")→ §1/§8 改「✅ 已闭环」 | | 2026-10-03 | **步骤 3/4 闭环**:V2/V4/V5 台架验收通过(V2 任务饿死声明 stall 后 ≤2.5s `reset cause: IWDG`;V4 关中断死循环每 ~4.9s 稳定 IWDG 复位;V5 30 min / **120×FTP 全 PASS、零 IWDG 复位**)→ §1 总览与 §4 状态改「✅ 已闭环」,实施细节与证据移入 `子系统设计/看门狗设计说明.md` 变更记录。S11 堆告警代码落地、V5 全程未触发(`min_ever=4728 B` > 3072 B)→ §5 改「✅ 已闭环」,基线入 `内存管理/RAM使用分析与优化方案.md`。新登记 `待处理内容.md` **N-17**(V5 期间 44 次 stall 与 `e02_probe` 的 time_sync 30s 掉线+监听重建重合;隔离实验「仅 FTP 120s = 0 stall」证实探针场景专属) | | 2026-10-03 | **重构为纯执行流程**:确立单一组织逻辑——本文 = **按顺序执行**的上板测试流程(步骤 1~7 + 状态标记),参考内容(§9 执行注意 / §10 已完成事项去向 / §11 引用闭环)后置。已完成事项(S7、A1~A4)的实施细节与真机证据**移出本文**,§10 只留「事项 → 去向」指针;§3 只留复跑命令与判据;§1「事项总览」改「执行流程总览」(步骤/状态列) | diff --git a/docs/测试说明/自动化测试流程.md b/docs/测试说明/自动化测试流程.md index 600ad0db..6ff78b69 100644 --- a/docs/测试说明/自动化测试流程.md +++ b/docs/测试说明/自动化测试流程.md @@ -26,7 +26,7 @@ | `.dsh/skills/serial-boot-test/scripts/run_boot_test.py` | `.dsh/skills/serial-boot-test/scripts/run_boot_test.py` | **一键闭环**:open + clear → 烧录 → 固件身份戳(从 hex 抠 `fw build:`)→ wait-idle(静默 8s/超时 30s/首字节 12s)→ 提取 `[RX]`(每行加 `[+Nms]` 时基)→ 回读校验(`stlink_flash.py cmp`)→ verify;`--keep-out` 清旧产物;退出码 0=PASS/1=FAIL/2=采集·烧录·回读错误/3=用法错误 | | `.dsh/skills/stlink-flash/scripts/stlink_flash.py` | `stlink-flash` skill | ST-LINK CLI 封装:`flash [hex]`(烧 + `-Rst -Run`)/ **`cmp [hex]`(回读比对,2026-10-10 加)**/ `reset`(只复位)/ `hard`(nRST 脉冲,CLI 拒 `RM=` 自动降级)/ `halt`/`run`/`list`;原 `MDK-ARM/reset.bat` 已于 2026-10-10 删除并全并入本 skill | | `.dsh/skills/serial-assistant/scripts/serial_tool.py` | `.dsh/skills/serial-assistant/` | Python 串口助手(常驻 daemon + 薄 CLI):ports/open/send/status/log/clear/wait-idle/selftest;全量日志 `state/serial.log`(20MB 自动截头) | -| `.dsh/skills/serial-boot-test/scripts/verify_log.py` | `.dsh/skills/serial-boot-test/scripts/verify_log.py` | 对日志做标记校验:Required/Forbidden(**命中逐行打印行号+内容**)、启动锚点计数 `--max-boot-count`、原因白名单 `--expect-reset-cause`、标记顺序 `--require-order`、开机耗时 `--done-marker`/`--max-boot-ms`、`--json` 结构化输出、**整行锚定 `--exact` / 跨复位配对 `--pair` / 崩溃腿 `--allow-residual`+`--head-char` / 判别自检 `--selftest`**;退出码 0/1/2。子串 Required 照不出"行首被吃掉"那一类行级损坏 → 行级完整性一律用 `--exact`,口径与固定动作见 `启动日志原子性复验流程.md` | +| `.dsh/skills/serial-boot-test/scripts/verify_log.py` | `.dsh/skills/serial-boot-test/scripts/verify_log.py` | 对日志做标记校验:Required/Forbidden(**命中逐行打印行号+内容**)、启动锚点计数 `--max-boot-count`、原因白名单 `--expect-reset-cause`、标记顺序 `--require-order`、开机耗时 `--done-marker`/`--max-boot-ms`、`--json` 结构化输出、**整行锚定 `--exact` / 跨复位配对 `--pair` / 崩溃腿 `--allow-residual`+`--head-char` / 判别自检 `--selftest`(22 条,含三计数分类器)**;退出码 **0=PASS / 1=设备侧真有坏消息 / 2=测量没成立**(读不到日志,或开了锚点计数却零锚点——采集断了不算设备故障)。子串 Required 照不出"行首被吃掉"那一类行级损坏 → 行级完整性一律用 `--exact`,口径与固定动作见 `启动日志原子性复验流程.md` | ## 判定标准 diff --git a/docs/环境与部署/本地工具路径环境变量方案.md b/docs/环境与部署/本地工具路径环境变量方案.md index 611b23e7..f98ca74c 100644 --- a/docs/环境与部署/本地工具路径环境变量方案.md +++ b/docs/环境与部署/本地工具路径环境变量方案.md @@ -85,10 +85,73 @@ $env:DTU_STLINK_CLI = 'D:\Tools\ST-LINK Utility\ST-LINK_CLI.exe' --- +## 5. 三个解释器各能跑什么(2026-10-27 实测,道G task-33) + +> 起因:有队友在 `.venv` 里 `import identify.tags` 失败,又用 `pip` 查不到 `pre-commit`,据此判定 +> 「`.venv` 里的 pre-commit 安装是残缺的」。**复测结论:环境不残缺,两条观测都成立但推错了因**—— +> 前者是版本本身没有那个模块(下表第三行),后者是查错了对象(下表第二行)。 +> 取证方法固定:同一个探针脚本(`sys.executable` + 逐个 `import` + `importlib.metadata.version`) +> 分别喂给三个解释器;脚本放仓外临时目录,不入库、不改任何环境。 + +| 解释器 | 身份 | 门禁相关模块 | 第三方包 | 结论 | +|---|---|---|---|---| +| `.venv\Scripts\python.exe` | 3.12.4(**基底是 Anaconda**,非干净 CPython) | `identify` 可导入、`pre_commit` 可导入;`identify.tags` **无此模块** | fontTools 4.51.0 / pillow 10.3.0 / pyserial 3.5 / pyyaml 6.0.3 / cfgv 3.5.0 全在,`pre-commit` 与 `identify` 的 dist-info 齐全(`pip show` 可查) | **就是钩子的执行环境**:`.git/hooks/pre-commit` 里 `INSTALL_PYTHON` 写死指向它 | +| `C:\Python314\python.exe` | 3.14.7,PATH 首位 | 三个全缺 | **一个都没装**(`pip` 元数据全 `PackageNotFoundError`) | 「查不到 pre-commit」的现场就是它;`language: system` 的 42 条 entry 的裸 `python` 落在这里 | +| `C:\Users\...\Python311\python.exe` | 3.11.9,PATH 次位 | 同上全缺 | 同上全缺 | 与门禁无关,列出来只为排除「装了但找不到」的猜测 | + +**为什么"缺 `identify.tags`"不是故障**:`identify-2.6.19.dist-info/RECORD` 一共 24 行,列出的模块只有 +`__init__.py` / `cli.py` / `extensions.py` / `identify.py` / `interpreters.py` / `vendor/licenses.py`—— +**根本没有 `identify/tags.py` 这一条**,而磁盘上的文件与 RECORD 逐项一致。即"元数据完好、文件齐全、 +只是这个版本没有那个模块"。标签常量在这个版本里位于 `identify/identify.py`(`TEXT` / `BINARY` / +`EXECUTABLE` / `ALL_TAGS`),pre-commit 4.6.2 自己也是从那里取的(`clientlib.py` 里 +`from identify.identify import ALL_TAGS`),**全仓没有任何脚本 import `identify.tags`**(实测 grep 零命中)。 +⇒ 需要复算文件类型标签时,正确入口是 `identify.identify`;旧写法 `identify.tags` 在这个版本对上必然失败。 + +### 5.1 新增钩子 entry 的硬规则(今天没炸的真正原因) + +`language: system` 的 entry 不继承 `.venv`:pre-commit 不改 `PATH`,所以裸 `python` 命中 PATH 首位 +(本机 = 3.14.7,**零第三方包**)。实测本仓 44 条 system 钩子里 **42 条 entry 以裸 `python` 开头**, +今天能全绿只因为两件事同时成立: + +1. 那 42 条脚本**只用标准库**(抽测 bom-drift / layer-deps / doc-links 与一套带 harness 的主机单测, + 在 3.14.7 下 exit 0); +2. 唯一需要第三方包的那条(字体幂等性钩子 `scripts/check_generated_fresh.py`)**自带解释器解析**, + 不依赖 entry 用的是谁。 + +⇒ 写新钩子时二选一,别开第三条路:要么脚本只用标准库,要么在脚本内部按 +「显式变量 > 当前解释器 > 仓库 `.venv` > PATH」自行解析并**探针校验**(先例与理由见 +`docs/门禁说明/generated-fresh.md` §4)。**不要**把 entry 写死成某台机器的绝对路径。 + +### 5.2 什么情况下才需要动环境,以及动之前要走死顺序 + +默认动作是**不动**(本节存在即结论:没有需要重装的东西)。只有出现下列**具体现象**才执行修复, +且必须先要到静默窗口——`pre-commit` 与 `.venv` 是全队共享运行面,中途换包会把别人的一次钩子跑打断: + +1. 停表:确认没有人在跑提交/门禁/台架(build 与 bench 两类锁都空闲,见 `task-worktree`); +2. 备份指针:记下 `& '.venv\Scripts\python.exe' -m pip freeze > 仓外文件`(不入库); +3. 只补不重建:优先 `pip install --force-reinstall --no-deps <包>==`, + **不要**顺手 `-U`(那会连带动别人的依赖);确实要整体重建才 `pip install -r requirements.txt`, + 且该文件必须保持纯 ASCII(Windows 下 pip 按代码页解码); +4. 自证(缺一不算修完):`& '.venv\Scripts\python.exe' -m pip show pre-commit` 有版本 → + `-m pre_commit --version` 通 → `scripts/check_generated_fresh.py` 与字体字符集钩子在 `.venv` + 与 PATH 两种解释器下都 exit 0 → 最后跑一次 `scripts/ci_local.py`; +5. 回滚:新装/升级出问题时按第 2 步的 freeze 反向 `pip install <包>==<原版本>`;**别删 `.venv` 重来**, + 删了就把「基底是 Anaconda」这条不可复制性也一起删掉(换机复现不了,见本文 §3 与 `docs/环境与部署/项目部署环境要求.md`)。 + +### 5.3 仍开着的账(不在本次范围,登记给对应属主) + +- `DTU_PRECOMMIT_PYTHON` 本机当前**未设**,靠 §1.1 的优先级自动落到 `.venv`;如果哪天 PATH 首位换了 + 解释器,静态门禁档就会跟着换——把它显式设成 `.venv` 是一条零成本的加固(改的是本机环境,不动仓库)。 +- 42 条裸 `python` entry 与两个解释器的版本差(3.14 vs 3.12):今天没有可观测的行为差,但这是 + 「靠巧合不炸」。要不要把 entry 统一改成显式解释器解析,属门禁属主的决定(同一族的先例见 §5.1)。 +- 本机的 `.venv` 基底是 Anaconda 发行版,`python -m venv` 在别的机器上给出的不是同一个东西; + 这条差异目前无人记录在 `docs/环境与部署/项目部署环境要求.md` 里。 + + ## 变更记录 | 日期 | 变更 | |---|---| -| 2026-10-02 | **文档改造**:删除规划阶段内容(设计结论、路径耦合盘点、代码改造映射、分阶段推广与验收、风险与边界、后续任务拆分),保留并重组为现行机制说明——§1 变量清单(含 Python 优先级特别说明)、§2 解析与失败语义、§3 本机配置方法(会话/用户/runner 三级)、§4 验收判据、变更记录;章节重编号,外部引用同步(`gen_refgraph.py` §3→§1、`ci_local.py` §3.1→§1.1、`Gitea_Runner部署指南.md` §5.3→§3.3) | +| 2026-10-27 | **新增 §5 解释器实测矩阵(道G task-33,只读诊断,未动任何环境)**:三个解释器(仓库 `.venv` / PATH 首位 / PATH 次位)逐个探针实测,钉住三条事实——① `.venv` **不是残缺**:`pre-commit` 与 `identify` 的 dist-info 齐全(`pip show` 可查),`identify-2.6.19` 的 RECORD 里本就没有 `tags.py`,标签常量在该版本的 `identify.identify` 里,pre-commit 自己也是从那里取(旧写法 `identify.tags` 在这个版本对上必失败,全仓零引用);② 钩子执行环境被 `.git/hooks/pre-commit` 的 `INSTALL_PYTHON` 钉死为 `.venv`,而 `language: system` 的 42 条 entry 用裸 `python` → 落到 PATH 首位那个**零第三方包**的解释器,今天没炸是「42 条全只用标准库 + 唯一需要第三方包的那条自带解释器解析」两件事同时成立,据此写下新增 entry 的硬规则(§5.1);③ 「查不到 pre-commit」的现场是查了 PATH 上的解释器。另附 §5.2「默认不动环境;确需修复时的死顺序与回滚(只补不重建、freeze 备份放仓外、按 freeze 反向回滚、禁止删 `.venv` 重来)」与 §5.3 三条待属主的账。| 2026-10-02 | **文档改造**:删除规划阶段内容(设计结论、路径耦合盘点、代码改造映射、分阶段推广与验收、风险与边界、后续任务拆分),保留并重组为现行机制说明——§1 变量清单(含 Python 优先级特别说明)、§2 解析与失败语义、§3 本机配置方法(会话/用户/runner 三级)、§4 验收判据、变更记录;章节重编号,外部引用同步(`gen_refgraph.py` §3→§1、`ci_local.py` §3.1→§1.1、`Gitea_Runner部署指南.md` §5.3→§3.3) | | 2026-10-02 | **实施完成**:阶段 A~D 落地。① 10 处调用点全部改为"`DTU_*` 变量优先 + 自动发现回退"(显式值无效 = 硬报错、不静默回退):`build/download/reset.bat`(goto 标签式解析,纯 ASCII)、`ci_local.py`(`DTU_PRECOMMIT_PYTHON` 严格校验)、`ci.yml`(`DTU_PRECOMMIT_PYTHON`/`DTU_CPPCHECK`/`DTU_MSVC_CL`/`DTU_WINDOWS_KITS_ROOT`,run 块纯 ASCII)、`gen_refgraph.py`(`DTU_GRAPHVIZ_DOT`→PATH,保留无渲染降级)、`unit_test_harness.py`(`DTU_MSVC_CL`/`DTU_WINDOWS_KITS_ROOT` + `%ProgramFiles*%` 推导取代静态盘符列表)、`build_sim.ps1`(`DTU_VSWHERE`→`%ProgramFiles(x86)%` 推导,去字面 C 盘回退)、`make_font.py`(`%WINDIR%` 推导);`_build.cmd` 确认零调用后删除。② 本机按 §3.2 配置用户级 `DTU_KEIL_UV4`/`DTU_STLINK_CLI`(其余工具本机均可自动发现,未设变量),验证:`@build` BUILD_OK 0/0、`ci_local.py` L0 15 钩子 + L0.5 278/278 断言全绿、refgraph 重生成(含修复 `引用关系图.json` 末尾换行缺失导致的 end-of-file-fixer 抖动,生成器补 `f.write("\n")`)、`build_sim.ps1` vswhere 推导命中 VS2026(当时模拟器 CMake 配置期暴露**既有**问题:`App/cfg_file.c` 已移入 `App/cfg/` 但 `simulator/CMakeLists.txt` 未同步——与环境变量改造无关;2026-10-02 已修复:CMakeLists 同步 `App/cfg/` + `test/board/` 引用,模拟器全量构建通过)、`reset.bat list` 识别探针。③ 文档收口:`AGENTS.md`/`MDK-ARM/README.md`/`项目部署环境要求.md`(新增 §2.1.1)/`Gitea_Runner部署指南.md`(新增 §5.1.3 机器级变量)/`CI配置指南.md`/`pre-commit使用说明.md`/字体/测试流程/引用图说明/ST-LINK 说明/实机规划/`scripts/README.md` 共 12 篇同步为变量口径。④ `requirements.txt` 增 `pre-commit==4.6.2`(重建 `.venv` 即可跑 L0)。⑤ 全量 `pre-commit --all-files` + `ci_local.py` 通过。待办:新 workflow 推送远程后跑一次 CI 两 job 闭环验证;第二台不同安装路径机器的交叉验证(操作清单已固化入 `项目部署环境要求.md` §2.12) | | 2026-10-02 | 初版规划:盘点 BAT/Python/PowerShell/Gitea workflow 中机器相关工具路径;定义 `DTU_*` 变量清单、优先级、用户级与 SYSTEM runner 配置边界;规划 4 阶段迁移及离线/实机验收范围。未改工具调用代码 | diff --git a/scripts/board_ui_nav.py b/scripts/board_ui_nav.py index caf60e13..4b0d93d7 100644 --- a/scripts/board_ui_nav.py +++ b/scripts/board_ui_nav.py @@ -841,6 +841,136 @@ BOARD_TRUTH = { } +def selftest_safety_gates(clf): + """两条执行风险硬闸的常驻回归用例(假设备、内存帧、零真机注键)。 + + 为什么要常驻这两条:闸只在真出事的那一屏才起作用,平时没人碰,于是"改了个常量 + 就把闸拆掉"这种回退不会有任何人察觉。本用例不连 7003、不开串口、不碰台架—— + 设备是一帧内存里的空白位图,按键只记账、不投递。 + 覆盖的两条闸(2026-10-26 台架叠险 → ac9ca33f 收口,理由见文件头): + 闸① UNKNOWN 退避里的 ENTER 次数上限(BACKOFF_MAX_E):需要第二次就当场停机; + 以及显式改用无 ENTER 键序(--backoff-keys sl)时,一个 ENTER 都不许投出去; + 闸② 执行类页面(EXEC_PAGE_IDS)默认拒绝下钻,且拒绝发生在**任何键投出去之前**; + 显式放行(--allow-exec-pages)后不得再报"刻意跳过"——放行 = 接受后果。 + 返回 (用例数, 失败列表),与其它 selftest_* 同形,失败非 0 退出由调用方决定。 + """ + g = globals() + saved = {"classify": g["classify"], "ALLOW_EXEC_PAGES": g["ALLOW_EXEC_PAGES"], + "BACKOFF_KEYS": g["BACKOFF_KEYS"], "BACKOFF_MAX_E": g["BACKOFF_MAX_E"]} + bad = [] + n = 0 + + class FakeDevice(object): + """假设备:grab 永远给同一帧内存位图,key 只记账不投递(不连板、不连模拟器)。""" + + def __init__(self, fr): + self.fr = fr + self.keys = [] + + def grab(self): + return self.fr + + def key(self, ch): + if ch not in KEYS: + raise ValueError("未知键 " + repr(ch)) + self.keys.append(ch) + return self.fr + + def stub(kind, extra=None): + def _f(fr, _clf): + st = {"kind": kind, "page": None, "cols": None, "evid": 0, "no": None} + if extra: + st.update(extra) + return st + return _f + + def check(name, ok, detail): + print("%-5s %s %s" % ("[OK]" if ok else "[FAIL]", name, detail)) + if not ok: + bad.append(name + ": " + detail) + + blank = Frame(bytes(FRAME_BYTES), "fake_blank") + try: + # 政策钉:出厂默认必须是"拒绝下钻"。下面所有用例会临时改这两个常量, + # 所以默认值只能在进函数时抓一次(saved)——否则有人把默认翻成 True, + # 用例照样自己设 False 再测,全绿放行一个危险的默认(实测漏过一次)。 + n += 1 + check("闸② 出厂默认即拒绝(ALLOW_EXEC_PAGES 默认 False)", + saved["ALLOW_EXEC_PAGES"] is False, + "模块默认=%r(True 意味着任何人漏写 --allow-exec-pages 也会下钻执行类页面)" + % (saved["ALLOW_EXEC_PAGES"],)) + # ---- 闸①:UNKNOWN 退避最多投 BACKOFF_MAX_E 次 ENTER,第二次当场停机 ---- + g["classify"] = stub("UNKNOWN") + g["BACKOFF_KEYS"] = "esl" + # 故意**不**覆盖 BACKOFF_MAX_E:闸的强度本身要被钉住(有人把上限调到 2 就该红) + dev = FakeDevice(blank) + msg1 = "" + try: + Navigator(dev, clf).to_root(limit=12) + except RuntimeError as exc: + msg1 = str(exc) + n += 1 + e_cnt = dev.keys.count("e") + check("闸① UNKNOWN 退避 ENTER 上限", + ("BACKOFF_MAX_E" in msg1) and e_cnt == saved["BACKOFF_MAX_E"] + and saved["BACKOFF_MAX_E"] <= 1, + "停机消息=%s,投出 ENTER=%d 次(上限配置=%d,政策钉死 <=1),键迹=%s" + % (bool(msg1), e_cnt, saved["BACKOFF_MAX_E"], "".join(dev.keys))) + + # ---- 闸①补:显式无 ENTER 键序时,一个 ENTER 都不许出现 ---- + g["BACKOFF_KEYS"] = "sl" + dev2 = FakeDevice(blank) + try: + Navigator(dev2, clf).to_root(limit=6) + except RuntimeError: + pass + n += 1 + check("闸①补 无 ENTER 键序(--backoff-keys sl)", "e" not in dev2.keys, + "键迹=%s(应不含 e)" % ("".join(dev2.keys) or "-")) + + # ---- 闸②:执行类页面默认拒绝,且拒绝发生在投键之前 ---- + g["BACKOFF_KEYS"] = "esl" + g["ALLOW_EXEC_PAGES"] = False + g["classify"] = stub("MENU", {"cols": [("根", 0)]}) + # 清单被清空 = 闸被拆。这种情况要**报红**,不能"零条用例"悄悄过去,也不能崩 + # (崩了只是噪声,红色才是判定;ng==0 只能兜住"一条都没跑",兜不住"清单空了") + n += 1 + check("闸② 执行类清单完整(清空或缺项 = 拆闸)", + len(EXEC_PAGE_IDS) >= 2 and {"clear", "reset"} <= set(EXEC_PAGE_IDS), + "EXEC_PAGE_IDS=%s" % (sorted(EXEC_PAGE_IDS) or "空")) + for pid in sorted(set(EXEC_PAGE_IDS) | {"clear", "reset"}): + dev3 = FakeDevice(blank) + msg3 = "" + try: + Navigator(dev3, clf).go_page(pid) + except RuntimeError as exc: + msg3 = str(exc) + n += 1 + check("闸② 执行类页面 %s 默认拒绝下钻" % pid, + ("刻意跳过执行类页面" in msg3) and ("真执行" in msg3) + and ("e" not in dev3.keys), + "拒绝理由点名真执行=%s,投出的 ENTER=%d(消息:%s)" + % ("真执行" in msg3, dev3.keys.count("e"), msg3[:36] or "没抛错")) + + # ---- 闸②反向:显式放行后不得再报"刻意跳过"(放行 = 接受后果)---- + g["ALLOW_EXEC_PAGES"] = True + g["classify"] = stub("UNKNOWN") # 放行后走进导航,被闸①停住而不是被闸②拒绝 + dev4 = FakeDevice(blank) + msg4 = "" + try: + Navigator(dev4, clf).go_page(sorted(set(EXEC_PAGE_IDS) | {"clear", "reset"})[0]) + except RuntimeError as exc: + msg4 = str(exc) + n += 1 + check("闸②反向 --allow-exec-pages 放行后不再拒绝", + ("刻意跳过执行类页面" not in msg4) and bool(msg4), + "放行者=%s(消息:%s)" % ("刻意跳过执行类页面" not in msg4, msg4[:36] or "没抛错")) + return n, bad + finally: + for k, v in saved.items(): + g[k] = v + + def selftest_board_frames(clf): """真机留档帧上的分类自检:不需要台架也能证明认屏认得对。""" bad = [] @@ -1032,13 +1162,17 @@ def main(argv): return 0 if "--selftest-quick" in argv: # 提交门禁用的快检档:只核"认屏认得对",不需要模拟器构建产物、不需要台架 - print("[1/2] 仓库黄金参考分类(模拟器与板端同一份 UI 源码 = 语义基线)") + print("[1/3] 仓库黄金参考分类(模拟器与板端同一份 UI 源码 = 语义基线)") nr, badr = selftest_refs(clf) - print("[2/2] 真机留档帧分类(目录缺则整段跳过,不算失败)") + print("[2/3] 真机留档帧分类(目录缺则整段跳过,不算失败)") n, bad1 = selftest_board_frames(clf) - print("汇总(快检):黄金参考 %d 帧错 %d;留档帧 %d 帧错 %d" % (nr, len(badr), n, len(bad1))) + print("[3/3] 执行风险硬闸(假设备、零真机注键,task-41)") + ng, badg = selftest_safety_gates(clf) + print("汇总(快检):黄金参考 %d 帧错 %d;留档帧 %d 帧错 %d;硬闸用例 %d 条错 %d" + % (nr, len(badr), n, len(bad1), ng, len(badg))) print("闭环导航未跑(需 lvgl_sim.exe):完整自检用 --selftest") - return 1 if (badr or bad1 or nr == 0) else 0 + # 硬闸用例一条没跑也算失败:静默跳过等于把闸拆了还显示绿(同 nr==0 的处理口径) + return 1 if (badr or bad1 or badg or nr == 0 or ng == 0) else 0 if "--selftest" in argv: print("[1/3] 仓库黄金参考分类(模拟器与板端同一份 UI 源码 = 语义基线)") nr, badr = selftest_refs(clf) @@ -1050,11 +1184,13 @@ def main(argv): all_ids = [p for (_l, _n, p, _np) in clf.tree if p] m, bad2, log = selftest_closed_loop(clf, all_ids) _dump_trace(log) - print("汇总:黄金参考 %d 帧错 %d;留档帧 %d 帧错 %d;闭环 %d 页失败 %d" - % (nr, len(badr), n, len(bad1), m, len(bad2))) + print("[4/4] 执行风险硬闸(假设备、零真机注键,task-41)") + ng, badg = selftest_safety_gates(clf) + print("汇总:黄金参考 %d 帧错 %d;留档帧 %d 帧错 %d;闭环 %d 页失败 %d;硬闸用例 %d 条错 %d" + % (nr, len(badr), n, len(bad1), m, len(bad2), ng, len(badg))) # 留档帧目录(ci-artifacts/uiwalk,不受版本控制)可以不存在 → 那一段不算失败; # 但黄金参考与闭环必须真跑过:任一为 0 说明这次"自检"其实什么都没检查 - return 1 if (badr or bad1 or bad2 or nr == 0 or m == 0) else 0 + return 1 if (badr or bad1 or bad2 or badg or nr == 0 or m == 0 or ng == 0) else 0 if "--walk" in argv: rc = bench_gate(argv, "--walk") if rc: diff --git a/scripts/ci_local.py b/scripts/ci_local.py index 08b81b56..9a63cbbf 100644 --- a/scripts/ci_local.py +++ b/scripts/ci_local.py @@ -71,6 +71,11 @@ def _global_python_candidates(): L0 全灭就是 workflow 把候选收窄成 PATH python + 仓库 .venv(runner 上两者 都是死路),而真正装了 pre-commit 的机器级解释器(如 C:\\ProgramData\\anaconda3\\python.exe)不再被探测。 + + 本机实测矩阵(哪个解释器装了哪些包、钩子实际跑在谁身上)见 + docs/环境与部署/本地工具路径环境变量方案.md §5:提交期钩子由 .git/hooks/pre-commit + 里的 INSTALL_PYTHON 钉死在仓库 .venv,而 language: system 的 entry 用裸 python 时 + **不继承**它——所以依赖第三方包的脚本必须像本函数这样自己解析解释器。 """ out = [] pf = os.environ.get("ProgramFiles", r"C:\Program Files")