Metadata-Version: 2.4
Name: testoracle
Version: 0.1.0
Summary: 测试预言库：用业务不变量规则做断言，避免硬编码预期值
Author-email: "tynam.yang" <tynam.yang@gmail.com>
License: MIT License
        
        Copyright (c) 2026 testoracle
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
        
Project-URL: Homepage, https://github.com/tynam-yang/testoracle
Project-URL: Repository, https://github.com/tynam-yang/testoracle
Project-URL: Issues, https://github.com/tynam-yang/testoracle/issues
Keywords: test,oracle,pytest,assertion,jsonpath,testing
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Pytest
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Testing
Classifier: Topic :: Software Development :: Testing :: Unit
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: jsonpath-ng>=1.6.0
Requires-Dist: pyyaml>=6.0
Requires-Dist: pytest>=7.0
Provides-Extra: allure
Requires-Dist: allure-pytest>=2.13.0; extra == "allure"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: allure-pytest>=2.13.0; extra == "dev"
Dynamic: license-file

# testoracle

> [中文](README.md) | [English](README_EN.md)

> 测试预言库：用**业务不变量规则**做断言，避免硬编码预期值。

很多自动化测试脚本大量硬编码返回值（`assert resp["total"] == 10`），业务微小变更就造成脚本批量失效。
`testoracle` 基于 **Test Oracle（测试预言）** 理论——测试不一定需要预先知道精确输出值，只需要判定输出是否符合业务约束、不变量规则。本库校验 `total >= 0`、`id 不重复` 这类业务规则，而非写死精确返回值。

- 适用于：接口返回、UI 提取数据、JSON 报文、报表数据
- 形态：Python 库 + pytest 插件 + CLI
- 轻量：无 Web 界面、无数据库、不发起 HTTP 请求、只负责**校验数据**

## 安装

```bash
pip install testoracle
```

核心依赖：`jsonpath-ng`、`pyyaml`、`pytest`。可选依赖：`allure-pytest`（allure 报告集成）。

```bash
# 仅核心功能
pip install testoracle

# 需要 allure 报告集成
pip install testoracle[allure]
```

## 快速开始

### 1. 编写规则（yaml）

直接用谓词字段声明约束；同一条规则声明多个谓词时
自动组合为**复合规则（AND 语义）**：

```yaml
rules:
  - name: "分页total不能为负数"
    path: "$.total"
    min: 0

  - name: "列表ID不能重复"
    path: "$.data[*].id"
    unique: true

  - name: "返回数据不能携带密码明文"
    path: "$.data[*].password"
    forbidden: true

  - name: "状态只能为0/1/2"
    path: "$.data[*].status"
    allowed_values: [0, 1, 2]

  # 复合规则：min + not_null 都要通过
  - name: "价格大于0，且不可为空"
    path: "$.data[*].price"
    min: 0
    not_null: true
```

### 1b. 显式 type 式（备选）

也可以用 `type` 字段显式指定规则类型：

```yaml
rules:
  - name: "分页total不能为负数"
    type: "range"
    path: "$.total"
    min: 0

  - name: "列表id不能重复"
    type: "unique"
    path: "$.data[*].id"

  - name: "不能返回明文password字段"
    type: "forbidden_field"
    path: "$.data[*].password"
```

两种风格可混用，也可在同一文件里共存。谓词字段含义见下表。

### 2. 作为 Python 库调用

支持三种规则来源：**YAML 文件**、**JSON 文件**、**字典内联**。

```python
from testoracle import OracleEngine

data = {"total": 20, "data": [{"id": 1, "status": 0}, {"id": 2, "status": 1}]}

# 方式一：从 YAML 文件加载规则
engine = OracleEngine.from_yaml("demo_rules.yaml")

# 方式二：从 JSON 文件加载规则
engine = OracleEngine.from_json("demo_rules.json")

# 方式三：直接传入规则字典（无需文件）
engine = OracleEngine.from_rules({
    "rules": [
        {"name": "total不能为负", "path": "$.total", "min": 0},
        {"name": "id不能重复", "path": "$.data[*].id", "unique": True},
        {"name": "状态合法", "path": "$.data[*].status", "allowed_values": [0, 1, 2]},
    ]
})

# 校验数据
result = engine.validate(data)
print(result.is_passed)
print(result.summary())
for item in result.rule_results:
    print(item)
```

JSON 规则文件示例（`demo_rules.json`）：

```json
{
  "rules": [
    {"name": "total不能为负", "path": "$.total", "min": 0},
    {"name": "id不能重复", "path": "$.data[*].id", "unique": true},
    {"name": "状态合法", "path": "$.data[*].status", "allowed_values": [0, 1, 2]}
  ]
}
```

### 3. 在 pytest 中使用

```python
from testoracle.pytest_plugin import assert_oracle

def test_user_list():
    resp = requests.get("/api/user/list")

    # 方式一：YAML 规则文件
    assert_oracle(resp.json(), rules_file="demo_rules.yaml")

    # 方式二：JSON 规则文件
    assert_oracle(resp.json(), rules_json="demo_rules.json")

    # 方式三：内联规则字典
    assert_oracle(resp.json(), rules={
        "rules": [
            {"name": "total非负", "path": "$.total", "min": 0},
            {"name": "id唯一", "path": "$.data[*].id", "unique": True},
        ]
    })

    # 方式四：直接传入 engine 实例
    engine = OracleEngine.from_yaml("demo_rules.yaml")
    assert_oracle(resp.json(), engine=engine)
```

校验失败时会展示：规则名称、jsonpath 路径、实际值、失败原因。
若安装了 `allure-pytest`，每条规则结果会自动嵌入 allure 步骤。

### 4. CLI 命令行

```bash
# 对一份 json 样本执行规则校验
testoracle run --data sample.json --rules demo_rules.yaml --output result.json
```

校验存在失败时退出码为 `1`，可在 CI 中阻断流水线。

## 支持的规则

两种声明风格：**`type` 式**（显式指定类型）与**谓词推断式**（不写 `type`，按字段推断，可组合）。

### 规则总览

| 约束 | `type` 值 | 谓词字段 | 说明 |
| --- | --- | --- | --- |
| 数值范围 | `range` | `min`, `max` | 数值落在 `[min, max]` |
| 值等于/浮点近似 | `range` | `equals`, `epsilon` | 精确等于或浮点近似相等 |
| 非空 | `not_null` | `not_null: true` | 非 None / 空串 / 空集合 |
| 唯一性 | `unique` | `unique: true` | 数组元素两两不重复 |
| 禁止字段 | `forbidden_field` | `forbidden: true` | 路径匹配到任意值即失败 |
| 枚举 | `enum` | `allowed_values` | 值在允许集合内 |
| 长度 | `length` | `min_length`/`max_length`/`length_equals` | 字符串/集合长度校验 |
| 类型 | `type` | `type: "number"` 等 | 值的 Python 类型校验 |
| 正则 | `regex` | `regex: "phone"` 等 | 值匹配正则表达式 |
| 条件 | `conditional` | `if` + `then` | 当 A 满足时，B 必须满足 |
| 字段存在 | `field_exist` | `must_exist` | 字段 key 必须存在于 dict 中 |
| 字段不存在 | `field_not_exist` | `must_not_exist` | 字段 key 一定不能存在 |
| 时间 | `time` | `time_format`/`future`/`past`/`before`/`after` | 日期/时间校验 |
| 跨字段对比 | `compare` | `left_path` + `right_path`/`right_expr` | 跨字段值对比或表达式计算 |
| 单调递增/递减 | `monotonic` | `monotonic`/`monotonic_step` | 列表值单调校验，可选步长 |
| 子 schema | `item_schema` | `item_rules` | 数组内对象嵌套子规则校验 |
| 或表达式 | `any_of` | `any_of` | 多个子规则满足其一即可 |
| 分组统计 | `count_by_group` | `min_count`/`max_count`/`group_by` | 分组计数约束 |
| 非法字符检测 | `text_sanitize` | `sanitize_categories`/`sanitize_custom_patterns` | XSS/SQL 注入等危险模式检测 |
| 嵌套深度限制 | `depth_limit` | `max_depth` | 对象嵌套层级不能过深 |
| 内容包含 | `contains` | `contains` | 值必须包含指定内容 |
| 内容排除 | `not_contains` | `not_contains` | 值不能包含指定内容 |
| 至少包含其一 | — | `contains_any` | 必须至少包含多项中的一项 |
| 必须全部包含 | — | `contains_all` | 必须同时包含所有指定内容 |
| 字段互斥 | `mutual_exclusive` | `mutual_exclusive` | 多个字段不能同时有值 |
| 聚合统计 | `aggregate` | `aggregate` | 求和/平均值/计数等聚合校验 |
| 引用完整性 | `referential_integrity` | `source_path` + `target_path` | 跨数组引用值必须存在 |
| 复合 | —（自动） | 多个谓词并存 | 多约束 AND，全部通过才算通过 |

### 条件规则支持的操作符

| 操作符 | 说明 |
| --- | --- |
| `equals` | 等于 |
| `not_equals` | 不等于 |
| `in` | 值在列表中 |
| `not_in` | 值不在列表中 |
| `contains` | 字符串/列表包含 |
| `not_contains` | 不包含 |
| `starts_with` | 字符串前缀匹配 |
| `ends_with` | 字符串后缀匹配 |
| `matches` | 正则匹配 |
| `exists` | 路径存在值 |
| `not_exists` | 路径不存在值 |
| `greater_than` | 大于 |
| `less_than` | 小于 |
| `greater_or_equal` | 大于等于 |
| `less_or_equal` | 小于等于 |
| `is_type` | Python 类型判断 |

### 类型规则支持的类型

`int`、`float`、`number`（int 或 float，排除 bool）、`str`/`string`、`bool`/`boolean`、`list`/`array`、`dict`/`object`、`null`/`none`、`any`。

### 正则规则内置模式

| 内置规则名 | 说明 |
| --- | --- |
| `phone` | 中国大陆手机号 |
| `email` | 邮箱地址 |
| `id_card` | 18 位中国大陆身份证号 |
| `url` | HTTP/HTTPS URL |
| `ipv4` | IPv4 地址 |
| `chinese` | 纯中文字符 |
| `postal_code` | 中国 6 位邮政编码 |
| `license_plate` | 中国车牌号 |

也支持自定义正则：`pattern: "^[a-zA-Z0-9]+$"`。

### 时间运算表达式

支持的时间关键字：`today`、`now`、`today_start`、`today_end`。
支持运算表达式：`today - 30`（30天前）、`today + 7d`、`now - 1h`、`now + 30m`、`today - 2w`、`today + 1y`。

单位：`d`（天）、`h`（小时）、`m`（分钟）、`w`（周）、`y`（年）。

### 聚合类型

`sum`（求和）、`avg`（平均）、`count`（计数）、`min`（最小）、`max`（最大）。

### 说明

- 谓词推断式中 `min`/`max` 一律按**数值范围**处理；若需**长度**校验请用 `min_length`/`max_length`/`length_equals` 或 `type: length`。
- `equals` 谓词表示"值等于"，支持 `epsilon` 浮点近似相等。
- 一条谓词式规则只含单个谓词时退化为普通规则；含多个谓词时自动包装为 `composite`。
- 谓词推断式中的 `type` 字段值如果是 Python 类型名（如 `number`、`list`），会被当作类型谓词处理；如果是规则类型名（如 `range`、`type`），会被当作显式 type 式处理。
- 谓词推断式中的 `regex` 字段值如果是内置规则名（如 `phone`、`email`），会使用内置正则；否则会被当作自定义正则表达式。
- 所有规则通过 jsonpath 表达式（基于 `jsonpath-ng`）定位数据。

## 编写示例

### 数值范围与浮点近似相等

```yaml
rules:
  - name: "分页total不能为负数"
    path: "$.total"
    min: 0

  # 浮点数近似相等
  - name: "计算结果应近似等于3.14"
    path: "$.result"
    equals: 3.14
    epsilon: 0.01

  # 精确等于
  - name: "状态码必须为200"
    path: "$.code"
    equals: 200
```

### 条件规则（if-then）

```yaml
rules:
  - name: "已退款订单必须有退款金额"
    if:
      path: "$.status"
      operator: "equals"
      value: "refunded"
    then:
      path: "$.refund_amount"
      min: 0.01
      not_null: true
```

### 字段存在性

```yaml
rules:
  - name: "成功响应必须包含token字段"
    path: "$"
    must_exist: "access_token"

  - name: "普通用户不能返回内部secret字段"
    path: "$"
    must_not_exist: "inner_secret"
```

### 时间校验

```yaml
rules:
  - name: "创建时间不能早于30天前"
    type: "time"
    path: "$.created_at"
    format: "%Y-%m-%d"
    after: "today - 30"

  - name: "过期时间必须在未来"
    path: "$.expire_at"
    time_format: "%Y-%m-%d %H:%M:%S"
    future: true

  - name: "生日不能晚于今天"
    path: "$.birthday"
    time_format: "%Y-%m-%d"
    max: "today"
    past: true
```

### 跨字段对比与计算

```yaml
rules:
  - name: "退款金额不能超过订单金额"
    type: "compare"
    left_path: "$.refund_amount"
    operator: "less_or_equal"
    right_path: "$.order_amount"

  # 表达式计算对比
  - name: "总价必须等于单价乘以数量"
    type: "compare"
    left_path: "$.total"
    operator: "equals"
    right_expr: "$.price * $.quantity"

  # 谓词推断式
  - name: "折扣价必须低于原价"
    left_path: "$.sale_price"
    compare_operator: "less_than"
    right_path: "$.original_price"
```

### 单调递增/递减

```yaml
rules:
  - name: "列表id必须单调递增"
    type: "monotonic"
    path: "$.data[*].id"
    direction: "asc"

  - name: "创建时间倒序返回"
    type: "monotonic"
    path: "$.data[*].create_ts"
    direction: "desc"
    strict: true

  # 带步长校验
  - name: "ID必须连续递增（步长为1）"
    type: "monotonic"
    path: "$.data[*].id"
    direction: "asc"
    step: 1

  # 谓词推断式
  - name: "序号必须递增"
    path: "$.data[*].seq"
    monotonic: "asc"
```

### 数组内对象子 schema 校验

```yaml
rules:
  - name: "每一条订单子项都要满足基础约束"
    type: "item_schema"
    path: "$.order_items[*]"
    item_rules:
      - min: 0
        path: "$.amount"
      - not_null: true
        path: "$.goods_id"
```

### 或表达式（any_of）

```yaml
rules:
  - name: "状态可以是0、1或2"
    type: "any_of"
    path: "$.data[*].status"
    any_of:
      - path: "$.status"
        equals: 0
      - path: "$.status"
        equals: 1
      - path: "$.status"
        equals: 2
```

### 分组统计

```yaml
rules:
  # 按 status 分组，已完成的记录至少 2 条
  - name: "已完成订单至少2条"
    type: "count_by_group"
    path: "$.data[*]"
    filter: {"status": 2}
    min_count: 2

  # 使用 group_by 自动推断父数组
  - name: "已完成订单至少2条"
    group_by: "$.data[*].status"
    filter: {"status": 2}
    min_count: 2

  # 最大数量限制
  - name: "失败订单最多1条"
    path: "$.data[*]"
    filter: {"status": "failed"}
    max_count: 1
```

### 非法字符检测

```yaml
rules:
  - name: "用户输入不能包含危险字符"
    type: "text_sanitize"
    path: "$.content"
    categories: ["xss", "sql_injection"]

  # 带自定义模式
  - name: "订单描述不能有特殊标记"
    type: "text_sanitize"
    path: "$.description"
    categories: ["all"]
    custom_patterns:
      - name: "自定义危险词"
        pattern: "SELECT\\s+.*FROM\\s+password"

  # 谓词推断式
  - name: "昵称不能包含脚本标签"
    path: "$.nickname"
    sanitize_categories: ["xss"]
```

### 嵌套深度限制

```yaml
rules:
  - name: "返回对象嵌套不能超过5层"
    type: "depth_limit"
    path: "$.data"
    max_depth: 5

  # 谓词推断式
  - name: "data 嵌套不能超过3层"
    path: "$.data"
    max_depth: 3
```

### 内容包含与排除

```yaml
rules:
  - name: "商品名称必须包含'无线'"
    type: "contains"
    path: "$.data[*].name"
    value: "无线"

  - name: "描述不能包含'测试'"
    type: "not_contains"
    path: "$.data[*].description"
    value: "测试"

  # 谓词推断式
  - name: "标签必须包含'热卖'"
    path: "$.data[*].tags"
    contains: "热卖"

  # 至少包含一个
  - name: "商品属性必须包含'新品'或'促销'"
    path: "$.data[*].tags"
    contains_any: ["新品", "促销"]

  # 必须全部包含
  - name: "地址必须同时包含'省'和'市'"
    path: "$.data[*].address"
    contains_all: ["省", "市"]
```

### 字段互斥

```yaml
rules:
  - name: "支付方式和优惠券不能同时有值"
    type: "mutual_exclusive"
    path: "$"
    fields: ["$.pay_type", "$.coupon_type"]

  # 谓词推断式
  - name: "三种配送方式只能选一种"
    path: "$"
    mutual_exclusive: ["$.express_delivery", "$.pickup", "$.store_delivery"]
```

### 聚合统计

```yaml
rules:
  # 求和等于另一字段
  - name: "明细金额总和等于总金额"
    type: "aggregate"
    path: "$.items[*].amount"
    agg: "sum"
    compare_path: "$.total_amount"
    operator: "equals"

  # 求和与常量比较
  - name: "订单明细总和不能超过10000"
    path: "$.items[*].amount"
    agg: "sum"
    max: 10000

  # 平均值范围
  - name: "评分平均分在3-5之间"
    path: "$.scores[*].value"
    agg: "avg"
    min: 3
    max: 5

  # 计数校验
  - name: "子项数量必须至少2条"
    path: "$.items[*]"
    agg: "count"
    min: 2

  # 谓词推断式
  - name: "明细总和等于total"
    path: "$.items[*].amount"
    aggregate: "sum"
    compare_path: "$.total"
```

### 跨数组引用完整性

校验一个数组中的引用值是否全部存在于另一个数组中，类似数据库的外键约束。

| 字段 | 说明 |
| --- | --- |
| `source_path` | 引用方路径，提取需要校验的引用值（如商品中的 `category_id`） |
| `target_path` | 被引用方路径，提取合法值集合（如分类列表中的 `id`） |

校验逻辑：`source_path` 提取到的每一个值，都必须在 `target_path` 提取到的值集合中存在，否则失败。

```yaml
rules:
  # 商品引用的分类ID，必须全部存在于分类列表的ID中
  - name: "商品分类ID必须合法"
    type: "referential_integrity"
    source_path: "$.items[*].category_id"    # 引用方：每个商品的 category_id
    target_path: "$.categories[*].id"        # 被引用方：分类列表中所有合法的 id

  # 谓词推断式
  - name: "下单用户必须存在"
    source_path: "$.orders[*].user_id"       # 引用方：每个订单的 user_id
    target_path: "$.users[*].id"             # 被引用方：用户列表中所有合法的 id
```

上述示例中，如果 `$.items[*].category_id` 提取到 `[1, 2, 99]`，而 `$.categories[*].id` 提取到 `[1, 2, 3]`，则 `99` 未在目标集合中，校验失败。

## 失败结果结构

```json
{
  "is_passed": false,
  "summary": "总计 6 条规则，通过 2，失败 4",
  "passed_count": 2,
  "failed_count": 4,
  "rule_results": [
    {
      "rule_name": "分页total不能为负数",
      "rule_type": "range",
      "passed": false,
      "path": "$.total",
      "actual": -5,
      "message": "值 -5 小于最小值 0"
    }
  ]
}
```

## CI 集成示例（GitLab CI）

```yaml
test:oracle:
  script:
    - testoracle run --data sample.json --rules demo_rules.yaml --output result.json
  artifacts:
    when: always
    paths:
      - result.json
```

非 0 退出码会阻断流水线；`result.json` 作为产物保留供排查。

## 自定义规则（扩展点）

```python
from testoracle.rules import register_rule
from testoracle.rules.base import BaseRule, RuleResult

class MyRule(BaseRule):
    rule_type = "my_rule"

    def validate(self, data):
        vals = self.extract_values(data)
        # ... 自定义逻辑
        return self._pass(vals)

register_rule("my_rule", MyRule)
```

注册后即可在 yaml 中使用 `type: "my_rule"`。

## 项目结构

```
testoracle/
├── testoracle/
│   ├── core/                             # 规则解析、执行引擎、数据提取、报告
│   │   ├── rule_engine.py                # OracleEngine + ValidationReport
│   │   ├── rule_parser.py                # YAML/JSON 规则解析器
│   │   ├── extractor.py                  # JSONPath 数据提取
│   │   └── exceptions.py                 # 自定义异常
│   ├── rules/                            # 内置规则实现与注册表
│   │   ├── __init__.py                   # 规则注册表 + 谓词推断
│   │   ├── base.py                       # BaseRule 抽象基类
│   │   ├── _comparators.py               # 共享比较操作符工具
│   │   ├── _subrule_mixin.py             # 子规则编译混合类
│   │   ├── range_rule.py                 # 数值范围 + equals + epsilon
│   │   ├── not_null_rule.py              # 非空校验
│   │   ├── unique_rule.py                # 唯一性校验
│   │   ├── enum_rule.py                  # 枚举校验
│   │   ├── length_rule.py                # 长度校验
│   │   ├── type_rule.py                  # 类型校验
│   │   ├── regex_rule.py                 # 正则校验（含内置模式）
│   │   ├── composite_rule.py             # 复合规则（AND）
│   │   ├── conditional_rule.py           # 条件规则（if-then）
│   │   ├── field_exist_rule.py           # 字段存在性（含 forbidden 模式）
│   │   ├── time_rule.py                  # 时间校验（含日期运算）
│   │   ├── compare_rule.py               # 跨字段对比与表达式计算
│   │   ├── monotonic_rule.py             # 单调递增/递减
│   │   ├── item_schema_rule.py           # 数组子 schema 校验
│   │   ├── any_of_rule.py                # 或表达式（OR）
│   │   ├── count_by_group_rule.py        # 分组统计
│   │   ├── text_sanitize_rule.py         # 非法字符检测
│   │   ├── depth_limit_rule.py           # 嵌套深度限制
│   │   ├── contains_rule.py              # 内容包含与排除
│   │   ├── mutual_exclusive_rule.py      # 字段互斥
│   │   ├── aggregate_rule.py             # 聚合统计
│   │   └── referential_integrity_rule.py # 跨数组引用完整性
│   ├── pytest_plugin.py                  # pytest 插件入口
│   └── cli.py                            # 命令行入口
├── examples/                             # 演示规则、库示例、pytest 示例、样本数据
├── tests/                                # 项目自身单元测试（400+ 用例）
└── pyproject.toml
```

## 设计原则

- 不写死预期值，只声明业务不变量
- 只校验数据，不发起请求、不抓流量、不做用例管理平台
- 规则解析 → 数据提取 → 规则校验 → 结果报告，职责单一
- 两种声明风格可混用：显式 `type` 式与谓词推断式

## License

MIT
