✨ feat: 会话通知插件支持系统通知与应用内轻弹窗

This commit is contained in:
pyh
2026-09-30 15:43:36 +08:00
commit 2f38b7ea3c
16 changed files with 1487 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# 仓库内一律使用 LF:这是跨平台插件(Windows / macOS / Linux),
# 换行符不该随检出机器变化。
* text=auto eol=lf
+6
View File
@@ -0,0 +1,6 @@
# 忽略安装与构建产物;这个包没有依赖,也没有构建步骤。
node_modules/
pnpm-lock.yaml
*.tgz
.DS_Store
Thumbs.db
+11
View File
@@ -0,0 +1,11 @@
# <emoji> <type>[(scope)][!]: <中文描述,一句话,不加句号>
# 一条提交信息只有这一行,不写正文,也不写脚注。
# 破坏性变更只用 ! 标记,例如:♻️ refactor!: 改掉了某某行为
# 需要交代背景时,把说明写进 README.md、CHANGELOG.md、CONTRIBUTING.md 或代码注释。
#
# 类型与 emoji:feat ✨ / fix 🐛 / docs 📝 / refactor ♻️ / perf ⚡️ /
# test ✅ / build 📦️ / ci 💚 / chore 🔧 / revert ⏪️
# 作用域(可选):client 浏览器半 / host 宿主半
# 例:✨ feat(client): 后台走系统通知,前台走应用内轻弹窗
# 例:🐛 fix(client): 前台判定改为现读可见性
# 例:📝 docs: 安装说明补充 Git 标签与本地路径两种方式
+35
View File
@@ -0,0 +1,35 @@
# 变更记录
版本说明按倒序排列。提交信息遵循 [CONTRIBUTING.md](CONTRIBUTING.md) 的单行规范,因此每次改动"为什么这样改、影响面是什么"记在这里,而不是提交信息里。
## v1.0.5
- 修复:**应用在后台时不发系统通知**。前台/后台以前只靠 `window` 的 `focus`/`blur` 事件记住,桌面外壳里 `blur` 可能不到,插件就一直以为窗口在前台,于是走了轻弹窗分支(而用户没在看,表现就是完全没有通知)。现在每次投递都现读 `document.visibilityState` 与 `document.hasFocus()`,任一表示不可见即走系统通知;观察器订阅的事件只是让重算更及时,不再决定通道。
- 系统通知构造失败时降级:改用轻弹窗,并把被拒原因写进插件页,不再静默失败。
- 插件页新增诊断:窗口当前状态(前台/后台,即会走哪条通道)、观察器健康、最近一次投递的通道与时间;「测试系统通知」按钮直接走系统通道(前台也照发),用来单独验证系统通知是否可用。
- 修掉日志前缀重复(`session-notify: session-notify:`)。
## v1.0.4
- 配置块去掉重复:插件页顶部已经有 DSH 渲染的插件标题与描述,原配置块又写了一遍标题和几乎一样的说明。现在这一段直接用一句「提醒是怎么送出去的」开头,随后进入设置项。
- 移除未使用的字典键 `config.title` / `config.note`,保证字典里没有死键。
## v1.0.3
- 设置从「设置 → 通用」移到插件自己的介绍页:注册 `plugins.bundle.config`(key 取包名),与官方 `dsh-experimental-voice-input-bundle` 用的是同一个口子;通用设置里不再有本插件的行。
- 应用内轻弹窗改成标题 + 描述两行:标题是提醒类型(会话已完成 / 需要授权 / 需要回答),描述是会话题目与细节;关闭按钮从 18px 放大到 28px 的方形热区,「查看」改成有 hover 背景的文字按钮,整条浮窗 hover 时暂停自动消失。
- 三个开关改为存浏览器本地(`localStorage`),重开页面、重装插件都保留;仍然不写 DSH 的配置文件。
- 插件页配置分四段:说明、提醒内容(三个开关)、系统通知权限、运行状态。
## v1.0.2
- 补上 `ctx.locale.register('session-notify', { zh, en })`:没有注册时 `locale.bind(ns)` 会把 key 原样返回,界面上就显示成 `settings.title` 这样的原始键。
- 字典兜底与 locale 兜底同时保留:缺框架的 `t`、或 locale 服务没保存注册,都不会显示原始键。
- 快照语言字段改为真实字段 `snapshot.active`。
## v1.0.1
- 首个发布版本:会话完成 / 需要授权 / 需要回答三类提醒。
- 窗口不在前台走系统通知(Windows / macOS / Linux 同一条渲染进程通道,无平台分支);窗口在前台走应用内轻弹窗;正在看的那个会话完成时不打扰。
- 只对「运行中 → 空闲」的转变提醒,同一会话同一轮只报一次,同一待处理请求只报一次;子智能体会话与空白会话跳过;多个会话同时完成有 1.5 秒节流,完成提醒前有 400ms 确认窗口。
- 修复:无渲染观察器原先挂在 `sidebar.panellist`,而该 slot 的宿主会把每个条目的 id 当作一个左侧面板按钮,导致左侧多出一行空面板并挤坏侧边栏;现改挂通用浮层 `shell.overlay`。
+103
View File
@@ -0,0 +1,103 @@
# 提交信息规范
本仓库的提交信息遵循 [Conventional Commits](https://www.conventionalcommits.org/) 的**首行**结构,并在行首加一个 emoji 做视觉标记。**描述用中文**,类型关键字、作用域保持英文。
## 格式
**一条提交信息只有一行**,没有正文,也没有脚注:
```text
<emoji> <type>[(scope)][!]: <中文描述>
```
- `<emoji>`:下面表格里该类型对应的 emoji,后面跟**一个空格**。emoji 是必需的,且必须与本行类型一致——`📝 docs:` 对,`✨ docs:` 不对。
- `<type>`:小写英文关键字,见下表。
- `(scope)`:可选,改动范围。用 `()` 包住一个简短英文标识,例如 `fix(client)`、`fix(host)`、`docs(ci)`。
- `!`:可选,紧跟在 type 或 scope 之后、冒号之前,表示破坏性变更。
- `<描述>`:中文,一句话,讲清这次改动做了什么;不换行、不用列表、不加句号。宽度不超过 72(中文字符按 2 个宽度算)。
**不要写正文,也不要写脚注。** 连 `Refs:` / `Closes:` / `BREAKING CHANGE:` 这类尾注也不写——需要交代的"为什么""影响面""迁移注意事项",写进 [README.md](README.md)、[CHANGELOG.md](CHANGELOG.md) 或本文件,或写进代码注释,让说明跟着代码走,而不是埋在 `git log` 里。
**破坏性变更只靠 `!` 标记**(例如 `♻️ refactor!: …`)。这一条是对 Conventional Commits 的有意偏离:它要求破坏性变更必须有 `BREAKING CHANGE:` 脚注,本仓库不写脚注,所以判断破坏性变更以 `!` 为准。
如果确实需要多行说明,说明这次改动不该只用一个提交标题交代——先补文档(这个插件的版本级说明进 [CHANGELOG.md](CHANGELOG.md)),再提交。
## 类型与 emoji
| 类型 | emoji | 码位 | 用途 |
| --- | --- | --- | --- |
| `feat` | ✨ | `U+2728` | 新增功能 |
| `fix` | 🐛 | `U+1F41B` | 修复缺陷 |
| `docs` | 📝 | `U+1F4DD` | 只改文档 |
| `refactor` | ♻️ | `U+267B U+FE0F` | 重构,行为不变 |
| `perf` | ⚡️ | `U+26A1 U+FE0F` | 性能优化 |
| `test` | ✅ | `U+2705` | 变更测试 |
| `build` | 📦️ | `U+1F4E6 U+FE0F` | 构建系统或依赖 |
| `ci` | 💚 | `U+1F49A` | CI 配置 |
| `chore` | 🔧 | `U+1F527` | 杂项,以上都不属于时用 |
| `revert` | ⏪️ | `U+23EA U+FE0F` | 回滚某次提交,正文写 `Refs: <被回滚的提交>` |
**复制上表里的 emoji,不要手敲。** 带 `U+FE0F` 的那五个是「基础字符 + 变体选择符」两个码位,手敲时很容易只打出前一个字符;对工具来说 `♻`(`U+267B`)和 `♻️`(`U+267B U+FE0F`)是两个不同的字符串,比对会失败。写规范或写脚本时,比较 emoji 前先做一次 NFC 归一化,或把两边都按 `\uFE0F?` 处理。
## 作用域约定
这个插件只有一个包,作用域用来区分改动落在哪一半,不是必需的:
- `client`:浏览器半(`client.js`)—— 通知引擎、轻弹窗、插件页配置、观察器;
- `host`:宿主半(`index.js`、`cordis.patch.yml`)—— 目前是空壳,只有它变化时才需要重启 DSH;
- 其它临时作用域(如 `deps`、`naming`)按需起,不必登记。
## 示例
每条都是一整条提交信息,就这一行:
```text
✨ feat(client): 后台走系统通知,前台走应用内轻弹窗
```
```text
🐛 fix(client): 前台判定改为现读可见性,不再只信 blur 事件
```
带作用域与破坏性标记(破坏性变更**没有**脚注,只看 `!`):
```text
♻️ refactor(client)!: 配置从通用设置挪到插件页
```
```text
📝 docs: 安装说明补充 Git 标签与本地路径两种方式
```
## 工具链提示
emoji 在行首是本仓库的有意选择,视觉上整齐。代价是:以类型前缀开头的解析工具(`commitlint`、`semantic-release`、`release-please` 等)会读不到类型。如果以后要接这类工具,有两条路:
1. 把 emoji 挪到冒号之后(`feat: ✨ 描述`),类型回到行首,规范其余部分不变;
2. 保留行首 emoji,给工具写一个剥掉开头 emoji 与空格的预处理。可用的正则(`\p{Extended_Pictographic}` 已覆盖带与不带变体选择符两种写法):
```text
^(?:\p{Extended_Pictographic}\uFE0F?\s+)?(?<type>[a-z]+)(?:\((?<scope>[^)]*)\))?(?<breaking>!)?:\s(?<desc>.+)$
```
## 自查
`scripts/check-commit-log.mjs` 会按本规范检查现有提交,只报告、不拦提交。除了首行格式,它还会报出**任何带正文或脚注的提交**:
```bash
node scripts/check-commit-log.mjs # 最近 10 条
node scripts/check-commit-log.mjs all # 全部
DN_SPEC=path/to/CONTRIBUTING.md node scripts/check-commit-log.mjs # 换一份规范文件(可选)
```
它从本文件的类型表里读 emoji 对照表,所以改了表不用改脚本。
## 启用提交模板(可选)
`.gitmessage` 是提交信息模板。启用后每次 `git commit` 会带上前缀提示:
```bash
git config commit.template .gitmessage
```
模板整份都是 `#` 开头的注释,git 会自动丢弃,所以直接 `git commit -m` 也不受影响。只对当前仓库生效;换机器要重新执行一次。
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 pyh
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.
+91
View File
@@ -0,0 +1,91 @@
# 会话通知
DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗口不在前台时用**系统通知**,窗口在前台时用**应用内轻弹窗**。
Windows / macOS / Linux 三个平台走的是同一条通知通道(渲染进程的 Web Notification API),插件里没有平台分支。
## 提醒什么
| 触发 | 通知标题 |
| --- | --- |
| 会话从「运行中」变为「空闲」(一轮回答结束) | 会话已完成 |
| 待处理的工具授权请求(`approval`) | 需要授权 |
| 待处理的提问 / 计划确认(`question` / `plan-review`) | 需要回答 |
每条只提醒一次:同一会话的同一轮只报一次完成,同一个待处理请求只报一次;请求消失后再来才会再报。
## 走哪个通道
| 状态 | 行为 |
| --- | --- |
| DSH 窗口**不在前台** | **系统通知**(这是插件唯一能拿到的系统通知通道,三个平台一致)。点通知:窗口回到前台并跳到该会话 |
| DSH 窗口在前台,且**不是**你正在看的那个会话 | **应用内轻弹窗**:右上角悬浮,点「查看」跳过去,6 秒自动消失,鼠标悬停时不消失,Esc 关掉最上面一条 |
| DSH 窗口在前台,且就是你**正在看的**那个会话 | 不打扰(你在看,不需要弹) |
## 设置
**设置 → 通用 → 会话通知**:
- 显示系统通知权限状态;
- 未授权时点「允许通知」请求权限(浏览器要求必须由你手动点击才能弹权限框),授权后自动发一条测试通知;
- 已授权时点「测试通知」随时验证;
- 权限被系统层关闭时,按平台给出开启路径(Windows / macOS 的通知设置、Linux 桌面环境的通知设置);
- 三个开关分别控制**完成 / 授权 / 提问**三类提醒(默认全开;开关状态存在当前页面内存里,刷新回到默认——本插件不写配置文件)。
## 安装
在 DSH 的 **插件 → 添加插件** 里填本目录的绝对路径即可(`plugin_manager` 也会做同样的安装):
```text
D:\DeepSeek Harness Plugins\dsh-session-notify
```
安装后:
- **浏览器半**随页面加载,刷新页面即生效;
- **宿主半**为空壳(不改任何 DSH 状态),如需重启才生效,完全退出 DSH 再打开即可;
- 本插件没有第三方依赖,不需要 pnpm 下载,也不需要构建步骤。
卸载:插件页里移除 `@dsh-plugin/session-notify`(它同时是 profile 的一个 bundle)。
## 平台说明与验证范围
- **通道是跨平台统一的**:插件的提醒都通过页面(Electron 渲染进程)的 `Notification` 构造器发出,Windows 通知中心 / macOS 通知中心 / Linux 通知守护(libnotify、GNOME、KDE)都由系统把它转成原生通知;应用内轻弹窗是纯 DOM,与平台无关。
- **宿主半拿不到系统通知**:DSH Desktop 的宿主进程是纯 Node 进程(不是 Electron 主进程),没有 Electron 的 `Notification` 可用,所以插件的所有提醒都在浏览器半产生——这也正是"三个平台一套代码"的原因。
- **已验证**:Windows 上的安装、三个 slot 条目注册、以及 22 条投递规则(见下)的离线验证;设置行与轻弹窗的文案/交互按 DSH 自带的 toast 与 switch 组件对齐。
- **未验证**:macOS 与 Linux 上的实际弹窗效果本机无法测试。结构上它们与 Windows 共用同一条通道、同一份代码,只有"权限被系统关闭时显示的开启路径"是分平台的。
- 提醒只在 DSH 进程运行、页面打开时产生:**DSH 完全退出期间结束的会话不会再补发通知**。
## 行为细节(都已验证)
- **只有观测到「运行中 → 空闲」的转变才提醒**:打开应用时已经在跑的会话只建立基线,不补发;历史里早已结束的会话不会被翻出来提醒。
- **子智能体会话不单独提醒**(`origin === 'subagent'`),归属它的主会话结束时才提醒。
- **新会话(空白会话)完成不提醒**。
- **授权/提问在会话仍在运行时被延后提醒**:先记下,等这一轮真正停下来再报,避免在模型还在跑的时候就打扰你。
- **节流**:1.5 秒内只投递一条,多个会话同时完成不会刷屏;完成提醒前有 400ms 的确认窗口,如果那一轮马上又跑起来就不报。
- **同一会话的完成提醒会覆盖上一条**(通知带 `tag`),不堆叠。
## 兼容性
基于 **DeepSeek Harness 0.2.0-rc.2** 编写,遵循 DSH 插件规范:`dsh.manifestVersion: 1`、`dsh.bundle.patch` 声明宿主行、`dsh.client` 声明 `platform: "web"` 的浏览器半。
依赖的都是稳定契约:
- 浏览器半只依赖 **slot 的标准 props**(`useSessions`、`useSessionStatus`)读取会话状态,这是 DSH 官方给插件读会话数据的方式;不轮询、不自己订阅会话事件、不读别的插件的 DOM;
- 只往两个官方 slot 注册:`settings.general.item`(设置行)与 `shell.overlay`(两个条目:轻弹窗层 + 无渲染的观察器,内容永远是 `null`);
- **不要**把"无渲染"条目放进 `sidebar.panellist`:该 slot 的宿主会把**每个条目的 id 当成一个左侧面板按钮**(`entriesOfSlot('sidebar.panellist')` → 面板列表,按钮文字取 `options.label ?? options.id`,条目本身作为图标内联渲染),放进去会在左侧多出一行空面板并挤坏侧边栏。本插件第一版踩过这个坑,现已改挂到通用浮层;
- 样式只用主题 token,不 import 任何 `@deepseek-ai/dsh-client-*` 包(规范要求,也是渲染不被上游改动打断的前提);
- 宿主半不声明 `inject`、不注册服务、不注册路由。
上游若改动 slot 名或 hook 名:注册会静默跳过而不是把页面弄坏,插件会退化成"不提醒";这类情况请按上面的契约名核对。
## 验证方式(开发记录)
`client.js` 是纯 JavaScript、浏览器端运行,可以在 Node 里用桩模块加载器评估并驱动:
- 模块接线:加载后返回的插件、`inject` 声明、注册集合(含"观察器绝不在 sidebar.* 里"这一条)、以及各组件在空状态 / 权限被拒 / 无 Notification API 下的渲染都不报错;
- 投递规则:40 项断言覆盖上面"行为细节"里的每一条(完成提醒一次、当前会话不打扰、后台走系统通知、授权/提问各提醒一次、运行中延后、子会话跳过、历史不补发、开关生效、弹窗自动消失等)。
## License
[MIT](LICENSE)
+1012
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
# Bundle patch layer for @dsh-plugin/session-notify.
#
# One Host row is enough: the Host half owns no services and no routes, and the
# browser half is picked up automatically from this package's `dsh.client`
# declaration (`dsh-client-modules` scans enabled Loader entries, serves
# `/plugins/@dsh-plugin/session-notify/client.js`, and boots it with the page).
- insert:
- id: session-notify
name: '@dsh-plugin/session-notify'
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<!-- Notification bell (bundle artwork; the only place a literal color is allowed). -->
<path d="M8 2.5C5.72 2.5 3.88 4.34 3.88 6.62V9.1L3.1 11.2C3.03 11.39 3.17 11.58 3.37 11.58H12.63C12.83 11.58 12.97 11.39 12.9 11.2L12.12 9.1V6.62C12.12 4.34 10.28 2.5 8 2.5Z" fill="#247bbf"/>
<path d="M6.3 12.7C6.52 13.62 7.2 14.25 8 14.25C8.8 14.25 9.48 13.62 9.7 12.7" stroke="#247bbf" stroke-width="1.2" stroke-linecap="round"/>
<circle cx="12.6" cy="3.4" r="2.1" fill="#e5484d" stroke="#ffffff" stroke-width="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 619 B

+29
View File
@@ -0,0 +1,29 @@
/**
* Host half of the session-notify bundle.
*
* Every notification in this plugin is produced by the browser half: the page
* owns the DOM (the in-app light popup) and is the only place a Web
* `Notification` can be constructed, which is the one system-notification
* channel that behaves the same on Windows, macOS, and Linux inside the Harness
* Desktop shell. The Host half therefore owns no state, registers no service,
* no route, and no event listener — it exists so the bundle has the ordinary
* profile-level plugin row its patch declares, and so a future Host-side
* capability (for example persisting the per-kind switches into this profile's
* patch) has a place to live.
*
* The export form is the documented one — `export function apply(ctx, config)`
* with no `inject` and no `Config` — and it is deliberately the only export
* form this package uses.
*
* @module @dsh-plugin/session-notify
*/
/**
* Mount the Host row.
* @param ctx - Host context; no service is required, so this never blocks activation.
*/
export function apply(ctx) {
ctx.logger?.debug?.(
'session-notify: host half idle by design — notifications run in the browser half',
)
}
+6
View File
@@ -0,0 +1,6 @@
{
"meta": {
"title": "Session notifications",
"description": "Tells you when a conversation finishes, needs approval, or needs an answer — a system notification when the window is in the background, a light in-app popup when it is in the foreground."
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"meta": {
"title": "会话通知",
"description": "会话完成、需要授权或需要回答时提醒你:窗口不在前台用系统通知,窗口在前台用应用内轻弹窗。"
}
}
+11
View File
@@ -0,0 +1,11 @@
/**
* CommonJS-resolvable entry alias for the Host half.
*
* `package.json` `exports` names `./index.js`, which is what the Cordis Loader
* and the plugin manager read. A loader that resolves this package by
* `main` instead of `exports` would otherwise find nothing, so this file
* re-exports the same plugin without adding behavior or export forms.
*
* @module @dsh-plugin/session-notify/main
*/
export { apply } from './index.js'
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@dsh-plugin/session-notify",
"version": "1.0.1",
"private": true,
"type": "module",
"description": "会话完成、需要授权、需要回答时提醒你:窗口不在前台用系统通知,窗口在前台用应用内轻弹窗。",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://gitea.iwake.top/dsh-plugin/session-notify.git"
},
"homepage": "https://gitea.iwake.top/dsh-plugin/session-notify",
"exports": {
".": "./index.js",
"./client": "./client.js",
"./package.json": "./package.json",
"./locale/*.json": "./locale/*.json"
},
"icon": "./icon.svg",
"meta": {
"title": "会话通知",
"description": "会话完成 / 需要授权 / 需要回答时提醒,支持系统通知与应用内轻弹窗。"
},
"files": [
"index.js",
"main.js",
"client.js",
"cordis.patch.yml",
"icon.svg",
"locale/*.json",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=22"
},
"dsh": {
"manifestVersion": 1,
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web",
"immediately": true,
"inject": [
"@deepseek-ai/dsh-client-ui-workspace"
]
}
}
}
+88
View File
@@ -0,0 +1,88 @@
// Checks existing commits against the CONTRIBUTING.md spec. The spec allows one
// line only, so a subject must both parse (emoji + type + Chinese description)
// and be the whole message: any body or footer is reported too.
//
// This is a reporting tool, not a hook: it never blocks a commit.
//
// Usage:
// node scripts/check-commit-log.mjs # newest 10 commits
// node scripts/check-commit-log.mjs 50 # newest 50 commits
// node scripts/check-commit-log.mjs all # every commit
// DSD_SPEC=path/to/CONTRIBUTING.md node scripts/check-commit-log.mjs
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const specPath = process.env.DN_SPEC ?? resolve(repoRoot, 'CONTRIBUTING.md')
const doc = readFileSync(specPath, 'utf8')
const count = process.argv[2] ?? '10'
/** The regex the spec documents for a subject line. */
const SUBJECT = new RegExp(
'^(?:\\p{Extended_Pictographic}\\uFE0F?\\s+)?' +
'(?<type>[a-z]+)' +
'(?:\\((?<scope>[^)]*)\\))?' +
'(?<breaking>!)?' +
':\\s(?<desc>.+)$',
'u',
)
// The spec's table is the single source of truth for the type → emoji mapping,
// so a table edit cannot drift from what this script checks.
const emojiOfType = new Map()
for (const row of doc.split('\n').filter((line) => /^\| `[a-z]+` \| /.test(line))) {
const cells = row.split('|').map((cell) => cell.trim())
emojiOfType.set(cells[1].replaceAll('`', ''), cells[2])
}
if (emojiOfType.size === 0) throw new Error(`${specPath} has no type table to read`)
/** Strip the variation selector, so '♻' and '♻️' compare equal. */
const base = (text) => text.replace(/\uFE0F/g, '')
/** Display width, counting anything above U+2000 as double. */
const widthOf = (text) => [...text].reduce((n, c) => n + (c.codePointAt(0) > 0x2000 ? 2 : 1), 0)
// %B is the raw message body, so a record can hold several lines: a record ends
// at the next hash that starts a line, which is what this splits on.
const range = count === 'all' ? [] : [`-${Number(count)}`]
const raw = execFileSync('git', ['log', ...range, '--format=%h%x09%B%x00'], {
encoding: 'utf8',
cwd: repoRoot,
})
const commits = raw.split('\0').map((chunk) => chunk.replace(/^\n+/, '')).filter((chunk) => chunk.trim() !== '')
let problems = 0
for (const chunk of commits) {
const tab = chunk.indexOf('\t')
const hash = chunk.slice(0, tab)
const message = chunk.slice(tab + 1).replace(/\n+$/, '')
const lines = message.split('\n')
const subject = lines[0]
const extra = lines.slice(1).filter((line) => line.trim() !== '')
const notes = []
const match = SUBJECT.exec(subject)
if (match === null) {
notes.push('首行缺少类型前缀,或整体格式不是 <emoji> <type>[(scope)][!]: <描述>')
} else {
const expected = emojiOfType.get(match.groups.type)
const lead = [...subject][0]
if (expected === undefined) notes.push(`类型不在规范表里:${match.groups.type}`)
else if (base(lead) !== base(expected)) notes.push(`类型 ${match.groups.type} 的 emoji 应为 ${expected},实际是 ${lead}`)
const width = widthOf(subject)
if (width > 72) notes.push(`首行宽度 ${width},超过 72`)
if (/[。.]$/.test(subject)) notes.push('首行结尾不应有句号')
}
if (extra.length > 0) notes.push(`带了 ${extra.length} 行正文或脚注,规范要求只有一行`)
if (notes.length > 0) problems += 1
const mark = notes.length === 0 ? 'ok ' : 'FAIL'
console.log(` ${mark} ${hash} ${notes.length === 0 ? '' : notes.join(';') + ' — '}${subject}`)
}
console.log(problems === 0
? `\n检查了 ${commits.length} 条提交,全部符合规范`
: `\n检查了 ${commits.length} 条提交,其中 ${problems} 条不符合规范(规范文件:${specPath})`)
process.exit(problems === 0 ? 0 : 1)