diff --git a/internal/uiaselector/report.go b/internal/uiaselector/report.go index 99fe377..7b1849f 100644 --- a/internal/uiaselector/report.go +++ b/internal/uiaselector/report.go @@ -1,6 +1,13 @@ package uiaselector -import "time" +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) const ( StrictExitOK = 0 @@ -100,3 +107,90 @@ func StrictExitCode(report Report) int { } 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) +} diff --git a/internal/uiaselector/report_test.go b/internal/uiaselector/report_test.go index c227721..de36437 100644 --- a/internal/uiaselector/report_test.go +++ b/internal/uiaselector/report_test.go @@ -1,6 +1,7 @@ package uiaselector import ( + "strings" "testing" "time" ) @@ -96,3 +97,47 @@ func TestReportRejectsNonReadOnlyCatalogEntries(t *testing.T) { t.Fatalf("StrictExitCode = %d, want %d", got, StrictExitInvalidCatalog) } } + +func TestReportDoesNotExposeVisibleText(t *testing.T) { + report := RunCatalogReport( + "testdata/n12r-2026-07-09-uia-redacted.json", + loadN14Fixture(t), + DefaultCatalog(), + fixedReportTime(), + ) + body, err := RenderReportJSON(report) + if err != nil { + t.Fatalf("RenderReportJSON: %v", err) + } + jsonText := string(body) + for _, forbidden := range []string{`"name"`, `"value"`, "国网甘肃省电力公司"} { + if strings.Contains(jsonText, forbidden) { + t.Fatalf("JSON report exposed forbidden text %q in %s", forbidden, jsonText) + } + } + for _, required := range []string{`"has_name"`, `"name_length"`, `"selector_id"`, `"automation_id"`} { + if !strings.Contains(jsonText, required) { + t.Fatalf("JSON report missing required structural field %q in %s", required, jsonText) + } + } +} + +func TestMarkdownReportDoesNotExposeVisibleText(t *testing.T) { + report := RunCatalogReport( + "testdata/n12r-2026-07-09-uia-redacted.json", + loadN14Fixture(t), + DefaultCatalog(), + fixedReportTime(), + ) + markdown := RenderReportMarkdown(report) + for _, forbidden := range []string{"国网甘肃省电力公司", "| name |", "| value |"} { + if strings.Contains(markdown, forbidden) { + t.Fatalf("Markdown report exposed forbidden text %q in %s", forbidden, markdown) + } + } + for _, required := range []string{"# N14 UIA Selector Report", "## Summary", "automation_id", "control_type", "class_name", "has_name", "name_length"} { + if !strings.Contains(markdown, required) { + t.Fatalf("Markdown report missing %q in %s", required, markdown) + } + } +}