Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4e16b2e
test: inject foreground-process query seam
JsonBorn98 Sep 1, 2026
9087bc9
refactor: deepen the Path C engine into a class module
JsonBorn98 Sep 1, 2026
995de9e
Merge branch 'ticket-01-pathc-engine' into architecture-deepening
JsonBorn98 Sep 1, 2026
e229f50
refactor: collapse config working copy into ConfigStore
JsonBorn98 Sep 1, 2026
6151882
refactor: capture completion behind an adapter
JsonBorn98 Sep 1, 2026
aa9332f
refactor: unify mapping schema and path classification
Sep 1, 2026
7ea6b52
Merge branch 'ticket-03-schema' into architecture-deepening
JsonBorn98 Sep 1, 2026
4f592cc
refactor: give rendering a seam
JsonBorn98 Sep 1, 2026
ad18b9a
docs: mark architecture-deepening tickets done (all six landed)
JsonBorn98 Sep 1, 2026
afe3566
fix: break render-seam recursion and address review findings
JsonBorn98 Sep 1, 2026
f699e57
fix: stage config create/copy writes through a temp file (atomic write)
JsonBorn98 Sep 2, 2026
3521886
ci: bound upload-artifact steps and record review follow-ups
JsonBorn98 Sep 2, 2026
2452cbc
ci: drop the hung upload-artifact step from the integration job
JsonBorn98 Sep 2, 2026
c10e15d
ci: bisect the integration-job wedge with per-file steps and node can…
JsonBorn98 Sep 2, 2026
142cc57
ci: run integration tests in log-file mode (wedge fix, round 3)
JsonBorn98 Sep 2, 2026
f8bf26e
ci: use absolute log paths in integration steps (fix Get-Content fail…
JsonBorn98 Sep 2, 2026
81f69b7
ci: pass absolute script paths and instrument integration steps
JsonBorn98 Sep 2, 2026
e603dc6
ci: isolate hotkey_pathAB on its own runner (wedge containment)
JsonBorn98 Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 92 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ jobs:
with:
name: test-results-unit
path: test-results
# test-results are diagnostics only; test-summary degrades gracefully
# when an artifact is missing. The bound stops a hung upload step from
# eating the whole job budget (seen 6/6 on this branch's integration
# job: upload completes server-side, the step process never exits).
timeout-minutes: 2
continue-on-error: true

test-integration:
needs: validate
Expand All @@ -123,20 +129,90 @@ jobs:
if (-not $baseFile) { throw "AutoHotkey64.exe not found" }
"AHK_BASE_FILE=$($baseFile.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append

- name: Run integration tests
# Wedge containment, round 5. Evidence matrix across 11 runs: a runner
# that has run hotkey_pathAB.test.ahk to completion wedges the next
# NODE process on it (upload-artifact 7/7 pre-bisect; Post Checkout in
# run 33657438288) below the step/job timeout machinery; in stdout-pipe
# mode the pathAB AHK process hangs instead (run 33652351022). Runs
# completing only config_io or engine_state never wedged a node
# process (runs 33654770247/33656838250 Post Checkout green, canaries
# green). pathAB therefore runs isolated on its own runner in the
# test-integration-pathab job; this job keeps config_io + engine_state
# (engine_state last) and keeps gating build. All steps use the
# invocation proven end-to-end (test.ps1's Invoke-TestProcess, local
# probes): absolute script AND log paths, log-file mode (no AHK stdout
# writes). No upload step: it hung in the post-upload phase on 7/7
# pre-bisect runs; test-summary degrades this suite to "No results".
- name: "Run integration test file: config_io.test.ahk"
shell: pwsh
run: |
.\scripts\test.ps1 `
-Suite integration `
-Ci `
-AutoHotkeyPath $env:AHK_BASE_FILE
$log = Join-Path $env:GITHUB_WORKSPACE 'test-results\logs\integration\config_io.test.log'
$script = Join-Path $env:GITHUB_WORKSPACE 'tests\integration\config_io.test.ahk'
New-Item -ItemType Directory -Force (Split-Path $log) | Out-Null
$env:AHKM_TEST_LOG_FILE = $log
& $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' $script
$rc = $LASTEXITCODE
Write-Host "AHK exit code: $rc; log exists: $(Test-Path $log)"
if (Test-Path $log) { Get-Content $log | Select-Object -Last 3 }
exit $rc

- name: "Run integration test file: hotkey_engine_state.test.ahk"
shell: pwsh
run: |
$log = Join-Path $env:GITHUB_WORKSPACE 'test-results\logs\integration\hotkey_engine_state.test.log'
$script = Join-Path $env:GITHUB_WORKSPACE 'tests\integration\hotkey_engine_state.test.ahk'
New-Item -ItemType Directory -Force (Split-Path $log) | Out-Null
$env:AHKM_TEST_LOG_FILE = $log
& $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' $script
$rc = $LASTEXITCODE
Write-Host "AHK exit code: $rc; log exists: $(Test-Path $log)"
if (Test-Path $log) { Get-Content $log | Select-Object -Last 3 }
exit $rc

# Isolation cell for hotkey_pathAB.test.ahk (see the evidence matrix on
# test-integration): a runner that completes this file wedges its next
# node process (upload-artifact, Post Checkout) below the timeout
# machinery, dragging the job to a 15m force-cancel. On its own runner
# with job-level continue-on-error the wedge burns this job only; the
# step conclusion stays visible in the timeline and the test still runs
# on every push/PR. It intentionally does NOT gate build; config_io +
# engine_state carry the integration gate.
test-integration-pathab:
needs: validate
runs-on: windows-2025
timeout-minutes: 10
continue-on-error: true

- name: Upload test results
if: always()
uses: actions/upload-artifact@v6
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Restore toolchain cache
uses: actions/cache/restore@v5
with:
name: test-results-integration
path: test-results
path: .ahk-toolchain
key: ${{ env.TOOLCHAIN_CACHE_KEY }}
fail-on-cache-miss: true

- name: Resolve runtime path
shell: pwsh
run: |
$baseFile = Get-ChildItem -Path .ahk-toolchain -Recurse -Filter AutoHotkey64.exe | Select-Object -First 1
if (-not $baseFile) { throw "AutoHotkey64.exe not found" }
"AHK_BASE_FILE=$($baseFile.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append

- name: "Run integration test file: hotkey_pathAB.test.ahk"
shell: pwsh
run: |
$log = Join-Path $env:GITHUB_WORKSPACE 'test-results\logs\integration\hotkey_pathAB.test.log'
$script = Join-Path $env:GITHUB_WORKSPACE 'tests\integration\hotkey_pathAB.test.ahk'
New-Item -ItemType Directory -Force (Split-Path $log) | Out-Null
$env:AHKM_TEST_LOG_FILE = $log
& $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' $script
$rc = $LASTEXITCODE
Write-Host "AHK exit code: $rc; log exists: $(Test-Path $log)"
if (Test-Path $log) { Get-Content $log | Select-Object -Last 3 }
exit $rc

test-gui:
needs: validate
Expand Down Expand Up @@ -175,6 +251,12 @@ jobs:
with:
name: test-results-gui
path: test-results
# test-results are diagnostics only; test-summary degrades gracefully
# when an artifact is missing. The bound stops a hung upload step from
# eating the whole job budget (seen 6/6 on this branch's integration
# job: upload completes server-side, the step process never exits).
timeout-minutes: 2
continue-on-error: true

build:
needs: [validate, test-unit, test-integration, test-gui]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 01 — Deepen the Path C engine

Status: ready-for-agent
Status: resolved

Depends on: none — land first; tickets 03/04 rewire the shapes this one creates, so every other ticket assumes it exists.

Expand Down Expand Up @@ -51,6 +51,8 @@ Internals: a `PathCSession` class whose constructor is the invariant (`state`, `

> *This was generated by AI during triage.*

Landed in PR #2 (commit 9087bc9).

## Agent Brief

**Category:** enhancement
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 02 — Collapse the config working copy into a config store

Status: ready-for-agent
Status: resolved

Depends on: [01](01-deepen-path-c-engine.md) (soft — both edit the HotkeyEngine skeleton, the `AHKeyMap.ahk` globals block, and the test base's state reset; landing 01 first avoids rebasing onto its surgery). Logically this ticket could stand alone; the dependency is churn-avoidance.

Expand Down Expand Up @@ -52,6 +52,12 @@ ConfigStore.Instance.DeleteConfig() ; absorbs the FileDelete in

> *This was generated by AI during triage.*

Addendum (2026-09-02 review, commit afe3566): `CreateConfig`/`CopyConfig` gained try-wrapped writes with a failure MsgBox (`Config.CreateError`/`Config.CopyError` localization keys) and partial-file cleanup. Not in the original decisions; endorsed post-hoc as the repo-standard "IniWrite in try" pattern surfaced at the create/copy seam.

Follow-up fix (post-afe3566, atomic write): the same two methods now stage into `<name>.ini.tmp` and `FileMove` onto the final path, per decision 3's "atomic config write" wording and CLAUDE.md's atomic-write rule. `Select`'s direct single-key `LastConfig` write stays as accepted status quo — migrated behavior; an atomic single-key update would duplicate `SaveEnabledStates`' read-modify-write shape.

Landed in PR #2 (commit e229f50).

## Agent Brief

**Category:** enhancement
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 03 — One mapping schema, one path rule

Status: ready-for-agent
Status: resolved

Depends on: [01](01-deepen-path-c-engine.md) (soft — the Path C guard this ticket rewires moves into the engine in 01), [02](02-collapse-config-working-copy.md) (soft — the editor's mapping-construction call changes shape in 02). The content adapts to either shape; landing last avoids editing intermediate code twice. All three tickets also touch `HotkeyEngine.ahk` and the test base.

Expand Down Expand Up @@ -58,6 +58,8 @@ Constructor invariants, enforced at **every** construction site including INI lo

> *This was generated by AI during triage.*

Landed in PR #2 (commit aa9332f).

## Agent Brief

**Category:** enhancement
Expand Down
4 changes: 3 additions & 1 deletion .scratch/architecture-deepening/issues/04-rendering-seam.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 04 — Give rendering a seam

Status: ready-for-agent
Status: resolved

Depends on: [01](01-deepen-path-c-engine.md) (**hard** — the design requires `PathCEngine.Commit()` to return registration errors), [02](02-collapse-config-working-copy.md) (**hard** — rendering triggers through `ConfigStore.OnChanged` and reads store state), [03](03-one-mapping-schema.md) (soft — sequential churn only). Without 01+02 this ticket's interfaces have nothing to attach to.

Expand Down Expand Up @@ -45,6 +45,8 @@ Rendering is smeared as side effects across domain modules, with dependencies ru

> *This was generated by AI during triage.*

Landed in PR #2 (commit 4f592cc).

## Agent Brief

**Category:** enhancement
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 05 — KeyCapture completion adapter

Status: ready-for-agent
Status: resolved

Depends on: [02](02-collapse-config-working-copy.md) (soft — both edit `MappingEditor.ahk`, in different functions). Otherwise independent; may run parallel to the 01→02 spine if conflicts on the shared file are acceptable. Patch bump from whatever landed last (`2.9.7` if landed in sequence).

Expand Down Expand Up @@ -33,6 +33,10 @@ KeyCapture reaches into MappingEditor's widgets: `ApplyCapturedKey` (`KeyCapture

> *This was generated by AI during triage.*

Deviation from decision 1 (2026-09-02 review): the completion callback is a session-scoped global `CaptureOnCaptured` (set in `StartCapture`, taken-and-cleared before firing in `FinishCapture`, cleared in `CancelCapture`) rather than a closure carried by the session. Reason: `FinishCapture` is reached from both the polling timer and the mouse-wheel hook, and a closure cannot span both entry points. Invariant: the slot is set per capture call, cleared on finish AND cancel, and fired exactly once.

Landed in PR #2 (commit 6151882).

## Agent Brief

**Category:** enhancement
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 06 — Inject the foreground-process query

Status: ready-for-agent
Status: resolved

Depends on: none — land any time, including in parallel with any other ticket (the only collisions are the two version lines and the globals block in `AHKeyMap.ahk`, trivially resolved). Patch bump from whatever landed last (`2.9.8` if landed in sequence).

Expand Down Expand Up @@ -32,6 +32,8 @@ Depends on: none — land any time, including in parallel with any other ticket

> *This was generated by AI during triage.*

Landed in PR #2 (commit 4e16b2e).

## Agent Brief

**Category:** enhancement
Expand Down
5 changes: 5 additions & 0 deletions .scratch/architecture-deepening/shoals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## Shoals
- [无头测试全绿不等于 GUI 模式安全](.scratch/architecture-deepening/issues/04-rendering-seam.md) — 渲染路径改动必须单独跑 gui 套件验证:OnChanged 无限递归只在 GUI 模式触发,无头测试全绿掩盖了它(GUI 套件挂死 27 分钟才定位);修复 afe3566 确立的不变量是渲染只读状态、永不写 store,后续动渲染层别再让渲染回调写 store
- [CI upload-artifact 步骤挂死=测试留下孤儿进程](https://github.com/JsonBorn98/AHKeyMap/pull/2) — 诊断签名:test 步骤秒过、summary 全 passed,挂死的是之后的 upload-artifact 步骤且产物创建正常=integration 测试(最可能 hotkey_engine_state.test.ahk)在 CI 真实桌面留了不退出的 hook/timer/弹窗类孤儿进程,其继承句柄让 node 进程无法退出;本地沙箱复现不出(本地 3.5 秒全绿无残留),改 integration 测试时先在真实桌面查残留进程
- [upload-artifact 挂死:step 级 timeout 围栏无效,处置是删步骤](.github/workflows/ci.yml) — 修正上一条的孤儿进程机制论(已证伪:测试进程 WaitForExit 返回、exit 0、无子进程);真实签名=upload-artifact 服务端 1 秒完整上传后 node 进程不退出、runner 楔死在超时机制之下(step timeout-minutes: 2 实测不触发,job 15 分钟被强杀、日志被清不可观察);处置=删掉该 job 的 upload 步骤而非加围栏,改 integration 测试后若要恢复上传,先在 CI 用 debug logging 拿挂死现场
- [AHK 相对路径按 A_WorkingDir(脚本目录)解析,坑调用方传相对路径](.github/workflows/ci.yml) — AHK 把相对路径解析到 A_WorkingDir=脚本文件所在目录,不是启动它的 shell 的工作目录;给 AHKM_TEST_LOG_FILE 传相对路径时文件落在 tests\integration\ 下而调用方按自己的 cwd 找——调用 AHK 的任何路径参数都必须用绝对路径(test.ps1 的 Invoke-TestProcess 一直这么干所以从没踩过)
2 changes: 2 additions & 0 deletions .scratch/architecture-deepening/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ A deep module in this repo is an AHK v2 `class` in its own file under `src/core/

## Tickets

All six landed on branch `architecture-deepening` (PR #2). Landed order: 06 (4e16b2e) → 01 (9087bc9) → 02 (e229f50) → 05 (6151882) → 03 (aa9332f) → 04 (4f592cc). Final version 2.9.10: the per-ticket plan said 2.9.8, then two post-land review-fix patch bumps (afe3566 render-recursion fix → 2.9.9; CreateConfig/CopyConfig atomic-write fix → 2.9.10).

- `issues/01-deepen-path-c-engine.md` — Depends on: none. Land first.
- `issues/02-collapse-config-working-copy.md` — Depends on: 01 (soft).
- `issues/03-one-mapping-schema.md` — Depends on: 01 (soft), 02 (soft).
Expand Down
49 changes: 37 additions & 12 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,15 @@ Audience: coding agents working on AHKeyMap.
## Repo map
```text
src/AHKeyMap.ahk — globals, constants, #Include list, StartApp()
src/core/Config.ahk — config/state INI I/O and atomic writes
src/shared/Schema.ahk — mapping/config record schema (static namespaces: construction, normalization, path rule)
src/core/Config.ahk — pure config/state INI I/O and atomic writes
src/core/ConfigStore.ahk — config working copy owner: AllConfigs, selection, mutation chokepoint, OnChanged slot
src/core/Localization.ahk — `L(key, args*)`, `BuildEnPack()`, `BuildZhPack()`
src/core/HotkeyEngine.ahk — Path A/B/C registration, conflicts, process checkers
src/core/PathCEngine.ahk — Path C engine (sessions, routing, repeat timers, own hotkey registration)
src/core/HotkeyEngine.ahk — Path A/B registration, conflicts, process checkers
src/core/KeyCapture.ahk — key capture via polling + mouse hook
src/shared/Utils.ahk — key formatting, process picker, auto-start helpers
src/ui/GuiMain.ahk — main window, tray menu, modal helpers
src/ui/GuiMain.ahk — main window, tray menu, modal helpers, render-from-state layer (pure view-model builders + widget writes)
src/ui/MappingEditor.ahk — mapping edit dialog
src/ui/GuiEvents.ahk — config/mapping CRUD and scope editing
tests/support/TestBase.ahk — assertions, sandbox reset, send capture
Expand Down Expand Up @@ -80,14 +83,17 @@ AutoHotkey64.exe /ErrorStdOut=UTF-8 tests\unit\scope_logic.test.ahk
## Include / import rules
- `src/AHKeyMap.ahk` owns the entire `#Include` list. Do not add cross-includes from leaf modules.
- Include order follows dependency flow:
1. `core/Config.ahk`
2. `shared/Utils.ahk`
3. `core/Localization.ahk`
4. `core/HotkeyEngine.ahk`
5. `core/KeyCapture.ahk`
6. `ui/GuiMain.ahk`
7. `ui/MappingEditor.ahk`
8. `ui/GuiEvents.ahk`
1. `shared/Schema.ahk`
2. `core/Config.ahk`
3. `core/ConfigStore.ahk`
4. `shared/Utils.ahk`
5. `core/Localization.ahk`
6. `core/PathCEngine.ahk`
7. `core/HotkeyEngine.ahk`
8. `core/KeyCapture.ahk`
9. `ui/GuiMain.ahk`
10. `ui/MappingEditor.ahk`
11. `ui/GuiEvents.ahk`
- Only `src/AHKeyMap.ahk` initializes globals with `:=`; other modules may declare `global VarName` but must not reinitialize shared state.

## Code style
Expand All @@ -106,7 +112,9 @@ AutoHotkey64.exe /ErrorStdOut=UTF-8 tests\unit\scope_logic.test.ahk

### Types and shared state
- Use `Map()` for keyed records and arrays for ordered collections.
- Config records and mappings are `Map()`-based; clone entries with `for k, v in old`, not direct assignment.
- Mapping/config records are `Map()`-based and constructed through `src/shared/Schema.ahk` (`Mapping.Make`, `ConfigRecord.Make`); never restate the field list inline.
- `Mapping.Normalize` enforces the record invariants (7-key whitelist, integer coercion, defaults, min-10 repeat timing); `Mapping.ClassifyPath`/`Mapping.HotkeyStringFor` own the path rule and hotkey-string derivation; `Mapping.ToIniPairs` owns the serialization field list.
- Clone entries with `for k, v in old`, not direct assignment.
- Prefer in-place mutation (`.Length := 0`, `.Push(...)`) over replacing shared arrays/maps.
- Coerce numeric INI values with `Integer()` on load.
- Process lists are stored as `|`-delimited strings in INI and parsed into arrays in memory.
Expand All @@ -131,10 +139,27 @@ AutoHotkey64.exe /ErrorStdOut=UTF-8 tests\unit\scope_logic.test.ahk

### Hotkey and scope conventions
- The engine uses three paths: Path A (no modifier), Path B (intercept combo), Path C (passthrough combo with session state).
- Path C lives in `src/core/PathCEngine.ahk`; production code uses the lazy singleton `PathCEngine.Instance`, and only its `Commit()`/`Reset()` methods touch `Hotkey()`.
- Process scope priority is `include > exclude > global`; an empty include/exclude list effectively behaves as global.
- Preserve Path C wheel-routing and `RButton` gesture behavior.
- Keep `AllProcessCheckers` references alive for closure lifetime.

### Config store conventions
- `src/core/ConfigStore.ahk` owns the config working copy: the `AllConfigs` array, the current selection (`ConfigStore.Instance.SelectedName`), and every mutation.
- Read the selected config via `ConfigStore.Instance.Selected()` / `SelectedMappings()`; never keep a mirrored set of `Current*` globals.
- Every mutation goes through one store method (`Select`, `SetEnabled`, `SetScope`, `AddMapping`, `ReplaceMapping`, `DeleteMapping`, `CreateConfig`, `CopyConfig`, `DeleteConfig`); each ends in the same chokepoint: atomic persist (`SaveConfig` + `SaveEnabledStates`) → `ReloadAllHotkeys()` → `OnChanged(reloadResult)`. `Select` is render-only and notifies with `""`.
- GUI handlers shrink to input validation plus one store call; they must not persist or reload on their own.
- `src/core/Config.ahk` is pure INI I/O; it owns no selection state and no render code.
- Tests reset the store with `ResetConfigStoreForTests()` (TestBase calls it from `ResetAppState`).

### Rendering seam conventions
- `ConfigStore.OnChanged` is the only core→ui data flow: core fires it, ui registers it. `BuildMainGui()` registers `RenderFromState` at startup; nothing in `src/core/` may reference GUI controls or ui functions.
- `RenderFromState(reloadResult)` in `src/ui/GuiMain.ahk` is the single render entry; it stores the reload result in the ui-owned `LastReloadResult` global and refreshes dropdown, scope controls, mapping list, and status bar.
- Rendering only runs when the main window exists (`RenderFromState` returns early headless); there are no `StatusText = ""`-style test guards.
- Pure view-model builders (`BuildStatusSummary`, `BuildMappingRows`, `BuildStatusDetails`, `FormatProcessDisplay`) take data and return view models; unit-test those, not the widgets. Widget writes stay in the thin `Refresh*`/`UpdateStatusText` functions.
- Engine output is returned, never stored in globals: `ReloadAllHotkeys()` returns `{conflicts, regErrors}`; `DetectHotkeyConflicts` and `PathCEngine.Commit()` return their arrays. `OnStatusTextClick` reads `LastReloadResult`.
- `StatusHasWarning` is written by the status render and read only by ui hover handlers.

## Common pitfalls
- `global Foo := value` inside a module overwrites the main-entry value at `#Include` time.
- Forgetting to reset `HotIf()` leaks scope to later hotkeys.
Expand Down
Loading
Loading