mirror of
https://github.com/motajs/template.git
synced 2026-10-29 12:20:17 +08:00
docs(09-24): revise plan for holding semantics, block gating and isActive no-scope
This commit is contained in:
parent
cfdca2f50e
commit
b79fc54340
@ -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<number>`:`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<IHotkeyCommandInit>)`、`class FakeElement`(`listeners` / `addEventListener` / `removeEventListener` / `dispatch` / `count`)、`interface FakeKeyEvent`(`keyCode` / `repeat` / `preventDefault`)、`createKeyEvent(keyCode, repeat?)`、`createExcitation()`(返回 `IExcitation<number>` + 记录 `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 如实记录。"
|
||||
---
|
||||
|
||||
<objective>
|
||||
@ -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` 外无任何写入。
|
||||
</objective>
|
||||
@ -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 个文件。
|
||||
</action>
|
||||
@ -176,7 +187,7 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
|
||||
<option id="execute-default">
|
||||
<name>可以执行(按本计划默认口径;OI-1 采用 `isActive`,OI-3 不修)</name>
|
||||
<pros>一次修掉 5 处已确认缺陷并让两个测试重新成为可信回归基线;只改 4 个代码/测试文件与 1 个 SUMMARY;不引入依赖、不碰权威设计与旧实现,风险面最小</pros>
|
||||
<cons>测试整体重写(两个文件约 900–1100 行),审阅量较大;KeyUp 的 `context` 快照与 `down` / `up` 返回值仍与 `type.ts` 的 jsDoc 不一致(按用户裁定保留);OI-3 的 `continuous + timeout > 0` 仍留在现状且无测试覆盖</cons>
|
||||
<cons>测试整体重写(两个文件约 900–1100 行),审阅量较大;`down` / `up` 返回值仍是「是否仍有待触发项」而非 `type.ts` jsDoc 字面所述的「是否触发过」(按用户裁定保留);`Continuous + timeout > 0` 的时序仍留在现状(按 OI-3 记录)且无测试覆盖</cons>
|
||||
</option>
|
||||
<option id="revise">
|
||||
<name>先修订 OI-1 命名 / 确认 OI-3 的处置,再重新汇报</name>
|
||||
@ -200,7 +211,7 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
|
||||
- 汇报涵盖:要解决的问题(5 处已确认缺陷 + 旧测试 18 failed / 42 passed)、5 处修正的确切落点与当前错误写法、按用户裁定保持现状的六项、测试重写范围与预期口径、验证方式、四项已知边界
|
||||
- 已逐条说明修正 2 的三个闸口位置(`down` / `up` / `emit`)与时序语义(延时触发到期而条件不满足 → 吞掉并出队、不补发)
|
||||
- 已说明 `count === 0` 变合法但 207 文案不改,以及构造器 `defaults: []` 不再误报 207 的附带效果
|
||||
- 已说明 `down` / `up` 返回值与 KeyUp 的 `context` 快照仍按当前实现(与 `type.ts` jsDoc 的差异会如实写进测试注释与 SUMMARY)
|
||||
- 已说明 `down` / `up` 返回值仍按当前实现,且 `context` 是「活引用 + 修饰键快照」:KeyUp 触发时 `holding` 不含被抬起的主键(`[Ctrl, KeyS]` → `[Ctrl]`、`[KeyS]` → `[]`),`createContext` 不改
|
||||
- 已说明 Task 1 / Task 3 的测试交付边界(Task 1 只写不依赖修正 2、4 的用例并跑绿)
|
||||
- 已提出 OI-1(`isActive`)与 OI-2(测试 helper)命名,并**以提问方式**提出 OI-3(`continuous + timeout > 0`),明确「未确认为问题、本计划不修、不写进测试」
|
||||
- 已说明修改由子代理执行、只写 `files_modified` 的 5 个文件
|
||||
@ -230,7 +241,7 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
|
||||
|
||||
(2) **`system.ts` 修正 3(无参 `leaveScope` 提前返回):** 在 `if (!sym) { ... }` 分支内、`this.waitTimeout.clear();` 之后、该分支右花括号之前插入 `return;`(即「栈非空 → pop + 两次 clear → return」;空栈分支保持既有的 `logger.warn(205); return;`)。有参分支(`findIndex` / `splice(index + 1)` / 两次 clear)与 `when` / `implement` 一律不动。
|
||||
|
||||
(3) **`system.test.ts` 整体重写(Task 1 切片):** 以新内容覆盖整个文件。文件头一行注释说明覆盖范围;首行起连续 `import`(`IExcitable` / `IExcitation` from `@motajs/animate`;`logger` from `@motajs/common`;`afterAll` / `afterEach` / `describe` / `expect` / `it` / `vi` from `vitest`;`KeyCode` from `../keyCodes`;`HotkeySystem` from `../system`;`HotkeyEmitMode` / `IHotkeyCommandInit` from `../type`)。**禁止从 `../index` 导入。**
|
||||
(3) **`system.test.ts` 整体重写(Task 1 切片):** 以新内容覆盖整个文件。文件头一行注释说明覆盖范围;首行起连续 `import`(`logger` from `@motajs/common`;`afterAll` / `afterEach` / `describe` / `expect` / `it` / `vi` from `vitest`;`KeyCode` from `../keyCodes`;`HotkeySystem` from `../system`;`HotkeyEmitMode` / `IHotkeyCommandInit` from `../type`)。**本任务不导入 `@motajs/animate`**:Task 1 禁止激励相关用例,`IExcitable` / `IExcitation` 只在 Task 3 引入假激励源时再加回(避免未使用导入被 eslint 拦下)。**禁止从 `../index` 导入。**
|
||||
|
||||
(4) **测试骨架:** `vi.hoisted(() => { vi.stubGlobal('main', { replayChecking: true }); vi.stubGlobal('location', { origin: 'http://localhost' }); })`;`afterEach(() => vi.restoreAllMocks())`;`afterAll(() => vi.unstubAllGlobals())`。helper(OI-2):`createInit(overrides: Partial<IHotkeyCommandInit> = {}): IHotkeyCommandInit` 返回 `{ priority: 0, block: false, defaults: [], ...overrides }`;`class FakeElement`(`listeners: Map<string, ((event: KeyboardEvent) => void)[]>`、`addEventListener` / `removeEventListener` / `dispatch(type, event: FakeKeyEvent)` / `count()`);`interface FakeKeyEvent { keyCode: number; repeat: boolean; preventDefault: ReturnType<typeof vi.fn> }`;`createKeyEvent(keyCode, repeat = false)`。`listen` 处沿用既有写法 `target as unknown as HTMLElement`(测试文件不受 dev.md 的连续 `as` 限制,AGENTS.md 只强制「每个 `it` 前一行注释」)。
|
||||
|
||||
@ -248,9 +259,9 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
|
||||
- 无作用域时 `when` / `implement` 各发 201 并返回 `this`。
|
||||
- `enterScope` 后 `implement('missing', fn)` 发 202(`toHaveBeenCalledWith(202, 'missing')`)并返回 `this`。
|
||||
(7) **KeyUp 用例(修正 1 的直接回归):**
|
||||
- `[Ctrl, KeyS]`:`down(Ctrl)` → `down(KeyS)` → `up(KeyS)`,handler 1 次;`info.mode === HotkeyEmitMode.KeyUp`、`info.index === 0`、`info.keys` 等于 `[Ctrl, KeyS]`、`info.command` 是**对象**且 `info.command.command === 'save'`、`info.command.mode === HotkeyEmitMode.KeyUp`;`info.context.holding` 等于 `[Ctrl, KeyS]`(匹配时刻快照,含被抬起的主键——按用户裁定,注释注明)、`ctrl` 为真、`shift` / `alt` / `meta` 为假。
|
||||
- `[Ctrl, KeyS]`:`down(Ctrl)` → `down(KeyS)` → `up(KeyS)`,handler 1 次;`info.mode === HotkeyEmitMode.KeyUp`、`info.index === 0`、`info.keys` 等于 `[Ctrl, KeyS]`、`info.command` 是**对象**且 `info.command.command === 'save'`、`info.command.mode === HotkeyEmitMode.KeyUp`;`info.context.holding` 等于 `[Ctrl]`(`holding` 是**活引用**,`up` 已 `splice` 掉被抬起的 `KeyS`;只有 `ctrl` / `shift` / `alt` / `meta` 是匹配时刻快照 —— 注释写明「holding 是活引用,仅修饰键布尔量是匹配时刻快照」)、`ctrl` 为真、`shift` / `alt` / `meta` 为假。
|
||||
- `[Ctrl, KeyS]` 先松 Ctrl:`down(Ctrl)` → `down(KeyS)` → `up(Ctrl)` → `up(KeyS)`,handler 0 次。
|
||||
- `[Ctrl]` 单辅助键:`down(Ctrl)` → `up(Ctrl)` 触发 1 次。
|
||||
- `[Ctrl]` 单辅助键:`down(Ctrl)` → `up(Ctrl)` 触发 1 次,且 `info.context.holding` 等于 `[]`(唯一被按下的键已 `splice`,`ctrl` 布尔量仍是匹配时刻的 `true`)。
|
||||
- `[Ctrl, Shift]` 两辅助键:两键依次按下、依次抬起,handler 0 次。
|
||||
- `[KeyA, KeyB]` 两常规键:`addCommand` 时 `warn` 收到 `(203, 'x')`,组合仍可经 `getKeyCombination(0)` 读回;两键按下 / 抬起后 handler 0 次。
|
||||
- 空组合 `[[]]`:`warn` 未被调用,按下 / 抬起后 handler 0 次。
|
||||
@ -353,7 +364,7 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
|
||||
<action>
|
||||
**本任务只落地修正 2 与修正 4,并只追加条件 / 时序 / `excitedBy` 用例;不改 Task 1 已通过的任何断言与实现分支。**
|
||||
|
||||
(1) **新增私有方法 `isActive()`(OI-1 命名)**:放在 `emit` 之前(私有方法置于调用者之前,dev.md)。实现为三段:取 `this.getScope()`;`if (!scope) return false;`;`return scope.condition ? scope.condition() : true;`。只写一行 jsDoc 说明「当前栈顶作用域是否存在且其条件是否满足」。
|
||||
(1) **新增私有方法 `isActive()`(OI-1 命名)**:放在 `emit` 之前(私有方法置于调用者之前,dev.md)。实现为三段:取 `this.getScope()`;`if (!scope) return true;`(**无作用域返回 `true`** —— 私有 `emit` 在无作用域时本就 `return`,这样 `down` / `up` 的返回值与引入闸口前一致);`return scope.condition ? scope.condition() : true;`。只写一行 jsDoc 说明「当前栈顶作用域是否存在且其条件是否满足」。
|
||||
|
||||
(2) **`emit` 闸口(修正 2 ③)**:在 `emit` 内、既有 `if (!scope) return;` 之后、`const impl = scope.implements.get(command);` 之前插入 `if (!this.isActive()) return;`。这样 `addPending` 的立即触发、`up` 的 KeyUp 触发、`excited` 的 `waitTimeout` 与 `holdingCmds` 触发全部受同一闸口约束。
|
||||
|
||||
@ -366,46 +377,46 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
|
||||
(6) **`system.test.ts` 追加用例(每个 `it` 前一行 `//` 注释,插在 `listen` 用例之后、`describe` 结尾之前):**
|
||||
- `when` 条件为假时不触发:`Once` / `[KeyA]`,`let ok = false`,`when(() => ok)`;`down(KeyA)` 与 `up(KeyA)` 都返回 `false`、handler 0 次。
|
||||
- 条件恢复后重新按下可触发:接着上面的状态把 `ok` 置 `true`,再次 `down(KeyA)` / `up(KeyA)` → handler 1 次(证明条件在派发时刻求值,未建立的待触发项不会补发)。
|
||||
- 条件为假时不占用 KeyUp 批次:两个 `[KeyA]` KeyUp 指令(一个 `block: true` 优先级 10、一个优先级 5),条件为假时两者都不触发(handler 各 0 次);条件为真时两键流程按优先级各触发 1 次。
|
||||
- 条件为假时两条指令都不触发(`when` 是**作用域级**条件,无法只抑制其中一条):两条 `[KeyA]` KeyUp 指令(`block: true` 优先级 10 的 `high`、优先级 5 的 `low`,同一作用域),`when(() => ok)` 且 `ok = false`;`down(KeyA)` / `up(KeyA)` → `high` / `low` 各 0 次。随后 `ok = true`,**重新按下**(`down(KeyA)` / `up(KeyA)`)→ `high` 1 次、`low` **0 次**。注释必须写明:`high` 的 `block: true` 与优先级 10 会在 `toEmit` 排序后把 `blocking` 抬到 10,`low` 的优先级 5 `< 10` 直接 `break`,所以 **`low` 无论条件真假都是 0**;本例只能用来证明「条件为假时连 `high` 都不触发」,不能当作「只有 block 指令被抑制」的证据。若需要「低优先级也触发」的对照组,必须另起用例改用**同优先级**或**去掉 `block`**。
|
||||
- `Once + timeout` 未到期不触发、到期触发一次、之后不再触发:`once` 同 `timeout: 100`;`down(KeyA)` → 0;`excite(99)` → 0;`excite(100)` → 1;`excite(150)` → 1(修正 4 的直接回归)。
|
||||
- `Continuous + timeout 0` 的 `interval` / `dt`:`mode: Continuous`、`timeout: 0`、`interval: 50`、`dt: 200`;`down(KeyA)` → 1;`excite(40)` → 1;`excite(50)` → 2;`excite(100)` → 2;`excite(250)` → 3。
|
||||
- `Continuous` 在 `interval` / `dt` 都为 0 时每次激励都触发:`down` → 1;`excite(10)` → 2;`excite(20)` → 3。
|
||||
- 松键后 `Continuous` 停止:`down` → 1、`up` 后 `excite(10)` → 仍 1。
|
||||
- 未触发的 `Once` 松键后转入待触发并在 `excite(200)` 触发一次:`once` 同 `timeout: 100`;`down(KeyA)`、`up(KeyA)` → 0;`excite(200)` → 1;`excite(300)` → 仍 1(当前实现:松键不是取消,而是转入 `waitTimeout`;注释写明)。
|
||||
- KeyUp 延时项的 `context` 是匹配时刻快照:`keyUp` 同 `timeout: 100`;`down(KeyS)`、`up(KeyS)` → 0;`excite(200)` → 1,且 `info.context.holding` 等于 `[KeyS]`(含被抬起的主键,注释注明按当前实现)。
|
||||
- KeyUp 延时项的 `context.holding` 是活引用(`up` 先 `splice` 再入 `waitTimeout`):`keyUp` 同 `timeout: 100`;`down(KeyS)`、`up(KeyS)` → 0;`excite(200)` → 1,且 `info.context.holding` 等于 `[]`(不含被抬起的主键;注释写明「holding 是活引用,仅修饰键布尔量是匹配时刻快照」)。
|
||||
- 延时触发到期时条件为假 → 吞掉且不补发:`keyUp` 同 `timeout: 100`、`when(() => ok)`(初始 `true`);`down(KeyS)`、`up(KeyS)`;`ok = false`;`excite(200)` → 0;`ok = true`;`excite(300)` → 仍 0。
|
||||
- `excitedBy` 单源切换:`Continuous` / `timeout: 0`;绑定 first → `down` → 1;切换 second;`first.excite(10)` → 仍 1;`second.excite(10)` → 2。
|
||||
- `excitedBy(null)`:绑定后置 `null`,原激励源 `excite(10)` 不再驱动(计数不变),且 `down` / `up` 仍可正常触发。
|
||||
- 未绑定激励源时延时指令永不触发:`once` 同 `timeout: 100`、`down(KeyA)` → 0(不调用任何 `excite`)。
|
||||
- `info.command` 为对象且 `info` 字段完整:`Once` / `[KeyS]`,先 `down(Ctrl)` / `down(Shift)` 再 `down(KeyS)`;断言 `info.command.command === 'fire'`、`info.command.mode === HotkeyEmitMode.Once`、`info.index === 0`、`info.keys` 等于 `[KeyS]`、`info.context.holding` 等于 `[Ctrl, Shift, KeyS]`、`ctrl` / `shift` 为真、`alt` / `meta` 为假。
|
||||
- `info.command` 为对象且 `info` 字段完整:`Once` / `[KeyS]`,先 `down(Ctrl)` / `down(Shift)` 再 `down(KeyS)`;断言 `info.command.command === 'fire'`、`info.command.mode === HotkeyEmitMode.Once`、`info.index === 0`、`info.keys` 等于 `[KeyS]`、`info.context.holding` 等于 `[Ctrl, Shift, KeyS]`(KeyDown 派发时三键都还在 `holding` 中,活引用内容即 `[Ctrl, Shift, KeyS]`)、`ctrl` / `shift` 为真、`alt` / `meta` 为假。
|
||||
|
||||
(7) **假激励源**:`createExcitation()` 返回 `IExcitation<number>`,`payload: () => 0`(`start` / `lastEmit` 落在 0,便于用 `excite(p)` 推时间)、`excite(payload)` 遍历内部 `Set` 调 `excited(payload)`、`add` 返回带 `revoke` 的控制器并记录、`remove` 从集合删除并记录、`destroy` 清空。
|
||||
(7) **假激励源与补加导入**:本任务在 `system.test.ts` 的 import 段补上 `IExcitable` / `IExcitation` from `@motajs/animate`(Task 1 未导入,避免未使用导入)。`createExcitation()` 返回 `IExcitation<number>`,`payload: () => 0`(`start` / `lastEmit` 落在 0,便于用 `excite(p)` 推时间)、`excite(payload)` 遍历内部 `Set` 调 `excited(payload)`、`add` 返回带 `revoke` 的控制器并记录、`remove` 从集合删除并记录、`destroy` 清空。
|
||||
|
||||
(8) **`09-24-SUMMARY.md`**:记录 5 处修正的最终落点(文件 + 位置 + 改动前后写法)、按用户裁定保持现状的六项、两个测试文件的用例清单与旧用例的对应关系、OI-1 / OI-2 的批准情形、OI-3 的提问与用户回复(只有用户确认时才可写成「已确认缺陷」,否则只记录「已提问、按用户指示未修」)、四道闸门的实际结果、边界(未做浏览器 E2E、未接线 `packages-user/**`、未跑全量构建、旧实现与 `type.ts` 零改动)、以及 `estimate.tokens=70000` 为未校准估算(`confidence: low`)的声明。
|
||||
(8) **`09-24-SUMMARY.md`**:记录 5 处修正的最终落点(文件 + 位置 + 改动前后写法)、按用户裁定保持现状的六项、两个测试文件的用例清单与旧用例的对应关系、OI-1 / OI-2 的批准情形、OI-3 的提问与用户回复(只有用户确认时才可写成「已确认缺陷」,否则只记录「已提问、按用户指示未修」)、四道闸门的实际结果、边界(未做浏览器 E2E、未接线 `packages-user/**`、未跑全量构建、旧实现与 `type.ts` 零改动)、`context.holding` 的活引用口径与 `createContext` 未被改动、`Continuous + timeout > 0` 仍按 OI-3 留在现状、以及 `estimate.tokens=70000` 为**未校准估算**(`confidence: low`,修订前基线为已提交的 09-23 实现 `3fd339f`)的声明。
|
||||
|
||||
(9) 改动文件以 CRLF 保存,写完后执行 `cmd /c "pnpm exec prettier --write packages/system/src/hotkey/system.ts packages/system/src/hotkey/__test__/system.test.ts"`。
|
||||
</action>
|
||||
<behavior>
|
||||
- `isActive()`:无作用域 → `false`;`condition === null` → `true`;`condition()` 返回 `false` → 触发路径全部不执行且不占用 block。
|
||||
- `isActive()`:无作用域 → **`true`**(保持 `down` / `up` 返回值不变,`emit` 本就无作用域即返回);`condition === null` → `true`;`condition()` 返回 `false` → 触发路径全部不执行且不占用 block。
|
||||
- `down` 在条件为假时不建立待触发项(`down` 返回 `false`);条件恢复后重新 `down` 可正常触发一次。
|
||||
- `up` 在条件为假时不派发、不转投 `waitTimeout`;`emit` 在延时到期但条件为假时被抑制,且该 `waitTimeout` 项按既有代码出队(不补发)。
|
||||
- `excited`:`timeout: 100` 时 `payload 99` 不触发、`payload 100` 触发;`Continuous` 的第二次按 `interval`、其后按 `dt`。
|
||||
- `excited`:`Once` + `timeout: 100` 时 `payload 99` 不触发、`payload 100` 触发;`Continuous` + `timeout: 0` 的第二次按 `interval`、其后按 `dt`;`Continuous + timeout > 0` 不在本行为承诺内(OI-3,未裁定)。
|
||||
- `excitedBy` 只保留一个激励源;置 `null` 后旧源不再驱动、系统本身仍可用。
|
||||
- `info.command` 是对象(`command.command` 为 id、`command.mode` 为模式),`info.keys` 为触发组合,`info.context.holding` 含 KeyDown 触发键。
|
||||
- `info.command` 是对象(`command.command` 为 id、`command.mode` 为模式),`info.keys` 为触发组合;`info.context.holding` 是活引用 —— KeyUp 触发时不含被抬起的主键(`[Ctrl, KeyS]` → `[Ctrl]`,`[KeyS]` → `[]`),只有修饰键布尔量是匹配时刻快照。
|
||||
</behavior>
|
||||
<verify>
|
||||
<automated>cmd /c "pnpm exec vitest run packages/system/src/hotkey/__test__/command.test.ts packages/system/src/hotkey/__test__/system.test.ts"</automated>
|
||||
<fails_when>non-zero exit,或任一用例失败:`when` 条件未生效 / 条件恢复后补发 / `timeout` 边界(99 与 100)/ `interval` / `dt` 的触发次数 / 松键后仍触发 / `excitedBy` 双源同时驱动 / 延时 `context` 不是匹配时刻快照</fails_when>
|
||||
<fails_when>non-zero exit,或任一用例失败:`when` 条件未生效 / 条件恢复后补发 / `timeout` 边界(99 与 100)/ `interval` / `dt` 的触发次数 / 松键后仍触发 / `excitedBy` 双源同时驱动 / KeyUp 的 `context.holding` 不是「活引用、不含被抬起的主键」/ 条件 + block 用例把低优先级被 `break` 当成「条件抑制」的证据</fails_when>
|
||||
<automated>cmd /c "pnpm exec eslint packages/system/src/hotkey/system.ts packages/system/src/hotkey/__test__/system.test.ts"</automated>
|
||||
<fails_when>non-zero exit:存在 eslint 报告</fails_when>
|
||||
<automated>node -e "const fs=require('fs');const bad=[];const sys=fs.readFileSync('packages/system/src/hotkey/system.ts','utf8');if(!/private isActive\(\): boolean/.test(sys))bad.push('isActive() private method missing');if(!/scope\.condition \? scope\.condition\(\) : true/.test(sys))bad.push('isActive does not evaluate scope.condition()');const calls=(sys.match(/this\.isActive\(\)/g)||[]).length;if(calls<3)bad.push('isActive() is not gated at down/up/emit (found '+calls+')');if(sys.indexOf('dt >= command.timeout')<0)bad.push('excited first-trigger comparison is not dt >= timeout');if(sys.indexOf('dt < command.timeout')>=0)bad.push('old dt < timeout comparison still present');if(/pending\.emitted === 0 && dt >= command\.timeout[\s\S]{0,80}else if/.test(sys))bad.push('OI-3 branch was modified without approval');const t=fs.readFileSync('packages/system/src/hotkey/__test__/system.test.ts','utf8');for(const s of ['HotkeyEmitType','HotkeyEmitMode.Throttle','.throttle(','context.type','context.key','../index']){if(t.indexOf(s)>=0)bad.push('system.test.ts still references obsolete design token: '+s)}for(const s of ['when(','excitedBy(','excite(','timeout']){if(t.indexOf(s)<0)bad.push('system.test.ts lacks coverage for '+s)}if(t.indexOf('private isActive')>=0)bad.push('system.test.ts asserts a private member name it should not depend on');const lines=t.split(/\r?\n/);let its=0;lines.forEach((l,i)=>{if(/(^|[^A-Za-z.])it\(/.test(l)){its++;let j=i-1;while(j>=0&&lines[j].trim()==='')j--;if(j<0||!lines[j].trim().startsWith('//'))bad.push('system.test.ts:'+(i+1)+' it() without a preceding comment line')}});if(its<32)bad.push('system.test.ts has only '+its+' it() cases, expected at least 32 across Task 1 + Task 3');if(!fs.existsSync('.planning/phases/09-minimum-browser-runnable/09-24-SUMMARY.md'))bad.push('09-24-SUMMARY.md missing');if(bad.length){console.error('FINAL GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK final gate: condition gating wired at 3 sites, timeout comparison fixed, '+its+' system cases, SUMMARY present')"</automated>
|
||||
<fails_when>non-zero exit,或 stderr 含 "FINAL GATE FAILED":`isActive` 缺失或未引用 `scope.condition()` / 闸口少于 3 处 / `excited` 比较符未改或旧写法残留 / 未获批准就改了 OI-3 的分支 / 测试仍含旧设计 token / 缺少条件或激励覆盖 / 某 `it` 前无注释 / 用例少于 32 条 / SUMMARY 缺失</fails_when>
|
||||
<automated>node -e "const fs=require('fs');const bad=[];const sys=fs.readFileSync('packages/system/src/hotkey/system.ts','utf8');if(!/private isActive\(\): boolean/.test(sys))bad.push('isActive() private method missing');if(!/scope\.condition \? scope\.condition\(\) : true/.test(sys))bad.push('isActive does not evaluate scope.condition()');if(!/if \(!scope\) return true;/.test(sys))bad.push('isActive must return true when there is no scope (down/up return values must not change)');const calls=(sys.match(/this\.isActive\(\)/g)||[]).length;if(calls<3)bad.push('isActive() is not gated at down/up/emit (found '+calls+')');if(sys.indexOf('dt >= command.timeout')<0)bad.push('excited first-trigger comparison is not dt >= timeout');if(sys.indexOf('dt < command.timeout')>=0)bad.push('old dt < timeout comparison still present');if(/pending\.emitted === 0 && dt >= command\.timeout[\s\S]{0,80}else if/.test(sys))bad.push('OI-3 branch was modified without approval');const t=fs.readFileSync('packages/system/src/hotkey/__test__/system.test.ts','utf8');for(const s of ['HotkeyEmitType','HotkeyEmitMode.Throttle','.throttle(','context.type','context.key','../index']){if(t.indexOf(s)>=0)bad.push('system.test.ts still references obsolete design token: '+s)}for(const s of ['when(','excitedBy(','excite(','timeout']){if(t.indexOf(s)<0)bad.push('system.test.ts lacks coverage for '+s)}if(t.indexOf('private isActive')>=0)bad.push('system.test.ts asserts a private member name it should not depend on');const lines=t.split(/\r?\n/);let its=0;lines.forEach((l,i)=>{if(/(^|[^A-Za-z.])it\(/.test(l)){its++;let j=i-1;while(j>=0&&lines[j].trim()==='')j--;if(j<0||!lines[j].trim().startsWith('//'))bad.push('system.test.ts:'+(i+1)+' it() without a preceding comment line')}});if(its<32)bad.push('system.test.ts has only '+its+' it() cases, expected at least 32 across Task 1 + Task 3');if(!fs.existsSync('.planning/phases/09-minimum-browser-runnable/09-24-SUMMARY.md'))bad.push('09-24-SUMMARY.md missing');if(bad.length){console.error('FINAL GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK final gate: condition gating wired at 3 sites, timeout comparison fixed, '+its+' system cases, SUMMARY present')"</automated>
|
||||
<fails_when>non-zero exit,或 stderr 含 "FINAL GATE FAILED":`isActive` 缺失、未引用 `scope.condition()` 或无作用域未返回 `true` / 闸口少于 3 处 / `excited` 比较符未改或旧写法残留 / 未获批准就改了 OI-3 的分支 / 测试仍含旧设计 token / 缺少条件或激励覆盖 / 某 `it` 前无注释 / 用例少于 32 条 / SUMMARY 缺失</fails_when>
|
||||
<automated>node -e "const cp=require('child_process');const out=cp.spawnSync('cmd',['/c','pnpm exec vue-tsc --noEmit'],{encoding:'utf8',timeout:170000});const text=(out.stdout ? out.stdout : '')+(out.stderr ? out.stderr : '');const bad=[];if(out.error||out.status===null||out.signal)bad.push('vue-tsc did not run to completion: '+((out.error&&out.error.code)||out.signal||'status=null'));if(out.status!==0&&text.trim()==='')bad.push('vue-tsc exited with status '+out.status+' and produced no output - the command did not execute normally');const errs=text.split(/\r?\n/).filter(l=>/packages[\\/]system[\\/]src[\\/]hotkey[\\/](command|system|index|__test__)/.test(l));for(const e of errs)bad.push(e);const legacy=text.split(/\r?\n/).filter(l=>/packages[\\/]system[\\/]src[\\/]hotkey[\\/](hotkey|keyboard)\.ts/.test(l));if(legacy.length!==2)bad.push('expected exactly 2 known-baseline legacy diagnostics in hotkey.ts/keyboard.ts, got '+legacy.length+' - vue-tsc output is not the known baseline (false green?)');if(bad.length){console.error('TYPECHECK FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK hotkey implementation and tests type-check clean (vue-tsc exit '+out.status+'); 2 pre-existing legacy errors are out of scope')"</automated>
|
||||
<fails_when>non-zero exit,或 stderr 含 "TYPECHECK FAILED":`vue-tsc` 没能正常跑完(`out.error` / `out.status === null` / 被信号终止)、非零退出且无任何输出、本计划文件(`command.ts` / `system.ts` / `index.ts` / `__test__/**`)存在类型错误,或 `hotkey.ts` / `keyboard.ts` 的 legacy 诊断数不等于 2(输出不是已知基线)</fails_when>
|
||||
<automated>node -e "const fs=require('fs'),cp=require('child_process');const bad=[];const plan=fs.readFileSync('.planning/phases/09-minimum-browser-runnable/09-24-PLAN.md','utf8');const start=plan.indexOf('files_modified:');const end=plan.indexOf('autonomous:');if(start<0||end<0)bad.push('cannot read files_modified from the plan');const allow=start>=0&&end>start?plan.slice(start,end).split(/\r?\n/).slice(1).map(s=>s.replace(/^\s*-\s*/,'').trim()).filter(Boolean):[];if(allow.indexOf('packages/system/src/hotkey/system.ts')<0)bad.push('allowlist derived from files_modified is wrong');if(allow.indexOf('packages/system/src/hotkey/command.ts')<0)bad.push('allowlist derived from files_modified is wrong');const wipAny=['.planning/','docs/'];const g=cp.spawnSync('git',['status','--porcelain'],{encoding:'utf8'});if(g.status!==0)bad.push('git status failed: status='+g.status);const lines=(g.stdout ? g.stdout : '').split(/\r?\n/).filter(Boolean);for(const l of lines){const p=l.slice(3).trim();if(allow.some(a=>[p===a,a.startsWith(p),p.startsWith(a)].some(Boolean)))continue;if(wipAny.some(a=>p.startsWith(a)))continue;bad.push('out-of-scope change: '+p)}for(const p of ['packages/system/src/hotkey/type.ts','packages/system/src/hotkey/hotkey.ts','packages/system/src/hotkey/keyboard.ts','packages/system/src/hotkey/keyCodes.ts','packages/system/src/hotkey/index.ts','packages/common/src/logger.json','packages/system/package.json','pnpm-lock.yaml']){if(lines.some(l=>l.slice(3).trim()===p))bad.push('must not be modified: '+p)}if(bad.length){console.error('SCOPE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK scope gate: every change matches files_modified ('+allow.length+' entries)')"</automated>
|
||||
<fails_when>non-zero exit,或 stderr 含 "SCOPE GATE FAILED":工作树出现 `files_modified` 之外的改动(白名单由本计划派生;`docs/` 与 `.planning/` 为流程 / 文档产物例外),或受保护文件(`type.ts` / 旧实现 / `keyCodes.ts` / `index.ts` / `logger.json` / 依赖文件 / lockfile)被修改</fails_when>
|
||||
</verify>
|
||||
<done>`when` 条件在 `down` / `up` / `emit` 三处真实生效(条件为假时不建立待触发项、不算 block、不调实现;延时到期被抑制且不补发);`excited` 首次触发改为 `dt >= command.timeout`(99 不触发 / 100 触发);`Continuous` 的 `interval` / `dt` 与 `Once` 的 `timeout` 行为被用例锁定;`excitedBy` 单源切换与 `null` 被覆盖;`system.test.ts` 总计至少 32 条用例全绿;eslint、`FINAL GATE`、类型闸门(本计划文件 0 条诊断、legacy 恰 2 条)、范围闸门全部通过;`09-24-SUMMARY.md` 已生成并如实记录修正落点、保持现状的六项、OI-3 的裁定情形与边界</done>
|
||||
<done>`when` 条件在 `down` / `up` / `emit` 三处真实生效(条件为假时不建立待触发项、不算 block、不调实现;延时到期被抑制且不补发;`isActive()` 无作用域返回 `true`,`down` / `up` 返回值不变);`excited` 首次触发改为 `dt >= command.timeout`(`Once` 的 99 不触发 / 100 触发);`Continuous + timeout === 0` 的 `interval` / `dt` 行为被用例锁定,`Continuous + timeout > 0` 按 OI-3 保持现状且不写断言;`excitedBy` 单源切换与 `null` 被覆盖;KeyUp 的 `context.holding` 按活引用口径断言(不含被抬起的主键);`system.test.ts` 总计至少 32 条用例全绿;eslint、`FINAL GATE`、类型闸门(本计划文件 0 条诊断、legacy 恰 2 条)、范围闸门全部通过;`09-24-SUMMARY.md` 已生成并如实记录修正落点、保持现状的六项、OI-3 的裁定情形与边界
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
@ -427,7 +438,7 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-09-24-01 | Elevation of privilege | `when` 条件(`isActive` 在 `down` / `up` / `emit` 的闸口) | high | mitigate | 三处闸口缺一不可:`down` 不建 pending、`up` 不算 block 不派发、`emit` 兜住延时路径;用例覆盖「条件为假不触发」「条件恢复后重新按下才触发」「延时到期条件为假 → 吞掉不补发」「条件为假时 block 指令不占用优先级」 |
|
||||
| T-09-24-01 | Elevation of privilege | `when` 条件(`isActive` 在 `down` / `up` / `emit` 的闸口) | high | mitigate | 三处闸口缺一不可:`down` 不建 pending、`up` 不算 block 不派发、`emit` 兜住延时路径;用例覆盖「条件为假不触发」「条件恢复后重新按下才触发」「延时到期条件为假 → 吞掉不补发」「条件为假时连 `block: true` 的高优先级指令也不触发;条件恢复后只有高优先级触发,低优先级是被 `block` 的 `break` 抑制(与条件无关)」 |
|
||||
| T-09-24-02 | Tampering | `matchKeyUp` 的辅助键 / 常规键拆分 | medium | mitigate | 解构顺序由 `matchArray` 的 `[match, unmatch]` 契约决定,改为 `[auxiliary, main]`;用例覆盖四条 KeyUp 规则与 203;结构闸门同时断言新写法存在、旧写法不存在 |
|
||||
| T-09-24-03 | Denial of service | 无参 `leaveScope` 误发 205 后继续执行 | medium | mitigate | 无参分支 `pop` + 两次 `clear` 后立即 `return;`;用例断言「无参退出不告警、只弹一级」,空栈仍 205;结构闸门断言 `pop()` 后 160 字符内出现 `return;` |
|
||||
| T-09-24-04 | Tampering | 权威设计与旧实现被误改 | high | mitigate | `type.ts` / `hotkey.ts` / `keyboard.ts` / `keyCodes.ts` / `index.ts` / `logger.json` / 依赖文件 / lockfile 全部由 `git status --porcelain` 零改动闸门守护;范围闸门由 `files_modified` 派生白名单 |
|
||||
@ -440,7 +451,7 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
|
||||
- **Task 0**:`PRESTATE` 闸门通过(5 处缺陷仍在、旧测试仍是旧设计、warn 201–207 齐全);用户明确回复「可以执行」(含 OI-1 命名与 OI-3 处置的裁定)。
|
||||
- **Task 1**:`system.test.ts` 全绿;eslint;`TRACER GATE`(`matchKeyUp` 解构正确且旧写法消失、无参 `leaveScope` 有 `return`、测试无旧设计 token、只从 `../system` 导入、未提前写激励用例、`it` 前注释、≥18 条);`LEGACY GATE`。
|
||||
- **Task 2**:两个测试文件全绿;eslint;`COMMAND GATE`(`setKeys` 只拦负数、207 两分支保留、测试无旧设计 token、含 `setKeys(0)` 与 203 断言、`it` 前注释、≥12 条);`LEGACY GATE`。
|
||||
- **Task 3**:两个测试文件全绿(`system.test.ts` ≥ 32 条);eslint;`FINAL GATE`(`isActive` 三处闸口 + `scope.condition()` 读、`dt >= timeout`、OI-3 分支未改、测试无旧设计 token、含 `when` / `excite` / `excitedBy` / `timeout` 覆盖、SUMMARY 存在);类型闸门(`vue-tsc --noEmit`,先校验 `spawnSync` 的 `out.error` / `out.status` / `out.signal`;本计划文件 0 条诊断;`hotkey.ts` / `keyboard.ts` 恰 2 条 legacy 作为输出完整的基线证据);`SCOPE GATE`(白名单由 `files_modified` 派生)。
|
||||
- **Task 3**:两个测试文件全绿(`system.test.ts` ≥ 32 条);eslint;`FINAL GATE`(`isActive` 三处闸口 + `scope.condition()` 读 + 无作用域返回 `true`、`dt >= timeout`、OI-3 分支未改、测试无旧设计 token、含 `when` / `excite` / `excitedBy` / `timeout` 覆盖、SUMMARY 存在);类型闸门(`vue-tsc --noEmit`,先校验 `spawnSync` 的 `out.error` / `out.status` / `out.signal`;本计划文件 0 条诊断;`hotkey.ts` / `keyboard.ts` 恰 2 条 legacy 作为输出完整的基线证据);`SCOPE GATE`(白名单由 `files_modified` 派生)。
|
||||
- **范围诚实**:不跑 `build:game` / `build:packages`,不修 `hotkey.ts` / `keyboard.ts` 的 2 条 legacy 类型错误,不接线 `packages-user/**`,不做浏览器 E2E;SUMMARY 须如实记录这些边界,不得表述为「浏览器端已验证」;**`estimate.tokens=70000` 是未校准估算(`confidence: low`),SUMMARY 须显式声明**。
|
||||
- **既有注释**:`system.ts` / `command.ts` / `type.ts` / `hotkey.ts` / `keyboard.ts` / `keyCodes.ts` 的既有注释与 jsDoc 零修改(`matchKeyUp` 的「抬起按键仅允许一个非辅助按键」在修正后依然成立)。
|
||||
</verification>
|
||||
@ -450,7 +461,8 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
|
||||
- `command.ts`:`setKeys` 的守卫改为 `count < 0`,`count === 0` 合法(`length = 0`、无 207),非整数 / 负数仍发 207,`logger.json` 文案不改。
|
||||
- `system.test.ts`:按重写后的设计整体重写并覆盖注册表 / 作用域 / KeyUp 四条规则 / holding 与子集 / 优先级与 block / `when` 条件 / `excited` 的 timeout·interval·dt / `excitedBy` / `listen` / 返回值,≥ 32 条用例全绿,每个 `it` 前一行注释,只从 `../system` 导入。
|
||||
- `command.test.ts`:按新设计整体重写并覆盖构造缺省与拷贝 / `isAuxiliaryKey` / `setKeys` 0·负·非整数 / 206 三态 / 203 与模式守卫 / 读取与迭代 / 模式方法三参,≥ 12 条用例全绿,只从 `../command` 导入。
|
||||
- 按用户裁定保持现状的六项(`down` / `up` 返回值、KeyUp 的 `context` 快照、`priority` / `block` 仅抬起生效、`waitTimeout` 计时起点、未实现指令的待触发项、`excited` 分支结构)未被改动,测试按现状断言并注明口径。
|
||||
- 按用户裁定保持现状的六项(`down` / `up` 返回值、`context.holding` 的活引用与修饰键快照(`createContext` 不改)、`priority` / `block` 仅抬起生效、`waitTimeout` 计时起点、未实现指令的待触发项、`excited` 分支结构)未被改动,测试按现状断言并注明口径;KeyUp 的 `info.context.holding` 断言为 `[Ctrl]` / `[]`(不含被抬起的主键)。
|
||||
- 修正 4 的承诺范围限于 `Once + timeout > 0` 与 `Continuous + timeout === 0`;`Continuous + timeout > 0` 按 OI-3 记录为未裁定、保持现状且无断言。
|
||||
- 受保护文件(`type.ts` / 旧实现 / `keyCodes.ts` / `index.ts` / `logger.json` / `package.json` / lockfile)零改动;OI-3 未被当作已确认缺陷、未被顺手修复。
|
||||
- `09-24-SUMMARY.md` 记录修正落点、保持现状的六项、测试清单与旧用例对应关系、OI-1 / OI-2 / OI-3 的裁定情形、四道闸门结果、边界,并声明 `estimate` 为未校准估算。
|
||||
</success_criteria>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user