package uiaselector import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "time" ) const ( StrictExitOK = 0 StrictExitReportGenerationFailure = 1 StrictExitUnmatched = 2 StrictExitAmbiguous = 3 StrictExitInvalidCatalog = 4 ) type Report struct { OK bool `json:"ok"` SourceDump string `json:"source_dump"` CatalogSize int `json:"catalog_size"` MatchedCount int `json:"matched_count"` UnmatchedCount int `json:"unmatched_count"` AmbiguousCount int `json:"ambiguous_count"` GeneratedAtUTC string `json:"generated_at_utc"` Selectors []SelectorReport `json:"selectors"` Warnings []string `json:"warnings"` Error *ReportError `json:"error,omitempty"` } type ReportError struct { Code string `json:"code"` Message string `json:"message"` } type SelectorReport struct { SelectorID string `json:"selector_id"` Description string `json:"description"` AllowedUse string `json:"allowed_use"` StabilityScore int `json:"stability_score"` Evidence Evidence `json:"evidence"` Matched bool `json:"matched"` MatchCount int `json:"match_count"` Matches []NodeMatch `json:"matches"` Warnings []string `json:"warnings"` } func RunCatalogReport(sourceDump string, root *Node, catalog []Selector, generatedAt time.Time) Report { report := Report{ OK: true, SourceDump: sourceDump, CatalogSize: len(catalog), GeneratedAtUTC: generatedAt.UTC().Format(time.RFC3339), Warnings: []string{}, } if root == nil { report.OK = false report.Error = &ReportError{Code: "NO_ROOT", Message: "UIA dump root is nil"} return report } for _, selector := range catalog { if selector.AllowedUse != AllowedUseReadOnlyLocate { report.OK = false report.Error = &ReportError{Code: "INVALID_CATALOG", Message: "selector is not read-only: " + selector.ID} return report } match := MatchSelector(root, catalog, selector.ID) selectorReport := SelectorReport{ SelectorID: selector.ID, Description: selector.Description, AllowedUse: selector.AllowedUse, StabilityScore: selector.StabilityScore, Evidence: selector.Evidence, Matched: match.Matched, MatchCount: match.MatchCount, Matches: match.Matches, Warnings: append([]string{}, match.Warnings...), } report.Selectors = append(report.Selectors, selectorReport) if match.Matched { report.MatchedCount++ } else { report.UnmatchedCount++ } if match.MatchCount > 1 { report.AmbiguousCount++ } } return report } func StrictExitCode(report Report) int { if !report.OK { if report.Error != nil && report.Error.Code == "INVALID_CATALOG" { return StrictExitInvalidCatalog } return StrictExitReportGenerationFailure } if report.UnmatchedCount > 0 { return StrictExitUnmatched } if report.AmbiguousCount > 0 { return StrictExitAmbiguous } return StrictExitOK } func RenderReportJSON(report Report) ([]byte, error) { return json.MarshalIndent(report, "", " ") } func WriteReportJSON(report Report, path string) error { body, err := RenderReportJSON(report) if err != nil { return err } return writeFileWithParents(path, body) } func RenderReportMarkdown(report Report) string { var b strings.Builder b.WriteString("# N14 UIA Selector Report\n\n") b.WriteString("- Source dump: `" + escapeMarkdownInline(report.SourceDump) + "`\n") b.WriteString(fmt.Sprintf("- Catalog size: %d\n", report.CatalogSize)) b.WriteString(fmt.Sprintf("- Matched: %d\n", report.MatchedCount)) b.WriteString(fmt.Sprintf("- Unmatched: %d\n", report.UnmatchedCount)) b.WriteString(fmt.Sprintf("- Ambiguous: %d\n", report.AmbiguousCount)) b.WriteString("\n## Summary\n\n") b.WriteString("| selector_id | status | matches | stability | evidence_path |\n") b.WriteString("| --- | --- | ---: | ---: | --- |\n") for _, selector := range report.Selectors { status := "unmatched" if selector.MatchCount > 1 { status = "ambiguous" } else if selector.Matched { status = "matched" } b.WriteString(fmt.Sprintf( "| %s | %s | %d | %d | %s |\n", escapeMarkdownCell(selector.SelectorID), status, selector.MatchCount, selector.StabilityScore, escapeMarkdownCell(selector.Evidence.UIAPath), )) } b.WriteString("\n## Selector Details\n") for _, selector := range report.Selectors { b.WriteString("\n### " + escapeMarkdownInline(selector.SelectorID) + "\n\n") b.WriteString("- Description: " + escapeMarkdownInline(selector.Description) + "\n") b.WriteString("- Allowed use: " + escapeMarkdownInline(selector.AllowedUse) + "\n") b.WriteString(fmt.Sprintf("- Stability score: %d\n", selector.StabilityScore)) b.WriteString(fmt.Sprintf("- Match count: %d\n", selector.MatchCount)) b.WriteString("\nMatches:\n\n") b.WriteString("| path | control_type | automation_id | class_name | framework_id | has_name | name_length |\n") b.WriteString("| --- | --- | --- | --- | --- | --- | ---: |\n") for _, match := range selector.Matches { b.WriteString(fmt.Sprintf( "| %s | %s | %s | %s | %s | %t | %d |\n", escapeMarkdownCell(match.Path), escapeMarkdownCell(match.ControlType), escapeMarkdownCell(match.AutomationID), escapeMarkdownCell(match.ClassName), escapeMarkdownCell(match.FrameworkID), match.HasName, match.NameLength, )) } } return b.String() } func WriteReportMarkdown(report Report, path string) error { return writeFileWithParents(path, []byte(RenderReportMarkdown(report))) } func writeFileWithParents(path string, body []byte) error { dir := filepath.Dir(path) if dir != "." && dir != "" { if err := os.MkdirAll(dir, 0o755); err != nil { return err } } return os.WriteFile(path, body, 0o644) } func escapeMarkdownInline(value string) string { return strings.NewReplacer("`", "'", "\r", " ", "\n", " ").Replace(value) } func escapeMarkdownCell(value string) string { return strings.NewReplacer("|", "\\|", "\r", " ", "\n", " ").Replace(value) }