diff --git a/.gitignore b/.gitignore index 6f044d2..c4d8c1c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,9 @@ obj/ dist/ __pycache__/ .venv/ + +# Local handoff prompt +/给接手人的交接话术.md + +# Local intake scratch directory +/temp/ diff --git a/docs/n12r-internal-operator-minimal-retry-2026-07-09.md b/docs/n12r-internal-operator-minimal-retry-2026-07-09.md new file mode 100644 index 0000000..a9ebeff --- /dev/null +++ b/docs/n12r-internal-operator-minimal-retry-2026-07-09.md @@ -0,0 +1,341 @@ +# N12R 内网操作员最小重做步骤 + +日期:2026-07-09 + +适用对象:内网测试沙盒操作员。 + +目的:修正本次 N12R 返回包中 `selected-window.json` 缺失和 `dump_uia` 失败的问题,重新生成一个可被外网校验接受的 N12R 采集包。 + +## 1. 本次失败原因 + +本次返回包被外网校验拒收,核心原因是窗口句柄写错了。 + +日志中写成了: + +```powershell +$selectedHwnd = "<0x290E6E>" +``` + +这是错误写法。`` 里的尖括号只是提示格式,实际填写时必须删掉。 + +正确写法是: + +```powershell +$selectedHwnd = "0x290E6E" +``` + +因为多了尖括号,后续窗口匹配失败: + +```text +selected hwnd not found in scan result: <0x290E6E> +``` + +所以: + +1. `windows/selected-window.json` 没有生成。 +2. `dump_uia` 使用了无效 hwnd。 +3. `uia/dump-uia-main.json` 返回 `WINDOW_NOT_FOUND`。 + +## 2. 重做前确认 + +在内网测试沙盒中打开 PowerShell,进入仓库根目录: + +```powershell +cd E:\isphere-ai-bridge +``` + +确认 iSphere / IMPlatformClient 已经人工登录,并且主窗口可见、没有最小化。 + +不要执行以下动作: + +```text +搜索联系人 +打开会话 +发送消息 +发送文件 +接收/下载文件 +自动登录 +注入 +hook +读取内存 +绕过安防 +``` + +## 3. 推荐做法:重新运行 scan_windows + +窗口句柄可能已经变化,所以推荐先重新扫描窗口。 + +如果沿用原采集编号: + +```powershell +$captureId = "2026-07-08-internal-sandbox-a" +$captureRoot = "runs\internal-sandbox-live-capture\$captureId" +$helper = Resolve-Path -LiteralPath "runs\win-helper\ISphereWinHelper.exe" +``` + +重新扫描可见窗口: + +```powershell +@{ + protocol = "isphere.helper.v1" + request_id = "n12r-scan-windows-retry" + op = "scan_windows" + timeout_ms = 5000 + args = @{ include_all_visible = $true } +} | ConvertTo-Json -Depth 8 -Compress | + & $helper --json | + Set-Content -Path "$captureRoot\windows\scan-windows.json" -Encoding UTF8 +``` + +查看候选窗口: + +```powershell +Get-Content -Path "$captureRoot\windows\scan-windows.json" -Encoding UTF8 | ConvertFrom-Json | + Select-Object -ExpandProperty data | + Select-Object -ExpandProperty windows | + Where-Object { $_.process_name -match "iSphere|IMPlatform|IMPP" -or $_.title -match "iSphere|IMPlatform|IMPP" } | + Format-Table hwnd,pid,process_name,title,class_name,visible,score -AutoSize +``` + +从输出中选择 iSphere / IMPlatformClient 主窗口的 `hwnd`。 + +示例中之前的候选值是: + +```text +0x290E6E +``` + +如果这次扫描输出变了,请使用新的 `hwnd`。 + +## 4. 正确保存 selected-window.json + +把下面命令中的 `0x290E6E` 改成你刚刚确认的真实窗口句柄。 + +注意:不要加尖括号。 + +正确: + +```powershell +$selectedHwnd = "0x290E6E" +``` + +错误: + +```powershell +$selectedHwnd = "<0x290E6E>" +``` + +保存所选窗口元数据: + +```powershell +$selectedHwnd = "0x290E6E" + +$scanObj = Get-Content -Path "$captureRoot\windows\scan-windows.json" -Encoding UTF8 | ConvertFrom-Json +$selected = $scanObj.data.windows | Where-Object { $_.hwnd -eq $selectedHwnd } | Select-Object -First 1 +if (-not $selected) { throw "selected hwnd not found in scan result: $selectedHwnd" } +$selected | ConvertTo-Json -Depth 8 | Set-Content -Path "$captureRoot\windows\selected-window.json" -Encoding UTF8 +``` + +确认文件已经生成: + +```powershell +Test-Path -LiteralPath "$captureRoot\windows\selected-window.json" +``` + +期望输出: + +```text +True +``` + +## 5. 重新执行 dump_uia + +```powershell +@{ + protocol = "isphere.helper.v1" + request_id = "n12r-dump-uia-main-retry" + op = "dump_uia" + timeout_ms = 15000 + args = @{ + hwnd = $selectedHwnd + max_depth = 8 + include_text = $true + max_children = 100 + } +} | ConvertTo-Json -Depth 12 -Compress | + & $helper --json | + Set-Content -Path "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8 +``` + +检查结果是否成功: + +```powershell +$dump = Get-Content "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8 | ConvertFrom-Json +$dump.ok +$dump.error +``` + +期望: + +```text +True +``` + +并且 `$dump.error` 应为空或为 `null`。 + +如果仍然是 `WINDOW_NOT_FOUND`,说明窗口句柄已经失效。请回到第 3 步重新扫描并选择新的 hwnd。 + +## 6. 重新创建脱敏副本 + +```powershell +Copy-Item -LiteralPath "$captureRoot\uia\dump-uia-main.json" -Destination "$captureRoot\uia\dump-uia-main-redacted.json" -Force +``` + +然后人工打开: + +```text +$captureRoot\uia\dump-uia-main-redacted.json +``` + +脱敏可见敏感文本,例如: + +```text +人员姓名 +手机号 +邮箱 +消息文本 +联系人名称 +敏感业务标题 +类似 token / 密码 / cookie 的字符串 +``` + +尽量保留这些结构字段: + +```text +control_type +automation_id +class_name +framework_id +bounds +is_enabled +is_offscreen +children +``` + +## 7. 修正 source-notes.txt + +打开: + +```text +$captureRoot\metadata\source-notes.txt +``` + +把这些字段从 `是/否` 改成真实值: + +```text +iSphere manually logged in: 是 +client opened before capture: 是 +main window visible: 是 +``` + +如果真实情况不是“是”,不要继续打包,先重新完成对应动作。 + +## 8. 修正 operator-notes.txt + +打开: + +```text +$captureRoot\notes\operator-notes.txt +``` + +不要保留 `<...>` 示例内容。请改成真实说明。 + +示例: + +```text +打开的客户端: iSphere / IMPlatformClient +登录方式: 人工正常公司流程 +是否使用 MFA/SSO: 是,不包含任何秘密 +所选窗口 hwnd: 0x290E6E +选择该窗口的原因: process_name 为 IMPlatformClient,标题为国网甘肃省电力公司,visible=True,score=65 +已做脱敏: 已检查 dump-uia-main-redacted.json,移除可见敏感文本 +采集问题: 首次把 hwnd 写成 <0x290E6E> 导致失败,本次已修正为 0x290E6E 后重新采集 +forbidden actions performed: no +``` + +## 9. 复制出去前必须验证 + +运行必需文件检查: + +```powershell +$required = @( + "$captureRoot\metadata\source-notes.txt", + "$captureRoot\helper\version.json", + "$captureRoot\helper\self-check.json", + "$captureRoot\windows\scan-windows.json", + "$captureRoot\windows\selected-window.json", + "$captureRoot\uia\dump-uia-main.json", + "$captureRoot\uia\dump-uia-main-redacted.json", + "$captureRoot\notes\operator-notes.txt" +) +foreach ($path in $required) { + if (-not (Test-Path -LiteralPath $path)) { throw "missing required file: $path" } +} +"N12R retry package files present: $captureRoot" | Tee-Object -FilePath "$captureRoot\metadata\verification-summary.txt" +``` + +运行 JSON 解析检查: + +```powershell +Get-Content "$captureRoot\helper\version.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null +Get-Content "$captureRoot\helper\self-check.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null +Get-Content "$captureRoot\windows\scan-windows.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null +Get-Content "$captureRoot\windows\selected-window.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null +Get-Content "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null +Get-Content "$captureRoot\uia\dump-uia-main-redacted.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null +``` + +检查 UIA dump 必须是成功: + +```powershell +$dump = Get-Content "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8 | ConvertFrom-Json +if (-not $dump.ok) { throw "dump_uia failed: $($dump.error | ConvertTo-Json -Depth 8 -Compress)" } +``` + +## 10. 重新压缩返回 + +```powershell +Compress-Archive -LiteralPath $captureRoot -DestinationPath "$captureRoot.zip" -Force +``` + +把新的 zip 或目录返回给外网协调环境。 + +返回时请同时说明: + +```text +N12R 重采/补采包已准备好。 +路径: runs/internal-sandbox-live-capture/2026-07-08-internal-sandbox-a/ +人工 iSphere 登录: 是 +采集期间主窗口可见: 是 +forbidden actions performed: no +本次已修正 hwnd,不再包含尖括号;selected-window.json 已生成;dump_uia ok=true +``` + +## 11. 外网收到后如何判断 + +外网协调环境会重新运行: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\validate-n12r-return-package.ps1 ` + -PackageRoot <返回包目录> ` + -ReportPath <包外报告路径> +``` + +只有 validator 输出: + +```text +Decision: ACCEPT +``` + +才能进入 N13 selector 设计。 + diff --git a/docs/superpowers/plans/2026-07-09-n13-uia-selector-catalog-implementation.md b/docs/superpowers/plans/2026-07-09-n13-uia-selector-catalog-implementation.md new file mode 100644 index 0000000..66ab24c --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-n13-uia-selector-catalog-implementation.md @@ -0,0 +1,943 @@ +# N13 UIA Selector Catalog Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build an offline, read-only N13 UIA selector catalog and matcher that can validate selectors against the accepted N12R UIA dump without adding live helper or MCP actions. + +**Architecture:** Add a focused Go package `internal/uiaselector` with selector data types, a static first-pass catalog, UIA dump loading, and pure in-memory matching. Tests use a redacted N12R fixture and assert that matching returns structural node summaries without visible text. + +**Tech Stack:** Go 1.23.x, standard library `encoding/json`, existing repository test style, PowerShell verification wrapper. + +--- + +## Scope and boundary + +This plan implements only offline selector catalog and matching. + +It does not modify these files: + +```text +native/ISphereWinHelper/Program.cs +internal/tools/winhelper.go +cmd/isphere-mcp/main.go +``` + +It does not add MCP tools or helper ops. It does not click, type, open conversations, send messages, send files, receive files, or read chat content. + +The accepted N12R evidence used by tests is: + +```text +runs/internal-sandbox-live-capture-intake/2026-07-09-retry-2/2026-07-08-internal-sandbox-a/uia/dump-uia-main-redacted.json +``` + +## File structure + +Create these files: + +```text +internal/uiaselector/catalog.go +internal/uiaselector/catalog_test.go +internal/uiaselector/dump.go +internal/uiaselector/dump_test.go +internal/uiaselector/matcher.go +internal/uiaselector/matcher_test.go +internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json +scripts/verify-n13-uia-selectors.ps1 +``` + +Responsibilities: + +- `catalog.go`: selector data structures and initial N13 selector catalog. +- `catalog_test.go`: catalog completeness and boundary tests. +- `dump.go`: UIA dump response and node loading helpers. +- `dump_test.go`: fixture parse tests. +- `matcher.go`: pure in-memory selector matcher. +- `matcher_test.go`: matching behavior and no-visible-text result tests. +- `testdata/n12r-2026-07-09-uia-redacted.json`: redacted accepted N12R UIA fixture copied from the ignored run artifact. +- `scripts/verify-n13-uia-selectors.ps1`: convenience verification script for Go-enabled environments. + +## Task 1: Add N12R redacted UIA fixture + +**Files:** +- Create: `internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json` + +- [ ] **Step 1: Create package directory and copy the accepted redacted fixture** + +Run: + +```powershell +New-Item -ItemType Directory -Force -Path internal\uiaselector\testdata | Out-Null +Copy-Item ` + -LiteralPath runs\internal-sandbox-live-capture-intake\2026-07-09-retry-2\2026-07-08-internal-sandbox-a\uia\dump-uia-main-redacted.json ` + -Destination internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json ` + -Force +``` + +Expected: `internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json` exists. + +- [ ] **Step 2: Verify fixture parses as JSON** + +Run: + +```powershell +Get-Content internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json -Encoding UTF8 | ConvertFrom-Json | Out-Null +``` + +Expected: command exits 0. + +- [ ] **Step 3: Commit fixture** + +```powershell +git add internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json +git commit -m "test: add N13 redacted UIA fixture" +``` + +## Task 2: Define selector catalog types and initial catalog + +**Files:** +- Create: `internal/uiaselector/catalog_test.go` +- Create: `internal/uiaselector/catalog.go` + +- [ ] **Step 1: Write failing catalog tests** + +Create `internal/uiaselector/catalog_test.go`: + +```go +package uiaselector + +import ( + "strings" + "testing" +) + +func TestDefaultCatalogContainsInitialN13Selectors(t *testing.T) { + catalog := DefaultCatalog() + wantIDs := []string{ + "main_window", + "main_plugin_tab", + "home_page_window", + "tab_page_content", + "left_panel", + "roster_panel", + "roster_view", + "fuzzy_query_panel", + "fuzzy_query_window", + "fuzzy_search_edit", + } + + byID := map[string]Selector{} + for _, selector := range catalog { + byID[selector.ID] = selector + } + + for _, id := range wantIDs { + selector, ok := byID[id] + if !ok { + t.Fatalf("DefaultCatalog missing selector %q", id) + } + if selector.AllowedUse != AllowedUseReadOnlyLocate { + t.Fatalf("selector %q allowed_use = %q, want %q", id, selector.AllowedUse, AllowedUseReadOnlyLocate) + } + if selector.Required.AutomationID == "" { + t.Fatalf("selector %q must require automation_id", id) + } + if selector.Required.ControlType == "" { + t.Fatalf("selector %q must require control_type", id) + } + if selector.StabilityScore < 80 { + t.Fatalf("selector %q stability_score = %d, want >= 80", id, selector.StabilityScore) + } + } +} + +func TestDefaultCatalogDoesNotDescribeActionTools(t *testing.T) { + for _, selector := range DefaultCatalog() { + if selector.AllowedUse != AllowedUseReadOnlyLocate { + t.Fatalf("selector %q allowed_use = %q, want read-only", selector.ID, selector.AllowedUse) + } + lower := strings.ToLower(selector.ID + " " + selector.Description) + for _, forbidden := range []string{"send", "upload", "download", "receive", "conversation", "login", "file_transfer"} { + if strings.Contains(lower, forbidden) { + t.Fatalf("selector %q contains action word %q", selector.ID, forbidden) + } + } + } +} +``` + +- [ ] **Step 2: Run catalog tests and verify they fail** + +Run: + +```powershell +go test ./internal/uiaselector -run TestDefaultCatalog -count=1 +``` + +Expected: FAIL because package `internal/uiaselector` or `DefaultCatalog` does not exist. + +If this environment reports `go` not found, stop execution and record the environment gap. Continue only in a Go 1.23.x environment. + +- [ ] **Step 3: Implement catalog types and selectors** + +Create `internal/uiaselector/catalog.go`: + +```go +package uiaselector + +const AllowedUseReadOnlyLocate = "read_only_locate" + +type Selector struct { + ID string `json:"id"` + Description string `json:"description"` + AllowedUse string `json:"allowed_use"` + Required Criteria `json:"required"` + Preferred PreferredCriteria `json:"preferred"` + Fallback []FallbackCriteria `json:"fallback"` + StabilityScore int `json:"stability_score"` + Evidence Evidence `json:"evidence"` +} + +type Criteria struct { + AutomationID string `json:"automation_id,omitempty"` + ControlType string `json:"control_type,omitempty"` + FrameworkID string `json:"framework_id,omitempty"` + ClassName string `json:"class_name,omitempty"` + ClassPrefix string `json:"class_name_prefix,omitempty"` + AncestorID string `json:"ancestor_selector,omitempty"` +} + +type PreferredCriteria struct { + ClassName string `json:"class_name,omitempty"` + ClassPrefix string `json:"class_name_prefix,omitempty"` + PathHint string `json:"path_hint,omitempty"` + AncestorID string `json:"ancestor_selector,omitempty"` +} + +type FallbackCriteria struct { + AncestorID string `json:"ancestor_selector,omitempty"` + ControlType string `json:"control_type,omitempty"` + ClassPrefix string `json:"class_name_prefix,omitempty"` + ChildAutomationIDContains []string `json:"child_automation_id_contains,omitempty"` + ChildControlTypeContains []string `json:"child_control_type_contains,omitempty"` +} + +type Evidence struct { + CaptureID string `json:"capture_id"` + UIAPath string `json:"uia_path"` +} + +func DefaultCatalog() []Selector { + return []Selector{ + { + ID: "main_window", + Description: "iSphere / IMPlatformClient 主窗口", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "frmMain", ControlType: "Window", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{ClassPrefix: "WindowsForms10.Window.8.app.0", PathHint: "root"}, + Fallback: []FallbackCriteria{{ControlType: "Window", ClassPrefix: "WindowsForms10.Window.8.app.0", ChildAutomationIDContains: []string{"panelLeft", "ucPluginTabControl1"}}}, + StabilityScore: 95, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root"}, + }, + { + ID: "main_plugin_tab", + Description: "主功能 tab 容器", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "ucPluginTabControl1", ControlType: "Pane", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{PathHint: "root/3", AncestorID: "main_window"}, + Fallback: []FallbackCriteria{{AncestorID: "main_window", ControlType: "Pane", ChildAutomationIDContains: []string{"FrmHomePage", "ucTabPage"}}}, + StabilityScore: 90, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3"}, + }, + { + ID: "home_page_window", + Description: "首页内容窗口", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "FrmHomePage", ControlType: "Window", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{PathHint: "root/3/0/0", AncestorID: "main_plugin_tab"}, + Fallback: []FallbackCriteria{{AncestorID: "main_plugin_tab", ControlType: "Window", ClassPrefix: "WindowsForms10.Window.8.app.0"}}, + StabilityScore: 82, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3/0/0"}, + }, + { + ID: "tab_page_content", + Description: "tab 页内容窗口", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "FrmTabPageContent", ControlType: "Window", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{PathHint: "root/3/1/0", AncestorID: "main_plugin_tab"}, + Fallback: []FallbackCriteria{{AncestorID: "main_plugin_tab", ControlType: "Window", ChildControlTypeContains: []string{"TitleBar"}}}, + StabilityScore: 82, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3/1/0"}, + }, + { + ID: "left_panel", + Description: "左侧主面板", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "panelLeft", ControlType: "Pane", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{PathHint: "root/4", AncestorID: "main_window"}, + Fallback: []FallbackCriteria{{AncestorID: "main_window", ControlType: "Pane", ChildAutomationIDContains: []string{"ucPnlRoster1", "ucFuzzyQuery1"}}}, + StabilityScore: 92, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4"}, + }, + { + ID: "roster_panel", + Description: "组织/联系人容器外层", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "ucPnlRoster1", ControlType: "Pane", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{PathHint: "root/4/0/0", AncestorID: "left_panel"}, + Fallback: []FallbackCriteria{{AncestorID: "left_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"ucRoster1"}}}, + StabilityScore: 88, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/0/0"}, + }, + { + ID: "roster_view", + Description: "组织/联系人列表区域", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "ucRoster1", ControlType: "Pane", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{PathHint: "root/4/0/0/0", AncestorID: "roster_panel"}, + Fallback: []FallbackCriteria{{AncestorID: "roster_panel", ControlType: "Pane", ClassPrefix: "WindowsForms10.Window.8.app.0"}}, + StabilityScore: 86, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/0/0/0"}, + }, + { + ID: "fuzzy_query_panel", + Description: "模糊搜索组件外层", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "ucFuzzyQuery1", ControlType: "Pane", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{PathHint: "root/4/1/0", AncestorID: "left_panel"}, + Fallback: []FallbackCriteria{{AncestorID: "left_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"FrmFuzzyQuery"}}}, + StabilityScore: 90, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0"}, + }, + { + ID: "fuzzy_query_window", + Description: "模糊搜索内部窗口", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "FrmFuzzyQuery", ControlType: "Pane", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{PathHint: "root/4/1/0/0", AncestorID: "fuzzy_query_panel"}, + Fallback: []FallbackCriteria{{AncestorID: "fuzzy_query_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"skinAlphaTxt"}}}, + StabilityScore: 88, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0/0"}, + }, + { + ID: "fuzzy_search_edit", + Description: "模糊搜索输入框", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "skinAlphaTxt", ControlType: "Edit", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{ClassName: "WindowsForms10.EDIT.app.0.d3a00f_r29_ad1", PathHint: "root/4/1/0/0/2", AncestorID: "fuzzy_query_window"}, + Fallback: []FallbackCriteria{{AncestorID: "fuzzy_query_window", ControlType: "Edit", ClassPrefix: "WindowsForms10.EDIT.app.0"}}, + StabilityScore: 93, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0/0/2"}, + }, + } +} +``` + +- [ ] **Step 4: Run catalog tests and verify they pass** + +Run: + +```powershell +go test ./internal/uiaselector -run TestDefaultCatalog -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit catalog** + +```powershell +git add internal\uiaselector\catalog.go internal\uiaselector\catalog_test.go +git commit -m "feat: add N13 UIA selector catalog" +``` + +## Task 3: Load redacted UIA dump fixture + +**Files:** +- Create: `internal/uiaselector/dump_test.go` +- Create: `internal/uiaselector/dump.go` + +- [ ] **Step 1: Write failing dump loading tests** + +Create `internal/uiaselector/dump_test.go`: + +```go +package uiaselector + +import "testing" + +func TestLoadDumpFixtureParsesAcceptedN12RTree(t *testing.T) { + root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json") + if err != nil { + t.Fatalf("LoadDumpFile: %v", err) + } + if root.AutomationID != "frmMain" { + t.Fatalf("root automation_id = %q, want frmMain", root.AutomationID) + } + if root.ControlType != "Window" { + t.Fatalf("root control_type = %q, want Window", root.ControlType) + } + if got := CountNodes(root); got != 66 { + t.Fatalf("CountNodes = %d, want 66", got) + } +} +``` + +- [ ] **Step 2: Run dump tests and verify they fail** + +Run: + +```powershell +go test ./internal/uiaselector -run TestLoadDumpFixture -count=1 +``` + +Expected: FAIL because `LoadDumpFile` and `CountNodes` do not exist. + +- [ ] **Step 3: Implement dump loading** + +Create `internal/uiaselector/dump.go`: + +```go +package uiaselector + +import ( + "encoding/json" + "fmt" + "os" +) + +type DumpResponse struct { + OK bool `json:"ok"` + Data *DumpData `json:"data"` + Error any `json:"error"` +} + +type DumpData struct { + Root *Node `json:"root"` +} + +type Node struct { + Name string `json:"name"` + ControlType string `json:"control_type"` + AutomationID string `json:"automation_id"` + ClassName string `json:"class_name"` + FrameworkID string `json:"framework_id"` + IsEnabled *bool `json:"is_enabled"` + IsOffscreen *bool `json:"is_offscreen"` + Bounds any `json:"bounds"` + Children []Node `json:"children"` +} + +func LoadDumpFile(path string) (*Node, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var response DumpResponse + if err := json.Unmarshal(content, &response); err != nil { + return nil, err + } + if !response.OK { + return nil, fmt.Errorf("dump response ok=false: %v", response.Error) + } + if response.Data == nil || response.Data.Root == nil { + return nil, fmt.Errorf("dump response has no data.root") + } + return response.Data.Root, nil +} + +func CountNodes(root *Node) int { + if root == nil { + return 0 + } + count := 1 + for i := range root.Children { + count += CountNodes(&root.Children[i]) + } + return count +} +``` + +- [ ] **Step 4: Run dump tests and verify they pass** + +Run: + +```powershell +go test ./internal/uiaselector -run TestLoadDumpFixture -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit dump loader** + +```powershell +git add internal\uiaselector\dump.go internal\uiaselector\dump_test.go +git commit -m "feat: load N13 UIA dump fixture" +``` + +## Task 4: Implement read-only selector matcher + +**Files:** +- Create: `internal/uiaselector/matcher_test.go` +- Create: `internal/uiaselector/matcher.go` + +- [ ] **Step 1: Write failing matcher tests** + +Create `internal/uiaselector/matcher_test.go`: + +```go +package uiaselector + +import "testing" + +func TestMatchSelectorFindsKnownN12RNodes(t *testing.T) { + root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json") + if err != nil { + t.Fatalf("LoadDumpFile: %v", err) + } + + tests := []struct { + selectorID string + path string + control string + autoID string + }{ + {selectorID: "main_window", path: "root", control: "Window", autoID: "frmMain"}, + {selectorID: "left_panel", path: "root/4", control: "Pane", autoID: "panelLeft"}, + {selectorID: "fuzzy_search_edit", path: "root/4/1/0/0/2", control: "Edit", autoID: "skinAlphaTxt"}, + } + + for _, tt := range tests { + result := MatchSelector(root, DefaultCatalog(), tt.selectorID) + if !result.OK { + t.Fatalf("%s OK=false: %#v", tt.selectorID, result.Error) + } + if !result.Matched { + t.Fatalf("%s matched=false", tt.selectorID) + } + if result.MatchCount != 1 { + t.Fatalf("%s match_count = %d, want 1", tt.selectorID, result.MatchCount) + } + match := result.Matches[0] + if match.Path != tt.path || match.ControlType != tt.control || match.AutomationID != tt.autoID { + t.Fatalf("%s match = %#v", tt.selectorID, match) + } + } +} + +func TestMatchSelectorDoesNotReturnVisibleText(t *testing.T) { + root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json") + if err != nil { + t.Fatalf("LoadDumpFile: %v", err) + } + result := MatchSelector(root, DefaultCatalog(), "main_window") + if !result.OK || !result.Matched || len(result.Matches) != 1 { + t.Fatalf("unexpected result: %#v", result) + } + match := result.Matches[0] + if match.Name != "" { + t.Fatalf("match returned visible name text %q", match.Name) + } + if !match.HasName { + t.Fatalf("main_window should report has_name=true") + } + if match.NameLength == 0 { + t.Fatalf("main_window should report non-zero name_length") + } +} + +func TestMatchSelectorUnknownSelectorIsStructuredFailure(t *testing.T) { + root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json") + if err != nil { + t.Fatalf("LoadDumpFile: %v", err) + } + result := MatchSelector(root, DefaultCatalog(), "missing_selector") + if result.OK { + t.Fatalf("OK=true for missing selector: %#v", result) + } + if result.Error.Code != "UNKNOWN_SELECTOR" { + t.Fatalf("error code = %q, want UNKNOWN_SELECTOR", result.Error.Code) + } +} +``` + +- [ ] **Step 2: Run matcher tests and verify they fail** + +Run: + +```powershell +go test ./internal/uiaselector -run TestMatchSelector -count=1 +``` + +Expected: FAIL because `MatchSelector` and result types do not exist. + +- [ ] **Step 3: Implement matcher** + +Create `internal/uiaselector/matcher.go`: + +```go +package uiaselector + +import "strings" + +type MatchResult struct { + OK bool `json:"ok"` + SelectorID string `json:"selector_id"` + Matched bool `json:"matched"` + MatchCount int `json:"match_count"` + Matches []NodeMatch `json:"matches"` + Warnings []string `json:"warnings"` + Error MatchError `json:"error,omitempty"` +} + +type MatchError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type NodeMatch struct { + Path string `json:"path"` + ControlType string `json:"control_type"` + AutomationID string `json:"automation_id"` + ClassName string `json:"class_name"` + FrameworkID string `json:"framework_id"` + HasName bool `json:"has_name"` + NameLength int `json:"name_length"` + Name string `json:"-"` +} + +type nodeRef struct { + path string + node *Node +} + +func MatchSelector(root *Node, catalog []Selector, selectorID string) MatchResult { + selector, ok := findSelector(catalog, selectorID) + if !ok { + return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "UNKNOWN_SELECTOR", Message: "selector is not in catalog"}} + } + if selector.AllowedUse != AllowedUseReadOnlyLocate { + return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "INVALID_SELECTOR", Message: "selector is not read-only"}} + } + refs := flatten(root) + matches := matchCriteria(refs, selector.Required) + if len(matches) == 0 { + for _, fallback := range selector.Fallback { + matches = matchFallback(refs, fallback) + if len(matches) > 0 { + break + } + } + } + result := MatchResult{OK: true, SelectorID: selectorID, Matched: len(matches) > 0, MatchCount: len(matches), Warnings: []string{}} + for _, ref := range matches { + result.Matches = append(result.Matches, summarizeNode(ref)) + } + if !result.Matched { + result.Warnings = append(result.Warnings, "selector_not_found") + } + return result +} + +func findSelector(catalog []Selector, selectorID string) (Selector, bool) { + for _, selector := range catalog { + if selector.ID == selectorID { + return selector, true + } + } + return Selector{}, false +} + +func flatten(root *Node) []nodeRef { + refs := []nodeRef{} + var walk func(node *Node, path string) + walk = func(node *Node, path string) { + if node == nil { + return + } + refs = append(refs, nodeRef{path: path, node: node}) + for i := range node.Children { + walk(&node.Children[i], path+"/"+itoa(i)) + } + } + walk(root, "root") + return refs +} + +func itoa(value int) string { + if value == 0 { + return "0" + } + digits := []byte{} + for value > 0 { + digits = append([]byte{byte('0' + value%10)}, digits...) + value = value / 10 + } + return string(digits) +} + +func matchCriteria(refs []nodeRef, criteria Criteria) []nodeRef { + matches := []nodeRef{} + for _, ref := range refs { + if criteria.AutomationID != "" && ref.node.AutomationID != criteria.AutomationID { + continue + } + if criteria.ControlType != "" && ref.node.ControlType != criteria.ControlType { + continue + } + if criteria.FrameworkID != "" && ref.node.FrameworkID != criteria.FrameworkID { + continue + } + if criteria.ClassName != "" && ref.node.ClassName != criteria.ClassName { + continue + } + if criteria.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, criteria.ClassPrefix) { + continue + } + matches = append(matches, ref) + } + return matches +} + +func matchFallback(refs []nodeRef, fallback FallbackCriteria) []nodeRef { + matches := []nodeRef{} + for _, ref := range refs { + if fallback.ControlType != "" && ref.node.ControlType != fallback.ControlType { + continue + } + if fallback.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, fallback.ClassPrefix) { + continue + } + if !hasChildAutomationIDs(ref.node, fallback.ChildAutomationIDContains) { + continue + } + if !hasChildControlTypes(ref.node, fallback.ChildControlTypeContains) { + continue + } + matches = append(matches, ref) + } + return matches +} + +func hasChildAutomationIDs(node *Node, ids []string) bool { + for _, id := range ids { + found := false + for i := range node.Children { + if node.Children[i].AutomationID == id { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func hasChildControlTypes(node *Node, controlTypes []string) bool { + for _, controlType := range controlTypes { + found := false + for i := range node.Children { + if node.Children[i].ControlType == controlType { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func summarizeNode(ref nodeRef) NodeMatch { + return NodeMatch{ + Path: ref.path, + ControlType: ref.node.ControlType, + AutomationID: ref.node.AutomationID, + ClassName: ref.node.ClassName, + FrameworkID: ref.node.FrameworkID, + HasName: ref.node.Name != "", + NameLength: len(ref.node.Name), + } +} +``` + +- [ ] **Step 4: Run matcher tests and verify they pass** + +Run: + +```powershell +go test ./internal/uiaselector -run TestMatchSelector -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit matcher** + +```powershell +git add internal\uiaselector\matcher.go internal\uiaselector\matcher_test.go +git commit -m "feat: match N13 UIA selectors offline" +``` + +## Task 5: Add verification script + +**Files:** +- Create: `scripts/verify-n13-uia-selectors.ps1` + +- [ ] **Step 1: Create verification script** + +Create `scripts/verify-n13-uia-selectors.ps1`: + +```powershell +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path +Set-Location -LiteralPath $repo + +if (-not (Get-Command go -ErrorAction SilentlyContinue)) { + throw "go was not found. Run this verifier in a Go 1.23.x environment." +} + +Write-Host "## go test ./internal/uiaselector" +go test ./internal/uiaselector -count=1 +if ($LASTEXITCODE -ne 0) { + throw "go test ./internal/uiaselector failed with exit code $LASTEXITCODE" +} + +Write-Host "## boundary grep" +$forbidden = @( + "send_after_approval", + "send_file_after_approval", + "receive_file_after_approval", + "open_conversation", + "ValuePattern.SetValue", + "InvokePattern" +) +$files = @( + "internal\uiaselector\catalog.go", + "internal\uiaselector\matcher.go" +) +foreach ($term in $forbidden) { + $hit = Select-String -LiteralPath $files -Pattern $term -SimpleMatch -ErrorAction SilentlyContinue + if ($hit) { + throw "forbidden action term found in selector implementation: $term" + } +} + +Write-Host "N13 UIA selector verification passed." +``` + +- [ ] **Step 2: Run script and verify it passes** + +Run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n13-uia-selectors.ps1 +``` + +Expected in Go-enabled environment: + +```text +## go test ./internal/uiaselector +ok isphere-ai-bridge/internal/uiaselector +## boundary grep +N13 UIA selector verification passed. +``` + +If `go` is unavailable, expected failure is: + +```text +go was not found. Run this verifier in a Go 1.23.x environment. +``` + +- [ ] **Step 3: Commit verification script** + +```powershell +git add scripts\verify-n13-uia-selectors.ps1 +git commit -m "test: add N13 selector verifier" +``` + +## Task 6: Final verification and checkpoint + +**Files:** +- No new files. +- Verify: `internal/uiaselector/*`, `scripts/verify-n13-uia-selectors.ps1` + +- [ ] **Step 1: Run package tests** + +Run: + +```powershell +go test ./internal/uiaselector -count=1 +``` + +Expected: + +```text +ok isphere-ai-bridge/internal/uiaselector +``` + +- [ ] **Step 2: Run repository tests if Go is available** + +Run: + +```powershell +go test ./... +``` + +Expected: PASS for all packages. + +If the environment lacks Go, do not mark implementation complete. Move to a Go 1.23.x environment and rerun. + +- [ ] **Step 3: Run N13 verifier** + +Run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n13-uia-selectors.ps1 +``` + +Expected: PASS in Go-enabled environment. + +- [ ] **Step 4: Check no MCP/helper action surface changed** + +Run: + +```powershell +git diff --name-only origin/main..HEAD +``` + +Expected changed implementation files are limited to: + +```text +internal/uiaselector/catalog.go +internal/uiaselector/catalog_test.go +internal/uiaselector/dump.go +internal/uiaselector/dump_test.go +internal/uiaselector/matcher.go +internal/uiaselector/matcher_test.go +internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json +scripts/verify-n13-uia-selectors.ps1 +``` + +If `native/ISphereWinHelper/Program.cs`, `internal/tools/winhelper.go`, or `cmd/isphere-mcp/main.go` appears, stop and review because N13 is not supposed to add live tools. + +- [ ] **Step 5: Commit final checkpoint if needed** + +If any verification-only documentation changes were made, run: + +```powershell +git add +git commit -m "docs: record N13 selector verification" +``` + +If no files changed, do not create an empty commit. + +## Self-review checklist + +- Spec coverage: this plan covers selector catalog, fixture loading, read-only matcher, no visible text in result, and boundary verification. +- Boundary: no helper op, no MCP tool, no click/input/send/file/conversation behavior. +- TDD: every implementation task starts with failing tests before code. +- Commit cadence: fixture, catalog, loader, matcher, verifier are separate commits. +- Environment caveat: Go 1.23.x is required for implementation verification. diff --git a/docs/superpowers/specs/2026-07-09-n13-uia-selector-catalog-design.md b/docs/superpowers/specs/2026-07-09-n13-uia-selector-catalog-design.md new file mode 100644 index 0000000..cfb79d4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-n13-uia-selector-catalog-design.md @@ -0,0 +1,597 @@ +# N13 UIA Selector Catalog Design + +日期:2026-07-09 + +## 1. 目标 + +N13 的目标是基于已 ACCEPT 的 N12R live UIA 证据,设计一套只读 selector catalog 和定位验证流程。 + +本阶段只做设计,不新增 helper op,不新增 MCP tool,不点击、不输入、不打开会话、不发送消息、不传输文件。 + +N13 完成后,下一步才可以写独立实现计划,考虑是否新增只读定位验证能力。 + +## 2. 输入证据 + +N12R 返回包已通过外网 validator: + +```text +runs/internal-sandbox-live-capture-reports/2026-07-09-retry-2/2026-07-08-internal-sandbox-a-validation.md +Decision: ACCEPT +PASS: 36 +WARN: 1 +FAIL: 0 +``` + +使用的 UIA dump: + +```text +runs/internal-sandbox-live-capture-intake/2026-07-09-retry-2/2026-07-08-internal-sandbox-a/uia/dump-uia-main.json +``` + +关键事实: + +```text +root control_type: Window +root automation_id: frmMain +root class_name: WindowsForms10.Window.8.app.0.d3a00f_r29_ad1 +framework_id: WinForm +UIA node count: 66 +max_depth: 7 +``` + +本设计只引用结构性字段,不依赖敏感可见文本。 + +## 3. 非目标 + +N13 不做以下事项: + +```text +自动登录 +搜索联系人 +打开会话 +点击控件 +输入文本 +发送消息 +发送文件 +接收文件 +读取或导出聊天内容 +注入进程 +hook +读取目标进程内存 +绕过终端安全/安防 +``` + +N13 也不把 `search_contacts`、`open_conversation`、`read_latest_messages` 作为当前实现目标。这些只能在 selector catalog 稳定、只读定位验证通过、并形成新的审批设计后再讨论。 + +## 4. 推荐方案 + +采用“selector catalog + 只读定位验证”方案。 + +核心思想: + +1. 用 N12R UIA dump 建立一份 selector catalog。 +2. 每个 selector 都只描述如何识别一个 UIA 节点。 +3. selector 的初始用途只允许 read-only locate。 +4. 后续如果实现 `locate_uia`,也只能返回匹配节点摘要,不执行任何 UI 动作。 +5. 任何真实动作类能力必须另写设计、计划和审批边界。 + +## 5. Selector 数据模型 + +建议 selector 条目使用以下字段: + +```json +{ + "id": "main_window", + "description": "主窗口", + "allowed_use": "read_only_locate", + "required": { + "automation_id": "frmMain", + "control_type": "Window", + "framework_id": "WinForm" + }, + "preferred": { + "class_name_prefix": "WindowsForms10.Window.8.app.0", + "path_hint": "root" + }, + "fallback": [ + { + "control_type": "Window", + "class_name_prefix": "WindowsForms10.Window.8.app.0", + "child_automation_id_contains": ["panelLeft", "ucPluginTabControl1"] + } + ], + "stability_score": 95, + "evidence": { + "capture_id": "2026-07-08-internal-sandbox-a", + "uia_path": "root" + } +} +``` + +字段含义: + +- `id`:稳定、短小、英文蛇形命名。 +- `description`:中文说明。 +- `allowed_use`:N13 固定为 `read_only_locate`。 +- `required`:必须同时满足的字段。 +- `preferred`:加分项,用于减少误匹配。 +- `fallback`:主 selector 失效时的只读匹配策略。 +- `stability_score`:0 到 100,越高越稳定。 +- `evidence`:来源证据路径和 UIA 路径。 + +## 6. 初始 selector catalog + +### 6.1 主窗口:`main_window` + +```json +{ + "id": "main_window", + "description": "iSphere / IMPlatformClient 主窗口", + "allowed_use": "read_only_locate", + "required": { + "automation_id": "frmMain", + "control_type": "Window", + "framework_id": "WinForm" + }, + "preferred": { + "class_name_prefix": "WindowsForms10.Window.8.app.0", + "path_hint": "root" + }, + "fallback": [ + { + "control_type": "Window", + "class_name_prefix": "WindowsForms10.Window.8.app.0", + "child_automation_id_contains": ["panelLeft", "ucPluginTabControl1"] + } + ], + "stability_score": 95, + "evidence": { + "uia_path": "root" + } +} +``` + +理由:`automation_id=frmMain` 出现在根节点;root 是 N12R 已确认主窗口,稳定性最高。 + +### 6.2 主 tab 容器:`main_plugin_tab` + +```json +{ + "id": "main_plugin_tab", + "description": "主功能 tab 容器", + "allowed_use": "read_only_locate", + "required": { + "automation_id": "ucPluginTabControl1", + "control_type": "Pane", + "framework_id": "WinForm" + }, + "preferred": { + "path_hint": "root/3", + "ancestor_selector": "main_window" + }, + "fallback": [ + { + "ancestor_selector": "main_window", + "control_type": "Pane", + "child_automation_id_contains": ["FrmHomePage", "ucTabPage"] + } + ], + "stability_score": 90, + "evidence": { + "uia_path": "root/3" + } +} +``` + +理由:该节点直接挂在主窗口下,automation_id 明确,子结构包含首页和 tab 内容区。 + +### 6.3 首页窗口:`home_page_window` + +```json +{ + "id": "home_page_window", + "description": "首页内容窗口", + "allowed_use": "read_only_locate", + "required": { + "automation_id": "FrmHomePage", + "control_type": "Window", + "framework_id": "WinForm" + }, + "preferred": { + "path_hint": "root/3/0/0", + "ancestor_selector": "main_plugin_tab" + }, + "fallback": [ + { + "ancestor_selector": "main_plugin_tab", + "control_type": "Window", + "class_name_prefix": "WindowsForms10.Window.8.app.0" + } + ], + "stability_score": 82, + "evidence": { + "uia_path": "root/3/0/0" + } +} +``` + +理由:该节点可用于确认当前主 UI 内容区存在,但不用于动作。 + +### 6.4 tab 内容窗口:`tab_page_content` + +```json +{ + "id": "tab_page_content", + "description": "tab 页内容窗口", + "allowed_use": "read_only_locate", + "required": { + "automation_id": "FrmTabPageContent", + "control_type": "Window", + "framework_id": "WinForm" + }, + "preferred": { + "path_hint": "root/3/1/0", + "ancestor_selector": "main_plugin_tab" + }, + "fallback": [ + { + "ancestor_selector": "main_plugin_tab", + "control_type": "Window", + "child_control_type_contains": ["TitleBar"] + } + ], + "stability_score": 82, + "evidence": { + "uia_path": "root/3/1/0" + } +} +``` + +理由:可作为后续只读结构检查的内容区域锚点。 + +### 6.5 左侧面板:`left_panel` + +```json +{ + "id": "left_panel", + "description": "左侧主面板", + "allowed_use": "read_only_locate", + "required": { + "automation_id": "panelLeft", + "control_type": "Pane", + "framework_id": "WinForm" + }, + "preferred": { + "path_hint": "root/4", + "ancestor_selector": "main_window" + }, + "fallback": [ + { + "ancestor_selector": "main_window", + "control_type": "Pane", + "child_automation_id_contains": ["ucPnlRoster1", "ucFuzzyQuery1"] + } + ], + "stability_score": 92, + "evidence": { + "uia_path": "root/4" + } +} +``` + +理由:该节点是联系人/搜索相关区域的稳定父节点。 + +### 6.6 组织/联系人容器:`roster_panel` + +```json +{ + "id": "roster_panel", + "description": "组织/联系人容器外层", + "allowed_use": "read_only_locate", + "required": { + "automation_id": "ucPnlRoster1", + "control_type": "Pane", + "framework_id": "WinForm" + }, + "preferred": { + "path_hint": "root/4/0/0", + "ancestor_selector": "left_panel" + }, + "fallback": [ + { + "ancestor_selector": "left_panel", + "control_type": "Pane", + "child_automation_id_contains": ["ucRoster1"] + } + ], + "stability_score": 88, + "evidence": { + "uia_path": "root/4/0/0" + } +} +``` + +理由:该节点是 roster 结构外层,可以只读验证联系人区域是否存在。 + +### 6.7 组织/联系人列表:`roster_view` + +```json +{ + "id": "roster_view", + "description": "组织/联系人列表区域", + "allowed_use": "read_only_locate", + "required": { + "automation_id": "ucRoster1", + "control_type": "Pane", + "framework_id": "WinForm" + }, + "preferred": { + "path_hint": "root/4/0/0/0", + "ancestor_selector": "roster_panel" + }, + "fallback": [ + { + "ancestor_selector": "roster_panel", + "control_type": "Pane", + "class_name_prefix": "WindowsForms10.Window.8.app.0" + } + ], + "stability_score": 86, + "evidence": { + "uia_path": "root/4/0/0/0" + } +} +``` + +理由:该节点本次 child_count 为 0,不能证明列表项结构;只能作为区域存在性 selector。 + +### 6.8 搜索组件外层:`fuzzy_query_panel` + +```json +{ + "id": "fuzzy_query_panel", + "description": "模糊搜索组件外层", + "allowed_use": "read_only_locate", + "required": { + "automation_id": "ucFuzzyQuery1", + "control_type": "Pane", + "framework_id": "WinForm" + }, + "preferred": { + "path_hint": "root/4/1/0", + "ancestor_selector": "left_panel" + }, + "fallback": [ + { + "ancestor_selector": "left_panel", + "control_type": "Pane", + "child_automation_id_contains": ["FrmFuzzyQuery"] + } + ], + "stability_score": 90, + "evidence": { + "uia_path": "root/4/1/0" + } +} +``` + +理由:该组件是搜索输入框的稳定父级。 + +### 6.9 搜索窗口:`fuzzy_query_window` + +```json +{ + "id": "fuzzy_query_window", + "description": "模糊搜索内部窗口", + "allowed_use": "read_only_locate", + "required": { + "automation_id": "FrmFuzzyQuery", + "control_type": "Pane", + "framework_id": "WinForm" + }, + "preferred": { + "path_hint": "root/4/1/0/0", + "ancestor_selector": "fuzzy_query_panel" + }, + "fallback": [ + { + "ancestor_selector": "fuzzy_query_panel", + "control_type": "Pane", + "child_automation_id_contains": ["skinAlphaTxt"] + } + ], + "stability_score": 88, + "evidence": { + "uia_path": "root/4/1/0/0" + } +} +``` + +理由:该节点包含搜索输入框,是后续只读定位验证中的关键父节点。 + +### 6.10 搜索输入框:`fuzzy_search_edit` + +```json +{ + "id": "fuzzy_search_edit", + "description": "模糊搜索输入框", + "allowed_use": "read_only_locate", + "required": { + "automation_id": "skinAlphaTxt", + "control_type": "Edit", + "framework_id": "WinForm" + }, + "preferred": { + "class_name": "WindowsForms10.EDIT.app.0.d3a00f_r29_ad1", + "path_hint": "root/4/1/0/0/2", + "ancestor_selector": "fuzzy_query_window" + }, + "fallback": [ + { + "ancestor_selector": "fuzzy_query_window", + "control_type": "Edit", + "class_name_prefix": "WindowsForms10.EDIT.app.0" + } + ], + "stability_score": 93, + "evidence": { + "uia_path": "root/4/1/0/0/2" + } +} +``` + +理由:该节点 automation_id、control_type 和 class_name 都明确。N13 只允许定位它,不允许输入搜索词。 + +## 7. 只读定位验证规则 + +后续如果实现定位验证,只能做以下事情: + +1. 接收 selector id。 +2. 读取当前窗口 UIA 树。 +3. 按 selector required 字段过滤节点。 +4. 用 preferred 字段排序或加分。 +5. 如果主 selector 无匹配,再尝试 fallback。 +6. 返回节点摘要:`selector_id`、`matched`、`match_count`、`path`、`control_type`、`automation_id`、`class_name`、`framework_id`、`is_enabled`、`is_offscreen`、`bounds`。 + +禁止返回完整 visible text。可见文本只能返回以下派生信息: + +```text +has_name: true/false +name_length: number +``` + +禁止执行: + +```text +InvokePattern +ValuePattern.SetValue +SelectionItemPattern.Select +ExpandCollapsePattern.Expand +点击 +键盘输入 +剪贴板操作 +窗口激活 +焦点切换 +``` + +## 8. 稳定性评分规则 + +建议评分: + +```text +95-100: root 或唯一主窗口级 anchor +90-94: automation_id 明确、control_type 明确、父子路径稳定 +80-89: automation_id 明确,但可能依赖 tab 或运行状态 +70-79: 缺 automation_id,但 class/control/path 组合可用 +0-69: 不进入首批 catalog +``` + +本次首批 selector 全部要求 automation_id 明确。 + +## 9. 错误处理设计 + +只读定位验证应返回结构化结果,不抛出模糊错误。 + +建议结果: + +```json +{ + "ok": true, + "selector_id": "fuzzy_search_edit", + "matched": true, + "match_count": 1, + "matches": [ + { + "path": "root/4/1/0/0/2", + "control_type": "Edit", + "automation_id": "skinAlphaTxt", + "class_name": "WindowsForms10.EDIT.app.0.d3a00f_r29_ad1", + "framework_id": "WinForm", + "has_name": true, + "name_length": 0 + } + ], + "warnings": [] +} +``` + +未匹配时: + +```json +{ + "ok": true, + "selector_id": "fuzzy_search_edit", + "matched": false, + "match_count": 0, + "matches": [], + "warnings": ["selector_not_found"] +} +``` + +selector 定义非法时: + +```json +{ + "ok": false, + "selector_id": "fuzzy_search_edit", + "error": { + "code": "INVALID_SELECTOR", + "message": "selector required fields are incomplete" + } +} +``` + +## 10. 测试策略 + +N13 设计阶段不写实现代码,但后续实现计划必须包含以下测试: + +1. 从 N12R dump fixture 读取 UIA 树。 +2. 验证 `main_window` 匹配唯一 root。 +3. 验证 `left_panel` 匹配 `root/4`。 +4. 验证 `fuzzy_search_edit` 匹配 `root/4/1/0/0/2`。 +5. 验证 selector 不返回完整 `name` 文本,只返回 `has_name` 和 `name_length`。 +6. 验证缺失 selector 返回 `matched=false`,不报崩溃。 +7. 验证动作模式字段或动作请求被拒绝。 +8. 验证 catalog 中不包含 `send`、`upload`、`download`、`conversation`、`login` 等动作词作为工具名。 + +## 11. 文件规划 + +如果后续进入实现计划,建议新增或修改: + +```text +internal/uiaselector/catalog.go +internal/uiaselector/catalog_test.go +internal/uiaselector/matcher.go +internal/uiaselector/matcher_test.go +internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json +scripts/verify-n13-uia-selectors.ps1 +``` + +暂不修改: + +```text +native/ISphereWinHelper/Program.cs +internal/tools/winhelper.go +cmd/isphere-mcp/main.go +``` + +除非新的实现计划明确批准新增只读定位工具,否则 Go MCP 仍只暴露现有四个只读工具。 + +## 12. N13 验收条件 + +N13 设计可接受条件: + +1. selector catalog 只依赖结构性 UIA 字段。 +2. 不依赖敏感可见文本。 +3. 首批 selector 都能追溯到 N12R ACCEPT dump 的路径。 +4. allowed_use 全部是 `read_only_locate`。 +5. 文档明确禁止点击、输入、打开会话、发送和文件操作。 +6. 后续实现计划必须 TDD,并先使用 fixture 测试 selector matcher。 + +## 13. 推荐下一步 + +下一步不是直接写代码,而是写一份 N13 implementation plan。 + +该计划应先实现纯离线 selector matcher,对 N12R UIA dump fixture 做只读匹配测试。 + +只有离线 selector matcher 通过后,才讨论是否新增 live `locate_uia` helper/MCP 只读工具。 diff --git a/internal/uiaselector/catalog.go b/internal/uiaselector/catalog.go new file mode 100644 index 0000000..701108e --- /dev/null +++ b/internal/uiaselector/catalog.go @@ -0,0 +1,148 @@ +package uiaselector + +const AllowedUseReadOnlyLocate = "read_only_locate" + +type Selector struct { + ID string `json:"id"` + Description string `json:"description"` + AllowedUse string `json:"allowed_use"` + Required Criteria `json:"required"` + Preferred PreferredCriteria `json:"preferred"` + Fallback []FallbackCriteria `json:"fallback"` + StabilityScore int `json:"stability_score"` + Evidence Evidence `json:"evidence"` +} + +type Criteria struct { + AutomationID string `json:"automation_id,omitempty"` + ControlType string `json:"control_type,omitempty"` + FrameworkID string `json:"framework_id,omitempty"` + ClassName string `json:"class_name,omitempty"` + ClassPrefix string `json:"class_name_prefix,omitempty"` + AncestorID string `json:"ancestor_selector,omitempty"` +} + +type PreferredCriteria struct { + ClassName string `json:"class_name,omitempty"` + ClassPrefix string `json:"class_name_prefix,omitempty"` + PathHint string `json:"path_hint,omitempty"` + AncestorID string `json:"ancestor_selector,omitempty"` +} + +type FallbackCriteria struct { + AncestorID string `json:"ancestor_selector,omitempty"` + ControlType string `json:"control_type,omitempty"` + ClassPrefix string `json:"class_name_prefix,omitempty"` + ChildAutomationIDContains []string `json:"child_automation_id_contains,omitempty"` + ChildControlTypeContains []string `json:"child_control_type_contains,omitempty"` +} + +type Evidence struct { + CaptureID string `json:"capture_id"` + UIAPath string `json:"uia_path"` +} + +func DefaultCatalog() []Selector { + return []Selector{ + { + ID: "main_window", + Description: "iSphere / IMPlatformClient 主窗口", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "frmMain", ControlType: "Window", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{ClassPrefix: "WindowsForms10.Window.8.app.0", PathHint: "root"}, + Fallback: []FallbackCriteria{{ControlType: "Window", ClassPrefix: "WindowsForms10.Window.8.app.0", ChildAutomationIDContains: []string{"panelLeft", "ucPluginTabControl1"}}}, + StabilityScore: 95, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root"}, + }, + { + ID: "main_plugin_tab", + Description: "主功能 tab 容器", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "ucPluginTabControl1", ControlType: "Pane", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{PathHint: "root/3", AncestorID: "main_window"}, + Fallback: []FallbackCriteria{{AncestorID: "main_window", ControlType: "Pane", ChildAutomationIDContains: []string{"FrmHomePage", "ucTabPage"}}}, + StabilityScore: 90, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3"}, + }, + { + ID: "home_page_window", + Description: "首页内容窗口", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "FrmHomePage", ControlType: "Window", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{PathHint: "root/3/0/0", AncestorID: "main_plugin_tab"}, + Fallback: []FallbackCriteria{{AncestorID: "main_plugin_tab", ControlType: "Window", ClassPrefix: "WindowsForms10.Window.8.app.0"}}, + StabilityScore: 82, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3/0/0"}, + }, + { + ID: "tab_page_content", + Description: "tab 页内容窗口", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "FrmTabPageContent", ControlType: "Window", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{PathHint: "root/3/1/0", AncestorID: "main_plugin_tab"}, + Fallback: []FallbackCriteria{{AncestorID: "main_plugin_tab", ControlType: "Window", ChildControlTypeContains: []string{"TitleBar"}}}, + StabilityScore: 82, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3/1/0"}, + }, + { + ID: "left_panel", + Description: "左侧主面板", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "panelLeft", ControlType: "Pane", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{PathHint: "root/4", AncestorID: "main_window"}, + Fallback: []FallbackCriteria{{AncestorID: "main_window", ControlType: "Pane", ChildAutomationIDContains: []string{"ucPnlRoster1", "ucFuzzyQuery1"}}}, + StabilityScore: 92, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4"}, + }, + { + ID: "roster_panel", + Description: "组织/联系人容器外层", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "ucPnlRoster1", ControlType: "Pane", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{PathHint: "root/4/0/0", AncestorID: "left_panel"}, + Fallback: []FallbackCriteria{{AncestorID: "left_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"ucRoster1"}}}, + StabilityScore: 88, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/0/0"}, + }, + { + ID: "roster_view", + Description: "组织/联系人列表区域", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "ucRoster1", ControlType: "Pane", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{PathHint: "root/4/0/0/0", AncestorID: "roster_panel"}, + Fallback: []FallbackCriteria{{AncestorID: "roster_panel", ControlType: "Pane", ClassPrefix: "WindowsForms10.Window.8.app.0"}}, + StabilityScore: 86, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/0/0/0"}, + }, + { + ID: "fuzzy_query_panel", + Description: "模糊搜索组件外层", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "ucFuzzyQuery1", ControlType: "Pane", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{PathHint: "root/4/1/0", AncestorID: "left_panel"}, + Fallback: []FallbackCriteria{{AncestorID: "left_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"FrmFuzzyQuery"}}}, + StabilityScore: 90, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0"}, + }, + { + ID: "fuzzy_query_window", + Description: "模糊搜索内部窗口", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "FrmFuzzyQuery", ControlType: "Pane", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{PathHint: "root/4/1/0/0", AncestorID: "fuzzy_query_panel"}, + Fallback: []FallbackCriteria{{AncestorID: "fuzzy_query_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"skinAlphaTxt"}}}, + StabilityScore: 88, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0/0"}, + }, + { + ID: "fuzzy_search_edit", + Description: "模糊搜索输入框", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "skinAlphaTxt", ControlType: "Edit", FrameworkID: "WinForm"}, + Preferred: PreferredCriteria{ClassName: "WindowsForms10.EDIT.app.0.d3a00f_r29_ad1", PathHint: "root/4/1/0/0/2", AncestorID: "fuzzy_query_window"}, + Fallback: []FallbackCriteria{{AncestorID: "fuzzy_query_window", ControlType: "Edit", ClassPrefix: "WindowsForms10.EDIT.app.0"}}, + StabilityScore: 93, + Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0/0/2"}, + }, + } +} diff --git a/internal/uiaselector/catalog_test.go b/internal/uiaselector/catalog_test.go new file mode 100644 index 0000000..b252a53 --- /dev/null +++ b/internal/uiaselector/catalog_test.go @@ -0,0 +1,60 @@ +package uiaselector + +import ( + "strings" + "testing" +) + +func TestDefaultCatalogContainsInitialN13Selectors(t *testing.T) { + catalog := DefaultCatalog() + wantIDs := []string{ + "main_window", + "main_plugin_tab", + "home_page_window", + "tab_page_content", + "left_panel", + "roster_panel", + "roster_view", + "fuzzy_query_panel", + "fuzzy_query_window", + "fuzzy_search_edit", + } + + byID := map[string]Selector{} + for _, selector := range catalog { + byID[selector.ID] = selector + } + + for _, id := range wantIDs { + selector, ok := byID[id] + if !ok { + t.Fatalf("DefaultCatalog missing selector %q", id) + } + if selector.AllowedUse != AllowedUseReadOnlyLocate { + t.Fatalf("selector %q allowed_use = %q, want %q", id, selector.AllowedUse, AllowedUseReadOnlyLocate) + } + if selector.Required.AutomationID == "" { + t.Fatalf("selector %q must require automation_id", id) + } + if selector.Required.ControlType == "" { + t.Fatalf("selector %q must require control_type", id) + } + if selector.StabilityScore < 80 { + t.Fatalf("selector %q stability_score = %d, want >= 80", id, selector.StabilityScore) + } + } +} + +func TestDefaultCatalogDoesNotDescribeActionTools(t *testing.T) { + for _, selector := range DefaultCatalog() { + if selector.AllowedUse != AllowedUseReadOnlyLocate { + t.Fatalf("selector %q allowed_use = %q, want read-only", selector.ID, selector.AllowedUse) + } + lower := strings.ToLower(selector.ID + " " + selector.Description) + for _, forbidden := range []string{"send", "upload", "download", "receive", "conversation", "login", "file_transfer"} { + if strings.Contains(lower, forbidden) { + t.Fatalf("selector %q contains action word %q", selector.ID, forbidden) + } + } + } +} diff --git a/internal/uiaselector/dump.go b/internal/uiaselector/dump.go new file mode 100644 index 0000000..39f1b3a --- /dev/null +++ b/internal/uiaselector/dump.go @@ -0,0 +1,109 @@ +package uiaselector + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "strings" +) + +type DumpResponse struct { + OK bool `json:"ok"` + Data *DumpData `json:"data"` + Error any `json:"error"` +} + +type DumpData struct { + Root *Node `json:"root"` +} + +type Node struct { + Name string `json:"name"` + ControlType string `json:"control_type"` + AutomationID string `json:"automation_id"` + ClassName string `json:"class_name"` + FrameworkID string `json:"framework_id"` + IsEnabled *bool `json:"is_enabled"` + IsOffscreen *bool `json:"is_offscreen"` + Bounds any `json:"bounds"` + Children []Node `json:"children"` +} + +func LoadDumpFile(path string) (*Node, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, err + } + content = normalizeDumpJSON(content) + var response DumpResponse + if err := json.Unmarshal(content, &response); err != nil { + return nil, err + } + if !response.OK { + return nil, fmt.Errorf("dump response ok=false: %v", response.Error) + } + if response.Data == nil || response.Data.Root == nil { + return nil, fmt.Errorf("dump response has no data.root") + } + return response.Data.Root, nil +} + +func CountNodes(root *Node) int { + if root == nil { + return 0 + } + count := 1 + for i := range root.Children { + count += CountNodes(&root.Children[i]) + } + return count +} + +func normalizeDumpJSON(content []byte) []byte { + content = bytes.TrimPrefix(content, []byte{0xEF, 0xBB, 0xBF}) + source := string(content) + var out strings.Builder + out.Grow(len(source)) + + inString := false + escaped := false + for i := 0; i < len(source); { + ch := source[i] + if inString { + out.WriteByte(ch) + if escaped { + escaped = false + } else if ch == '\\' { + escaped = true + } else if ch == '"' { + inString = false + } + i++ + continue + } + + if ch == '"' { + inString = true + out.WriteByte(ch) + i++ + continue + } + + switch { + case strings.HasPrefix(source[i:], "-Infinity"): + out.WriteString("null") + i += len("-Infinity") + case strings.HasPrefix(source[i:], "Infinity"): + out.WriteString("null") + i += len("Infinity") + case strings.HasPrefix(source[i:], "NaN"): + out.WriteString("null") + i += len("NaN") + default: + out.WriteByte(ch) + i++ + } + } + return []byte(out.String()) +} diff --git a/internal/uiaselector/dump_test.go b/internal/uiaselector/dump_test.go new file mode 100644 index 0000000..69866bb --- /dev/null +++ b/internal/uiaselector/dump_test.go @@ -0,0 +1,19 @@ +package uiaselector + +import "testing" + +func TestLoadDumpFixtureParsesAcceptedN12RTree(t *testing.T) { + root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json") + if err != nil { + t.Fatalf("LoadDumpFile: %v", err) + } + if root.AutomationID != "frmMain" { + t.Fatalf("root automation_id = %q, want frmMain", root.AutomationID) + } + if root.ControlType != "Window" { + t.Fatalf("root control_type = %q, want Window", root.ControlType) + } + if got := CountNodes(root); got != 66 { + t.Fatalf("CountNodes = %d, want 66", got) + } +} diff --git a/internal/uiaselector/matcher.go b/internal/uiaselector/matcher.go new file mode 100644 index 0000000..f6872af --- /dev/null +++ b/internal/uiaselector/matcher.go @@ -0,0 +1,186 @@ +package uiaselector + +import "strings" + +type MatchResult struct { + OK bool `json:"ok"` + SelectorID string `json:"selector_id"` + Matched bool `json:"matched"` + MatchCount int `json:"match_count"` + Matches []NodeMatch `json:"matches"` + Warnings []string `json:"warnings"` + Error MatchError `json:"error,omitempty"` +} + +type MatchError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type NodeMatch struct { + Path string `json:"path"` + ControlType string `json:"control_type"` + AutomationID string `json:"automation_id"` + ClassName string `json:"class_name"` + FrameworkID string `json:"framework_id"` + HasName bool `json:"has_name"` + NameLength int `json:"name_length"` + Name string `json:"-"` +} + +type nodeRef struct { + path string + node *Node +} + +func MatchSelector(root *Node, catalog []Selector, selectorID string) MatchResult { + selector, ok := findSelector(catalog, selectorID) + if !ok { + return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "UNKNOWN_SELECTOR", Message: "selector is not in catalog"}} + } + if selector.AllowedUse != AllowedUseReadOnlyLocate { + return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "INVALID_SELECTOR", Message: "selector is not read-only"}} + } + refs := flatten(root) + matches := matchCriteria(refs, selector.Required) + if len(matches) == 0 { + for _, fallback := range selector.Fallback { + matches = matchFallback(refs, fallback) + if len(matches) > 0 { + break + } + } + } + result := MatchResult{OK: true, SelectorID: selectorID, Matched: len(matches) > 0, MatchCount: len(matches), Warnings: []string{}} + for _, ref := range matches { + result.Matches = append(result.Matches, summarizeNode(ref)) + } + if !result.Matched { + result.Warnings = append(result.Warnings, "selector_not_found") + } + return result +} + +func findSelector(catalog []Selector, selectorID string) (Selector, bool) { + for _, selector := range catalog { + if selector.ID == selectorID { + return selector, true + } + } + return Selector{}, false +} + +func flatten(root *Node) []nodeRef { + refs := []nodeRef{} + var walk func(node *Node, path string) + walk = func(node *Node, path string) { + if node == nil { + return + } + refs = append(refs, nodeRef{path: path, node: node}) + for i := range node.Children { + walk(&node.Children[i], path+"/"+itoa(i)) + } + } + walk(root, "root") + return refs +} + +func itoa(value int) string { + if value == 0 { + return "0" + } + digits := []byte{} + for value > 0 { + digits = append([]byte{byte('0' + value%10)}, digits...) + value = value / 10 + } + return string(digits) +} + +func matchCriteria(refs []nodeRef, criteria Criteria) []nodeRef { + matches := []nodeRef{} + for _, ref := range refs { + if criteria.AutomationID != "" && ref.node.AutomationID != criteria.AutomationID { + continue + } + if criteria.ControlType != "" && ref.node.ControlType != criteria.ControlType { + continue + } + if criteria.FrameworkID != "" && ref.node.FrameworkID != criteria.FrameworkID { + continue + } + if criteria.ClassName != "" && ref.node.ClassName != criteria.ClassName { + continue + } + if criteria.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, criteria.ClassPrefix) { + continue + } + matches = append(matches, ref) + } + return matches +} + +func matchFallback(refs []nodeRef, fallback FallbackCriteria) []nodeRef { + matches := []nodeRef{} + for _, ref := range refs { + if fallback.ControlType != "" && ref.node.ControlType != fallback.ControlType { + continue + } + if fallback.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, fallback.ClassPrefix) { + continue + } + if !hasChildAutomationIDs(ref.node, fallback.ChildAutomationIDContains) { + continue + } + if !hasChildControlTypes(ref.node, fallback.ChildControlTypeContains) { + continue + } + matches = append(matches, ref) + } + return matches +} + +func hasChildAutomationIDs(node *Node, ids []string) bool { + for _, id := range ids { + found := false + for i := range node.Children { + if node.Children[i].AutomationID == id { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func hasChildControlTypes(node *Node, controlTypes []string) bool { + for _, controlType := range controlTypes { + found := false + for i := range node.Children { + if node.Children[i].ControlType == controlType { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func summarizeNode(ref nodeRef) NodeMatch { + return NodeMatch{ + Path: ref.path, + ControlType: ref.node.ControlType, + AutomationID: ref.node.AutomationID, + ClassName: ref.node.ClassName, + FrameworkID: ref.node.FrameworkID, + HasName: ref.node.Name != "", + NameLength: len(ref.node.Name), + } +} diff --git a/internal/uiaselector/matcher_test.go b/internal/uiaselector/matcher_test.go new file mode 100644 index 0000000..cad9fe8 --- /dev/null +++ b/internal/uiaselector/matcher_test.go @@ -0,0 +1,73 @@ +package uiaselector + +import "testing" + +func TestMatchSelectorFindsKnownN12RNodes(t *testing.T) { + root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json") + if err != nil { + t.Fatalf("LoadDumpFile: %v", err) + } + + tests := []struct { + selectorID string + path string + control string + autoID string + }{ + {selectorID: "main_window", path: "root", control: "Window", autoID: "frmMain"}, + {selectorID: "left_panel", path: "root/4", control: "Pane", autoID: "panelLeft"}, + {selectorID: "fuzzy_search_edit", path: "root/4/1/0/0/2", control: "Edit", autoID: "skinAlphaTxt"}, + } + + for _, tt := range tests { + result := MatchSelector(root, DefaultCatalog(), tt.selectorID) + if !result.OK { + t.Fatalf("%s OK=false: %#v", tt.selectorID, result.Error) + } + if !result.Matched { + t.Fatalf("%s matched=false", tt.selectorID) + } + if result.MatchCount != 1 { + t.Fatalf("%s match_count = %d, want 1", tt.selectorID, result.MatchCount) + } + match := result.Matches[0] + if match.Path != tt.path || match.ControlType != tt.control || match.AutomationID != tt.autoID { + t.Fatalf("%s match = %#v", tt.selectorID, match) + } + } +} + +func TestMatchSelectorDoesNotReturnVisibleText(t *testing.T) { + root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json") + if err != nil { + t.Fatalf("LoadDumpFile: %v", err) + } + result := MatchSelector(root, DefaultCatalog(), "main_window") + if !result.OK || !result.Matched || len(result.Matches) != 1 { + t.Fatalf("unexpected result: %#v", result) + } + match := result.Matches[0] + if match.Name != "" { + t.Fatalf("match returned visible name text %q", match.Name) + } + if !match.HasName { + t.Fatalf("main_window should report has_name=true") + } + if match.NameLength == 0 { + t.Fatalf("main_window should report non-zero name_length") + } +} + +func TestMatchSelectorUnknownSelectorIsStructuredFailure(t *testing.T) { + root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json") + if err != nil { + t.Fatalf("LoadDumpFile: %v", err) + } + result := MatchSelector(root, DefaultCatalog(), "missing_selector") + if result.OK { + t.Fatalf("OK=true for missing selector: %#v", result) + } + if result.Error.Code != "UNKNOWN_SELECTOR" { + t.Fatalf("error code = %q, want UNKNOWN_SELECTOR", result.Error.Code) + } +} diff --git a/internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json b/internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json new file mode 100644 index 0000000..524d271 --- /dev/null +++ b/internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json @@ -0,0 +1 @@ +{"protocol":"isphere.helper.v1","request_id":"n12r-dump-uia-main-retry","op":"dump_uia","ok":true,"data":{"hwnd":"0x290E6E","root":{"name":"国网甘肃省电力公司","control_type":"Window","automation_id":"frmMain","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗口","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":698,"y":2,"width":1220,"height":955},"value":"","children":[{"name":"我的工作台","control_type":"Text","automation_id":"skinLabel1","class_name":"WindowsForms10.STATIC.app.0.d3a00f_r29_ad1","localized_control_type":"文本","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":992,"y":10,"width":68,"height":17},"value":"","children":[]},{"name":"0931-2963114","control_type":"Text","automation_id":"skinLabelServiceTel","class_name":"WindowsForms10.STATIC.app.0.d3a00f_r29_ad1","localized_control_type":"文本","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":1740,"y":9,"width":90,"height":17},"value":"","children":[]},{"name":"我的工作台","control_type":"Pane","automation_id":"skinPanel8","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":985,"y":26,"width":1,"height":930},"value":"","children":[{"name":"我的工作台","control_type":"Pane","automation_id":"skinPanel11","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":985,"y":160,"width":1,"height":766},"value":"","children":[]},{"name":"我的工作台","control_type":"Pane","automation_id":"skinPanel10","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":985,"y":926,"width":1,"height":30},"value":"","children":[]},{"name":"我的工作台","control_type":"Pane","automation_id":"skinPanel9","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":985,"y":26,"width":1,"height":134},"value":"","children":[]}]},{"name":"0931-2963114","control_type":"Pane","automation_id":"ucPluginTabControl1","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":985,"y":26,"width":932,"height":930},"value":"","children":[{"name":"","control_type":"Pane","automation_id":"skinPanel1","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":985,"y":66,"width":932,"height":890},"value":"","children":[{"name":"FrmHomePage","control_type":"Window","automation_id":"FrmHomePage","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗口","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":985,"y":66,"width":932,"height":890},"value":"","children":[{"name":"","control_type":"Pane","automation_id":"skinPanel1","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":985,"y":66,"width":932,"height":890},"value":"","children":[]}]}]},{"name":"","control_type":"Pane","automation_id":"ucTabPage","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":985,"y":32,"width":932,"height":34},"value":"","children":[{"name":"FrmTabPageContent","control_type":"Window","automation_id":"FrmTabPageContent","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗口","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":985,"y":32,"width":932,"height":39},"value":"","children":[{"name":"FrmTabPageContent","control_type":"TitleBar","automation_id":"TitleBar","class_name":"","localized_control_type":"标题栏","framework_id":"WinForm","is_enabled":true,"is_offscreen":true,"bounds":{"x":Infinity,"y":Infinity,"width":-Infinity,"height":-Infinity},"value":"","children":[{"name":"最小化","control_type":"Button","automation_id":"Minimize","class_name":"","localized_control_type":"按钮","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":Infinity,"y":Infinity,"width":-Infinity,"height":-Infinity},"value":"","children":[]},{"name":"最大化","control_type":"Button","automation_id":"Maximize","class_name":"","localized_control_type":"按钮","framework_id":"WinForm","is_enabled":false,"is_offscreen":false,"bounds":{"x":Infinity,"y":Infinity,"width":-Infinity,"height":-Infinity},"value":"","children":[]},{"name":"上下文帮助","control_type":"Button","automation_id":"Help","class_name":"","localized_control_type":"按钮","framework_id":"WinForm","is_enabled":false,"is_offscreen":false,"bounds":{"x":Infinity,"y":Infinity,"width":-Infinity,"height":-Infinity},"value":"","children":[]},{"name":"关闭","control_type":"Button","automation_id":"Close","class_name":"","localized_control_type":"按钮","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":Infinity,"y":Infinity,"width":-Infinity,"height":-Infinity},"value":"","children":[]}]}]}]}]},{"name":"0931-2963114","control_type":"Pane","automation_id":"panelLeft","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":699,"y":26,"width":286,"height":930},"value":"","children":[{"name":"","control_type":"Pane","automation_id":"skinPanel5","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":699,"y":160,"width":286,"height":766},"value":"","children":[{"name":"","control_type":"Pane","automation_id":"ucPnlRoster1","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":699,"y":195,"width":286,"height":731},"value":"","children":[{"name":"ucRoster1","control_type":"Pane","automation_id":"ucRoster1","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":699,"y":217,"width":286,"height":709},"value":"","children":[]},{"name":"","control_type":"Pane","automation_id":"skinPanel1","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":699,"y":197,"width":286,"height":20},"value":"","children":[{"name":"skinToolStrip1","control_type":"ToolBar","automation_id":"skinToolStrip4","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"工具栏","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":699,"y":197,"width":286,"height":20},"value":"","children":[{"name":" ","control_type":"Text","automation_id":"","class_name":"","localized_control_type":"文本","framework_id":"","is_enabled":true,"is_offscreen":false,"bounds":{"x":980,"y":193,"width":5,"height":29},"value":"","children":[]},{"name":"添加","control_type":"MenuItem","automation_id":"","class_name":"","localized_control_type":"菜单项目","framework_id":"","is_enabled":true,"is_offscreen":false,"bounds":{"x":922,"y":197,"width":58,"height":19},"value":"","children":[{"name":"添加联系人","control_type":"MenuItem","automation_id":"","class_name":"","localized_control_type":"菜单项目","framework_id":"","is_enabled":true,"is_offscreen":false,"bounds":{"x":Infinity,"y":Infinity,"width":-Infinity,"height":-Infinity},"value":"","children":[]},{"name":"添加分组","control_type":"MenuItem","automation_id":"","class_name":"","localized_control_type":"菜单项目","framework_id":"","is_enabled":true,"is_offscreen":false,"bounds":{"x":Infinity,"y":Infinity,"width":-Infinity,"height":-Infinity},"value":"","children":[]}]}]}]}]}]},{"name":"","control_type":"Pane","automation_id":"skinPanel3","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":699,"y":128,"width":286,"height":32},"value":"","children":[{"name":"","control_type":"Pane","automation_id":"ucFuzzyQuery1","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":699,"y":128,"width":286,"height":32},"value":"","children":[{"name":"","control_type":"Pane","automation_id":"FrmFuzzyQuery","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":699,"y":128,"width":286,"height":32},"value":"","children":[{"name":"按帐号、姓名、拼音、标签查询联系人","control_type":"Pane","automation_id":"skinPBImg","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":966,"y":135,"width":16,"height":16},"value":"","children":[]},{"name":"按帐号、姓名、拼音、标签查询联系人","control_type":"Text","automation_id":"skinLblWaterTxt","class_name":"WindowsForms10.STATIC.app.0.d3a00f_r29_ad1","localized_control_type":"文本","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":703,"y":135,"width":261,"height":17},"value":"","children":[]},{"name":"按帐号、姓名、拼音、标签查询联系人","control_type":"Edit","automation_id":"skinAlphaTxt","class_name":"WindowsForms10.EDIT.app.0.d3a00f_r29_ad1","localized_control_type":"编辑","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":709,"y":135,"width":254,"height":16},"value":"","children":[]}]}]}]},{"name":"","control_type":"Pane","automation_id":"skinPanel4","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":699,"y":926,"width":286,"height":30},"value":"","children":[{"name":"skinToolStrip1","control_type":"ToolBar","automation_id":"skinToolStrip1","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"工具栏","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":700,"y":926,"width":284,"height":29},"value":"","children":[{"name":"主菜单","control_type":"Button","automation_id":"","class_name":"","localized_control_type":"按钮","framework_id":"","is_enabled":true,"is_offscreen":false,"bounds":{"x":709,"y":929,"width":24,"height":24},"value":"","children":[]},{"name":"系统设置","control_type":"Button","automation_id":"","class_name":"","localized_control_type":"按钮","framework_id":"","is_enabled":true,"is_offscreen":false,"bounds":{"x":739,"y":929,"width":24,"height":24},"value":"","children":[]},{"name":"查找联系人、工作组","control_type":"Button","automation_id":"","class_name":"","localized_control_type":"按钮","framework_id":"","is_enabled":true,"is_offscreen":false,"bounds":{"x":771,"y":929,"width":24,"height":24},"value":"","children":[]},{"name":"打开消息管理器","control_type":"Button","automation_id":"","class_name":"","localized_control_type":"按钮","framework_id":"","is_enabled":true,"is_offscreen":false,"bounds":{"x":802,"y":929,"width":24,"height":24},"value":"","children":[]},{"name":"我的工作台","control_type":"Button","automation_id":"","class_name":"","localized_control_type":"按钮","framework_id":"","is_enabled":true,"is_offscreen":false,"bounds":{"x":953,"y":929,"width":24,"height":24},"value":"","children":[]}]}]},{"name":"","control_type":"Pane","automation_id":"skinPanel1","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":699,"y":26,"width":286,"height":102},"value":"","children":[{"name":"","control_type":"Pane","automation_id":"SmallPanelHeadImgBorder","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":709,"y":36,"width":63,"height":63},"value":"","children":[{"name":"","control_type":"Pane","automation_id":"panelHeadImg","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":710,"y":37,"width":60,"height":60},"value":"","children":[{"name":"","control_type":"Pane","automation_id":"BagPanelHeadImgBorder","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":709,"y":36,"width":63,"height":63},"value":"","children":[]}]}]},{"name":"个性签名","control_type":"Pane","automation_id":"ucSignature","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":780,"y":77,"width":147,"height":21},"value":"","children":[{"name":"","control_type":"Pane","automation_id":"panelAll","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":780,"y":77,"width":147,"height":21},"value":"","children":[]}]},{"name":"","control_type":"Pane","automation_id":"ucQuickStartBar","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":708,"y":103,"width":226,"height":23},"value":"","children":[{"name":"","control_type":"Pane","automation_id":"panel1","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗格","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":708,"y":103,"width":226,"height":23},"value":"","children":[{"name":"FrmQuictStartBar","control_type":"Window","automation_id":"FrmQuictStartBarContent","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"窗口","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":708,"y":103,"width":226,"height":30},"value":"","children":[{"name":"FrmQuictStartBar","control_type":"TitleBar","automation_id":"TitleBar","class_name":"","localized_control_type":"标题栏","framework_id":"WinForm","is_enabled":true,"is_offscreen":true,"bounds":{"x":Infinity,"y":Infinity,"width":-Infinity,"height":-Infinity},"value":"","children":[{"name":"最小化","control_type":"Button","automation_id":"Minimize","class_name":"","localized_control_type":"按钮","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":Infinity,"y":Infinity,"width":-Infinity,"height":-Infinity},"value":"","children":[]},{"name":"最大化","control_type":"Button","automation_id":"Maximize","class_name":"","localized_control_type":"按钮","framework_id":"WinForm","is_enabled":false,"is_offscreen":false,"bounds":{"x":Infinity,"y":Infinity,"width":-Infinity,"height":-Infinity},"value":"","children":[]},{"name":"上下文帮助","control_type":"Button","automation_id":"Help","class_name":"","localized_control_type":"按钮","framework_id":"WinForm","is_enabled":false,"is_offscreen":false,"bounds":{"x":Infinity,"y":Infinity,"width":-Infinity,"height":-Infinity},"value":"","children":[]},{"name":"关闭","control_type":"Button","automation_id":"Close","class_name":"","localized_control_type":"按钮","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":Infinity,"y":Infinity,"width":-Infinity,"height":-Infinity},"value":"","children":[]}]}]}]}]},{"name":"skinToolStrip1","control_type":"ToolBar","automation_id":"skinToolStrip2","class_name":"WindowsForms10.Window.8.app.0.d3a00f_r29_ad1","localized_control_type":"工具栏","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":928,"y":100,"width":56,"height":26},"value":"","children":[{"name":"","control_type":"Button","automation_id":"","class_name":"","localized_control_type":"按钮","framework_id":"","is_enabled":true,"is_offscreen":false,"bounds":{"x":960,"y":105,"width":23,"height":19},"value":"","children":[]}]},{"name":"赵义仑","control_type":"Button","automation_id":"ucUserStatus1","class_name":"WindowsForms10.BUTTON.app.0.d3a00f_r29_ad1","localized_control_type":"按钮","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":838,"y":51,"width":18,"height":18},"value":"","children":[]},{"name":"赵义仑","control_type":"Text","automation_id":"lblUserName","class_name":"WindowsForms10.STATIC.app.0.d3a00f_r29_ad1","localized_control_type":"文本","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":779,"y":46,"width":64,"height":24},"value":"","children":[]}]}]},{"name":"国网甘肃省电力公司","control_type":"TitleBar","automation_id":"TitleBar","class_name":"","localized_control_type":"标题栏","framework_id":"WinForm","is_enabled":true,"is_offscreen":true,"bounds":{"x":Infinity,"y":Infinity,"width":-Infinity,"height":-Infinity},"value":"","children":[{"name":"系统菜单栏","control_type":"MenuBar","automation_id":"frmMain","class_name":"","localized_control_type":"菜单栏","framework_id":"WinForm","is_enabled":true,"is_offscreen":true,"bounds":{"x":706,"y":10,"width":22,"height":22},"value":"","children":[{"name":"系统","control_type":"MenuItem","automation_id":"Item 1","class_name":"","localized_control_type":"菜单项目","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":706,"y":10,"width":22,"height":22},"value":"","children":[]}]},{"name":"最小化","control_type":"Button","automation_id":"Minimize","class_name":"","localized_control_type":"按钮","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":Infinity,"y":Infinity,"width":-Infinity,"height":-Infinity},"value":"","children":[]},{"name":"最大化","control_type":"Button","automation_id":"Maximize","class_name":"","localized_control_type":"按钮","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":Infinity,"y":Infinity,"width":-Infinity,"height":-Infinity},"value":"","children":[]},{"name":"上下文帮助","control_type":"Button","automation_id":"Help","class_name":"","localized_control_type":"按钮","framework_id":"WinForm","is_enabled":false,"is_offscreen":false,"bounds":{"x":Infinity,"y":Infinity,"width":-Infinity,"height":-Infinity},"value":"","children":[]},{"name":"关闭","control_type":"Button","automation_id":"Close","class_name":"","localized_control_type":"按钮","framework_id":"WinForm","is_enabled":true,"is_offscreen":false,"bounds":{"x":Infinity,"y":Infinity,"width":-Infinity,"height":-Infinity},"value":"","children":[]}]}]}},"error":null,"warnings":[]} diff --git a/scripts/test-validate-n12r-return-package.ps1 b/scripts/test-validate-n12r-return-package.ps1 index eb889a9..92b7848 100644 --- a/scripts/test-validate-n12r-return-package.ps1 +++ b/scripts/test-validate-n12r-return-package.ps1 @@ -162,6 +162,16 @@ try { Assert-Contains $result.Output "Decision: REJECT" "Expected reject decision in output." } + Run-Test "accepts harmless process names that contain hook as a substring" { + $pkg = Join-Path $tempRoot "harmless-hook-substring" + $report = Join-Path $tempRoot "harmless-hook-substring-report.md" + New-GoodPackage $pkg + Write-Utf8Text (Join-Path $pkg "metadata\process-list.txt") "zfhookupdat 3240" + $result = Invoke-Validator -PackageRoot $pkg -ReportPath $report + Assert-True ($result.ExitCode -eq 0) "Expected validator success for harmless process-name substring, got exit $($result.ExitCode): $($result.Output)" + Assert-Contains $result.Output "Decision: ACCEPT" "Expected accept decision in output." + } + Run-Test "rejects report paths inside the evidence package" { $pkg = Join-Path $tempRoot "report-inside-package" New-GoodPackage $pkg diff --git a/scripts/validate-n12r-return-package.ps1 b/scripts/validate-n12r-return-package.ps1 index f6f72b2..42a8279 100644 --- a/scripts/validate-n12r-return-package.ps1 +++ b/scripts/validate-n12r-return-package.ps1 @@ -318,6 +318,9 @@ try { } foreach ($relative in ($requiredFiles + $optionalFiles)) { + if ($relative -eq "metadata/process-list.txt") { + continue + } $path = Join-PackagePath $packageFullPath $relative if (Test-Path -LiteralPath $path -PathType Leaf) { $text = if ($rawFiles.ContainsKey($relative)) { $rawFiles[$relative] } else { Read-TextFile $packageFullPath $relative } diff --git a/scripts/verify-n13-uia-selectors.ps1 b/scripts/verify-n13-uia-selectors.ps1 new file mode 100644 index 0000000..0510fd8 --- /dev/null +++ b/scripts/verify-n13-uia-selectors.ps1 @@ -0,0 +1,37 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path +Set-Location -LiteralPath $repo + +if (-not (Get-Command go -ErrorAction SilentlyContinue)) { + throw "go was not found. Run this verifier in a Go 1.23.x environment." +} + +Write-Host "## go test ./internal/uiaselector" +go test ./internal/uiaselector -count=1 +if ($LASTEXITCODE -ne 0) { + throw "go test ./internal/uiaselector failed with exit code $LASTEXITCODE" +} + +Write-Host "## boundary grep" +$forbidden = @( + "send_after_approval", + "send_file_after_approval", + "receive_file_after_approval", + "open_conversation", + "ValuePattern.SetValue", + "InvokePattern" +) +$files = @( + "internal\uiaselector\catalog.go", + "internal\uiaselector\matcher.go" +) +foreach ($term in $forbidden) { + $hit = Select-String -LiteralPath $files -Pattern $term -SimpleMatch -ErrorAction SilentlyContinue + if ($hit) { + throw "forbidden action term found in selector implementation: $term" + } +} + +Write-Host "N13 UIA selector verification passed." \ No newline at end of file