From 2609b2028df84227c58e07d87c81db14e5084748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=B3=E5=A4=B4?= <3301352378@qq.com> Date: Thu, 9 Jul 2026 17:04:19 +0800 Subject: [PATCH 1/4] docs: design N15 selector report hardening --- ...09-n15-selector-report-hardening-design.md | 266 ++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-09-n15-selector-report-hardening-design.md diff --git a/docs/superpowers/specs/2026-07-09-n15-selector-report-hardening-design.md b/docs/superpowers/specs/2026-07-09-n15-selector-report-hardening-design.md new file mode 100644 index 0000000..9bbe28e --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-n15-selector-report-hardening-design.md @@ -0,0 +1,266 @@ +# N15 Selector Report Hardening Design + +日期:2026-07-09 + +## 1. 背景 + +N14 已在 `main` 上合并并推送,提供了离线 UIA selector 报告能力: + +- `internal/uiaselector/report.go`:报告模型、catalog report generator、JSON/Markdown renderer。 +- `cmd/uia-selector-report/main.go`:离线 CLI,只接受 dump 文件路径和输出报告路径。 +- `scripts/verify-n14-uia-selector-report.ps1`:运行测试、生成报告并做基础安全扫描。 + +N14 最终 review 留下两个非阻塞建议: + +1. `StrictExitAmbiguous` 分支缺少显式单测。 +2. verifier 的输出安全检查仍以字符串扫描为主,并且运行 CLI 前没有清理旧输出文件。 + +N15 的目标是把这些 review 建议收口为一个小型加固节点,不扩展产品能力,不接触 live UI,不新增 helper op,不新增 MCP tool,不扩展 live action surface。 + +## 2. 目标 + +N15 只做报告安全与验证加固: + +1. 增加 `StrictExitAmbiguous` 显式单测,锁定 `ambiguous_count > 0` 时严格模式返回 `3`。 +2. 加固 `scripts/verify-n14-uia-selector-report.ps1`,避免旧报告掩盖 CLI 输出回归。 +3. 增加输出文件检查:必须存在、必须是文件、大小必须大于 0。 +4. 增加 JSON 字段 allowlist 检查,确认 `selectors[].matches[]` 只包含允许的结构字段。 +5. 保持 N14 报告格式和 CLI 参数不变。 + +## 3. 非目标 + +N15 不做以下事项: + +```text +新增 selector +修改 selector catalog +修改报告 JSON/Markdown 格式 +新增 CLI 参数 +新增 helper op +新增 MCP tool +连接内网 iSphere +live dump 当前窗口 +使用 hwnd/process 参数 +点击控件 +输入文本 +打开会话 +搜索联系人 +发送消息 +发送文件 +接收文件 +读取聊天内容 +导出完整 visible text +``` + +N15 也不清理本地分支。分支清理可以在 N15 合并后单独执行。 + +## 4. 推荐方案 + +采用“小范围加固”方案,只修改两个文件: + +```text +internal/uiaselector/report_test.go +scripts/verify-n14-uia-selector-report.ps1 +``` + +不修改: + +```text +native/ISphereWinHelper/Program.cs +internal/tools/winhelper.go +cmd/isphere-mcp/main.go +cmd/uia-selector-report/main.go +internal/uiaselector/report.go +``` + +理由:N14 的实现逻辑已经通过最终验证和 review。N15 只需要补测试和 verifier 防回归能力,不需要改变生产代码路径。 + +## 5. Ambiguous strict-mode 测试设计 + +在 `internal/uiaselector/report_test.go` 中新增测试: + +```text +TestStrictModeClassifiesAmbiguousMatches +``` + +测试构造一个最小 UIA tree: + +```text +root Window frmMain + child Pane duplicate + child Pane duplicate +``` + +测试 catalog 只包含一个 read-only selector: + +```text +id: duplicate_panel +allowed_use: read_only_locate +required: + automation_id: duplicate + control_type: Pane + framework_id: WinForm +``` + +期望: + +```text +report.OK == true +report.CatalogSize == 1 +report.MatchedCount == 1 +report.UnmatchedCount == 0 +report.AmbiguousCount == 1 +report.Selectors[0].MatchCount == 2 +StrictExitCode(report) == StrictExitAmbiguous +``` + +该测试只使用内存构造的 `Node`,不依赖 N12R fixture,也不涉及 visible text。 + +## 6. Verifier 加固设计 + +### 6.1 清理旧输出 + +在 `go run ./cmd/uia-selector-report` 之前删除旧报告: + +```powershell +Remove-Item -LiteralPath $jsonPath, $mdPath -Force -ErrorAction SilentlyContinue +``` + +目的:如果未来 CLI 回归为“退出 0 但未写新文件”,旧报告不会让后续存在性检查误通过。 + +### 6.2 文件存在和非空检查 + +替换当前 `Test-Path` 检查为更严格的文件检查: + +```powershell +$jsonItem = Get-Item -LiteralPath $jsonPath -ErrorAction Stop +if (-not $jsonItem.PSIsContainer -eq $false) { ... } +if ($jsonItem.Length -le 0) { ... } +``` + +实际实现应避免 PowerShell 运算优先级歧义,推荐使用清晰表达: + +```powershell +if ($jsonItem.PSIsContainer) { throw "JSON report path is not a file: $jsonPath" } +if ($jsonItem.Length -le 0) { throw "JSON report is empty: $jsonPath" } +``` + +Markdown 报告同样检查。 + +### 6.3 JSON match 字段 allowlist + +解析 JSON 后,对每个 `selectors[].matches[]` 对象检查字段集合,只允许: + +```text +path +control_type +automation_id +class_name +framework_id +has_name +name_length +``` + +禁止出现: + +```text +name +value +text +visible_text +``` + +实现策略: + +1. 遍历 `$report.selectors`。 +2. 遍历 `$selector.matches`。 +3. 对每个 match 的 `$match.PSObject.Properties.Name` 做集合比较。 +4. 遇到不在 allowlist 中的字段立即失败。 +5. 如果字段名是 `name` 或 `value`,错误信息明确指出这是 visible text 字段泄漏。 + +这比单纯扫描字符串更稳,因为它检查的是 JSON 对象结构。 + +### 6.4 保留字符串扫描 + +保留 N14 已有字符串扫描,用于发现: + +```text +"name" +"value" +国网甘肃省电力公司 +send_after_approval +send_file_after_approval +receive_file_after_approval +open_conversation +InvokePattern +ValuePattern.SetValue +``` + +字段 allowlist 和字符串扫描同时存在: + +- allowlist 负责结构化字段泄漏。 +- 字符串扫描负责已知敏感文本和动作词防线。 + +## 7. 测试与验证策略 + +N15 implementation plan 必须采用 TDD。 + +最小验证命令: + +```powershell +go test ./internal/uiaselector -run "TestStrictModeClassifiesAmbiguousMatches" -count=1 +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n14-uia-selector-report.ps1 +go test ./... +``` + +最终验证命令: + +```powershell +go test ./internal/uiaselector ./cmd/uia-selector-report -count=1 +go test ./... +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n14-uia-selector-report.ps1 +git diff --name-only main..HEAD -- native/ISphereWinHelper/Program.cs internal/tools/winhelper.go cmd/isphere-mcp/main.go +``` + +最后一条命令期望无输出。 + +## 8. 风险与缓解 + +### 风险 1:PowerShell 对单元素数组展开造成遍历差异 + +缓解:实现遍历时使用 `@($report.selectors)` 和 `@($selector.matches)`,确保单元素和多元素都按数组处理。 + +### 风险 2:allowlist 检查误报 PowerShell 内置属性 + +缓解:只读取 `$match.PSObject.Properties.Name`,这返回 JSON 对象自身属性,不读取方法或扩展成员。 + +### 风险 3:旧输出清理误删非目标文件 + +缓解:只删除两个明确计算出的文件路径: + +```text +runs/n14-selector-report/n12r-selector-report.json +runs/n14-selector-report/n12r-selector-report.md +``` + +不递归删除目录。 + +## 9. 验收条件 + +N15 可接受条件: + +1. `StrictExitAmbiguous` 有显式单测。 +2. verifier 运行 CLI 前删除旧 JSON/Markdown 输出文件。 +3. verifier 检查 JSON/Markdown 输出存在、是文件、非空。 +4. verifier 对 JSON `matches[]` 执行字段 allowlist 检查。 +5. verifier 继续扫描 `"name"`、`"value"`、已知 visible text 和动作词。 +6. `go test ./...` 通过。 +7. `scripts\verify-n14-uia-selector-report.ps1` 通过。 +8. 不修改 helper/MCP/live action 文件。 + +## 10. 推荐下一步 + +下一步写 N15 implementation plan,拆分为两个小任务: + +1. TDD 增加 ambiguous strict-mode 单测。 +2. 加固 verifier 的旧输出清理、文件检查和 JSON 字段 allowlist。 \ No newline at end of file From d85fae08520dddef9266a66b511a4c82dc7fe49e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=B3=E5=A4=B4?= <3301352378@qq.com> Date: Thu, 9 Jul 2026 17:06:50 +0800 Subject: [PATCH 2/4] docs: plan N15 selector report hardening --- ...elector-report-hardening-implementation.md | 466 ++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-n15-selector-report-hardening-implementation.md diff --git a/docs/superpowers/plans/2026-07-09-n15-selector-report-hardening-implementation.md b/docs/superpowers/plans/2026-07-09-n15-selector-report-hardening-implementation.md new file mode 100644 index 0000000..322d82f --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-n15-selector-report-hardening-implementation.md @@ -0,0 +1,466 @@ +# N15 Selector Report Hardening 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:** Harden the N14 offline selector report tests and verifier so ambiguous strict-mode behavior and report-output safety regressions are caught before merge. + +**Architecture:** Keep N15 as a test/verifier-only change. Add an in-memory ambiguous-match test to `internal/uiaselector/report_test.go`, then strengthen `scripts/verify-n14-uia-selector-report.ps1` with stale-output cleanup, file/non-empty checks, and JSON match-field allowlist validation. + +**Tech Stack:** Go 1.23.x tests, existing `internal/uiaselector` package, Windows PowerShell 5.1-compatible verifier script, standard `go test` and repository Git workflow. + +--- + +## Scope and boundary + +N15 changes only test and verifier coverage. + +Modify only: + +```text +internal/uiaselector/report_test.go +scripts/verify-n14-uia-selector-report.ps1 +``` + +Do not modify: + +```text +native/ISphereWinHelper/Program.cs +internal/tools/winhelper.go +cmd/isphere-mcp/main.go +cmd/uia-selector-report/main.go +internal/uiaselector/report.go +internal/uiaselector/catalog.go +internal/uiaselector/matcher.go +``` + +Do not add selectors, helper ops, MCP tools, CLI arguments, live hwnd/process handling, UI automation calls, clicks, keyboard input, clipboard operations, conversation actions, message sending, file transfer, or visible text export. + +## File structure + +```text +internal/uiaselector/report_test.go + Add one explicit ambiguous strict-mode regression test using an in-memory UIA tree. + +scripts/verify-n14-uia-selector-report.ps1 + Clean stale outputs before CLI execution, require fresh non-empty report files, and validate JSON match fields with an allowlist. +``` + +## Task 1: Cover ambiguous strict-mode classification + +**Files:** +- Modify: `internal/uiaselector/report_test.go` + +- [ ] **Step 1: Add the ambiguous strict-mode regression test** + +Append this test to `internal/uiaselector/report_test.go`: + +```go +func TestStrictModeClassifiesAmbiguousMatches(t *testing.T) { + root := &Node{ + ControlType: "Window", + AutomationID: "frmMain", + FrameworkID: "WinForm", + Children: []Node{ + {ControlType: "Pane", AutomationID: "duplicate", FrameworkID: "WinForm"}, + {ControlType: "Pane", AutomationID: "duplicate", FrameworkID: "WinForm"}, + }, + } + catalog := []Selector{ + { + ID: "duplicate_panel", + Description: "duplicate structural selector", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "duplicate", ControlType: "Pane", FrameworkID: "WinForm"}, + StabilityScore: 80, + Evidence: Evidence{CaptureID: "unit-test", UIAPath: "root/duplicate"}, + }, + } + + report := RunCatalogReport("synthetic-ambiguous.json", root, catalog, fixedReportTime()) + if !report.OK { + t.Fatalf("report OK=false: %#v", report.Error) + } + if report.CatalogSize != 1 { + t.Fatalf("catalog_size = %d, want 1", report.CatalogSize) + } + if report.MatchedCount != 1 { + t.Fatalf("matched_count = %d, want 1", report.MatchedCount) + } + if report.UnmatchedCount != 0 { + t.Fatalf("unmatched_count = %d, want 0", report.UnmatchedCount) + } + if report.AmbiguousCount != 1 { + t.Fatalf("ambiguous_count = %d, want 1", report.AmbiguousCount) + } + if len(report.Selectors) != 1 { + t.Fatalf("len(selectors) = %d, want 1", len(report.Selectors)) + } + if got := report.Selectors[0].MatchCount; got != 2 { + t.Fatalf("selector match_count = %d, want 2", got) + } + if got := StrictExitCode(report); got != StrictExitAmbiguous { + t.Fatalf("StrictExitCode = %d, want %d", got, StrictExitAmbiguous) + } +} +``` + +- [ ] **Step 2: Prove the test catches a broken ambiguous branch** + +Temporarily change `internal/uiaselector/report.go` in `StrictExitCode` from: + +```go + if report.AmbiguousCount > 0 { + return StrictExitAmbiguous + } +``` + +to: + +```go + if report.AmbiguousCount > 0 { + return StrictExitOK + } +``` + +Run: + +```powershell +gofmt -w internal\uiaselector\report_test.go +go test ./internal/uiaselector -run "TestStrictModeClassifiesAmbiguousMatches" -count=1 +``` + +Expected: FAIL with: + +```text +StrictExitCode = 0, want 3 +``` + +Restore `internal/uiaselector/report.go` immediately after this command so production code again returns `StrictExitAmbiguous` for ambiguous reports. Do not commit the temporary mutation. + +- [ ] **Step 3: Run the ambiguous test with production code restored** + +Run: + +```powershell +gofmt -w internal\uiaselector\report_test.go internal\uiaselector\report.go +go test ./internal/uiaselector -run "TestStrictModeClassifiesAmbiguousMatches" -count=1 +``` + +Expected: PASS. + +- [ ] **Step 4: Run package tests** + +Run: + +```powershell +go test ./internal/uiaselector -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit ambiguous strict-mode test** + +Run: + +```powershell +git add internal\uiaselector\report_test.go +git diff --cached --name-only +``` + +Expected staged file list: + +```text +internal/uiaselector/report_test.go +``` + +Then commit: + +```powershell +git commit -m "test: cover N15 ambiguous strict exit" +``` + +## Task 2: Harden N14 selector report verifier + +**Files:** +- Modify: `scripts/verify-n14-uia-selector-report.ps1` + +- [ ] **Step 1: Run a failing pre-change verifier hardening check** + +Run this PowerShell check before editing the verifier: + +```powershell +$scriptPath = "scripts\verify-n14-uia-selector-report.ps1" +$scriptText = Get-Content -LiteralPath $scriptPath -Raw -Encoding UTF8 +$requiredSnippets = @( + 'Remove-Item -LiteralPath $jsonPath, $mdPath -Force -ErrorAction SilentlyContinue', + '$jsonItem = Get-Item -LiteralPath $jsonPath -ErrorAction Stop', + 'if ($jsonItem.PSIsContainer)', + 'if ($jsonItem.Length -le 0)', + '$mdItem = Get-Item -LiteralPath $mdPath -ErrorAction Stop', + '$allowedMatchFields = @(', + '$match.PSObject.Properties', + 'JSON match leaks visible text field' +) +foreach ($snippet in $requiredSnippets) { + if (-not $scriptText.Contains($snippet)) { + throw "missing verifier hardening snippet: $snippet" + } +} +``` + +Expected: FAIL, with the first missing snippet indicating stale-output cleanup is not implemented yet. + +- [ ] **Step 2: Insert stale-output cleanup before CLI execution** + +In `scripts/verify-n14-uia-selector-report.ps1`, after: + +```powershell +$jsonPath = Join-Path $outDir "n12r-selector-report.json" +$mdPath = Join-Path $outDir "n12r-selector-report.md" +``` + +insert: + +```powershell +Remove-Item -LiteralPath $jsonPath, $mdPath -Force -ErrorAction SilentlyContinue +``` + +- [ ] **Step 3: Replace output file existence checks with file and non-empty checks** + +Replace this block: + +```powershell +if (-not (Test-Path -LiteralPath $jsonPath)) { + throw "JSON report was not created: $jsonPath" +} +if (-not (Test-Path -LiteralPath $mdPath)) { + throw "Markdown report was not created: $mdPath" +} +``` + +with: + +```powershell +$jsonItem = Get-Item -LiteralPath $jsonPath -ErrorAction Stop +if ($jsonItem.PSIsContainer) { + throw "JSON report path is not a file: $jsonPath" +} +if ($jsonItem.Length -le 0) { + throw "JSON report is empty: $jsonPath" +} +$mdItem = Get-Item -LiteralPath $mdPath -ErrorAction Stop +if ($mdItem.PSIsContainer) { + throw "Markdown report path is not a file: $mdPath" +} +if ($mdItem.Length -le 0) { + throw "Markdown report is empty: $mdPath" +} +``` + +- [ ] **Step 4: Add JSON match-field allowlist validation after report summary checks** + +After this block: + +```powershell +if ($report.ambiguous_count -ne 0) { + throw "ambiguous_count=$($report.ambiguous_count), want 0" +} +``` + +insert: + +```powershell +Write-Host "## JSON match field allowlist" +$allowedMatchFields = @( + "path", + "control_type", + "automation_id", + "class_name", + "framework_id", + "has_name", + "name_length" +) +$allowedMatchFieldLookup = @{} +foreach ($field in $allowedMatchFields) { + $allowedMatchFieldLookup[$field] = $true +} +foreach ($selector in @($report.selectors)) { + foreach ($match in @($selector.matches)) { + if ($null -eq $match) { + continue + } + foreach ($property in @($match.PSObject.Properties)) { + $fieldName = $property.Name + if ($fieldName -eq "name" -or $fieldName -eq "value") { + throw "JSON match leaks visible text field: $fieldName" + } + if (-not $allowedMatchFieldLookup.ContainsKey($fieldName)) { + throw "JSON match contains disallowed field: $fieldName" + } + } + } +} +``` + +- [ ] **Step 5: Run the hardening check again** + +Run: + +```powershell +$scriptPath = "scripts\verify-n14-uia-selector-report.ps1" +$scriptText = Get-Content -LiteralPath $scriptPath -Raw -Encoding UTF8 +$requiredSnippets = @( + 'Remove-Item -LiteralPath $jsonPath, $mdPath -Force -ErrorAction SilentlyContinue', + '$jsonItem = Get-Item -LiteralPath $jsonPath -ErrorAction Stop', + 'if ($jsonItem.PSIsContainer)', + 'if ($jsonItem.Length -le 0)', + '$mdItem = Get-Item -LiteralPath $mdPath -ErrorAction Stop', + '$allowedMatchFields = @(', + '$match.PSObject.Properties', + 'JSON match leaks visible text field' +) +foreach ($snippet in $requiredSnippets) { + if (-not $scriptText.Contains($snippet)) { + throw "missing verifier hardening snippet: $snippet" + } +} +Write-Host "VERIFIER_HARDENING_SNIPPETS_OK" +``` + +Expected: + +```text +VERIFIER_HARDENING_SNIPPETS_OK +``` + +- [ ] **Step 6: Run the hardened verifier** + +Run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n14-uia-selector-report.ps1 +``` + +Expected output includes: + +```text +## go test ./internal/uiaselector ./cmd/uia-selector-report +## go run ./cmd/uia-selector-report +N14 selector report generated: catalog=10 matched=10 unmatched=0 ambiguous=0 +## JSON match field allowlist +## output safety scan +N14 UIA selector report verification passed. +``` + +- [ ] **Step 7: Commit verifier hardening** + +Run: + +```powershell +git add scripts\verify-n14-uia-selector-report.ps1 +git diff --cached --name-only +``` + +Expected staged file list: + +```text +scripts/verify-n14-uia-selector-report.ps1 +``` + +Then commit: + +```powershell +git commit -m "test: harden N15 selector report verifier" +``` + +## Task 3: Final verification and boundary check + +**Files:** +- No new files. +- Verify: `internal/uiaselector/report_test.go`, `scripts/verify-n14-uia-selector-report.ps1`, and unchanged helper/MCP action surface. + +- [ ] **Step 1: Run targeted tests** + +Run: + +```powershell +go test ./internal/uiaselector -run "TestStrictModeClassifiesAmbiguousMatches" -count=1 +go test ./internal/uiaselector ./cmd/uia-selector-report -count=1 +``` + +Expected: both commands PASS. + +- [ ] **Step 2: Run repository tests** + +Run: + +```powershell +go test ./... +``` + +Expected: all packages PASS. + +If dependency download fails with a transient proxy error, retry once with: + +```powershell +$env:GOPROXY = "https://goproxy.cn,direct" +go test ./... +``` + +- [ ] **Step 3: Run the hardened verifier** + +Run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n14-uia-selector-report.ps1 +``` + +Expected: verifier PASS and output includes `## JSON match field allowlist`. + +- [ ] **Step 4: Check helper/MCP/live action boundary** + +Run: + +```powershell +git diff --name-only main..HEAD -- native/ISphereWinHelper/Program.cs internal/tools/winhelper.go cmd/isphere-mcp/main.go cmd/uia-selector-report/main.go internal/uiaselector/report.go internal/uiaselector/catalog.go internal/uiaselector/matcher.go +``` + +Expected: no output. + +If any file appears, stop and inspect because N15 is test/verifier-only. + +- [ ] **Step 5: Check changed files** + +Run: + +```powershell +git diff --name-only main..HEAD +``` + +Expected changed files are limited to: + +```text +docs/superpowers/specs/2026-07-09-n15-selector-report-hardening-design.md +docs/superpowers/plans/2026-07-09-n15-selector-report-hardening-implementation.md +internal/uiaselector/report_test.go +scripts/verify-n14-uia-selector-report.ps1 +``` + +- [ ] **Step 6: Final status** + +Run: + +```powershell +git status --short --branch +git log --oneline --max-count=6 +``` + +Expected: working tree clean on `codex/n15-report-hardening`. Do not push unless the user explicitly requests pushing this branch or merging to main. + +## Self-review checklist + +- Spec coverage: ambiguous strict-mode test, stale-output cleanup, file/non-empty checks, JSON match-field allowlist, retained forbidden string scan, and helper/MCP boundary check are all covered. +- Scope: only `internal/uiaselector/report_test.go` and `scripts/verify-n14-uia-selector-report.ps1` are implementation targets; design and plan docs are documentation targets. +- Boundary: no helper op, no MCP tool, no live hwnd/process behavior, no UI action, no report format change, no CLI argument change. +- Verification: final commands include targeted tests, all repository tests, hardened verifier, boundary diff check, and changed-file check. +- Commit cadence: test and verifier hardening are separate commits. \ No newline at end of file From a009128e50275c312535cb5e92758078c779b4f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=B3=E5=A4=B4?= <3301352378@qq.com> Date: Thu, 9 Jul 2026 17:14:32 +0800 Subject: [PATCH 3/4] test: cover N15 ambiguous strict exit --- internal/uiaselector/report_test.go | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/internal/uiaselector/report_test.go b/internal/uiaselector/report_test.go index de36437..f19efa0 100644 --- a/internal/uiaselector/report_test.go +++ b/internal/uiaselector/report_test.go @@ -141,3 +141,51 @@ func TestMarkdownReportDoesNotExposeVisibleText(t *testing.T) { } } } + +func TestStrictModeClassifiesAmbiguousMatches(t *testing.T) { + root := &Node{ + ControlType: "Window", + AutomationID: "frmMain", + FrameworkID: "WinForm", + Children: []Node{ + {ControlType: "Pane", AutomationID: "duplicate", FrameworkID: "WinForm"}, + {ControlType: "Pane", AutomationID: "duplicate", FrameworkID: "WinForm"}, + }, + } + catalog := []Selector{ + { + ID: "duplicate_panel", + Description: "duplicate structural selector", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "duplicate", ControlType: "Pane", FrameworkID: "WinForm"}, + StabilityScore: 80, + Evidence: Evidence{CaptureID: "unit-test", UIAPath: "root/duplicate"}, + }, + } + + report := RunCatalogReport("synthetic-ambiguous.json", root, catalog, fixedReportTime()) + if !report.OK { + t.Fatalf("report OK=false: %#v", report.Error) + } + if report.CatalogSize != 1 { + t.Fatalf("catalog_size = %d, want 1", report.CatalogSize) + } + if report.MatchedCount != 1 { + t.Fatalf("matched_count = %d, want 1", report.MatchedCount) + } + if report.UnmatchedCount != 0 { + t.Fatalf("unmatched_count = %d, want 0", report.UnmatchedCount) + } + if report.AmbiguousCount != 1 { + t.Fatalf("ambiguous_count = %d, want 1", report.AmbiguousCount) + } + if len(report.Selectors) != 1 { + t.Fatalf("len(selectors) = %d, want 1", len(report.Selectors)) + } + if got := report.Selectors[0].MatchCount; got != 2 { + t.Fatalf("selector match_count = %d, want 2", got) + } + if got := StrictExitCode(report); got != StrictExitAmbiguous { + t.Fatalf("StrictExitCode = %d, want %d", got, StrictExitAmbiguous) + } +} From fcd7a2ec53205a49257b94f10cb736b3d02ecd75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=B3=E5=A4=B4?= <3301352378@qq.com> Date: Thu, 9 Jul 2026 17:20:34 +0800 Subject: [PATCH 4/4] test: harden N15 selector report verifier --- scripts/verify-n14-uia-selector-report.ps1 | 48 ++++++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/scripts/verify-n14-uia-selector-report.ps1 b/scripts/verify-n14-uia-selector-report.ps1 index 683bf83..4eadf20 100644 --- a/scripts/verify-n14-uia-selector-report.ps1 +++ b/scripts/verify-n14-uia-selector-report.ps1 @@ -18,6 +18,7 @@ $outDir = Join-Path $repo "runs\n14-selector-report" New-Item -ItemType Directory -Force -Path $outDir | Out-Null $jsonPath = Join-Path $outDir "n12r-selector-report.json" $mdPath = Join-Path $outDir "n12r-selector-report.md" +Remove-Item -LiteralPath $jsonPath, $mdPath -Force -ErrorAction SilentlyContinue Write-Host "## go run ./cmd/uia-selector-report" go run ./cmd/uia-selector-report ` @@ -29,11 +30,19 @@ if ($LASTEXITCODE -ne 0) { throw "uia-selector-report failed with exit code $LASTEXITCODE" } -if (-not (Test-Path -LiteralPath $jsonPath)) { - throw "JSON report was not created: $jsonPath" +$jsonItem = Get-Item -LiteralPath $jsonPath -ErrorAction Stop +if ($jsonItem.PSIsContainer) { + throw "JSON report path is not a file: $jsonPath" } -if (-not (Test-Path -LiteralPath $mdPath)) { - throw "Markdown report was not created: $mdPath" +if ($jsonItem.Length -le 0) { + throw "JSON report is empty: $jsonPath" +} +$mdItem = Get-Item -LiteralPath $mdPath -ErrorAction Stop +if ($mdItem.PSIsContainer) { + throw "Markdown report path is not a file: $mdPath" +} +if ($mdItem.Length -le 0) { + throw "Markdown report is empty: $mdPath" } $report = Get-Content -LiteralPath $jsonPath -Raw -Encoding UTF8 | ConvertFrom-Json @@ -53,6 +62,37 @@ if ($report.ambiguous_count -ne 0) { throw "ambiguous_count=$($report.ambiguous_count), want 0" } +Write-Host "## JSON match field allowlist" +$allowedMatchFields = @( + "path", + "control_type", + "automation_id", + "class_name", + "framework_id", + "has_name", + "name_length" +) +$allowedMatchFieldLookup = @{} +foreach ($field in $allowedMatchFields) { + $allowedMatchFieldLookup[$field] = $true +} +foreach ($selector in @($report.selectors)) { + foreach ($match in @($selector.matches)) { + if ($null -eq $match) { + continue + } + foreach ($property in @($match.PSObject.Properties)) { + $fieldName = $property.Name + if ($fieldName -eq "name" -or $fieldName -eq "value") { + throw "JSON match leaks visible text field: $fieldName" + } + if (-not $allowedMatchFieldLookup.ContainsKey($fieldName)) { + throw "JSON match contains disallowed field: $fieldName" + } + } + } +} + Write-Host "## output safety scan" $jsonRaw = Get-Content -LiteralPath $jsonPath -Raw -Encoding UTF8 $mdRaw = Get-Content -LiteralPath $mdPath -Raw -Encoding UTF8