atlas-project-harness 0.2.0-beta.1 → 0.2.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## [v0.2.0-beta.2] - 2026-10-22 - 2026-10-22
8
+
9
+ ### ✨ E2E hash 查询参数(D132)
10
+
11
+ * **`hash参数:` + `e2e.query_preset.hash_keys`**:HashRouter 页面 `#` 之后的查询前提;`goto` 值内联 hash 查询,生成器把它注入守卫导航的 hash 段,base query 仍留在 `#` 之前;零步基线与 `参数:` 同纪律不施加;路径门两侧去双查询后比对。
12
+ * **钉**:生成器 +6(守卫/步骤双发射、零步基线排除、未白名单、死声明、值形态、`url_merge` 原语);校验器 +7(正向、未白名单、白名单外、死声明、值形态、未声明 hash 键、双载体)。`shared/gates.md` 的 `tests/test_validators.py` 计数已同步 **138**。
13
+
14
+ ---
15
+
7
16
  ## [v0.2.0-beta.1] - 2026-10-21
8
17
 
9
18
  ### ✨ 评审 / 覆盖 / 事故三条反馈闭环(D128–D131)
package/DESIGN.md CHANGED
@@ -598,6 +598,7 @@ adapters:
598
598
  | D129 | 独立审查报告契约扩展为 **`schema_version: 2`**,引入缺陷指纹去重、存量代码标记、处置留痕与轮次上限(2026-10-21;`shared/independent-review.md` §3/§4;`_support/review_policy.py`、`scripts/validate_review_report.py`、`scripts/cli_review_convert.py`、`scripts/gen_e2e_scripts.py`) | ① **病灶**:① 原契约状态映射硬死(有任一 Critical 即 FAIL),**无法区分改动引入的新缺陷与存量旧代码问题**;Agent 评审实测 59% 为既有存量代码,导致微小改动因存量问题被无限阻断;② 缺乏 finding 去重指纹,导致相同问题跨轮重复刷分;③ 缺乏轮次上限,实测出现连夜 50+ 轮死循环;④ 缺乏显式处置闭环(disposition),误报(false positive)无法由人或架构签字排除。② **定案**:① 设立单源内核 `_support/review_policy.py`;② `review.json` 引入 `schema_version: 2`;`checks[]` 扩充 `fingerprint`(排除行号的稳定哈希)、`pre_existing`(bool)、`disposition`(open/fixed/false_positive/intentional/ignored)、`verification`;③ 状态映射:阻断性 Critical = `Critical ∧ pre_existing!=true ∧ disposition未关闭`;`pre_existing=true` 的 Critical 降级为 WARN 下限(记录存量,不静默放行);带签字与取证的 `false_positive` 降级为 WARN;重复 fingerprint ⇒ 校验器 FAIL;④ 轮次收敛:round >= 4 必须提供 `--escalate-to` 与 `--escalate-reason` 升级复议,否则拒绝放行;⑤ 首轮后支持 `--delta-only` 差量复审;⑥ 缺省 `schema_version: 1` 维持既有全阻断逻辑,存量历史报告 100% 兼容。③ **被否**:把 disposition 做成无需签字的口头开关(开严重安全后门);由模型自审关闭 Critical(违背独立审查原则,必须人/架构签字)。④ **钉与变异**:`tests/test_review_report.py` +4、`tests/test_cli_review_convert.py` +3(含 `test_fingerprint_stable_across_line_numbers`:行号漂移后指纹必须不变、内容不同指纹必须不同——评审抓到 CLI 恒产 `path:12-15` 而旧正则剥不掉 ⇒ 去重门永不命中)、`tests/test_gen_e2e_scripts.py` +1;变异 **M130**(摘掉 pre_existing 豁免退回原写死 FAIL ⇒ 钉红)、**M130b**(摘 `validate_review_report` 的 fingerprint 去重段 ⇒ 重复指纹钉红)、**M130c**(`clean_target_file` 行号正则退回 `:\d+$`,剥不掉 CLI 恒产的 `path:12-15` ⇒ 指纹稳定性钉红),四段协议(基线绿→注入红→逐字节还原→复绿)全部通过、还原 sha256 逐字节一致。全量测试通过。 |
599
599
  | D130 | E2E 环引入 **前端适配版 C01-C14 覆盖维度矩阵** 与缺口对账(2026-10-21;`rings/e2e/reference.md` §5.1/§5.2/§5.5/§8.2;`_support/case_block.py`、`validators/validate_e2e_index.py`、`templates/prd/domain.md`、`templates/e2e/case-shard.md`) | ① **病灶**:原「AC 反向覆盖门」仅能回答「每条 AC 是否至少被一条用例指向」,**无法回答每条 AC 的异常流、边界值、权限、会话恢复、幂等、状态转换等高危场景是否覆盖**;导致 Agent 生成大量表面正向用例,真正易崩场景系统性遗漏。② **定案**:① 前端适配裁剪版 C01-C14 场景维度闭集(C01正常流、C02异常流、C03边界值、C04权限、C05并发、C06重试、C07审计、C08会话恢复、C09一致性、C10依赖容错、C11幂等、C12输入校验、C13状态转换、C14安全);② 用例分片块体增加可选数组字段 `覆盖维度:`,单源解析器 `_support/case_block.py` 纳入键空间;③ 域级 PRD 验收标准表增加可选第 4 列 `适用覆盖维度`(如 `C01, C02`),前三列保持不变;④ 校验器 `validators/validate_e2e_index.py` 增加「AC × 覆盖维度」对账项:已声明但未全量覆盖 ⇒ WARN;未知维度词 ⇒ WARN;未声明第 4 列的项目 ⇒ N/A 兜底,绝不产生破坏存量的永久红门;⑤ 控制台 snapshot case 对象透传 `dimensions` 字段。③ **被否**:在 PRD 新建第 4 张手工对账表(严重违反 single-source 契约,且会与 AC 唯一性定义门发生行号碰撞);对所有 AC 强制 14 维度全覆盖(引发无脑生成几十条形式用例的膨胀灾难)。④ **钉与变异**:`tests/test_validators.py` 增加 `test_e2e_index_coverage_dimensions_pass_and_warn`(N/A / WARN / PASS 三态验证);变异 **M131**(摘掉 dimensions 聚合回落全 PASS ⇒ 钉红),还原逐字节一致。全量测试通过。 |
600
600
  | D131 | 收口契约引入 **变更风险定档、Always-Human 路径、修复验证 7 检查点与事故追问闭环**(2026-10-21;`shared/closeout.md` §2 步骤 3/4/11;`duty-review/README.md` §2;`templates/reviews/README.md`) | ① **病灶**:① 收口阶段缺乏系统性风险定档,高危底层改动(如权限、路由、CI、包依赖)容易被 Agent 自述误导而跳过严格人工亲测;② 缺陷修复任务(bug fix)常直接修改业务代码并自报已修复,缺乏「先失败后转绿」的 Red First 证明与边界/回归核对,导致同类问题反复逃逸;③ 线上/联调事故复盘停留在修代码,未追问「哪一道门本该接住」,事故没有转化为常态化回归用例或变异钉。② **定案**:① `shared/closeout.md` 补齐步骤 3「变更风险定档与人工审批」:三档风险矩阵,明确 `auth / permission / routing / CI / package deps / shared contracts` 为 Always-Human 永远人工路径,严禁 Agent 代勾放行;② 补齐步骤 4「缺陷修复验证 7 检查点」(复现明确、Red First 先失败测试、最小 diff、边界值、状态机完整、回归范围界定、闭环证据留存);③ 步骤 11 与 `duty-review/README.md` 引入事故追问机制:每个确认逃逸缺陷必须在 `bugs.md` 标注原因,并在关闭前补齐 E2E 回归用例或 M 系列变异钉;④ 提供 `templates/reviews/README.md` 代码审查指引轻量模板(≤10 行,聚焦失败特征与证据链)。③ **被否**:用文件名/正则做脆弱的机器强制路径阻断脚本(极易被重命名或参数绕过,且易对合法改动造成假阻断);由 Agent 自动签署审批(违背人类作为最终交付负责人的第一原则)。④ **钉与变异**:`test_canonical_consistency.py` 计数与跨文件一致性全量跑绿。 |
601
+ | D132 | 新增 **E2E hash 查询参数契约**(`hash参数:` + `e2e.query_preset.hash_keys`)(2026-10-22;`rings/e2e/reference.md` §5.5/§5.7/§8.2;`shared/stack-profile.md` §2;`_support/case_block.py` / `_support/url_merge.py` / `scripts/gen_e2e_scripts.py` / `validators/validate_e2e_index.py`) | ① **病灶**:HashRouter 页面的 `useSearchParams` 只读 `#` 之后的查询段;D109 `参数:` 只覆盖 `#` 之前的常规查询,`会话:` 是 sessionStorage,导致快照页这类「`#` 后查询前提」用例无合法写法,只能手补生成物(重跑即丢)。② **定案**:`hash参数:` 只声明键,值内联在 `goto` 的 hash 查询串;白名单 `query_preset.hash_keys`;守卫导航把同一段 hash 查询追加到 hash 段(`append_hash_query`),base query 仍在 `#` 前;零步基线不施加;路径门两侧 `strip_queries` 后比对;同键不得跨 `参数:`/`会话:`/`hash参数:` 双写。③ **被否**:把 `#` 后查询硬塞进 `参数:`(会丢 hash 段 / 与 base query 合并语义冲突);项目侧手补生成物(不可重生成,属已否的 D98 类补丁)。④ **钉**:生成器 6 条 + 校验器 7 条(含 `test_hash_param_carried_to_guard_and_goto`、`test_zero_step_baseline_carries_no_hash_param`、`test_hash_query_merge_helpers` 与正向/未白名单/白名单外/死声明/值形态/未声明 hash 键/双载体);`tests/test_validators.py` 计数 138 已登记 `shared/gates.md`。 |
601
602
 
602
603
 
603
604
 
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Atlas — 践行 Project Harness 的全流程 AI 编程工作流
2
2
 
3
3
  <p align="left">
4
- <img src="https://img.shields.io/badge/version-0.2.0--beta.1-blue.svg" alt="Version" />
4
+ <img src="https://img.shields.io/badge/version-0.2.0--beta.2-blue.svg" alt="Version" />
5
5
  <img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License" />
6
6
  <img src="https://img.shields.io/badge/tests-passing-success.svg" alt="Tests" />
7
7
  <a href="https://sunwenzhe-git.github.io/atlas/"><img src="https://img.shields.io/badge/docs-VitePress-green.svg" alt="Docs" /></a>
@@ -45,8 +45,10 @@ strip_inline_comment = load_module("stack_profile").strip_inline_comment
45
45
  # 键空间 = `rings/e2e/reference.md` §5.5 用例块字段(消费方按需读取)。
46
46
  # `参数`(2026-10-18,`DESIGN.md` D109):宿主注入型 URL 参数的**键清单**(值内联在 step 的
47
47
  # `goto` 查询串里)——不在键空间内的键会被按**标量**入表(`key = None`),其后 `- 项` 行跟丢。
48
+ # `hash参数`(2026-10-22,HashRouter 场景):`#` 之后 hash 查询串的**键清单**(值同样内联在
49
+ # `goto` 的 hash 查询里)——与 `参数:` 分载体,值来自 `window.location.hash` 的查询段。
48
50
  # `覆盖维度`(2026-10-21,`DESIGN.md` D130):前端适配版 C01-C14 场景维度列表。
49
- _BLOCK_KEYS = ("pages", "precondition", "seed", "mock", "step", "expected", "testid", "落点", "会话", "参数", "覆盖维度")
51
+ _BLOCK_KEYS = ("pages", "precondition", "seed", "mock", "step", "expected", "testid", "落点", "会话", "参数", "hash参数", "覆盖维度")
50
52
 
51
53
 
52
54
  def parse_block(body: str) -> dict:
@@ -81,6 +81,17 @@ def query_of(target: str) -> str:
81
81
  return target.partition("#")[0].partition("?")[2]
82
82
 
83
83
 
84
+ def hash_query_of(target: str) -> str:
85
+ """`target` 的 **hash 查询串**(`/a#/h?k=v` ⇒ `k=v`;无 hash / 无查询 ⇒ 空串)。
86
+
87
+ 与 `query_of` 分载体:HashRouter 页面的 `useSearchParams` 读的是 hash 内的查询段,
88
+ 常规查询串(`#` 之前)由 `query_of` 负责。两者不得混用。
89
+ """
90
+ if "#" not in target:
91
+ return ""
92
+ return target.partition("#")[2].partition("?")[2]
93
+
94
+
84
95
  def query_keys(target: str) -> list[str]:
85
96
  """`target` 查询串里的键(按出现顺序去重)——「键须已声明」门与生成器守卫取值**共用本单源**。"""
86
97
  out: list[str] = []
@@ -107,12 +118,57 @@ def append_query(target: str, q: str) -> str:
107
118
  return f"{joined}#{frag}" if sep else joined
108
119
 
109
120
 
121
+ def hash_query_keys(target: str) -> list[str]:
122
+ """`target` 的 hash 查询键(按出现顺序去重)——`hash参数:` 声明门与生成器守卫取值共用。"""
123
+ out: list[str] = []
124
+ for part in hash_query_of(target).split("&"):
125
+ if not part:
126
+ continue
127
+ k = part.partition("=")[0].strip()
128
+ if k and k not in out:
129
+ out.append(k)
130
+ return out
131
+
132
+
133
+ def append_hash_query(target: str, q: str) -> str:
134
+ """把查询串 `q` 追加到 `target` 的 **hash 查询段**(`/a#/h` ⇒ `/a#/h?q`)。
135
+
136
+ 只处理已带 hash 的目标;没有 hash 时原样返回(不私造一条 hash 路由,调用方应响亮失败)。
137
+ 已有 hash 查询 ⇒ 与 `q` 求并集(既有在前、`q` 在后,**同键 ⇒ `q` 胜**)。
138
+ """
139
+ if not q or "#" not in target:
140
+ return target
141
+ head, _, frag = target.partition("#")
142
+ fpath, fsep, fq = frag.partition("?")
143
+ merged = q if not fsep else union_query(fq, q)
144
+ return f"{head}#{fpath}?{merged}"
145
+
146
+
110
147
  def strip_query(target: str) -> str:
111
- """去掉查询串、保留 hash(`/a?k=v#/h` ⇒ `/a#/h`)——供路径门按**路径形态**匹配(`_route_hit`)。"""
112
- if "?" not in target:
148
+ """去掉**常规查询串**、保留 hash 段(含 hash 查询)(`/a?k=v#/h?x=1` ⇒ `/a#/h?x=1`)。
149
+
150
+ 只处理 `#` 之前的查询;hash 内的查询由 `strip_hash_query` 单独处理。
151
+ """
152
+ head, sep, frag = target.partition("#")
153
+ if "?" in head:
154
+ head = head.partition("?")[0]
155
+ return f"{head}#{frag}" if sep else head
156
+
157
+
158
+ def strip_hash_query(target: str) -> str:
159
+ """去掉 **hash 查询串**、保留 hash 路径(`/a#/h?k=v` ⇒ `/a#/h`)。"""
160
+ head, sep, frag = target.partition("#")
161
+ if not sep or "?" not in frag:
113
162
  return target
114
- head, _, rest = target.partition("?")
115
- return head + "#" + rest.partition("#")[2] if "#" in rest else head
163
+ return f"{head}#{frag.partition('?')[0]}"
164
+
165
+
166
+ def strip_queries(target: str) -> str:
167
+ """同时去掉常规查询串与 hash 查询串,保留 hash 路径(`/a?t=1#/h?k=v` ⇒ `/a#/h`)。
168
+
169
+ 供 `goto` / `守卫` 路径门按**路径形态**匹配(页面表 `路由` 列是路径形态)。顺序无关。
170
+ """
171
+ return strip_hash_query(strip_query(target))
116
172
 
117
173
 
118
174
  def origin_of(base: str) -> str:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas-project-harness",
3
- "version": "0.2.0-beta.1",
3
+ "version": "0.2.0-beta.2",
4
4
  "description": "践行 Project Harness 的全流程 AI 编程工作流治理引擎",
5
5
  "bin": {
6
6
  "atlas": "bin/atlas.js"
@@ -303,6 +303,13 @@ testid:
303
303
  - **边界(明示)**:本通道只表达**载入时**注入;「运行中改地址栏查询串」**无通道**(`refresh` 后的地址不含该参数——这正是「禁用态由会话内快照承载」类用例的被测属性,与 §5.5 `会话:` 分工)。
304
304
  - **与 §5.7 的边界(硬)**:本通道只承载**宿主注入型前提**(身份 / 租户 / 管控 / 灰度 / 预览壳类)——与 `会话:` 同族、**同白名单纪律**;**数据实体状态仍只能用真实界面动作达到**。**未声明或不在白名单的查询键一律 FAIL** ⇒ 原靶(「不得用调试 / 演示参数直达某个数据态」)由此守住(2026-10-18 重靶的落点,见 §8.2 门表与 `DESIGN.md` D109)。
305
305
  - **双载体禁止**:同一键**不得**同时出现在 `会话:` 与 `参数:`(两个载体写同一键 = 前提不可解释)⇒ **FAIL**。
306
+ - **`hash参数:`(可选,2026-10-22 补,`DESIGN.md` D132)**:**HashRouter 载入参数前提**。行形态 `- <键>`(一词一条,**只声明键、无值**)。
307
+ - **为何要它**:HashRouter 页面的 `useSearchParams` 读的是 `#` 之后的查询段;`参数:` 只覆盖 `#` 之前的常规查询(`window.location.search`),`会话:` 是 sessionStorage,都送不到这类消费面。值内联写在 `step` 的 `goto` 的 **hash 查询串**里(`/a#/h?k=v`)。
308
+ - **守卫导航**:取该用例首个带 hash 查询的 `goto`,把同一段 hash 查询追加到守卫导航的 hash 段(`url_merge.append_hash_query`);base query 仍在 `#` 之前,二者不互相吞掉。
309
+ - **键必须在项目侧白名单内**(`stack-profile.yaml` 的 `e2e.query_preset.hash_keys`,见 `shared/stack-profile.md` §2):白名单外 / 整段未声明 / 声明了却没被任何 `goto` 的 hash 查询用到(**死声明**)⇒ **文本期 FAIL + 生成器中止**。
310
+ - **与 §5.6 R9 的关系**:**零步基线不施加 `hash参数:`**(同 `参数:`)⇒ 依赖该前提的断言在基线默认态下不成立,R9 自动满足。
311
+ - **路径门**:`goto` / `守卫` 的路径匹配先去掉常规查询与 hash 查询,再与页面表 `路由` 比对(页面表可以带宿主 query 或 hash 段)。
312
+ - **双载体禁止**:同一键**不得**同时出现在 `参数:`、`会话:`、`hash参数:`(多个载体写同一键 = 前提不可解释)⇒ **FAIL**。
306
313
  - **`守卫:`(可选,2026-10-16 补,issue #23)**:**元素齐全守卫导航用的具体路径**(标量)。
307
314
  - **为何要它**:页面表 `路由` 列按 §3.1 只承载**业务段**(带参页面的真实路由形如 `/x/:id`,而该列写 `/x`)——它是**模式**,不是可导航地址。而生成器的元素齐全守卫要 `page.goto` 一次才能数元素 ⇒ 直接拿 `路由` 列导航,带参页面会落到路由匹配不上的地址(页面不渲染 ⇒ 守卫在 `goto` 后即红,用例自身的带参 `goto` 来不及生效)。**同一列被两种相反语义的用途消费**(一处要模式、一处要具体地址)是本字段的由来。
308
315
  - **值**:一条具体路径;必须命中页面表 `路由` 列(口径同 §8.2 的 `goto` 路径门:精确,或**非根路由**的子路径前缀命中)——分片仍不得私藏路径。
@@ -536,11 +543,15 @@ testid:
536
543
  | 分片块 | 字段齐(`id`/`pages`/`precondition`/`step`/`expected`/`testid`);块 `id` 属于索引;无孤儿(分片有索引外用例);无死链(索引用例在分片缺失) |
537
544
  | `pages` 合法性 | 每页均为已知页面(索引**页面表**的页 slug);`page` ∈ `pages` |
538
545
  | 反向视图一致 | 反向视图 == 由「主表 + 分片」推导的集合 |
539
- | `goto` 查询键须已声明 | `goto` **可以**带查询串,但每个键必须 ① ∈ `e2e.query_preset.keys` **∧** ② ∈ 本用例 `参数:` 声明;缺一即 **FAIL**(**2026-10-18 重靶**,`DESIGN.md` D109;原门为「`goto` 不带查询参数」一刀切)。**原靶仍在 FAIL 面内**:未声明的查询键(原型专有 `?state=` 那类调试参数 = 「数据态直达」形态)照样红 |
546
+ | `goto` 查询键须已声明 | `goto` **可以**带常规查询串,但每个键必须 ① ∈ `e2e.query_preset.keys` **∧** ② ∈ 本用例 `参数:` 声明;缺一即 **FAIL**(**2026-10-18 重靶**,`DESIGN.md` D109;原门为「`goto` 不带查询参数」一刀切)。**原靶仍在 FAIL 面内**:未声明的查询键(原型专有 `?state=` 那类调试参数 = 「数据态直达」形态)照样红 |
540
547
  | `参数` 声明有效 | `参数:` 项须为**纯键**(无 `=` / 无空白)、去重、键 ∈ `e2e.query_preset.keys`、且须被本用例某个 `goto` 用到(**死声明** ⇒ FAIL);整段未声明而出现 `参数:` 行 ⇒ FAIL(2026-10-18,D109) |
541
548
  | `参数` 值形态 | `goto` 查询值须命中 URL 安全集(禁空白 / 引号(含 `'`)/ 反斜杠 / `&` / `#` / `=`);越形态 ⇒ **FAIL**(生成物是 `page.goto('<url>')` 的**单引号拼接面**;2026-10-18,D109) |
542
549
  | `会话`·`参数` 同键不叠写 | 同一用例同键同时出现在 `会话:` 与 `参数:` ⇒ **FAIL**(前提不可解释,R7 同族;2026-10-18,D109) |
543
- | `goto` 路径 ∈ 页面表 | `step` 的 `goto <路径>` 必须命中索引页面表「路由」列:精确命中,或**非根路由**的子路径前缀命中(`/projects/x` 匹配 `/projects`;根路由 `/` 仅精确);越界或**缺路径参数**即 **FAIL**(2026-09-30 补——实测原型时代路径漂移 `goto /home` vs 页面表 `/`,真栈 404;页面表是页↔路由唯一真相源,分片不得私藏路径) |
550
+ | `goto` hash 查询键须已声明 | `goto` 的 **hash 查询串**(`#` 之后)每个键必须 ① ∈ `e2e.query_preset.hash_keys` ∧ ② ∈ 本用例 `hash参数:` 声明;缺一即 **FAIL**(2026-10-22,D132) |
551
+ | `hash 参数` 声明有效 | `hash参数:` 项须为纯键、去重、键 ∈ `e2e.query_preset.hash_keys`、且须被本用例某个 `goto` 的 hash 查询用到(**死声明** ⇒ FAIL);整段未声明而出现 `hash参数:` 行 ⇒ FAIL(2026-10-22,D132) |
552
+ | `hash 参数` 值形态 | `goto` 的 hash 查询值须命中 URL 安全集(同 `参数` 值形态);越形态 ⇒ **FAIL**(2026-10-22,D132) |
553
+ | `hash参数`·`其他载体` 同键不叠写 | 同一用例同键同时出现在 `hash参数:` 与 `参数:`/`会话:` ⇒ **FAIL**(前提不可解释;2026-10-22,D132) |
554
+ | `goto` 路径 ∈ 页面表 | `step` 的 `goto <路径>` 必须命中索引页面表「路由」列:精确命中,或**非根路由**的子路径前缀命中(`/projects/x` 匹配 `/projects`;根路由 `/` 仅精确);比对前**两侧都去掉常规查询与 hash 查询**(`url_merge.strip_queries`,2026-10-22 D132);越界或**缺路径参数**即 **FAIL**(2026-09-30 补——实测原型时代路径漂移 `goto /home` vs 页面表 `/`,真栈 404;页面表是页↔路由唯一真相源,分片不得私藏路径) |
544
555
  | `auth` 取值合法 | 块键 `auth` 只允许 `preset` \| `none`(缺省 `preset`);白名单外 **FAIL**(文本期挡 + 生成器中止双保险;2026-09-30 补) |
545
556
  | `waitFor` 属动作 | `waitFor` 出现在 `expected` ⇒ **FAIL**(它是动作、不是终态;`expected` 是终态集合,§5.6 R9) |
546
557
  | 同一 testid 断言不自相矛盾 | 同一条用例对同一 testid 既断言可见/有文案、又断言不可见 → **FAIL**(§5.6 R7) |
@@ -440,6 +440,13 @@ def ts_case(case: dict, url: str, base: str = "", esm: bool = False) -> str:
440
440
  # `url` 在调用侧已并入 base query(幂等);`守卫:` 是未合并的具体路径 —— 两者都按
441
441
  # 「追加到自身 query」处理(插在 hash 之前),随后的一次 merge 会把 base 去重并上来。
442
442
  _guard_target = url_merge.append_query(_guard_target, _inject_q)
443
+ _inject_hq = case.get("_inject_hash_query") or ""
444
+ if _inject_hq:
445
+ # `hash参数:` 是 HashRouter 的 `#` 后查询前提;守卫导航必须带同一段,否则守卫读的是
446
+ # 无前提的 DOM(快照页会停在「缺少快照标识」错误态)。没有 hash 路由 ⇒ 响亮失败。
447
+ if "#" not in _guard_target:
448
+ raise ValueError(f"{cid}: `hash参数:` 需要守卫/页面路由含 hash 段")
449
+ _guard_target = url_merge.append_hash_query(_guard_target, _inject_hq)
443
450
  guard_url = url_merge.merge_base_query(_guard_target, base)
444
451
  # 契约 §5.5 / §7.1("元素齐全守卫按自动等待判定"):守卫 id 面 = **该用例 `testid:` 应然清单**
445
452
  # (实测:首步为 `goto`/`refresh` 的用例取 `step[0]` 会得到**空表** ⇒ 守卫静默消失)。
@@ -738,9 +745,11 @@ def ts_assert_expr(line: str) -> str:
738
745
  return f"(async () => (await {target}.locator('option').count()) === Number({v}))()"
739
746
  if verb == "visible":
740
747
  return f"(async () => await tsVisible(page, {tid_param}))()"
748
+ # is*() 对缺失元素会等挂载(30s 挂起)——先 count 守卫。
749
+ # enabled/disabled/checked 缺失 ⇒ false;hidden/unchecked 缺失 ⇒ null,
750
+ # 否则「元素不存在」会被反转成 true,基线误判为恒真假阳性。
741
751
  if verb == "hidden":
742
- return f"(async () => !(await tsVisible(page, {tid_param})))()"
743
- # is*() 对缺失元素会等挂载(30s 挂起)——先 count 守卫:缺失 ⇒ false(基线探针安全跳过)
752
+ return f"(async () => {{ const c = await page.getByTestId({t}).count(); if (c <= {nth_idx}) return null; return !(await tsVisible(page, {tid_param})); }})()"
744
753
  if verb == "enabled":
745
754
  return f"(async () => (await page.getByTestId({t}).count()) > {nth_idx} && (await {nth_locator}.isEnabled()))()"
746
755
  if verb == "disabled":
@@ -748,7 +757,7 @@ def ts_assert_expr(line: str) -> str:
748
757
  if verb == "checked":
749
758
  return f"(async () => (await page.getByTestId({t}).count()) > {nth_idx} && (await {nth_locator}.isChecked()))()"
750
759
  if verb == "unchecked":
751
- return f"(async () => !((await page.getByTestId({t}).count()) > {nth_idx} && (await {nth_locator}.isChecked())))()"
760
+ return f"(async () => {{ const c = await page.getByTestId({t}).count(); if (c <= {nth_idx}) return null; return !(await {nth_locator}.isChecked()); }})()"
752
761
  if verb == "value":
753
762
  if value.strip() in ('""', "(空)"):
754
763
  v = json.dumps("", ensure_ascii=False)
@@ -1367,6 +1376,13 @@ def main() -> int:
1367
1376
  qpreset_keys: set[str] = set()
1368
1377
  if isinstance(qpreset_cfg, dict):
1369
1378
  qpreset_keys = {str(k).strip() for k in (qpreset_cfg.get("keys") or []) if str(k).strip()}
1379
+ # 契约 §5.5 `hash参数:`(2026-10-22,HashRouter):`#` 之后 hash 查询串的键清单。
1380
+ # 与 `参数:` 分载体、分白名单(`query_preset.hash_keys`),守卫导航同样取首跳。
1381
+ hash_qpreset_keys: set[str] = set()
1382
+ if isinstance(qpreset_cfg, dict):
1383
+ hash_qpreset_keys = {
1384
+ str(k).strip() for k in (qpreset_cfg.get("hash_keys") or []) if str(k).strip()
1385
+ }
1370
1386
  bad_inject: list[str] = []
1371
1387
  for items in cases.values():
1372
1388
  for c in items:
@@ -1419,11 +1435,73 @@ def main() -> int:
1419
1435
  f"{c['id']}: 参数键 {k!r} 已在 `参数:` 声明、但没有任何 `goto` 用到(死声明)")
1420
1436
  c["_inject_keys"] = ok_keys
1421
1437
  c["_inject_query"] = first_q
1422
- if bad_inject:
1423
- print("ERROR: `参数:` 行不合法(契约 rings/e2e/reference.md §5.5):", file=sys.stderr)
1424
- for b in bad_inject[:40]:
1425
- print(f" - {b}", file=sys.stderr)
1426
- print(f" 共 {len(bad_inject)} 条", file=sys.stderr)
1438
+ # 契约 §5.5 `hash参数:`(2026-10-22,HashRouter):第二遍独立校验,不能复用 `参数:`
1439
+ # 分支的 `continue`(没有常规 `参数:` 的用例同样要能声明 hash 参数)。
1440
+ bad_hash_inject: list[str] = []
1441
+ for items in cases.values():
1442
+ for c in items:
1443
+ c["_inject_hash_keys"] = []
1444
+ c["_inject_hash_query"] = None
1445
+ declared_hash = [str(x).strip() for x in (c.get("hash参数") or []) if str(x).strip()]
1446
+ if not declared_hash:
1447
+ continue
1448
+ if not hash_qpreset_keys:
1449
+ bad_hash_inject.append(
1450
+ f"{c['id']}: 出现 `hash参数:` 行,但 stack-profile 未声明 "
1451
+ "`e2e.query_preset.hash_keys`(该靶场不允许 hash 查询前提;契约 §5.5)")
1452
+ continue
1453
+ ok_hash_keys: list[str] = []
1454
+ for k in declared_hash:
1455
+ if "=" in k or any(ch.isspace() for ch in k):
1456
+ bad_hash_inject.append(
1457
+ f"{c['id']}: `hash参数:` 项须为纯键(值写在 goto 的 hash 查询串里):{k!r}")
1458
+ elif k not in hash_qpreset_keys:
1459
+ bad_hash_inject.append(
1460
+ f"{c['id']}: hash 参数键 {k!r} 不在 `e2e.query_preset.hash_keys` 白名单内"
1461
+ f"(={sorted(hash_qpreset_keys)})")
1462
+ elif k not in ok_hash_keys:
1463
+ ok_hash_keys.append(k)
1464
+ first_hq: str | None = None
1465
+ used_hash: set[str] = set()
1466
+ for s in c.get("step") or []:
1467
+ verb, _tid, value, _ = parse_line(s)
1468
+ if verb != "goto":
1469
+ continue
1470
+ keep_hash: list[str] = []
1471
+ for pair in url_merge.hash_query_of(value.strip()).split("&"):
1472
+ if not pair:
1473
+ continue
1474
+ k, _, v = pair.partition("=")
1475
+ k, v = k.strip(), v.strip()
1476
+ if k not in ok_hash_keys:
1477
+ bad_hash_inject.append(
1478
+ f"{c['id']}: `goto` hash 查询键 {k!r} 未在本用例 `hash参数:` 内声明"
1479
+ "或不在白名单内")
1480
+ continue
1481
+ if v and not url_merge.VALUE_RE.match(v):
1482
+ bad_hash_inject.append(f"{c['id']}: hash 查询值含越形态字符:{k}={v}")
1483
+ continue
1484
+ keep_hash.append(pair)
1485
+ used_hash.add(k)
1486
+ if keep_hash and first_hq is None:
1487
+ first_hq = "&".join(keep_hash)
1488
+ for k in ok_hash_keys:
1489
+ if k not in used_hash:
1490
+ bad_hash_inject.append(
1491
+ f"{c['id']}: hash 参数键 {k!r} 已在 `hash参数:` 声明、但没有任何 `goto` 用到(死声明)")
1492
+ c["_inject_hash_keys"] = ok_hash_keys
1493
+ c["_inject_hash_query"] = first_hq
1494
+ if bad_inject or bad_hash_inject:
1495
+ if bad_inject:
1496
+ print("ERROR: `参数:` 行不合法(契约 rings/e2e/reference.md §5.5):", file=sys.stderr)
1497
+ for b in bad_inject[:40]:
1498
+ print(f" - {b}", file=sys.stderr)
1499
+ print(f" 共 {len(bad_inject)} 条", file=sys.stderr)
1500
+ if bad_hash_inject:
1501
+ print("ERROR: `hash参数:` 行不合法(契约 rings/e2e/reference.md §5.5):", file=sys.stderr)
1502
+ for b in bad_hash_inject[:40]:
1503
+ print(f" - {b}", file=sys.stderr)
1504
+ print(f" 共 {len(bad_hash_inject)} 条", file=sys.stderr)
1427
1505
  return 2
1428
1506
  # upload fixture 引用闭环(契约 §5.5,B201-1,2026-10-12):值 = 相对
1429
1507
  # `product/e2e/fixtures/` 的文件路径(与 §5.8 mock 同基线、同一 fixtures 树)。