---
title: "CI 中的 evlog map"
description: "使用 --min-score 根据可观测性分数阻止拉取请求。JSON 契约、退出代码、GitHub Actions 和 jq 配方。"
canonical_url: "https://evlog.zhcndoc.com/cli/ci"
---
# CI 中的 evlog map

> 使用 --min-score 根据可观测性分数阻止拉取请求。JSON 契约、退出代码、GitHub Actions 和 jq 配方。

一次看的分数只是一个愉快的午后。CI 中的分数，才是真正阻止下一个处理器在黑暗中上线的东西。

## 在回归时使构建失败

`--min-score <n>` 在项目低于阈值时会打印明确的判定并以 1 退出：

```bash [Terminal]
evlog map --min-score 80
```

```text [低于阈值]
GATE  score 76 is below --min-score 90 — exit code 1
fix what is listed under FIX FIRST to pass · evlog.dev/cli/ci
```

```text [达到或高于阈值]
GATE  score 76 meets --min-score 70 — exit code 0
```

无论哪种情况都会打印完整报告，因此失败的任务会告诉你需要修复什么，而无需再次运行。

阈值必须是 0 到 100 之间的整数。任何其他内容（拼写错误、误加的单位、未展开的 shell 变量）都会使命令停止，而不是被当作“不设置门禁”，因为一个悄悄自我禁用的门禁，会让无人检查的标准报告成功。

这就把分数变成了拉取请求可以推动的东西。一次运行会说应用低于门槛，并指出负责的三个入口点；下一次则会说它已经超过：

<map-pr-gate>



</map-pr-gate>

<callout color="neutral" icon="i-lucide-shield-check">

[机会项](https://evlog.zhcndoc.com/cli/rules#opportunities) 永远不会影响分数，所以门槛绝不可能因为 CLI 建议你采用某个功能而失败。只有要求项才会使构建失败。

</callout>

## 棘轮：`--baseline`

`--min-score` 问的是“这个应用够好吗”。`--baseline` 问的是另一个问题：**这次拉取请求有没有让它变得更糟**。

```bash [Terminal]
evlog map --baseline
```

它会按每个入口点、每个检查项，将最新扫描结果与您提交的 `evlog.map.json` 进行比较：

```text [一次回归]
BASELINE  score 56 → 44 (-12) vs evlog.map.json

REGRESSED
✗ POST /api/checkout — useLogger 不再通过
   server/api/checkout.post.ts · evlog.dev/learn/wide-events
✗ POST /api/checkout — log.audit 不再通过
   server/api/checkout.post.ts · evlog.dev/use-cases/audit/overview

NEW AND DARK
⚠ GET /api/reports — 新增但没有任何埋点

2 个回归和 12 分的下降 — 退出码 1 · evlog.dev/cli/ci
evlog.map.json 未被重写 — 修复回归，或者在不使用 --baseline 的情况下重新运行以接受它
```

单位是需求本身，而不是总分。一次重构如果给一个路由加了埋点，却把另一个路由弄坏了，分数可能完全不变；而只盯着数字的门禁会把它当成没有变化。

<callout color="neutral" icon="i-lucide-lock">

**不会获取任何内容。** 基线是您的代码仓库已经包含的文件，因此 CI 在 `checkout` 后立即就能在磁盘上获得它：不需要网络、令牌或仓库访问权限。私有仓库和公开仓库的门禁完全相同。

</callout>

### 什么算回归

<table>
<thead>
  <tr>
    <th>
      变更
    </th>
    
    <th>
      结论
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      某项需求从 <code>
        pass
      </code>
      
       变成 <code>
        fail
      </code>
    </td>
    
    <td>
      <strong>
        门禁失败
      </strong>
    </td>
  </tr>
  
  <tr>
    <td>
      一个通过的需求被<a href="https://evlog.zhcndoc.com/cli/rules#disabling-a-check">
        注释禁用
      </a>
    </td>
    
    <td>
      <strong>
        门禁失败
      </strong>
    </td>
  </tr>
  
  <tr>
    <td>
      全局分数下降了
    </td>
    
    <td>
      <strong>
        门禁失败
      </strong>
    </td>
  </tr>
  
  <tr>
    <td>
      新增了一个没有埋点的入口点
    </td>
    
    <td>
      列在 <code>
        NEW AND DARK
      </code>
      
       下，不会失败
    </td>
  </tr>
  
  <tr>
    <td>
      删除了一个入口点
    </td>
    
    <td>
      记为移除
    </td>
  </tr>
  
  <tr>
    <td>
      某项需求从 <code>
        fail
      </code>
      
       变成 <code>
        pass
      </code>
    </td>
    
    <td>
      记为修复
    </td>
  </tr>
</tbody>
</table>

故意让禁用检查和破坏检查付出同样代价：禁用注释是用最小成本让门禁变绿、却不写任何埋点的最便宜方式；如果棘轮放它过去，测到的就会是注释而不是代码。

新增的黑暗路由会被报告，而不是触发门禁。对于尚未变绿的应用，如果每个新增端点都会导致拉取请求失败，团队最终学会的就是关闭这个作业。新工作的标准应由 `--min-score` 设定。如果你想同时实现这两点，就同时运行它们。

### map 文件就是棘轮

`--baseline` 的意思是：您要**提交** `evlog.map.json`，而不是忽略它：

```bash [Terminal]
evlog map            # 更新文件
git add evlog.map.json
```

一次报告回归的运行会刻意保持文件不变。如果那时把它重写，就相当于把棘轮往更差的状态往下拨；而同一个命令再运行一次就会报告没有回归并以 0 退出。因此，接受一次下降必须是显式的：在不使用 `--baseline` 的情况下重新运行，并在提交信息中写明原因，把新 map 一起提交。

```yaml [.github/workflows/observability.yml]
- uses: actions/checkout@v5
      - run: pnpm install --frozen-lockfile
      - run: pnpm evlog map --baseline
```

这个文件每次运行还会因为 `generatedAt` 而产生变动。代价就是要跟踪它，但回报是能拿到一份可审查的 diff，准确显示哪些入口点改变了类别。

### 也可以改为对比某个分支

传入 `git:<ref>`，通过 git 而不是工作树读取已提交的副本，这适合修改 map 文件本身的拉取请求：

```bash [Terminal]
evlog map --baseline git:origin/main
evlog map --baseline ../base-map.json   # 或任意路径
```

如果只传了裸的 `--baseline`，而磁盘上又没有 map，CLI 会自动回退到 `git:HEAD`，因此答案仍然是“上一个提交怎么说”，而不是“我一分钟前怎么说”。

通过 git 读取只适用于已经提交的文件：被 gitignore 的 map 在任何 ref 中都不存在，CLI 的错误信息会准确说明这一点，并指出修复方法。

### 当规则集发生变化时

`evlog.map.json` 会记录写入它的 CLI 版本，以及一个独立的**规则集版本**；只有规则语义发生变化时，规则集版本才会变化。在 `--baseline` 模式下，CLI 会将已提交的规则集版本与自身版本进行比较。如果二者不同，它会拒绝执行 diff 并以 `2` 退出：如果两个版本之间收紧了一条规则，那么拉取请求未触及的代码也会显示为从 `pass` 变成 `fail`；而一个归咎错误对象的门禁，还不如一个承认自己无法运行的门禁。

```text
baseline was written by @evlog/cli 0.3.0, running @evlog/cli 0.4.1 (rule set 1 → 2)
regenerate the baseline: evlog map && git add evlog.map.json
```

发布版本如果加入了功能但没有修改规则，就会保留规则集版本，因此升级 CLI 不会强迫所有人重新生成 map。版本报告功能加入之前写入的 map 没有版本字段；CLI 会将其视为未知并警告一次，而不是在升级时让每个项目都失败。

## 每个退出代码的含义

<table>
<thead>
  <tr>
    <th>
      代码
    </th>
    
    <th>
      含义
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        0
      </code>
    </td>
    
    <td>
      分数达到阈值、相对于基线没有回归，或两者都未请求
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        1
      </code>
    </td>
    
    <td>
      分数低于 <code>
        --min-score
      </code>
      
      、相对于 <code>
        --baseline
      </code>
      
       存在回归，或扫描无法运行
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        2
      </code>
    </td>
    
    <td>
      用法错误——未知标志、无效的 <code>
        --framework
      </code>
      
      ，或基线的规则集与运行中的 CLI 不匹配
    </td>
  </tr>
</tbody>
</table>

<warning>

管道会将 `$?` 替换为其中最后一个命令的退出代码，因此 `evlog map --min-score 90 \| tee map.log` 总是看起来是成功的。请在该步骤中添加 `set -o pipefail`。

</warning>

## 在 GitHub Actions 中运行

[evlog action](https://github.com/evloghq/action) 会运行 `evlog map`，将拉取请求与其基线比较，在差异中标注每条发现，将报告写入作业摘要，并持续更新拉取请求中的一条评论：

```yaml [.github/workflows/observability.yml]
name: Observability

on: pull_request

permissions:
  contents: read
  pull-requests: write

jobs:
  map:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: evloghq/action@v1
        with:
          min-score: 80
```

无需提交任何文件，也无需创建 token。在拉取请求中，action 会将基线分支检出到工作区旁并扫描它，因此原本通过、现在不再通过的检查会使作业失败，并在导致失败的代码行上显示错误。`pull-requests: write` 仅用于评论；没有它时，action 会在通知中说明，但注释和摘要仍然有效，来自 fork 的拉取请求就是这种情况。

每个 action 发布版本都会固定与其测试过的 `@evlog/cli`，因此 `@v1` 具有确定性：CLI 发布不会改变你门控下的结论，而升级 action 的拉取请求正是分数变化需要被审查的地方。输入、输出（`score`、`delta`、`regressions`、`passed`、`results`）和示例见 [action 的 README](https://github.com/evloghq/action#readme)。

### Without the action

对于不使用第三方 action 的工作流，CLI 可以在 `run:` 步骤中完成相同的操作：

```yaml [.github/workflows/observability.yml]
- uses: actions/setup-node@v5
        with:
          node-version: 22
      - run: npx evlog map --format github --min-score 80 --no-write
```

`--format github` 会将[工作流命令](https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions)写入 stdout，因此 runner 会将每个发现放在拉取请求差异中的独立行上，而不是放在作业日志里。面向人的报告仍会写入 stderr，退出代码也相同，因此 `--min-score` 和 `--baseline` 仍会让作业失败。`--no-write` 会阻止作业生成一个没人会去看的 `evlog.map.json`。

使用 `--baseline` 时，发现结果会作为错误表示回退：只包含此拉取请求破坏的检查，不包含 map 中已有的内容。`git:<ref>` 会读取该 ref 中已提交的 `evlog.map.json`，这是 action 为你处理的一件事：

```yaml [.github/workflows/observability.yml]
- run: npx evlog map --format github --baseline git:origin/main --no-write
```

```text
::error file=src/checkout.ts,line=12,title=evlog map%3A audit::has logger + context but no log.audit() — sensitive route needs audit trail
::error title=evlog map::score 74/100 (good): 18 instrumented, 2 partial, 9 dark; regressed against git:origin/main
```

没有基线时，发现结果会作为警告，构成报告的 FIX FIRST 列表，并按入口点从最差到最好排列。列表会在 `--limit` 处停止（默认为 10，即 GitHub 每个步骤保留其余注释前的数量），结尾通知会说明省略了多少项：

```text
::warning file=src/payments.ts,line=3,title=evlog map%3A audit::has logger + context but no log.audit() — sensitive route needs audit trail
::warning file=src/health.ts,line=5,title=evlog map%3A wide-event::handler adds nothing to its request event — only method, path and status are recorded
::notice title=evlog map::score 76/100 (good): 19 instrumented, 2 partial, 8 dark; 13 more findings not shown
```

只有当注释所在的行属于差异时，它才会以内联形式绘制；完整列表位于 Checks 标签页中作业的 Annotations 下。

### 锁定版本

这个 CLI 还很年轻：规则仍在不断细化，也会不断加入新规则，所以一次发布就可能改变某段没人动过的代码的判定。在受保护的作业里，这会表现为拉取请求因为作者在差异中看不到的原因而失败。

action 会为你固定 CLI。若没有安装 `@evlog/cli` 而自行运行 CLI，`evlog` 可执行文件会在每次运行时获取它，因此 CI 任务会跟随最新版本。请安装它，并让 lockfile 将版本固定住：

```bash [Terminal]
pnpm add -D @evlog/cli
```

```yaml [.github/workflows/observability.yml]
- run: pnpm install --frozen-lockfile
      - run: pnpm evlog map --min-score 80 --no-write
```

这样，分数变化总是由你造成的，而升级 CLI 则是一个独立的拉取请求；在其中，分数变化是目的，而不是意外。

### 逐步提高，不要一步到顶

如果把阈值设为 90，而应用当前得分只有 41，那么每个拉取请求都会失败，这会让团队学会忽略这个作业。先把它设为当前分数，然后随着你修复问题再逐步提高：

```bash [Terminal]
# 我们现在是多少？
evlog map --json --no-write | jq '.map.score'
```

每一个提升分数的拉取请求，都会把底线一起抬高。报告已经告诉你下一步值多少钱：`▲ 76 → 86，修复上面列出的 3 项即可`。

## 使用 JSON 输出

`--json` 会将整个 map 写入 stdout。报告会输出到 stderr，因此二者永远不会混在一起。

```bash [Terminal]
evlog map --json --no-write > map.json
```

```json [结构]
{
  "schemaVersion": 2,
  "environment": "production",
  "map": {
    "version": 1,
    "generatedAt": "2026-07-25T18:42:10.114Z",
    "framework": "nuxt",
    "projectName": "evlog-playground",
    "score": 76,
    "routes": []
  },
  "summary": { "instrumented": 19, "partial": 2, "dark": 8, "exempt": 0, "suppressedChecks": 0 },
  "mapPath": "/path/to/app/evlog.map.json"
}
```

在 `--no-write` 下，`mapPath` 为 `null`。`map.routes` 中的每一项都长这样：

<code-collapse>

```json [一个入口点]
{
  "framework": "nuxt",
  "kind": "api",
  "method": "POST",
  "path": "/api/auth/login",
  "file": "server/api/auth/login.post.ts",
  "handler": { "line": 1, "column": 0 },
  "id": "337325358269",
  "checks": {
    "wide-event": { "status": "pass" },
    "context": { "status": "pass" },
    "structured-errors": { "status": "n/a" },
    "error-handling": { "status": "n/a" },
    "audit": {
      "status": "fail",
      "message": "有 logger + context 但没有 log.audit() — 敏感路由需要审计轨迹",
      "evidence": { "file": "server/api/auth/login.post.ts", "line": 1 }
    }
  },
  "suggestions": {},
  "sensitivity": { "level": "high", "reasons": ["auth: 路径中包含 \"auth\""] },
  "score": 75
}
```

</code-collapse>

`checks` 包含要求项，`suggestions` 包含机会项。它们使用独立的键，正是为了让消费者或 CI 脚本永远不会将建议误认为失败。规则 id 是稳定的：注册表和已发布的 id 联合会在构建时相互校验，因此发布版本不会悄悄改变你收到的内容。

某人 [通过注释禁用](https://evlog.zhcndoc.com/cli/rules#disabling-a-check) 的检查会显示为 `n/a`，并带有 `"suppressed": true`，其 `evidence` 指向注释而不是处理器：

```json [一个被禁用的检查]
"wide-event": {
  "status": "n/a",
  "suppressed": true,
  "message": "在第 1 行禁用 — 存活探针，故意保持静默",
  "evidence": { "file": "server/api/health.get.ts", "line": 1 }
}
```

`summary.suppressedChecks` 是项目总数。它是需要和分数一起关注的数字：门禁只衡量规则被允许查看的内容。

### 配方

```bash [Terminal]
# 分数，用于徽章或评论
evlog map --json --no-write | jq '.map.score'

# 所有完全没有事件的入口点
evlog map --json --no-write \
  | jq -r '.map.routes[] | select(.checks["wide-event"].status == "fail") | .file'

# 每个失败项，格式为 file:line — message
evlog map --json --no-write \
  | jq -r '.map.routes[].checks | to_entries[] | select(.value.status == "fail")
           | "\(.value.evidence.file):\(.value.evidence.line) — \(.value.message)"'

# 当有任何暗点时让脚本失败
test "$(evlog map --json --no-write | jq '.summary.dark')" -eq 0

# 有多少分数来自被禁用的检查
evlog map --json --no-write | jq '.summary.suppressedChecks'
```

## map 文件位于何处

除非传入 `--no-write`，否则每次运行都会将 `evlog.map.json` 写入项目根目录。它包含与 `--json` 相同的数据。是否提交它取决于你的门禁方式：

- **不对 CI 设置门禁，或只使用 --min-score**：该文件是构建产物，因此应忽略它：```bash [.gitignore]
evlog.map.json
```

<br />

`--no-write` 会让作业完全不生成该文件。
- **使用 --baseline 设置门禁**：像 lockfile 一样跟踪它。使用 `evlog map` 重新生成，在 diff 中审查它，不要手动编辑。棘轮会与已提交的副本进行比较，而 `git:<ref>` 只能读取已经提交的文件。

已跟踪的 map 可读，而这是一项优点，不是代价：拉取请求的 diff 会准确显示哪些入口点改变了类别，这是一个值得讨论的内容。代价是每次运行都会产生变动，因为 `generatedAt` 每次都会变化。

## 门禁 monorepo 中的每个包

`evlog map` 一次扫描一个应用。action 每行接收一个目录或 glob，并将每个目录作为独立软件包扫描，在报告中拥有自己的行，路径则从仓库根目录解析：

```yaml [.github/workflows/observability.yml]
- uses: evloghq/action@v1
        with:
          packages: |
            apps/*
            packages/api
          min-score: 70
```

单独使用 CLI 时，matrix 也能完成同样的事情，并允许每个应用携带自己的阈值，这通常正是你想要的：处理支付的应用应该比营销网站要求更高。

```yaml [.github/workflows/observability.yml]
jobs:
  map:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        include:
          - { app: apps/web, min: 80 }
          - { app: apps/admin, min: 60 }
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 22
      - run: npx evlog map --cwd ${{ matrix.app }} --min-score ${{ matrix.min }} --no-write
```

## 下一步

- [规则](https://evlog.zhcndoc.com/cli/rules)：失败的检查意味着什么，以及如何修复
- [评分](https://evlog.zhcndoc.com/cli/scoring)：门禁所依据的数字是如何计算的

---

- [评分](https://evlog.zhcndoc.com/cli/scoring)
- [规则](https://evlog.zhcndoc.com/cli/rules)


## Sitemap

See the full [sitemap](https://evlog.zhcndoc.com/sitemap.md) for all pages.
