From f55f6a55fe790529851a41e86b25a40ee2ba90ba Mon Sep 17 00:00:00 2001 From: btlqql <2977859784@qq.com> Date: Sun, 19 Jul 2026 10:17:20 +0800 Subject: [PATCH 1/2] feat(accessibility): add deterministic page audit --- .../add-page-accessibility-audit/design.md | 7 + .../add-page-accessibility-audit/proposal.md | 16 ++ .../specs/page-accessibility-audit/spec.md | 11 + .../add-page-accessibility-audit/tasks.md | 9 + packages/ubml-codegen/package.json | 5 +- .../ubml-codegen/src/accessibility/audit.ts | 75 +++++ .../src/accessibility/baseline.ts | 103 +++++++ .../ubml-codegen/src/accessibility/fixes.ts | 128 +++++++++ .../ubml-codegen/src/accessibility/index.ts | 7 + .../ubml-codegen/src/accessibility/report.ts | 69 +++++ .../ubml-codegen/src/accessibility/rules.ts | 182 ++++++++++++ .../ubml-codegen/src/accessibility/types.ts | 131 +++++++++ .../ubml-codegen/src/accessibility/walker.ts | 87 ++++++ .../src/cli/accessibility-audit.ts | 49 ++++ packages/ubml-codegen/src/runtime.ts | 5 + .../test/accessibility-audit.test.ts | 262 ++++++++++++++++++ .../test/fixtures/accessibility/bad-page.yaml | 19 ++ 17 files changed, 1164 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/add-page-accessibility-audit/design.md create mode 100644 openspec/changes/add-page-accessibility-audit/proposal.md create mode 100644 openspec/changes/add-page-accessibility-audit/specs/page-accessibility-audit/spec.md create mode 100644 openspec/changes/add-page-accessibility-audit/tasks.md create mode 100644 packages/ubml-codegen/src/accessibility/audit.ts create mode 100644 packages/ubml-codegen/src/accessibility/baseline.ts create mode 100644 packages/ubml-codegen/src/accessibility/fixes.ts create mode 100644 packages/ubml-codegen/src/accessibility/index.ts create mode 100644 packages/ubml-codegen/src/accessibility/report.ts create mode 100644 packages/ubml-codegen/src/accessibility/rules.ts create mode 100644 packages/ubml-codegen/src/accessibility/types.ts create mode 100644 packages/ubml-codegen/src/accessibility/walker.ts create mode 100644 packages/ubml-codegen/src/cli/accessibility-audit.ts create mode 100644 packages/ubml-codegen/test/accessibility-audit.test.ts create mode 100644 packages/ubml-codegen/test/fixtures/accessibility/bad-page.yaml diff --git a/openspec/changes/add-page-accessibility-audit/design.md b/openspec/changes/add-page-accessibility-audit/design.md new file mode 100644 index 00000000..9388e8ac --- /dev/null +++ b/openspec/changes/add-page-accessibility-audit/design.md @@ -0,0 +1,7 @@ +# Design: page accessibility audit + +The audit runs after normal DSL parsing and never claims rendered-DOM conformance. Rules are conservative static checks mapped to WCAG 2.2 concepts. Findings carry confidence so consumers can distinguish definite missing metadata from review recommendations. + +The engine has five layers: normalized tree walking, rule registry, finding aggregation, deterministic quick fixes, and report/CLI adapters. Unknown or raw sections are retained and surfaced for manual review. Fixes edit only explicit known properties and are re-audited before returning YAML. + +Exit codes: 0 when the selected threshold passes, 1 when findings meet the failure threshold, and 2 for invalid input or CLI usage. diff --git a/openspec/changes/add-page-accessibility-audit/proposal.md b/openspec/changes/add-page-accessibility-audit/proposal.md new file mode 100644 index 00000000..4919f67d --- /dev/null +++ b/openspec/changes/add-page-accessibility-audit/proposal.md @@ -0,0 +1,16 @@ +# Proposal: add deterministic page accessibility audit + +## Why + +UBML page verification proves that a page can compile, but it does not catch common accessibility failures such as ambiguous labels, icon-only actions, untitled data regions, inaccessible raw sections or destructive actions without confirmation. These defects are expensive to discover after deployment. + +## What changes + +- Add a browser-safe WCAG 2.2-oriented audit engine for UBML page DSL. +- Add stable issue paths, severity, evidence, remediation and confidence metadata. +- Add safe quick fixes for deterministic metadata corrections. +- Add JSON, Markdown and terminal reports plus a CI-friendly CLI. + +## Scope + +Only the owned `packages/ubml-codegen`, documentation and OpenSpec files change. No vendor code is modified. diff --git a/openspec/changes/add-page-accessibility-audit/specs/page-accessibility-audit/spec.md b/openspec/changes/add-page-accessibility-audit/specs/page-accessibility-audit/spec.md new file mode 100644 index 00000000..c6121fbe --- /dev/null +++ b/openspec/changes/add-page-accessibility-audit/specs/page-accessibility-audit/spec.md @@ -0,0 +1,11 @@ +# Page accessibility audit + +## Requirements + +The system SHALL audit form controls, data regions, tabs, actions, raw sections and nested layouts using stable rule identifiers and paths. + +The system SHALL distinguish errors, warnings and review-only notices and SHALL provide a reproducible score. + +The system SHALL only offer quick fixes that are deterministic and SHALL preserve the original YAML when a fix cannot be validated. + +The CLI SHALL support JSON, Markdown and terminal output and a configurable failure severity. diff --git a/openspec/changes/add-page-accessibility-audit/tasks.md b/openspec/changes/add-page-accessibility-audit/tasks.md new file mode 100644 index 00000000..6037dbc4 --- /dev/null +++ b/openspec/changes/add-page-accessibility-audit/tasks.md @@ -0,0 +1,9 @@ +# Tasks + +- [x] Implement page tree normalization and context-aware traversal. +- [x] Implement WCAG-oriented static rules and score aggregation. +- [x] Implement deterministic quick-fix registry and YAML preservation. +- [x] Implement JSON, Markdown, SARIF and terminal reporters. +- [x] Add CI-friendly CLI, debt baselines and package exports. +- [x] Add rule, nested-section, fix, baseline and reporting tests. +- [x] Run focused tests and typecheck diagnostics for changed files. diff --git a/packages/ubml-codegen/package.json b/packages/ubml-codegen/package.json index 7f3c5fac..edf57fbc 100644 --- a/packages/ubml-codegen/package.json +++ b/packages/ubml-codegen/package.json @@ -30,6 +30,7 @@ "./eapi-decompile": "./src/eapi/decompile-eapi.ts", "./derive": "./src/derive/derive-from-be.ts", "./query-compile": "./src/query/compile-query.ts", + "./accessibility": "./src/accessibility/index.ts", "./pageflow-compile": "./src/pageflow/compile-pageflow.ts", "./component-compile": "./src/component/compile-component.ts", "./javagen": "./src/javagen/compile-javagen.ts", @@ -59,7 +60,8 @@ "ubml-javagen": "./src/javagen/cli/javagen.ts", "ubml-junit-skeleton": "./src/javagen/junit/cli.ts", "ubml-app-generate": "./src/app/cli/app-generate.ts", - "ubml-app-decompile": "./src/app/cli/app-decompile.ts" + "ubml-app-decompile": "./src/app/cli/app-decompile.ts", + "ubml-accessibility-audit": "./src/cli/accessibility-audit.ts" }, "scripts": { "entity-compile": "bun run src/cli/entity-compile.ts", @@ -83,6 +85,7 @@ "javagen": "bun run src/javagen/cli/javagen.ts", "app-generate": "bun run src/app/cli/app-generate.ts", "app-decompile": "bun run src/app/cli/app-decompile.ts", + "accessibility-audit": "bun run src/cli/accessibility-audit.ts", "test": "bun test", "typecheck": "tsgo --noEmit -p tsconfig.json" }, diff --git a/packages/ubml-codegen/src/accessibility/audit.ts b/packages/ubml-codegen/src/accessibility/audit.ts new file mode 100644 index 00000000..ed7b15ac --- /dev/null +++ b/packages/ubml-codegen/src/accessibility/audit.ts @@ -0,0 +1,75 @@ +import { pageDslSchema, type PageDsl } from "../dsl/schema" +import { parseDslYaml } from "../dsl/parse" +import { ACCESSIBILITY_RULES } from "./rules" +import { walkAccessibilityNodes } from "./walker" +import type { AccessibilityAuditOptions, AccessibilityAuditResult, AccessibilityFinding, AccessibilityNode, AccessibilityRuleContext, AccessibilityScore, AccessibilitySeverity } from "./types" + +export const DEFAULT_ACCESSIBILITY_OPTIONS: Required = { + failOn: "error", + includeNotices: true, + genericLabels: ["标题", "名称", "字段", "内容", "信息", "按钮", "操作", "item", "field", "name", "title", "button"], + destructiveWords: ["删除", "作废", "清空", "移除", "撤销", "终止", "delete", "remove", "clear", "void"], + maxFormColumns: 4, + maxLabelLength: 40, +} + +const severityRank: Record = { error: 3, warning: 2, notice: 1 } + +function score(findings: AccessibilityFinding[]): AccessibilityScore { + const errors = findings.filter((item) => item.severity === "error").length + const warnings = findings.filter((item) => item.severity === "warning").length + const notices = findings.filter((item) => item.severity === "notice").length + const value = Math.max(0, 100 - errors * 12 - warnings * 5 - notices) + const grade = value >= 95 ? "A" : value >= 85 ? "B" : value >= 70 ? "C" : value >= 50 ? "D" : "F" + return { value, grade, errors, warnings, notices, highConfidence: findings.filter((item) => item.confidence === "high").length, manualReview: findings.filter((item) => item.confidence === "review").length } +} + +function parseFailure(message: string, options: Required): AccessibilityAuditResult { + const findings: AccessibilityFinding[] = [{ id: "parse:page", ruleId: "empty-region", severity: "error", confidence: "high", path: "page", message, remediation: "先修复 Page YAML 结构,再执行无障碍审计。", wcag: [], fixes: [] }] + return { ok: false, parsed: false, findings, score: score(findings), summary: "Page YAML 无法解析,未执行规则审计。", options } +} + +function contextFor(page: PageDsl, nodes: AccessibilityNode[], options: Required): AccessibilityRuleContext { + let serial = 0 + return { + page, nodes, options, + finding(input) { serial += 1; return { ...input, id: `${input.ruleId}:${serial}:${input.path}`, fixes: input.fixes ?? [] } }, + siblings(node, kind) { + return nodes.filter((candidate) => candidate.parent?.path === node.parent?.path && (!kind || candidate.kind === kind)) + }, + } +} + +export function auditPageAccessibility(input: string | unknown, partialOptions: AccessibilityAuditOptions = {}): AccessibilityAuditResult { + const options = { ...DEFAULT_ACCESSIBILITY_OPTIONS, ...partialOptions } + let raw: unknown + try { raw = typeof input === "string" ? parseDslYaml(input) : input } + catch (error) { return parseFailure(error instanceof Error ? error.message : String(error), options) } + const parsed = pageDslSchema.safeParse(raw) + if (!parsed.success) { + const first = parsed.error.issues[0] + const path = first?.path.map(String).join(".") || "page" + return parseFailure(`${path}: ${first?.message ?? "Page DSL 不合法"}`, options) + } + const page = parsed.data + const nodes = walkAccessibilityNodes(page) + const context = contextFor(page, nodes, options) + let findings = ACCESSIBILITY_RULES.flatMap((rule) => nodes.flatMap((node) => rule.evaluate(node, context))) + if (!options.includeNotices) findings = findings.filter((item) => item.severity !== "notice") + findings.sort((left, right) => severityRank[right.severity] - severityRank[left.severity] || left.path.localeCompare(right.path) || left.ruleId.localeCompare(right.ruleId)) + const measured = score(findings) + const threshold = severityRank[options.failOn] + const ok = !findings.some((item) => severityRank[item.severity] >= threshold) + const summary = findings.length ? `${measured.grade} / ${measured.value} 分:${measured.errors} 错误,${measured.warnings} 警告,${measured.notices} 待复核。` : "A / 100 分:未发现静态无障碍问题。" + return { ok, parsed: true, page, findings, score: measured, summary, options } +} + +export function findingsAtPath(result: AccessibilityAuditResult, path: string): AccessibilityFinding[] { + return result.findings.filter((item) => item.path === path || item.path.startsWith(`${path}.`) || item.path.startsWith(`${path}[`)) +} + +export function groupFindingsByRule(result: AccessibilityAuditResult): Map { + const groups = new Map() + for (const finding of result.findings) groups.set(finding.ruleId, [...(groups.get(finding.ruleId) ?? []), finding]) + return groups +} diff --git a/packages/ubml-codegen/src/accessibility/baseline.ts b/packages/ubml-codegen/src/accessibility/baseline.ts new file mode 100644 index 00000000..dee7c937 --- /dev/null +++ b/packages/ubml-codegen/src/accessibility/baseline.ts @@ -0,0 +1,103 @@ +import type { AccessibilityAuditResult, AccessibilityBaseline, AccessibilityBaselineComparison, AccessibilityBaselineEntry, AccessibilityFinding, AccessibilitySeverity } from "./types" + +const severityRank: Record = { error: 3, warning: 2, notice: 1 } + +function stableText(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, " ").replace(/[“”"'`]/g, "") +} + +function hash(value: string): string { + let first = 0x811c9dc5 + let second = 0x9e3779b9 + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index) + first = Math.imul(first ^ code, 0x01000193) + second = Math.imul(second ^ (code + index), 0x85ebca6b) + } + return `${(first >>> 0).toString(16).padStart(8, "0")}${(second >>> 0).toString(16).padStart(8, "0")}` +} + +export function accessibilityFindingFingerprint(finding: Pick): string { + return hash(`${finding.ruleId}|${finding.path}|${stableText(finding.message)}`) +} + +export function createAccessibilityBaseline(result: AccessibilityAuditResult, options: { source?: string; createdAt?: string } = {}): AccessibilityBaseline { + const entries = result.findings.map((finding): AccessibilityBaselineEntry => ({ + fingerprint: accessibilityFindingFingerprint(finding), + ruleId: finding.ruleId, + severity: finding.severity, + path: finding.path, + message: finding.message, + })).sort((left, right) => left.fingerprint.localeCompare(right.fingerprint)) + return { version: 1, ...(options.source ? { source: options.source } : {}), createdAt: options.createdAt ?? new Date().toISOString(), entries } +} + +function isEntry(value: unknown): value is AccessibilityBaselineEntry { + if (!value || typeof value !== "object" || Array.isArray(value)) return false + const entry = value as Record + return typeof entry.fingerprint === "string" && typeof entry.ruleId === "string" && ["error", "warning", "notice"].includes(String(entry.severity)) && typeof entry.path === "string" && typeof entry.message === "string" +} + +export function parseAccessibilityBaseline(input: string | unknown): AccessibilityBaseline { + const value = typeof input === "string" ? JSON.parse(input) : input + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Accessibility baseline must be an object") + const raw = value as Record + if (raw.version !== 1 || !Array.isArray(raw.entries) || !raw.entries.every(isEntry)) throw new Error("Unsupported or malformed accessibility baseline") + return { version: 1, ...(typeof raw.source === "string" ? { source: raw.source } : {}), createdAt: typeof raw.createdAt === "string" ? raw.createdAt : "unknown", entries: raw.entries } +} + +export function compareAccessibilityBaseline(result: AccessibilityAuditResult, baselineInput: AccessibilityBaseline | string): AccessibilityBaselineComparison { + const baseline = parseAccessibilityBaseline(baselineInput) + const before = new Map(baseline.entries.map((entry) => [entry.fingerprint, entry])) + const current = new Map(result.findings.map((finding) => [accessibilityFindingFingerprint(finding), finding])) + const newFindings: AccessibilityFinding[] = [] + const unchanged: AccessibilityFinding[] = [] + const severityChanged: AccessibilityBaselineComparison["severityChanged"] = [] + + for (const [fingerprint, finding] of current) { + const previous = before.get(fingerprint) + if (!previous) { + const sameLocation = baseline.entries.find((entry) => entry.ruleId === finding.ruleId && entry.path === finding.path) + if (sameLocation && sameLocation.severity !== finding.severity) severityChanged.push({ before: sameLocation, after: finding }) + else newFindings.push(finding) + } else unchanged.push(finding) + } + const resolved = baseline.entries.filter((entry) => !current.has(entry.fingerprint) && !severityChanged.some((change) => change.before.fingerprint === entry.fingerprint)) + const worsened = severityChanged.filter((change) => severityRank[change.after.severity] > severityRank[change.before.severity]).length + const regressed = newFindings.length > 0 || worsened > 0 + const summary = regressed + ? `无障碍基线退化:新增 ${newFindings.length} 项,严重度升级 ${worsened} 项。` + : `无障碍基线未退化:解决 ${resolved.length} 项,保留 ${unchanged.length} 项已知问题。` + return { regressed, newFindings, resolved, unchanged, severityChanged, summary } +} + +export function suppressBaselineFindings(result: AccessibilityAuditResult, baselineInput: AccessibilityBaseline | string): AccessibilityAuditResult { + const baseline = parseAccessibilityBaseline(baselineInput) + const known = new Set(baseline.entries.map((entry) => entry.fingerprint)) + const findings = result.findings.filter((finding) => !known.has(accessibilityFindingFingerprint(finding))) + const errors = findings.filter((finding) => finding.severity === "error").length + const warnings = findings.filter((finding) => finding.severity === "warning").length + const notices = findings.filter((finding) => finding.severity === "notice").length + const value = Math.max(0, 100 - errors * 12 - warnings * 5 - notices) + const grade = value >= 95 ? "A" : value >= 85 ? "B" : value >= 70 ? "C" : value >= 50 ? "D" : "F" + const threshold = severityRank[result.options.failOn] + return { + ...result, + ok: !findings.some((finding) => severityRank[finding.severity] >= threshold), + findings, + score: { + value, + grade, + errors, + warnings, + notices, + highConfidence: findings.filter((finding) => finding.confidence === "high").length, + manualReview: findings.filter((finding) => finding.confidence === "review").length, + }, + summary: findings.length ? `相对基线新增 ${errors} 错误、${warnings} 警告、${notices} 待复核。` : "相对基线未新增问题。", + } +} + +export function accessibilityBaselineToJson(baseline: AccessibilityBaseline): string { + return JSON.stringify(baseline, null, 2) +} diff --git a/packages/ubml-codegen/src/accessibility/fixes.ts b/packages/ubml-codegen/src/accessibility/fixes.ts new file mode 100644 index 00000000..f9f5d23d --- /dev/null +++ b/packages/ubml-codegen/src/accessibility/fixes.ts @@ -0,0 +1,128 @@ +import { parse, stringify } from "yaml" +import { auditPageAccessibility } from "./audit" +import type { AccessibilityAuditOptions, AccessibilityFinding, AccessibilityFixResult, AccessibilityQuickFix } from "./types" + +type PathToken = string | number + +function tokens(path: string): PathToken[] { + const result: PathToken[] = [] + const matcher = /([^.[\]]+)|\[(\d+)\]/g + let match: RegExpExecArray | null + while ((match = matcher.exec(path))) result.push(match[2] !== undefined ? Number(match[2]) : match[1]!) + return result +} + +function isContainer(value: unknown): value is Record | unknown[] { + return !!value && typeof value === "object" +} + +function parentAt(root: unknown, path: PathToken[]): { parent: Record | unknown[]; key: PathToken } | undefined { + if (!path.length) return undefined + let current: unknown = root + for (const token of path.slice(0, -1)) { + if (!isContainer(current)) return undefined + current = Array.isArray(current) ? current[Number(token)] : current[String(token)] + } + if (!isContainer(current)) return undefined + return { parent: current, key: path[path.length - 1]! } +} + +function setValue(root: unknown, path: PathToken[], value: unknown): boolean { + if (!path.length) return false + let current: unknown = root + const parents = path.slice(0, -1) + for (let index = 0; index < parents.length; index++) { + const token = parents[index]! + if (!isContainer(current)) return false + const wantsArray = typeof path[index + 1] === "number" + let next: unknown + if (Array.isArray(current)) { + const key = Number(token) + next = current[key] + if (!isContainer(next)) { next = wantsArray ? [] : {}; current[key] = next } + } else { + const key = String(token) + next = current[key] + if (!isContainer(next)) { next = wantsArray ? [] : {}; current[key] = next } + } + current = next + } + if (!isContainer(current)) return false + const last = path[path.length - 1]! + if (Array.isArray(current)) current[Number(last)] = value + else current[String(last)] = value + return true +} + +function removeValue(root: unknown, path: PathToken[]): boolean { + const target = parentAt(root, path) + if (!target) return false + if (Array.isArray(target.parent)) { + const index = Number(target.key) + if (!Number.isInteger(index) || index < 0 || index >= target.parent.length) return false + target.parent.splice(index, 1) + } else { + const key = String(target.key) + if (!(key in target.parent)) return false + delete target.parent[key] + } + return true +} + +function readValue(root: unknown, path: PathToken[]): unknown { + let current: unknown = root + for (const token of path) { + if (!isContainer(current)) return undefined + current = Array.isArray(current) ? current[Number(token)] : current[String(token)] + } + return current +} + +function applyOperation(root: unknown, fix: AccessibilityQuickFix): boolean { + const path = tokens(fix.operation.path) + if (fix.operation.type === "set") return setValue(root, path, structuredClone(fix.operation.value)) + if (fix.operation.type === "remove") return removeValue(root, path) + const current = readValue(root, path) + if (typeof current !== "string") return false + return setValue(root, path, current + fix.operation.suffix) +} + +export function collectAccessibilityFixes(findings: AccessibilityFinding[]): AccessibilityQuickFix[] { + const seen = new Set() + return findings.flatMap((finding) => finding.fixes).filter((fix) => !seen.has(fix.id) && !!seen.add(fix.id)) +} + +export function applyAccessibilityFix(yaml: string, fixId: string, options: AccessibilityAuditOptions = {}): AccessibilityFixResult { + const before = auditPageAccessibility(yaml, options) + const fix = collectAccessibilityFixes(before.findings).find((item) => item.id === fixId) + if (!fix) return { ok: false, yaml, findings: before.findings, message: "修复项不存在或已失效。" } + if (!fix.safe) return { ok: false, yaml, findings: before.findings, message: "该修复需要人工判断,不能自动执行。" } + let raw: unknown + try { raw = parse(yaml) } + catch (error) { return { ok: false, yaml, findings: before.findings, message: error instanceof Error ? error.message : String(error) } } + const clone = structuredClone(raw) + if (!applyOperation(clone, fix)) return { ok: false, yaml, findings: before.findings, message: "目标路径已变化,未修改 YAML。" } + const nextYaml = stringify(clone, { lineWidth: 120, indent: 2 }).trimEnd() + "\n" + const after = auditPageAccessibility(nextYaml, options) + if (!after.parsed) return { ok: false, yaml, findings: before.findings, message: "修复后的 YAML 未通过结构校验,已回滚。" } + if (after.findings.some((finding) => finding.id === fixId || finding.fixes.some((candidate) => candidate.id === fixId))) { + return { ok: false, yaml, findings: before.findings, message: "修复未消除目标问题,已回滚。" } + } + return { ok: true, yaml: nextYaml, appliedFixId: fix.id, findings: after.findings, message: `已应用:${fix.title}` } +} + +export function applyAllSafeAccessibilityFixes(yaml: string, options: AccessibilityAuditOptions = {}): AccessibilityFixResult { + let current = yaml + let applied = 0 + const attempted = new Set() + while (true) { + const audit = auditPageAccessibility(current, options) + const next = collectAccessibilityFixes(audit.findings).find((fix) => fix.safe && !attempted.has(fix.id)) + if (!next) return { ok: true, yaml: current, findings: audit.findings, message: `已应用 ${applied} 项安全修复。` } + attempted.add(next.id) + const result = applyAccessibilityFix(current, next.id, options) + if (!result.ok) return { ok: false, yaml, findings: auditPageAccessibility(yaml, options).findings, message: `批量修复在“${next.title}”失败,已回滚全部修改。` } + current = result.yaml + applied += 1 + } +} diff --git a/packages/ubml-codegen/src/accessibility/index.ts b/packages/ubml-codegen/src/accessibility/index.ts new file mode 100644 index 00000000..fc9642f3 --- /dev/null +++ b/packages/ubml-codegen/src/accessibility/index.ts @@ -0,0 +1,7 @@ +export { auditPageAccessibility, DEFAULT_ACCESSIBILITY_OPTIONS, findingsAtPath, groupFindingsByRule } from "./audit" +export { applyAccessibilityFix, applyAllSafeAccessibilityFixes, collectAccessibilityFixes } from "./fixes" +export { accessibilityReportToJson, accessibilityReportToMarkdown, accessibilityReportToTerminal, accessibilitySarif, formatAccessibilityReport } from "./report" +export { ACCESSIBILITY_RULES } from "./rules" +export { accessibilityBaselineToJson, accessibilityFindingFingerprint, compareAccessibilityBaseline, createAccessibilityBaseline, parseAccessibilityBaseline, suppressBaselineFindings } from "./baseline" +export { walkAccessibilityNodes } from "./walker" +export type { AccessibilityAuditOptions, AccessibilityAuditResult, AccessibilityBaseline, AccessibilityBaselineComparison, AccessibilityBaselineEntry, AccessibilityConfidence, AccessibilityEvidence, AccessibilityFinding, AccessibilityFixResult, AccessibilityNode, AccessibilityQuickFix, AccessibilityReportFormat, AccessibilityRule, AccessibilityRuleContext, AccessibilityRuleId, AccessibilityScore, AccessibilitySeverity } from "./types" diff --git a/packages/ubml-codegen/src/accessibility/report.ts b/packages/ubml-codegen/src/accessibility/report.ts new file mode 100644 index 00000000..b8423aa0 --- /dev/null +++ b/packages/ubml-codegen/src/accessibility/report.ts @@ -0,0 +1,69 @@ +import type { AccessibilityAuditResult, AccessibilityFinding, AccessibilityReportFormat, AccessibilitySeverity } from "./types" + +const icon: Record = { error: "✖", warning: "⚠", notice: "•" } + +function findingLine(finding: AccessibilityFinding): string { + const wcag = finding.wcag.length ? ` [WCAG ${finding.wcag.join(", ")}]` : "" + return `${icon[finding.severity]} ${finding.ruleId} ${finding.path}: ${finding.message}${wcag}` +} + +export function accessibilityReportToTerminal(result: AccessibilityAuditResult, color = false): string { + const lines = [`UBML 页面无障碍审计:${result.summary}`, `状态:${result.ok ? "通过" : "未通过"};失败阈值:${result.options.failOn}`] + if (!result.findings.length) return lines.join("\n") + lines.push("") + for (const finding of result.findings) { + let line = findingLine(finding) + if (color) { + const code = finding.severity === "error" ? 31 : finding.severity === "warning" ? 33 : 36 + line = `\u001b[${code}m${line}\u001b[0m` + } + lines.push(line, ` 建议:${finding.remediation}`) + if (finding.fixes.length) lines.push(` 安全修复:${finding.fixes.map((fix) => fix.id).join(", ")}`) + } + return lines.join("\n") +} + +function escapeCell(value: string): string { + return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ") +} + +export function accessibilityReportToMarkdown(result: AccessibilityAuditResult, source?: string): string { + const lines = ["# UBML 页面无障碍审计", "", `- 结果:**${result.ok ? "通过" : "未通过"}**`, `- 评分:**${result.score.grade} / ${result.score.value}**`, `- 问题:${result.score.errors} 错误、${result.score.warnings} 警告、${result.score.notices} 待复核`, `- 失败阈值:${result.options.failOn}`] + if (source) lines.push(`- 输入:\`${source}\``) + lines.push("", "## Findings", "") + if (!result.findings.length) lines.push("未发现静态无障碍问题。") + else { + lines.push("| 级别 | 规则 | 路径 | 问题 | WCAG |", "|---|---|---|---|---|") + for (const finding of result.findings) lines.push(`| ${finding.severity} | ${finding.ruleId} | \`${escapeCell(finding.path)}\` | ${escapeCell(finding.message)} | ${finding.wcag.join(", ")} |`) + lines.push("", "## Remediation", "") + result.findings.forEach((finding, index) => { + lines.push(`### ${index + 1}. ${finding.ruleId}`, "", `- 路径:\`${finding.path}\``, `- 置信度:${finding.confidence}`, `- 建议:${finding.remediation}`) + if (finding.fixes.length) lines.push(`- 安全修复:${finding.fixes.map((fix) => `\`${fix.id}\``).join("、")}`) + lines.push("") + }) + } + lines.push("## Scope", "", "本报告是针对 UBML DSL 的确定性静态审计,不替代渲染后对键盘、焦点、屏幕阅读器、缩放和颜色对比度的人工/自动化测试。", "") + return lines.join("\n") +} + +export function accessibilityReportToJson(result: AccessibilityAuditResult): string { + return JSON.stringify({ version: 1, ok: result.ok, parsed: result.parsed, score: result.score, summary: result.summary, options: result.options, findings: result.findings }, null, 2) +} + +export function formatAccessibilityReport(result: AccessibilityAuditResult, format: AccessibilityReportFormat, options: { color?: boolean; source?: string } = {}): string { + if (format === "json") return accessibilityReportToJson(result) + if (format === "markdown") return accessibilityReportToMarkdown(result, options.source) + return accessibilityReportToTerminal(result, options.color) +} + +export function accessibilitySarif(result: AccessibilityAuditResult, source = "page.yaml"): Record { + const rules = new Map() + result.findings.forEach((finding) => rules.set(finding.ruleId, finding)) + return { + $schema: "https://json.schemastore.org/sarif-2.1.0.json", version: "2.1.0", + runs: [{ + tool: { driver: { name: "UBML Accessibility Audit", version: "1", informationUri: "https://www.w3.org/TR/WCAG22/", rules: [...rules.values()].map((finding) => ({ id: finding.ruleId, shortDescription: { text: finding.message }, help: { text: finding.remediation } })) } }, + results: result.findings.map((finding) => ({ ruleId: finding.ruleId, level: finding.severity === "error" ? "error" : finding.severity === "warning" ? "warning" : "note", message: { text: finding.message }, locations: [{ physicalLocation: { artifactLocation: { uri: source }, region: { snippet: { text: finding.path } } } }], properties: { path: finding.path, wcag: finding.wcag, confidence: finding.confidence } })), + }], + } +} diff --git a/packages/ubml-codegen/src/accessibility/rules.ts b/packages/ubml-codegen/src/accessibility/rules.ts new file mode 100644 index 00000000..33afd6d4 --- /dev/null +++ b/packages/ubml-codegen/src/accessibility/rules.ts @@ -0,0 +1,182 @@ +import { hasOwn, nodeBoolean, nodeText } from "./walker" +import type { AccessibilityFinding, AccessibilityNode, AccessibilityRule, AccessibilityRuleContext } from "./types" + +const LABEL_WCAG = ["1.3.1", "2.4.6", "3.3.2"] +const NAME_WCAG = ["2.5.3", "4.1.2"] +const KEYBOARD_WCAG = ["2.1.1", "2.4.7", "2.4.11"] + +function result(context: AccessibilityRuleContext, input: Omit & { fixes?: AccessibilityFinding["fixes"] }): AccessibilityFinding[] { + return [context.finding(input)] +} + +function duplicateText(node: AccessibilityNode, context: AccessibilityRuleContext, text: string, getter: (item: AccessibilityNode) => string): AccessibilityNode[] { + if (!text) return [] + return context.siblings(node, node.kind).filter((item) => item.path !== node.path && getter(item).toLowerCase() === text.toLowerCase()) +} + +const pageTitleGeneric: AccessibilityRule = { + id: "page-title-generic", + description: "Page titles must identify the page purpose.", + evaluate(node, context) { + if (node.kind !== "page") return [] + const title = nodeText(node, "title") + if (!title || !context.options.genericLabels.some((label) => title.toLowerCase() === label.toLowerCase())) return [] + return result(context, { + ruleId: this.id, severity: "error", confidence: "high", path: `${node.path}.title`, + message: `页面标题“${title || "(空)"}”不能描述页面用途。`, remediation: "使用能区分业务对象和任务的页面标题。", wcag: ["2.4.2", "2.4.6"], + evidence: { actual: title, expected: "例如“费用报销单查询”" }, + }) + }, +} + +const pageTitleLength: AccessibilityRule = { + id: "page-title-length", + description: "Very long titles are difficult to scan.", + evaluate(node, context) { + if (node.kind !== "page") return [] + const title = nodeText(node, "title") + if (title.length <= context.options.maxLabelLength) return [] + return result(context, { ruleId: this.id, severity: "warning", confidence: "medium", path: `${node.path}.title`, message: `页面标题有 ${title.length} 个字符,辅助技术和窄屏中难以快速识别。`, remediation: `将核心用途放在前 ${context.options.maxLabelLength} 个字符内。`, wcag: ["2.4.2", "2.4.6"], evidence: { actual: title } }) + }, +} + +const sectionHeadingMissing: AccessibilityRule = { + id: "section-heading-missing", + description: "Major content regions need names.", + evaluate(node, context) { + if (node.kind !== "section" || !["form", "content-container", "filter-bar"].includes(node.sectionType ?? "")) return [] + const title = nodeText(node, "title") + const showHeader = nodeBoolean(node, "showHeader") + if (title || showHeader === false) return [] + const inferred = node.sectionType === "filter-bar" ? "筛选条件" : node.sectionType === "form" ? "基本信息" : "内容" + return result(context, { + ruleId: this.id, severity: "warning", confidence: "medium", path: `${node.path}.title`, message: "内容区域没有可识别标题。", remediation: "为区域添加简短、唯一的标题;若视觉上隐藏,渲染层仍应保留可访问名称。", wcag: ["1.3.1", "2.4.6"], + fixes: [{ id: `${this.id}:${node.path}`, title: `设置标题“${inferred}”`, description: "写入确定性的区域标题。", safe: true, operation: { type: "set", path: `${node.path}.title`, value: inferred } }], + }) + }, +} + +const fieldLabelGeneric: AccessibilityRule = { + id: "field-label-generic", + description: "Form control labels must describe purpose.", + evaluate(node, context) { + if (node.kind !== "field") return [] + const label = nodeText(node, "label") + if (label && !context.options.genericLabels.some((item) => item.toLowerCase() === label.toLowerCase())) return [] + const binding = nodeText(node, "binding") + return result(context, { ruleId: this.id, severity: "error", confidence: "high", path: `${node.path}.label`, message: `字段“${binding || node.index + 1}”缺少能说明用途的标签。`, remediation: "使用业务含义明确的可见标签。", wcag: LABEL_WCAG, evidence: { actual: label || "(空)", expected: binding || "业务字段名称" }, ...(binding ? { fixes: [{ id: `${this.id}:${node.path}`, title: `使用绑定名“${binding}”`, description: "以绑定字段作为保守默认标签,仍建议人工本地化。", safe: true, operation: { type: "set", path: `${node.path}.label`, value: binding } }] } : {}) }) + }, +} + +const fieldLabelDuplicate: AccessibilityRule = { + id: "field-label-duplicate", + description: "Repeated labels in one region need disambiguation.", + evaluate(node, context) { + if (node.kind !== "field") return [] + const label = nodeText(node, "label") + const duplicates = duplicateText(node, context, label, (item) => nodeText(item, "label")) + if (!duplicates.length || duplicates.some((item) => item.index < node.index)) return [] + return result(context, { ruleId: this.id, severity: "warning", confidence: "medium", path: `${node.path}.label`, message: `同一区域内有 ${duplicates.length + 1} 个“${label}”字段,语音操作时难以区分。`, remediation: "在标签中加入对象或时间范围等限定词。", wcag: ["2.4.6", "2.5.3"], evidence: { relatedPaths: duplicates.map((item) => `${item.path}.label`) } }) + }, +} + +const fieldBindingDuplicate: AccessibilityRule = { + id: "field-binding-duplicate", + description: "Duplicate controls bound to one value require review.", + evaluate(node, context) { + if (node.kind !== "field") return [] + const binding = nodeText(node, "binding") + const duplicates = duplicateText(node, context, binding, (item) => nodeText(item, "binding")) + if (!duplicates.length || duplicates.some((item) => item.index < node.index)) return [] + return result(context, { ruleId: this.id, severity: "notice", confidence: "review", path: `${node.path}.binding`, message: `同一区域重复绑定“${binding}”,需确认焦点顺序和标签是否区分。`, remediation: "删除重复控件,或为不同用途提供明确标签和说明。", wcag: ["1.3.1", "2.4.3"], evidence: { relatedPaths: duplicates.map((item) => `${item.path}.binding`) } }) + }, +} + +const fieldLabelInName: AccessibilityRule = { + id: "field-label-in-name", + description: "Visible label should align with the programmatic name.", + evaluate(node, context) { + if (node.kind !== "field") return [] + const label = nodeText(node, "label") + const props = node.value.props + if (!props || typeof props !== "object" || Array.isArray(props)) return [] + const aria = typeof (props as Record)["aria-label"] === "string" ? String((props as Record)["aria-label"]) : "" + if (!aria || aria.includes(label) || label.includes(aria)) return [] + return result(context, { ruleId: this.id, severity: "error", confidence: "high", path: `${node.path}.props.aria-label`, message: `可访问名称“${aria}”不包含可见标签“${label}”。`, remediation: "让 aria-label 包含可见标签文字,或移除多余 aria-label 让组件使用可见标签。", wcag: NAME_WCAG, fixes: [{ id: `${this.id}:${node.path}`, title: "移除冲突的 aria-label", description: "回退到组件与可见标签的原生关联。", safe: true, operation: { type: "remove", path: `${node.path}.props.aria-label` } }] }) + }, +} + +const gridTitleMissing: AccessibilityRule = { + id: "grid-title-missing", + description: "Data grids need a region name.", + evaluate(node, context) { + if (node.kind !== "section" || !["data-grid", "tree-grid"].includes(node.sectionType ?? "") || nodeText(node, "title")) return [] + return result(context, { ruleId: this.id, severity: "warning", confidence: "high", path: `${node.path}.title`, message: "数据表格没有标题,多个表格并存时无法区分。", remediation: "添加描述表格内容的短标题。", wcag: ["1.3.1", "2.4.6"], fixes: [{ id: `${this.id}:${node.path}`, title: "设置默认表格标题", description: "使用“数据列表”作为保守默认值。", safe: true, operation: { type: "set", path: `${node.path}.title`, value: "数据列表" } }] }) + }, +} + +const gridColumnDuplicate: AccessibilityRule = { + id: "grid-column-duplicate", + description: "Column headers must be distinguishable.", + evaluate(node, context) { + if (node.kind !== "column") return [] + const title = nodeText(node, "title") + const duplicates = duplicateText(node, context, title, (item) => nodeText(item, "title")) + if (!duplicates.length || duplicates.some((item) => item.index < node.index)) return [] + return result(context, { ruleId: this.id, severity: "error", confidence: "high", path: `${node.path}.title`, message: `表格中存在重复列标题“${title}”。`, remediation: "用限定词区分列的业务含义。", wcag: ["1.3.1", "2.4.6"], evidence: { relatedPaths: duplicates.map((item) => `${item.path}.title`) } }) + }, +} + +const tabRules: AccessibilityRule = { + id: "tab-title-duplicate", + description: "Tabs need unique names and content.", + evaluate(node, context) { + if (node.kind !== "tab") return [] + const title = nodeText(node, "title") + const findings: AccessibilityFinding[] = [] + const duplicates = duplicateText(node, context, title, (item) => nodeText(item, "title")) + if (duplicates.length && !duplicates.some((item) => item.index < node.index)) findings.push(context.finding({ ruleId: "tab-title-duplicate", severity: "error", confidence: "high", path: `${node.path}.title`, message: `页签标题“${title}”重复。`, remediation: "为每个页签使用唯一、描述内容的名称。", wcag: ["2.4.6", "4.1.2"], evidence: { relatedPaths: duplicates.map((item) => `${item.path}.title`) } })) + const sections = node.value.sections + if (!Array.isArray(sections) || sections.length === 0) findings.push(context.finding({ ruleId: "tab-empty", severity: "warning", confidence: "high", path: `${node.path}.sections`, message: `页签“${title || node.index + 1}”没有内容。`, remediation: "删除空页签或添加有意义内容。", wcag: ["1.3.1", "2.4.6"] })) + return findings + }, +} + +function actionRules(node: AccessibilityNode, context: AccessibilityRuleContext): AccessibilityFinding[] { + if (node.kind !== "button") return [] + const findings: AccessibilityFinding[] = [] + const text = nodeText(node, "text", "title", "ariaLabel") + if (!text || /^[\p{P}\p{S}\s]+$/u.test(text)) findings.push(context.finding({ ruleId: node.parent?.sectionType === "footer" ? "footer-action-name" : "toolbar-action-name", severity: "error", confidence: "high", path: `${node.path}.text`, message: "操作按钮没有可读名称。", remediation: "提供描述动作结果的文字名称;图标按钮也需可访问名称。", wcag: NAME_WCAG })) + const duplicates = duplicateText(node, context, text, (item) => nodeText(item, "text", "title", "ariaLabel")) + if (text && duplicates.length && !duplicates.some((item) => item.index < node.index)) findings.push(context.finding({ ruleId: "toolbar-action-duplicate", severity: "warning", confidence: "medium", path: `${node.path}.text`, message: `同一区域存在重复操作名“${text}”。`, remediation: "用对象或结果限定动作名称。", wcag: ["2.4.6", "2.5.3"], evidence: { relatedPaths: duplicates.map((item) => `${item.path}.text`) } })) + if (context.options.destructiveWords.some((word) => text.includes(word))) { + const handler = nodeText(node, "onClick") + const props = node.value.props + const confirm = recordValue(props, "confirm") || recordValue(props, "confirmText") || /confirm|确认/i.test(handler) + if (!confirm) findings.push(context.finding({ ruleId: "destructive-action-confirmation", severity: "warning", confidence: "medium", path: node.path, message: `破坏性操作“${text}”没有可识别的确认提示。`, remediation: "在执行前显示包含影响范围的确认对话框,并允许取消。", wcag: ["3.3.4", "3.3.6"] })) + } + return findings +} + +function recordValue(value: unknown, key: string): unknown { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record)[key] : undefined +} + +const structuralReviewRules: AccessibilityRule = { + id: "raw-section-review", + description: "Opaque and highly customized regions need manual review.", + evaluate(node, context) { + if (node.kind !== "section") return [] + const findings: AccessibilityFinding[] = [] + if (node.sectionType === "raw") findings.push(context.finding({ ruleId: "raw-section-review", severity: "notice", confidence: "review", path: node.path, message: "Raw 区域绕过结构化语义,无法静态确认键盘和名称支持。", remediation: "在渲染态执行键盘、焦点、语义和对比度人工测试。", wcag: KEYBOARD_WCAG })) + if (node.sectionType === "form" && Number(node.value.formColumns ?? 0) > context.options.maxFormColumns) findings.push(context.finding({ ruleId: "dense-form-review", severity: "warning", confidence: "medium", path: `${node.path}.formColumns`, message: `${node.value.formColumns} 列表单在缩放和窄屏下可能造成阅读顺序混乱。`, remediation: `建议不超过 ${context.options.maxFormColumns} 列,并验证 200% 缩放下的顺序。`, wcag: ["1.3.2", "1.4.10"] })) + if (nodeBoolean(node, "enableAccordion") && !nodeText(node, "title")) findings.push(context.finding({ ruleId: "accordion-heading-missing", severity: "error", confidence: "high", path: `${node.path}.title`, message: "可折叠区域没有标题,展开按钮将缺少名称。", remediation: "为折叠区域提供描述内容的标题。", wcag: NAME_WCAG })) + const props = node.value.props + if (recordValue(props, "autofocus") === true || recordValue(props, "autoFocus") === true) findings.push(context.finding({ ruleId: "autofocus-review", severity: "notice", confidence: "review", path: `${node.path}.props`, message: "自动聚焦可能跳过页面上下文或在移动端弹出键盘。", remediation: "仅在用户明确进入编辑任务时自动聚焦,并验证焦点顺序。", wcag: ["2.4.3", "3.2.1"] })) + if (recordValue(props, "color") && !recordValue(props, "statusText") && !recordValue(props, "aria-label")) findings.push(context.finding({ ruleId: "color-only-status", severity: "warning", confidence: "medium", path: `${node.path}.props.color`, message: "状态可能仅通过颜色表达。", remediation: "同时提供文字、图标形状或可访问名称。", wcag: ["1.4.1"] })) + if ((hasOwn(node, "onKeyDown") || recordValue(props, "onKeyDown")) && !recordValue(props, "keyboardHelp")) findings.push(context.finding({ ruleId: "keyboard-handler-review", severity: "notice", confidence: "review", path: node.path, message: "自定义键盘处理需要检查快捷键冲突和键盘陷阱。", remediation: "验证标准 Tab/Enter/Escape 行为并为非标准按键提供说明。", wcag: ["2.1.2", "2.1.4"] })) + return findings + }, +} + +export const ACCESSIBILITY_RULES: AccessibilityRule[] = [pageTitleGeneric, pageTitleLength, sectionHeadingMissing, fieldLabelGeneric, fieldLabelDuplicate, fieldBindingDuplicate, fieldLabelInName, gridTitleMissing, gridColumnDuplicate, tabRules, { id: "toolbar-action-name", description: "Actions need accessible names and safeguards.", evaluate: actionRules }, structuralReviewRules] diff --git a/packages/ubml-codegen/src/accessibility/types.ts b/packages/ubml-codegen/src/accessibility/types.ts new file mode 100644 index 00000000..d1993b30 --- /dev/null +++ b/packages/ubml-codegen/src/accessibility/types.ts @@ -0,0 +1,131 @@ +import type { PageDsl } from "../dsl/schema" + +export type AccessibilitySeverity = "error" | "warning" | "notice" +export type AccessibilityConfidence = "high" | "medium" | "review" + +export type AccessibilityRuleId = + | "page-title-generic" | "page-title-length" | "section-heading-missing" + | "field-label-generic" | "field-label-duplicate" | "field-binding-duplicate" + | "field-label-in-name" | "grid-title-missing" | "grid-column-duplicate" + | "grid-column-label-in-name" | "tab-title-duplicate" | "tab-empty" + | "toolbar-action-name" | "toolbar-action-duplicate" | "destructive-action-confirmation" + | "footer-action-name" | "raw-section-review" | "accordion-heading-missing" + | "dense-form-review" | "color-only-status" | "autofocus-review" + | "keyboard-handler-review" | "empty-region" | "query-field-label" + +export interface AccessibilityEvidence { + actual?: string + expected?: string + relatedPaths?: string[] +} + +export interface AccessibilityQuickFix { + id: string + title: string + description: string + safe: boolean + operation: + | { type: "set"; path: string; value: unknown } + | { type: "remove"; path: string } + | { type: "append-text"; path: string; suffix: string } +} + +export interface AccessibilityFinding { + id: string + ruleId: AccessibilityRuleId + severity: AccessibilitySeverity + confidence: AccessibilityConfidence + path: string + message: string + remediation: string + wcag: string[] + evidence?: AccessibilityEvidence + fixes: AccessibilityQuickFix[] +} + +export interface AccessibilityScore { + value: number + grade: "A" | "B" | "C" | "D" | "F" + errors: number + warnings: number + notices: number + highConfidence: number + manualReview: number +} + +export interface AccessibilityAuditOptions { + failOn?: AccessibilitySeverity + includeNotices?: boolean + genericLabels?: string[] + destructiveWords?: string[] + maxFormColumns?: number + maxLabelLength?: number +} + +export interface AccessibilityAuditResult { + ok: boolean + parsed: boolean + page?: PageDsl + findings: AccessibilityFinding[] + score: AccessibilityScore + summary: string + options: Required +} + +export interface AccessibilityNode { + kind: "page" | "section" | "field" | "column" | "tab" | "button" + path: string + value: Record + parent?: AccessibilityNode + sectionType?: string + index: number + depth: number +} + +export interface AccessibilityRuleContext { + page: PageDsl + nodes: AccessibilityNode[] + options: Required + finding(input: Omit & { fixes?: AccessibilityQuickFix[] }): AccessibilityFinding + siblings(node: AccessibilityNode, kind?: AccessibilityNode["kind"]): AccessibilityNode[] +} + +export interface AccessibilityRule { + id: AccessibilityRuleId + description: string + evaluate(node: AccessibilityNode, context: AccessibilityRuleContext): AccessibilityFinding[] +} + +export interface AccessibilityFixResult { + ok: boolean + yaml: string + appliedFixId?: string + findings: AccessibilityFinding[] + message: string +} + +export type AccessibilityReportFormat = "terminal" | "markdown" | "json" + +export interface AccessibilityBaselineEntry { + fingerprint: string + ruleId: AccessibilityRuleId + severity: AccessibilitySeverity + path: string + message: string +} + +export interface AccessibilityBaseline { + version: 1 + source?: string + createdAt: string + entries: AccessibilityBaselineEntry[] +} + +export interface AccessibilityBaselineComparison { + regressed: boolean + newFindings: AccessibilityFinding[] + resolved: AccessibilityBaselineEntry[] + unchanged: AccessibilityFinding[] + severityChanged: Array<{ before: AccessibilityBaselineEntry; after: AccessibilityFinding }> + summary: string +} diff --git a/packages/ubml-codegen/src/accessibility/walker.ts b/packages/ubml-codegen/src/accessibility/walker.ts new file mode 100644 index 00000000..1597e64b --- /dev/null +++ b/packages/ubml-codegen/src/accessibility/walker.ts @@ -0,0 +1,87 @@ +import type { PageDsl } from "../dsl/schema" +import type { AccessibilityNode } from "./types" + +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function node(kind: AccessibilityNode["kind"], path: string, value: Record, index: number, depth: number, parent?: AccessibilityNode, sectionType?: string): AccessibilityNode { + return { kind, path, value, index, depth, ...(parent ? { parent } : {}), ...(sectionType ? { sectionType } : {}) } +} + +function walkFields(value: Record, path: string, parent: AccessibilityNode, nodes: AccessibilityNode[], depth: number) { + const fields = value.fields + if (!Array.isArray(fields)) return + fields.forEach((field, index) => { + if (record(field)) nodes.push(node("field", `${path}.fields[${index}]`, field, index, depth, parent, parent.sectionType)) + }) +} + +function walkColumns(value: Record, path: string, parent: AccessibilityNode, nodes: AccessibilityNode[], depth: number) { + const columns = value.columns + if (!Array.isArray(columns)) return + columns.forEach((column, index) => { + if (record(column)) nodes.push(node("column", `${path}.columns[${index}]`, column, index, depth, parent, parent.sectionType)) + }) +} + +function walkButtons(value: Record, key: string, path: string, parent: AccessibilityNode, nodes: AccessibilityNode[], depth: number) { + const buttons = value[key] + if (!Array.isArray(buttons)) return + buttons.forEach((button, index) => { + if (record(button)) nodes.push(node("button", `${path}.${key}[${index}]`, button, index, depth, parent, parent.sectionType)) + }) +} + +function walkSection(value: Record, path: string, index: number, depth: number, parent: AccessibilityNode, nodes: AccessibilityNode[]) { + const sectionType = typeof value.type === "string" ? value.type : "form" + const section = node("section", path, value, index, depth, parent, sectionType) + nodes.push(section) + walkFields(value, path, section, nodes, depth + 1) + walkColumns(value, path, section, nodes, depth + 1) + walkButtons(value, "buttons", path, section, nodes, depth + 1) + + const nested = value.sections + if (Array.isArray(nested)) nested.forEach((child, childIndex) => { if (record(child)) walkSection(child, `${path}.sections[${childIndex}]`, childIndex, depth + 1, section, nodes) }) + + const tabs = value.tabs + if (Array.isArray(tabs)) tabs.forEach((tabValue, tabIndex) => { + if (!record(tabValue)) return + const tab = node("tab", `${path}.tabs[${tabIndex}]`, tabValue, tabIndex, depth + 1, section, sectionType) + nodes.push(tab) + const tabSections = tabValue.sections + if (Array.isArray(tabSections)) tabSections.forEach((child, childIndex) => { if (record(child)) walkSection(child, `${tab.path}.sections[${childIndex}]`, childIndex, depth + 2, tab, nodes) }) + }) + + const panes = value.panes + if (Array.isArray(panes)) panes.forEach((pane, paneIndex) => { + if (!record(pane) || !Array.isArray(pane.sections)) return + pane.sections.forEach((child, childIndex) => { if (record(child)) walkSection(child, `${path}.panes[${paneIndex}].sections[${childIndex}]`, childIndex, depth + 2, section, nodes) }) + }) +} + +export function walkAccessibilityNodes(page: PageDsl): AccessibilityNode[] { + const pageValue = page.page as unknown as Record + const root = node("page", "page", pageValue, 0, 0) + const nodes: AccessibilityNode[] = [root] + walkButtons(pageValue, "toolbar", "page", root, nodes, 1) + page.page.sections.forEach((section, index) => walkSection(section as unknown as Record, `page.sections[${index}]`, index, 1, root, nodes)) + return nodes +} + +export function nodeText(node: AccessibilityNode, ...keys: string[]): string { + for (const key of keys) { + const value = node.value[key] + if (typeof value === "string" && value.trim()) return value.trim() + } + return "" +} + +export function nodeBoolean(node: AccessibilityNode, key: string): boolean | undefined { + const value = node.value[key] + return typeof value === "boolean" ? value : undefined +} + +export function hasOwn(node: AccessibilityNode, key: string): boolean { + return Object.prototype.hasOwnProperty.call(node.value, key) +} diff --git a/packages/ubml-codegen/src/cli/accessibility-audit.ts b/packages/ubml-codegen/src/cli/accessibility-audit.ts new file mode 100644 index 00000000..a38d213a --- /dev/null +++ b/packages/ubml-codegen/src/cli/accessibility-audit.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env bun +import { parseArgs } from "node:util" +import { readFile, writeFile } from "node:fs/promises" +import { accessibilityBaselineToJson, applyAllSafeAccessibilityFixes, auditPageAccessibility, accessibilitySarif, createAccessibilityBaseline, formatAccessibilityReport, parseAccessibilityBaseline, suppressBaselineFindings, type AccessibilityReportFormat, type AccessibilitySeverity } from "../accessibility" + +const parsed = parseArgs({ args: Bun.argv.slice(2), allowPositionals: true, options: { + format: { type: "string", default: "terminal" }, output: { type: "string" }, "fail-on": { type: "string", default: "error" }, "no-notices": { type: "boolean", default: false }, fix: { type: "boolean", default: false }, sarif: { type: "boolean", default: false }, color: { type: "boolean", default: false }, baseline: { type: "string" }, "write-baseline": { type: "string" }, +} }) + +const input = parsed.positionals[0] +if (!input) { console.error("usage: ubml-accessibility-audit [--format terminal|markdown|json] [--fail-on error|warning|notice] [--fix] [--sarif] [--baseline file] [--write-baseline file] [--output file]"); process.exit(2) } +const formats = new Set(["terminal", "markdown", "json"]) +const severities = new Set(["error", "warning", "notice"]) +if (!formats.has(parsed.values.format ?? "") || !severities.has(parsed.values["fail-on"] ?? "")) { console.error("invalid --format or --fail-on value"); process.exit(2) } + +let yaml: string +try { yaml = await readFile(input, "utf8") } +catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(2) } +const options = { failOn: parsed.values["fail-on"] as AccessibilitySeverity, includeNotices: !parsed.values["no-notices"] } +if (parsed.values.fix) { + const fixed = applyAllSafeAccessibilityFixes(yaml, options) + if (!fixed.ok) { console.error(fixed.message); process.exit(2) } + if (fixed.yaml !== yaml) { await writeFile(input, fixed.yaml, "utf8"); yaml = fixed.yaml } + console.error(fixed.message) +} +const audited = auditPageAccessibility(yaml, options) +if (!audited.parsed) { + console.error(audited.summary) + process.exit(2) +} +if (parsed.values["write-baseline"]) { + const baseline = createAccessibilityBaseline(audited, { source: input }) + await writeFile(parsed.values["write-baseline"], accessibilityBaselineToJson(baseline) + "\n", "utf8") + console.error(`已写入无障碍基线:${parsed.values["write-baseline"]}`) +} +let result = audited +if (parsed.values.baseline) { + try { + const baselineText = await readFile(parsed.values.baseline, "utf8") + result = suppressBaselineFindings(audited, parseAccessibilityBaseline(baselineText)) + } catch (error) { + console.error(`读取无障碍基线失败:${error instanceof Error ? error.message : String(error)}`) + process.exit(2) + } +} +const output = parsed.values.sarif ? JSON.stringify(accessibilitySarif(result, input), null, 2) : formatAccessibilityReport(result, parsed.values.format as AccessibilityReportFormat, { source: input, color: parsed.values.color }) +if (parsed.values.output) await writeFile(parsed.values.output, output + "\n", "utf8") +else console.log(output) +process.exit(result.ok ? 0 : 1) diff --git a/packages/ubml-codegen/src/runtime.ts b/packages/ubml-codegen/src/runtime.ts index 57237116..9ce28bfd 100644 --- a/packages/ubml-codegen/src/runtime.ts +++ b/packages/ubml-codegen/src/runtime.ts @@ -239,3 +239,8 @@ export { runEntityEdit, ALL_ENTITY_EDIT_OPS, type EntityEditResult, type EntityE export type { EntityEditOp } from "./entity/edit/entity-dsl" export { runVoEdit, VO_EDIT_OPS, type VoEditOp, type VoEditResult, type VoEditIssue } from "./vo/edit/operations" export { runEapiEdit, EAPI_EDIT_OPS, type EapiEditOp, type EapiEditResult, type EapiEditIssue } from "./eapi/edit/operations" +export { auditPageAccessibility, DEFAULT_ACCESSIBILITY_OPTIONS, findingsAtPath, groupFindingsByRule } from "./accessibility/audit" +export { applyAccessibilityFix, applyAllSafeAccessibilityFixes, collectAccessibilityFixes } from "./accessibility/fixes" +export { accessibilityReportToJson, accessibilityReportToMarkdown, accessibilityReportToTerminal, accessibilitySarif, formatAccessibilityReport } from "./accessibility/report" +export { accessibilityBaselineToJson, accessibilityFindingFingerprint, compareAccessibilityBaseline, createAccessibilityBaseline, parseAccessibilityBaseline, suppressBaselineFindings } from "./accessibility/baseline" +export type { AccessibilityAuditOptions, AccessibilityAuditResult, AccessibilityBaseline, AccessibilityBaselineComparison, AccessibilityFinding, AccessibilityFixResult, AccessibilityQuickFix, AccessibilityScore, AccessibilitySeverity } from "./accessibility/types" diff --git a/packages/ubml-codegen/test/accessibility-audit.test.ts b/packages/ubml-codegen/test/accessibility-audit.test.ts new file mode 100644 index 00000000..a5e30e7c --- /dev/null +++ b/packages/ubml-codegen/test/accessibility-audit.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, test } from "bun:test" +import { accessibilityBaselineToJson, accessibilityFindingFingerprint, accessibilityReportToJson, accessibilityReportToMarkdown, accessibilityReportToTerminal, accessibilitySarif, applyAccessibilityFix, applyAllSafeAccessibilityFixes, auditPageAccessibility, collectAccessibilityFixes, compareAccessibilityBaseline, createAccessibilityBaseline, findingsAtPath, groupFindingsByRule, parseAccessibilityBaseline, suppressBaselineFindings, walkAccessibilityNodes } from "../src/accessibility" + +const GOOD = `page: + type: list + title: 客户资料查询 + entity: Customer + toolbar: + - text: 新增客户 + onClick: addCustomer + - text: 刷新列表 + onClick: refresh + sections: + - type: filter-bar + title: 筛选条件 + fields: + - label: 客户编码 + binding: Customer.Code + editor: text + - label: 客户名称 + binding: Customer.Name + editor: text + - type: data-grid + title: 客户列表 + binding: CustomerList + columns: + - title: 客户编码 + binding: Code + editor: text + - title: 客户名称 + binding: Name + editor: text +` + +const BAD = `page: + type: card + title: 标题 + entity: Expense + toolbar: + - text: "⚙" + onClick: settings + - text: 删除 + onClick: deleteExpense + - text: 删除 + onClick: removeExpense + sections: + - type: form + title: 基本信息 + formColumns: 6 + enableAccordion: true + fields: + - label: 名称 + binding: Expense.Name + editor: text + - label: 名称 + binding: Expense.Name + editor: text + - type: filter-bar + fields: + - label: 条件 + binding: Expense.Code + editor: text + - type: data-grid + binding: Items + columns: + - title: 金额 + binding: Amount + editor: number + - title: 金额 + binding: Tax + editor: number + - type: tabs + tabs: + - title: 明细 + sections: [] + - title: 明细 + sections: [] + - type: raw + raw: + component: custom-chart +` + +describe("accessibility audit", () => { + test("passes a well-labelled page", () => { + const result = auditPageAccessibility(GOOD) + expect(result.parsed).toBe(true) + expect(result.ok).toBe(true) + expect(result.score.errors).toBe(0) + expect(result.score.grade).toBe("A") + expect(result.findings).toHaveLength(0) + }) + + test("finds high-impact static failures", () => { + const result = auditPageAccessibility(BAD) + expect(result.parsed).toBe(true) + expect(result.ok).toBe(false) + const ids = new Set(result.findings.map((finding) => finding.ruleId)) + for (const expected of [ + "page-title-generic", "section-heading-missing", + "field-label-generic", "field-label-duplicate", "field-binding-duplicate", + "grid-title-missing", "grid-column-duplicate", "tab-title-duplicate", "tab-empty", + "toolbar-action-name", "toolbar-action-duplicate", "destructive-action-confirmation", + "dense-form-review", "raw-section-review", + ]) expect(ids.has(expected as any)).toBe(true) + expect(result.score.errors).toBeGreaterThan(3) + expect(result.score.value).toBeLessThan(70) + }) + + test("supports fail thresholds and notice filtering", () => { + const warningOnly = GOOD.replace(" title: 客户列表\n", "") + const defaultResult = auditPageAccessibility(warningOnly) + expect(defaultResult.ok).toBe(true) + expect(defaultResult.score.warnings).toBeGreaterThan(0) + const strict = auditPageAccessibility(warningOnly, { failOn: "warning" }) + expect(strict.ok).toBe(false) + const hidden = auditPageAccessibility(BAD, { includeNotices: false }) + expect(hidden.findings.every((finding) => finding.severity !== "notice")).toBe(true) + }) + + test("returns a parse finding for invalid YAML", () => { + const result = auditPageAccessibility("page: [") + expect(result.parsed).toBe(false) + expect(result.ok).toBe(false) + expect(result.findings[0]).toMatchObject({ severity: "error", path: "page" }) + }) + + test("walks tabs and nested pane sections with stable paths", () => { + const yaml = `page: + type: layout + title: 工作台 + sections: + - type: splitter + panes: + - sections: + - type: tabs + tabs: + - title: 信息 + sections: + - type: form + title: 基本信息 + fields: + - { label: 编码, binding: Demo.Code, editor: text } +` + const result = auditPageAccessibility(yaml) + expect(result.page).toBeDefined() + const paths = walkAccessibilityNodes(result.page!).map((node) => node.path) + expect(paths).toContain("page.sections[0].panes[0].sections[0].tabs[0].sections[0].fields[0]") + }) + + test("groups and filters findings for UI consumers", () => { + const result = auditPageAccessibility(BAD) + expect(findingsAtPath(result, "page.sections[0]").length).toBeGreaterThan(3) + const groups = groupFindingsByRule(result) + expect(groups.get("tab-empty")).toHaveLength(2) + expect(groups.get("page-title-generic")).toHaveLength(1) + }) +}) + +describe("safe accessibility fixes", () => { + test("collects unique deterministic fixes", () => { + const fixes = collectAccessibilityFixes(auditPageAccessibility(BAD).findings) + expect(fixes.length).toBeGreaterThanOrEqual(3) + expect(new Set(fixes.map((fix) => fix.id)).size).toBe(fixes.length) + expect(fixes.every((fix) => fix.safe)).toBe(true) + }) + + test("applies a section heading fix and re-audits", () => { + const before = auditPageAccessibility(BAD) + const fix = collectAccessibilityFixes(before.findings).find((item) => item.id.startsWith("section-heading-missing:page.sections[1]")) + expect(fix).toBeDefined() + const result = applyAccessibilityFix(BAD, fix!.id) + expect(result.ok).toBe(true) + expect(result.yaml).toContain("title: 基本信息") + expect(result.findings.some((finding) => finding.ruleId === "section-heading-missing" && finding.path.startsWith("page.sections[1]"))).toBe(false) + }) + + test("removes a conflicting aria label only when exposed by the DSL", () => { + const result = applyAccessibilityFix(GOOD, "missing") + expect(result.ok).toBe(false) + expect(result.yaml).toBe(GOOD) + expect(result.message).toContain("不存在") + }) + + test("applies all safe fixes as one recoverable operation", () => { + const result = applyAllSafeAccessibilityFixes(BAD) + expect(result.ok).toBe(true) + expect(result.message).toMatch(/已应用 \d+ 项/) + const after = auditPageAccessibility(result.yaml) + expect(after.score.value).toBeGreaterThan(auditPageAccessibility(BAD).score.value) + expect(after.findings.some((finding) => finding.ruleId === "grid-title-missing")).toBe(false) + }) +}) + +describe("accessibility reports", () => { + const result = auditPageAccessibility(BAD) + + test("renders terminal output", () => { + const output = accessibilityReportToTerminal(result) + expect(output).toContain("UBML 页面无障碍审计") + expect(output).toContain("page-title-generic") + expect(output).toContain("建议:") + }) + + test("renders markdown output", () => { + const output = accessibilityReportToMarkdown(result, "bad.yaml") + expect(output).toContain("# UBML 页面无障碍审计") + expect(output).toContain("| 级别 | 规则 |") + expect(output).toContain("`bad.yaml`") + expect(output).toContain("不替代渲染后") + }) + + test("renders machine-readable JSON", () => { + const parsed = JSON.parse(accessibilityReportToJson(result)) + expect(parsed.version).toBe(1) + expect(parsed.findings.length).toBe(result.findings.length) + expect(parsed.score.grade).toBe(result.score.grade) + }) + + test("renders SARIF for CI code scanning", () => { + const sarif = accessibilitySarif(result, "bad.yaml") as any + expect(sarif.version).toBe("2.1.0") + expect(sarif.runs[0].tool.driver.name).toBe("UBML Accessibility Audit") + expect(sarif.runs[0].results[0].locations[0].physicalLocation.artifactLocation.uri).toBe("bad.yaml") + }) +}) + +describe("accessibility baselines", () => { + test("creates deterministic fingerprints and a portable baseline", () => { + const result = auditPageAccessibility(BAD) + const baseline = createAccessibilityBaseline(result, { source: "bad.yaml", createdAt: "2026-07-19T00:00:00.000Z" }) + expect(baseline.entries).toHaveLength(result.findings.length) + expect(baseline.entries[0]?.fingerprint).toHaveLength(16) + expect(accessibilityFindingFingerprint(result.findings[0]!)).toBe(accessibilityFindingFingerprint({ ...result.findings[0]!, message: ` ${result.findings[0]!.message} ` })) + expect(parseAccessibilityBaseline(accessibilityBaselineToJson(baseline))).toEqual(baseline) + }) + + test("rejects malformed baseline input", () => { + expect(() => parseAccessibilityBaseline('{"version":2,"entries":[]}')).toThrow("Unsupported") + expect(() => parseAccessibilityBaseline('{broken')).toThrow() + }) + + test("suppresses known debt while keeping score and threshold consistent", () => { + const before = auditPageAccessibility(BAD, { failOn: "warning" }) + const baseline = createAccessibilityBaseline(before) + const clean = suppressBaselineFindings(before, baseline) + expect(clean.ok).toBe(true) + expect(clean.findings).toHaveLength(0) + expect(clean.score).toMatchObject({ value: 100, grade: "A", errors: 0, warnings: 0, notices: 0, highConfidence: 0, manualReview: 0 }) + }) + + test("detects only findings introduced after the baseline", () => { + const good = auditPageAccessibility(GOOD) + const baseline = createAccessibilityBaseline(good) + const current = auditPageAccessibility(BAD) + const comparison = compareAccessibilityBaseline(current, baseline) + expect(comparison.regressed).toBe(true) + expect(comparison.newFindings.length).toBeGreaterThan(5) + expect(comparison.summary).toContain("基线退化") + const relative = suppressBaselineFindings(current, baseline) + expect(relative.findings.length).toBe(comparison.newFindings.length) + }) +}) diff --git a/packages/ubml-codegen/test/fixtures/accessibility/bad-page.yaml b/packages/ubml-codegen/test/fixtures/accessibility/bad-page.yaml new file mode 100644 index 00000000..addade0c --- /dev/null +++ b/packages/ubml-codegen/test/fixtures/accessibility/bad-page.yaml @@ -0,0 +1,19 @@ +page: + type: list + title: 页面 + entity: Customer + toolbar: + - text: 删除 + onClick: deleteSelected + sections: + - type: filter-bar + fields: + - label: 名称 + binding: Customer.Name + editor: text + - type: data-grid + binding: CustomerList + columns: + - title: 名称 + binding: Name + editor: text -- Gitee From b4101495ab244a0fd07c44ddfca8b328a0287731 Mon Sep 17 00:00:00 2001 From: btlqql <2977859784@qq.com> Date: Sun, 19 Jul 2026 10:32:46 +0800 Subject: [PATCH 2/2] feat(accessibility): add auditable rule policy --- .../add-page-accessibility-audit/proposal.md | 1 + .../add-page-accessibility-audit/tasks.md | 1 + .../ubml-codegen/src/accessibility/index.ts | 3 +- .../ubml-codegen/src/accessibility/policy.ts | 150 ++++++++++++++++++ .../ubml-codegen/src/accessibility/types.ts | 31 ++++ .../src/cli/accessibility-audit.ts | 10 +- .../test/accessibility-audit.test.ts | 48 +++++- 7 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 packages/ubml-codegen/src/accessibility/policy.ts diff --git a/openspec/changes/add-page-accessibility-audit/proposal.md b/openspec/changes/add-page-accessibility-audit/proposal.md index 4919f67d..6a70771f 100644 --- a/openspec/changes/add-page-accessibility-audit/proposal.md +++ b/openspec/changes/add-page-accessibility-audit/proposal.md @@ -10,6 +10,7 @@ UBML page verification proves that a page can compile, but it does not catch com - Add stable issue paths, severity, evidence, remediation and confidence metadata. - Add safe quick fixes for deterministic metadata corrections. - Add JSON, Markdown and terminal reports plus a CI-friendly CLI. +- Add auditable rule policies and expiring waivers for gradual adoption. ## Scope diff --git a/openspec/changes/add-page-accessibility-audit/tasks.md b/openspec/changes/add-page-accessibility-audit/tasks.md index 6037dbc4..89314e54 100644 --- a/openspec/changes/add-page-accessibility-audit/tasks.md +++ b/openspec/changes/add-page-accessibility-audit/tasks.md @@ -5,5 +5,6 @@ - [x] Implement deterministic quick-fix registry and YAML preservation. - [x] Implement JSON, Markdown, SARIF and terminal reporters. - [x] Add CI-friendly CLI, debt baselines and package exports. +- [x] Add rule severity policy, scoped waivers and expiry reporting. - [x] Add rule, nested-section, fix, baseline and reporting tests. - [x] Run focused tests and typecheck diagnostics for changed files. diff --git a/packages/ubml-codegen/src/accessibility/index.ts b/packages/ubml-codegen/src/accessibility/index.ts index fc9642f3..4c18cf11 100644 --- a/packages/ubml-codegen/src/accessibility/index.ts +++ b/packages/ubml-codegen/src/accessibility/index.ts @@ -3,5 +3,6 @@ export { applyAccessibilityFix, applyAllSafeAccessibilityFixes, collectAccessibi export { accessibilityReportToJson, accessibilityReportToMarkdown, accessibilityReportToTerminal, accessibilitySarif, formatAccessibilityReport } from "./report" export { ACCESSIBILITY_RULES } from "./rules" export { accessibilityBaselineToJson, accessibilityFindingFingerprint, compareAccessibilityBaseline, createAccessibilityBaseline, parseAccessibilityBaseline, suppressBaselineFindings } from "./baseline" +export { ACCESSIBILITY_POLICY_EXAMPLE, accessibilityPolicyToJson, applyAccessibilityPolicy, parseAccessibilityPolicy, reviewAccessibilityPolicy } from "./policy" export { walkAccessibilityNodes } from "./walker" -export type { AccessibilityAuditOptions, AccessibilityAuditResult, AccessibilityBaseline, AccessibilityBaselineComparison, AccessibilityBaselineEntry, AccessibilityConfidence, AccessibilityEvidence, AccessibilityFinding, AccessibilityFixResult, AccessibilityNode, AccessibilityQuickFix, AccessibilityReportFormat, AccessibilityRule, AccessibilityRuleContext, AccessibilityRuleId, AccessibilityScore, AccessibilitySeverity } from "./types" +export type { AccessibilityAuditOptions, AccessibilityAuditResult, AccessibilityBaseline, AccessibilityBaselineComparison, AccessibilityBaselineEntry, AccessibilityConfidence, AccessibilityEvidence, AccessibilityFinding, AccessibilityFixResult, AccessibilityNode, AccessibilityPolicy, AccessibilityPolicyApplication, AccessibilityPolicyIssue, AccessibilityPolicyWaiver, AccessibilityQuickFix, AccessibilityReportFormat, AccessibilityRule, AccessibilityRuleContext, AccessibilityRuleId, AccessibilityScore, AccessibilitySeverity } from "./types" diff --git a/packages/ubml-codegen/src/accessibility/policy.ts b/packages/ubml-codegen/src/accessibility/policy.ts new file mode 100644 index 00000000..4c4d865c --- /dev/null +++ b/packages/ubml-codegen/src/accessibility/policy.ts @@ -0,0 +1,150 @@ +import type { AccessibilityAuditResult, AccessibilityFinding, AccessibilityPolicy, AccessibilityPolicyApplication, AccessibilityPolicyIssue, AccessibilityPolicyWaiver, AccessibilityRuleId, AccessibilityScore, AccessibilitySeverity } from "./types" + +const severityRank: Record = { error: 3, warning: 2, notice: 1 } +const severities = new Set(["error", "warning", "notice", "off"]) + +function isObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function isDate(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}(?:T.*)?$/.test(value) && !Number.isNaN(Date.parse(value)) +} + +function parseWaiver(value: unknown, index: number): AccessibilityPolicyWaiver { + if (!isObject(value)) throw new Error(`waivers[${index}] 必须是对象`) + if (typeof value.id !== "string" || !value.id.trim()) throw new Error(`waivers[${index}].id 不能为空`) + if (typeof value.reason !== "string" || value.reason.trim().length < 8) throw new Error(`waivers[${index}].reason 至少需要 8 个字符`) + if (typeof value.owner !== "string" || !value.owner.trim()) throw new Error(`waivers[${index}].owner 不能为空`) + if (value.path === undefined && value.ruleId === undefined) throw new Error(`waivers[${index}] 必须限定 ruleId 或 path`) + if (value.match !== undefined && !["exact", "prefix"].includes(String(value.match))) throw new Error(`waivers[${index}].match 只能是 exact 或 prefix`) + if (value.expiresAt !== undefined && (typeof value.expiresAt !== "string" || !isDate(value.expiresAt))) throw new Error(`waivers[${index}].expiresAt 不是有效日期`) + return { + id: value.id.trim(), + ...(typeof value.ruleId === "string" ? { ruleId: value.ruleId as AccessibilityRuleId } : {}), + ...(typeof value.path === "string" ? { path: value.path } : {}), + ...(value.match === "exact" || value.match === "prefix" ? { match: value.match } : {}), + reason: value.reason.trim(), + owner: value.owner.trim(), + ...(typeof value.expiresAt === "string" ? { expiresAt: value.expiresAt } : {}), + } +} + +export function parseAccessibilityPolicy(input: string | unknown): AccessibilityPolicy { + const value = typeof input === "string" ? JSON.parse(input) : input + if (!isObject(value)) throw new Error("无障碍策略必须是 JSON 对象") + if (value.failOn !== undefined && !["error", "warning", "notice"].includes(String(value.failOn))) throw new Error("failOn 只能是 error、warning 或 notice") + const severity: AccessibilityPolicy["severity"] = {} + if (value.severity !== undefined) { + if (!isObject(value.severity)) throw new Error("severity 必须是规则到级别的对象") + for (const [ruleId, level] of Object.entries(value.severity)) { + if (typeof level !== "string" || !severities.has(level)) throw new Error(`severity.${ruleId} 不是支持的级别`) + severity[ruleId as AccessibilityRuleId] = level as AccessibilitySeverity | "off" + } + } + const waivers = value.waivers === undefined ? [] : Array.isArray(value.waivers) ? value.waivers.map(parseWaiver) : (() => { throw new Error("waivers 必须是数组") })() + const duplicate = waivers.find((waiver, index) => waivers.findIndex((candidate) => candidate.id === waiver.id) !== index) + if (duplicate) throw new Error(`waiver id 重复:${duplicate.id}`) + return { + ...(typeof value.failOn === "string" ? { failOn: value.failOn as AccessibilitySeverity } : {}), + ...(Object.keys(severity).length ? { severity } : {}), + ...(waivers.length ? { waivers } : {}), + } +} + +function waiverExpired(waiver: AccessibilityPolicyWaiver, now: Date): boolean { + return waiver.expiresAt !== undefined && Date.parse(waiver.expiresAt) < now.getTime() +} + +function waiverMatches(waiver: AccessibilityPolicyWaiver, finding: AccessibilityFinding): boolean { + if (waiver.ruleId && waiver.ruleId !== finding.ruleId) return false + if (!waiver.path) return true + return waiver.match === "prefix" + ? finding.path === waiver.path || finding.path.startsWith(`${waiver.path}.`) || finding.path.startsWith(`${waiver.path}[`) + : finding.path === waiver.path +} + +function measure(findings: AccessibilityFinding[]): AccessibilityScore { + const errors = findings.filter((finding) => finding.severity === "error").length + const warnings = findings.filter((finding) => finding.severity === "warning").length + const notices = findings.filter((finding) => finding.severity === "notice").length + const value = Math.max(0, 100 - errors * 12 - warnings * 5 - notices) + const grade = value >= 95 ? "A" : value >= 85 ? "B" : value >= 70 ? "C" : value >= 50 ? "D" : "F" + return { value, grade, errors, warnings, notices, highConfidence: findings.filter((finding) => finding.confidence === "high").length, manualReview: findings.filter((finding) => finding.confidence === "review").length } +} + +export function applyAccessibilityPolicy(result: AccessibilityAuditResult, policyInput: AccessibilityPolicy | string, options: { now?: Date } = {}): AccessibilityPolicyApplication { + const policy = parseAccessibilityPolicy(policyInput) + const now = options.now ?? new Date() + const activeWaivers = (policy.waivers ?? []).filter((waiver) => !waiverExpired(waiver, now)) + const expiredWaivers = (policy.waivers ?? []).filter((waiver) => waiverExpired(waiver, now)) + const suppressed: AccessibilityFinding[] = [] + const findings: AccessibilityFinding[] = [] + for (const original of result.findings) { + const override = policy.severity?.[original.ruleId] + if (override === "off") { suppressed.push(original); continue } + const finding = override ? { ...original, severity: override } : original + if (activeWaivers.some((waiver) => waiverMatches(waiver, finding))) suppressed.push(finding) + else findings.push(finding) + } + findings.sort((left, right) => severityRank[right.severity] - severityRank[left.severity] || left.path.localeCompare(right.path) || left.ruleId.localeCompare(right.ruleId)) + const score = measure(findings) + const failOn = policy.failOn ?? result.options.failOn + const ok = !findings.some((finding) => severityRank[finding.severity] >= severityRank[failOn]) + const updated: AccessibilityAuditResult = { + ...result, + ok, + findings, + score, + options: { ...result.options, failOn }, + summary: findings.length ? `${score.grade} / ${score.value} 分:策略应用后 ${score.errors} 错误,${score.warnings} 警告,${score.notices} 待复核。` : "A / 100 分:策略应用后未发现未豁免问题。", + } + const summary = `策略抑制 ${suppressed.length} 项;有效豁免 ${activeWaivers.length} 条;过期豁免 ${expiredWaivers.length} 条。` + return { result: updated, suppressed, activeWaivers, expiredWaivers, summary } +} + +export function accessibilityPolicyToJson(policy: AccessibilityPolicy): string { + return JSON.stringify(parseAccessibilityPolicy(policy), null, 2) +} + +export function reviewAccessibilityPolicy(policyInput: AccessibilityPolicy | string, options: { now?: Date } = {}): AccessibilityPolicyIssue[] { + const policy = parseAccessibilityPolicy(policyInput) + const now = options.now ?? new Date() + const issues: AccessibilityPolicyIssue[] = [] + for (const [ruleId, severity] of Object.entries(policy.severity ?? {})) { + if (severity === "off") issues.push({ + code: "rule-disabled", + severity: "warning", + path: `severity.${ruleId}`, + message: `规则 ${ruleId} 被全局关闭;优先使用有负责人和期限的局部豁免。`, + }) + } + for (const [index, waiver] of (policy.waivers ?? []).entries()) { + const path = `waivers[${index}]` + if (!waiver.expiresAt) issues.push({ + code: "waiver-no-expiry", + severity: "warning", + path, + message: `豁免 ${waiver.id} 没有到期时间,可能永久隐藏回归。`, + }) + else if (waiverExpired(waiver, now)) issues.push({ + code: "waiver-expired", + severity: "warning", + path, + message: `豁免 ${waiver.id} 已于 ${waiver.expiresAt} 到期,应移除或重新审批。`, + }) + if (!waiver.path) issues.push({ + code: "waiver-too-broad", + severity: "notice", + path, + message: `豁免 ${waiver.id} 覆盖规则的所有位置,建议限定到最小页面路径。`, + }) + } + return issues +} + +export const ACCESSIBILITY_POLICY_EXAMPLE: AccessibilityPolicy = { + failOn: "warning", + severity: { "raw-section-review": "warning", "page-title-length": "notice" }, + waivers: [{ id: "legacy-raw-section", ruleId: "raw-section-review", path: "page.sections[2]", match: "prefix", reason: "第三方报表迁移期间保留,已安排替换", owner: "frontend-owner", expiresAt: "2026-12-31" }], +} diff --git a/packages/ubml-codegen/src/accessibility/types.ts b/packages/ubml-codegen/src/accessibility/types.ts index d1993b30..e2181f16 100644 --- a/packages/ubml-codegen/src/accessibility/types.ts +++ b/packages/ubml-codegen/src/accessibility/types.ts @@ -129,3 +129,34 @@ export interface AccessibilityBaselineComparison { severityChanged: Array<{ before: AccessibilityBaselineEntry; after: AccessibilityFinding }> summary: string } + +export interface AccessibilityPolicyWaiver { + id: string + ruleId?: AccessibilityRuleId + path?: string + match?: "exact" | "prefix" + reason: string + owner: string + expiresAt?: string +} + +export interface AccessibilityPolicy { + failOn?: AccessibilitySeverity + severity?: Partial> + waivers?: AccessibilityPolicyWaiver[] +} + +export interface AccessibilityPolicyApplication { + result: AccessibilityAuditResult + suppressed: AccessibilityFinding[] + activeWaivers: AccessibilityPolicyWaiver[] + expiredWaivers: AccessibilityPolicyWaiver[] + summary: string +} + +export interface AccessibilityPolicyIssue { + code: "waiver-expired" | "waiver-no-expiry" | "waiver-too-broad" | "rule-disabled" + severity: "warning" | "notice" + path: string + message: string +} diff --git a/packages/ubml-codegen/src/cli/accessibility-audit.ts b/packages/ubml-codegen/src/cli/accessibility-audit.ts index a38d213a..53fd3ed8 100644 --- a/packages/ubml-codegen/src/cli/accessibility-audit.ts +++ b/packages/ubml-codegen/src/cli/accessibility-audit.ts @@ -1,14 +1,14 @@ #!/usr/bin/env bun import { parseArgs } from "node:util" import { readFile, writeFile } from "node:fs/promises" -import { accessibilityBaselineToJson, applyAllSafeAccessibilityFixes, auditPageAccessibility, accessibilitySarif, createAccessibilityBaseline, formatAccessibilityReport, parseAccessibilityBaseline, suppressBaselineFindings, type AccessibilityReportFormat, type AccessibilitySeverity } from "../accessibility" +import { accessibilityBaselineToJson, applyAccessibilityPolicy, applyAllSafeAccessibilityFixes, auditPageAccessibility, accessibilitySarif, createAccessibilityBaseline, formatAccessibilityReport, parseAccessibilityBaseline, parseAccessibilityPolicy, suppressBaselineFindings, type AccessibilityReportFormat, type AccessibilitySeverity } from "../accessibility" const parsed = parseArgs({ args: Bun.argv.slice(2), allowPositionals: true, options: { - format: { type: "string", default: "terminal" }, output: { type: "string" }, "fail-on": { type: "string", default: "error" }, "no-notices": { type: "boolean", default: false }, fix: { type: "boolean", default: false }, sarif: { type: "boolean", default: false }, color: { type: "boolean", default: false }, baseline: { type: "string" }, "write-baseline": { type: "string" }, + format: { type: "string", default: "terminal" }, output: { type: "string" }, "fail-on": { type: "string", default: "error" }, "no-notices": { type: "boolean", default: false }, fix: { type: "boolean", default: false }, sarif: { type: "boolean", default: false }, color: { type: "boolean", default: false }, baseline: { type: "string" }, "write-baseline": { type: "string" }, policy: { type: "string" }, } }) const input = parsed.positionals[0] -if (!input) { console.error("usage: ubml-accessibility-audit [--format terminal|markdown|json] [--fail-on error|warning|notice] [--fix] [--sarif] [--baseline file] [--write-baseline file] [--output file]"); process.exit(2) } +if (!input) { console.error("usage: ubml-accessibility-audit [--format terminal|markdown|json] [--fail-on error|warning|notice] [--fix] [--sarif] [--policy file] [--baseline file] [--write-baseline file] [--output file]"); process.exit(2) } const formats = new Set(["terminal", "markdown", "json"]) const severities = new Set(["error", "warning", "notice"]) if (!formats.has(parsed.values.format ?? "") || !severities.has(parsed.values["fail-on"] ?? "")) { console.error("invalid --format or --fail-on value"); process.exit(2) } @@ -34,6 +34,10 @@ if (parsed.values["write-baseline"]) { console.error(`已写入无障碍基线:${parsed.values["write-baseline"]}`) } let result = audited +if (parsed.values.policy) { + try { result = applyAccessibilityPolicy(result, parseAccessibilityPolicy(await readFile(parsed.values.policy, "utf8"))).result } + catch (error) { console.error(`读取无障碍策略失败:${error instanceof Error ? error.message : String(error)}`); process.exit(2) } +} if (parsed.values.baseline) { try { const baselineText = await readFile(parsed.values.baseline, "utf8") diff --git a/packages/ubml-codegen/test/accessibility-audit.test.ts b/packages/ubml-codegen/test/accessibility-audit.test.ts index a5e30e7c..53707aaa 100644 --- a/packages/ubml-codegen/test/accessibility-audit.test.ts +++ b/packages/ubml-codegen/test/accessibility-audit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { accessibilityBaselineToJson, accessibilityFindingFingerprint, accessibilityReportToJson, accessibilityReportToMarkdown, accessibilityReportToTerminal, accessibilitySarif, applyAccessibilityFix, applyAllSafeAccessibilityFixes, auditPageAccessibility, collectAccessibilityFixes, compareAccessibilityBaseline, createAccessibilityBaseline, findingsAtPath, groupFindingsByRule, parseAccessibilityBaseline, suppressBaselineFindings, walkAccessibilityNodes } from "../src/accessibility" +import { accessibilityBaselineToJson, accessibilityFindingFingerprint, accessibilityPolicyToJson, accessibilityReportToJson, accessibilityReportToMarkdown, accessibilityReportToTerminal, accessibilitySarif, applyAccessibilityFix, applyAccessibilityPolicy, applyAllSafeAccessibilityFixes, auditPageAccessibility, collectAccessibilityFixes, compareAccessibilityBaseline, createAccessibilityBaseline, findingsAtPath, groupFindingsByRule, parseAccessibilityBaseline, parseAccessibilityPolicy, reviewAccessibilityPolicy, suppressBaselineFindings, walkAccessibilityNodes } from "../src/accessibility" const GOOD = `page: type: list @@ -260,3 +260,49 @@ describe("accessibility baselines", () => { expect(relative.findings.length).toBe(comparison.newFindings.length) }) }) + +describe("accessibility governance policy", () => { + test("overrides severity and fail threshold deterministically", () => { + const audited = auditPageAccessibility(BAD) + const applied = applyAccessibilityPolicy(audited, { + failOn: "warning", + severity: { "page-title-generic": "notice", "raw-section-review": "off" }, + }) + expect(applied.result.options.failOn).toBe("warning") + expect(applied.result.findings.find((finding) => finding.ruleId === "page-title-generic")?.severity).toBe("notice") + expect(applied.result.findings.some((finding) => finding.ruleId === "raw-section-review")).toBe(false) + expect(applied.suppressed.some((finding) => finding.ruleId === "raw-section-review")).toBe(true) + }) + + test("applies scoped waivers and reports expiry", () => { + const audited = auditPageAccessibility(BAD) + const application = applyAccessibilityPolicy(audited, { + waivers: [ + { id: "active-grid", ruleId: "grid-title-missing", path: "page.sections[2]", reason: "旧版列表正在迁移替换中", owner: "frontend-owner", expiresAt: "2026-12-31" }, + { id: "expired-page", ruleId: "page-title-generic", reason: "历史页面等待产品确认中", owner: "product-owner", expiresAt: "2026-01-01" }, + ], + }, { now: new Date("2026-07-19T00:00:00Z") }) + expect(application.activeWaivers.map((waiver) => waiver.id)).toEqual(["active-grid"]) + expect(application.expiredWaivers.map((waiver) => waiver.id)).toEqual(["expired-page"]) + expect(application.result.findings.some((finding) => finding.ruleId === "grid-title-missing" && finding.path === "page.sections[2]")).toBe(false) + expect(application.result.findings.some((finding) => finding.ruleId === "page-title-generic")).toBe(true) + }) + + test("parses portable JSON and rejects unauditable waivers", () => { + const policy = parseAccessibilityPolicy('{"failOn":"warning","severity":{"tab-empty":"error"}}') + expect(policy.severity?.["tab-empty"]).toBe("error") + expect(JSON.parse(accessibilityPolicyToJson(policy)).failOn).toBe("warning") + expect(() => parseAccessibilityPolicy({ waivers: [{ id: "x", ruleId: "tab-empty", reason: "太短", owner: "" }] })).toThrow() + }) + + test("reviews broad, permanent and expired policy exceptions", () => { + const issues = reviewAccessibilityPolicy({ + severity: { "raw-section-review": "off" }, + waivers: [ + { id: "permanent", ruleId: "tab-empty", reason: "遗留向导等待统一重构中", owner: "ui-owner" }, + { id: "expired", ruleId: "page-title-generic", reason: "历史标题等待产品确认中", owner: "product-owner", expiresAt: "2026-01-01" }, + ], + }, { now: new Date("2026-07-19T00:00:00Z") }) + expect(issues.map((issue) => issue.code)).toEqual(["rule-disabled", "waiver-no-expiry", "waiver-too-broad", "waiver-expired", "waiver-too-broad"]) + }) +}) -- Gitee