diff --git a/.planning/phases/09-minimum-browser-runnable/09-24-PLAN.md b/.planning/phases/09-minimum-browser-runnable/09-24-PLAN.md index 1d8b979..e0e22b7 100644 --- a/.planning/phases/09-minimum-browser-runnable/09-24-PLAN.md +++ b/.planning/phases/09-minimum-browser-runnable/09-24-PLAN.md @@ -22,33 +22,34 @@ must_haves: truths: - "**本计划第一个 block 点是 Task 0 的 `blocking-human` 汇报关卡(AGENTS.md)**:先汇报「修正用户重写后按键系统的已确认缺陷 + 按重写后的设计重写适配测试」的要做什么 / 为什么 / 大致怎么做 / 影响范围,逐条列明 5 处已确认修正的确切落点、测试重写的覆盖清单与预期口径、验证方式、已知边界、以及待批准命名(OI-1 / OI-2)与疑似问题(OI-3,只提问不判定、不修复),然后停下等待「可以执行」。" - "**修正 1 —— `matchKeyUp` 解构写反(`system.ts`)**:`matchArray(keys, v => isAuxiliaryKey(v))` 返回 `[匹配, 未匹配]`,而 `isAuxiliaryKey` 的谓词为真者是**辅助键**(`packages/common/src/utils/func.ts:30` 的 jsDoc 与实现均如此)。当前写作 `const [main, auxiliary] = ...` 导致 `main` 实际是辅助键、`auxiliary` 实际是常规键;改为 `const [auxiliary, main] = ...`,`matchKeyUp` 的其余逻辑(`keys.length === 0` 早退、`main.length > 1` 非法、`main.length === 0` 单辅助键分支、`main[0] !== key`、`auxiliary.every(held)`)**逐字不变**。修正后 KeyUp 四条规则才成立:`[Ctrl, KeyS]` 在 Ctrl 仍按住时抬 S 触发、Ctrl 先松则永不触发、`[Ctrl]` 单独抬起触发、`[Ctrl, Shift]` 永不触发、`[KeyA, KeyB]` 由 203 拦下且永不触发。" - - "**修正 2 —— `when` 条件生效(`system.ts`)**:`scope.condition` 是唯一条件来源,当前只写不读,导致条件完全不参与触发。新增私有方法 `isActive()`(OI-1 待批准):取栈顶作用域,无作用域返回 `false`,`condition` 为 `null` 返回 `true`,否则返回 `condition()` 的本次求值结果。判定落点三处:① `down` 在 `holding` 入栈后、遍历指令之前包一层 `if (this.isActive())`,条件不满足则不建立任何待触发项、不占用 block;② `up` 在既有清理循环之后、`toEmit.sort(...)` 与优先级 / block 循环之前,条件不满足则直接返回当前的待触发项布尔值(不派发、不按 block 计算、不把 KeyUp 项转投 `waitTimeout`);③ `emit` 在取到作用域之后、取实现之前再判一次 `isActive()`,这是**延时触发**(`excited` 的 `waitTimeout` 循环与 `holdingCmds` 循环)唯一可靠的闸口。语义口径:条件只在**派发 / 触发时刻**求值,不追溯已建立的待触发项;`waitTimeout` 中的项到期时若条件不满足,本次触发被吞掉并按既有代码从 `waitTimeout` 出队(**不补发**);`holdingCmds` 中的项被抑制时保留,条件恢复后按自身规则继续。" + - "**修正 2 —— `when` 条件生效(`system.ts`)**:`scope.condition` 是唯一条件来源,当前只写不读,导致条件完全不参与触发。新增私有方法 `isActive()`(OI-1 待批准):取栈顶作用域,**无作用域返回 `true`**(私有 `emit` 在无作用域时本就 `return`,这样三处闸口不会改变 `down` / `up` 的返回值),`condition` 为 `null` 返回 `true`,否则返回 `condition()` 的本次求值结果。判定落点三处:① `down` 在 `holding` 入栈后、遍历指令之前包一层 `if (this.isActive())`,条件不满足则不建立任何待触发项、不占用 block;② `up` 在既有清理循环之后、`toEmit.sort(...)` 与优先级 / block 循环之前,条件不满足则直接返回当前的待触发项布尔值(不派发、不按 block 计算、不把 KeyUp 项转投 `waitTimeout`);③ `emit` 在取到作用域之后、取实现之前再判一次 `isActive()`,这是**延时触发**(`excited` 的 `waitTimeout` 循环与 `holdingCmds` 循环)唯一可靠的闸口。语义口径:条件只在**派发 / 触发时刻**求值,不追溯已建立的待触发项;`waitTimeout` 中的项到期时若条件不满足,本次触发被吞掉并按既有代码从 `waitTimeout` 出队(**不补发**);`holdingCmds` 中的项被抑制时保留,条件恢复后按自身规则继续。" - "**修正 3 —— `leaveScope()` 无参分支缺 `return`(`system.ts`)**:`if (!sym)` 分支在 `this.scopes.pop()` 与两次 `clear()` 之后必须立即 `return;`,否则会继续执行 `findIndex(v => v.symbol === undefined)`(符号永不相等)→ 判定 `index < 0` → **误发 warn 205**。修正后:无参且栈非空 → 只弹一级、不告警;无参且空栈 → 仍发 205 并返回;有参且找不到 → 仍发 205 并保持栈不变;有参且命中 → `splice(index + 1)` 保留命中作用域为栈顶(既有行为不变)。" - - "**修正 4 —— `excited` 首次触发判定写反(`system.ts`)**:`if (pending.emitted === 0 && dt < command.timeout)` 改为 `dt >= command.timeout`。修正后:`timeout > 0` 的按下类指令在 `payload - start` 达到 `timeout` 的那一次激励才首次触发,未到期不触发。只改这一处比较符,`waitTimeout` 循环里既有的 `dt >= pending.command.timeout` 与 `interval` / `dt` 分支**不动**。" + - "**修正 4 —— `excited` 首次触发判定写反(`system.ts`)**:`if (pending.emitted === 0 && dt < command.timeout)` 改为 `dt >= command.timeout`。修正后(**仅对 `Once + timeout > 0` 与 `Continuous + timeout === 0` 成立**):按下类指令在 `payload - start` 达到 `timeout` 的那一次激励才首次触发,未到期不触发。**`Continuous + timeout > 0` 不在本节承诺范围内**:该组合的首次触发之后会在同一次激励里继续走 `Continuous` 分支,时序仍按现状,按 **OI-3**(未裁定)记录,本计划不改、不写断言。只改这一处比较符,`waitTimeout` 循环里既有的 `dt >= pending.command.timeout` 与 `interval` / `dt` 分支**不动**。" - "**修正 5 —— `setKeys` 允许 0(`command.ts`)**:`count === 0` 合法(不告警、缩容到空、`length = 0`);仅**非整数**(`!Number.isInteger(count)`,沿用现有 `'float'`)与**负数**(`count < 0`,沿用现有 `'negative integer'`)按 207 告警并保持原状。即把 `if (count <= 0)` 改为 `if (count < 0)`。`logger.json` 的 207 文案**一字不改**(用户裁定沿用现有措辞,尽管其文本写的是 positive integer)。附带的实际效果是:构造器对 `defaults: []` 调用 `setKeys(0)` 不再产生 207 噪声。" - - "**按用户裁定保持现状的六项(逐项不得改动)**:① `down` / `up` 的返回值仍为 `holdingCmds.size > 0 || waitTimeout.size > 0`(反映调用后仍存在的待触发项,而非「是否真的触发过实现」);② KeyUp 的 `context` 仍是**匹配时刻快照**(`addPending` 时 `createContext()`,`down` 重新匹配时刷新),因此 KeyUp 触发时 `holding` **包含**被抬起的主键(与 `type.ts` 的 jsDoc 口径不同,以用户裁定为准);③ `priority` / `block` 仅对抬起触发(KeyUp)批次生效;④ `waitTimeout` 的到期判定基于 `pending.start`(`addPending` 时刻),不是抬起时刻;⑤ 未实现的指令仍会建立待触发项(`block` 在匹配层计算);⑥ `excited` 的三段时序结构(先 `waitTimeout`、再 `holdingCmds`、`Continuous` 内 `emitted === 1` 走 `interval`、否则走 `dt`)不改。" + - "**按用户裁定保持现状的六项(逐项不得改动)**:① `down` / `up` 的返回值仍为 `holdingCmds.size > 0 || waitTimeout.size > 0`(反映调用后仍存在的待触发项,而非「是否真的触发过实现」);② `context` **不是整体快照**:`createContext()` 把 `holding` 写成 `this.holding` 的**活引用**,只有 `ctrl` / `shift` / `alt` / `meta` 四个布尔量是匹配时刻的快照;又因为 `up` 在 `emit` 之前就 `this.holding.splice(index, 1)`,所以 KeyUp 触发时 `info.context.holding` **不含**被抬起的主键(`[Ctrl, KeyS]` 抬 S → `[Ctrl]`;`[KeyS]` 抬 S → `[]`),与 `type.ts` 的 jsDoc 一致;延时触发读取的也是同一个活数组,断言只能写派发时刻的实际内容;`createContext()` 本身不得修改;③ `priority` / `block` 仅对抬起触发(KeyUp)批次生效;④ `waitTimeout` 的到期判定基于 `pending.start`(`addPending` 时刻),不是抬起时刻;⑤ 未实现的指令仍会建立待触发项(`block` 在匹配层计算);⑥ `excited` 的三段时序结构(先 `waitTimeout`、再 `holdingCmds`、`Continuous` 内 `emitted === 1` 走 `interval`、否则走 `dt`)不改。" - "**测试按重写后的设计整体重写(两个文件)**:删除对已不存在的 `HotkeyEmitType` / `HotkeyEmitMode.Throttle` / `command.throttle(...)` / `context.type` / `context.key` / `info.command` 为字符串的断言;`info.command` 为 `IHotkeyCommand` **对象**(含 `command` id 与 `mode`),`info.mode` 为 `HotkeyEmitMode`;`continuous(interval, dt, timeout)` 三参;`setKeys` / `setKeyCombination` 的 206 / 207 校验按现有实现断言;每个 `it` 前一行 `//` 注释说明该用例覆盖什么。" - "**测试只从实现模块导入**:`command.test.ts` 用 `import { HotkeyCommand, isAuxiliaryKey } from '../command'`,`system.test.ts` 用 `import { HotkeySystem } from '../system'`;**禁止 `from '../index'`**(`hotkey.ts` 顶层执行 `document.addEventListener`,Node 下导入即抛错)。Node 环境无 `HTMLElement`,`listen` 用最小 fake 元素(`addEventListener` / `removeEventListener` 记录处理器、可手动派发伪事件),激励源用最小 fake(实现 `IExcitation`:`payload` / `excite` / `add` 返回带 `revoke` 的控制器 / `remove` / `destroy`)。" - - "**system.test.ts 的三段交付边界(避免半成品红灯)**:Task 1 只重写「注册表 / 作用域 / KeyUp 四条规则 / holding 与子集匹配 / 优先级与 block / `listen` / `down`·`up` 返回值」这些**不依赖修正 2、4** 的用例(该文件在 Task 1 结束时必须全绿);Task 3 再追加「`when` 条件生效 / `excited` 驱动的 timeout·interval·dt / `excitedBy` 单源切换与 null / 延时触发的 context 快照」用例。**不得**在 Task 1 提前写入依赖未修正代码的断言。" + - "**system.test.ts 的三段交付边界(避免半成品红灯)**:Task 1 只重写「注册表 / 作用域 / KeyUp 四条规则 / holding 与子集匹配 / 优先级与 block / `listen` / `down`·`up` 返回值」这些**不依赖修正 2、4** 的用例(该文件在 Task 1 结束时必须全绿);Task 3 再追加「`when` 条件生效 / `excited` 驱动的 timeout·interval·dt / `excitedBy` 单源切换与 null / 延时触发的 `context.holding` 活引用口径」用例。**不得**在 Task 1 提前写入依赖未修正代码的断言。" - "**范围(只动 5 个文件)**:`packages/system/src/hotkey/system.ts`、`command.ts`、`__test__/system.test.ts`、`__test__/command.test.ts`、`09-24-SUMMARY.md`。`type.ts`(权威设计)、`hotkey.ts` / `keyboard.ts`(旧实现)、`keyCodes.ts`、`index.ts`、`packages/common/src/logger.json`(201–207 已存在)、`package.json` / lockfile、`packages-user/**`、`src/**`、`public/**`、`script/**`、`vite.config.ts`、`tsconfig.json`、`eslint.config.js` 一律零改动。" - "**验证可运行且范围诚实(Nyquist)**:`cmd /c \"pnpm exec vitest run packages/system/src/hotkey/__test__/command.test.ts packages/system/src/hotkey/__test__/system.test.ts\"`;`cmd /c \"pnpm exec eslint <四个改动 TS 文件>\"`;结构闸门(5 处修正的代码形态、测试卫生、`it` 前注释、导入路径);按文件过滤的类型闸门(`vue-tsc --noEmit`,先校验 `spawnSync` 的 `out.error` / `out.status` / `out.signal`,本计划文件 0 条诊断,`hotkey.ts` / `keyboard.ts` 恰 2 条已知 legacy 诊断作为「输出完整」的基线证据);受保护文件 `git status --porcelain` 零改动闸门;范围闸门(白名单由 `files_modified` 派生)。不跑 `build:game` / `build:packages`,不做全仓类型修复。" artifacts: - path: packages/system/src/hotkey/system.ts - provides: "修正 1(`matchKeyUp` 解构改为 `const [auxiliary, main]`)、修正 2(新增私有 `isActive()` 并在 `down` / `up` / `emit` 三处设闸)、修正 3(无参 `leaveScope` 在 `pop` + `clear` 后 `return;`)、修正 4(`dt >= command.timeout`);除此之外不改任何既有逻辑、注释与 jsDoc" + provides: "修正 1(`matchKeyUp` 解构改为 `const [auxiliary, main]`)、修正 2(新增私有 `isActive()`,无作用域返回 `true`,并在 `down` / `up` / `emit` 三处设闸)、修正 3(无参 `leaveScope` 在 `pop` + `clear` 后 `return;`)、修正 4(`dt >= command.timeout`);除此之外不改任何既有逻辑、注释与 jsDoc" - path: packages/system/src/hotkey/command.ts provides: "修正 5:`setKeys` 的 `if (count <= 0)` 改为 `if (count < 0)`,使 `count === 0` 合法(`length = 0`、无 207);非整数 / 负数的 207 分支与文案不变;其余方法零改动" - path: packages/system/src/hotkey/__test__/system.test.ts - provides: "按重写后设计重写的系统单测:注册表(204 / 重复返回同一对象 / `getCommand` / `deleteCommand` 清理待触发项 / `iterateCommands`)、作用域(`enterScope` 返回 symbol、无参 `leaveScope` 只弹一级且**不再误发 205**、空栈 205、`leaveScope(sym)` 保留命中作用域、未知 sym 205、无作用域时 201、未知指令 202)、`when` 条件生效与回退、KeyUp 四条规则(含 203 与空组合)、holding 顺序与去重、KeyDown 子集匹配、优先级降序 / 严格更低阻断 / 同优先级全触发、`down`·`up` 返回值(按当前实现)、`listen` 的映射(65→`KeyA`、91·92·224→`Meta`、无映射跳过)、重复按下不重复触发、`preventDefault`、`listen(null)` 解绑、同元素不重复注册;每个 `it` 前一行注释" + provides: "按重写后设计重写的系统单测:注册表(204 / 重复返回同一对象 / `getCommand` / `deleteCommand` 清理待触发项 / `iterateCommands`)、作用域(`enterScope` 返回 symbol、无参 `leaveScope` 只弹一级且**不再误发 205**、空栈 205、`leaveScope(sym)` 保留命中作用域、未知 sym 205、无作用域时 201、未知指令 202)、`when` 条件生效与回退、KeyUp 四条规则(含 203 与空组合;`context.holding` 按活引用口径断言 `[Ctrl]` / `[]`)、holding 顺序与去重、KeyDown 子集匹配、优先级降序 / 严格更低阻断 / 同优先级全触发、`down`·`up` 返回值(按当前实现)、`listen` 的映射(65→`KeyA`、91·92·224→`Meta`、无映射跳过)、重复按下不重复触发、`preventDefault`、`listen(null)` 解绑、同元素不重复注册;每个 `it` 前一行注释" - path: packages/system/src/hotkey/__test__/command.test.ts provides: "按新设计重写的指令单测:构造缺省与 `defaults` 拷贝、`isAuxiliaryKey`、`setKeys` 的 0 / 负数 / 非整数、扩缩容、`setKeyCombination` 的 206 三态与写入副本、KeyUp 模式的 203 与「1 常规 + 辅助」「2 辅助」「空组合」不告警、非 KeyUp 模式不校验 203、`getKeyCombination` 越界空数组与副本、`iterateKeyCombinations` 顺序与副本、`keyUp` / `once` / `continuous` 的参数与 mode、`command` id;每个 `it` 前一行注释" - path: .planning/phases/09-minimum-browser-runnable/09-24-SUMMARY.md provides: "执行摘要:5 处修正的最终落点、按用户裁定保持现状的六项、测试重写与旧用例的对应关系、疑似问题 OI-3 的裁定结果(未确认为问题则只记录提问与用户回复)、门禁结果、边界(未做浏览器 E2E、未跑全量构建、旧实现与 `type.ts` 零改动)、`estimate.tokens=70000` 为未校准估算(`confidence: low`)的声明" key_links: - "`matchArray` 的 `[match, unmatch]` 元组(`packages/common/src/utils/func.ts`)↔ `matchKeyUp` 的 `const [auxiliary, main]` —— 辅助键集合只由 `isAuxiliaryKey` 定义一次,解构顺序写反会让整套 KeyUp 规则失效" - - "`scope.condition`(`when` 写入)↔ `isActive()` ↔ `down` / `up` / `emit` —— 条件必须在「建 pending」「算 block」「调实现」三个位置都成立,`emit` 是延时触发的唯一兜底闸口" + - "`scope.condition`(`when` 写入)↔ `isActive()` ↔ `down` / `up` / `emit` —— 条件必须在「建 pending」「算 block」「调实现」三个位置都成立,`emit` 是延时触发的唯一兜底闸口;`isActive()` 在无作用域时返回 `true`,否则 `down` / `up` 的返回值会被闸口改写;另注意 `when` 是**作用域级**条件(`scope.condition` 由同一作用域内所有指令共享),它无法只抑制某一条指令,测试不得把「只有 block 指令被抑制」写成预期" - "`leaveScope` 的作用域栈 ↔ 205 —— 无参分支的 `return` 决定「弹一级」不会被后续 `findIndex(undefined)` 误判成未知作用域" - "`excited(payload)` 的 `payload` ↔ `pending.start` / `pending.lastEmit` ↔ `timeout` / `interval` / `dt` —— 首次触发用 `payload - start >= timeout`,第二次用 `>= interval`,其后用 `>= dt`" - "`setKeys(count)` ↔ `keys.length` ↔ `length` ↔ 构造器的 `setKeys(init.defaults.length)` —— `count === 0` 必须能通过,否则 `defaults: []` 会误报 207" + - "`createContext()` 的 `holding: this.holding`(活引用)↔ `up` 的 `this.holding.splice(index, 1)` ↔ `emit` —— 派发时 `holding` 已不含被抬起的主键,KeyUp 断言必须写 `[Ctrl]`(`[Ctrl, KeyS]` 抬 S)与 `[]`(`[KeyS]` 抬 S),不能写「含主键的快照」" - "`IHotkeyEmitInfo.command`(对象)↔ `IHotkeyCommand.mode` / `.command` —— 测试断言的是对象与其 id,不是旧的字符串 id 与 `HotkeyEmitType`" - "测试文件的导入路径 ↔ `hotkey/index.ts` 的模块顶层副作用 —— 一旦从 `../index` 导入,`hotkey.ts` 的 `document.addEventListener` 会让整个测试文件在 Node 下崩掉" assumptions: @@ -59,12 +60,14 @@ must_haves: - "**测试收集与解析**:根 `vite.config.ts` 的 `test` 未设 `include`,vitest 默认收集 `**/*.test.ts`;`@motajs/*` 由 `vite.config.ts` 的 glob 别名与 `tsconfig.json` 的 `paths` 指向 `packages/*/src`,故 `@motajs/animate` / `@motajs/common` 可直接导入。" - "**`HotkeyEmitMode` 是 `const enum`,但测试可正常引用成员**:既有测试已用 `HotkeyEmitMode.KeyUp` 并在 vitest 下运行(42 条通过),重写沿用同一写法即可。" - "**条件求值次数**:`down` / `up` 的闸口与 `emit` 的闸口会让同一次派发内 `condition()` 被调用 1–2 次;`when` 的约定是纯判定函数(`type.ts` 的 jsDoc 只要求返回布尔),重复求值不视为问题;本计划不改 `when` 的存储方式。" - - "**KeyUp 的 context 是匹配时刻快照(用户裁定)**:`addPending` 在 `down` 命中组合时 `createContext()` 并保存;`up` 的清理循环只为非 KeyUp 模式刷新 `pending.context`。因此 KeyUp 触发时 `holding` 含被抬起的主键,测试按此断言并在注释里写明这是当前口径。" - - "**`down` / `up` 返回值按当前实现**:KeyUp 在 `up` 内立即触发后 `holdingCmds` 已删除该指令、`waitTimeout` 为空 → `up` 返回 `false`;而 `down` 建立起待触发项 → 返回 `true`。测试按此断言并注明「返回值反映调用后仍存在的待触发项」。" - - "**Task 3 的 `excited` 断言只覆盖不会踩到 OI-3 的形态**:`Once + timeout` 只走 `pending.emitted === 0` 分支;`Continuous + timeout === 0` 在 `addPending` 已立即触发(`emitted` 从 1 起算),因此 `interval` / `dt` 断言不受 OI-3 影响。`Continuous + timeout > 0` 不在本计划断言范围内(见 OI-3)。" + - "**`context.holding` 是活引用,只有修饰键布尔量是匹配时刻快照(已核验实现)**:`createContext()`(`system.ts:110-118`)返回 `{ holding: this.holding, ctrl: this.holdingSet.has(KeyCode.Ctrl), ... }`,`holding` 直接指向系统内部数组;`up()` 在 `holdingSet.delete` 之后、构造 `toEmit` 与 `emit` 之前执行 `this.holding.splice(index, 1)`。因此 KeyUp 触发时 `info.context.holding` **不含**被抬起的主键:`[Ctrl, KeyS]` 抬 S → `[Ctrl]`;`[KeyS]` 抬 S → `[]`。`keyUp(timeout)` 的延时项读取的仍是同一个活数组,所以延时到期的断言同样按派发时刻的内容写(`[]`)。**不得改 `createContext`**(`holding: this.holding` 逐字保留)。" + - "**`down` / `up` 返回值按当前实现**:KeyUp 在 `up` 内立即触发后 `holdingCmds` 已删除该指令、`waitTimeout` 为空 → `up` 返回 `false`;而 `down` 建立起待触发项 → 返回 `true`。测试按此断言并注明「返回值反映调用后仍存在的待触发项」。`isActive()` 在无作用域时返回 `true`,且私有 `emit` 本就无作用域即 `return`,故新增闸口不改变这两种返回值。" + - "**Task 3 的 `excited` 断言只覆盖不会踩到 OI-3 的形态**:`Once + timeout` 只走 `pending.emitted === 0` 分支(`Once` 不进 `Continuous` 分支,故 99 不触发 / 100 触发 / 之后不再触发是确定的);`Continuous + timeout === 0` 在 `addPending` 已立即触发(`emitted` 从 1 起算,随后同一次 `excited` 调用里 `dt2 = payload - lastEmit`),因此 `interval` / `dt` 断言不受 OI-3 影响。**`Continuous + timeout > 0` 不在本计划断言范围内**(见 OI-3,未裁定),Task 3 也不得声称已修正该组合。" + - "**本轮修订前基线是已提交的 09-23 实现(INFO)**:`git log` 的 `cfdca2f docs(09-24)` 是纯计划提交,其父提交 `3fd339f feat: 抬起按键的优先级` 即 09-23 交付的实现与测试;本计划 Task 0 的前置校验与 `LEGACY GATE` 都以该已提交内容为基线。本轮修订只改 `09-24-PLAN.md`,未触碰任何代码 / 测试,`git status --porcelain` 对 `system.ts` / `command.ts` / 两个测试文件为空。" prohibitions: - "不得在用户回复通过 Task 0 关卡之前执行任何任务或派发执行子代理。" - - "**不得修正确认清单以外的任何行为**:只能落地 5 处修正;`down` / `up` 返回值、KeyUp 的 context 快照、`priority` / `block` 的生效范围、`waitTimeout` 的计时起点、未实现指令的待触发项、`excited` 的分支结构一律按现状保留。**疑似问题 OI-3 未经用户确认前不得修改,也不得写成已确认缺陷。**" + - "**不得修正确认清单以外的任何行为**:只能落地 5 处修正;`down` / `up` 返回值、`createContext()` 的 `holding: this.holding` 活引用与四个修饰键布尔量的匹配时刻快照、`priority` / `block` 的生效范围、`waitTimeout` 的计时起点、未实现指令的待触发项、`excited` 的分支结构一律按现状保留。**疑似问题 OI-3 未经用户确认前不得修改,也不得写成已确认缺陷。**" + - "**不得改 `createContext()` / 不得把 `context.holding` 写成快照**:KeyUp 触发时 `holding` 不含被抬起的主键,是因为 `up` 先 `splice` 再 `emit`;把 `createContext` 改成 `holding: [...this.holding]` 属于确认清单外的行为变更,禁止。同理不得为了让断言「好看」而在测试里复制 `holding` 后再断言。" - "**不得改受保护文件**:`type.ts`(权威设计,逐字保留)、`hotkey.ts` / `keyboard.ts`(旧实现,含顶层 `document` 监听)、`keyCodes.ts`、`hotkey/index.ts`、`packages/common/src/logger.json`、`package.json` / lockfile、`packages-user/**`、`src/**`、`public/**`、`script/**`、`vite.config.ts`、`tsconfig.json`、`eslint.config.js`、`dev.md`、`AGENTS.md`。" - "**不得改写既有注释 / jsDoc(AGENTS.md)**:`system.ts` / `command.ts` 的既有注释逐字保留;`matchKeyUp` 的注释「抬起按键仅允许一个非辅助按键」在修正后依然成立,不得改动;新逻辑的说明只允许写在新成员(`isActive`)上。" - "**不得从 `../index` 导入测试**:会让 `hotkey.ts` 的模块顶层 `document.addEventListener` 在 Node 下抛错;`command.test.ts` 只导 `../command` + `../keyCodes` + `../type`,`system.test.ts` 只导 `../system` + `../keyCodes` + `../type`。" @@ -74,10 +77,10 @@ must_haves: - "**不得跑全量构建或全仓类型修复**:不执行 `build:game` / `build:packages`,不修 `hotkey.ts` / `keyboard.ts` 的 2 条 legacy 类型错误,不改其它包。" - "**不得用 `cmd` 以外的裸 `pnpm`**:PowerShell 5.1 下 `pnpm.ps1` 会因 `PSSecurityException` 失败,命令一律 `cmd /c \"pnpm exec ...\"`;`node -e` 内联脚本只允许单引号字面量(不得出现 `\\\"`,否则 PowerShell 会提前截断字符串)。" open_items: - - "**OI-1(新增私有方法命名,待批准)**:提议 `private isActive(): boolean`(`system.ts`,判定栈顶作用域及其条件)。备选:`isConditionMet` / `canEmit` / `isScopeActive`。**未获批前不得落码**;若改名,Task 3 的结构闸门字符串与 `must_haves` 同步修订并重新汇报。" + - "**OI-1(新增私有方法命名,待批准)**:提议 `private isActive(): boolean`(`system.ts`,判定栈顶作用域是否存在且其条件是否满足;**无作用域返回 `true`**,以保持 `down` / `up` 返回值与闸口引入前一致)。备选:`isConditionMet` / `canEmit` / `isScopeActive`。**未获批前不得落码**;若改名,Task 3 的结构闸门字符串与 `must_haves` 同步修订并重新汇报。" - "**OI-2(测试内部 helper 命名,报备)**:`createInit(overrides?: Partial)`、`class FakeElement`(`listeners` / `addEventListener` / `removeEventListener` / `dispatch` / `count`)、`interface FakeKeyEvent`(`keyCode` / `repeat` / `preventDefault`)、`createKeyEvent(keyCode, repeat?)`、`createExcitation()`(返回 `IExcitation` + 记录 `remove` 调用)。均为测试文件内部声明、不导出。" - - "**OI-3(疑似问题,仅提问、未确认为问题、本计划不修复)**:`system.ts` 的 `excited` 在 `Continuous` 分支里写的是 `if (pending.emitted === 1) {...} else { if (dt2 >= command.dt) emit }`;首次触发前 `emitted === 0` 且 `lastEmit === -Infinity`,`dt2 = payload - (-Infinity) = Infinity` 恒 `>= dt`,因此 **`continuous` 且 `timeout > 0` 的指令会在 timeout 到期前的那次激励就被触发**(例如 `timeout: 100` 时 `excite(99)` 即触发);同理 `timeout` 到期的同一次激励可能连续触发两次(`interval` 为 0 时)。**这只是向用户的提问,不是已登记缺陷**:本计划不修、不在测试中断言该组合;若用户确认要修,先修订本计划(改为 `else if (pending.emitted > 1)` 或等价结构 + 补 `continuous + timeout > 0` 的时序用例)再执行。" - - "**OI-4(口径备注,已按用户裁定执行,不需另批)**:KeyUp 触发时 `info.context.holding` 含被抬起的主键(匹配时刻快照),`up` 在 KeyUp 立即触发后返回 `false`、`down` 返回 `true`(返回值反映仍存在的待触发项)。测试按此断言并在 `it` 注释中注明「按当前实现」,SUMMARY 需如实记录这两条与 `type.ts` jsDoc 的差异。" + - "**OI-3(疑似问题,仅提问、未确认为问题、本计划不修复)**:`system.ts` 的 `excited` 在 `Continuous` 分支里写的是 `if (pending.emitted === 1) {...} else { if (dt2 >= command.dt) emit }`;首次触发前 `emitted === 0` 且 `lastEmit === -Infinity`,`dt2 = payload - (-Infinity) = Infinity` 恒 `>= dt`,因此 **`continuous` 且 `timeout > 0` 的指令会在 timeout 到期前的那次激励就被触发**(例如 `timeout: 100` 时 `excite(99)` 即触发,且这次触发来自 `Continuous` 的 `else` 分支,**修正 4 的 `dt >= timeout` 闸口管不住它**);同理 `timeout` 到期的同一次激励可能连续触发两次(`interval` 为 0 时)。**这只是向用户的提问,不是已登记缺陷**:本计划不修、不在测试中断言该组合、SUMMARY 不得表述为「timeout 行为已全部修正」;若用户确认要修,先修订本计划(改为 `else if (pending.emitted > 1)` 或等价结构 + 补 `continuous + timeout > 0` 的时序用例)再执行。" + - "**OI-4(口径备注,已按实现核验,不需另批)**:`context` 是「活引用 + 修饰键快照」的混合体 —— `createContext()` 写 `holding: this.holding`(活引用),只有 `ctrl` / `shift` / `alt` / `meta` 是匹配时刻的快照;`up` 在 `emit` 之前 `splice` 掉被抬起的键,故 KeyUp 触发时 `info.context.holding` 为 `[Ctrl]`(`[Ctrl, KeyS]` 抬 S)或 `[]`(`[KeyS]` 抬 S),**不含**被抬起的主键(与 `type.ts` jsDoc 一致)。`up` 在 KeyUp 立即触发后返回 `false`、`down` 返回 `true`(返回值反映仍存在的待触发项)。测试按此断言并在 `it` 注释中写明「holding 是活引用,仅修饰键布尔量是匹配时刻快照」,SUMMARY 如实记录。" --- @@ -92,7 +95,15 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman - `__test__/command.test.ts`:整体重写(Task 2 与修正 5 同批交付并跑绿)。 - 不碰 `type.ts` / `hotkey.ts` / `keyboard.ts` / `keyCodes.ts` / `index.ts` / `logger.json` / `package.json` / lockfile / `packages-user/**` / `src/**`。 -**约束:** 只动 `files_modified` 内的 5 个文件;除 5 处修正外不改任何行为(含 `down` / `up` 返回值、KeyUp 的 context 快照、`priority` / `block` 的生效范围);不把疑似问题 OI-3 当成已确认缺陷;不跑全量构建。 +**修订 2(本轮,按 plan-checker 的 2 blocker / 3 warning 修订):** +- ① KeyUp 的 `context.holding` 口径改为「**活引用 + 修饰键快照**」:`createContext()` 写 `holding: this.holding`,`up` 先 `splice` 再 `emit`,故 KeyUp 触发时 `holding` 不含被抬起的主键(`[Ctrl, KeyS]` 抬 S → `[Ctrl]`,`[KeyS]` 抬 S → `[]`);`createContext` 不改。 +- ② Task 3 的条件 + block 用例重写:`block: true` 优先级 10 会始终阻断优先级 5(`break`),且 `when` 是**作用域级**、无法只抑制其中一条 —— 改为「条件为假 → 两条都 0;条件为真 + 重新按下 → 高优先级 1 次、低优先级 0 次」。 +- ③ Task 1 的 import 列表移除 `IExcitable` / `IExcitation`(Task 1 禁止激励相关用例),改在 Task 3 引入 `createExcitation()` 时加回。 +- ④ `isActive()` 在无作用域时返回 `true`(私有 `emit` 本就 no-op),使 `down` / `up` 的返回值与闸口引入前完全一致。 +- ⑤ 修正 4 的承诺范围限定为 `Once + timeout > 0` 与 `Continuous + timeout === 0`;`Continuous + timeout > 0` 按 **OI-3** 记录为未裁定、保持现状。 +- ⑥ SUMMARY 须声明 `estimate` 为未校准估算(`confidence: low`),并注明修订前基线是已提交的 09-23 实现(`3fd339f`)。 + +**约束:** 只动 `files_modified` 内的 5 个文件;除 5 处修正外不改任何行为(含 `down` / `up` 返回值、`createContext()` 的 `holding` 活引用与修饰键快照、`priority` / `block` 的生效范围);不把疑似问题 OI-3 当成已确认缺陷;不跑全量构建。 **修改方式声明(AGENTS.md):** 本计划执行含文件写入(改写 2 个实现文件的部分行、整体重写 2 个测试文件、写 1 个 SUMMARY),由**派发子代理(`gsd-executor`)执行**,非内联执行;除 `files_modified` 外无任何写入。 @@ -145,26 +156,26 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman ② **修正 1(`system.ts`,`matchKeyUp`):** `matchArray(keys, v => isAuxiliaryKey(v))` 返回 `[匹配, 未匹配]`,谓词为真者是**辅助键**;当前 `const [main, auxiliary] = ...` 把两者拿反,导致 `[Ctrl, KeyS]` 在抬起 `KeyS` 时 `main[0]` 是 `Ctrl` ≠ `KeyS` → 永不触发。改为 `const [auxiliary, main] = ...`,其余逻辑逐字不变。修正后 KeyUp 四条规则成立。 - ③ **修正 2(`system.ts`,`when`):** `scope.condition` 只写不读。新增私有方法 `isActive()`(OI-1 待批准:取栈顶、无作用域 `false`、`condition` 为 `null` 为 `true`、否则取 `condition()`),并在三处设闸:`down` 在 `holding` 入栈后、遍历指令前;`up` 在清理循环后、`toEmit.sort(...)` 与优先级 / block 循环前;`emit` 在取到作用域后、取实现前(延时触发的唯一兜底)。条件不满足时:不建立待触发项、不算 block、不调实现;`waitTimeout` 中的项到期而条件不满足 → 本次吞掉并出队、不补发;`holdingCmds` 中的项保留,条件恢复后按自身规则继续。 + ③ **修正 2(`system.ts`,`when`):** `scope.condition` 只写不读。新增私有方法 `isActive()`(OI-1 待批准:取栈顶;**无作用域返回 `true`**,因为私有 `emit` 无作用域本就 `return`,这样 `down` / `up` 返回值不变;`condition` 为 `null` 为 `true`;否则取 `condition()`),并在三处设闸:`down` 在 `holding` 入栈后、遍历指令前;`up` 在清理循环后、`toEmit.sort(...)` 与优先级 / block 循环前;`emit` 在取到作用域后、取实现前(延时触发的唯一兜底)。条件不满足时:不建立待触发项、不算 block、不调实现;`waitTimeout` 中的项到期而条件不满足 → 本次吞掉并出队、不补发;`holdingCmds` 中的项保留,条件恢复后按自身规则继续。 ④ **修正 3(`system.ts`,`leaveScope`):** 无参分支在 `this.scopes.pop()` 与两次 `clear()` 之后补 `return;`。当前缺 `return` 会继续 `findIndex(v => v.symbol === undefined)` → 误发 205。空栈无参仍发 205,有参未知仍发 205,有参命中仍 `splice(index + 1)` 保留命中作用域。 - ⑤ **修正 4(`system.ts`,`excited`):** `dt < command.timeout` 改为 `dt >= command.timeout`(`timeout > 0` 时未到期不触发,到期的那次激励才首次触发)。只改比较符,`waitTimeout` 循环与 `interval` / `dt` 分支不动。 + ⑤ **修正 4(`system.ts`,`excited`):** `dt < command.timeout` 改为 `dt >= command.timeout`(`timeout > 0` 时未到期不触发,到期的那次激励才首次触发)。**承诺范围仅 `Once + timeout > 0` 与 `Continuous + timeout === 0`**;`Continuous + timeout > 0` 仍按 OI-3 保持现状(这次比较符的修正管不住 `Continuous` 的 `else` 分支)。只改比较符,`waitTimeout` 循环与 `interval` / `dt` 分支不动。 ⑥ **修正 5(`command.ts`,`setKeys`):** `if (count <= 0)` 改为 `if (count < 0)`,使 `count === 0` 合法(无 207、缩容到空、`length = 0`);非整数 / 负数仍按 207 告警(文案沿用 `logger.json` 现有 207,不改文案)。附带的实际效果是构造器对 `defaults: []` 的 `setKeys(0)` 不再误报 207。 - ⑦ **按用户裁定保持现状(明确不改,并会写进测试预期):** `down` / `up` 返回值仍是「调用后是否仍有待触发项」(KeyUp 在 `up` 立即触发后 `up` 返回 `false`、`down` 返回 `true`);KeyUp 的 `context` 仍是匹配时刻快照(`holding` 含被抬起的主键,与 `type.ts` jsDoc 不同);`priority` / `block` 只对抬起批次生效;`waitTimeout` 的计时起点是 `addPending` 时刻;未实现指令仍建立待触发项(`block` 在匹配层计算);`excited` 的三段分支结构不动。 + ⑦ **按用户裁定保持现状(明确不改,并会写进测试预期):** `down` / `up` 返回值仍是「调用后是否仍有待触发项」(KeyUp 在 `up` 立即触发后 `up` 返回 `false`、`down` 返回 `true`);`context` 是「活引用 + 修饰键快照」—— `createContext()` 写 `holding: this.holding`,`up` 先 `splice` 再 `emit`,故 KeyUp 触发时 `holding` **不含**被抬起的主键(`[Ctrl, KeyS]` 抬 S → `[Ctrl]`;`[KeyS]` 抬 S → `[]`),只有 `ctrl` / `shift` / `alt` / `meta` 是匹配时刻快照,且 `createContext` 不改;`priority` / `block` 只对抬起批次生效;`waitTimeout` 的计时起点是 `addPending` 时刻;未实现指令仍建立待触发项(`block` 在匹配层计算);`excited` 的三段分支结构不动。 ⑧ **测试重写范围(两个文件,逐 `it` 前一行注释,只从 `../command` / `../system` 导入):** - - `system.test.ts` Task 1 交付:注册表(204 / 返回同一对象 / `getCommand` / `deleteCommand` 清理待触发项 / `iterateCommands`)、作用域(`enterScope` 返回 symbol;**无参 `leaveScope` 只弹一级且不再误发 205**;空栈 205;`leaveScope(sym)` 保留命中作用域、上方丢弃;未知 sym 205;无作用域 201;未知指令 202)、KeyUp 四条规则(`[Ctrl, KeyS]` 抬 S 触发且 Ctrl 先松不触发、`[Ctrl]` 单辅助键触发、`[Ctrl, Shift]` 不触发、`[KeyA, KeyB]` 203 且永不触发、空组合不触发不告警)、holding 顺序与去重、KeyDown 子集匹配(`[Ctrl, KeyS]` 与 `[KeyS]` 同时命中)、优先级降序 / 严格更低阻断 / 同优先级全触发、`down` / `up` 返回值(按当前实现)、`listen` 映射(65→`KeyA`、91/92/224→`Meta`、无映射跳过)、重复按下不重复触发、`preventDefault`(按下时 `down` 为真 → 调用;KeyUp 抬起后 `up` 为假 → 不调用)、`listen(null)` 解绑、同元素不重复注册。 - - `system.test.ts` Task 3 追加:`when` 条件生效与回退(条件为假时按下 / 抬起都不触发,恢复后重新按下可触发;延时触发到期时条件为假 → 吞掉且不补发)、`excited` 驱动的 `Once + timeout`(99 不触发 / 100 触发 / 之后仍只 1 次)、`Continuous + timeout 0` 的 `interval` / `dt`(down 1 次;`excite(40)` 仍 1;`excite(50)` → 2;`excite(100)` 仍 2;`excite(250)` → 3)、松键后 Continuous 停止、未触发的 `Once` 松键后转入待触发并在 `excite(200)` 触发一次(当前实现,非取消)、KeyUp 延时项的 `context` 快照(`holding` 含被抬起的主键)、`excitedBy` 单源切换(切换后旧源不再驱动、新源驱动)与 `excitedBy(null)`、未绑定激励源时延时指令永不触发、`info.command` 为对象且含 `mode` / `command` id。 + - `system.test.ts` Task 1 交付:注册表(204 / 返回同一对象 / `getCommand` / `deleteCommand` 清理待触发项 / `iterateCommands`)、作用域(`enterScope` 返回 symbol;**无参 `leaveScope` 只弹一级且不再误发 205**;空栈 205;`leaveScope(sym)` 保留命中作用域、上方丢弃;未知 sym 205;无作用域 201;未知指令 202)、KeyUp 四条规则(`[Ctrl, KeyS]` 抬 S 触发且 Ctrl 先松不触发、`[Ctrl]` 单辅助键触发、`[Ctrl, Shift]` 不触发、`[KeyA, KeyB]` 203 且永不触发、空组合不触发不告警;`[Ctrl, KeyS]` 抬 S 时 `info.context.holding` 等于 `[Ctrl]`、`[Ctrl]` 单辅助键抬 Ctrl 时等于 `[]`,注释写明「holding 是活引用,仅修饰键布尔量是匹配时刻快照」)、holding 顺序与去重、KeyDown 子集匹配(`[Ctrl, KeyS]` 与 `[KeyS]` 同时命中)、优先级降序 / 严格更低阻断 / 同优先级全触发、`down` / `up` 返回值(按当前实现)、`listen` 映射(65→`KeyA`、91/92/224→`Meta`、无映射跳过)、重复按下不重复触发、`preventDefault`(按下时 `down` 为真 → 调用;KeyUp 抬起后 `up` 为假 → 不调用)、`listen(null)` 解绑、同元素不重复注册。 + - `system.test.ts` Task 3 追加:`when` 条件生效与回退(条件为假时按下 / 抬起都不触发,恢复后重新按下可触发;延时触发到期时条件为假 → 吞掉且不补发)、`excited` 驱动的 `Once + timeout`(99 不触发 / 100 触发 / 之后仍只 1 次)、`Continuous + timeout 0` 的 `interval` / `dt`(down 1 次;`excite(40)` 仍 1;`excite(50)` → 2;`excite(100)` 仍 2;`excite(250)` → 3)、松键后 Continuous 停止、未触发的 `Once` 松键后转入待触发并在 `excite(200)` 触发一次(当前实现,非取消)、KeyUp 延时项的 `context.holding` 为活引用(`up` 先 `splice` 再入 `waitTimeout`,断言 `[]`,不含被抬起的主键)、`excitedBy` 单源切换(切换后旧源不再驱动、新源驱动)与 `excitedBy(null)`、未绑定激励源时延时指令永不触发、`info.command` 为对象且含 `mode` / `command` id。 - `command.test.ts` Task 2 交付:构造缺省(`mode` 为 `KeyUp`、`timeout` / `interval` / `dt` 为 0、`block` 为 `false`)与 `defaults` 拷贝、`defaults: []` 不告警、`isAuxiliaryKey` 四真四假、`setKeys(0)` 合法(无 207、`length` 0)、`setKeys(-1)` / `setKeys(1.5)` 各发一次 207 且状态不变、扩缩容、`setKeyCombination` 的非整数 / 负数 / 越界三种 206 且组合不变、写入副本、KeyUp 模式 203(2 常规键)与 1 常规 + 辅助 / 2 辅助 / 空组合不告警、非 KeyUp 模式不校验 203、`getKeyCombination` 越界空数组与副本、`iterateKeyCombinations` 顺序与副本、`keyUp(120)` / `once(15)` / `continuous(30, 60, 90)` / `continuous()` 的 `mode` 与字段、`command` id。 ⑨ **验证方式:** `cmd /c "pnpm exec vitest run packages/system/src/hotkey/__test__/command.test.ts packages/system/src/hotkey/__test__/system.test.ts"`;`cmd /c "pnpm exec eslint <四个改动 TS 文件>"`;结构闸门(5 处修正的代码形态、测试卫生:无 `HotkeyEmitType` / `Throttle` / `context.key` / `context.type` / `../index`,每个 `it` 前有注释);按文件过滤的类型闸门(`vue-tsc --noEmit`,先校验 `spawnSync` 的 `out.error` / `out.status` / `out.signal`,本计划文件 0 条诊断,`hotkey.ts` / `keyboard.ts` 恰 2 条已知 legacy 诊断作为输出完整的基线证据);受保护文件 `git status --porcelain` 零改动;范围闸门(白名单由 `files_modified` 派生)。不跑 `build:game`。 ⑩ **已知边界(原样告知):** ①不做浏览器 E2E、不接线 `packages-user/**` 的 `IClientHotkey`;②`hotkey.ts` / `keyboard.ts` 的 2 条 legacy 类型错误保持原状;③`type.ts` / `keyCodes.ts` / `index.ts` / `logger.json` / `package.json` / lockfile 零改动;④不修 OI-3(疑似问题,见下)除非用户确认并要求先修订计划。 - ⑪ **待批准命名与待裁决问题:** OI-1 私有方法名 `isActive`(备选 `isConditionMet` / `canEmit` / `isScopeActive`);OI-2 测试内部 helper(`createInit` / `FakeElement` / `FakeKeyEvent` / `createKeyEvent` / `createExcitation`,不导出);**OI-3(只提问,未确认为问题、本计划不修)**:`excited` 的 `Continuous` 分支 `if (pending.emitted === 1) {...} else { if (dt2 >= command.dt) emit }` 在 `emitted === 0` 且 `lastEmit === -Infinity` 时 `dt2` 为 `Infinity`,会让 `continuous` + `timeout > 0` 在 timeout 到期前就触发(如 `timeout: 100` 时 `excite(99)` 即触发);本计划不修、不在测试中断言该组合,若确认要修请先说,届时先修订本计划再执行。OI-4 口径备注:KeyUp 的 `context.holding` 含被抬起主键、`up` 在 KeyUp 立即触发后返回 `false`(按用户裁定的当前实现)。 + ⑪ **待批准命名与待裁决问题:** OI-1 私有方法名 `isActive`(备选 `isConditionMet` / `canEmit` / `isScopeActive`);OI-2 测试内部 helper(`createInit` / `FakeElement` / `FakeKeyEvent` / `createKeyEvent` / `createExcitation`,不导出);**OI-3(只提问,未确认为问题、本计划不修)**:`excited` 的 `Continuous` 分支 `if (pending.emitted === 1) {...} else { if (dt2 >= command.dt) emit }` 在 `emitted === 0` 且 `lastEmit === -Infinity` 时 `dt2` 为 `Infinity`,会让 `continuous` + `timeout > 0` 在 timeout 到期前就触发(如 `timeout: 100` 时 `excite(99)` 即触发);本计划不修、不在测试中断言该组合,若确认要修请先说,届时先修订本计划再执行。OI-4 口径备注:`context` 是「活引用 + 修饰键快照」(KeyUp 触发时 `[Ctrl, KeyS]` → `[Ctrl]`、`[KeyS]` → `[]`,不含被抬起的主键,`createContext` 不改;`up` 在 KeyUp 立即触发后返回 `false`,返回值反映仍存在的待触发项)。 ⑫ **修改方式:** 由 `gsd-executor` 子代理执行(非内联),只写 `files_modified` 的 5 个文件。 @@ -176,7 +187,7 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman