Files
isphere-ai-bridge/docs/superpowers/plans/2026-07-09-n13-uia-selector-catalog-implementation.md

944 lines
28 KiB
Markdown

# 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 <changed verification docs>
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.