Files
novalon-website/docs/acceptance/2026-09-23-gates/final-verdict.md
T
zhangxiang a0328a623f chore(qa): 验收台账与证据入库 + 构建/部署配置同步
- docs/acceptance/qa-tracker.md:跨周期缺陷单一真源台账(§7=第五轮)。
- 周期 1/2 + iPhone SE/axe 验收证据目录、ACCEPTANCE_REVIEW 快照入库。
- 同步 README/CONTEXT/CLAUDE/DESIGN/testing/deployment/lessons-learned 口径;
  next.config/Dockerfile/nginx/Jenkinsfile/docker-compose/sentry/prisma 对齐
  standalone 产物装配与部署形态。
2026-09-28 10:48:09 +08:00

208 lines
106 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 系统性 Review / 全面测试 / 验收 复测报告 — 2026-09-23
- 分支:`refactor/optimize-ui`(工作树含 **341** 条未提交改动(39 条未跟踪),HEAD 仍是 `f543e47` ⇒ 见 §7 的最高优先披露)
- 环境:Next.js 16.3.0 (Turbopack) / React 18.3 / TS 5 strict / Prisma 6.19 + SQLite / Tailwind 3.4 / Jest + Playwright + lhci + Stryker + axe-core 4.11.4
- 方法:subagent 并行分模块审查 + 分文件所有权修复;主代理逐条复核证据;所有门禁数字为**本轮亲跑**,未引用 2026-09-21 报告的历史值
- 上游输入:`ACCEPTANCE_REVIEW_2026-09-21.md`(判定 **REJECTED**,5 类阻断)——本报告是其**闭环复核**
## 0. 验收结论
**有条件通过(CONDITIONAL PASS)。**
2026-09-21 的 5 类阻断中,**4 类已在代码层闭环并有本轮实测证据**(安全提权/存储型 XSS/上传类型/CI 门禁吞错与 a11y 整族对比度)。判「有条件」而非「通过」的原因**只剩两类**(下面第 3 条原为第三条理由,本轮已修复并复测,保留编号以便对照):
1. **五项验收动作需单独授权,本轮未执行**:`db:seed`、写库 E2E(本轮取证后范围已收窄到 `e2e/cms-workflow.spec.ts` 与 `e2e/user-journey.spec.ts` 两个文件,合计 11 例;此前只登记了前者,低估一半)、视觉基线 `--update-snapshots`、`git commit`/push/PR、Stryker 变异测试(`--inPlace` 会改写工作树,而本树 341 条改动未提交 ⇒ 中途崩溃不可恢复)。
**除这五项之外,本轮把所有能测的都实测了**,可交付的测试凭据为:生产目标 write-free E2E **92 例通过 / 0 skipped**(其中 16 个 GA4 用例首次在产物上真实断言)、`@security` **68 通过 + 4 显式 skipped**、**`@accessibility\|@performance` 层 124 通过 / 4 webkit 失败**(该层此前同样不在任何门禁内,见 N-32)、**`@regression` 全功能层 200 例首次运行**(同一代码三轮 194/1、8、5 的抖动样本,定性见 N-28)、视觉**只读比对** 104 通过 / 21 失败且 **0 例像素不符**、`test:coverage` EXIT=0、真库集成 23 例(`prisma/dev.db` 行数复核未变)、axe 四组 `bgMismatch/contrastNodes/violationNodes` 全 0、Lighthouse 27 次运行四类目 99–100。
⇒ 「有条件」的实质含义因此收敛为两件事:**变更尚未固化为提交**(见第 2 条)+**上述五项动作等授权**;不再包含"某类测试没跑过"。
2. **交付物只是工作树,不是提交**:全部修复未进 git(HEAD 仍为分支起点 `f543e47`,**341 条**未提交改动,其中 39 条未跟踪、85 条是 `e2e/visual-snapshots` 下**已跟踪但被修改**的 PNG)⇒ 不可审、不可回滚、不可复现。**这一条对视觉回归的杀伤力最大**:本轮 104 例通过所比的参照本身就不在版本控制里(详见 §7 的基线时间线)。
3. ~~N-9 门禁可信度缺陷待复测~~ → **已修复并复测通过(本轮收尾完成)**:本地 standalone 产物曾缺 `dist/static`,导致 `check:axe` 与 `npm run lighthouse` 在**全站样式 404** 的裸 HTML 上跑(axe 靠 `bgMismatch` 诚实报红,而 Lighthouse 在 CSS 全 404 下仍 exit 0 —— A-11 的第三个实例)。`postbuild` 修复后重跑,**两条门禁现在都在真实装配产物上出数**:axe `PASSED = true`(四组浏览器×主题,`bgMismatch=0`、`contrastNodes=0`、`violationNodes=0`),Lighthouse **a11y 9/9 URL 全部 100 分、对比度失败节点 0、`aria-required-children` 失败节点 0**(上一轮为 92-97 分 + 10 个失败节点)。数值见 §1。
> 附带一条本轮自查抓到的**门禁回红**:`test:coverage` 在补测后一度 EXIT=1(新测试文件自身的 `Object.defineProperty` 缺 `writable` 把全局 `requestAnimationFrame` 变成只读,使 `afterEach` 的 `mockRestore()` 抛错 ⇒ 整套件 failed to run)。已定位根因并修复,最终树 **132 套件 / 1677 例全通过、EXIT=0、0 条阈值告警**。记为 N-12,同时说明「子代理产出必须逐条复跑」这条纪律本轮真实生效(见 §3)。
> 🔴 **收尾追加(`src/app/**` 专项审查,subagent 扫描 + 主代理逐条复核源码)**:覆盖率门禁的 `collectCoverageFrom` **不含 `src/app/**`**,即所有 route handler 与页面都在棘轮之外。针对这一盲区做专项审查后,**确认并修复 3 项 P1 + 1 项 P2 门禁诚实性问题**:N-18 `draft/disable` 开放重定向(无鉴权,修复前实测会向站外发 307)、N-19 `admin/stats` 只验会话即返回跨模型草稿标题(任何零权限登录账号可读)、N-20 停用/删除账号的已签发令牌在 24h 内仍通过全部 guarded 路由(B-7 的状态校验当时只补在 refresh 链路上)、N-17 `security-headers.spec.ts:364` 以裸 `return` 伪装通过。三处修复各有 **RED 实测**(临时移除 guard ⇒ 相应用例判红 ⇒ 恢复后转绿),另有 2 项留决策(N-21:`'content-item'` 幻影 modelCode、`/admin` 无服务端 gating)。修后复跑:`type-check` EXIT=0、`test:unit` **133 套件 / 1691 例 EXIT=0**、`lint` 0 error / 105 warning。**⚠ 口径提醒**:1691 是 `test:unit` 下的数,与上面 1677(`test:coverage`,套件集不同)不可直接相减比较。**这不改变「有条件通过」的判定,但改变了它的含义**:本轮之前对该盲区的唯一保障是"有人看过",现在多了一条可执行的回归测试。
> 🔴 **第三轮收尾追加(四路并行专项:门禁脚本自身 / 部署层 / E2E 断言质量 / 内容零编造 + 设计约束)**。全部为「subagent 扫描 + 主代理逐条读源码复核」,**未照抄任何一条子代理结论**(其中至少三次纠正了子代理的错误建议:它给的替代选择器 `hero-primary-cta` 不存在、它猜的两处 testid 其实存在、它建议的 `.dockerignore` 一行修会打断 `NEXT_PUBLIC_*` 构建期内联)。战绩:
> - **门禁的门禁**(N-22/N-23/N-24):`check-security-headers.ts` 的 cookie 判定 `status` 写死为字面量 `'pass'`、汇总只看 `headerChecks`、`X-XSS-Protection` 两分支相同、**缺 CSP 只 warn** ⇒ 该安全头门禁对两条主判据永不失败;已修并做**双向对照**(无 CSP 的本地服务 ⇒ EXIT=1/4 fail;真实 standalone 产物 ⇒ EXIT=0/0 fail)。顺带由正向对照挖出 **HSTS 只在边缘注入** ⇒ 该门禁结构上无法在分支产物通过,而 `Jenkinsfile` 打印「检查本分支构建产物」是假的(N-23,未修待决策)。
> - **部署层**(N-25/N-26):限流键取 `X-Forwarded-For` 最左项(客户端自报)⇒ 每请求换头即绕过「5 次/小时」,旧逻辑实测 distinct=3;已抽成 `src/lib/client-ip.ts`(放进覆盖率棘轮内)+ 6 例测试。另有 `.env.production` 携 `JWT_SECRET` 等进入 builder 层、CI 先删 lock 再 `npm ci` 致 lockfile 从未生效、`nginx-static-production.conf` 字体/图片块缺 `@nextjs` 回退——三者分别触碰密钥约定/CI/线上 Nginx,未擅动。
> - **E2E 断言质量**(N-27/N-28):145 个真实 test 块里 5 个零断言、至少 14 处断言被包在 `if (count() > 0)` 里。修掉 6 处(含 `expect(a) || expect(b)` 这个真 JS 逻辑 bug、`text=开始合作` 这个全站不存在的死选择器、`.catch(() => {})` 吞掉跳转失败),我自己两次改错均被四 project 实测否掉后才收敛(教训:桌面-only 直觉在移动 project 必误红)。
> - **内容零编造 + 设计约束**(N-29/N-30):`3ed3afd` 的数字口径机制**只是一个单测里的 7 文件白名单**,其 seed 扫描不含 `SERVICES`/about、禁词扫描不含 `src/lib`+`prisma`,故白名单外无人管;删掉两处凭空数字(`效率提升 40%`、`40%`/`核心成果提升`),其中后者**原本被一条绿色测试正向锁定**,已把该断言反转。另记 AGENTS.md §8 把 `CONTEXT.md` 的三档动效口径压成一句「180–280ms」(照字面审会误伤合法的 150/100ms),已按信源改正。
>
> **最终树门禁**:`type-check` EXIT=0、`lint` 0 error / 105 warning、`test:unit` **134 套件 / 1697 例** EXIT=0、`test:coverage` EXIT=0、`check:a11y` EXIT=0(此时已跑在修好的标题门禁与真实产物上)。发现清单 **N-1…N-30** 连续编号,其中本轮新增 N-14…N-30 共 17 项。**判定不变:有条件通过**——理由只剩「未提交」与「五项待授权动作」两条。
## 1. 本轮门禁实测
| 门禁 | 命令 | 本轮实测 | 判定 |
|---|---|---|---|
| 类型检查 | `npm run type-check` | EXIT=0,0 error | 通过 |
| Lint | `npm run lint` | EXIT=0,`✖ 105 problems (0 errors, 105 warnings)`:55 `no-console` / 25 `@typescript-eslint/no-explicit-any` / 12 `react-hooks/set-state-in-effect` / 10 `@next/next/no-img-element` / 各 1 `@next/next/no-sync-scripts`、`@next/next/no-html-link-for-pages`、`Unused eslint-disable directive`(死抑制)。分项相加 = 105 | 通过(0 error);口径与 AGENTS.md §5 已统一 |
| 单元 + 覆盖率 | `npm run test:coverage` | 首轮 **130 套件 / 1608 例**,EXIT=1:`sections` branches 70.87<72、`content` stmts+lines 38.6<52 / funcs 20<46 → 派子代理补**真实测试**(明令禁止降阈值、禁止改配置)。中途一度因新测试自身缺陷回红(N-12,已修)。**最终树:132 套件 / 1677 例全通过,EXIT=0,0 条阈值告警**;global **85.56 stmts / 86.86 branch / 80.59 funcs / 85.56 line**,`components/content` **100/100/100/100**,`components/sections` **72.77 / 87.06 / 73.8 / 72.77**,`lib/cms` 91.9/92.63/92.5 | **通过**。两项门限与首轮判红时完全一致(`sections` branches 72、`content` 52/46),`content` branches 只从 52 **上抬**到 95(实测 100)⇒ 阈值未被下调,棘轮方向正确 |
| 真库集成层 | `npm run test:integration:real` | **4 套件 / 19 例全通过**,EXIT=0;teardown 守卫打印「prisma/dev.db / dev.db / data.db 的 inode+size+mtime 与运行前完全一致」 | 通过(新接入 `test:all`) |
| 可访问性静态三件套 | `npm run check:a11y` | EXIT=0;contrast 0 问题、headings 0 问题、brand-token 扫描 389 文件违规 0(1 处白名单降级,见 N-8) | 通过 |
| 全站 axe 节点计数 | `npm run check:axe:routes` + `check:axe`(专用端口 3100 起**装配后**的 standalone) | 首轮 **EXIT=1 / PASSED=false**(`bgMismatch=34`×四组,样式未加载 ⇒ 对比度「意外达标」)→ 根因 N-9。**postbuild 修复后重跑:`routes-exit=0`、`axe-exit=0`、`PASSED = true`** —— 四组 `chromium`/`firefox` × `light`/`dark` 各 34 页:`themeMismatch=0`、**`bgMismatch=0`**、**`contrastNodes=0`**、**`violationNodes=0`**、`extraRuleNodes=0`、`extraRuleCoveragePairs=102`。light 底 `rgb(255,255,255)`、dark 底 `rgb(10,14,20)` 与令牌一致 ⇒ 样式确已加载。⚠ 每组 `non2xxResponses=1`,逐行核对为 **`/_not-found` 返回 404**(该路径由预渲染产物清单被爬入,404 是其正确语义,且该页同样 0 违规、底色正确)⇒ 非缺陷 | **通过**(本轮真实出数) |
| Lighthouse | `npm run lighthouse`(lhci 自起 :3200 standalone) | 首轮 EXIT=0 **但在 CSS 全 404 的产物上取得,判定不作数**。**修复后重跑:EXIT=0,断言 9 URL / 27 次运行全通过**,逐 URL 中位数:perf **99-100**、**a11y 100(9/9 全部)**、BP 100(9/9)——该列首轮唯一例外 `/contact` 为 **96**,N-11 修复后对同一 URL 用**同一审计**(`inspector-issues`)单独复跑得 score 1 / 0 items / **BP 100**,故 9/9 齐平、FCP **248-336ms**、LCP **795-903ms**、CLS **0.000**、TBT **0ms**。**对比度失败节点累计 0、`aria-required-children` 失败节点累计 0**(上一轮为 a11y 92-97 + 10 个失败节点,其中 7 个来自 Cookie 条) | **通过**(A-11/A-12 至此有了正向实测凭据) |
| E2E(**生产目标**,写库用例已排除) | `E2E_TARGET=production` + `@smoke\|@critical\|@journey` × 4 project,`--grep-invert` 掉 `cms-workflow.spec.ts`/`user-journey.spec.ts` | **EXIT=0,92 通过 / 0 失败 / 0 skipped**(2.3 分钟)。服务由 harness 新起 `node dist/standalone/server.js`(production 目标 `reuseExistingServer:false` ⇒ 必然测当前产物)。**16 个 GA4 用例在生产目标下首次真实断言并通过**,闭合 AGENTS.md 长期声明的「dev 下是 skipped 不是 passed」缺口。**11 例未跑**(两个写库 spec),需 §5 第 2 项授权 | **通过**(限于只读子集;A-10 由「机制已建未执行」升级为「已执行」) |
| E2E(**`@regression` 全量功能层,本轮首跑**) | `--grep '@regression'` + 按文件排除两个写库 spec,chromium project | **194 通过 / 1 失败 / 5 skipped,EXIT=1**(3.0 分钟,14 个 spec、200 例)。此前这 200 例**在任何自动化门禁里都不跑**(`test:e2e:fast` 只 `@smoke\|@critical`;Jenkins 的 `test:e2e:prod` 加 `@journey`;CI 的 `test:visual` 只桌面 chromium 一个 project)⇒ 本轮是它对人的第一次实测。**唯一失败已定性为间歇性**:`website-acceptance.spec.ts:111`「响应式设计正常工作」在 `[data-testid="mobile-navigation"]` 上报 `element(s) not found`(5s 超时);**选择器本身有效**(该 testid 确实存在于 `src/`,且 `mobile.spec.ts` 用同一选择器的多条用例本轮全通过),**隔离复跑 `--retries=0` 直接 passed(13.0s)** ⇒ 判为并发下的时序/超时抖动,与前述 UJ-10 那例同族。**未通过加 timeout/retry 掩盖**,作为已知不稳定项记录;**同一份代码连跑三轮的失败数为 8 → 1 → 5**(三轮仅差我改的 3 条用例),故这一层的失败绝大多数是并发负载抖动而非缺陷。第 2 轮跑的是"本轮改动的回归比对",**我改的 3 条在第 2、3 轮均全 project 通过**,第 3 轮的 5 例失败**没有一条来自我的改动**(`p4:31` 首页完整加载 < 8s、`p4:68` FCP、`p4:408` 跳转链接、`p2:177`/`p2:239` webkit 卡片与方案详情)| **实质通过**(1 例间歇红,待按"降低本体等待成本"处理而非放宽阈值) |
| E2E(**生产目标**,安全头子集) | `E2E_TARGET=production` + `--grep '@security'`(`security-headers.spec.ts` 18 例/project × 4 project) | **EXIT=0,首轮 72 通过 / 0 失败**(32 秒;N-17 修复后复跑为 **68 通过 + 4 显式 skipped**,见下方「一条例外」),日志 `docs/acceptance/2026-09-23-gates/e2e-security-prod.log`。**这次是真在产物上断言响应头**:`X-Frame-Options: DENY`、`X-Content-Type-Options: nosniff`、`Referrer-Policy: strict-origin-when-cross-origin`、`Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()`、`X-XSS-Protection: 1; mode=block`、CSP 存在性与指令逐项检查(日志内 ✅ 头断言行 48 条),且表单用例经 `page.route('**/api/contact', fulfill)` 打桩 ⇒ **不产生对外提交**。**一条例外已记为 N-17**:`security-headers.spec.ts:364` 的「静态资源通过 HTTPS 加载」在 `baseURL` 以 `http://localhost` 开头时**裸 `return`**,而 harness 的 `baseURL` 恒为 `http://localhost:3000`(`playwright.config.ts:27`)⇒ 该例(×4 project = 4 例)**在本地与产物目标下都不可能执行任何断言,却计为 passed** | **通过**(除 N-17 那 4 例条件空转;HTTPS-only 断言仍只能靠打线上,属 §5 未决) |
| E2E(**生产目标**,写库用例已排除) | `npm run test` / 完整 `test:e2e:prod` | **未执行——需授权**(`@critical` 含真写库规格) | 无数据 |
| E2E(**重新构建后复跑**,验证层级对齐) | `npm run build` → 同一 write-free 生产目标命令 | `BUILD_EXIT=0` 且 `postbuild` 装配自检通过(`dist/standalone/dist/static`、`dist/standalone/public` 均在);E2E **91 通过 / 1 失败 / 0 skipped**(2.6 分钟)。**失败非回归**:`mobile-user-journeys` 的 UJ-10 在 firefox/mobile 下失败,**隔离复跑(`--retries=0`)通过**(14.5s,日志含 `all articles count: 2 … completed successfully`)⇒ 判定为 4 worker 并发下的**间歇性超时/负载相关**,与本轮 `src/app/**` 改动无因果(该用例不触任何管理端 API)。日志 `docs/acceptance/2026-09-23-gates/e2e-prod-after-authfixes.log` | **实质通过**(1 例待按"概率性超时只能靠降本体成本解决"处理,加 retry 等于把间歇红藏更深,故不这么做) |
| 视觉回归(**只读比对**) | `npm run test:visual:all -- --update-snapshots=none`(规范模式 dev server,5 project × 25 例) | **EXIT=1,21 失败 / 104 通过**(4.9 分钟)。逐条归类后**没有任何一例是像素不符**:① 10 例基线文件根本不存在(`product-erp-upgrade-fullpage` / `about-brand-fullpage` × 5 project)⇒ 只读模式拒绝创建,属**覆盖空洞**;② 10 例在截图**之前**就死在定位断言上(`[class*="card"]` 在 `/products` 匹配 0 个元素;`input[type="text"], input[type="email"]` 的 `.first()` 命中的是**反垃圾蜜罐** `<input type="text" tabindex="-1" name="website" aria-hidden="true" aria-label="honeypot">`,该字段按设计隐藏 ⇒ `Received: hidden`,**5/5 project 恒定失败**);③ 1 例 chromium-mobile 的 `button, a[role="button"]` 首元素在窄视口不可见。日志 `docs/acceptance/2026-09-23-gates/visual-readonly-finaltree.log` | **判红**,但红在测试装配而非页面渲染:**104 例通过**说明当前树对全部现存基线逐像素复现一致,`/contact` 全页比对亦通过 ⇒ **N-11 的 `jitless` 改动未造成任何视觉变化(独立于评分的正向确认)**。21 例失败为**从未绿过**的坏测试与缺失参照(N-15),与本轮修复无因果 |
| 变异测试 / k6 | `test:mutation` / `test:performance` | 未执行(Stryker `--inPlace` 会改写工作树;k6 需本机 CLI) | 无数据 |
> 数字纪律:AGENTS.md §5 与 `config/test/jest.config.js` 的历史数字来自上一轮树(137 套件/1659 例,global 77.92/84.6/77.64/77.92),本轮因删除死代码与测试合并而先变小、补测后变大。**本报告不拿旧数字当现状**;收尾已一次性回填四处活文档(`AGENTS.md` §5、`CLAUDE.md:240`、`README.md:167-168`、`docs/development/quality-gates.md` §3 与 `config/test/jest.config.js` 顶部注释),统一为 **132 套件 / 1677 例 / 85.56 · 86.86 · 80.59 · 85.56**。历史证据目录 `docs/acceptance/2026-09-21-gates/*` 保留原值不改——它们记录的是当时为真。
## 2. 2026-09-21 阻断项闭环对照
只列本轮**有实测或直接代码证据**的项;无新证据的一律标「本轮未复测」,不沿用旧结论。
| 编号 | 原判定 | 本轮状态 | 证据 |
|---|---|---|---|
| A-1 `content_admin` 自主提权 | P0 | **已闭环** | `tests-integration/00-security-boundary.itest.ts` 真库例:授予 `super_admin` ⇒ 403 且库里无新用户、无新 `UserRole`(本轮通过) |
| A-2 重置任意用户口令/停用超管 | P0 | **已闭环** | 同套件:改密/停用超管 ⇒ 403,真实哈希与 `status` 未变(本轮通过);另有「禁用账号换不到 access token」正反向对照 |
| A-3 CMS 富文本未净化直出 | P0 | **已闭环** | `src/app/terms/page.tsx:3,213` 与 `src/app/privacy/page.tsx:3,268` 均 `sanitizeRichText(cmsContent)`;净化器 `src/lib/sanitize.ts` |
| A-4 上传同源分发 + 类型不设限 | P0 | **已闭环** | 新增 `src/lib/media/upload-policy.ts`:扩展名 allowlist + magic-byte 嗅探 + 声明 MIME 必须与嗅探结果一致,`svg` 明确排除;本轮另修正 `docker-compose.server.yml` 落盘挂载路径与真实写盘根 `<cwd>/public/uploads` 对齐 |
| A-5 CI 用 `\|\| echo` 吞掉功能测试 | P0 | **已闭环** | `Jenkinsfile` 现存 7 处 `\|\| echo` 全部位于环境探测块(`:69-74`,Node/npm/git/ssh/rsync/curl 是否存在),`:165` 有注释自述历史吞错已移除;lighthouse 阶段 `:249`、axe 阶段 `:390-392` 已在流水线内 |
| A-6 `@critical` GA4 自证空测 | P0 | **已闭环(逐行复测 + 本轮产物目标实测)** | 自证模式已移除并换成真实拦截:`gotoWithGtag` 用 `page.route` 桩掉 `googletagmanager.com`、`waitForRequest` 抓应用自己发出的 `gtag/js?id=` 并从 **URL 参数**取 `measurementId`(`:44-61`),断言对象是应用推入 dataLayer 的调用(`:128-138` `config` 次数=1、`anonymize_ip:true`;`:183-196` 表单 `form_submit` + `conversion` 的 `event_label`/`value`/`currency`/`transaction_id` 类型;`:257-258` 路由 `page_title`/`page_location`)。`__gtagCalls` 只剩文件头注释(`:7`,作为反例记录)。**`test.skip(measurementId === null)`(`:124,152,202,237`)说明该组仅在 `E2E_TARGET=production` 下真正跑,本轮未执行 E2E** |
| A-7 覆盖率门禁形同虚设 + 三处文档口径互斥 | P0 | **已闭环** | 阈值单一真源 = `config/test/jest.config.js` 的 `coverageThreshold`(根 `jest.config.js` 仅转发);本轮目录级阈值真的把门判红了(§1),反证其可约束性 |
| A-8 单测不接触真实数据层 / `test:integration` 命名误导 | P0 | **已闭环** | 新建 `tests-integration/*.itest.ts` + `config/test/jest.integration.config.js`(一次性临时 SQLite + 项目库 inode 守卫);脚本改名收口:`test:integration:mocked`(原 mock 版)/ `test:integration:real`(真库层),并接入 `test:all`、AGENTS.md §5、CLAUDE.md:81 |
| A-9 视觉基线追认现状 / 近空白基线 | P0 | **部分复测:只读比对已跑(§1 视觉行)** | 实测口径更正:`e2e/visual-snapshots` 共 **107** 张 PNG(5 个 project 目录各 21 张 + 目录外 2 张),`--list` 为 **25 例/project × 5 project = 125 例**(此前记录的「270 例/project × 4 project」不成立)。只读比对结果 **104 通过 / 21 失败 / 0 例像素不符** ⇒ **现存参照与当前树逐像素一致,"基线被无声追认了错误现状"这一原始症结本轮未复现**。但暴露两个更硬的事实:**(a) 磁盘基线 ≠ 已提交基线**——85 张 PNG 相对 HEAD `f543e47` 处于 modified,mtime 全部落在 **2026-09-22 02:37–02:56**,即提交(2026-09-20 11:32)之后两天被重新生成且从未提交,任何人从 HEAD 检出的参照都不是本轮所比的那个(详见 §7);**(b) `f543e47` 的自述与现状矛盾**——该提交标题为「以规范模式重生成**全站**视觉基线并补齐 firefox 缺口」,而 `/products/erp-upgrade` 与 `/about/brand` 两页在**全部 5 个 project** 下都无基线 ⇒ 覆盖空洞在提交时即存在。追认 85 张差异是否合规仍需 `--update-snapshots` 授权 + 人工核看(§5 第 3 项) |
| A-10 E2E 从不验证交付物 | P1 | **已执行并实测通过(写库用例除外)** | 本轮以 `E2E_TARGET=production` 打**装配后的 standalone 产物**跑 `@smoke\|@critical\|@journey` × 4 project,**92 例全通过、EXIT=0、0 例 skipped**(2.3 分钟,日志 `docs/acceptance/2026-09-23-gates/e2e-prod-finaltree.log`)。**关键增量**:AGENTS.md 一直声明「16 个 GA4 用例在 dev 目标下是 skipped 而不是 passed」——本轮在生产目标下这 16 例(TC-GA4-001..004 × chromium/chromium-mobile/firefox/webkit)**逐例真实断言且通过**,日志内 `skipped` 计数为 **0**,故 A-6 的 GA4 覆盖面第一次拿到产物目标凭据。**排除项与代价(如实披露)**:仅跑了 **7 个 spec / 23 例每 project**,按文件路径 `--grep-invert` 排除了 `cms-workflow.spec.ts` 与 `user-journey.spec.ts`——**这两个文件才是真正写库的**(各有 9 处 `request.post/put/delete` 打 `/api/admin/items`、`/api/admin/media`、`/api/auth/login`、`/api/cms/revalidate`),合计 **11 例未执行**,仍需 §5 第 2 项授权。**顺带纠正一处文档假事实**:`README.md:174` 与 `AGENTS.md:158` 称生产目标是「`E2E_TARGET=production` → `npm run start`」,而 `e2e/playwright.config.ts:132-135` 实为 `HOSTNAME=localhost PORT=3000 node dist/standalone/server.js`(`CLAUDE.md:46` 与 `docs/testing.md:276` 写法正确,且后者已注明「非 `next start`」)——standalone 下 `next start` 不受支持,按错误口径执行会测到降级路径 |
| A-11 两道 a11y 门禁双盲 | P0 | **部分闭环,且新增第三实例** | `check:contrast` + 新增 `check:brand-token` 本轮 0 违规;axe 门禁的 `bgMismatch` 判据成功抓到「样式未加载」而非报绿(判据有效),但暴露本地跑法缺陷 N-9 |
| A-12 Cookie 条隐私链接 3.31:1(全站) | P0 | **已闭环** | `grep -rEo 'text-\[var\(--color-brand\)\]' src --include='*.tsx'` = **0 命中**(原 67 处),文字通道统一 `text-brand-ink` |
| A-13 / A-14 `lerp` 恒 `start=0` / `randomBetween` 只断边界 | P1 | **测试数据缺陷已修,变异体未重跑** | 两处致盲根因已消除并留有指名验收号的注释:`lerp` 新增全非零 `start` 用例(`utils.test.ts:156-159`,`lerp(10,20,0.5)=15` —— 原四例 `start` 恒 0,使 `end-start` 与 `end+start` 数值相同不可观测);`randomBetween` 改为 `spyOn(Math,'random')` 钉住线性映射(`:118-127`,`0→1`、`0.5→5.5`、`0.25→…`,算子由 `*` 改 `/` 会立即偏离)。⚠ **未重跑 Stryker**,故「这两个变异体已被杀」是由用例算术推得的结论,非实测;且 `test:mutation*` 带 `--inPlace` 会直改工作树,列为待授权项 |
| A-15 变异门禁排除「零编造」核心 | P1 | **已闭环** | `stryker.config.json:7-13` 的 `mutate` 现为 `src/lib/**/*.ts` 等白名单,未见 `!src/lib/constants/**` 排除 |
| R-1 双重移动端安全区留白 ~190px | P1 | **已闭环** | 组件侧 `pb-[calc(8rem+env(...))]` 已不存在,仅 `src/app/globals.css:1332` 单处 |
| R-2 动效收敛到 300ms 且注释错引契约 | P1 | **已闭环** | `duration-300` **0 命中**(原 108 处),改用 `duration-normal`/`duration-fast` 令牌 |
| R-3 新闻页暗色对比度 | P1 | **已闭环** | 并入 A-12 整族收敛,`--color-brand` 文字通道 0 命中 |
| R-4 蓝色硬编码 + 品牌红光晕 | P1 | **已闭环** | `src/components/detail/solution-service-card.tsx:128-129` 为 `bg-accent-blue-soft` / `text-accent-blue`,`#1d4ed8` 与 `blue-*` 工具类已消失 |
| R-5 `prefers-reduced-motion` 未被 framer-motion 尊重 | P1 | **已闭环** | `src/components/ui/motion-provider.tsx:8` `<MotionConfig reducedMotion="user">`,一处收口 |
| B-1 创建接口绕过发布工作流 | P1 | **已闭环(本轮主修项)** | 见 §3 N-4/N-5/N-6;`test:integration:real` 的「B-1:POST 携带 `status:"published"` 不得绕过工作流」本轮通过 |
| B-3 `basis` 写入侧无校验 → 残余为「枚举拦得住格式、拦不住说谎」 | P1 | **写入侧校验已落地;说谎面如实记录** | 原判定「`basis` 仅类型约定、写入侧无校验」**已不成立**:`metricBasisField` 以 `type:'select'` + 三项 `options`(值恰为 `METRIC_BASES` = `target`/`team-history`/`verified`,`content-types.ts:8-18`)铺进 **8 个**内容类型的指标字段(`:184,207,385,408,572,595,781,1207`),`validateContentData` 会递归进数组元素(`validate-content-data.ts:113-121`),POST/PUT 两侧 `fieldErrors.length > 0` 即 400(`items/route.ts:144-147`、`:228-232`)。负向对照已在库:伪造 `basis:'customer-validated'` ⇒ `path:'metrics[0].basis', rule:'option'`(`validate-content-data.test.ts:73-79`)。**未解决面**:`basis:'verified'` 本身是合法枚举值,写入侧无法证伪「声称有实测出处」—— this 是治理缺口不是格式缺陷,itest 已如实声明,不冒充已修 |
| B-4 脏 `data` 列让读取路径崩溃 | P1 | **已闭环** | `parseCmsData` 收口至 `items`、`items/[id]`、`workflow` 三个路由;itest 脏列例(5 种脏值 + workflow 500)本轮通过 |
| B-7 侧效应:角色标签错显 | P2 | **已闭环** | `src/components/admin/admin-layout.tsx` 模块级 `ROLE_LABELS`,取值对齐 `prisma/seed.ts` 与 `refresh/route.ts:31` 的真实 role code |
## 3. 本轮新增发现
| 编号 | 级别 | 发现 | 状态 |
|---|---|---|---|
| N-1 | P1 | **JSON-LD 未做 `</script>` 闭合防护**:`structured-data.tsx` 7 处 `dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}`,schema 含可控文本时可提前闭合 `<script>` 注入 HTML(存储型 XSS 面) | **已修**:新增 `jsonLd()` 把 `<`/`>` 转义为 `\u003c`/`\u003e`(JSON 语义不变);`structured-data.test.tsx` 用 `</script><img src=x onerror=alert(1)>` 载荷断言序列化结果不含裸 `</script>` 且 JSON 可回读(修复前该例为红) |
| N-2 | P2 | 覆盖率配置仍引用已删除目录 `src/components/cms/**`,使该目录的门禁条目永不可约束(假绿条目) | **已修**:条目删除 |
| N-3 | P2 | `docs/cms/api-contract.md` 宣称后台表单支持 richtext 编辑器,实际 `renderField` 无该分支(落 `default:` 文本框),TipTap 与 `.rich-text-editor-content` 为死代码 | **已修**:§10.4 按现实重写,死 CSS 一并移除 |
| N-4 | **P0(功能性)** | **后台编辑器的每一次保存都会 400**:编辑页 `performSave` 总把 `status` 放进 PUT 载荷,而 PUT 拒收任何 `status` 字段 —— 「保存」与「自动保存」在真实环境全部失败 | **已修**:保存载荷不再带 `status`;PUT 只在 status **被改变**时拒绝(原样回传放行)。回归由新建的 `src/app/admin/content/[modelCode]/[itemId]/page.test.tsx`(11 例)钉住:断言 POST/PUT 实际入参对象**不含 `status` 键** |
| N-5 | **P0(功能性)** | **「发布」按钮不可能生效**:UI 走 `updateItem` 直接写 `status:'published'`,既被 PUT 拒绝,又绕过 `publish` 权限校验(与 B-1 同一枚硬币的两面) | **已修**:`adminApi.runWorkflow` 为唯一写状态通道;确认发布后按 draft→`submit`→`approve` 流转,无 `publish` 权限者停在「待审核」并如实提示 |
| N-6 | P1 | 表单里的 `状态` 下拉框(草稿/已发布/已归档)任何改动都必然 400 —— 一个永远不能落库的控件;且缺 `review` 态 | **已修**:改为只读状态展示 + 模块级 `STATUS_LABELS` 四态,附一行说明为何移除;测试断言表单内不存在任何 `select`/`combobox` |
| N-7 | P2 | `approve` 失败时 UI 仍显示「草稿」,而服务端已被 `submit` 推进到 `review`(子代理写测试时实测到的状态回写缺口) | **已修**:`submit` 成功后立即 `setStatus`,403 分支断言「待审核」 |
| N-8 | P2 | `check:brand-token` 对 `src/components/ui/loading-state.tsx` 的裸 `var()/80` 死样式**做了白名单降级**(理由:该组件无页面引用、同族正被清理)。降级本身诚实可见,但门禁留了一处已知豁免 | 保持现状 + 本报告披露;建议随 `loading-*` 死组件族一并删除 |
| N-9 | **P0(门禁可信度)** | `next build` 的 standalone 产物**不含 `dist/static` 与 `public`**(Dockerfile:5-9 已写明需手工拷贝,镜像用 `COPY` 满足),CI 的 axe 阶段自己做了拷贝(`Jenkinsfile:350-355`,注释还写明「缺了它们对比度会意外达标」),但**本地跑法没有任何拷贝步骤**:`npm run lighthouse` 与手工 `check:axe` 直接对着未装配的 standalone 起服务 ⇒ 全站 CSS 404;`check:axe` 靠 `bgMismatch` 报红,而 `npm run lighthouse` 在样式全丢的裸 HTML 上 **exit 0** | **已修**:新增 `postbuild` 脚本,按 Dockerfile:53-57 同构把 `dist/static → dist/standalone/dist/static`、`public → dist/standalone/public`;修复后实测:`curl` 取 `/_next/static/chunks/05m0199uz1s5s.css` 从 404/9 字节变为 **200 / 105607 字节**;axe 与 lighthouse 重跑见 §6。⚠ 复验须换端口——上一版进程仍占 3101 时会给出修复前的假 404(本轮实际踩到) |
| N-10 | P3 | 澄清而非缺陷:`k6@0.0.0` 是 LoadImpact/Grafana 官方「Dummy package for autocompleting k6 scripts」编辑器包(`node_modules/k6/package.json` 自述),非占位错装;`npm run test:performance` 需另装 k6 CLI。`docker-compose` 依赖的 `.env.production` 本机**确实存在**(此前记录的「缺失」不成立) | 记录,无需动作 |
| N-11 | P2 | **`/contact` 有一处 CSP 违规**:修复 N-9 后的 Lighthouse 重跑中,9 个 URL 的 best-practices 全为 100,唯独 `/contact` 为 **96**,唯一失败项是 `inspector-issues`,内容只有 `{"issueType":"Content security policy","subItems":{"items":[]}}` —— **Lighthouse 不给指令名与被拦资源**,无法从报告本身定因。已排除:a11y 无关(该页 100)、与 `'unsafe-eval'` 无关(当前 CSP 已不含该指令)。风险面:若被拦的是表单提交链路上的脚本,则是**静默功能失效**而非仅评分问题 | **已修(源码根因 + 同仪器 A/B 双取证)**:根因不是本站脚本,而是 **Zod 4.4.3 在模块求值时的 eval 能力探测**——`node_modules/zod/v4/core/util.js:145-162` 的 `allowsEval` 用 `new Function("")` 试探,throw 被库自己吞掉,但浏览器仍就地点报一次 `securitypolicyviolation`;库在源码注释里亲自写明这一现象(`:146-147`:「strict CSPs report the caught `new Function` as a `securitypolicyviolation` even though the throw is swallowed」)。命中面与观测完全吻合:全仓仅 3 个文件 import zod,`src/app/api/contact/route.ts` 是服务端(Node 无 CSP),`src/components/ui/form.tsx` 虽是 `'use client'` 但**零引用者**(本轮新增死组件,见 §4.4),故浏览器侧只有 `src/app/(marketing)/contact/contact-content-v3.tsx`(`'use client'` + `:34` 模块级 `z.object`)会求值 ⇒ 9 个 URL 里只有它报。**修法用库认可的开关**:`z.config({ jitless: true })`(公开且有类型:`node_modules/zod/v4/core/core.d.ts:66-70`,注释即「Useful in environments that disallow `eval`」),落在 `contact-content-v3.tsx:32`(schema 之前)。**行为零变化**:`jitless` 只让 `allowsEval` 直接返回 false,而 CSP 下该探测本就抛错返回 false ⇒ 唯一依赖它的 JIT 快路径(`schemas.js:971-972` 的 `fastEnabled = jit && allowsEval.value`)修复前后同样处于关闭状态。**且这一点是实测而非推断**:以 21 组输入(含 CJK/emoji/换行的边界长度、缺键、`null`、错类型、`__proto__` 污染对象)分别过 `jitless:true` 与 `jitless:false` 两份同构 schema,逐条比对 `safeParse` 的成功值与 `issues` 的 `path`+`code`+`message` 序列化结果 ⇒ **21 组全等,diffs=0**。**同仪器 A/B**(Lighthouse `inspector-issues`,也就是当初发现它的审计):修复前 `lighthouse-reports/contact-2026_09_23_04_54_06.report.json` = score 0 / 1 item / best-practices **96**;修复后独立重跑 = score 1 / 0 items / best-practices **100** ⇒ **9/9 URL 全部 100**。复跑门禁:`type-check` EXIT=0、`lint` EXIT=0 且仍是 105 warnings / 0 error(未新增告警)。附带澄清一条误报:GA4 **没有**被本站 CSP 拦——真实浏览器下 `/contact` 的 `gtag.js` 200、三条 `/g/collect` 全部落在已放行的 `https://www.google-analytics.com` 并返回 **204**;此前记录的「疑似 `www.google.com/g/collect` 被 `connect-src` 拦掉」**不成立**(该域在 gtag.js 里出现 17 次,但本次真实加载未向其发 collect;`ad_storage: 'denied'`(`GoogleAnalytics.tsx:97`)压制了 Ads 侧目的地)。**遗留条件风险**:若将来接入 consent 管理并把 `ad_storage` 放开,`www.google.com` 可能成为 hit 目的地而被拦——届时评估扩 `connect-src` 是部署决策,不在本轮顺手放宽。 |
| N-12 | P1(门禁可信度,自查发现) | **子代理补测的测试自身把门禁跑红**:新增的 `src/components/sections/hero-particle-field-engine.test.tsx:373` 在 `finally` 里用 `Object.defineProperty(window,'requestAnimationFrame',{configurable:true,value:savedRaf})` 还原全局 —— **`defineProperty` 未写 `writable` 时默认 false**,于是该属性变成只读;随后 `afterEach` 中 `rafSpy.mockRestore()` 走赋值还原 ⇒ 抛 `TypeError: Cannot assign to read only property 'requestAnimationFrame'`,**整套件判 failed to run、EXIT=1**,而 `Tests:` 行仍显示全部通过(该套件 25 例根本没跑)。定位方式:隔离跑该套件(0 测试通过 + 0.956s ⇒ 证明非并发污染)、再按 `-t` 单例复现(只有该例失败,其余 24 例跳过仍绿) | **已修**:补 `writable: true` 并写明原因。复验:单例 EXIT=0、整档 **25/25 通过**、全量 **132 套件 / 1677 例 EXIT=0**。教训已入 §6 纪律:子代理产物一律逐档隔离复跑,`Tests: N passed` 不等于套件跑全 |
| N-13 | P2(门禁可信度,自查发现) | **同一处 CSP 违规对两个审计是「一显一隐」,且自造浏览器探针无法对它做正向对照**:(a) N-11 那一轮 `/contact` 的 `errors-in-console` 给出 score **1** / items **0**(判为干净),而 `inspector-issues` 给出 score **0** / items **1**——CSP 违规只走 DevTools issue 通道,看控制台的审计看不见它;(b) 我先写的 Playwright 探针(`document` + `window` 双挂 `securitypolicyviolation`)在修复后的页面上采到 **0 违规**,但它的正向对照**失败**:经 CDP 注入的 `page.evaluate(() => (0,eval)('1+1'))` 与 `addScriptTag({content})` 里的 eval **既不抛错也不报违规**(Chromium 不对调试器注入的代码施加 `unsafe-eval` 判定),所以「0」当时无法区分「真干净」与「探针看不见这一类」。校准实验同时证明监听器**对子资源类是有效的**:同一 harness 里 `connect-src` 与 `script-src-elem` 两条违规被 document/window 两个挂点都采到 | **已按此纠正验证方式**:N-11 的结论不建立自造探针的「0 违规」上,而是建立在**发现它的同一仪器**(Lighthouse `inspector-issues`)的前后 A/B 上(96→100、items 1→0)。纪律补两条:① 自建探针的正向对照必须在**同一违规类别**上成立,跨类别采到事件不等于该类可采;② 报告「0 违规/0 问题」前须先证明探针能采到非零,否则该 0 记为「未证」。这也是 A-11「门禁看不见它要保护的东西」家族的第四个实例。**并已补上真正的门禁**:`config/test/lighthouserc.json` 的 `assert.assertions` 新增 `"inspector-issues": ["error", {"minScore": 1}]`——此前唯一相关的断言是 `categories:best-practices >= 0.9`,而 `/contact` 的 96 分**恰好从它下面溜过去**(0.96 > 0.9),等于没有。**该新断言做过存活证明**(避免 §5.25 的「死断言恒绿」):把修复前那份 `contact-2026_09_23_04_54_06.report.json`(`inspector-issues` score 0)单独放进临时 `.lighthouseci/lhr-*.json` 喂给 `lhci assert`,实测输出 `Checking assertions against 1 URL(s), 1 total run(s)` → `✘ inspector-issues failure for minScore assertion / expected: >=1 / found: 0` → **EXIT=1**。⇒ 这条规则是活的,会在同类回归上判红。⚠ 附带工具链坑:`lhci assert --assertions='{...}'` 的**行内 JSON 形式会被错误解析**(把值当成 audit 名,报 `"15" is not a known audit` 且**仍以 0 退出**),必须走 `--config=<file>`;用行内形式做「断言是否生效」的自检会得到假的通过 |
| N-14 | P3(死资源提示 / 误导性装配) | `src/app/layout.tsx:192-193` 对 `//formsubmit.co` 发 `dns-prefetch` + `preconnect`,但**浏览器从不联系该源**:FormSubmit 只在 `src/app/api/contact/route.ts:84` 由 **Node 运行时**调用(CSP 不管服务端),客户端唯一动作是同源 `POST /api/contact`(`contact-content-v3.tsx:181`)。五路独立取证:全仓 `formsubmit` 仅 4 文件 9 行(其余为 GA4 `form_submit` 事件的同名变量与文档);`src` 内两个 `<form>` 均**只有 `onSubmit` 无 `action`**;无任何 `'use client'` 文件 fetch 绝对 URL;无任何 `NEXT_PUBLIC_*FORM*` 常量可拼出该目标;`src/lib/sanitize.ts` 的白名单无 `form`/`action` 且 `allowProtocolRelative:false` ⇒ CMS 注入路亦封死。**且当前 CSP 本就拦得住它**:`connect-src`(`next.config.mjs:31`)不含 formsubmit,`form-action 'self'` ⇒ 即便存在浏览器侧提交也会被拦。git 史确证为迁移残留:`46cff87`(2026-04-21) 浏览器直连 FormSubmit → `f08874f`(2026-05-11) 加这两行提示(当时有效)→ **`415a103`(2026-06-17) 把 formsubmit 一次性搬进 `route.ts`**,提示自此成为孤儿,已腐烂约 4 个月。副作用面:48 个预渲染文档每页都对一个永不使用的第三方源预热连接 | **仅记录,本轮未改**。删除是安全的(Jest/Playwright/Lighthouse 断言中**无任何一处引用 preconnect/dns-prefetch**,故不会因删它而判红)——但**同理也没有任何东西保护它**,静默留着等于无人看管。**刻意不顺手改**:① 它是行为保持型清理而非缺陷修复,与"提交前须逐项过门禁"的收尾阶段无关;② 一旦在只读视觉比对进行中改 `layout.tsx`,dev server 热更新会让那一轮跑在一个我看不见的树上(§3 N-15 的教训正来自"测的还是不是当前代码")。**同一处真正的反向缺口一并记录**:浏览器**确实**全站联系 `www.googletagmanager.com` / `www.google-analytics.com`(Lighthouse 网络日志实证),却**拿不到任何提示**——补这两个提示是独立的性能决策,不应与清理捆绑 |
| N-15 | P1(测试装配可信度,本轮实测新发现) | **视觉回归的 L2/L1 有 21 例是恒定失败的坏测试,其中 20 例从未绿过、也没有参照**:(a) `visual-regression.spec.ts:130` 卡片用例在 `/products` 用 `[class*="card"]` ⇒ `element(s) not found`,该页无此 class 子串,`product-card.png` 从未存在;(b) `:148` 输入框用例的 `input[type="text"], input[type="email"]` 经 `.first()` 命中**反垃圾蜜罐** `<input type="text" tabindex="-1" name="website" aria-hidden="true" aria-label="honeypot">`(按设计隐藏)⇒ `Expected: visible / Received: hidden`,5/5 project 必失败,`input-default.png` 从未存在;(c) `:27-42` 的页面清单含 `/products/erp-upgrade` 与 `/about/brand`,但两页基线在 5 个 project 下**全部缺失** ⇒ 只读模式判红,而 `f543e47` 的提交标题自称「重生成全站视觉基线」。**蜜罐这一条尤其值得记**:测试选中的第一个 `type="text"` 恰好是安全控件,意味着任何用同样宽松选择器写的用例都在断言一个故意不可见的东西——失败是测试错,不是页面坏 | **仅记录 + 给出精确修法,不做无法验证的盲改**。理由:改选择器只会把「断言失败」变成「参照缺失」,仍红 ⇒ 该修法的**唯一验证路径是 `--update-snapshots` 写基线**,而这项未获授权(§5 第 3 项)。在不能复验的前提下改门禁脚本正是本轮 N-13 批评的行为,故不执行。授权后建议一次做完:① `:130` 换成页面上真实存在的卡片选择器(或给组件补 `data-testid`,与 `:154` 创始人语录用例的既有做法一致);② `:148` 排除蜜罐——`input[type="text"]:not([name='website']), input[type="email"]:not([aria-hidden='true'])`;③ 决定两页全页基线是补还是从 `VISUAL_TEST_PAGES` 摘除(当前是"列了但从不比对"的最坏中间态);④ 复跑 `--update-snapshots=none` 确认失败数从 21 归零而非换一个错误类别。**另记一条我自己的 harness 坑(同族复发)**:该轮以后台命令跑时,包装体形如 `{ npm run test:visual:all -- --update-snapshots=none; echo "VISUAL_EXIT=$?"; }`,**命令组的退出码取自最后一条 `date`** ⇒ 系统通知报「exit code 0」,而真实门禁是 **EXIT=1**。若不是日志里显式写了 `VISUAL_EXIT=$?`,这一轮会被读成绿。规则:包装后台命令时,末位必须是 `exit $rc` 或把结果行作为唯一判据,**不得以通知里的 exit code 作为门禁结论** |
| N-16 | P2(交付口径,本轮实测发现) | **`AGENTS.md` 不在版本控制里**:`git ls-files --error-unmatch AGENTS.md` ⇒ `error: pathspec 'AGENTS.md' did not match any file(s) known to git`,`git check-ignore -v` ⇒ 命中 **`.gitignore:312`**(该节注释即 `# AGENTS`,是**有意**忽略而非疏漏)。对照同族文档:`CLAUDE.md` / `README.md` / `CONTEXT.md` / `docs/lessons-learned.md` 全部 `tracked=yes ignored=no`。**后果**:本报告与历轮报告里所有「已回填 AGENTS.md §5 门禁表」的声明**都不进入 diff**——既不可审、不可回滚,也不会随分支交付给下一个人;而 §6「文档同步清单」恰恰要求更新 AGENTS.md,等于要求改一个提交不上去的文件。叠加风险:该文件页脚自述「written and re-added by `next dev`」,**本轮已核实为真**(`node_modules/next/dist/server/lib/generate-agent-files.js:2` 注释即 "Auto-generate AGENTS.md / CLAUDE.md with the managed Next...",并含 `LEGACY_AGENT_RULES_START_MARKER` 标记),故 `next dev` 可能重写其内容 ⇒ 写进其中的门禁口径存在被覆盖丢失的可能 | **仅记录,本轮未改**。三种处置各有代价,且都超出"顺手改"的边界:① **从 `.gitignore` 摘掉 `AGENTS.md`** 使其入库——但它会被 `next dev` 反复改写,一旦入库就会**长期产出脏 diff**(这正是当初忽略它的合理动机);② **保持忽略,把权威口径迁到已跟踪文件**(`CLAUDE.md` 或 `docs/development/quality-gates.md`),`AGENTS.md` 降级为本地便签——与现状最一致,成本是历史声明需逐条搬迁;③ **改为托管块写法**,只把项目自定义内容放在 marker 之外。**本轮采取的动作**:把 N-15 视觉门禁的判读口径**同时写入已跟踪的 `docs/lessons-learned.md` §5.29 与本报告 §1/§3**,确保这份知识在 diff 里存在一份,不受 ①②③ 选择影响;`AGENTS.md` 内的那条仅作本地便签,**不计入交付物** |
| N-17 | P2(门禁可信度,A-6 同族) | `e2e/security-headers.spec.ts:364`「静态资源通过 HTTPS 加载」在 `baseURL` 以 `http://localhost` 开头时**裸 `return`**——而 harness 的 baseURL 恒为 `http://localhost:3000`(`playwright.config.ts:27`,dev 与 production 目标同值)⇒ **该例在本地与产物目标下都不可能执行任何断言,却被计入 passed**(本轮 `@security` 跑 72 passed 中有 4 例是这种条件空转,日志里以 `⏭️ 本地开发环境(HTTP),跳过 HTTPS 资源检查` 自述)。这与 A-6「GA4 自证空测」是同一形状,只是它连 `test.skip` 都不打 | **已修**:改为 `test.skip(condition, '…(N-17)')`,让它显式记为 skipped 而不是伪装通过。判据与实测**:修复前 `@security` 为 **72 passed / 0 skipped**;修复后同命令复跑 ⇒ **68 passed + 4 skipped,EXIT=0**(日志 `docs/acceptance/2026-09-23-gates/e2e-security-prod-after-n17.log`),四条空转如期从"伪装通过"变为"显式跳过"。HTTPS-only 断言本身仍只能打真实 HTTPS 部署,属 §5 之外的对外请求授权 |
| N-18 | **P1(开放重定向,已确认并修复)** | `src/app/api/cms/draft/disable/route.ts` 把 `body.redirect` **原样**交给 `NextResponse.redirect(new URL(redirect, request.url))`,无任何校验;而姊妹路由 `draft/enable` 早在 B-6 就加了 `isInternalRedirect()`——该函数当时定义在 `enable/route.ts:9` 内、**私有不可复用**,于是同一缺陷在姊妹路由中复活。该端点**无鉴权、无 secret**,且第一方代码零调用者(孤儿但对外暴露) | **已修 + 回归测试 + RED 实测**:抽出 `src/lib/cms/internal-redirect.ts` 供两条路由共用;`disable` 采用与 `enable` 相同的顺序——**先校验后产生副作用**(非法请求不再 `draft.disable()`)。新增 `disable/route.test.ts`(7 例:无 redirect→200、站内路径→307、`https://evil`/`//evil`/`/\evil`/`javascript:` 四种载荷→400 且 disable 未被调用)。**RED 探针为实测而非推断**:临时移除该 guard 后复跑,4 条攻击用例全部 `Expected: 400 / Received: 307` 判红,两条合法用例仍绿 ⇒ 修复前确实会向站外 307,且修法无误伤 |
| N-19 | **P1(越权读取,已确认并修复)** | `src/app/api/admin/stats/route.ts:9-10` 只用 `authenticateRequest`(= 「有没有有效会话」),随后 `:49-59` 返回 `prisma.contentItem.findMany({ take: 5, select: { title, modelCode, status } })`——**跨全部模型**的最近内容(含草稿标题与状态)。任何登录账号(包括 `userRole` 为空、零权限的历史账号)都能读到。对照同族:`admin/items` GET 用 `requirePermission(…, 'read')`(`items/route.ts:79-81`)、`admin/models` GET 用 `requirePermission('content-model','read')` ⇒ 这是复制守卫时漏掉权限检查的那一份(全仓 7 个 handler 走内联 role 数组而非 `requirePermission`,stats 是其中掉了角色检查的一个) | **已修**:改为 `requirePermission(request, 'content-model', 'read')`。**与 `admin/models` GET 同权限**是刻意选择:仪表盘既然已经要能列模型,用同一道门就不会新引入 403 面。复验:`type-check` EXIT=0、`test:unit` **133 套件 / 1691 例 EXIT=0**、`lint` 0 error / 105 warning(未新增告警) |
| N-20 | **P1(停用账号仍可用,已确认并修复)** | `src/lib/auth.ts:7` `TOKEN_EXPIRY = '24h'`;`authenticateRequest` 只验签名(`auth.ts:71-81`),`checkUserPermission`(`permissions.ts:33-43`)只查 `userRole` + `permission`,**从不读 `User` 行**。而 B-7 的账号状态校验**只加在了 `/api/auth/refresh`**(`auth/refresh/route.ts:23-25`,注释自述「被禁用/删除的账号可在 7 天内继续换取访问令牌」)。⇒ 通过 `PUT /api/admin/users` 置 `status: 0` 停用某账号后,其**已签发的访问令牌在 24 小时内仍能通过每一个 guarded 路由**(读写皆可),修复只做在了刷新链路上 | **已修**:在 `checkUserPermission` 入口先 `prisma.user.findUnique({ select: { status } })`,`!account \|\| account.status === 0` 即 `return false`,且**位置刻意早于 `roleCodes.includes('super_admin')` 短路**(否则被停用的超管令牌仍畅通)。补 3 条用例(停用+有权角色→false 且不再查角色;账号行不存在→false;停用的 super_admin→false)。**RED 实测**:临时删除该 guard 后 3 条用例全部判红(`3 failed, 20 passed`),恢复后 `23/23` 通过。代价:每个受守卫请求多一次 `findUnique`(SQLite 本地实测无感) |
| N-21 | P2(防御纵深,**未修,留决策**) | 两条相关但未在本轮动刀的:① `items/route.ts:79` `const permissionModelCode = modelCode \|\| 'content-item'`——省略 `modelCode` 时以字符串 `'content-item'` 的 read 权限放行**全部模型**,而该 modelCode 在 `src/` 与 `prisma/` 里**别处不存在**(唯一命中);`roles/route.ts:84-85` 又接受任意 `perm.modelCode` 字符串 ⇒ 一次拼错的授权即等价于全站内容读权限。② 全仓**无 `middleware.ts`**(`find . -name middleware.ts` 无结果),`src/app/admin/layout.tsx:1` 是 `'use client'`,登录跳转发生在客户端(`admin-layout.tsx:151`、`auth-context.tsx:79`)⇒ `/admin` 的 HTML 外壳在无会话时也会先下发。本轮实测:admin 页面数据全部来自已加守卫的 API,故 ② 只暴露外壳、不泄露数据,属防御纵深而非可利用缺陷 | **未修,仅记录**。① 需要决定「跨模型列举」这个动作对应的权限语义(新增 modelCode 还是显式 `'*'` 权限),属产品/权限模型决策,不能顺手改常量;② 加 `middleware.ts` 会改变所有 `/admin/*` 请求的渲染路径与现有 E2E 的登录前置,须独立评估。另记一条 `draft/enable` 设的草稿 cookie **没有任何 `draftMode()` 消费者**(预览链路是死代码,不构成绕过,但属无人验证的攻击面) |
| N-22 | **P1(安全门禁整体失效,已修 + 双向对照实测)** | 派 subagent 审「谁来测门禁」时命中 `scripts/utils/check-security-headers.ts`(该脚本是 `npm run test:all` 的一环)的四处**恒不成立断言**,我逐条读源码复核确认:① `checkCookies` 里 `status: 'pass' as const` 是**字面量**——上面三个 `hasHttpOnly/hasSecure/hasSameSite` 全算完就地丢弃;② 汇总的 `hasFailures` **只看 `headerChecks`**,于是 ① 的 cookie 永不判失败也永不影响退出码(`cookieFailed` 只为打印而存在);③ `X-XSS-Protection` 写的是 `return val ? 'warn' : 'warn'`,**两个分支完全相同**;④ **缺 CSP 只 `warn`**(而同族 `X-Content-Type-Options` 缺失判 `fail`)⇒ 把整条 CSP 删掉也无法让这个"安全检查"变红。合起来:这个安全头门禁对 CSP 与 cookie 两条最重要的判据**永远不会失败** | **已修 ①②③④ 并追加 ⑤**:cookie 状态改为按 flag 推导(会话类 cookie 缺 `HttpOnly`/`SameSite` ⇒ `fail`,其余缺失 ⇒ `warn`),`hasFailures` 纳入 cookie 失败,死三元改为「无该头 ⇒ pass(现代浏览器已不需要,与原 description 自述一致)/ 有但值异常 ⇒ warn」,缺 CSP ⇒ `fail`。**⑤ 是正向对照逼出来的**:首次在本地 standalone 产物上跑修后的门禁 ⇒ `EXIT=1`,唯一红项是 `Strict-Transport-Security` 未设置——**HSTS 是 Nginx/CDN 在边缘注入的**,于是发现该门禁**结构上不可能在分支产物上通过**、只能打线上(与 N-23 互为因果)。据此把 HSTS 改为「仅在 `https:` 目标上缺失才判 fail,http 目标下降为 warn」。**双向对照均为实测**:负向(`python -m http.server` 起的无 CSP 服务)⇒ **EXIT=1、4 项 fail**(修复前同样的输入只会 warn 到 0);正向(真实 standalone 产物 :3601)⇒ **EXIT=0、0 失败、`无 Cookie 设置`**。`type-check` EXIT=0。**残留**:本地匿名请求拿不到会话 cookie ⇒ cookie 分支目前仍是"有数据才有效",需带登录态的目标才能完全激活 |
| N-23 | **P1(门禁测错了对象,未修,需决策)** | `check-security-headers.ts:34` 的默认 URL 是 **`https://novalon.cn`**,而 `package.json:56` 的 `test:security:headers` 不带 `--url` 调用它,并被 `test:all`(`package.json:48`)与 `Jenkinsfile:293-294` 引用;`Jenkinsfile` 那两行上方打印的正是「🔒 检查**本分支构建产物**的安全响应头」。**声明与行为相反**:CI 打的是**已经部署的线上站点**,不是当前分支的产物 ⇒ `next.config.mjs` 里删掉一条头,CI 仍然全绿,直到部署之后才(也许永远不会)暴露。这解释了 N-22 里 HSTS 的现象,也让"这个门禁保护什么"这个问题没有答案 | **未修,留决策**(三项都在等授权或等口径选择):① 改默认行为(CI 下强制要求 `--url` 或默认 `http://localhost:PORT`)会改变 `test:all` 与流水线的语义,属口径变更;② 若要保留打线上,必须**改掉那句误导性打印**并在 `AGENTS.md`/`docs` 里写明"此项验的是线上而非本分支";③ 打线上属**对外请求**,本轮未获授权,故连一次实测都没做,线上下游是否真的注入了 HSTS 亦**未验证**。建议顺序:先 ②(纯文档,零风险),再 ①(独立动作),③ 单独授权 |
| N-24 | P2→**部分已修**(门禁范围性空洞) | 同一轮审计的次级结果,四条均有源码佐证:① `axe-node-count.mjs` 的分母自检很硬,但**逐页不断言状态码**——聚合保险丝 `push(s.okResponses > 0, …)` 只在**全部**响应非 2xx 时才炸,单个页面变 404(standalone 的 not-found 仍套应用布局,背景色与令牌一致、节点数近零)会**计入绿**;② `check-heading-hierarchy.ts:157` 的 `page.goto` 不因 404/500 抛错,Next not-found 只有一个 h1 ⇒ 判"层级正常",且 `waitForServer` **接受 404 作为就绪信号**(`:90` 原为 `if (res.ok 或 res.status === 404) return;`)⇒ 若 :3000 被别的服务占着,它量的是错对象;③ 同文件 `:47` 用 `spawn('npm', ['run','preview'])` 起服务,而 `package.json:16` 的 `preview` 就是 `next start -p 3000` ⇒ **本项目是 `output: 'standalone'`,Next 16 明确告警 `"next start" does not work with "output: standalone"`**,即该门禁一直跑在不受支持的降级路径上;④ `check-pr-checklist.sh` 的 `--pr-dir` 目录不存在时 `exit 0`、目录内无 `*.md` 时 `violations` 为空 ⇒ **空转通过** | **②③④ 已修并双向对照实测**:② `waitForServer` 去掉「404 也算就绪」那半个条件(只认 `res.ok`)+ 逐页 `response.status() >= 400` 即记为失败页并计入 `totalIssues`(给 `HeadingIssue.type` 增加 `"http_error"` 而非塞字符串,保持联合类型闭合);③ 改为**存在 `dist/standalone/server.js` 时直起 standalone**(`HOSTNAME/PORT/NODE_ENV` 显式注入,与 `check:axe`/`Jenkinsfile`/`lighthouserc` 同法),缺失时回退 `preview` **并打印告警**说明该路径不受支持;④ 两处空转都改成仓库既有约定 **`exit 2`=脚本自身/入参故障**(与 `crawl-routes.mjs` 的 `paths.length === 0 → exit 2` 一致),并打印实际扫描文件数。**实测**:④ 四例 `MISSING_DIR=2 / EMPTY_DIR=2 / CLEAN=0 / DIRTY=1`(修复前前两个都是 0);②③ 正向 `10 页全通过 / EXIT=0`,负向(临时往 `PAGES` 塞一条不存在路由)⇒ **`❌ 不存在的页 (/zz-bogus-route): HTTP 404`、失败页面 1、EXIT=1**,探针已复原(`grep -c` 为 0)。`type-check` EXIT=0;`lint` 不受影响(`scripts/**` 在 eslint ignore 内)。**① 未修**(`axe-node-count.mjs` 的 per-route 状态断言),因其判据与 `bgMismatch` 保险丝耦合、改动会影响全站四组扫描的判定口径,留作独立动作 |
| N-25 | **P1(限流可被绕过,已修 + 实测)** | 部署层专项:`src/app/api/contact/route.ts` 的 `getClientIp` 原实现是 `forwarded.split(',')[0]` —— **取 `X-Forwarded-For` 最左项,而那一项由客户端自报**。边缘两侧配置用的是 `$proxy_add_x_forwarded_for`(`nginx-static.conf:101,119`、`nginx-static-production.conf:104`),语义是**追加** `$remote_addr`、**不清除**客户端自带的 XFF ⇒ 脚本每次请求换一个 `X-Forwarded-For: 1.1.1.1 / 2.2.2.2 / …` 就能无限拿到新限流桶,把「5 次/小时」整体绕过;而同时设置的 `X-Real-IP`(`$remote_addr`,Nginx **覆写**、客户端无法伪造)反而**从没被优先读过**。**实测复现**:以旧逻辑喂三条伪造请求 ⇒ 桶为 `["1.1.1.1","2.2.2.2","3.3.3.3"]`,`distinct = 3` | **已修**:抽出 `src/lib/client-ip.ts`(**放这里是刻意的**——`src/app/**` 不在覆盖率棘轮内,留在路由文件里等于没有回归保护),规则改为「优先 `X-Real-IP`;否则取 XFF 链**最右**一项;都缺 ⇒ `unknown`,且空白值等同缺失」。新增 6 例 `client-ip.test.ts`,其中两条直接以攻击者视角写(轮换 XFF ⇒ 桶数必须为 1;X-Real-IP 必须压过伪造 XFF)。复跑:`type-check` EXIT=0、`test:unit` **134 套件 / 1697 例 EXIT=0**、`lint` 0 error / 105 warning(未新增)。**残留(未修,须知)**:`rateLimitMap` 是**进程内 Map** ⇒ 多实例部署下每实例各计一份,实际阈值约为「5×实例数/小时」;真限流要落到共享存储,属独立设计决策 |
| N-26 | **P1(部署层,未修 — 多数须授权或须改部署约定)** | 同一轮部署审计里其余 CONFIRMED 项,逐条我已在源码侧复核:① **密钥进入构建上下文**——`.dockerignore` 只挡了 `.env`/`.env.local`/`.env.*.local`(`:5-7`),**没有挡 `.env.production`**,而该文件实际含 `JWT_SECRET`、`JWT_REFRESH_SECRET`、`CMS_REVALIDATE_SECRET`、`ENCRYPTION_SECRET`(只看变量名,未取值),`Dockerfile:28` 的 `COPY . .` 会把它烘进 **builder 层**(runner 镜像只 `COPY` `dist/standalone`+`dist/static`+`public`,故不在最终镜像里)。**⚠ 显而易见的"一行修复"是错的**:把 `.env.production` 加进 `.dockerignore` 会让 `next build` 拿不到 `NEXT_PUBLIC_GA_MEASUREMENT_ID` / `NEXT_PUBLIC_ENCRYPTION_SECRET`(`NEXT_PUBLIC_*` 在**构建期**内联,见 `Dockerfile:35` 的 `npm run build`)⇒ GA 静默失效,是一次典型"修掉可见症状、制造不可见回归"。正解是把服务端密钥从 `.env.production` 迁到运行期注入、`NEXT_PUBLIC_*` 改走 build-arg,属部署约定变更;② **CI 安装不确定**——`Jenkinsfile:107-110` 先 `rm -rf node_modules package-lock.json` 再 `npm ci`,而 `npm ci` **必须要 lockfile** ⇒ 必然失败并落到 `npm install --legacy-peer-deps`,即 lockfile 在 CI 里从未生效,与 `Dockerfile:25-26`(保留 lock 跑 `npm ci`)解析出的依赖树可以不同。修法是一行(别删 lock,`npm ci` 失败仍有或运算兜底),但**改 CI 属共享状态,未擅动**;③ **字体/图片可能在生产 404 而本地正常**——`nginx-static-production.conf:160-174` 的 `ttf` / `woff2` 与 `svg` / `jpg` 正则块以 `try_files $uri =404` 结尾**没有** `@nextjs` 回退,而同文件的 `/_next/static/`(`:156`)与另一份 `nginx-static.conf:70-90` 都有回退,正则块优先级高于前缀块 ⇒ 磁盘缺文件时直接 404 而不是交给其实持有这些资源的 Node 服务;④ **`/api/` 可能重复 `Cache-Control`**——`nginx-static-production.conf:220` 的 `add_header ... always` 只会追加而非覆盖(该处注释自述知道此事仍保留);⑤ `allowEmptyArchive:true`/`allowMissing:true`(`Jenkinsfile:154,185,193,215,255,399`)⇒ 归档步骤可在无文件时报绿 | **未修,全部留决策/待授权**。理由分组:①②③ 分别触碰**密钥约定、CI 流水线、线上 Nginx**,都在"须单独授权的共享状态"里,且 ① 的错误修法比不修更糟;④ 是已知的边缘行为,改前需确认没有客户端依赖双值;⑤ 是 Jenkins 归档语义,与判定正确性无关但会让人误以为产物存在。**每项都给了最小判据**(不改代码也能证伪):①`grep -n 'env' .dockerignore` 对照 `grep -c 'NEXT_PUBLIC' .env.production`;②读 `Jenkinsfile:107-110` 的先后顺序即可;③`grep 'try_files \$uri =404' nginx-static-production.conf` 对照同文件 `@nextjs` 的落点;⑤对照 `archiveArtifacts` 的 glob 与实际产物目录。**另需真人复验的**:③ 是否已经在生产实际发生(取决于 `/var/www/novalon` 里的同步内容,本轮无部署权限亦不应 `nginx -t`,**未验证**) |
| N-27 | P2(测试装配可信度:无断言用例 + **系统性「条件守卫包住断言」**) | 对 20 个 `e2e/*.spec.ts`(除已审的 visual / security-headers)做逐 test 块扫描:真实 test 块 **145** 个,其中 **5 个块内 `expect(` 计数为 0**。逐个读源码后定性:**① `p4-performance-a11y.spec.ts:343`「焦点管理清晰可见」与 ② `p4:546`「没有混合内容警告」是纯空操作**——前者算出 `hasOutlineStyle` 布尔值后只 `console.log`,后者注释直接写着「只记录不强制断言」,两者**永远不可能失败**(与 N-17 同族);③ `website-acceptance.spec.ts:51` 用 `waitForURL(/\/.+/)` 作隐式断言,有效但极弱(任何路径变化都算过);④ 另两处经复核**不是**空操作:`website-acceptance` 电话用例依赖的 `data-testid="contact-info"` **确实存在**(`contact-content-v3.tsx:316`)、`p4:365` 跳转链接依赖的 `data-skip-to-content` **也确实存在**(`layout.tsx:203`)——我最初的猜测若不做这步核实就会被写成假发现。<br>**更要紧的是系统性模式**:全仓至少 **14 处断言被包在 `if (await X.count() > 0)` / `X.isVisible()` 里**(`p2-functional-e2e.spec.ts` 一个文件就占 11 处:`:64,74,84,114,213,221,247,445,502,527,577,742`;另见 `mobile.spec.ts:334`、`p3-compatibility.spec.ts:42`)。⇒ **选择器一旦失效,用例不是变红而是静默少测**,正是 N-15 蜜罐那族在功能层的翻版 | **①② 已修并实测**:`p4:343` 改为真正断言 `expect(hasOutlineStyle).toBe(true)`(复跑 **passed**,证本站确有焦点指示样式,且今后删掉 `outline`/`box-shadow`/`data-focus-visible` 会判红);`p4:546` 按 N-17 同法处理——`test.skip(baseURL 为 http://localhost, 理由)` + 在 HTTPS 下断言 `expect(mixedContent).toEqual([])`,复跑 **1 skipped / 1 passed**,混合内容判据在本地不再伪装通过。<br>**④ 14 处条件守卫不盲改**:其中相当一部分是**合法**的视口/角色分支(桌面顶栏 vs 移动抽屉、超管才见的元素),一律改成硬断言会制造假红,而逐个判断需要跑完整 E2E 才能确认每个元素在哪个 project 下真的存在 ⇒ 记为**待授权的独立整改**,规则写明:先 `await expect(locator).toBeVisible()` 再断言其内容,让"元素消失"变成失败而非跳过。**③ 留作弱断言清单**,不改判据也不宣称它已失效。<br>**追加已修(同一轮,全部经四 project 实测)**:`p2:82-87` 的死 CTA 门(换成首页真实装配的 `[data-testid="cases-co-creation-cta"]`,去掉 `if(isVisible)` 守卫);`p2:481` 的 `expect(a) \|\| expect(b)` **JS 或运算 bug**(`toContain` 失败会抛错 ⇒ 右侧永不执行,改为 `toMatch(/条款\|服务/)`);`p2:129`「导航链接可点击且跳转正确」原本**零断言 + `.catch(() => {})` 吞跳转失败**,改为复用 `primaryNav()` 取当前视口的导航容器、从链接自身 `href` 推导目标并断言落地;`p4:223`「键盘可访问」的恒真断言 `expect(typeof hasFocus).toBe('boolean')` 改为与引擎无关的 `focus()` + `toBeFocused()`。**过程须如实记:我自己两次改错**——第一版加 `exercised > 0` 守卫在 chromium-mobile 误红(桌面顶栏有「联系我们」、移动抽屉没有,**硬编码链接文案天然不是视口无关的**);第二版用 `waitForURL(/^\/services/)` **四 project 全红**(Playwright 的 URL 匹配跑的是**完整** URL,锚定 `^/path` 永不成立)。两版都被实测否掉后才得到现在这版四 project 全通过。**教训**:给既有测试"补断言"必须跨 project 跑,桌面-only 的直觉在这里就是错的 |
| N-28 | P2(这一层从未被要求过可靠性,故无稳定性预算) | 本轮首次把 `@regression` 功能层(14 spec / 200 例,chromium 单 project 3.0 分钟)跑给人看,随即测得**同一代码三轮失败数 8 / 1 / 5**。失败集中在两类:① **墙钟阈值型用例**——`p4:31`「首页完整加载时间 < 8 秒」、`p4:68`「FCP < 阈值」,它们断言的是绝对毫秒数,而 dev server(未压缩 React + 按需编译)在 4 project × 6 worker 并发下本身就更慢 ⇒ 这类用例在并发门禁里**必然间歇红**;② **webkit / chromium-mobile 下的点击跳转超时**(`p2:177`、`p2:239`、`p4:408`)。根因不是新缺陷:**这些用例从未进入任何自动化门禁**(`test:e2e:fast` 只 `@smoke\|@critical`;Jenkins 的 `test:e2e:prod` 加 `@journey`;CI 的 `test:visual` 只桌面 chromium 一个 project,四个视觉 project 里三个在 CI 从不跑),所以它们从未被要求稳定过,也没有重试预算或隔离策略 | **未修,记为待决策**,因为正确解法需要产品取舍而非改代码:(a) 墙钟阈值型用例不该放在共享并发门禁里——应单独串行跑、或改用相对判据(与站内其他页面对比、或 Lighthouse 那类受控测量,本轮 `npm run lighthouse` 已在产物上给出 FCP 248-336ms / LCP 795-903ms 的稳定口径);(b) 若要并入 CI,需同时决定 `retries` 与 `workers` 策略并明确"允许 flaky 的清单",否则会把并发抖动当成回归追;(c) 三个从不跑的视觉 project 是否纳入 CI(纳入则基线维护成本 ×3)。**本轮明确不做**:不加 `retries` 掩盖(等于把间歇红藏更深)、不放宽 timeout 阈值(同理) |
| N-29 | P3(设计约束无门禁 + 一份自相矛盾的约束摘要) | 查「AGENTS.md §8 记的设计约束到底有没有人验」,实测三条:① **动效时长完全没有门禁**——`check:a11y` 只等于 `check:contrast` + `check:headings` + `check:brand-token`,没有任何脚本管时长或缓动曲线;② **AGENTS.md §8 把约束写错了档**:原文只有一句「动效 180–280ms」,而真正信源 `CONTEXT.md:76` 定义的是**三档**(入场 180–280ms、hover **150ms**、反馈 **100ms**)外加 `:82`「禁止超过 700ms 的入场动效」⇒ 照 AGENTS.md 的字面去审,会把**设计上合法**的 150/100ms 一律误判为违规(实测 `mega-dropdown.tsx:100` 就有一处 `duration: 0.15`,属不属于"入场"需设计判断);③ 令牌层 `globals.css:263-268` 定义了 100/180/280/**450**/**700**/**1000**ms 六档,其中 **`--transition-gentle: 1000ms` 全仓零使用(死令牌)**,而 `fadeInUp`/`fadeInDown`(`globals.css:1225,1229`)**正好压在 700ms 上限**(用 `slower`,未超 ⇒ 合规,但没有门禁阻止它继续加大)。组件侧实测分布健康:`duration: 0.28` 145 处、`0.2` 3 处、`0.25` 1 处、`0.15` 1 处 | **AGENTS.md 那行已按信源改正**(恢复三档 + 700ms 上限,并注明是本轮修正)。**未新增门禁**——理由:按 AGENTS.md 的错误口径写门禁会立刻误伤合法用例,而按正确口径写需要先定义「哪些算入场、hover/反馈、`slow(450)`/`slower(700)` 各自允许用在哪」,那是设计决策不是代码问题。留给设计侧三件小事:`--transition-gentle` 删除或明确用途、`mega-dropdown` 的 150ms 归类、若要设门禁则**先定类别再定阈值** |
| N-30 | **P1(内容「零编造」:机制是白名单式的,白名单外无人管;两处编造数字已删)** | 针对 AGENTS.md §3 的「零编造」与 `3ed3afd`(`fix(metrics): 数字口径 basis 结构强制,删除详情页虚构佐证`)做内容专项。**先查机制再判内容**:`basis?: 'target' \| 'team-history' \| 'verified'`(`src/lib/constants/metrics-basis.ts:13`)+ `MetricsBasisNote`,唯一执行者是 `src/lib/constants/metrics-basis.test.ts` 一个单测,而它有四处结构性盲区:① `REQUIRED_ANNOTATED_SOURCES`(test:27-36)是**硬编码 7 个文件**的白名单 ⇒ 任何第 8 个打印大数字的文件天然免检;② `allSeedMetricBases()`(test:53-64)只走 `PRODUCTS`/`STANDALONE_PRODUCTS`/`SOLUTIONS`,**`SERVICES` 与 about 的 keyMetrics 从不被看**;③ 禁词扫描的 `SOURCE_ROOTS`(test:24)只有 `src/app`+`src/components` ⇒ **`src/lib/**` 与 `prisma/**`(即全部 seed 文案)不在扫描范围**;④ `basis?` 在所有类型里都是**可选**(`products.ts:12`、`services.ts:40`、`solutions.ts:26`),`validate-content-data.ts` 自述「未声明的键一律放行」⇒ 无类型层/运行时层强制。**结论:白名单内是真的,白名单外是 Aspirational。**<br>**本轮已删的两处凭空数字**(实测改前 23 例、改后 23 例通过):`service-detail-content-v4.tsx:16` 无 benefits 时兜底成 `'效率提升 40%'`(改为无数据不渲染该行);`case-detail-page.tsx:95,99` 无 metrics 时兜底 `'40%'` 与 `'核心成果提升'`。**关键:后者原本被一条测试正向锁定**——`case-detail-page.test.tsx:162` 名字就叫 「falls back to the placeholder headline…」并断言 `40%` 出现 ⇒ 错误行为被测试固化,改正行为时该断言必须反转为 `queryByText('40%')` 为 null(已改,注释标明理由)。 | **删兜底已完成**(`type-check` 0、`test:unit` 134 套件 / 1697 例 0、`lint` 0 error / 105 warning)。**余下全部为 NEEDS-PRODUCT-DECISION,未擅自改动**,因为「哪个数字是真的」只有业务能答:① **四条互斥 SLA 同时在线**——`detail-cta-section.tsx:84`「平均响应时间 < 2小时 · 7×24小时技术支持 · 无需预付费用」、`prisma/seeds/services.ts:129`「<4小时(工作日技术支持)」、`case-detail-page.tsx:513`「48小时内给出初步方案建议」、`seed.ts:836`「工作日 2 小时内」,四者不可能同时成立;② `/products/erp-upgrade` 的 `99.2% 数据准确性`、`40%+`、`70%` +「从5天到1天」、`100%` 安全保障(`erp-upgrade-content-v2.tsx:63,69,75-77,81`)**既无 basis 也无 MetricsBasisNote**,读起来像实测而非目标;③ **`3ed3afd` 的机制没够到它自己的目标**——`content-types.ts:1207` 给 keyMetrics spread 了 `metricBasisField`,但 `/about` 读的是 `seed.ts:375-379` 那份**没有 basis 键**的字段定义,于是编辑**永远无法**给 `/about` 那四个数字声明口径;④ `prisma/seeds/services.ts:41-43,127-129,168-170,209-211` 共 12 条 `dataProofs`(含 `系统可用率 99.9%`、`代码质量 A+`、`数据源对接 20+种`)在**所有检查之外**,且当前无渲染方消费(潜伏非在线);⑤ 死文件 `why-us-section.tsx:158,166` 自称「技术伙伴 AWS / Azure」且 `:13,35` 称覆盖「政务」行业——`prisma/seeds/solutions.ts` 里**没有政务方案**、`package.json` 里**没有 AWS/Azure**,该文件零 importer(删或改标签为「技术栈」)。**同时记一条应当保留的诚实基线**:`CASE_STUDIES = []`(`case-studies.ts:11` 注释「暂无对外可验证的客户案例」)、`certifications: []`、`partners: []` 全为空,**未发现任何编造的客户名或 logo**;创始人引言归于「睿新致远创始团队」而非虚构个人;「6 款自研产品 / 6 个行业场景」与 seed 计数**逐一对上**(属已证实,勿改) |
| N-31 | P2(文档与仓库现实不符:测试指南里 7 条命令不可执行 + 一段指向不存在的 CI) | 对 `README/CLAUDE/AGENTS/CONTEXT/docs-testing/quality-gates/deployment` 做机械交叉核对(脚本名对照 `package.json`、相对链接对照磁盘、阈值对照 `config/test/jest.config.js`)。结果:**链接全部可解析(0 缺失)、覆盖率阈值口径一致(82/75/75/75 三方相同)**,但 `docs/testing.md` 有 4 处假事实:① `npm run test:ui` / `test:debug` / `test:headed` **三个脚本在 package.json 里根本不存在**(照做必报 Missing script,正解是 `npx playwright test --ui/--debug/--headed`);② `npm run test:allure` / `:open` / `:serve` 三条同样不存在,且 **`allure-commandline` 既不在 dependencies 也不在 devDependencies、`node_modules/allure-commandline` 也不存在** ⇒ 整个 Allure 报告章节不可执行(仓库只产出 `e2e/allure-results/*.json`);③ 一段 **GitLab-CI 风格 `pipeline:` 片段**写着 `image: node:18-alpine` 并调 `npm run test:ci`(同样不存在),而本仓真实流水线是根目录 `Jenkinsfile`、`.github/workflows/` 不存在、`Dockerfile` 用 node:20 ⇒ 三重过期;④ `AGENTS.md` §5 的覆盖率行停留在 **132 套件 / 1677 例**,与收尾实测不符 | **①②③ 已改 `docs/testing.md`**:换成可执行的原生 flag 写法;Allure 段改为「仓库未定义这些脚本 + CLI 未安装」并给出 `npx -y allure-commandline generate/open` 的真实路径;CI 片段顶部加显著警告说明真实流水线是 `Jenkinsfile`、并把 node 版本更正为 20。**④ 已按 `test:coverage` 收尾实测回填** `AGENTS.md`:134 套件 / 1697 例、global 85.59/87.5/80.67/85.59、目录级 `content` 100×4、`sections` 72.77/**90.79**/73.8/72.77(较上一版分支上升,因本轮改过 `case-detail-page.test.tsx`)、`lib/cms` 91.93/92.77/92.68/91.93,0 条阈值告警。复跑核对脚本:**「引用不存在的 npm script」由 7 降为 0**。方法论:这类检查全部用脚本机械比对,不靠阅读 —— 阅读会漏,且我写的更正注释本身一度被自己的检查器当成第 8 条假命令(把 `npm run test:allure*` 引号内示例改掉才归零) |
| N-32 | P2(本轮首跑的最后一个标签层:webkit axe 报 1 项 serious/critical,而 axe 门禁根本不覆盖 webkit) | 收尾时把**从未在任何门禁里执行过**的 `@accessibility\|@performance` 标签层跑了:`E2E_TARGET=production`(打当前构建产物)× 4 project,**124 通过 / 4 失败,EXIT=1**(2.2 分钟;日志 `docs/acceptance/2026-09-23-gates/e2e-a11y-perf-prod.log`)。4 例失败**全部是 webkit** 的 `mobile-accessibility.spec.ts:41` axe-core 扫描,分别落在 `/`、`/about`、`/services`、`/news`,每页 **1 项** critical/serious 级违规(断言 `criticalSerious.length === 0`)。**关键结构事实:全站 axe 门禁 `check:axe` 只跑 `chromium\|firefox × light\|dark`**(§1 那轮的四组即此),因此 **webkit 的 axe 结果不在任何门禁的观察范围内**——这不是推断,是从门禁配置本身读出来的覆盖面。**本轮定性证据**:单独隔离复跑 `--grep 关于我们 --project=webkit --retries=0` ⇒ **1 passed(10.7s)**,即该违规在低并发下不复现 ⇒ 归入 N-28 的并发/时序族(axe 注入时机与页面未完全稳定相关),**而非已证实的真实缺陷**。同时如实记一条取证不足:失败发生时用例确实 `console.log` 了规则 id 与节点,但 line reporter 未把这批 stdout 带进汇总(`grep -A3 可访问性违规` 只能取到「(1 项)」标题行,取不到 `[impact] id: help`),**故规则名本轮未能确定** | **未修,两项待办分开**:① 若要真拿到 webkit 信号,最小改法是给 `check:axe` / `scripts/accessibility/axe-node-count.mjs` 的浏览器矩阵**加入 webkit**(与现有四组同构,成本是全站扫描时间 ×1.5),这属门禁覆盖面扩张、且需重跑全量出数 ⇒ 未擅动;② 要让这条用例可诊断,应把违规明细写进附件而非 stdout(`testInfo.attach`),否则并发失败时永远查不到规则名。**本轮不给它加 retry、也不放宽 `criticalSerious === 0` 阈值**——那会把一个可能是真的 a11y 信号彻底抹掉 |
| N-33 | P2(文档一致性专项:3 处已修,其中 1 处是**我本轮自己写错的**;另余 8 组数值口径冲突待清) | 第四路 subagent(文档 vs 仓库现实)+ 我逐条复核。**最该记的一条**:`README.md:174` 里「GA4 与生产响应头只在这里真正断言」这句话**是我今天早些时候改正 `npm run start` 时亲手写进去的**——我在纠正一个假事实的同时制造了另一个(生产响应头的断言在 `@security`/`test:security:headers`,不在该 grep 集合)。 | **已修三处**:① `README.md:174` 拆清 GA4 与响应头;② `CLAUDE.md:23-24` 原称「仍留在 `next start` 上的是 `check:headings`」,而我修 N-24③ 后它已直起 standalone ⇒ **这是「缺陷已修但文档仍说它活着」的危险方向**(会让后人重复修一遍或误判门禁无效),已改为描述现状并注 N-24③;③ `docs/development/quality-gates.md:126` 同一处 GA4/响应头混淆已拆。**同轮我自己补测并修掉的第 4 处**:`mobile-performance.spec.ts:145` 把 `largeImageCount`/`oversizedForMobile` 算完只 `console.log`、唯一断言是 `totalImages > 0` ⇒ 在装配产物上以 375px 视口实测 `/`、`/about`、`/products`、`/cases`、`/news` 五页 `naturalWidth > 1920` 均为 **0**(首页最大 480、`/cases` 与 `/news` 最大 1108),据此加 `expect(largeImageCount).toBe(0)` 棘轮并**在 chromium + webkit 上复跑通过**;`>800` 的张数是 1~2 属现状,**不设阈值**(硬设会误伤真实图片)。**余下未清的口径冲突(全部已取证,逐条待授权后一并修)**:`jest.config.js:26` 注释、`README:167`、`CLAUDE:240`、`quality-gates:90` 仍写 **132 套件/1677 例**(实际 134/1697,`--listTests` 已核);`README:251` 写「Lines 73.59」**低于 75 门限**,与 EXIT=0 互相矛盾(实测 85.59);`README` 内部三处例数互斥(:34 1594 / :225 120·1509 / :167 132·1677);`README:175,241,260` 的 `@mobile` 53 个(实测 55)、`README:242` 的 105 张基线(实测 25 主语 × 5 project = 125)、`README:254` 全量 631 passed(证据文件为 1167/1195);`CONTEXT.md:220` 称 lint 106/56 并自称「已按实测改文」(实测 105/55,且改文未发生);`AGENTS.md:226-227` 引 `next.config.mjs:4`/`:8-14` 实为 `:44`/`:58`;`CLAUDE.md:205` 的动效档「fast (150-300ms)」与 `CONTEXT.md:76` 三档冲突。**另有 `docs/allure-report-guide.md`、`docs/test-optimization-guide.md`、`docs/development/IMPLEMENTATION-REPORT.md`、`docs/deployment/DEPLOYMENT.md`、`docs/CDN_QUICK_START.md`、`docs/development/getting-started.md` 里成片的不可执行命令**(`test:tier:*`、`db:push/generate/migrate/studio`、`deploy:cdn`、`test:integration` 旧名),以及 `CONTEXT.md:143`/`README:123,127`/`CLAUDE:234,236` 提到**不存在的目录**(`src/components/effects/`、`contexts/`、`cms/`、`providers/`、`_archive/`)与 `CLAUDE.md:265` 把 `home-content-cms.tsx` 说成现行文件(实际是 `home-content-v15.tsx`,同一文件 `:182` 自己已否定)。**收尾批次已改(本轮最后一轮,全部用实测值回填,不用文档互抄)**:`README.md:167`、`CLAUDE.md:242`、`config/test/jest.config.js:27`、`docs/development/quality-gates.md:90` 的 132 套件/1677 例 ⇒ **134/1697**(`jest --listTests` 实数 134);`README:251` 的 Branches 82.38%/Lines 73.59% ⇒ **87.5%/85.59%**(该 73.59 低于 75 门限却宣称 EXIT=0,本身就是自相矛盾);`README:175` 的 @mobile 53 ⇒ **55**(`--grep @mobile --list` 实测)。改后 `grep` 复核六个文档全部为 0 残留,`type-check`/`lint`/`test:unit` 均 EXIT=0。**仍未批量改**:数量已超一次收尾能复验的范围,且删目录条目需确认历史文档不被引用 ⇒ 列为独立的文档清理动作 |
## 4. 明确未做 / 需决策
1. **Sentry 实际未接线**:`@sentry/nextjs@10` 已装、三个 `sentry.*.config.ts` 存在,但仓库内**无 `instrumentation.ts`、`next.config.mjs` 未用 `withSentryConfig` 包裹、`src/**` 无 `@sentry/nextjs` import** ⇒ 生产错误上报不生效。`@sentry/tracing@7`(v7 已 EOL、全仓无引用)为冗余依赖。接线需决策是否引入 source maps 上传(要 `SENTRY_AUTH_TOKEN`,会改变构建流程)——**已在 `docs/sentry-setup-guide.md` 顶部写明现状**。
2. **审核流缺 `reject` / `archive` 入口**:`/api/admin/items/[id]/workflow` 支持四态全部动作,UI 只做了 `submit`/`approve`。提交进 `review` 的条目对审批者没有「驳回」路径,`archived` 条目无出口。属产品决策 + 设计,不在收尾阶段仓促加按钮。
3. **CSP 去掉 `'unsafe-eval'` 的影响面:本地已闭环,线上头仍未复验**。装配产物 + 真实浏览器下已实测 GA4/GTM 在收紧后的 CSP 中正常工作(`/contact`、`/services`、`/about` 三页:`gtag/js` 200、`https://www.google-analytics.com/g/collect` 全部 **204**、对被放行域零拦截),唯一被该指令影响的是 Zod 的 eval 探测,已按 N-11 处置;**Sentry 因根本未接线(本项第 1 条)而无从验证**——它不是「CSP 拦住了它」,而是「它没在跑」。仍欠的是**线上真实响应头**下的复验(需打 `https://novalon.cn`,属对外请求,本轮未擅自执行)。
4. **死组件族(本轮扩充并逐条取证)**:`src/components/sections/` 下 **7 个**组件在 `src/**` 内**零引用者**——`why-us-section`、`challenge-section`、`methodology-section`、`product-matrix-section`、`question-card`、`service-grid`、`social-proof-section`。取证:`grep -r "challenge-section|why-us-section|social-proof-section" src` ⇒ **No matches found**(即连它们自己的测试也不导入,故覆盖率为 0%,与补测子代理实测一致)。注意 `case-detail-page.tsx:194` 的 `ChallengeSection` 是**该文件内的同名局部定义**,不是这几个文件的引用者,容易误判为「还在用」。另加此前的 `hero-section-v2`、`ui/product-card`、`ui/loading-state.tsx`(N-8 的门禁豁免对象),以及**本轮为定位 N-11 顺手取证的 `src/components/ui/form.tsx`**:它是 `'use client'` 且 import zod,但全仓对它的引用为 **0**(两种写法 `@/components/ui/form` 与相对路径 `./form` 均 No matches found)⇒ 属死组件,也正因如此 /contact 才是唯一在浏览器侧求值 zod 的活页面。删除前需确认视觉基线路由表与 E2E 未以动态方式引用,且这是独立于验收的清理动作 ⇒ **不在收尾阶段批量删**,留作决策。
## 5. 待授权清单(**五项**,均需单独明文批准;本节标题原写「四项」,第 5 项 Stryker 是本轮补登,标题已同步改正)
| # | 动作 | 命令 | 风险 |
|---|---|---|---|
| 1 | 重跑 seed | `npm run db:seed` | 写 `prisma/dev.db`,覆盖现有开发数据 |
| 2 | 写库 E2E | 完整 `npm run test` / 完整 `test:e2e:prod`(**只剩** `e2e/cms-workflow.spec.ts` 与 `e2e/user-journey.spec.ts`,各 9 处 `request.post/put/delete`,合计 11 例) | 真写 `prisma/dev.db`(建草稿/上传媒体/改角色/调 revalidate)。**注意口径已收窄**:本轮取证发现「写库」范围此前被低估——除 `cms-workflow` 外 `user-journey.spec.ts` 同样写库,故它**不在**任何已跑过的子集里。另需更正 AGENTS.md/README 的一句长期表述:**GA4 与「生产响应头」并不是同一件事**——GA4 的 16 例确已在只读子集(`@smoke\|@critical\|@journey`)断言通过,而响应头断言在 `@security`(`security-headers.spec.ts`,18 例/project × 4)里,该标签**不在**上述 grep 集合内,另跑一轮才算数 |
| 3 | 视觉基线更新 | `npx playwright test ... --update-snapshots` | **改写已批准参照**,会追认当前现状(A-9 原始症结),须先人工核看差异。**注意区分**:**只读比对已在本轮跑完**(`--update-snapshots=none`,实测不写任何基线)⇒ 缺的不是"测一遍",而是**修 N-15 那 20 例坏用例后为它们创建参照**,以及**人工裁决 85 张现存基线相对 HEAD `f543e47` 的差异是否追认**。在此授权之前 `test:visual:all` 无法转绿 |
| 4 | 提交 / 推送 / PR | `git add` + `git commit` + `git push` + Gitea PR | 共享状态;须按 AGENTS.md §5.1 PR-First 流程并过 `scripts/check-pr-checklist.sh` |
| 5 | 变异测试复跑(A-13/A-14 的唯一实测凭据) | `npm run test:mutation:quick`(或去掉 `--inPlace` 的沙箱跑法) | ⚠ `--inPlace` 会让 Stryker **直接改写工作树**(其日志自述 "overriding YOUR files")。本树 **341 条改动全部未提交**,中途崩溃即无法用 `git checkout` 复原 ⇒ 在提交(第 4 项)之前跑它属于**丢失工作的实险**。建议顺序:先提交,再以沙箱模式跑 |
## 6. 收尾回填位(本轮已全部完成)
- [x] `test:coverage` 最终一格:`components/sections` **72.77 / 87.06 / 73.8 / 72.77**、`components/content` **100/100/100/100**、global **85.56 / 86.86 / 80.59 / 85.56**,132 套件 / 1677 例,**EXIT=0,0 条阈值告警**(详见 §1;过程中修掉 N-12)
- [x] `check:axe` 与 `npm run lighthouse` 在 `postbuild` 修复后的重跑:**axe `PASSED=true`(四组 `bgMismatch=0`/`contrastNodes=0`/`violationNodes=0`)**;**Lighthouse 27 次运行断言全通过,a11y 9/9 = 100,对比度与 `aria-required-children` 失败节点均为 0**(§1 保留首轮判红/无效记录,未删除,以便对照「修复前后门禁看到的东西完全不同」)
- [x] AGENTS.md §5 与 `config/test/jest.config.js` 的数值口径统一回填(见下)
- [x] **N-11 已闭环**(本节追加项):根因定位到 Zod 4.4.3 的 eval 能力探测(`node_modules/zod/v4/core/util.js:145-162`,库源码注释自述「strict CSPs report the caught `new Function` as a `securitypolicyviolation`」),以库认可的 `z.config({jitless:true})` 修掉,并用**发现它的同一仪器**做前后 A/B(`inspector-issues` 0→1、`/contact` BP 96→100)。过程中另立 N-13:自造 Playwright 探针的正向对照在 eval 类上失效,故其「0 违规」不足以定论——方法学已纠正,结论不依赖该探针。
- [x] **N-11 修复后的 axe 全量复跑**(专用端口 :3400,不与 §1 那轮的 :3100 混用):四组 `chromium|firefox × light|dark` **各 34 页**,`PASSED = true`,逐组 `themeMismatch=0 / bgMismatch=0 / contrastNodes=0 / violationNodes=0 / extraRuleNodes=0`,`extraRuleCoveragePairs=102` ⇒ N-11 的 `jitless` 改动**没有引入任何可访问性回归**。证据落 `docs/acceptance/2026-09-23-gates/axe-evidence.json`。**一条如实记录的次级现象**:每组 `non2xxResponses=1 / okResponses=33`,4 次全部是 `/_not-found` 返回 **404** —— 这是 Next 内部路由被 BFS 爬到的**正确**状态码而非故障,门禁因此不判红;但它说明路由清单混入了内部路由,收紧时可在 `crawl-routes` 侧排除(独立小改,本轮未动)
- [x] **视觉回归只读比对已跑**(`--update-snapshots=none`,规范模式 dev server):**EXIT=1,21 失败 / 104 通过 / 0 例像素不符**,三失败类已在 §1 与 N-15 归类。**判读须分两层**:现存参照层面**全绿**(含 `/contact` 全页),故本轮修复无视觉外溢;装配层面**判红**,且红的是"从未绿过的坏用例 + 从未存在的基线",非本轮引入。转绿的唯一路径是 §5 第 3 项授权
- [x] **Lighthouse 在最终树重跑**(N-11 修复之后,2026-09-23 05:38–05:39Z,EXIT=0):`27 次运行 = 9 URL × 3`,四类目逐 URL 全部 **99–100**(perf 99-100 / **a11y 100 × 9** / **BP 100 × 9** / **SEO 100 × 9**),且**新加的 `inspector-issues` 断言在 27/27 运行中均为 score 1 / 0 items** ⇒ BP 由首轮「8/9 齐平 + `/contact` 96」变为 **9/9 满分**,并证明这条门禁在干净树上不产生误红(存活证明见 N-13)
- [x] **`src/app/**` 盲区专项审查已做(收尾追加,subagent + 逐条源码复核)**:确认并修复 N-18(开放重定向)/ N-19(越权读取)/ N-20(停用账号令牌)三项 P1,另修 N-17 门禁诚实性并把 `@security` 从「72 passed / 0 skipped」如实变为「**68 passed + 4 skipped**」。三处修复各有 **RED 探针实测**(临时移除 guard ⇒ 对应用例判红 ⇒ 恢复转绿),非推断。修后复跑:`type-check` EXIT=0、`test:unit` **133 套件 / 1691 例 EXIT=0**、`lint` 0 error / 105 warning。留决策:N-21(幻影 modelCode、`/admin` 无服务端 gating)。教训已入 `docs/lessons-learned.md` §5.30。**⚠ 这 4 项修复的验证层级要说清**:N-18/N-19/N-20 是**应用代码**,而 E2E 打的是 **13:17 那次 `npm run build` 的产物**(本轮未重新构建)⇒ 上面的 92 例与 68+4 例**并不覆盖这三处修复**;它们的凭据是 `test:unit`(含新增 `disable/route.test.ts` 7 例与 `permissions.test.ts` 新增 3 例)+ 各自的 RED 探针 + `type-check`。**该缺口已于收尾时闭合**:重新 `npm run build`(`BUILD_EXIT=0`、装配自检 OK、且 `find src -newer dist/standalone/dist/BUILD_ID` 为空 ⇒ 产物确含当前全部源码),再以 `PORT=3700 node dist/standalone/server.js` 起真实产物直接探测三条修复:`POST /api/cms/draft/disable` 的 `https://evil.example/p`、`//evil.example`、`/\\evil.example` **全部 400**(修复前同一入口实测返回 307 向外跳转),合法 `/cases` 仍 **307 + `location: http://localhost:3700/cases`**(无误伤);`GET /api/admin/stats` 无令牌 **401**(权限层级由 `permissions.test.ts` + 真库 `03-authorization.itest.ts` 覆盖);`GET /` **200** 且 CSP 头存在 ⇒ 路由改动未破坏站点。
**闭合后的两条复跑证据(都在最终树上)**:① 视觉只读比对再次 **104 通过 / 21 失败 / EXIT=1**,且**失败集合与第一次逐行 diff 完全一致**(两份 55 行清单 `diff` 为空)⇒ N-30 删掉的兜底文案**没有造成任何视觉漂移**,21 例仍是 N-15 的装配缺陷,不新增待追认项;② 生产目标 write-free E2E 在**新产物**上重跑 **92 通过 / EXIT=0**(2.9 分钟)⇒ 前面所有以「92 例」为凭的说法现在对应的是含 N-18/19/20/25/30 改动的当前源码,而不是 13:17 的旧构建。N-17 是**测试代码**,其 68+4 复跑发生在修复之后,直接有效。教训已入 `docs/lessons-learned.md` §5.30
- [x] **「谁来测门禁」专项已做(第二次 subagent 扫描 + 逐条源码复核)**:审 `scripts/**` + `Jenkinsfile` 的恒不成立断言,产出 N-22(`check-security-headers.ts` 的 CSP 与 cookie 两条主判据**永远不会失败**,含 `val ? 'warn' : 'warn'` 这种同分支死三元)与 N-23(该门禁默认打**线上** `https://novalon.cn`,而 Jenkinsfile 打印"检查本分支构建产物"——声明与行为相反)、N-24(per-route 状态码空洞、三个从未接入的坏脚本、`--pr-dir` 空目录空转)。**N-22 已修并做双向对照**:无 CSP 的本地服务 ⇒ `EXIT=1/4 项 fail`;真实 standalone 产物 ⇒ `EXIT=0/0 失败`。过程中由正向对照额外发现 **HSTS 只由边缘注入**,故原门禁结构上无法在分支产物通过(已按 http/https 分档处理)。修后复跑:`type-check` EXIT=0、`lint` 0 error / **105 warning(未新增)**、`test:unit` **133 套件 / 1691 例**、`test:integration:real` **4 套件 / 23 例**(真实临时 SQLite,`dev.db` 未触)
- [x] **重新构建后在产物目标上复跑 write-free E2E**:`BUILD_EXIT=0` + 装配自检 OK,**91 通过 / 1 失败**,失败例隔离复跑通过 ⇒ 判为并发下的间歇性超时而非回归(不靠加 retry 掩盖)。同时把 N-18/N-19/N-20 的验证层级说清:其凭据在单测 + 真库 itest + RED 探针,产物目标 E2E 不触管理端 API
- [x] **N-24 的 ②③④ 已修(门禁自身不再空转)**:`check-heading-hierarchy.ts` 去掉「404 即就绪」+ 加 per-route 状态断言(新增 `"http_error"` 类型)+ 起服务从不受支持的 `next start` 改为直起 standalone;`check-pr-checklist.sh` 的两处空转改为 `exit 2`。**四例 + 双向对照全部实测**:`MISSING_DIR=2 / EMPTY_DIR=2 / CLEAN=0 / DIRTY=1`;标题门禁正向 `10 页 / EXIT=0`、负向(临时塞一条不存在路由)`HTTP 404 / EXIT=1`,探针已复原。修后 `type-check` 0、`lint` 仍 0 error / 105 warning、`test:unit` 1691 例通过。①(`axe-node-count` 的 per-route 断言)留作独立动作
- [x] **部署层专项已做(第三次 subagent 扫描 + 逐条源码复核)**:`Dockerfile` / `Dockerfile.prod` / 两份 nginx conf / `docker-compose*` / `Jenkinsfile` / `.dockerignore` / `scripts/deploy.sh` 交叉核对。**1 项已修(N-25 限流 XFF 伪造绕过,攻击路径实测复现,6 例新测试)**,**5 项记录留决策(N-26)**:密钥进 builder 层(且"加进 .dockerignore"这个显然修法会连带打断 `NEXT_PUBLIC_*` 构建期内联 ⇒ 不该那么修)、CI 先删 lock 再 `npm ci` 导致 lock 从未生效、`nginx-static-production.conf` 字体/图片块缺 `@nextjs` 回退、边缘 `add_header` 可能追加出双份 `Cache-Control`、`allowEmptyArchive` 让归档步骤可空转报绿。**正向结论也记一条**:镜像装配与本地 `postbuild` 拷的目录集**完全一致**(`dist/static`、`public`),`test:e2e:prod` / LHCI / Dockerfile CMD 三者启动方式一致 ⇒ N-9 那族"门禁与交付物不是同一个东西"的问题在装配层已收敛
- [ ] 视觉基线 85 张差异的**人工追认**、N-15 四类修法、`--update-snapshots` 写参照:均需 §5 第 3 项授权,本轮不执行
## 7. 工作树状态披露(不静默留给下个会话)
> 🔴 **最高优先披露:本报告判为「已闭环」的全部修复只存在于工作树,一个 commit 都没有。**
> `git status --porcelain` = **341 条**(39 条未跟踪;其中 85 条是 `e2e/visual-snapshots` 的 PNG),HEAD 仍是分支起点 `f543e47`。
> 也就是说 §2 里所有「已闭环」的行——A-1..A-8、A-11..A-15、R-1..R-5、B-1/B-3/B-4/B-7、N-1..N-9——**任何一条 `git checkout .` / `git reset --hard` 都会连同新增文件被清掉**(未跟踪的 `src/lib/sanitize.ts`、`upload-policy.ts`、`validate-content-data.ts`、`global-error.tsx`、`motion-provider.tsx`、`tests-integration/`、`config/test/itest/`、`scripts/accessibility/`、`page.test.tsx` 等约 30 项需 `clean -f` 才丢)。
> 验收含义:本轮交付的是**未固化的工作树状态**,不是可复现的提交。第 4 项授权(commit/push/PR)未获批准 ⇒ 无法把它变成可审、可回滚、可复测的单元。
- 🔴 **视觉基线:磁盘上的参照不是已提交的参照(A-9 的补充披露,本轮实测)**。`git status --porcelain` 中 **85 个 `.png` 全部是 `M`(已跟踪且被修改),0 个未跟踪**,集中在 5 个 project 目录各 17 张。三件事需要同时说清:
1. **时间线**:HEAD `f543e47` 提交于 **2026-09-20 11:32**,其标题自称「以规范模式重生成全站视觉基线」;而这 85 张的 mtime 全部落在 **2026-09-22 02:37–02:56** ⇒ 它们在提交之后两天被重新生成并停留在工作树,**从未进入任何 commit**。
2. **验收后果**:本轮只读比对(104 通过)比的是**这套 09-22 工作树参照**;任何从 `f543e47` 检出的人、任何 CI、以及下一位开发者比的是**另一套参照**。因此「视觉回归通过」这句话**依赖未提交状态**才成立——这正是 A-9 原始症结(基线被无声追认)的镜像形态,只是这次追认物在工作树而非提交里,且**没人核看过这 85 张的差异**。
3. **可逆性与判据**:`git checkout -- e2e/visual-snapshots` 会**静默丢弃**这 85 张并重回到 `f543e47` 版本(视觉历史将不可复原这 09-22 的现状记录),故本轮**未做任何还原/清理动作**;差异是否追认属 §5 第 3 项授权范围。若要复核差异本身,`git diff --stat HEAD -- '*-snapshots*'` 只给字节数(如 `solutions-fullpage` 1,651,956 → 1,646,257、`theme-light-main` 3,501,526 → 3,502,516),**像素级判读需人工看图**,本轮据实声明:未做人工像素核看,故不宣称「85 张差异合规」。
- ⚠ **本轮对 `AGENTS.md` 的改动不计入交付物**:该文件被 `.gitignore:312` 有意忽略且**从未被 git 跟踪**(`git ls-files` 无匹配),历轮报告里「已回填 AGENTS.md §5」类声明同样**不进入 diff**、不可审不可回滚,并可能被 `next dev` 重写(`node_modules/next/dist/server/lib/generate-agent-files.js:2` 已核实为自动生成的管理文件)。详见 N-16。本报告的门禁口径因此**在已跟踪文件中各存一份**:`docs/lessons-learned.md` §5.29(视觉门禁分层判读)与本文 §1/§3(N-14、N-15、N-16)。
- 索引内已有 1 条**已 staged 的删除**:`config/test/jest.setup.js`(全仓 `config/test/jest.setup` 引用为 0,真实 setup 是根目录 `jest.setup.js` ⇒ 删除安全,但它是**本轮之前**就处于已暂存状态的条目,非本会话动作)
- 未跟踪新增项包括:`tests-integration/`、`config/test/itest/*`、`config/test/jest.integration.config.js`、`scripts/accessibility/`、`scripts/utils/check-brand-text-token.ts`、`e2e/{fixtures,hydrated,primary-nav,touch-targets}.ts`、`src/app/admin/content/[modelCode]/[itemId]/page.test.tsx`、`src/lib/{sanitize,media/upload-policy,cms/validate-content-data}.{ts,test.ts}`、`src/app/global-error.{tsx,test.tsx}`、`src/components/ui/motion-provider.tsx`、`src/app/api/admin/users/route.test.ts`、`src/app/api/auth/{login,refresh}/route.test.ts`、`src/app/api/cms/draft/enable/route.test.ts`、`docs/acceptance/2026-09-2{1,2,3}-*` 证据目录、根目录 `ACCEPTANCE_REVIEW_2026-09-21.md`(上一轮判定报告)、`deliverables/`
- 另有已删除但未提交的死代码:`Dockerfile.static`、`lighthouserc.json`(根目录旧位置)、`src/components/{cms/RichTextEditor,content/testimonials,detail/list-page-hero,layout/page-nav,sections/stats-bar,ui/{accordion,dialog,flip-clock,loading-skeleton,metric-card,milestone-timeline,select,stats-showcase,tabs}}`、`src/lib/{colors,gradients}`、`src/hooks/use-keyboard-shortcuts`、`src/components/examples/ContactFormAnalyticsExample.tsx` 及各自同名测试
- ⚠ SQLite 边车文件 `prisma/dev.db-shm`、`prisma/dev.db-wal` 处于未跟踪且**未被 .gitignore 覆盖**状态(`.gitignore:98` 只忽略 `*.db`)——`git add -A` 会把它们带进提交,提交前必须排除。建议改法(**本轮未擅自改**,因为它会改变 `git add -A` 的捕获范围,与第 4 项提交授权相互影响):在 `.gitignore:98` 旁补 `*.db-shm` 与 `*.db-wal` 两行。另已实测 `.lighthouseci/`、`lighthouse-reports/`(本轮 346 个报告文件)、`coverage/`、`dist/standalone/` **均已忽略** ⇒ 本轮跑门禁没有污染提交面。
- 收尾后计数:`git status --porcelain` = **341 条**(未跟踪含本轮新增源码:`src/lib/client-ip.ts`、`src/lib/client-ip.test.ts`、`src/lib/cms/internal-redirect.ts`、`src/app/api/cms/draft/disable/route.test.ts`)。**更正本行早前的说法**:收尾阶段并非"只新增文档改动"——为修 N-18/N-20 另新增了 **2 个未跟踪源码文件**(`src/lib/cms/internal-redirect.ts`、`src/app/api/cms/draft/disable/route.test.ts`),并修改了 6 个既有文件(`src/lib/permissions.ts`、`src/lib/permissions.test.ts`、`src/app/api/admin/stats/route.ts`、`src/app/api/cms/draft/{enable,disable}/route.ts`、`e2e/security-headers.spec.ts`,这些文件此前就已处于 modified 状态,故条目数增量小于改动文件数)。提交时须与其余修复一起纳入,不可只挑文档。
- **本轮数据库完整性实测**:只读计数 `ContentItem 38(全部 published)/ User 3 / Role 5 / MediaAsset 0`,与 seed 形态一致,无残留 draft/测试条目 ⇒ 真库集成层确实落在 `/tmp` 一次性库。**收尾时(全部 E2E / 门禁 / 集成跑完之后)以 `sqlite3 -readonly prisma/dev.db` 复测,四组数字与 draft=0 完全未变**;此处另记一条方法学:Prisma 客户端在裸脚本上下文下会因缺少 env 装配而 `PrismaClientInitializationError`(`Failed to apply SQLite WAL/busy_timeout pragmas`),**验证只读状态应直接用 `sqlite3 -readonly`,不要为了一次计数去初始化 ORM**。`dev.db` 的 mtime 会因 standalone server 以 WAL 模式**读取**而变动,这不等于写入,故以上述行数为判据。
## 8. 续工交接(本轮到此停止,以下为唯一未推进项与精确入口)
> 本轮所有能自主推进的工作已落地并有实测凭据;剩下的**全部**需要用户单独授权或产品/设计决策。**接手时先读本节,不要重跑 §1 已出数的门禁。**
### 8.1 等待授权的动作(五项,逐项明文批准后才动)
| # | 动作 | 精确命令 | 批准前为什么不能做 |
|---|---|---|---|
| 1 | 重跑 seed | `npm run db:seed` | 覆盖 `prisma/dev.db` 现有开发数据(当前 38/3/5/0 与 seed 形态一致) |
| 2 | 写库 E2E | 完整 `npm run test` 或完整 `test:e2e:prod`;**只差** `e2e/cms-workflow.spec.ts` 与 `e2e/user-journey.spec.ts`(合计 11 例) | 这 11 例真写库(建草稿/上传媒体/改角色/`revalidate`);其余 92 + 68 + 124 例已在只读子集下实测通过 |
| 3 | 视觉基线写入 | `npx playwright test visual-regression.spec.ts --update-snapshots` | 会改写已批准参照;且需先人工核看磁盘上 85 张相对 HEAD `f543e47` 的 modified 差异(§7 时间线) |
| 4 | 提交 / 推送 / PR | `git add`(按路径,勿 `-A`:`.gitignore:98` 未挡 `prisma/dev.db-shm`/`-wal`)+ commit + push + Gitea PR | 共享状态;须过 `scripts/check-pr-checklist.sh`;工作树 341 条改动一次入库体积过大,建议按 N 系列分批 |
| 5 | 变异测试 | `npm run test:mutation:quick`(或去 `--inPlace` 的沙箱跑法) | `--inPlace` 直接改写工作树,而本树全部改动未提交 ⇒ 崩溃不可恢复。**顺序:第 4 项之后再跑** |
### 8.2 等待产品 / 设计决策(代码侧不该替业务回答)
1. **四条互斥 SLA**(N-30):`<2小时 · 7×24`(`detail-cta-section.tsx:84`)/ `<4小时 工作日`(`prisma/seeds/services.ts:129`)/ `48小时内`(`case-detail-page.tsx:513`)/ `工作日 2 小时内`(`prisma/seed.ts:836`)—— 只能留一个。
2. **`/products/erp-upgrade` 的数字口径**(N-30):`99.2%`、`40%+`、`70%`、`100%`、「从5天到1天」既无 `basis` 也无 `MetricsBasisNote`;需业务给出真实来源或降级为目标口径标注。同项下 `SERVICES.dataProofs` 12 条在所有检查之外(潜伏,未在线)。
3. **`/about` 的四个 keyMetrics 永远无法声明口径**(N-30 ③):`seed.ts:375-379` 的字段定义缺 `basis`,覆盖了 `content-types.ts:1207` 的正确定义 —— 修法是改 seed 字段定义,但要确认不引发数据迁移。
4. **webkit 是否纳入 axe 门禁矩阵**(N-32):现矩阵只有 `chromium|firefox`;`@accessibility` 层在 webkit 上报 1 项 serious/critical(4 页,隔离复跑不复现,规则名未取到)。要先让该用例把违规明细写成附件再判真伪。
5. **`/admin` 无服务端 gating + `'content-item'` 幻影 modelCode**(N-21);**`security-headers` 门禁默认打线上**(N-23);**axe/标题门禁的 per-route 状态断言**(N-24①);**N-15 的 21 例坏用例与 N-28 的并发抖动策略**(墙钟型用例不该进共享并发门禁)。
### 8.3 已知不稳定清单(不要通过加 retry/放宽阈值来「修」)
- `mobile-user-journeys` UJ-10(firefox,隔离复跑通过)、`website-acceptance:111` 响应式设计(隔离复跑通过)、`@accessibility` webkit 四例。三者同属**并发/墙钟**族:同一代码三轮全量跑失败数为 8 → 1 → 5。加 retry 或调大 timeout 只会把间歇红藏更深。
### 8.4 收尾时的环境状态(已核对)
本轮自建的探测端口(3600/3601/3700/3710 及 3500/3501 负向对照服务)全部释放,`:3000` 无残留监听;仓库内无遗留临时脚本;他人项目占用的 `5432`/`8080` 全程未被使用。`prisma/dev.db` 以 `sqlite3 -readonly` 复核仍为 `ContentItem 38 / User 3 / Role 5 / MediaAsset 0 / draft 0`。