diff --git a/.githooks/install-hooks.sh b/.githooks/install-hooks.sh new file mode 100755 index 0000000..6c7cc49 --- /dev/null +++ b/.githooks/install-hooks.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# install-hooks.sh – Symlink the project's Git hooks into .git/hooks/. +# +# Run this script once after cloning the repository to activate the +# pre-commit accessibility checks provided by A11yAgent. +# +# Usage: +# bash .githooks/install-hooks.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +GIT_HOOKS_DIR="$REPO_ROOT/.git/hooks" + +if [[ ! -d "$GIT_HOOKS_DIR" ]]; then + echo "install-hooks: .git/hooks directory not found at $GIT_HOOKS_DIR" + echo " Make sure you are running this script from within the repository." + exit 1 +fi + +install_hook() { + local name="$1" + local source="$SCRIPT_DIR/$name" + local target="$GIT_HOOKS_DIR/$name" + + if [[ ! -f "$source" ]]; then + echo "install-hooks: source hook not found: $source" + return 1 + fi + + # Make the source hook executable. + chmod +x "$source" + + # Back up any existing hook that is not already our symlink. + if [[ -e "$target" && ! -L "$target" ]]; then + local backup="$target.bak" + echo "install-hooks: backing up existing $name hook to $target.bak" + mv "$target" "$backup" + elif [[ -L "$target" ]]; then + # Remove a stale symlink so we can recreate it cleanly. + rm "$target" + fi + + ln -s "$source" "$target" + echo "install-hooks: installed $name → $target" +} + +install_hook "pre-commit" + +echo "" +echo "Git hooks installed successfully." +echo "" +echo "The pre-commit hook will run A11yAgent accessibility checks on staged" +echo "Kotlin files before each commit." +echo "" +echo "To build the A11yAgent JAR (required by the hook):" +echo " ./gradlew :A11yAgent:shadowJar" +echo "" +echo "To skip the hook for a single commit:" +echo " git commit --no-verify" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..1d92907 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# pre-commit – Run A11yAgent accessibility checks on staged Kotlin files. +# +# Copies the staged (index) version of each modified/added .kt file into a +# temporary directory and runs the A11yAgent shadow JAR against that snapshot. +# Warnings are shown but do not block the commit; errors cause an exit 1. +# +# To skip this hook for a single commit: +# git commit --no-verify +# +# To build the JAR if it is missing: +# ./gradlew :A11yAgent:shadowJar +set -euo pipefail + +JAR_PATH="A11yAgent/build/libs/a11y-check-android-0.1.0.jar" + +# --------------------------------------------------------------------------- +# 1. Collect staged .kt files (added, copied, or modified – not deleted). +# --------------------------------------------------------------------------- +KT_FILES=() +while IFS= read -r line; do + KT_FILES+=("$line") +done < <(git diff --cached --name-only --diff-filter=ACM | grep '\.kt$' || true) + +if [[ ${#KT_FILES[@]} -eq 0 ]]; then + exit 0 +fi + +# --------------------------------------------------------------------------- +# 2. Verify the shadow JAR is available. +# --------------------------------------------------------------------------- +if [[ ! -f "$JAR_PATH" ]]; then + echo "" + echo "pre-commit: A11yAgent JAR not found at $JAR_PATH" + echo " Build it first with: ./gradlew :A11yAgent:shadowJar" + echo " To skip this check: git commit --no-verify" + echo "" + exit 1 +fi + +# --------------------------------------------------------------------------- +# 3. Mirror staged file content into a temp directory. +# --------------------------------------------------------------------------- +TMPDIR_HOOK="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_HOOK"' EXIT + +for f in "${KT_FILES[@]}"; do + dest="$TMPDIR_HOOK/$f" + mkdir -p "$(dirname "$dest")" + # Read the staged (index) version, not the working-tree version. + git show ":$f" > "$dest" +done + +# --------------------------------------------------------------------------- +# 4. Run the accessibility checker. +# --------------------------------------------------------------------------- +echo "" +echo "pre-commit: Running A11yAgent accessibility checks on ${#KT_FILES[@]} staged Kotlin file(s)…" +echo "" + +# Capture output; strip the temp-dir prefix so paths look project-relative. +RAW_OUTPUT="$(java -jar "$JAR_PATH" "$TMPDIR_HOOK" --format gradle 2>&1)" || true +OUTPUT="$(printf '%s\n' "$RAW_OUTPUT" | sed "s|$TMPDIR_HOOK/||g")" + +# --------------------------------------------------------------------------- +# 5. Evaluate results. +# --------------------------------------------------------------------------- +HAS_ERRORS=0 + +if printf '%s\n' "$OUTPUT" | grep -q '^.*: error:'; then + HAS_ERRORS=1 +fi + +if [[ -n "$OUTPUT" ]]; then + printf '%s\n' "$OUTPUT" + echo "" +fi + +if [[ $HAS_ERRORS -eq 1 ]]; then + echo "pre-commit: Accessibility errors found – commit blocked." + echo " Fix the errors above, then re-stage your files and commit." + echo " To skip this check: git commit --no-verify" + echo "" + exit 1 +fi + +echo "pre-commit: Accessibility checks passed." +echo "" +exit 0 diff --git a/.github/workflows/a11y-check.yml b/.github/workflows/a11y-check.yml new file mode 100644 index 0000000..567d6b6 --- /dev/null +++ b/.github/workflows/a11y-check.yml @@ -0,0 +1,110 @@ +name: A11y Check + +on: + pull_request: + paths: + - '**/*.kt' + push: + branches: [main] + paths: + - '**/*.kt' + +env: + MIN_SCORE: 70 + +jobs: + a11y-check: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + security-events: write + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build a11y-check-android + run: ./gradlew :A11yAgent:shadowJar + + - name: Run a11y-check (JSON) + id: a11y_json + run: | + java -jar A11yAgent/build/libs/a11y-check-android-*.jar \ + app/src/main/java \ + --format json > a11y-results.json || true + echo "score=$(python3 -c 'import json,sys; d=json.load(open("a11y-results.json")); print(d.get("score",{}).get("score",0))')" >> "$GITHUB_OUTPUT" + echo "grade=$(python3 -c 'import json,sys; d=json.load(open("a11y-results.json")); print(d.get("score",{}).get("grade","?"))')" >> "$GITHUB_OUTPUT" + echo "errors=$(python3 -c 'import json,sys; d=json.load(open("a11y-results.json")); print(d.get("score",{}).get("totalErrors",0))')" >> "$GITHUB_OUTPUT" + echo "warnings=$(python3 -c 'import json,sys; d=json.load(open("a11y-results.json")); print(d.get("score",{}).get("totalWarnings",0))')" >> "$GITHUB_OUTPUT" + echo "failed_criteria=$(python3 -c ' + import json + d=json.load(open("a11y-results.json")) + fc=d.get("score",{}).get("failedCriteria",[]) + if fc: + print("\\n".join(["- " + c for c in fc])) + else: + print("None") + ')" >> "$GITHUB_OUTPUT" + + - name: Run a11y-check (SARIF) + run: | + java -jar A11yAgent/build/libs/a11y-check-android-*.jar \ + app/src/main/java \ + --format sarif > a11y-results.sarif || true + + - name: Upload SARIF to GitHub Code Scanning + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: a11y-results.sarif + category: a11y-check + + - name: Upload results artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: a11y-results + path: | + a11y-results.json + a11y-results.sarif + + - name: Post PR Comment + if: github.event_name == 'pull_request' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: a11y-check + message: | + ## Accessibility Score: ${{ steps.a11y_json.outputs.score }}/100 (${{ steps.a11y_json.outputs.grade }}) + + | Metric | Count | + |--------|-------| + | Errors | ${{ steps.a11y_json.outputs.errors }} | + | Warnings | ${{ steps.a11y_json.outputs.warnings }} | + + **Failed WCAG Criteria:** + ${{ steps.a11y_json.outputs.failed_criteria }} + +
+ Run details + + Generated by `a11y-check-android` — WCAG 2.2 static analysis for Jetpack Compose. + Minimum score threshold: ${{ env.MIN_SCORE }} +
+ + - name: Check minimum score + run: | + score="${{ steps.a11y_json.outputs.score }}" + if [ "$(echo "$score < $MIN_SCORE" | bc -l)" -eq 1 ]; then + echo "::error::Accessibility score $score is below minimum threshold $MIN_SCORE" + exit 1 + fi + echo "Accessibility score $score meets minimum threshold $MIN_SCORE" diff --git a/.gitignore b/.gitignore index 7e6e157..7c7b6bd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,10 @@ .externalNativeBuild .cxx local.properties + +# a11y-check score tracking and baseline files +.a11y-scores.json +.a11y-baseline.json + +# Gradle daemon JVM properties (auto-generated) +gradle/gradle-daemon-jvm.properties diff --git a/A11yAgent/README.md b/A11yAgent/README.md new file mode 100644 index 0000000..e807575 --- /dev/null +++ b/A11yAgent/README.md @@ -0,0 +1,590 @@ +# A11y Checker for Android Compose (a11y-check-android) + +Static analysis for Jetpack Compose accessibility issues, mapped to [WCAG 2.2](https://www.w3.org/TR/WCAG22/) success criteria. Scans `.kt` source files and reports missing labels, incorrect semantics, touch target sizes, color contrast, dynamic type, and more — with 32 rules across 17 WCAG criteria. Includes a **WCAG 2.2 scoring system** that grades your files or entire project from 0-100. + +## Quick start + +### Run via Gradle (recommended) + +The tool is integrated into the project's Gradle build. Every debug build runs the check automatically: + +```bash +# Run accessibility check only +./gradlew a11yCheck + +# Or just build — a11y-check runs automatically after compileDebugKotlin +./gradlew assembleDebug +``` + +Results appear as clickable file:line links in the Build output. An HTML report is generated at `app/build/reports/a11y-check.html`. + +### Run the JAR directly + +```bash +# Build the fat JAR +./gradlew :A11yAgent:shadowJar + +# Run against any Compose source directory +java -jar A11yAgent/build/libs/a11y-check-android-0.1.0.jar app/src/main/java + +# Run against a specific file +java -jar A11yAgent/build/libs/a11y-check-android-0.1.0.jar app/src/main/java/com/example/MyScreen.kt +``` + +### Quick tips + +```bash +java -jar a11y-check-android.jar . --only error # Show only errors +java -jar a11y-check-android.jar . --format html # HTML report to stdout +java -jar a11y-check-android.jar . --diff # Only issues on lines you changed +java -jar a11y-check-android.jar . --format sarif # SARIF for GitHub code scanning +java -jar a11y-check-android.jar . --badge # SVG score badge +java -jar a11y-check-android.jar . --fix # Auto-fix issues where possible +java -jar a11y-check-android.jar . --fix --dry-run # Preview fixes without applying +java -jar a11y-check-android.jar . --per-composable # Score each @Composable separately +java -jar a11y-check-android.jar . --lines 50-120 # Check only specific lines +java -jar a11y-check-android.jar . --watch # Re-run on file changes +java -jar a11y-check-android.jar . --compact # Suppress file path headers +java -jar a11y-check-android.jar . --per-file # Per-file accessibility scores +java -jar a11y-check-android.jar --baseline-save # Save current issues as baseline +java -jar a11y-check-android.jar . --baseline # Only report new issues +java -jar a11y-check-android.jar . --diff-report old.json # Only new issues vs previous run +java -jar a11y-check-android.jar --list-rules # List all 32 rules +java -jar a11y-check-android.jar --generate-docs # Generate rule documentation +``` + +Every run includes a **WCAG 2.2 accessibility score** (0-100 with letter grade). Use `--min-score 80` to fail CI if the score drops below a threshold. + +## Building from source + +Requires JDK 21+. + +```bash +cd android-compose-accessibility-techniques +./gradlew :A11yAgent:shadowJar +``` + +The fat JAR is at `A11yAgent/build/libs/a11y-check-android-0.1.0.jar`. + +## Usage + +```bash +JAR=A11yAgent/build/libs/a11y-check-android-0.1.0.jar + +# Check a directory (all .kt files recursively) +java -jar $JAR app/src/main/java + +# Check a specific file +java -jar $JAR app/src/main/java/com/example/MyView.kt + +# List all rules +java -jar $JAR --list-rules + +# Only show errors (skip warnings/info) +java -jar $JAR app/src/main/java --only error + +# Disable specific rules +java -jar $JAR . --disable icon-missing-label,fixed-font-size + +# Use a config file +java -jar $JAR . --config .a11ycheck.yml +``` + +### Output formats + +```bash +# Default: colored terminal output +java -jar $JAR . + +# Gradle: clickable file:line output for Android Studio Build tab +java -jar $JAR . --format gradle + +# JSON (for CI pipelines and tooling) +java -jar $JAR . --format json + +# HTML report (WCAG summary, code snippets, fix suggestions) +java -jar $JAR . --format html > report.html + +# SARIF (for GitHub code scanning) +java -jar $JAR . --format sarif > results.sarif +``` + +### Diff-only mode + +Only report issues on lines you changed — enforce "no new accessibility issues" without legacy violations: + +```bash +# Issues on uncommitted changes only +java -jar $JAR . --diff + +# Issues on changes vs. a branch +java -jar $JAR . --diff --diff-base main +``` + +## Gradle integration + +The `app/build.gradle` is configured to run a11y-check on every debug build: + +```groovy +// In app/build.gradle +dependencies { + lintChecks project(':lint-checks') // Android Lint rules (see lint-checks/) +} + +// a11y-check runs after compileDebugKotlin +tasks.register('a11yCheck', JavaExec) { + classpath = files(project(':A11yAgent').tasks.named('shadowJar').map { it.archiveFile }) + args = ["${projectDir}/src/main/java", '--format', 'gradle'] + ignoreExitValue = true +} +``` + +The `gradle` format produces output like: + +``` +w: file:///path/to/MyScreen.kt:42:1 IconButton has no accessible label [icon-button-missing-label] (WCAG 4.1.2) +``` + +These appear as clickable links in Android Studio's Build tab. + +## Accessibility scoring + +Every run calculates a **WCAG 2.2 accessibility score** (0-100) with a letter grade: + +``` +5 errors, 3 warnings in 4 files + +WCAG Score: 62.5 / 100 (D) + [============........] 62.5% 8 criteria passed, 2 failed -- 5 errors, 3 warnings + +Failed WCAG criteria: + X 1.1.1 Non-text Content (3 errors) + X 1.3.1 Info and Relationships (2 errors, 1 warning) + +Needs review: + ! 2.4.6 Headings and Labels (1 warning) +``` + +Use `--min-score` to enforce a minimum: + +```bash +java -jar $JAR . --min-score 80 +``` + +### How scoring works + +The overall score combines two components equally: + +1. **Criteria coverage (50%)** — what percentage of checked WCAG criteria pass. Errors cause a criterion to fail; warnings put it in "review" status. +2. **Issue density (50%)** — a deduction based on issue counts, **weighted by impact**: + - Critical: **2.0x** (a critical error deducts 10 points) + - Serious: **1.5x** (a serious error deducts 7.5 points) + - Moderate: **1.0x** (unchanged) + - Minor: **0.5x** (a minor warning deducts only 1 point) + +### Impact levels + +| Impact | Meaning | Example rules | +|--------|---------|---------------| +| **Critical** | Content completely inaccessible | `icon-missing-label`, `textfield-missing-label`, `hidden-with-interactive-children` | +| **Serious** | Major barrier, workaround may exist | `color-contrast`, `small-touch-target`, `heading-semantics-missing` | +| **Moderate** | Inconvenient but usable | `input-purpose`, `label-contains-role-button` | +| **Minor** | Annoyance, best-practice | `label-contains-role-image`, `hardcoded-color` | + +## Trend tracking + +Score tracking is **automatic** — every run records the score, grade, error count, and git commit hash to `.a11y-scores.json`. Once you have history, the terminal output shows a sparkline and delta: + +``` +Score Trend: + Change from last run: +4.2 + History: ___________ + + Date Score Grade Errors Delta + 2025-04-01T10:00:00Z 72.5 C 12 -- + 2025-04-03T14:30:00Z 76.8 C 8 +4.3 + > now 81.0 B- 5 +4.2 +``` + +HTML reports include an SVG trend chart. Use `--no-trend` to disable tracking. + +## Baseline mode + +Suppress known issues and only report new regressions: + +```bash +# Save current issues as baseline +java -jar $JAR . --baseline-save + +# Later: only report new issues not in the baseline +java -jar $JAR . --baseline +``` + +The baseline is saved to `.a11y-baseline.json` in the target directory. Commit this file to track known issues. + +## Auto-fix + +Use `--fix` to automatically apply available fixes to your source files: + +```bash +java -jar $JAR . --fix +``` + +Preview what would change without modifying files: + +```bash +java -jar $JAR . --fix --dry-run +``` + +After applying fixes, a11y-check re-analyzes and shows the updated score. + +## Per-composable scoring + +Use `--per-composable` to score each `@Composable` function independently: + +```bash +java -jar $JAR . --per-composable +``` + +This detects all `@Composable` functions and shows a score for each, sorted worst-first: + +``` +Per-Composable Scores: + [███████░░░░░░░░░░░░░] 37.5 (F) BadFormScreen FormScreen.kt:45-120 (11 errors) + [████████████████████] 100.0 (A+) GoodFormScreen FormScreen.kt:5-43 + [████████████████████] 100.0 (A+) HomeScreen HomeScreen.kt:1-80 +``` + +## Per-file scoring + +Show accessibility scores for each file, sorted worst-first: + +```bash +java -jar $JAR . --per-file +``` + +Output: + +``` +Per-File Scores: + [████████░░░░░░░░░░░░] 42.5 (F) TextAlternatives.kt (12 errors, 3 warnings) + [██████████████░░░░░░] 72.5 (C-) InteractiveControlLabels.kt (3 errors, 5 warnings) + [████████████████████] 100.0 (A+) HomeScreen.kt +``` + +## Watch mode + +Re-run analysis automatically when files change: + +```bash +java -jar $JAR . --watch +``` + +Press `Ctrl+C` to stop. Each time a `.kt` file is modified, a11y-check re-runs and prints updated results. + +## Lines range filter + +Only check specific line ranges: + +```bash +java -jar $JAR MyScreen.kt --lines 50-120 +java -jar $JAR . --lines 10-30,80-100 +``` + +## Report diff + +Compare current results against a previous JSON report to see only **new** issues: + +```bash +# Save a report +java -jar $JAR . --format json > baseline-report.json + +# Later, compare against it +java -jar $JAR . --diff-report baseline-report.json +``` + +## HTML report + +Generate a self-contained HTML report: + +```bash +java -jar $JAR app/src/main/java --format html > accessibility-report.html +``` + +Or via Gradle: + +```bash +./gradlew a11yCheck +# Report at: app/build/reports/a11y-check.html +``` + +The report includes: + +- **Summary banner** with error, warning, and info counts +- **WCAG 2.2 score** with grade badge and conformance breakdown +- **Score trend chart** with SVG visualization and history table +- **WCAG 2.2 conformance table** showing pass/fail for each criterion, linked to the WCAG spec +- **Issues by file** with expandable sections, code snippets, and fix suggestions +- **"Current code" / "Fixed code"** side-by-side showing the problem and how to fix it +- **Issues by rule** summary table + +## SARIF output (GitHub Code Scanning) + +Generate [SARIF](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) output for GitHub code scanning: + +```bash +java -jar $JAR . --format sarif > results.sarif +``` + +Upload in your GitHub Actions workflow: + +```yaml +- name: Run a11y-check + run: java -jar A11yAgent/build/libs/a11y-check-android-0.1.0.jar app/src/main/java --format sarif > results.sarif +- name: Upload SARIF + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif +``` + +## Score badge + +Generate an SVG badge showing your project's accessibility score: + +```bash +java -jar $JAR . --badge > a11y-badge.svg +``` + +Add to your README: + +```markdown +![a11y score](a11y-badge.svg) +``` + +## CI integration + +### GitHub Actions + +A ready-to-use workflow is included at [`.github/workflows/a11y-check.yml`](../.github/workflows/a11y-check.yml). It: + +1. Builds the shadow JAR +2. Runs the accessibility check +3. Posts the score as a PR comment +4. Uploads SARIF to GitHub Code Scanning +5. Fails the job if the score is below the configurable threshold + +### Diff-only in CI + +Only fail if the PR introduces new accessibility errors: + +```yaml +- name: Accessibility check (changed files only) + run: java -jar $JAR app/src/main/java --diff --diff-base origin/main --only error +``` + +### Baseline mode in CI + +```bash +# Once: save the baseline (commit this file) +java -jar $JAR app/src/main/java --baseline-save + +# In CI: only new issues fail the build +java -jar $JAR app/src/main/java --baseline +``` + +## MCP (Cursor / AI assistants) + +An [MCP server](mcp-server/README.md) is included so AI assistants like Cursor can run a11y-check through natural language. + +### Quick setup + +1. Build the shadow JAR: `./gradlew :A11yAgent:shadowJar` +2. Install the MCP server: + ```bash + cd A11yAgent/mcp-server && npm install && npm run build + ``` +3. Add to Cursor Settings > Features > MCP > Edit config: + ```json + { + "mcpServers": { + "a11y-check-android": { + "command": "node", + "args": ["/path/to/A11yAgent/mcp-server/dist/index.js"] + } + } + } + ``` +4. Restart Cursor. + +### What you can ask + +- "Check this project for accessibility issues" +- "Run a11y-check on TextFieldControls.kt" +- "Which issues are the most critical to fix?" +- "Fix the textfield-missing-label issues" +- "What's the accessibility score?" +- "Generate an HTML accessibility report" + +## Configuration file + +Create a `.a11ycheck.yml` in your project root: + +```yaml +# .a11ycheck.yml + +# Override rule severities +severity_overrides: + icon-missing-label: warning + hardcoded-color: info + +# Disable rules entirely +disabled_rules: + - max-lines-one + - fixed-font-size + +# Allowlist mode: if non-empty, only these rules run +enabled_only: [] + +# Rule-specific options +options: + min_touch_target: 48 # override small-touch-target threshold (default 48dp) + contrast_ratio: 4.5 # WCAG AA contrast minimum + +# Skip paths matching these patterns +exclude_paths: + - "*/generated/*" + - "*/build/*" + - "*Test*" +``` + +CLI flags (`--disable`, `--only`) are applied on top of the config file. + +## Inline suppression + +Suppress specific diagnostics in your source code: + +```kotlin +// Suppress a specific rule on this line +Icon(imageVector = icon, contentDescription = null) // a11y-check:disable icon-missing-label + +// Suppress multiple rules +Icon(imageVector = icon, contentDescription = null) // a11y-check:disable icon-missing-label, empty-content-description + +// Suppress all rules on this line +Icon(imageVector = icon, contentDescription = null) // a11y-check:disable +``` + +## Options + +| Option | Description | +|--------|-------------| +| `paths` | File or directory paths to analyze (default: `.`) | +| `--format` | Output format: `terminal` (default), `json`, `gradle`, `html`, `sarif` | +| `--only` | Minimum severity: `info`, `warning`, or `error` | +| `--disable` | Comma-separated rule IDs to disable | +| `--config` | Path to `.a11ycheck.yml` config file (auto-detected if not specified) | +| `--diff` | Only report diagnostics on lines changed in the git diff | +| `--diff-base` | Git ref to diff against (default: `HEAD`). Use with `--diff` | +| `--list-rules` | Print all rules and exit | +| `--min-score` | Minimum passing score (0-100). Exits with code 1 if below threshold | +| `--trend` / `--no-trend` | Score trend tracking (enabled by default) | +| `--baseline-save` | Save current issues as baseline (`.a11y-baseline.json`) | +| `--baseline` | Filter out baseline issues — only new regressions shown | +| `--badge` | Generate an SVG score badge to stdout | +| `--generate-docs` | Generate Markdown rule documentation to stdout | +| `--fix` | Automatically apply available fixes to source files | +| `--dry-run` | Show what `--fix` would change without modifying files | +| `--per-composable` | Show per-`@Composable` function scores | +| `--per-file` | Show per-file accessibility scores | +| `--watch` | Watch for file changes and re-run analysis automatically | +| `--lines` | Only check lines in a range, e.g. `50-120` or `10-30,80-100` | +| `--diff-report` | Compare against a previous JSON report — only new issues shown | +| `--compact` | Suppress file path headers in output | + +## Rules + +a11y-check-android includes 32 rules across these categories: + +| Category | Rules | WCAG | Impact | +|----------|-------|------|--------| +| **Icons & images** | `icon-missing-label`, `label-contains-role-image`, `empty-content-description` | 1.1.1 | Critical, Minor, Serious | +| **Headings** | `heading-semantics-missing`, `fake-heading-in-label` | 2.4.6, 1.3.1 | Serious, Serious | +| **Color & contrast** | `hardcoded-color`, `color-contrast` | 1.4.3 | Minor, Serious | +| **Dynamic type** | `fixed-font-size`, `max-lines-one` | 1.4.4 | Serious, Serious | +| **Focus** | `dialog-focus-management` | 2.4.3 | Serious | +| **Pane titles** | `missing-pane-title`, `tab-missing-label` | 2.4.2 | Serious, Serious | +| **Links** | `generic-link-text`, `button-used-as-link` | 2.4.4 | Serious, Serious | +| **Touch targets** | `small-touch-target` | 2.5.8 | Serious | +| **Label in Name** | `label-in-name` | 2.5.3 | Serious | +| **Buttons** | `label-contains-role-button`, `icon-button-missing-label`, `visually-disabled-not-semantically` | 4.1.2 | Moderate, Critical, Serious | +| **Clickable** | `clickable-missing-role` | 4.1.2 | Critical | +| **Toggles** | `toggle-missing-label` | 4.1.2 | Critical | +| **Form controls** | `textfield-missing-label`, `slider-missing-label`, `dropdown-missing-label` | 4.1.2 | Critical | +| **Grouping** | `accessibility-grouping`, `hidden-with-interactive-children`, `box-child-order`, `radio-group-missing` | 1.3.1, 4.1.2, 1.3.2 | Minor, Critical, Minor, Moderate | +| **Animation** | `reduce-motion` | 2.3.1 | Serious | +| **Gestures** | `gesture-missing-alternative` | 2.1.1 | Serious | +| **Input** | `input-purpose` | 1.3.5 | Moderate | +| **Timing** | `timing-adjustable` | 2.2.1 | Moderate | + +Run `java -jar $JAR --list-rules` for full descriptions and severities. + +## IDE plugin (IntelliJ / Android Studio) + +An IntelliJ Platform plugin is included in `ide-plugin/` that provides real-time inline accessibility warnings in the editor. It uses an `ExternalAnnotator` to run the a11y-check analysis engine on each Kotlin file and display squiggly underlines for accessibility issues. + +### Building the plugin + +The IDE plugin is a standalone Gradle project (separate from the main Android build) due to IntelliJ Platform SDK requirements: + +```bash +# First, build the A11yAgent JAR +./gradlew :A11yAgent:shadowJar + +# Then build the plugin +cd ide-plugin +./gradlew buildPlugin +``` + +The plugin ZIP is produced in `ide-plugin/build/distributions/`. Install via **Settings > Plugins > Install Plugin from Disk**. + +### What it does + +- Runs all 32 rules on the current Kotlin file as you edit +- Shows error/warning/info squiggles inline in the editor +- Tooltip shows the rule ID, WCAG criteria, and fix suggestion +- No Gradle or JAR needed — the analysis engine runs directly as a library + +## Also included: Android Lint rules + +The project also includes a custom Android Lint module (`lint-checks/`) that integrates 5 accessibility checks into Android's built-in Lint system. These appear in `./gradlew lint` HTML reports alongside standard Android Lint results. See [`lint-checks/README.md`](../lint-checks/README.md). + +## Testing + +```bash +./gradlew :A11yAgent:test +``` + +Tests cover the rule engine, scanner, score calculator, config loader, baseline, inline suppression, and individual rules. + +## Contributor Guide + +1. Before contributing to this CVS Health sponsored project, you will need to sign the associated [Contributor License Agreement](https://forms.office.com/r/9e9VmE7qLW). +2. See the [contributing](../CONTRIBUTING.md) page. + +## License +a11y-check-android is licensed under the Apache License, Version 2.0. See [LICENSE](../LICENSE) file for more information. + +Copyright 2026 CVS Health and/or one of its affiliates + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. diff --git a/A11yAgent/build.gradle b/A11yAgent/build.gradle new file mode 100644 index 0000000..2ee1b6a --- /dev/null +++ b/A11yAgent/build.gradle @@ -0,0 +1,62 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' + id 'application' + id 'com.gradleup.shadow' version '9.0.0-beta12' +} + +group = 'com.cvshealth.a11y' +version = '0.1.0' + +application { + mainClass = 'com.cvshealth.a11y.agent.cli.A11yCheckKt' +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + implementation 'com.github.ajalt.clikt:clikt:4.4.0' + implementation 'com.charleskorn.kaml:kaml:0.72.0' + implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1' + + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4' + testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +shadowJar { + archiveBaseName = 'a11y-check-android' + archiveClassifier = '' + archiveVersion = project.version +} + +test { + useJUnitPlatform() +} + +tasks.register('npmInstallMcp', Exec) { + description = 'Install MCP server npm dependencies' + group = 'build' + workingDir = file('mcp-server') + commandLine 'npm', 'install' + onlyIf { !file('mcp-server/node_modules').exists() } +} + +tasks.register('buildMcpServer', Exec) { + description = 'Build the MCP server TypeScript code' + group = 'build' + workingDir = file('mcp-server') + commandLine 'npm', 'run', 'build' + dependsOn 'npmInstallMcp' +} + +tasks.register('a11yCheck', JavaExec) { + description = 'Run accessibility check on the app source code' + group = 'verification' + classpath = files(shadowJar.archiveFile) + dependsOn shadowJar + args = [project.findProperty('a11y.paths') ?: "${rootProject.projectDir}/app/src/main/java", + '--format', project.findProperty('a11y.format') ?: 'gradle'] +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/cli/A11yCheck.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/cli/A11yCheck.kt new file mode 100644 index 0000000..5eea097 --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/cli/A11yCheck.kt @@ -0,0 +1,375 @@ +package com.cvshealth.a11y.agent.cli + +import com.cvshealth.a11y.agent.core.* +import com.cvshealth.a11y.agent.formatters.* +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.arguments.multiple +import com.github.ajalt.clikt.parameters.options.* +import com.github.ajalt.clikt.parameters.types.double +import com.github.ajalt.clikt.parameters.types.enum +import kotlinx.serialization.json.* +import java.io.File +import kotlin.system.exitProcess + +enum class OutputFormat { TERMINAL, JSON, SARIF, HTML, GRADLE } + +class A11yCheck : CliktCommand( + name = "a11y-check-android" +) { + val paths by argument(help = "Files or directories to analyze") + .multiple(default = listOf(".")) + + val format by option("--format", help = "Output format") + .enum(ignoreCase = true) + .default(OutputFormat.TERMINAL) + + val disable by option("--disable", help = "Comma-separated rule IDs to disable") + + val only by option("--only", help = "Minimum severity to show (info, warning, error)") + .enum(ignoreCase = true) + + val listRules by option("--list-rules", help = "List all available rules and exit") + .flag() + + val config by option("--config", help = "Path to .a11ycheck.yml config file") + + val diff by option("--diff", help = "Only check lines changed in git diff") + .flag() + + val diffBase by option("--diff-base", help = "Git ref to diff against") + + val minScore by option("--min-score", help = "Exit 1 if score below threshold") + .double() + + val trend by option("--trend", help = "Track score history") + .flag("--no-trend", default = true) + + val baselineSave by option("--baseline-save", help = "Save current issues as baseline") + .flag() + + val baseline by option("--baseline", help = "Filter known baseline issues") + .flag() + + val badge by option("--badge", help = "Output SVG score badge") + .flag() + + val generateDocs by option("--generate-docs", help = "Generate rule documentation") + .flag() + + val fix by option("--fix", help = "Automatically apply available fixes") + .flag() + + val dryRun by option("--dry-run", help = "Preview fixes without applying") + .flag() + + val perComposable by option("--per-composable", help = "Score each @Composable function separately") + .flag() + + val watch by option("--watch", help = "Watch for file changes and re-run") + .flag() + + val lines by option("--lines", help = "Only check lines in a range, e.g. 50-120 or 10-30,80-100") + + val diffReport by option("--diff-report", help = "Compare against a previous JSON report") + + val perFile by option("--per-file", help = "Show per-file accessibility scores") + .flag() + + val compact by option("--compact", help = "Suppress file path in output") + .flag() + + override fun run() { + val registry = RuleRegistry() + + // Load config + val loadedConfig = if (config != null) { + ConfigLoader.loadAt(config!!) + } else { + val firstPath = paths.firstOrNull() ?: "." + ConfigLoader.load(firstPath) + } + registry.applyConfig(loadedConfig) + + // Apply --disable + disable?.split(",")?.map { it.trim() }?.forEach { + registry.disabledRuleIDs.add(it) + } + + // --list-rules + if (listRules) { + printRuleList(registry) + return + } + + // --generate-docs + if (generateDocs) { + printRuleDocs(registry) + return + } + + // Collect diagnostics + registry.clearComposables() + var diagnostics = mutableListOf() + val allFilePaths = mutableListOf() + + for (path in paths) { + val file = File(path) + if (file.isFile) { + diagnostics.addAll(registry.analyzeFile(file.absolutePath)) + allFilePaths.add(file.absolutePath) + } else if (file.isDirectory) { + diagnostics.addAll(registry.analyzeDirectory(file.absolutePath)) + allFilePaths.addAll(registry.allFilePaths(file.absolutePath)) + } + } + + // Apply filters + if (diff) { + val workDir = File(paths.first()).let { if (it.isFile) it.parentFile else it }.absolutePath + val changedLines = DiffFilter.changedLines(workDir, diffBase) + diagnostics = DiffFilter.filter(diagnostics, changedLines).toMutableList() + } + + // Lines range filter + if (lines != null) { + val ranges = parseLineRanges(lines!!) + if (ranges.isNotEmpty()) { + diagnostics = diagnostics.filter { diag -> + ranges.any { diag.line in it } + }.toMutableList() + } + } + + // Severity filter + if (only != null) { + diagnostics = diagnostics.filter { it.severity >= only!! }.toMutableList() + } + + // Auto-fix + if (fix || dryRun) { + val fixer = AutoFixer() + val result = fixer.applyFixes(diagnostics, dryRun) + echo(fixer.formatResult(result, dryRun)) + + if (fix && !dryRun && result.totalFixed > 0) { + // Re-analyze after applying fixes + registry.clearComposables() + diagnostics = mutableListOf() + for (path in paths) { + val file = File(path) + if (file.isFile) { + diagnostics.addAll(registry.analyzeFile(file.absolutePath)) + } else if (file.isDirectory) { + diagnostics.addAll(registry.analyzeDirectory(file.absolutePath)) + } + } + // Re-apply severity filter + if (only != null) { + diagnostics = diagnostics.filter { it.severity >= only!! }.toMutableList() + } + } + } + + // Calculate score + val score = ScoreCalculator().calculate(diagnostics, registry.enabledRules, allFilePaths) + + // --baseline-save + if (baselineSave) { + val baselineObj = Baseline.from(diagnostics, score.score) + val dir = File(paths.first()).let { if (it.isFile) it.parentFile else it }.absolutePath + baselineObj.save(dir) + echo("Baseline saved with ${diagnostics.size} issues (score: ${"%.1f".format(score.score)})") + return + } + + // --baseline + if (baseline) { + val dir = File(paths.first()).let { if (it.isFile) it.parentFile else it }.absolutePath + val baselineObj = Baseline.loadFrom(dir) + if (baselineObj != null) { + diagnostics = baselineObj.filterNew(diagnostics).toMutableList() + } + } + + // --diff-report + if (diffReport != null) { + val reportFile = File(diffReport!!) + if (reportFile.exists()) { + val json = Json.parseToJsonElement(reportFile.readText()).jsonObject + val oldDiags = json["diagnostics"]?.jsonArray + if (oldDiags != null) { + val oldFingerprints = oldDiags.mapNotNull { d -> + val obj = d.jsonObject + val ruleID = obj["ruleID"]?.jsonPrimitive?.contentOrNull + val filePath = obj["filePath"]?.jsonPrimitive?.contentOrNull + val message = obj["message"]?.jsonPrimitive?.contentOrNull + if (ruleID != null && filePath != null && message != null) + "$ruleID|$filePath|$message" + else null + }.toSet() + + val before = diagnostics.size + diagnostics = diagnostics.filter { diag -> + val fp = "${diag.ruleID}|${diag.filePath}|${diag.message}" + fp !in oldFingerprints + }.toMutableList() + System.err.println("Diff report: ${before - diagnostics.size} existing issues filtered, ${diagnostics.size} new issues") + } + } + } + + // Trend tracking + val tracker = if (trend) { + val dir = File(paths.first()).let { if (it.isFile) it.parentFile else it }.absolutePath + TrendTracker(dir) + } else null + + val trendHistory = tracker?.load() + + // --badge + if (badge) { + echo(generateBadgeSvg(score)) + return + } + + // Format output + val output = when (format) { + OutputFormat.TERMINAL -> { + val main = TerminalFormatter.format(diagnostics, score, compact) + val trendOutput = tracker?.formatTrend(score) ?: "" + main + trendOutput + } + OutputFormat.JSON -> JsonFormatter.format(diagnostics, score, trendHistory?.entries) + OutputFormat.SARIF -> SarifFormatter.format(diagnostics, registry.enabledRules) + OutputFormat.HTML -> HtmlFormatter.format(diagnostics, score, trendHistory?.entries ?: emptyList(), registry.enabledRules) + OutputFormat.GRADLE -> GradleFormatter.format(diagnostics, score, compact) + } + + echo(output) + + // Per-composable scoring + if (perComposable) { + val scorer = ComposableScorer() + val scores = scorer.scoreComposables(registry.composables, diagnostics, registry.enabledRules) + echo(scorer.formatScores(scores)) + } + + // Per-file scoring + if (perFile && score.fileScores.isNotEmpty()) { + echo(formatFileScores(score.fileScores)) + } + + // Record trend + tracker?.record(score) + + // Exit code + val hasErrors = diagnostics.any { it.severity == A11ySeverity.ERROR } + val belowMinScore = minScore != null && score.score < minScore!! + + if (hasErrors || belowMinScore) { + if (!watch) exitProcess(1) + } + + // Watch mode + if (watch) { + System.err.println("Watching for changes... (press Ctrl+C to stop)") + var lastModified = latestModificationTime(allFilePaths) + while (true) { + Thread.sleep(1000) + val current = latestModificationTime(allFilePaths) + if (current > lastModified) { + lastModified = current + System.err.println("\n--- File change detected, re-running... ---\n") + val javaHome = System.getProperty("java.home") + val javaBin = "$javaHome/bin/java" + val classpath = System.getProperty("java.class.path") + val mainClass = "com.cvshealth.a11y.agent.cli.A11yCheckKt" + val args = currentContext.originalArgv.filter { it != "--watch" } + val pb = ProcessBuilder(listOf(javaBin, "-cp", classpath, mainClass) + args) + pb.inheritIO() + pb.start().waitFor() + } + } + } + } + + private fun parseLineRanges(spec: String): List { + return spec.split(",").mapNotNull { part -> + val trimmed = part.trim() + val components = trimmed.split("-", limit = 2) + when (components.size) { + 2 -> { + val start = components[0].toIntOrNull() + val end = components[1].toIntOrNull() + if (start != null && end != null && start <= end) start..end else null + } + 1 -> components[0].toIntOrNull()?.let { it..it } + else -> null + } + } + } + + private fun latestModificationTime(filePaths: List): Long { + return filePaths.maxOfOrNull { File(it).lastModified() } ?: 0L + } + + private fun printRuleList(registry: RuleRegistry) { + echo("Available rules (${registry.rules.size}):\n") + val maxIdLen = registry.rules.maxOf { it.id.length } + for (rule in registry.rules.sortedBy { it.id }) { + val enabled = if (rule.id in registry.disabledRuleIDs) " [disabled]" else "" + val wcag = rule.wcagCriteria.joinToString(", ") + echo(" ${rule.id.padEnd(maxIdLen + 2)} ${rule.severity.label.padEnd(7)} [${rule.impact.label.padEnd(8)}] WCAG $wcag$enabled") + echo(" ${rule.description}") + } + } + + private fun printRuleDocs(registry: RuleRegistry) { + echo(RuleDocsGenerator().generate(registry.rules)) + } + + private fun formatFileScores(fileScores: List): String { + val sb = StringBuilder() + sb.appendLine("\nPer-File Scores:") + val sorted = fileScores.sortedBy { it.score } + for (fs in sorted) { + val filled = (fs.score / 100.0 * 20).toInt().coerceIn(0, 20) + val bar = "\u2588".repeat(filled) + "\u2591".repeat(20 - filled) + val grade = A11yScore.letterGrade(fs.score) + val shortPath = fs.filePath.substringAfterLast("/") + val issues = mutableListOf() + if (fs.errorCount > 0) issues.add("${fs.errorCount} error${if (fs.errorCount != 1) "s" else ""}") + if (fs.warningCount > 0) issues.add("${fs.warningCount} warning${if (fs.warningCount != 1) "s" else ""}") + val issueStr = if (issues.isNotEmpty()) " (${issues.joinToString(", ")})" else "" + sb.appendLine(" [$bar] ${"%.1f".format(fs.score)} ($grade) $shortPath$issueStr") + } + return sb.toString() + } + + private fun generateBadgeSvg(score: A11yScore): String { + val color = when { + score.score >= 90 -> "#4c1" + score.score >= 80 -> "#97CA00" + score.score >= 70 -> "#dfb317" + score.score >= 60 -> "#fe7d37" + else -> "#e05d44" + } + val scoreText = "${"%.0f".format(score.score)}%" + val label = "a11y score" + return """ + $label: $scoreText + + + + + + $label + + $scoreText + +""" + } +} + +fun main(args: Array) = A11yCheck().main(args) diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/A11yDiagnostic.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/A11yDiagnostic.kt new file mode 100644 index 0000000..b8e62e0 --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/A11yDiagnostic.kt @@ -0,0 +1,38 @@ +package com.cvshealth.a11y.agent.core + +import kotlinx.serialization.Serializable + +@Serializable +enum class A11ySeverity : Comparable { + INFO, WARNING, ERROR; + + val label: String get() = name.lowercase() +} + +@Serializable +enum class A11yImpact : Comparable { + MINOR, MODERATE, SERIOUS, CRITICAL; + + val label: String get() = name.lowercase() +} + +data class A11yFix( + val description: String, + val replacementText: String, + val startOffset: Int, + val endOffset: Int +) + +data class A11yDiagnostic( + val ruleID: String, + val severity: A11ySeverity, + val impact: A11yImpact = A11yImpact.MODERATE, + val message: String, + val filePath: String, + val line: Int, + val column: Int = 1, + val wcagCriteria: List = emptyList(), + val fix: A11yFix? = null, + val suggestion: String? = null, + var sourceSnippet: String? = null +) diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/A11yRule.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/A11yRule.kt new file mode 100644 index 0000000..a8053c5 --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/A11yRule.kt @@ -0,0 +1,68 @@ +package com.cvshealth.a11y.agent.core + +import com.cvshealth.a11y.agent.scanner.ParsedKotlinFile + +data class RuleContext( + val filePath: String, + val sourceText: String, + val sourceLines: List, + val disabledRules: Set = emptySet(), + val severityOverrides: Map = emptyMap(), + val configOptions: ConfigOptions = ConfigOptions() +) + +data class ConfigOptions( + val minTouchTarget: Int = 48, + val contrastRatio: Double = 4.5 +) + +/** + * Compute a character offset from 1-based line and column numbers. + */ +fun lineColumnToOffset(sourceText: String, line: Int, column: Int = 1): Int { + var offset = 0 + for (i in 1 until line) { + val nl = sourceText.indexOf('\n', offset) + if (nl < 0) break + offset = nl + 1 + } + return offset + column - 1 +} + +interface A11yRule { + val id: String + val name: String + val severity: A11ySeverity + val impact: A11yImpact + val wcagCriteria: List + val description: String + + fun check(file: ParsedKotlinFile, context: RuleContext): List + + fun makeDiagnostic( + message: String, + line: Int, + column: Int = 1, + context: RuleContext, + severityOverride: A11ySeverity? = null, + wcagCriteriaOverride: List? = null, + fix: A11yFix? = null, + suggestion: String? = null + ): A11yDiagnostic { + val effectiveSeverity = severityOverride + ?: context.severityOverrides[id] + ?: severity + return A11yDiagnostic( + ruleID = id, + severity = effectiveSeverity, + impact = impact, + message = message, + filePath = context.filePath, + line = line, + column = column, + wcagCriteria = wcagCriteriaOverride ?: wcagCriteria, + fix = fix, + suggestion = suggestion + ) + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/A11yScore.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/A11yScore.kt new file mode 100644 index 0000000..c7675af --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/A11yScore.kt @@ -0,0 +1,89 @@ +package com.cvshealth.a11y.agent.core + +import kotlinx.serialization.Serializable + +@Serializable +enum class WCAGLevel(val label: String) { + A("A"), AA("AA"), AAA("AAA") +} + +@Serializable +enum class WCAGPrinciple(val label: String) { + PERCEIVABLE("Perceivable"), + OPERABLE("Operable"), + UNDERSTANDABLE("Understandable"), + ROBUST("Robust"); + + companion object { + fun from(criterion: String): WCAGPrinciple { + return when (criterion.firstOrNull()) { + '1' -> PERCEIVABLE + '2' -> OPERABLE + '3' -> UNDERSTANDABLE + '4' -> ROBUST + else -> PERCEIVABLE + } + } + } +} + +@Serializable +enum class CriterionStatus(val label: String) { + PASS("pass"), + FAIL("fail"), + REVIEW("review"), + NOT_CHECKED("not_checked") +} + +data class CriterionScore( + val criterion: String, + val name: String, + val principle: WCAGPrinciple, + val level: WCAGLevel, + val status: CriterionStatus, + val errorCount: Int, + val warningCount: Int, + val infoCount: Int = 0, + val ruleIDs: List +) + +data class FileScore( + val filePath: String, + val score: Double, + val errorCount: Int, + val warningCount: Int, + val infoCount: Int +) + +data class A11yScore( + val score: Double, + val grade: String, + val criteriaScores: List, + val principleScores: Map, + val fileScores: List, + val totalErrors: Int, + val totalWarnings: Int, + val totalInfo: Int, + val filesAnalyzed: Int, + val criteriaPassed: Int, + val criteriaFailed: Int, + val criteriaNotChecked: Int +) { + companion object { + fun letterGrade(score: Double): String = when { + score >= 97.0 -> "A+" + score >= 93.0 -> "A" + score >= 90.0 -> "A-" + score >= 87.0 -> "B+" + score >= 83.0 -> "B" + score >= 80.0 -> "B-" + score >= 77.0 -> "C+" + score >= 73.0 -> "C" + score >= 70.0 -> "C-" + score >= 67.0 -> "D+" + score >= 63.0 -> "D" + score >= 60.0 -> "D-" + else -> "F" + } + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/AutoFixer.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/AutoFixer.kt new file mode 100644 index 0000000..d93d81e --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/AutoFixer.kt @@ -0,0 +1,92 @@ +package com.cvshealth.a11y.agent.core + +import java.io.File + +class AutoFixer { + + data class FileResult( + val filePath: String, + val fixesApplied: Int, + val fixDescriptions: List + ) + + data class Result( + val fileResults: List, + val totalFixed: Int, + val totalFiles: Int + ) + + fun applyFixes(diagnostics: List, dryRun: Boolean = false): Result { + val withFixes = diagnostics.filter { it.fix != null } + val byFile = withFixes.groupBy { it.filePath } + val fileResults = mutableListOf() + + for ((filePath, fileDiags) in byFile) { + // Sort fixes by startOffset descending so applying end-to-start preserves earlier offsets + val sortedFixes = fileDiags.mapNotNull { it.fix }.sortedByDescending { it.startOffset } + + // Deduplicate overlapping ranges + val appliedRanges = mutableListOf() + val fixesToApply = mutableListOf() + + for (fix in sortedFixes) { + val range = fix.startOffset until fix.endOffset + val overlaps = appliedRanges.any { existing -> + range.first < existing.last && range.last > existing.first + } + if (!overlaps) { + appliedRanges.add(range) + fixesToApply.add(fix) + } + } + + if (!dryRun && fixesToApply.isNotEmpty()) { + val file = File(filePath) + var source = file.readText() + for (fix in fixesToApply) { + if (fix.startOffset >= 0 && fix.endOffset <= source.length && fix.startOffset <= fix.endOffset) { + source = source.replaceRange(fix.startOffset, fix.endOffset, fix.replacementText) + } + } + file.writeText(source) + } + + fileResults.add( + FileResult( + filePath = filePath, + fixesApplied = fixesToApply.size, + fixDescriptions = fixesToApply.map { it.description } + ) + ) + } + + return Result( + fileResults = fileResults, + totalFixed = fileResults.sumOf { it.fixesApplied }, + totalFiles = fileResults.size + ) + } + + fun formatResult(result: Result, dryRun: Boolean): String { + if (result.totalFixed == 0) { + return if (dryRun) "Dry run: no auto-fixable issues found." + else "No auto-fixable issues found." + } + + val verb = if (dryRun) "would fix" else "fixed" + val suffix = if (dryRun) " (dry run — no files modified)" else "" + + return buildString { + appendLine("Auto-fix: $verb ${result.totalFixed} issue(s) in ${result.totalFiles} file(s)$suffix") + appendLine() + for (fr in result.fileResults) { + val shortPath = fr.filePath.substringAfterLast("/") + appendLine(" $shortPath — ${fr.fixesApplied} fix(es)") + for (desc in fr.fixDescriptions) { + val mark = if (dryRun) "~" else "\u2713" + appendLine(" $mark $desc") + } + } + } + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/Baseline.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/Baseline.kt new file mode 100644 index 0000000..292e5e6 --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/Baseline.kt @@ -0,0 +1,85 @@ +package com.cvshealth.a11y.agent.core + +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File +import java.time.Instant + +@Serializable +data class Baseline( + val entries: List, + val createdAt: String = Instant.now().toString(), + val score: Double? = null +) { + @Serializable + data class Entry( + val ruleID: String, + val filePath: String, + val line: Int, + val message: String + ) { + val fingerprint: String get() = "$ruleID|$filePath|$message" + } + + fun filterNew(diagnostics: List): List { + val baselineFingerprints = entries.map { it.fingerprint }.toSet() + return diagnostics.filter { diag -> + val fp = "${diag.ruleID}|${diag.filePath}|${diag.message}" + fp !in baselineFingerprints + } + } + + fun save(directory: String) { + val file = File(directory, DEFAULT_FILE_NAME) + file.writeText(json.encodeToString(this)) + } + + companion object { + const val DEFAULT_FILE_NAME = ".a11y-baseline.json" + + private val json = Json { + prettyPrint = true + encodeDefaults = true + } + + fun loadFrom(directory: String): Baseline? { + val path = findFile(directory) ?: return null + return loadAt(path) + } + + fun loadAt(path: String): Baseline? { + val file = File(path) + if (!file.exists()) return null + return try { + json.decodeFromString(file.readText()) + } catch (_: Exception) { + null + } + } + + fun from(diagnostics: List, score: Double? = null): Baseline { + val entries = diagnostics.map { diag -> + Entry( + ruleID = diag.ruleID, + filePath = diag.filePath, + line = diag.line, + message = diag.message + ) + } + return Baseline(entries = entries, score = score) + } + + private fun findFile(startingAt: String): String? { + var dir = File(startingAt).canonicalFile + while (true) { + val candidate = File(dir, DEFAULT_FILE_NAME) + if (candidate.exists()) return candidate.absolutePath + val parent = dir.parentFile ?: break + if (parent == dir) break + dir = parent + } + return null + } + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/ComposableScorer.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/ComposableScorer.kt new file mode 100644 index 0000000..695909f --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/ComposableScorer.kt @@ -0,0 +1,59 @@ +package com.cvshealth.a11y.agent.core + +data class ComposableScore( + val name: String, + val filePath: String, + val startLine: Int, + val endLine: Int, + val score: Double, + val grade: String, + val errorCount: Int, + val warningCount: Int +) + +class ComposableScorer { + + fun scoreComposables( + composables: List, + diagnostics: List, + rules: List + ): List { + val calculator = ScoreCalculator() + return composables.map { (filePath, comp) -> + val compDiags = diagnostics.filter { d -> + d.filePath == filePath && d.line in comp.startLine..comp.endLine + } + val score = calculator.calculate(compDiags, rules, listOf(filePath)) + ComposableScore( + name = comp.name, + filePath = filePath, + startLine = comp.startLine, + endLine = comp.endLine, + score = score.score, + grade = score.grade, + errorCount = compDiags.count { it.severity == A11ySeverity.ERROR }, + warningCount = compDiags.count { it.severity == A11ySeverity.WARNING } + ) + }.sortedBy { it.score } + } + + fun formatScores(scores: List): String { + if (scores.isEmpty()) return "No @Composable functions found." + + return buildString { + appendLine() + appendLine("Per-Composable Scores:") + for (cs in scores) { + val barLength = 20 + val filled = (cs.score / 100.0 * barLength).toInt().coerceIn(0, barLength) + val bar = "\u2588".repeat(filled) + "\u2591".repeat(barLength - filled) + val shortPath = cs.filePath.substringAfterLast("/") + val issues = mutableListOf() + if (cs.errorCount > 0) issues.add("${cs.errorCount} error${if (cs.errorCount != 1) "s" else ""}") + if (cs.warningCount > 0) issues.add("${cs.warningCount} warning${if (cs.warningCount != 1) "s" else ""}") + val issueStr = if (issues.isNotEmpty()) " (${issues.joinToString(", ")})" else "" + appendLine(" [$bar] ${"%.1f".format(cs.score)} (${cs.grade}) ${cs.name} $shortPath:${cs.startLine}-${cs.endLine}$issueStr") + } + } + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/Config.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/Config.kt new file mode 100644 index 0000000..5f1d22e --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/Config.kt @@ -0,0 +1,122 @@ +package com.cvshealth.a11y.agent.core + +import com.charleskorn.kaml.Yaml +import kotlinx.serialization.Serializable +import java.io.File + +@Serializable +data class A11yConfig( + val severity_overrides: Map = emptyMap(), + val disabled_rules: List = emptyList(), + val enabled_only: List = emptyList(), + val options: YamlConfigOptions = YamlConfigOptions(), + val exclude_paths: List = emptyList() +) { + @Serializable + data class YamlConfigOptions( + val min_touch_target: Int? = null, + val contrast_ratio: Double? = null + ) + + val severityOverrides: Map + get() = severity_overrides.mapNotNull { (k, v) -> + val severity = try { A11ySeverity.valueOf(v.uppercase()) } catch (_: Exception) { null } + if (severity != null) k to severity else null + }.toMap() + + val disabledRules: Set get() = disabled_rules.toSet() + val enabledOnly: Set get() = enabled_only.toSet() + val configOptions: ConfigOptions + get() = ConfigOptions( + minTouchTarget = options.min_touch_target ?: 48, + contrastRatio = options.contrast_ratio ?: 4.5 + ) + + fun shouldExclude(relativePath: String): Boolean = + exclude_paths.any { pattern -> fnmatchGlob(pattern, relativePath) } + + companion object { + val empty = A11yConfig() + } +} + +object ConfigLoader { + const val FILE_NAME = ".a11ycheck.yml" + + fun load(directory: String): A11yConfig { + val path = findConfigFile(directory) ?: return A11yConfig.empty + return loadAt(path) + } + + fun loadAt(path: String): A11yConfig { + val contents = File(path).readText() + return parse(contents) + } + + fun parse(yaml: String): A11yConfig { + return try { + Yaml.default.decodeFromString(A11yConfig.serializer(), yaml) + } catch (_: Exception) { + A11yConfig.empty + } + } + + private fun findConfigFile(startingAt: String): String? { + var dir = File(startingAt).canonicalFile + while (true) { + val candidate = File(dir, FILE_NAME) + if (candidate.exists()) return candidate.absolutePath + val parent = dir.parentFile ?: break + if (parent == dir) break + dir = parent + } + return null + } +} + +private fun fnmatchGlob(pattern: String, path: String): Boolean { + if (!pattern.contains("/")) { + return path.split("/").any { matchWildcard(pattern, it) } + } + val patternParts = pattern.split("/") + val pathParts = path.split("/") + return matchParts(patternParts, 0, pathParts, 0) +} + +private fun matchParts(pattern: List, pi: Int, path: List, pa: Int): Boolean { + if (pi >= pattern.size) return pa >= path.size + + if (pattern[pi] == "**") { + for (skip in 0..(path.size - pa)) { + if (matchParts(pattern, pi + 1, path, pa + skip)) return true + } + return false + } + + if (pa >= path.size) return false + if (!matchWildcard(pattern[pi], path[pa])) return false + return matchParts(pattern, pi + 1, path, pa + 1) +} + +private fun matchWildcard(pattern: String, string: String): Boolean { + if (pattern == "*") return true + + val p = pattern.toCharArray() + val s = string.toCharArray() + var pi = 0; var si = 0 + var starP = -1; var starS = -1 + + while (si < s.size) { + if (pi < p.size && (p[pi] == s[si] || p[pi] == '?')) { + pi++; si++ + } else if (pi < p.size && p[pi] == '*') { + starP = pi; starS = si; pi++ + } else if (starP >= 0) { + pi = starP + 1; starS++; si = starS + } else { + return false + } + } + while (pi < p.size && p[pi] == '*') pi++ + return pi == p.size +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/DiffFilter.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/DiffFilter.kt new file mode 100644 index 0000000..df4e1dc --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/DiffFilter.kt @@ -0,0 +1,88 @@ +package com.cvshealth.a11y.agent.core + +import java.io.File + +object DiffFilter { + + typealias ChangedLineMap = Map> + + fun changedLines(directory: String, baseBranch: String? = null): ChangedLineMap { + val args = mutableListOf("diff", "--unified=0", "--no-color") + if (baseBranch != null) args.add(baseBranch) + args.addAll(listOf("--", "*.kt")) + + val output = runGit(args, directory) ?: return emptyMap() + return parseUnifiedDiff(output, directory) + } + + fun filter(diagnostics: List, changedLines: ChangedLineMap): List { + if (changedLines.isEmpty()) return emptyList() + return diagnostics.filter { diag -> + changedLines[diag.filePath]?.contains(diag.line) == true + } + } + + private fun runGit(args: List, directory: String): String? { + return try { + val process = ProcessBuilder(listOf("git") + args) + .directory(File(directory)) + .redirectErrorStream(false) + .start() + val output = process.inputStream.bufferedReader().readText() + process.waitFor() + output + } catch (_: Exception) { + null + } + } + + internal fun parseUnifiedDiff(diff: String, workingDirectory: String): ChangedLineMap { + val result = mutableMapOf>() + var currentFile: String? = null + + for (line in diff.lines()) { + if (line.startsWith("+++ b/")) { + val relativePath = line.removePrefix("+++ b/") + currentFile = File(workingDirectory, relativePath).absolutePath + result.getOrPut(currentFile) { mutableSetOf() } + continue + } + + if (line.startsWith("@@") && currentFile != null) { + val range = parseHunkHeader(line) + if (range != null) { + result.getOrPut(currentFile) { mutableSetOf() }.addAll(range) + } + } + } + return result + } + + internal fun parseHunkHeader(line: String): IntRange? { + val plusIdx = line.indexOf('+', startIndex = 2) + if (plusIdx < 0) return null + + val afterPlus = line.substring(plusIdx + 1) + val commaIdx = afterPlus.indexOf(',') + + val start: Int + val count: Int + + if (commaIdx >= 0) { + start = afterPlus.substring(0, commaIdx).toIntOrNull() ?: return null + val endIdx = afterPlus.indexOfFirst { it == ' ' || it == '@' }.let { + if (it < 0) afterPlus.length else it + } + count = afterPlus.substring(commaIdx + 1, endIdx).toIntOrNull() ?: return null + } else { + val endIdx = afterPlus.indexOfFirst { it == ' ' || it == '@' }.let { + if (it < 0) afterPlus.length else it + } + start = afterPlus.substring(0, endIdx).toIntOrNull() ?: return null + count = 1 + } + + if (count <= 0) return null + return start..(start + count - 1) + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/InlineSuppression.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/InlineSuppression.kt new file mode 100644 index 0000000..d777646 --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/InlineSuppression.kt @@ -0,0 +1,57 @@ +package com.cvshealth.a11y.agent.core + +object InlineSuppression { + + data class Suppression( + val line: Int, + val ruleIDs: Set? + ) + + fun filter(diagnostics: List, sourceText: String): List { + val suppressions = parseSuppressionsFromSource(sourceText) + if (suppressions.isEmpty()) return diagnostics + + return diagnostics.filter { diag -> + suppressions.none { sup -> + sup.line == diag.line && (sup.ruleIDs == null || sup.ruleIDs.contains(diag.ruleID)) + } + } + } + + internal fun parseSuppressionsFromSource(source: String): List { + val suppressions = mutableListOf() + val lines = source.lines() + + for ((index, line) in lines.withIndex()) { + val lineNumber = index + 1 + val trimmed = line.trim() + + // Check for disable-next-line (must come before disable check) + val nextLineIdx = trimmed.indexOf("// a11y-check:disable-next-line") + if (nextLineIdx >= 0) { + val afterDirective = trimmed.substring(nextLineIdx + "// a11y-check:disable-next-line".length).trim() + val ruleIDs = parseRuleIDs(afterDirective) + suppressions.add(Suppression(line = lineNumber + 1, ruleIDs = ruleIDs)) + continue + } + + // Check for same-line disable + val disableIdx = trimmed.indexOf("// a11y-check:disable") + if (disableIdx >= 0) { + val afterDirective = trimmed.substring(disableIdx + "// a11y-check:disable".length).trim() + if (afterDirective.startsWith("-next-line")) continue + val ruleIDs = parseRuleIDs(afterDirective) + suppressions.add(Suppression(line = lineNumber, ruleIDs = ruleIDs)) + } + } + + return suppressions + } + + private fun parseRuleIDs(text: String): Set? { + val ids = text.split(",") + .map { it.trim() } + .filter { it.isNotEmpty() } + return if (ids.isEmpty()) null else ids.toSet() + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/RuleDocsGenerator.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/RuleDocsGenerator.kt new file mode 100644 index 0000000..69609ae --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/RuleDocsGenerator.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.a11y.agent.core + +/** + * Generates Markdown documentation for all registered rules. + * Produces a summary table, WCAG criterion grouping, and severity breakdown. + */ +class RuleDocsGenerator { + + fun generate(rules: List): String { + val sb = StringBuilder() + + val uniqueCriteria = rules.flatMap { it.wcagCriteria }.toSet() + sb.appendLine("# a11y-check Rules Reference") + sb.appendLine() + sb.appendLine("Generated automatically. ${rules.size} rules across ${uniqueCriteria.size} WCAG criteria.") + sb.appendLine() + + // Summary table + sb.appendLine("| # | Rule ID | Severity | WCAG | Description |") + sb.appendLine("|---|---------|----------|------|-------------|") + for ((i, rule) in rules.sortedBy { it.wcagCriteria.firstOrNull() ?: "z" }.withIndex()) { + val wcag = rule.wcagCriteria.joinToString(", ") + sb.appendLine("| ${i + 1} | `${rule.id}` | ${rule.severity.label} | $wcag | ${rule.description} |") + } + sb.appendLine() + + // Group by WCAG criterion + sb.appendLine("---") + sb.appendLine() + sb.appendLine("## By WCAG Criterion") + sb.appendLine() + + val byCriterion = mutableMapOf>() + for (rule in rules) { + for (criterion in rule.wcagCriteria) { + byCriterion.getOrPut(criterion) { mutableListOf() }.add(rule) + } + } + for ((criterion, rulesForCriterion) in byCriterion.toSortedMap()) { + sb.appendLine("### WCAG $criterion") + sb.appendLine() + for (rule in rulesForCriterion) { + sb.appendLine("- **`${rule.id}`** (${rule.severity.label}) — ${rule.name}") + } + sb.appendLine() + } + + // Severity breakdown + sb.appendLine("## By Severity") + sb.appendLine() + + val errors = rules.filter { it.severity == A11ySeverity.ERROR } + val warnings = rules.filter { it.severity == A11ySeverity.WARNING } + val infos = rules.filter { it.severity == A11ySeverity.INFO } + + if (errors.isNotEmpty()) { + sb.appendLine("### Errors (${errors.size})") + sb.appendLine() + for (rule in errors) { + sb.appendLine("- `${rule.id}` — ${rule.name}") + } + sb.appendLine() + } + if (warnings.isNotEmpty()) { + sb.appendLine("### Warnings (${warnings.size})") + sb.appendLine() + for (rule in warnings) { + sb.appendLine("- `${rule.id}` — ${rule.name}") + } + sb.appendLine() + } + if (infos.isNotEmpty()) { + sb.appendLine("### Info (${infos.size})") + sb.appendLine() + for (rule in infos) { + sb.appendLine("- `${rule.id}` — ${rule.name}") + } + sb.appendLine() + } + + return sb.toString() + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/RuleRegistry.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/RuleRegistry.kt new file mode 100644 index 0000000..f587beb --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/RuleRegistry.kt @@ -0,0 +1,192 @@ +package com.cvshealth.a11y.agent.core + +import com.cvshealth.a11y.agent.rules.* +import com.cvshealth.a11y.agent.scanner.ComposableFunction +import com.cvshealth.a11y.agent.scanner.KotlinFileScanner +import java.io.File + +class RuleRegistry { + + private val _rules = mutableListOf() + val rules: List get() = _rules + + var disabledRuleIDs = mutableSetOf() + var config: A11yConfig = A11yConfig.empty + + data class LocatedComposable(val filePath: String, val composable: ComposableFunction) + + private val _composables = mutableListOf() + val composables: List get() = _composables + + init { + registerBuiltInRules() + } + + fun register(rule: A11yRule) { + _rules.add(rule) + } + + private fun registerBuiltInRules() { + // Images (WCAG 1.1.1) + register(IconMissingLabelRule()) + register(LabelContainsRoleImageRule()) + register(EmptyContentDescriptionRule()) + + // Headings (WCAG 1.3.1, 2.4.6) + register(HeadingSemanticsMissingRule()) + register(FakeHeadingInLabelRule()) + + // Buttons (WCAG 4.1.2) + register(LabelContainsRoleButtonRule()) + register(IconButtonMissingLabelRule()) + register(VisuallyDisabledNotSemanticallyRule()) + + // Label in Name (WCAG 2.5.3) + register(LabelInNameRule()) + + // Clickable / Traits (WCAG 4.1.2) + register(ClickableMissingRoleRule()) + + // Toggles (WCAG 4.1.2) + register(ToggleMissingLabelRule()) + + // Links (WCAG 2.4.4) + register(GenericLinkTextRule()) + register(ButtonUsedAsLinkRule()) + + // Touch Targets (WCAG 2.5.8) + register(SmallTouchTargetRule()) + + // Dynamic Type (WCAG 1.4.4) + register(FixedFontSizeRule()) + register(MaxLinesOneRule()) + + // Page Titles (WCAG 2.4.2) + register(MissingPaneTitleRule()) + register(TabMissingLabelRule()) + + // Accessibility Hidden (WCAG 4.1.2) + register(HiddenWithInteractiveChildrenRule()) + + // Color / Contrast (WCAG 1.4.3) + register(HardcodedColorRule()) + register(ColorContrastRule()) + + // Form Controls (WCAG 4.1.2) + register(TextFieldMissingLabelRule()) + register(SliderMissingLabelRule()) + register(DropdownMissingLabelRule()) + + // Focus (WCAG 2.4.3) + register(DialogFocusManagementRule()) + + // Animation / Motion (WCAG 2.3.1) + register(ReduceMotionRule()) + + // Input Purpose (WCAG 1.3.5) + register(InputPurposeRule()) + + // Gestures (WCAG 2.1.1) + register(GestureMissingAlternativeRule()) + + // Grouping (WCAG 1.3.1) + register(AccessibilityGroupingRule()) + + // Meaningful Sequence (WCAG 1.3.2) + register(BoxChildOrderRule()) + + // Timing (WCAG 2.2.1) + register(TimingAdjustableRule()) + + // Android-specific: RadioButton grouping + register(RadioGroupMissingRule()) + } + + fun applyConfig(config: A11yConfig) { + this.config = config + disabledRuleIDs.addAll(config.disabledRules) + } + + val enabledRules: List + get() { + var filtered = _rules.filter { it.id !in disabledRuleIDs } + if (config.enabledOnly.isNotEmpty()) { + filtered = filtered.filter { it.id in config.enabledOnly } + } + return filtered + } + + fun clearComposables() { + _composables.clear() + } + + fun analyze(sourceText: String, filePath: String): List { + val parsedFile = KotlinFileScanner.scan(sourceText, filePath) + _composables.addAll(parsedFile.composables.map { LocatedComposable(filePath, it) }) + val context = RuleContext( + filePath = filePath, + sourceText = sourceText, + sourceLines = sourceText.lines(), + disabledRules = disabledRuleIDs, + severityOverrides = config.severityOverrides, + configOptions = config.configOptions + ) + + var diagnostics = enabledRules.flatMap { rule -> + rule.check(parsedFile, context) + } + + diagnostics = InlineSuppression.filter(diagnostics, sourceText) + + // Populate source snippets + val sourceLines = sourceText.lines() + diagnostics = diagnostics.map { diag -> + val diagLine = diag.line + val start = maxOf(0, diagLine - 2) + val end = minOf(sourceLines.size - 1, diagLine) + val snippet = (start..end).joinToString("\n") { idx -> + val lineNum = idx + 1 + val marker = if (lineNum == diagLine) ">" else " " + "$marker $lineNum | ${sourceLines[idx]}" + } + diag.copy(sourceSnippet = snippet) + } + + return diagnostics.sortedWith(compareBy({ it.line }, { it.column })) + } + + fun analyzeFile(path: String): List { + val file = File(path) + if (!file.exists() || !file.isFile) return emptyList() + return analyze(file.readText(), file.absolutePath) + } + + fun analyzeDirectory(path: String): List { + val dir = File(path) + if (!dir.exists()) return emptyList() + + return dir.walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .filter { !config.shouldExclude(it.relativeTo(dir).path) } + .flatMap { file -> + try { + analyzeFile(file.absolutePath) + } catch (_: Exception) { + emptyList() + } + } + .sortedWith(compareBy({ it.filePath }, { it.line }, { it.column })) + .toList() + } + + fun allFilePaths(path: String): List { + val dir = File(path) + if (!dir.exists()) return emptyList() + return dir.walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .filter { !config.shouldExclude(it.relativeTo(dir).path) } + .map { it.absolutePath } + .sorted() + .toList() + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/ScoreCalculator.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/ScoreCalculator.kt new file mode 100644 index 0000000..eb27f7c --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/ScoreCalculator.kt @@ -0,0 +1,215 @@ +package com.cvshealth.a11y.agent.core + +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +class ScoreCalculator { + + companion object { + val wcagCatalog: List> = listOf( + // Principle 1 - Perceivable + Triple("1.1.1", "Non-text Content", WCAGLevel.A), + Triple("1.3.1", "Info and Relationships", WCAGLevel.A), + Triple("1.3.2", "Meaningful Sequence", WCAGLevel.A), + Triple("1.3.3", "Sensory Characteristics", WCAGLevel.A), + Triple("1.3.4", "Orientation", WCAGLevel.AA), + Triple("1.3.5", "Identify Input Purpose", WCAGLevel.AA), + Triple("1.4.1", "Use of Color", WCAGLevel.A), + Triple("1.4.2", "Audio Control", WCAGLevel.A), + Triple("1.4.3", "Contrast (Minimum)", WCAGLevel.AA), + Triple("1.4.4", "Resize Text", WCAGLevel.AA), + Triple("1.4.5", "Images of Text", WCAGLevel.AA), + Triple("1.4.10", "Reflow", WCAGLevel.AA), + Triple("1.4.11", "Non-text Contrast", WCAGLevel.AA), + Triple("1.4.12", "Text Spacing", WCAGLevel.AA), + Triple("1.4.13", "Content on Hover or Focus", WCAGLevel.AA), + // Principle 2 - Operable + Triple("2.1.1", "Keyboard", WCAGLevel.A), + Triple("2.1.2", "No Keyboard Trap", WCAGLevel.A), + Triple("2.1.4", "Character Key Shortcuts", WCAGLevel.A), + Triple("2.2.1", "Timing Adjustable", WCAGLevel.A), + Triple("2.2.2", "Pause, Stop, Hide", WCAGLevel.A), + Triple("2.3.1", "Three Flashes or Below Threshold", WCAGLevel.A), + Triple("2.4.1", "Bypass Blocks", WCAGLevel.A), + Triple("2.4.2", "Page Titled", WCAGLevel.A), + Triple("2.4.3", "Focus Order", WCAGLevel.A), + Triple("2.4.4", "Link Purpose (In Context)", WCAGLevel.A), + Triple("2.4.5", "Multiple Ways", WCAGLevel.AA), + Triple("2.4.6", "Headings and Labels", WCAGLevel.AA), + Triple("2.4.7", "Focus Visible", WCAGLevel.AA), + Triple("2.4.11", "Focus Not Obscured (Minimum)", WCAGLevel.AA), + Triple("2.5.1", "Pointer Gestures", WCAGLevel.A), + Triple("2.5.2", "Pointer Cancellation", WCAGLevel.A), + Triple("2.5.3", "Label in Name", WCAGLevel.A), + Triple("2.5.4", "Motion Actuation", WCAGLevel.A), + Triple("2.5.7", "Dragging Movements", WCAGLevel.AA), + Triple("2.5.8", "Target Size (Minimum)", WCAGLevel.AA), + // Principle 3 - Understandable + Triple("3.1.1", "Language of Page", WCAGLevel.A), + Triple("3.1.2", "Language of Parts", WCAGLevel.AA), + Triple("3.2.1", "On Focus", WCAGLevel.A), + Triple("3.2.2", "On Input", WCAGLevel.A), + Triple("3.2.6", "Consistent Help", WCAGLevel.A), + Triple("3.3.1", "Error Identification", WCAGLevel.A), + Triple("3.3.2", "Labels or Instructions", WCAGLevel.A), + Triple("3.3.3", "Error Suggestion", WCAGLevel.AA), + Triple("3.3.4", "Error Prevention (Legal, Financial, Data)", WCAGLevel.AA), + Triple("3.3.7", "Redundant Entry", WCAGLevel.A), + Triple("3.3.8", "Accessible Authentication (Minimum)", WCAGLevel.AA), + // Principle 4 - Robust + Triple("4.1.2", "Name, Role, Value", WCAGLevel.A), + Triple("4.1.3", "Status Messages", WCAGLevel.AA), + ) + + private const val ERROR_PENALTY = 5.0 + private const val WARNING_PENALTY = 2.0 + private const val INFO_PENALTY = 0.5 + + private fun impactMultiplier(impact: A11yImpact): Double = when (impact) { + A11yImpact.CRITICAL -> 2.0 + A11yImpact.SERIOUS -> 1.5 + A11yImpact.MODERATE -> 1.0 + A11yImpact.MINOR -> 0.5 + } + + fun computeFileScore(errors: Int, warnings: Int, info: Int): Double { + val penalty = errors * ERROR_PENALTY + warnings * WARNING_PENALTY + info * INFO_PENALTY + return max(0.0, min(100.0, 100.0 - penalty)) + } + } + + fun calculate( + diagnostics: List, + rules: List, + filePaths: List + ): A11yScore { + val totalErrors = diagnostics.count { it.severity == A11ySeverity.ERROR } + val totalWarnings = diagnostics.count { it.severity == A11ySeverity.WARNING } + val totalInfo = diagnostics.count { it.severity == A11ySeverity.INFO } + + val diagsByCriterion = mutableMapOf>() + for (diag in diagnostics) { + for (c in diag.wcagCriteria) { + diagsByCriterion.getOrPut(c) { mutableListOf() }.add(diag) + } + } + + val rulesByCriterion = mutableMapOf>() + for (rule in rules) { + for (c in rule.wcagCriteria) { + rulesByCriterion.getOrPut(c) { mutableListOf() }.add(rule.id) + } + } + + val checkedCriteria = rules.flatMap { it.wcagCriteria }.toSet() + + var passCount = 0 + var failCount = 0 + var notCheckedCount = 0 + val criteriaScores = mutableListOf() + + for ((criterion, name, level) in wcagCatalog) { + val isChecked = criterion in checkedCriteria + val diags = diagsByCriterion[criterion] ?: emptyList() + val errors = diags.count { it.severity == A11ySeverity.ERROR } + val warnings = diags.count { it.severity == A11ySeverity.WARNING } + val infos = diags.count { it.severity == A11ySeverity.INFO } + + val status: CriterionStatus + if (!isChecked) { + status = CriterionStatus.NOT_CHECKED + notCheckedCount++ + } else if (errors > 0) { + status = CriterionStatus.FAIL + failCount++ + } else if (warnings > 0 || infos > 0) { + status = CriterionStatus.REVIEW + passCount++ + } else { + status = CriterionStatus.PASS + passCount++ + } + + criteriaScores.add( + CriterionScore( + criterion = criterion, + name = name, + principle = WCAGPrinciple.from(criterion), + level = level, + status = status, + errorCount = errors, + warningCount = warnings, + infoCount = infos, + ruleIDs = rulesByCriterion[criterion] ?: emptyList() + ) + ) + } + + // Principle-level scores + val principleScores = mutableMapOf() + for (principle in WCAGPrinciple.entries) { + val critForPrinciple = criteriaScores.filter { + it.principle == principle && it.status != CriterionStatus.NOT_CHECKED + } + if (critForPrinciple.isEmpty()) { + principleScores[principle] = 100.0 + } else { + val passed = critForPrinciple.count { + it.status == CriterionStatus.PASS || it.status == CriterionStatus.REVIEW + } + var base = (passed.toDouble() / critForPrinciple.size) * 100.0 + val warningPenalty = critForPrinciple + .filter { it.status == CriterionStatus.REVIEW } + .sumOf { it.warningCount } * 1.0 + base = max(0.0, base - warningPenalty) + principleScores[principle] = base + } + } + + // Per-file scores + val diagsByFile = diagnostics.groupBy { it.filePath } + val fileScores = filePaths.sorted().map { path -> + val fileDiags = diagsByFile[path] ?: emptyList() + val fe = fileDiags.count { it.severity == A11ySeverity.ERROR } + val fw = fileDiags.count { it.severity == A11ySeverity.WARNING } + val fi = fileDiags.count { it.severity == A11ySeverity.INFO } + FileScore(path, computeFileScore(fe, fw, fi), fe, fw, fi) + } + + // Overall score + val principleAvg = if (principleScores.isEmpty()) 100.0 + else principleScores.values.sum() / principleScores.size + + val issuePenalty = diagnostics.sumOf { diag -> + val basePenalty = when (diag.severity) { + A11ySeverity.ERROR -> ERROR_PENALTY + A11ySeverity.WARNING -> WARNING_PENALTY + A11ySeverity.INFO -> INFO_PENALTY + } + basePenalty * impactMultiplier(diag.impact) + } + val filesCount = max(1, filePaths.size) + val normalizedPenalty = min(100.0, issuePenalty / filesCount * 2.0) + val issueScore = max(0.0, 100.0 - normalizedPenalty) + + val overallScore = min(100.0, max(0.0, principleAvg * 0.5 + issueScore * 0.5)) + val rounded = (overallScore * 10).roundToInt() / 10.0 + val grade = A11yScore.letterGrade(rounded) + + return A11yScore( + score = rounded, + grade = grade, + criteriaScores = criteriaScores, + principleScores = principleScores, + fileScores = fileScores, + totalErrors = totalErrors, + totalWarnings = totalWarnings, + totalInfo = totalInfo, + filesAnalyzed = filePaths.size, + criteriaPassed = passCount, + criteriaFailed = failCount, + criteriaNotChecked = notCheckedCount + ) + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/TrendTracker.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/TrendTracker.kt new file mode 100644 index 0000000..477251a --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/core/TrendTracker.kt @@ -0,0 +1,164 @@ +package com.cvshealth.a11y.agent.core + +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File +import java.time.Instant + +class TrendTracker(directory: String, fileName: String = ".a11y-scores.json") { + + @Serializable + data class Entry( + val date: String, + val score: Double, + val grade: String, + val errors: Int, + val warnings: Int, + val criteriaPassed: Int, + val criteriaFailed: Int, + val filesAnalyzed: Int, + val gitCommit: String? = null + ) + + @Serializable + data class History( + val entries: MutableList = mutableListOf() + ) + + private val filePath = File(directory, fileName).absolutePath + private val json = Json { prettyPrint = true; encodeDefaults = true } + + fun load(): History { + val file = File(filePath) + if (!file.exists()) return History() + return try { + json.decodeFromString(file.readText()) + } catch (_: Exception) { + History() + } + } + + fun record(score: A11yScore) { + val history = load() + val entry = Entry( + date = Instant.now().toString(), + score = score.score, + grade = score.grade, + errors = score.totalErrors, + warnings = score.totalWarnings, + criteriaPassed = score.criteriaPassed, + criteriaFailed = score.criteriaFailed, + filesAnalyzed = score.filesAnalyzed, + gitCommit = currentGitCommit() + ) + history.entries.add(entry) + File(filePath).writeText(json.encodeToString(history)) + } + + fun formatTrend(currentScore: A11yScore, lastN: Int = 10): String { + val history = load() + val reset = "\u001B[0m" + val bold = "\u001B[1m" + val dim = "\u001B[2m" + val green = "\u001B[32m" + val red = "\u001B[31m" + val yellow = "\u001B[33m" + + val sb = StringBuilder() + sb.appendLine("\n${bold}Score Trend:$reset") + + if (history.entries.isEmpty()) { + sb.appendLine(" ${dim}No previous scores recorded. Run with --trend again to start tracking.$reset") + return sb.toString() + } + + val recent = history.entries.takeLast(lastN) + + // Delta from last + recent.lastOrNull()?.let { last -> + val delta = currentScore.score - last.score + val deltaStr = when { + delta > 0 -> "$green+${"%.1f".format(delta)}$reset" + delta < 0 -> "$red${"%.1f".format(delta)}$reset" + else -> "${dim}\u00b10.0$reset" + } + sb.appendLine(" Change from last run: $deltaStr") + } + + // Sparkline + val allScores = recent.map { it.score } + currentScore.score + sb.appendLine(" ${bold}History:$reset ${formatSparkline(allScores)}") + + // Table + sb.appendLine("\n ${dim}Date Score Grade Errors \u0394$reset") + var prevScore: Double? = null + for (entry in recent) { + val delta = formatDelta(entry.score, prevScore, green, red, dim, reset) + val gradeColor = gradeColor(entry.grade, green, yellow, red) + val dateShort = entry.date.take(25).padEnd(30) + sb.append(" $dateShort") + sb.append("%5.1f ".format(entry.score)) + sb.append("$gradeColor${entry.grade.padEnd(5)}$reset ") + sb.append("${if (entry.errors > 0) red else dim}${entry.errors.toString().padEnd(6)}$reset ") + sb.appendLine(delta) + prevScore = entry.score + } + + // Current + val currentDelta = formatDelta(currentScore.score, prevScore, green, red, dim, reset) + val gradeColor = gradeColor(currentScore.grade, green, yellow, red) + sb.append(" ${bold}\u2192 now".padEnd(36)) + sb.append("%5.1f ".format(currentScore.score)) + sb.append("$gradeColor${currentScore.grade.padEnd(5)}$reset ") + sb.append("${if (currentScore.totalErrors > 0) red else dim}${currentScore.totalErrors.toString().padEnd(6)}$reset ") + sb.appendLine("$currentDelta$reset") + + return sb.toString() + } + + private fun formatDelta(score: Double, prevScore: Double?, green: String, red: String, dim: String, reset: String): String { + if (prevScore == null) return "$dim \u2014$reset" + val d = score - prevScore + return when { + d > 0 -> "$green+${"%.1f".format(d)}$reset" + d < 0 -> "$red${"%.1f".format(d)}$reset" + else -> "$dim 0.0$reset" + } + } + + private fun gradeColor(grade: String, green: String, yellow: String, red: String): String { + return when (grade.first()) { + 'A', 'B' -> green + 'C', 'D' -> yellow + else -> red + } + } + + private fun formatSparkline(values: List): String { + val blocks = listOf("\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588") + val min = values.minOrNull() ?: return "" + val max = values.maxOrNull() ?: return "" + val range = max - min + return values.joinToString("") { value -> + if (range == 0.0) blocks[4] + else { + val idx = ((value - min) / range * (blocks.size - 1)).toInt().coerceIn(0, blocks.size - 1) + blocks[idx] + } + } + } + + private fun currentGitCommit(): String? { + return try { + val process = ProcessBuilder("git", "rev-parse", "--short", "HEAD") + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText().trim() + process.waitFor() + if (process.exitValue() == 0) output else null + } catch (_: Exception) { + null + } + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/formatters/GradleFormatter.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/formatters/GradleFormatter.kt new file mode 100644 index 0000000..988d669 --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/formatters/GradleFormatter.kt @@ -0,0 +1,44 @@ +package com.cvshealth.a11y.agent.formatters + +import com.cvshealth.a11y.agent.core.* + +/** + * Outputs diagnostics in the file:line:column: severity: message format + * that Android Studio / IntelliJ IDEA parses from Gradle task output + * to produce clickable inline annotations in the editor. + * + * Equivalent to the iOS a11y-check --format xcode output. + */ +object GradleFormatter { + + fun format(diagnostics: List, score: A11yScore, compact: Boolean = false): String { + val sb = StringBuilder() + + for (diag in diagnostics.sortedWith(compareBy({ it.filePath }, { it.line }))) { + val severity = when (diag.severity) { + A11ySeverity.ERROR -> "error" + A11ySeverity.WARNING -> "warning" + A11ySeverity.INFO -> "info" + } + val wcag = if (diag.wcagCriteria.isNotEmpty()) { + " [WCAG ${diag.wcagCriteria.joinToString(", ")}]" + } else "" + + val path = if (compact) diag.filePath.substringAfterLast("/") else diag.filePath + sb.appendLine("$path:${diag.line}:${diag.column}: $severity: [${diag.ruleID}] [${diag.impact.label}] ${diag.message}$wcag") + } + + // Score summary as a project-level diagnostic + val scoreSeverity = when { + score.criteriaFailed > 0 -> "warning" + else -> "info" + } + val failedCriteria = score.criteriaScores + .filter { it.status == CriterionStatus.FAIL } + .joinToString(", ") { "${it.criterion} ${it.name}" } + val failedStr = if (failedCriteria.isNotEmpty()) " — Failed: $failedCriteria" else "" + sb.appendLine("w: [a11y-score] WCAG Score: ${"%.1f".format(score.score)}/100 (${score.grade}) — ${score.totalErrors} errors, ${score.totalWarnings} warnings$failedStr") + + return sb.toString() + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/formatters/HtmlFormatter.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/formatters/HtmlFormatter.kt new file mode 100644 index 0000000..1e6b952 --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/formatters/HtmlFormatter.kt @@ -0,0 +1,520 @@ +package com.cvshealth.a11y.agent.formatters + +import com.cvshealth.a11y.agent.core.* +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +object HtmlFormatter { + + fun format( + diagnostics: List, + score: A11yScore, + trendEntries: List = emptyList(), + allRules: List = emptyList() + ): String { + val byFile = diagnostics.groupBy { it.filePath } + val byRule = diagnostics.groupBy { it.ruleID } + val timestamp = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z") + .withZone(ZoneId.systemDefault()) + .format(Instant.now()) + + return buildString { + appendLine("") + appendLine("") + appendLine("") + appendLine("") + appendLine("") + appendLine("Compose Accessibility Report") + appendLine("") + appendLine("") + appendLine("") + + // Page header + appendLine("

Compose Accessibility Report

") + appendLine("

Generated: $timestamp

") + + // Summary stat cards + appendLine("
") + appendLine("
${score.totalErrors}
Errors
") + appendLine("
${score.totalWarnings}
Warnings
") + appendLine("
${score.totalInfo}
Info
") + appendLine("
${score.filesAnalyzed}
Files
") + appendLine("
") + + // Score section + val gradeClass = when (score.grade.first()) { + 'A' -> "grade-a"; 'B' -> "grade-b"; 'C' -> "grade-c"; 'D' -> "grade-d"; else -> "grade-f" + } + appendLine("
") + appendLine("
") + appendLine("
${"%.1f".format(score.score)}
") + appendLine("
") + appendLine("
${score.grade}
") + appendLine("
WCAG 2.2 Accessibility Score
") + appendLine("
") + appendLine("
") + appendLine("
") + appendLine("${score.criteriaPassed} criteria passed") + appendLine("${score.criteriaFailed} failed") + appendLine("${score.filesAnalyzed} files analyzed") + appendLine("
") + + // Failed criteria list + val failedCriteria = score.criteriaScores.filter { it.status == CriterionStatus.FAIL } + if (failedCriteria.isNotEmpty()) { + appendLine("
") + appendLine("

Failed WCAG Criteria

") + appendLine("
    ") + for (c in failedCriteria) { + val anchor = wcagAnchor(c.criterion) + appendLine("
  • \u2717 ${c.criterion} ${c.name} ${c.errorCount} errors, ${c.warningCount} warnings
  • ") + } + appendLine("
") + appendLine("
") + } + + val reviewCriteria = score.criteriaScores.filter { it.status == CriterionStatus.REVIEW } + if (reviewCriteria.isNotEmpty()) { + appendLine("
") + appendLine("

Needs Review

") + appendLine("
    ") + for (c in reviewCriteria) { + val anchor = wcagAnchor(c.criterion) + appendLine("
  • \u26A0 ${c.criterion} ${c.name} ${c.warningCount} warnings, ${c.infoCount} info
  • ") + } + appendLine("
") + appendLine("
") + } + appendLine("
") + + // Trend section + if (trendEntries.isNotEmpty()) { + appendLine("
") + appendLine("

Score Trend

") + + // Delta from last run + val lastEntry = trendEntries.last() + val delta = score.score - lastEntry.score + val deltaClass = when { delta > 0 -> "positive"; delta < 0 -> "negative"; else -> "neutral" } + val deltaStr = when { delta > 0 -> "+${"%.1f".format(delta)}"; delta < 0 -> "${"%.1f".format(delta)}"; else -> "\u00b10.0" } + appendLine("
Change from last run: $deltaStr
") + + // SVG trend chart + val allScores = trendEntries.map { it.score } + score.score + val chartW = 680; val chartH = 200 + val padL = 40; val padR = 20; val padT = 20; val padB = 40 + val plotW = chartW - padL - padR + val plotH = chartH - padT - padB + val minY = (allScores.minOrNull() ?: 0.0).coerceAtMost(50.0) + val maxY = (allScores.maxOrNull() ?: 100.0).coerceAtLeast(100.0) + val rangeY = maxY - minY + + appendLine("
") + appendLine("") + + // Grid lines + for (v in listOf(0, 25, 50, 75, 100).filter { it >= minY && it <= maxY }) { + val y = padT + plotH - ((v - minY) / rangeY * plotH) + appendLine("") + appendLine("$v") + } + + // Points + val points = allScores.mapIndexed { i, s -> + val x = if (allScores.size == 1) padL + plotW / 2.0 else padL + i.toDouble() / (allScores.size - 1) * plotW + val y = padT + plotH - ((s - minY) / rangeY * plotH) + Pair(x, y) + } + + // Area fill + val areaPath = points.joinToString(" ") { "%.1f,%.1f".format(it.first, it.second) } + val firstX = "%.1f".format(points.first().first) + val lastX = "%.1f".format(points.last().first) + val bottomY = "%.1f".format(padT + plotH.toDouble()) + appendLine("") + + // Line + val linePath = points.joinToString(" ") { "%.1f,%.1f".format(it.first, it.second) } + appendLine("") + + // Dots and labels + val dates = trendEntries.map { it.date.take(10) } + "Now" + points.forEachIndexed { i, (x, y) -> + val dotClass = if (i == points.size - 1) "chart-dot-current" else "chart-dot" + val r = if (i == points.size - 1) 5 else 4 + appendLine("") + appendLine("${"%.0f".format(allScores[i])}") + appendLine("${dates[i]}") + } + + appendLine("") + appendLine("
") + + // History table + appendLine("") + appendLine("") + appendLine("") + var prevScore: Double? = null + for (entry in trendEntries) { + val d = if (prevScore != null) entry.score - prevScore!! else 0.0 + val changeHtml = when { + prevScore == null -> "\u2014" + d > 0 -> "+${"%.1f".format(d)}" + d < 0 -> "${"%.1f".format(d)}" + else -> "0.0" + } + appendLine("") + prevScore = entry.score + } + // Current row + val currentDelta = if (prevScore != null) score.score - prevScore!! else 0.0 + val currentChangeHtml = when { + prevScore == null -> "\u2014" + currentDelta > 0 -> "+${"%.1f".format(currentDelta)}" + currentDelta < 0 -> "${"%.1f".format(currentDelta)}" + else -> "0.0" + } + appendLine("") + appendLine("
DateScoreGradeErrorsChange
${entry.date.take(10)}${"%.1f".format(entry.score)}${entry.grade}${entry.errors}$changeHtml
Now${"%.1f".format(score.score)}${score.grade}${score.totalErrors}$currentChangeHtml
") + appendLine("
") + } + + // WCAG criteria table + appendLine("

WCAG 2.2 Conformance

") + appendLine("") + appendLine("") + appendLine("") + for (c in score.criteriaScores.filter { it.status != CriterionStatus.NOT_CHECKED }) { + val statusBadge = when (c.status) { + CriterionStatus.PASS -> "\u2713 Pass" + CriterionStatus.FAIL -> "\u2717 Fail" + CriterionStatus.REVIEW -> "\u26A0 Review" + CriterionStatus.NOT_CHECKED -> "\u00b7 N/A" + } + val issues = mutableListOf() + if (c.errorCount > 0) issues.add("${c.errorCount}E") + if (c.warningCount > 0) issues.add("${c.warningCount}W") + if (c.infoCount > 0) issues.add("${c.infoCount}I") + val issueStr = issues.joinToString(" ").ifEmpty { "\u2014" } + val anchor = wcagAnchor(c.criterion) + appendLine("") + appendLine("") + appendLine("") + appendLine("") + appendLine("") + appendLine("") + } + appendLine("
CriterionNameLevelStatusIssues
${c.criterion}${c.name}${c.level.label}$statusBadge$issueStr
") + + // Build rule name lookup + val ruleNames = allRules.associate { it.id to it.name } + + // Issues by File (expand/collapse) + appendLine("

Issues by File

") + for ((filePath, fileDiags) in byFile.entries.sortedBy { it.key }) { + val shortPath = filePath.substringAfterLast("/").ifEmpty { filePath } + val errorCount = fileDiags.count { it.severity == A11ySeverity.ERROR } + val warningCount = fileDiags.count { it.severity == A11ySeverity.WARNING } + + appendLine("
") + append("$shortPath \u2014 ${fileDiags.size} issue(s)") + if (errorCount > 0) append(" $errorCount errors") + if (warningCount > 0) append(" $warningCount warnings") + appendLine("") + appendLine("
") + + for (diag in fileDiags.sortedBy { it.line }) { + val ruleName = ruleNames[diag.ruleID] ?: diag.ruleID + appendLine("
") + appendLine("
${escapeHtml(ruleName)}
") + appendLine("${diag.severity.label}") + appendLine("${diag.impact.label}") + appendLine("${diag.line}:${diag.column}") + appendLine(escapeHtml(diag.message)) + appendLine("${diag.ruleID}") + if (diag.wcagCriteria.isNotEmpty()) { + val links = diag.wcagCriteria.joinToString(", ") { c -> + "${c}" + } + appendLine("WCAG $links") + } + + // Bad code block + diag.sourceSnippet?.let { snippet -> + appendLine("
Current code
") + appendLine("
") + for (line in snippet.lines()) { + val escaped = escapeHtml(line) + if (line.startsWith(">")) { + appendLine("$escaped") + } else { + appendLine(escaped) + } + } + appendLine("
") + } + + // Fix suggestion + diag.suggestion?.let { + appendLine("
${escapeHtml(it)}
") + } + + // Good code block + if (diag.sourceSnippet != null && diag.suggestion != null) { + val corrected = generateCorrectedSnippet(diag.sourceSnippet!!, diag.suggestion!!) + if (corrected != null) { + appendLine("
Fixed code
") + appendLine("
") + for (line in corrected.lines()) { + val escaped = escapeHtml(line) + if (line.startsWith(">")) { + appendLine("$escaped") + } else { + appendLine(escaped) + } + } + appendLine("
") + } + } + appendLine("
") + } + appendLine("
") + appendLine("
") + } + + // Issues by Rule summary table + if (allRules.isNotEmpty()) { + appendLine("

Issues by Rule

") + appendLine("") + appendLine("") + appendLine("") + for (rule in allRules.sortedBy { it.id }) { + val count = byRule[rule.id]?.size ?: 0 + appendLine("") + } + appendLine("
RuleSeverityImpactCountWCAG
${rule.id}${rule.severity.label}${rule.impact.label}$count${rule.wcagCriteria.joinToString(", ")}
") + } + + appendLine("
Generated by a11y-check-android v0.1.0
") + appendLine("") + } + } + + /** + * Heuristically generates a corrected version of the source snippet by applying the suggestion. + */ + private fun generateCorrectedSnippet(snippet: String, suggestion: String): String? { + val lines = snippet.lines().toMutableList() + val flaggedIdx = lines.indexOfFirst { it.startsWith(">") } + if (flaggedIdx == -1) return null + + val flaggedLine = lines[flaggedIdx] + val prefixMatch = Regex("""^>\s*(\d+)\s*\|\s?(.*)$""").find(flaggedLine) ?: return null + val lineNum = prefixMatch.groupValues[1] + val originalCode = prefixMatch.groupValues[2] + val indent = originalCode.takeWhile { it == ' ' } + + val modifierPattern = Regex("""(Modifier\.\w+\s*(?:\([^)]*\)|\{[^}]*\}))""") + val modifierMatch = modifierPattern.find(suggestion) + + val paramPattern = Regex("""(contentDescription\s*=\s*\S+(?:\([^)]*(?:\([^)]*\)[^)]*)*\))?|label\s*=\s*\{[^}]*\}|label\s*=\s*\{?\s*Text\([^)]*\)\s*\}?|enabled\s*=\s*\w+|text\s*=\s*\{[^}]*\})""") + val paramMatch = paramPattern.find(suggestion) + + val correctedLines = lines.toMutableList() + + if (suggestion.contains("contentDescription = null") && suggestion.contains("contentDescription =")) { + val replacement = paramMatch?.groupValues?.get(1) ?: "contentDescription = stringResource(R.string.description)" + val fixed = originalCode.replace(Regex("""contentDescription\s*=\s*null"""), replacement) + correctedLines[flaggedIdx] = "> $lineNum | $fixed" + return correctedLines.joinToString("\n") + } + + if (suggestion.startsWith("Add ") || suggestion.startsWith("Use ")) { + if (modifierMatch != null) { + val modifierCode = modifierMatch.groupValues[1] + correctedLines[flaggedIdx] = "> $lineNum | $originalCode" + correctedLines.add(flaggedIdx + 1, "> $lineNum | $indent.$modifierCode") + return correctedLines.joinToString("\n") + } + if (paramMatch != null) { + val param = paramMatch.groupValues[1] + if (originalCode.contains("(")) { + val fixed = if (originalCode.trimEnd().endsWith("(")) { + "$originalCode\n> $lineNum | $indent$param," + } else { + originalCode.replace(")", ", $param)") + } + correctedLines[flaggedIdx] = "> $lineNum | $fixed" + return correctedLines.joinToString("\n") + } else { + correctedLines.add(flaggedIdx + 1, "> $lineNum | $indent$param") + return correctedLines.joinToString("\n") + } + } + } + + if (suggestion.contains("Change ") && suggestion.contains(" to ")) { + val changePattern = Regex("""Change\s+(\S+)\s+to\s+(\S+)""") + val changeMatch = changePattern.find(suggestion) + if (changeMatch != null) { + val from = changeMatch.groupValues[1] + val to = changeMatch.groupValues[2] + val fixed = originalCode.replace(from, to) + if (fixed != originalCode) { + correctedLines[flaggedIdx] = "> $lineNum | $fixed" + return correctedLines.joinToString("\n") + } + } + } + + if (suggestion.contains("Remove ")) { + val removePattern = Regex("""Remove\s+"([^"]+)"""") + val removeMatch = removePattern.find(suggestion) + if (removeMatch != null) { + val toRemove = removeMatch.groupValues[1] + val fixed = originalCode.replace(toRemove, "").replace(" ", " ") + correctedLines[flaggedIdx] = "> $lineNum | $fixed" + return correctedLines.joinToString("\n") + } + } + + // Fallback: try any code-like pattern + if (modifierMatch != null || paramMatch != null) { + val fixCode = modifierMatch?.groupValues?.get(1) ?: paramMatch?.groupValues?.get(1) ?: return null + correctedLines[flaggedIdx] = "> $lineNum | $originalCode" + correctedLines.add(flaggedIdx + 1, "> $lineNum | $indent$fixCode") + return correctedLines.joinToString("\n") + } + + val genericCodePattern = Regex("""(\w+\s*=\s*\w+\([^)]*(?:\([^)]*\)[^)]*)*\)|\.\w+\s*\([^)]*\)|\.\w+\s*\{[^}]*\}|semantics\s*\{[^}]*\})""") + val genericMatch = genericCodePattern.find(suggestion) + if (genericMatch != null) { + val fixCode = genericMatch.groupValues[1] + correctedLines[flaggedIdx] = "> $lineNum | $originalCode" + correctedLines.add(flaggedIdx + 1, "> $lineNum | $indent$fixCode") + return correctedLines.joinToString("\n") + } + + return null + } + + private fun escapeHtml(text: String): String = + text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) + + private fun wcagAnchor(criterion: String): String { + val slugs = mapOf( + "1.1.1" to "non-text-content", "1.2.1" to "audio-only-and-video-only-prerecorded", + "1.3.1" to "info-and-relationships", "1.3.2" to "meaningful-sequence", + "1.3.3" to "sensory-characteristics", "1.3.4" to "orientation", "1.3.5" to "identify-input-purpose", + "1.4.1" to "use-of-color", "1.4.2" to "audio-control", "1.4.3" to "contrast-minimum", + "1.4.4" to "resize-text", "1.4.5" to "images-of-text", + "1.4.10" to "reflow", "1.4.11" to "non-text-contrast", "1.4.12" to "text-spacing", + "1.4.13" to "content-on-hover-or-focus", + "2.1.1" to "keyboard", "2.1.2" to "no-keyboard-trap", "2.1.4" to "character-key-shortcuts", + "2.2.1" to "timing-adjustable", "2.2.2" to "pause-stop-hide", + "2.3.1" to "three-flashes-or-below-threshold", + "2.4.1" to "bypass-blocks", "2.4.2" to "page-titled", "2.4.3" to "focus-order", + "2.4.4" to "link-purpose-in-context", "2.4.5" to "multiple-ways", + "2.4.6" to "headings-and-labels", "2.4.7" to "focus-visible", "2.4.11" to "focus-not-obscured-minimum", + "2.5.1" to "pointer-gestures", "2.5.2" to "pointer-cancellation", + "2.5.3" to "label-in-name", "2.5.4" to "motion-actuation", + "2.5.7" to "dragging-movements", "2.5.8" to "target-size-minimum", + "3.1.1" to "language-of-page", "3.1.2" to "language-of-parts", + "3.2.1" to "on-focus", "3.2.2" to "on-input", "3.2.6" to "consistent-help", + "3.3.1" to "error-identification", "3.3.2" to "labels-or-instructions", + "3.3.3" to "error-suggestion", "3.3.4" to "error-prevention-legal-financial-data", + "3.3.7" to "redundant-entry", "3.3.8" to "accessible-authentication-minimum", + "4.1.2" to "name-role-value", "4.1.3" to "status-messages" + ) + return slugs[criterion] ?: criterion.replace(".", "-") + } + + private val CSS = """ + :root { --bg: #f8f9fa; --card-bg: #fff; --text: #1a1a2e; --border: #dee2e6; --error: #dc3545; --error-bg: #f8d7da; --warning: #856404; --warning-bg: #fff3cd; --info: #0c5460; --info-bg: #d1ecf1; --pass: #155724; --pass-bg: #d4edda; } + * { box-sizing: border-box; margin: 0; padding: 0; } + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; padding: 2rem; max-width: 1200px; margin: 0 auto; } + h1 { font-size: 1.75rem; margin-bottom: 0.5rem; } + h2 { font-size: 1.25rem; margin: 2rem 0 1rem; border-bottom: 2px solid var(--border); padding-bottom: 0.5rem; } + a { text-decoration: underline; color: #0b5ed7; } + .timestamp { color: #595f64; font-size: 0.875rem; margin-bottom: 1.5rem; } + .summary { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 2rem; } + .stat { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1rem 1.5rem; min-width: 120px; text-align: center; } + .stat .number { font-size: 2rem; font-weight: 700; } + .stat .label { font-size: 0.875rem; color: #595f64; } + .stat.error .number { color: var(--error); } + .stat.warning .number { color: var(--warning); } + .stat.info .number { color: var(--info); } + .score-section { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; } + .score-header-row { display: flex; align-items: center; gap: 1.5rem; margin-bottom: 1rem; flex-wrap: wrap; } + .score-big { font-size: 3rem; font-weight: 800; line-height: 1; } + .score-grade { font-size: 2rem; font-weight: 700; padding: 0.25rem 0.75rem; border-radius: 8px; display: inline-block; } + .grade-a { background: var(--pass-bg); color: var(--pass); } + .grade-b { background: #d4edda; color: #155724; } + .grade-c { background: var(--warning-bg); color: var(--warning); } + .grade-d { background: #ffe0b2; color: #e65100; } + .grade-f { background: var(--error-bg); color: var(--error); } + .score-subtitle { color: #595f64; font-size: 0.875rem; } + .score-stats { display: flex; gap: 1.5rem; flex-wrap: wrap; font-size: 0.875rem; color: #595f64; margin-bottom: 1rem; } + .score-stats span { white-space: nowrap; } + .failed-criteria { margin-top: 0.75rem; } + .failed-criteria h3 { font-size: 0.9375rem; margin-bottom: 0.5rem; } + .failed-criteria ul { list-style: none; padding: 0; } + .failed-criteria li { padding: 0.25rem 0; font-size: 0.875rem; } + .failed-criteria .criterion-id { font-weight: 600; } + .failed-criteria .criterion-counts { color: #595f64; font-size: 0.75rem; } + .criteria-table { width: 100%; border-collapse: collapse; background: var(--card-bg); border-radius: 8px; overflow: hidden; border: 1px solid var(--border); margin-bottom: 1.5rem; } + .criteria-table th { background: #f1f3f5; font-weight: 600; padding: 0.625rem 1rem; text-align: left; border-bottom: 1px solid var(--border); font-size: 0.875rem; } + .criteria-table td { padding: 0.5rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.875rem; } + .criteria-table td.status-cell { text-align: center; } + .badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; } + .badge-pass { background: var(--pass-bg); color: var(--pass); } + .badge-fail { background: var(--error-bg); color: var(--error); } + .badge-error { background: var(--error-bg); color: var(--error); } + .badge-warning { background: var(--warning-bg); color: var(--warning); } + .badge-info { background: var(--info-bg); color: var(--info); } + .badge-critical { background: #f8d7da; color: #721c24; } + .badge-serious { background: #ffe0b2; color: #e65100; } + .badge-moderate { background: #fff3cd; color: #856404; } + .badge-minor { background: #e2e3e5; color: #383d41; } + details { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 0.75rem; } + summary { padding: 0.75rem 1rem; cursor: pointer; font-weight: 500; font-size: 0.875rem; } + summary:hover { background: #f1f3f5; } + .diag-list { padding: 0.5rem 1rem 1rem; } + .diag { padding: 0.75rem 0; border-bottom: 1px solid #e9ecef; font-size: 0.8125rem; } + .diag-title { font-size: 0.9375rem; font-weight: 700; margin: 0 0 0.375rem 0; color: #212529; } + .diag:last-child { border-bottom: none; } + .diag-loc { color: #595f64; font-family: monospace; } + .diag-rule { color: #595f64; font-style: italic; } + .diag-wcag { font-size: 0.75rem; font-weight: 600; color: #495057; } + .diag-wcag a { color: #0b5ed7; } + .code-label { font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 0.5rem; padding: 0.15rem 0.5rem; border-radius: 3px 3px 0 0; display: block; width: fit-content; } + .bad-label { background: var(--error); color: white; } + .good-label { background: #28a745; color: white; } + .code-block { background: #1e1e2e; color: #cdd6f4; border-radius: 0 6px 6px 6px; padding: 0.625rem 0.75rem; margin: 0 0 0.375rem 0; font-family: 'SF Mono', Menlo, Consolas, monospace; font-size: 0.75rem; overflow-x: auto; line-height: 1.5; white-space: pre; } + .code-block .line-bad { color: #f38ba8; } + .suggestion { background: var(--pass-bg); color: var(--pass); border-radius: 4px; padding: 0.375rem 0.625rem; margin: 0.375rem 0; font-size: 0.75rem; display: inline-block; } + .suggestion::before { content: "Fix: "; font-weight: 600; } + .fix-block { background: #1e3a1e; color: #a6e3a1; border-radius: 0 6px 6px 6px; padding: 0.625rem 0.75rem; margin: 0 0 0.375rem 0; font-family: 'SF Mono', Menlo, Consolas, monospace; font-size: 0.75rem; overflow-x: auto; line-height: 1.5; white-space: pre; } + .fix-block .line-good { color: #40e040; font-weight: 600; } + .trend-section { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; } + .trend-section h2 { margin-top: 0; border-bottom: none; padding-bottom: 0; } + .trend-chart { margin: 1rem 0; } + .trend-chart svg { width: 100%; max-width: 700px; } + .trend-chart .chart-line { fill: none; stroke: #0d6efd; stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; } + .trend-chart .chart-area { fill: rgba(13, 110, 253, 0.1); } + .trend-chart .chart-dot { fill: #0d6efd; } + .trend-chart .chart-dot-current { fill: #198754; stroke: #fff; stroke-width: 2; } + .trend-chart .chart-grid { stroke: #e9ecef; stroke-width: 1; } + .trend-chart .chart-label { fill: #595f64; font-size: 11px; font-family: -apple-system, sans-serif; } + .trend-delta { font-size: 1.25rem; font-weight: 700; margin-bottom: 0.5rem; } + .trend-delta.positive { color: var(--pass); } + .trend-delta.negative { color: var(--error); } + .trend-delta.neutral { color: #595f64; } + .trend-table { width: 100%; max-width: 700px; } + .trend-table th { background: #f1f3f5; } + footer { margin-top: 3rem; text-align: center; color: #6c757d; font-size: 0.85rem; } + """.trimIndent() +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/formatters/JsonFormatter.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/formatters/JsonFormatter.kt new file mode 100644 index 0000000..23570f5 --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/formatters/JsonFormatter.kt @@ -0,0 +1,123 @@ +package com.cvshealth.a11y.agent.formatters + +import com.cvshealth.a11y.agent.core.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +object JsonFormatter { + + private val json = Json { + prettyPrint = true + encodeDefaults = true + } + + @Serializable + data class DiagnosticJson( + val ruleID: String, + val severity: String, + val impact: String, + val message: String, + val filePath: String, + val line: Int, + val column: Int, + val wcagCriteria: List, + val suggestion: String? = null + ) + + @Serializable + data class ScoreJson( + val score: Double, + val grade: String, + val totalErrors: Int, + val totalWarnings: Int, + val totalInfo: Int, + val filesAnalyzed: Int, + val criteriaPassed: Int, + val criteriaFailed: Int, + val criteriaNotChecked: Int, + val failedCriteria: List = emptyList(), + val reviewCriteria: List = emptyList() + ) + + @Serializable + data class TrendJson( + val entries: List, + val delta: Double + ) + + @Serializable + data class TrendEntryJson( + val date: String, + val score: Double, + val grade: String, + val errors: Int, + val warnings: Int + ) + + @Serializable + data class OutputJson( + val diagnostics: List, + val score: ScoreJson, + val trend: TrendJson? = null + ) + + fun format( + diagnostics: List, + score: A11yScore, + trendEntries: List? = null + ): String { + val diagsJson = diagnostics.map { diag -> + DiagnosticJson( + ruleID = diag.ruleID, + severity = diag.severity.label, + impact = diag.impact.label, + message = diag.message, + filePath = diag.filePath, + line = diag.line, + column = diag.column, + wcagCriteria = diag.wcagCriteria, + suggestion = diag.suggestion + ) + } + + val failedCriteria = score.criteriaScores + .filter { it.status == CriterionStatus.FAIL } + .map { "${it.criterion} ${it.name}" } + val reviewCriteria = score.criteriaScores + .filter { it.status == CriterionStatus.REVIEW } + .map { "${it.criterion} ${it.name}" } + + val scoreJson = ScoreJson( + score = score.score, + grade = score.grade, + totalErrors = score.totalErrors, + totalWarnings = score.totalWarnings, + totalInfo = score.totalInfo, + filesAnalyzed = score.filesAnalyzed, + criteriaPassed = score.criteriaPassed, + criteriaFailed = score.criteriaFailed, + criteriaNotChecked = score.criteriaNotChecked, + failedCriteria = failedCriteria, + reviewCriteria = reviewCriteria + ) + + val trendJson = trendEntries?.let { entries -> + val delta = if (entries.isNotEmpty()) score.score - entries.last().score else 0.0 + TrendJson( + entries = entries.map { + TrendEntryJson(it.date, it.score, it.grade, it.errors, it.warnings) + }, + delta = delta + ) + } + + val output = OutputJson( + diagnostics = diagsJson, + score = scoreJson, + trend = trendJson + ) + + return json.encodeToString(output) + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/formatters/SarifFormatter.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/formatters/SarifFormatter.kt new file mode 100644 index 0000000..3661622 --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/formatters/SarifFormatter.kt @@ -0,0 +1,150 @@ +package com.cvshealth.a11y.agent.formatters + +import com.cvshealth.a11y.agent.core.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +object SarifFormatter { + + private val json = Json { + prettyPrint = true + encodeDefaults = false + } + + @Serializable + data class SarifOutput( + val `$schema`: String = "https://json.schemastore.org/sarif-2.1.0.json", + val version: String = "2.1.0", + val runs: List + ) + + @Serializable + data class SarifRun( + val tool: SarifTool, + val results: List + ) + + @Serializable + data class SarifTool(val driver: SarifDriver) + + @Serializable + data class SarifDriver( + val name: String = "a11y-check-android", + val version: String = "0.1.0", + val informationUri: String = "https://github.com/cvs-health/android-compose-accessibility-techniques", + val rules: List + ) + + @Serializable + data class SarifRuleDescriptor( + val id: String, + val name: String, + val shortDescription: SarifMessage, + val helpUri: String? = null, + val properties: SarifRuleProperties? = null + ) + + @Serializable + data class SarifRuleProperties( + val impact: String? = null, + val wcagCriteria: List? = null + ) + + @Serializable + data class SarifResult( + val ruleId: String, + val level: String, + val message: SarifMessage, + val locations: List + ) + + @Serializable + data class SarifMessage(val text: String) + + @Serializable + data class SarifLocation(val physicalLocation: SarifPhysicalLocation) + + @Serializable + data class SarifPhysicalLocation( + val artifactLocation: SarifArtifactLocation, + val region: SarifRegion + ) + + @Serializable + data class SarifArtifactLocation(val uri: String) + + @Serializable + data class SarifRegion(val startLine: Int, val startColumn: Int) + + fun format(diagnostics: List, rules: List): String { + val ruleDescriptors = rules.map { rule -> + val wcagUri = rule.wcagCriteria.firstOrNull()?.let { + "https://www.w3.org/TR/WCAG22/#${criterionAnchor(it)}" + } + SarifRuleDescriptor( + id = rule.id, + name = rule.name, + shortDescription = SarifMessage(rule.description), + helpUri = wcagUri, + properties = SarifRuleProperties( + impact = rule.impact.label, + wcagCriteria = rule.wcagCriteria + ) + ) + } + + val results = diagnostics.map { diag -> + SarifResult( + ruleId = diag.ruleID, + level = when (diag.severity) { + A11ySeverity.ERROR -> "error" + A11ySeverity.WARNING -> "warning" + A11ySeverity.INFO -> "note" + }, + message = SarifMessage(diag.message), + locations = listOf( + SarifLocation( + SarifPhysicalLocation( + artifactLocation = SarifArtifactLocation(diag.filePath), + region = SarifRegion(diag.line, diag.column) + ) + ) + ) + ) + } + + val sarif = SarifOutput( + runs = listOf( + SarifRun( + tool = SarifTool(SarifDriver(rules = ruleDescriptors)), + results = results + ) + ) + ) + + return json.encodeToString(sarif) + } + + private fun criterionAnchor(criterion: String): String { + val anchors = mapOf( + "1.1.1" to "non-text-content", + "1.3.1" to "info-and-relationships", + "1.3.2" to "meaningful-sequence", + "1.3.5" to "identify-input-purpose", + "1.4.3" to "contrast-minimum", + "1.4.4" to "resize-text", + "2.1.1" to "keyboard", + "2.2.1" to "timing-adjustable", + "2.3.1" to "three-flashes-or-below-threshold", + "2.4.2" to "page-titled", + "2.4.3" to "focus-order", + "2.4.4" to "link-purpose-in-context", + "2.4.6" to "headings-and-labels", + "2.5.1" to "pointer-gestures", + "2.5.8" to "target-size-minimum", + "4.1.2" to "name-role-value" + ) + return anchors[criterion] ?: criterion.replace(".", "-") + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/formatters/TerminalFormatter.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/formatters/TerminalFormatter.kt new file mode 100644 index 0000000..ff53acf --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/formatters/TerminalFormatter.kt @@ -0,0 +1,98 @@ +package com.cvshealth.a11y.agent.formatters + +import com.cvshealth.a11y.agent.core.* + +object TerminalFormatter { + + private const val RESET = "\u001B[0m" + private const val BOLD = "\u001B[1m" + private const val DIM = "\u001B[2m" + private const val RED = "\u001B[31m" + private const val GREEN = "\u001B[32m" + private const val YELLOW = "\u001B[33m" + private const val CYAN = "\u001B[36m" + + fun format(diagnostics: List, score: A11yScore, compact: Boolean = false): String { + val sb = StringBuilder() + + if (diagnostics.isEmpty()) { + sb.appendLine("${GREEN}${BOLD}No accessibility issues found!$RESET") + } else { + // Group by file + val byFile = diagnostics.groupBy { it.filePath } + for ((filePath, fileDiags) in byFile.entries.sortedBy { it.key }) { + if (!compact) { + val shortPath = filePath.substringAfterLast("/src/") + sb.appendLine("\n${BOLD}$shortPath$RESET") + } + + for (diag in fileDiags.sortedBy { it.line }) { + val severityColor = when (diag.severity) { + A11ySeverity.ERROR -> RED + A11ySeverity.WARNING -> YELLOW + A11ySeverity.INFO -> CYAN + } + val marker = when (diag.severity) { + A11ySeverity.ERROR -> "\u2717" + A11ySeverity.WARNING -> "\u26A0" + A11ySeverity.INFO -> "\u2139" + } + val wcag = if (diag.wcagCriteria.isNotEmpty()) " [WCAG ${diag.wcagCriteria.joinToString(", ")}]" else "" + sb.appendLine(" $severityColor$marker ${diag.line}:${diag.column} ${diag.severity.label} [${diag.impact.label}]: ${diag.message}$wcag ($DIM${diag.ruleID}$RESET$severityColor)$RESET") + + diag.suggestion?.let { suggestion -> + sb.appendLine(" ${DIM}Suggestion: $suggestion$RESET") + } + } + } + } + + sb.appendLine(formatScoreSummary(score)) + return sb.toString() + } + + private fun formatScoreSummary(score: A11yScore): String { + val sb = StringBuilder() + val gradeColor = when { + score.score >= 80 -> GREEN + score.score >= 60 -> YELLOW + else -> RED + } + + sb.appendLine("\n${BOLD}Accessibility Score:$RESET") + + // Progress bar + val filled = (score.score / 5).toInt() + val empty = 20 - filled + val bar = "$gradeColor${"█".repeat(filled)}${DIM}${"░".repeat(empty)}$RESET" + sb.appendLine(" $bar ${gradeColor}${BOLD}${"%.1f".format(score.score)}/100 (${score.grade})$RESET") + + sb.appendLine() + sb.appendLine(" ${BOLD}Summary:$RESET") + sb.appendLine(" ${if (score.totalErrors > 0) RED else DIM}Errors: ${score.totalErrors}$RESET") + sb.appendLine(" ${if (score.totalWarnings > 0) YELLOW else DIM}Warnings: ${score.totalWarnings}$RESET") + sb.appendLine(" ${DIM}Info: ${score.totalInfo}$RESET") + sb.appendLine(" Files analyzed: ${score.filesAnalyzed}") + sb.appendLine(" WCAG criteria: ${score.criteriaPassed} passed, ${score.criteriaFailed} failed, ${score.criteriaNotChecked} not checked") + + // Failed criteria + val failedCriteria = score.criteriaScores.filter { it.status == CriterionStatus.FAIL } + if (failedCriteria.isNotEmpty()) { + sb.appendLine("\n ${RED}${BOLD}Failed WCAG Criteria:$RESET") + for (c in failedCriteria) { + sb.appendLine(" ${RED}\u2717 ${c.criterion} ${c.name} (${c.errorCount} errors)$RESET") + } + } + + // Review criteria + val reviewCriteria = score.criteriaScores.filter { it.status == CriterionStatus.REVIEW } + if (reviewCriteria.isNotEmpty()) { + sb.appendLine("\n ${YELLOW}${BOLD}Needs Review:$RESET") + for (c in reviewCriteria) { + sb.appendLine(" ${YELLOW}\u26A0 ${c.criterion} ${c.name} (${c.warningCount} warnings)$RESET") + } + } + + return sb.toString() + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/AnimationRules.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/AnimationRules.kt new file mode 100644 index 0000000..b362442 --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/AnimationRules.kt @@ -0,0 +1,79 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import com.cvshealth.a11y.agent.scanner.ParsedKotlinFile + +class ReduceMotionRule : A11yRule { + override val id = "reduce-motion" + override val name = "Reduce Motion Not Checked" + override val severity = A11ySeverity.WARNING + override val impact = A11yImpact.MODERATE + override val wcagCriteria = listOf("2.3.1") + override val description = "Animations should respect the user's reduced motion accessibility setting" + + private val animationCalls = setOf( + "AnimatedVisibility", "AnimatedContent", "Crossfade", + "animateFloatAsState", "animateColorAsState", "animateDpAsState", + "animateIntAsState", "animateContentSize" + ) + private val animationPatterns = listOf( + Regex("""animate\w+AsState"""), + Regex("""Animatable\s*\("""), + Regex("""infiniteTransition"""), + Regex("""rememberInfiniteTransition""") + ) + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + // Check detected calls + for (call in file.allCalls.filter { it.name in animationCalls }) { + val scope = call.enclosingScopeText + if (scope.contains("reduceMotion") || + scope.contains("isReduceMotionEnabled") || + scope.contains("LocalReduceMotion") || + scope.contains("prefersReducedMotion") || + scope.contains("ANIMATOR_DURATION_SCALE")) continue + + diagnostics.add( + makeDiagnostic( + message = "${call.name} does not check for reduced motion preference.", + line = call.line, + column = call.column, + context = context, + suggestion = "Check accessibility settings for reduced motion and provide a non-animated alternative" + ) + ) + } + + // Check line-level animation patterns + for ((index, line) in context.sourceLines.withIndex()) { + for (pattern in animationPatterns) { + if (!pattern.containsMatchIn(line)) continue + + // Avoid duplicate reports from detected calls + val alreadyReported = diagnostics.any { it.line == index + 1 } + if (alreadyReported) continue + + val start = maxOf(0, index - 10) + val end = minOf(context.sourceLines.size - 1, index + 10) + val scopeText = context.sourceLines.subList(start, end + 1).joinToString("\n") + + if (scopeText.contains("reduceMotion") || + scopeText.contains("isReduceMotionEnabled") || + scopeText.contains("ANIMATOR_DURATION_SCALE")) continue + + diagnostics.add( + makeDiagnostic( + message = "Animation pattern without reduced motion check.", + line = index + 1, + column = (pattern.find(line)?.range?.first ?: 0) + 1, + context = context, + suggestion = "Check accessibility settings for reduced motion and provide a non-animated alternative" + ) + ) + } + } + return diagnostics + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/ButtonRules.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/ButtonRules.kt new file mode 100644 index 0000000..71ef3fc --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/ButtonRules.kt @@ -0,0 +1,155 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import com.cvshealth.a11y.agent.scanner.ParsedKotlinFile + +class LabelContainsRoleButtonRule : A11yRule { + override val id = "label-contains-role-button" + override val name = "Button Label Contains Role Word" + override val severity = A11ySeverity.WARNING + override val impact = A11yImpact.MINOR + override val wcagCriteria = listOf("4.1.2") + override val description = "Button labels should not contain the word 'button' — TalkBack announces it" + + private val buttonCalls = setOf("Button", "TextButton", "OutlinedButton", + "FilledTonalButton", "ElevatedButton", "IconButton") + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for (call in file.allCalls.filter { it.name in buttonCalls }) { + // Check text content in the button body + val bodyText = call.rawArgumentText.lowercase() + if (bodyText.contains("\"") && Regex("""\bbutton\b""").containsMatchIn( + bodyText.replace(Regex("""onClick\s*=\s*\{[^}]*\}"""), ""))) { + diagnostics.add( + makeDiagnostic( + message = "Button label contains the word \"button\". TalkBack already announces the Button role.", + line = call.line, + column = call.column, + context = context, + suggestion = "Remove \"button\" from the label text" + ) + ) + } + } + return diagnostics + } +} + +class IconButtonMissingLabelRule : A11yRule { + override val id = "icon-button-missing-label" + override val name = "IconButton Missing Accessible Label" + override val severity = A11ySeverity.ERROR + override val impact = A11yImpact.CRITICAL + override val wcagCriteria = listOf("4.1.2") + override val description = "IconButton must have an accessible label via its Icon's contentDescription or semantics" + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for (call in file.allCalls.filter { it.name == "IconButton" }) { + // Check if the IconButton has semantics with contentDescription + if (call.hasSemanticsProperty("contentDescription")) continue + + // Check if there's an Icon inside with a non-null contentDescription + val bodyText = call.rawArgumentText + val iconPattern = Regex("""Icon\s*\([^)]*contentDescription\s*=\s*(?!null)""") + if (iconPattern.containsMatchIn(bodyText)) continue + + // Check for clearAndSetSemantics on the button itself + if (call.modifierChain.contains("clearAndSetSemantics")) continue + + val fix = computeIconButtonFix(call, context) + + diagnostics.add( + makeDiagnostic( + message = "IconButton has no accessible label. Set contentDescription on the Icon or add semantics { contentDescription = \"...\" } to the IconButton.", + line = call.line, + column = call.column, + context = context, + fix = fix, + suggestion = "Add contentDescription = stringResource(R.string.button_description) to the Icon inside the IconButton" + ) + ) + } + return diagnostics + } + + private fun computeIconButtonFix(call: com.cvshealth.a11y.agent.scanner.DetectedCall, context: RuleContext): A11yFix? { + // Find the Icon( call inside the IconButton body and insert contentDescription + val lineOffset = lineColumnToOffset(context.sourceText, call.line) + val bodyText = call.rawArgumentText + val iconIdx = bodyText.indexOf("Icon(") + if (iconIdx < 0) return null + + // Find the Icon call in the source text starting from the IconButton line + val searchArea = context.sourceText.substring( + lineOffset, + minOf(lineOffset + bodyText.length + 200, context.sourceText.length) + ) + val iconInSource = searchArea.indexOf("Icon(") + if (iconInSource < 0) return null + + // Find the closing paren of Icon() by counting depth + val absIconStart = lineOffset + iconInSource + 5 // past "Icon(" + var depth = 1 + var pos = absIconStart + while (pos < context.sourceText.length && depth > 0) { + when (context.sourceText[pos]) { + '(' -> depth++ + ')' -> depth-- + } + if (depth > 0) pos++ + } + // pos is now at the closing paren + val insertOffset = pos + return A11yFix( + description = "Add contentDescription to Icon in IconButton", + replacementText = ",\n contentDescription = stringResource(R.string.button_description)", + startOffset = insertOffset, + endOffset = insertOffset + ) + } +} + +class VisuallyDisabledNotSemanticallyRule : A11yRule { + override val id = "visually-disabled-not-semantically" + override val name = "Visually Disabled Not Semantically Disabled" + override val severity = A11ySeverity.WARNING + override val impact = A11yImpact.SERIOUS + override val wcagCriteria = listOf("4.1.2") + override val description = "Elements using alpha to appear disabled must also be semantically disabled" + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + val alphaPattern = Regex("""\.\s*alpha\s*\(\s*(0\.\d+f?)\s*\)""") + + for ((index, line) in context.sourceLines.withIndex()) { + val match = alphaPattern.find(line) ?: continue + val alphaValue = match.groupValues[1].removeSuffix("f").toDoubleOrNull() ?: continue + if (alphaValue >= 0.5) continue + + // Check surrounding context for enabled = false or disabled semantics + val start = maxOf(0, index - 5) + val end = minOf(context.sourceLines.size - 1, index + 5) + val surroundingText = context.sourceLines.subList(start, end + 1).joinToString("\n") + + if (surroundingText.contains("enabled = false") || + surroundingText.contains("enabled(false)") || + surroundingText.contains("disabled = true") || + surroundingText.contains(".disabled(")) continue + + diagnostics.add( + makeDiagnostic( + message = "Element uses alpha($alphaValue) to appear disabled but may not be semantically disabled for TalkBack.", + line = index + 1, + column = match.range.first + 1, + context = context, + suggestion = "Add Modifier.semantics { disabled() } or use enabled = false on the component" + ) + ) + } + return diagnostics + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/ClickableRules.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/ClickableRules.kt new file mode 100644 index 0000000..3c74861 --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/ClickableRules.kt @@ -0,0 +1,65 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import com.cvshealth.a11y.agent.scanner.ParsedKotlinFile + +class ClickableMissingRoleRule : A11yRule { + override val id = "clickable-missing-role" + override val name = "Clickable Missing Role or Click Label" + override val severity = A11ySeverity.WARNING + override val impact = A11yImpact.SERIOUS + override val wcagCriteria = listOf("4.1.2") + override val description = "Modifier.clickable() should include onClickLabel for accessible action description" + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + val clickablePattern = Regex("""\.\s*clickable\s*[({]""") + + for ((index, line) in context.sourceLines.withIndex()) { + if (!clickablePattern.containsMatchIn(line)) continue + + // Get surrounding context to check for onClickLabel + val start = maxOf(0, index) + val end = minOf(context.sourceLines.size - 1, index + 5) + val contextText = context.sourceLines.subList(start, end + 1).joinToString("\n") + + if (contextText.contains("onClickLabel")) continue + if (contextText.contains("role =")) continue + + // Skip if inside a known interactive composable + val lineText = line.trim() + if (lineText.startsWith("Button") || lineText.startsWith("IconButton") || + lineText.startsWith("TextButton")) continue + + val fix = computeClickableFix(index + 1, line, context) + + diagnostics.add( + makeDiagnostic( + message = "Modifier.clickable() without onClickLabel. TalkBack users won't know what the action does.", + line = index + 1, + column = (clickablePattern.find(line)?.range?.first ?: 0) + 1, + context = context, + fix = fix, + suggestion = "Add onClickLabel = \"action description\" to Modifier.clickable()" + ) + ) + } + return diagnostics + } + + private fun computeClickableFix(lineNumber: Int, line: String, context: RuleContext): A11yFix? { + val lineOffset = lineColumnToOffset(context.sourceText, lineNumber) + val searchArea = context.sourceText.substring( + lineOffset, + minOf(lineOffset + line.length + 100, context.sourceText.length) + ) + val match = Regex("""\.\s*clickable\s*\(""").find(searchArea) ?: return null + val insertOffset = lineOffset + match.range.last + 1 + return A11yFix( + description = "Add onClickLabel to clickable", + replacementText = "onClickLabel = \"TODO: describe action\", ", + startOffset = insertOffset, + endOffset = insertOffset + ) + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/ColorRules.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/ColorRules.kt new file mode 100644 index 0000000..02f5dce --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/ColorRules.kt @@ -0,0 +1,205 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import com.cvshealth.a11y.agent.scanner.ParsedKotlinFile + +class HardcodedColorRule : A11yRule { + override val id = "hardcoded-color" + override val name = "Hardcoded Color" + override val severity = A11ySeverity.WARNING + override val impact = A11yImpact.MODERATE + override val wcagCriteria = listOf("1.4.3") + override val description = "Avoid hardcoded colors — use MaterialTheme.colorScheme for dark mode and contrast support" + + private val hardcodedColors = setOf( + "Color.Black", "Color.White", "Color.Red", "Color.Green", "Color.Blue", + "Color.Yellow", "Color.Cyan", "Color.Magenta", "Color.Gray", + "Color.LightGray", "Color.DarkGray", "Color.Transparent" + ) + private val hexColorPattern = Regex("""Color\s*\(\s*0x[0-9A-Fa-f]+\s*\)""") + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for ((index, line) in context.sourceLines.withIndex()) { + val trimmed = line.trim() + if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue + // Skip color definitions in theme files + if (trimmed.contains("= Color(") && (trimmed.contains("val ") || trimmed.contains("private "))) continue + + for (color in hardcodedColors) { + if (line.contains(color)) { + diagnostics.add( + makeDiagnostic( + message = "Hardcoded color $color won't adapt to dark mode or high-contrast themes.", + line = index + 1, + column = line.indexOf(color) + 1, + context = context, + suggestion = "Use MaterialTheme.colorScheme.onSurface (or appropriate semantic color) instead" + ) + ) + break + } + } + + val hexMatch = hexColorPattern.find(line) + if (hexMatch != null && !trimmed.contains("val ") && !trimmed.contains("private ")) { + diagnostics.add( + makeDiagnostic( + message = "Hardcoded hex color ${hexMatch.value} won't adapt to dark mode or high-contrast themes.", + line = index + 1, + column = hexMatch.range.first + 1, + context = context, + suggestion = "Use MaterialTheme.colorScheme tokens instead of hardcoded hex values" + ) + ) + } + } + return diagnostics + } +} + +object ColorUtils { + private val namedColors = mapOf( + "Color.Black" to Triple(0, 0, 0), + "Color.DarkGray" to Triple(68, 68, 68), + "Color.Gray" to Triple(136, 136, 136), + "Color.LightGray" to Triple(192, 192, 192), + "Color.White" to Triple(255, 255, 255), + "Color.Red" to Triple(255, 0, 0), + "Color.Green" to Triple(0, 255, 0), + "Color.Blue" to Triple(0, 0, 255), + "Color.Yellow" to Triple(255, 255, 0), + "Color.Cyan" to Triple(0, 255, 255), + "Color.Magenta" to Triple(255, 0, 255), + "Color.Transparent" to Triple(0, 0, 0), + "Color.Unspecified" to null + ) + + private val hexPattern = Regex("""Color\s*\(\s*0x([0-9A-Fa-f]{6,8})\s*\)""") + + fun parseColor(expr: String): Triple? { + val trimmed = expr.trim() + + // Named color + for ((name, rgb) in namedColors) { + if (trimmed.contains(name)) return rgb + } + + // Hex color: Color(0xAARRGGBB) or Color(0xRRGGBB) + val hexMatch = hexPattern.find(trimmed) + if (hexMatch != null) { + val hex = hexMatch.groupValues[1] + val argb = hex.toLongOrNull(16) ?: return null + return if (hex.length == 8) { + Triple(((argb shr 16) and 0xFF).toInt(), ((argb shr 8) and 0xFF).toInt(), (argb and 0xFF).toInt()) + } else { + Triple(((argb shr 16) and 0xFF).toInt(), ((argb shr 8) and 0xFF).toInt(), (argb and 0xFF).toInt()) + } + } + return null + } + + private fun linearize(channel: Int): Double { + val sRGB = channel / 255.0 + return if (sRGB <= 0.04045) sRGB / 12.92 else Math.pow((sRGB + 0.055) / 1.055, 2.4) + } + + fun relativeLuminance(r: Int, g: Int, b: Int): Double { + return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b) + } + + fun contrastRatio(fg: Triple, bg: Triple): Double { + val l1 = relativeLuminance(fg.first, fg.second, fg.third) + val l2 = relativeLuminance(bg.first, bg.second, bg.third) + val lighter = maxOf(l1, l2) + val darker = minOf(l1, l2) + return (lighter + 0.05) / (darker + 0.05) + } +} + +class ColorContrastRule : A11yRule { + override val id = "color-contrast" + override val name = "Color Contrast" + override val severity = A11ySeverity.WARNING + override val impact = A11yImpact.SERIOUS + override val wcagCriteria = listOf("1.4.3") + override val description = "Hardcoded foreground/background color pairs should meet WCAG contrast requirements" + + private val colorArgPattern = Regex("""color\s*=\s*(.+?)(?:\s*[,)]|$)""") + private val backgroundPattern = Regex("""\.background\s*\(\s*(?:color\s*=\s*)?(.+?)\s*\)""") + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + val minRatio = context.configOptions.contrastRatio + + for (call in file.allCalls.filter { it.name == "Text" }) { + val colorArg = call.getArgument("color") ?: continue + val fgColor = ColorUtils.parseColor(colorArg) ?: continue + + // Search enclosing scope for background color + val bgMatch = backgroundPattern.find(call.enclosingScopeText ?: "") ?: continue + val bgExpr = bgMatch.groupValues[1] + val bgColor = ColorUtils.parseColor(bgExpr) ?: continue + + val ratio = ColorUtils.contrastRatio(fgColor, bgColor) + if (ratio < minRatio) { + diagnostics.add( + makeDiagnostic( + message = "Color contrast ratio %.2f:1 is below the minimum %.1f:1 (WCAG 1.4.3). Foreground: %s, Background: %s".format( + ratio, minRatio, colorArg.trim(), bgExpr.trim() + ), + line = call.line, + column = call.column, + context = context, + suggestion = "Use colors with a contrast ratio of at least %.1f:1, or use MaterialTheme.colorScheme tokens".format(minRatio) + ) + ) + } + } + + // Also check hardcoded color pairs in nearby lines (fallback heuristic) + val colorLinePattern = Regex("""(Color\.\w+|Color\s*\(\s*0x[0-9A-Fa-f]+\s*\))""") + for ((index, line) in context.sourceLines.withIndex()) { + val trimmed = line.trim() + if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue + + val matches = colorLinePattern.findAll(line).toList() + if (matches.isEmpty()) continue + + // Check surrounding lines for a second color + val start = maxOf(0, index - 5) + val end = minOf(context.sourceLines.size - 1, index + 5) + val nearby = context.sourceLines.subList(start, end + 1).joinToString("\n") + + for (match in matches) { + val fg = ColorUtils.parseColor(match.value) ?: continue + val nearbyColors = colorLinePattern.findAll(nearby).toList() + for (nearbyMatch in nearbyColors) { + if (nearbyMatch.value == match.value) continue + val bg = ColorUtils.parseColor(nearbyMatch.value) ?: continue + val ratio = ColorUtils.contrastRatio(fg, bg) + if (ratio < minRatio) { + // Deduplicate — only report from Text call checks or unique line + val alreadyReported = diagnostics.any { it.line == index + 1 && it.ruleID == id } + if (!alreadyReported) { + diagnostics.add( + makeDiagnostic( + message = "Potential low contrast %.2f:1 between %s and %s (minimum %.1f:1).".format( + ratio, match.value, nearbyMatch.value, minRatio + ), + line = index + 1, + column = match.range.first + 1, + context = context, + suggestion = "Use MaterialTheme.colorScheme colors which are designed to meet contrast requirements" + ) + ) + break + } + } + } + } + } + return diagnostics + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/DynamicTypeRules.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/DynamicTypeRules.kt new file mode 100644 index 0000000..67f248d --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/DynamicTypeRules.kt @@ -0,0 +1,72 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import com.cvshealth.a11y.agent.scanner.ParsedKotlinFile + +class FixedFontSizeRule : A11yRule { + override val id = "fixed-font-size" + override val name = "Fixed Font Size" + override val severity = A11ySeverity.WARNING + override val impact = A11yImpact.SERIOUS + override val wcagCriteria = listOf("1.4.4") + override val description = "Avoid hardcoded .sp font sizes — use MaterialTheme.typography styles for dynamic type support" + + private val spPattern = Regex("""(\d+)\.sp\b""") + private val excludePatterns = listOf("fontSize =", "TextStyle(", "typography") + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for ((index, line) in context.sourceLines.withIndex()) { + val match = spPattern.find(line) ?: continue + + // Skip if this is in a Typography/TextStyle definition (theme setup is fine) + val trimmed = line.trim() + if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue + if (trimmed.contains("TextStyle(")) continue + + // Check if it's a font size parameter on a Text/composable + if (trimmed.contains("fontSize") || trimmed.contains("size =")) { + diagnostics.add( + makeDiagnostic( + message = "Hardcoded font size ${match.value} — won't scale with user's text size settings.", + line = index + 1, + column = match.range.first + 1, + context = context, + suggestion = "Use MaterialTheme.typography.bodyLarge (or appropriate style) instead of fixed sp values" + ) + ) + } + } + return diagnostics + } +} + +class MaxLinesOneRule : A11yRule { + override val id = "max-lines-one" + override val name = "maxLines = 1 Text Truncation" + override val severity = A11ySeverity.INFO + override val impact = A11yImpact.MODERATE + override val wcagCriteria = listOf("1.4.4") + override val description = "Text with maxLines = 1 may truncate content when text size is increased" + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for (call in file.allCalls.filter { it.name == "Text" }) { + val maxLines = call.getArgument("maxLines") ?: continue + if (maxLines.trim() == "1") { + diagnostics.add( + makeDiagnostic( + message = "Text with maxLines = 1 may truncate content when the user increases text size.", + line = call.line, + column = call.column, + context = context, + suggestion = "Consider allowing text to wrap or using overflow = TextOverflow.Ellipsis with a tooltip" + ) + ) + } + } + return diagnostics + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/FormControlRules.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/FormControlRules.kt new file mode 100644 index 0000000..f72730f --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/FormControlRules.kt @@ -0,0 +1,127 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import com.cvshealth.a11y.agent.scanner.ParsedKotlinFile + +class TextFieldMissingLabelRule : A11yRule { + override val id = "textfield-missing-label" + override val name = "TextField Missing Label" + override val severity = A11ySeverity.ERROR + override val impact = A11yImpact.CRITICAL + override val wcagCriteria = listOf("4.1.2") + override val description = "TextField and OutlinedTextField must have a label parameter for accessibility" + + private val textFieldCalls = setOf("TextField", "OutlinedTextField") + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for (call in file.allCalls.filter { it.name in textFieldCalls }) { + val label = call.getArgument("label") + + if (label == null || label.isBlank() || label.trim() == "{}") { + // Check for alternative labeling + if (call.hasSemanticsProperty("contentDescription")) continue + if (call.enclosingScopeText.contains("LabeledContent")) continue + if (call.modifierChain.contains("clearAndSetSemantics")) continue + + val fix = computeTextFieldFix(call, context) + + diagnostics.add( + makeDiagnostic( + message = "${call.name} is missing a label parameter. TalkBack users won't know what input is expected.", + line = call.line, + column = call.column, + context = context, + fix = fix, + suggestion = "Add label = { Text(\"Field label\") } to the ${call.name}" + ) + ) + } + } + return diagnostics + } + + private fun computeTextFieldFix(call: com.cvshealth.a11y.agent.scanner.DetectedCall, context: RuleContext): A11yFix? { + val lineOffset = lineColumnToOffset(context.sourceText, call.line) + val callStart = context.sourceText.indexOf(call.name + "(", lineOffset) + if (callStart < 0) return null + + // Find the opening paren + val openParen = callStart + call.name.length + if (openParen >= context.sourceText.length || context.sourceText[openParen] != '(') return null + + // Insert label as first named argument after the opening paren + val insertOffset = openParen + 1 + return A11yFix( + description = "Add label parameter to ${call.name}", + replacementText = "\n label = { Text(\"TODO: add label\") },", + startOffset = insertOffset, + endOffset = insertOffset + ) + } +} + +class SliderMissingLabelRule : A11yRule { + override val id = "slider-missing-label" + override val name = "Slider Missing Label" + override val severity = A11ySeverity.ERROR + override val impact = A11yImpact.SERIOUS + override val wcagCriteria = listOf("4.1.2") + override val description = "Slider must have an associated label via semantics or wrapping composable" + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for (call in file.allCalls.filter { it.name in setOf("Slider", "RangeSlider") }) { + if (call.hasSemanticsProperty("contentDescription")) continue + if (call.modifierChain.contains("clearAndSetSemantics")) continue + if (call.enclosingScopeText.contains("LabeledContent")) continue + + // Check if there's nearby text that could be a label via mergeDescendants + if (call.enclosingScopeText.contains("mergeDescendants")) continue + + diagnostics.add( + makeDiagnostic( + message = "${call.name} has no accessible label. TalkBack users won't know what the slider controls.", + line = call.line, + column = call.column, + context = context, + suggestion = "Wrap with a visible Text label (e.g., Column with Text + Slider, or LabeledContent). Fallback: add Modifier.semantics { contentDescription = \"...\" }" + ) + ) + } + return diagnostics + } +} + +class DropdownMissingLabelRule : A11yRule { + override val id = "dropdown-missing-label" + override val name = "Dropdown Menu Missing Label" + override val severity = A11ySeverity.ERROR + override val impact = A11yImpact.SERIOUS + override val wcagCriteria = listOf("4.1.2") + override val description = "ExposedDropdownMenuBox must have an associated label" + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for (call in file.allCalls.filter { it.name == "ExposedDropdownMenuBox" }) { + // Check if the body contains a TextField with a label + val bodyText = call.rawArgumentText + if (bodyText.contains("label =") && bodyText.contains("TextField")) continue + if (call.hasSemanticsProperty("contentDescription")) continue + + diagnostics.add( + makeDiagnostic( + message = "ExposedDropdownMenuBox has no accessible label. Include a labeled TextField inside it.", + line = call.line, + column = call.column, + context = context, + suggestion = "Add a TextField with label = { Text(\"Selection\") } inside the dropdown" + ) + ) + } + return diagnostics + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/GroupingRules.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/GroupingRules.kt new file mode 100644 index 0000000..df21f15 --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/GroupingRules.kt @@ -0,0 +1,150 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import com.cvshealth.a11y.agent.scanner.ParsedKotlinFile + +class AccessibilityGroupingRule : A11yRule { + override val id = "accessibility-grouping" + override val name = "Accessibility Grouping Missing" + override val severity = A11ySeverity.INFO + override val impact = A11yImpact.MODERATE + override val wcagCriteria = listOf("1.3.1") + override val description = "Row/Column containing Icon + Text should use semantics(mergeDescendants = true) for grouping" + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for (call in file.allCalls.filter { it.name in setOf("Row", "Column") }) { + val body = call.rawArgumentText + + // Check if it contains both an Icon and Text + val hasIcon = body.contains("Icon(") || body.contains("Image(") + val hasText = body.contains("Text(") + if (!hasIcon || !hasText) continue + + // Skip if already has mergeDescendants or clearAndSetSemantics + if (call.modifierChain.contains("mergeDescendants") || + call.modifierChain.contains("clearAndSetSemantics") || + body.contains("mergeDescendants")) continue + + // Skip if the Row/Column is inside a Button or clickable + if (call.enclosingCallName in setOf("Button", "TextButton", "IconButton", + "OutlinedButton", "Card", "ElevatedCard")) continue + if (call.enclosingScopeText.contains(".clickable(")) continue + if (call.enclosingScopeText.contains(".toggleable(")) continue + + diagnostics.add( + makeDiagnostic( + message = "${call.name} contains Icon and Text but lacks mergeDescendants. TalkBack will announce them as separate elements.", + line = call.line, + column = call.column, + context = context, + suggestion = "Add Modifier.semantics(mergeDescendants = true) { } to the ${call.name}" + ) + ) + } + return diagnostics + } +} + +class HiddenWithInteractiveChildrenRule : A11yRule { + override val id = "hidden-with-interactive-children" + override val name = "Hidden Semantics with Interactive Children" + override val severity = A11ySeverity.ERROR + override val impact = A11yImpact.CRITICAL + override val wcagCriteria = listOf("4.1.2") + override val description = "clearAndSetSemantics hides all child semantics including interactive controls" + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + val interactiveChildren = setOf("Button", "TextButton", "IconButton", "OutlinedButton", + "Checkbox", "Switch", "RadioButton", "Slider", "TextField", "OutlinedTextField") + + for (call in file.allCalls) { + if (!call.modifierChain.contains("clearAndSetSemantics")) continue + + val body = call.rawArgumentText + val hasInteractive = interactiveChildren.any { body.contains("$it(") } + if (!hasInteractive) continue + + diagnostics.add( + makeDiagnostic( + message = "clearAndSetSemantics on ${call.name} hides interactive child controls from TalkBack.", + line = call.line, + column = call.column, + context = context, + suggestion = "Use semantics(mergeDescendants = true) instead, or ensure the replacement contentDescription covers all child functionality" + ) + ) + } + return diagnostics + } +} + +class BoxChildOrderRule : A11yRule { + override val id = "box-child-order" + override val name = "Box Child Reading Order" + override val severity = A11ySeverity.INFO + override val impact = A11yImpact.MODERATE + override val wcagCriteria = listOf("1.3.2") + override val description = "Box children are read by TalkBack in declaration order — ensure visual and reading order match" + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for (call in file.allCalls.filter { it.name == "Box" }) { + val body = call.rawArgumentText + // Detect when both align and zIndex are used, suggesting overlapping content + if (body.contains(".align(") && body.contains(".zIndex(")) { + // Check for traversalIndex to override reading order + if (body.contains("traversalIndex") || body.contains("isTraversalGroup")) continue + + diagnostics.add( + makeDiagnostic( + message = "Box uses both align and zIndex with overlapping children. TalkBack reading order may not match visual order.", + line = call.line, + column = call.column, + context = context, + suggestion = "Use semantics { isTraversalGroup = true } and traversalIndex to control TalkBack reading order" + ) + ) + } + } + return diagnostics + } +} + +class RadioGroupMissingRule : A11yRule { + override val id = "radio-group-missing" + override val name = "RadioButton Not in SelectableGroup" + override val severity = A11ySeverity.ERROR + override val impact = A11yImpact.SERIOUS + override val wcagCriteria = listOf("1.3.1", "4.1.2") + override val description = "RadioButton groups should use Modifier.selectableGroup() on the parent Column/Row" + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for (call in file.allCalls.filter { it.name == "RadioButton" }) { + // Check if there's a selectableGroup in the enclosing scope + if (call.enclosingScopeText.contains("selectableGroup")) continue + + // Check if inside a RadioButtonGroup component + if (call.enclosingCallName == "RadioButtonGroup") continue + + // Check if the RadioButton is properly wrapped with selectable + if (call.enclosingScopeText.contains(".selectable(")) continue + + diagnostics.add( + makeDiagnostic( + message = "RadioButton is not inside a selectableGroup(). TalkBack won't announce the group relationship.", + line = call.line, + column = call.column, + context = context, + suggestion = "Wrap RadioButtons in Column(Modifier.selectableGroup()) and use Row(Modifier.selectable(role = Role.RadioButton))" + ) + ) + } + return diagnostics + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/HeadingRules.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/HeadingRules.kt new file mode 100644 index 0000000..2483d68 --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/HeadingRules.kt @@ -0,0 +1,152 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import com.cvshealth.a11y.agent.scanner.ParsedKotlinFile + +class HeadingSemanticsMissingRule : A11yRule { + override val id = "heading-semantics-missing" + override val name = "Heading Semantics Missing" + override val severity = A11ySeverity.WARNING + override val impact = A11yImpact.SERIOUS + override val wcagCriteria = listOf("2.4.6", "1.3.1") + override val description = "Text using heading typography should have semantics { heading() }" + + private val headingStyles = setOf( + "headlineLarge", "headlineMedium", "headlineSmall", + "titleLarge", "titleMedium", "titleSmall", + "displayLarge", "displayMedium", "displaySmall" + ) + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for (call in file.allCalls.filter { it.name == "Text" }) { + val styleArg = call.getArgument("style") ?: continue + + val hasHeadingStyle = headingStyles.any { styleArg.contains(it) } + if (!hasHeadingStyle) continue + + // Skip if inside a known heading wrapper + if (call.enclosingCallName in setOf("SimpleHeading", "GoodExampleHeading", + "BadExampleHeading", "OkExampleHeading", "ProblematicExampleHeading")) continue + + // Skip if inside Button/Link/Toggle + if (call.enclosingCallName in setOf("Button", "TextButton", "IconButton", + "OutlinedButton", "Checkbox", "Switch", "RadioButton")) continue + + // Check for heading() in semantics on the modifier chain or enclosing scope + if (call.hasSemanticsProperty("heading")) continue + if (call.enclosingScopeText.contains("heading()")) continue + + val fix = computeHeadingFix(call, context) + + diagnostics.add( + makeDiagnostic( + message = "Text uses heading typography (${styleArg.substringAfterLast(".")}) but lacks semantics { heading() }", + line = call.line, + column = call.column, + context = context, + fix = fix, + suggestion = "Add Modifier.semantics { heading() } to the Text or its container" + ) + ) + } + return diagnostics + } + + private fun computeHeadingFix(call: com.cvshealth.a11y.agent.scanner.DetectedCall, context: RuleContext): A11yFix? { + val lineOffset = lineColumnToOffset(context.sourceText, call.line) + val callStart = context.sourceText.indexOf("Text(", lineOffset) + if (callStart < 0) return null + + val modifierArg = call.getArgument("modifier") + if (modifierArg != null) { + // Append .semantics { heading() } to existing modifier chain + val searchArea = context.sourceText.substring(callStart) + val modIdx = searchArea.indexOf("modifier") + if (modIdx >= 0) { + // Find the end of the modifier expression (look for comma or next named arg) + val absModStart = callStart + modIdx + val modValueStart = context.sourceText.indexOf("=", absModStart) + if (modValueStart >= 0) { + // Walk to find the "Modifier" token start and append after the chain + val afterEquals = modValueStart + 1 + // Find the next comma or closing paren at the same depth + var depth = 0 + var endOfModifier = afterEquals + for (i in afterEquals until context.sourceText.length) { + when (context.sourceText[i]) { + '(' -> depth++ + ')' -> { + if (depth == 0) { endOfModifier = i; break } + depth-- + } + '{' -> depth++ + '}' -> { + if (depth == 0) { endOfModifier = i; break } + depth-- + } + ',' -> if (depth == 0) { endOfModifier = i; break } + } + } + // Insert .semantics { heading() } before the comma/paren + val insertBefore = endOfModifier + // Skip trailing whitespace backward + var insertPoint = insertBefore + while (insertPoint > afterEquals && context.sourceText[insertPoint - 1].isWhitespace() && context.sourceText[insertPoint - 1] != '\n') { + insertPoint-- + } + return A11yFix( + description = "Append .semantics { heading() } to modifier chain", + replacementText = ".semantics { heading() }", + startOffset = insertPoint, + endOffset = insertPoint + ) + } + } + } else { + // No modifier argument — insert modifier = Modifier.semantics { heading() } as first arg + val openParen = callStart + "Text".length + if (openParen < context.sourceText.length && context.sourceText[openParen] == '(') { + val insertOffset = openParen + 1 + return A11yFix( + description = "Add modifier with semantics { heading() }", + replacementText = "\n modifier = Modifier.semantics { heading() },", + startOffset = insertOffset, + endOffset = insertOffset + ) + } + } + return null + } +} + +class FakeHeadingInLabelRule : A11yRule { + override val id = "fake-heading-in-label" + override val name = "Fake Heading in Label" + override val severity = A11ySeverity.WARNING + override val impact = A11yImpact.MODERATE + override val wcagCriteria = listOf("1.3.1") + override val description = "Content descriptions should not contain the word 'heading'" + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + val headingPattern = Regex("""contentDescription\s*=\s*"[^"]*\bheading\b[^"]*"""", RegexOption.IGNORE_CASE) + + for (line in context.sourceLines.withIndex()) { + val match = headingPattern.find(line.value) + if (match != null) { + diagnostics.add( + makeDiagnostic( + message = "Content description contains 'heading'. Use semantics { heading() } instead of including role in the label.", + line = line.index + 1, + column = match.range.first + 1, + context = context, + suggestion = "Remove 'heading' from contentDescription and add semantics { heading() }" + ) + ) + } + } + return diagnostics + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/ImageRules.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/ImageRules.kt new file mode 100644 index 0000000..fd5055f --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/ImageRules.kt @@ -0,0 +1,170 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import com.cvshealth.a11y.agent.scanner.ParsedKotlinFile + +class IconMissingLabelRule : A11yRule { + override val id = "icon-missing-label" + override val name = "Icon Missing Content Description" + override val severity = A11ySeverity.ERROR + override val impact = A11yImpact.CRITICAL + override val wcagCriteria = listOf("1.1.1") + override val description = "Icon composables must have a non-null contentDescription or be explicitly decorative" + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for (call in file.allCalls.filter { it.name == "Icon" }) { + val contentDesc = call.getArgument("contentDescription") + ?: call.getArgument("__pos_1") // 2nd positional arg + + // null contentDescription is the problem + if (contentDesc == null || contentDesc.trim() == "null") { + // Skip if inside IconButton/Button that provides its own label + if (call.enclosingCallName in setOf("IconButton", "Button", "TextButton", "OutlinedButton", + "FilledTonalButton", "ElevatedButton", "FloatingActionButton", "ExtendedFloatingActionButton")) { + // Only skip if the parent button provides an accessible label elsewhere + continue + } + + // Skip if explicitly hidden from accessibility + if (call.hasSemanticsProperty("invisibleToUser") || + call.modifierChain.contains("clearAndSetSemantics")) { + continue + } + + // Compute auto-fix + val fix = computeIconFix(call, contentDesc, context) + + diagnostics.add( + makeDiagnostic( + message = "Icon is missing contentDescription. Provide a descriptive label or use null for decorative icons inside labeled containers.", + line = call.line, + column = call.column, + context = context, + fix = fix, + suggestion = "Add contentDescription = stringResource(R.string.description) to the Icon" + ) + ) + } + } + return diagnostics + } + + private fun computeIconFix(call: com.cvshealth.a11y.agent.scanner.DetectedCall, contentDesc: String?, context: RuleContext): A11yFix? { + val lineOffset = lineColumnToOffset(context.sourceText, call.line) + val callLine = context.sourceLines.getOrNull(call.line - 1) ?: return null + val replacement = "stringResource(R.string.icon_description)" + + if (contentDesc?.trim() == "null") { + // Replace `contentDescription = null` with a real value + val searchStart = lineOffset + val searchArea = context.sourceText.substring(searchStart, minOf(searchStart + callLine.length + 200, context.sourceText.length)) + val idx = searchArea.indexOf("contentDescription") + if (idx >= 0) { + val nullIdx = searchArea.indexOf("null", idx + "contentDescription".length) + if (nullIdx >= 0) { + val absStart = searchStart + nullIdx + return A11yFix( + description = "Replace null contentDescription with $replacement", + replacementText = replacement, + startOffset = absStart, + endOffset = absStart + 4 + ) + } + } + } else { + // No contentDescription argument — insert one before closing paren + val searchStart = lineOffset + val rawText = call.rawArgumentText + // Find the closing paren of the call + val callStart = context.sourceText.indexOf(call.name + "(", searchStart) + if (callStart >= 0) { + // Find matching close paren + var depth = 0 + var closeIdx = -1 + for (i in (callStart + call.name.length) until context.sourceText.length) { + when (context.sourceText[i]) { + '(' -> depth++ + ')' -> { depth--; if (depth == 0) { closeIdx = i; break } } + } + } + if (closeIdx > 0) { + return A11yFix( + description = "Add contentDescription parameter", + replacementText = ",\n contentDescription = $replacement)", + startOffset = closeIdx, + endOffset = closeIdx + 1 + ) + } + } + } + return null + } +} + +class LabelContainsRoleImageRule : A11yRule { + override val id = "label-contains-role-image" + override val name = "Content Description Contains Role Name" + override val severity = A11ySeverity.WARNING + override val impact = A11yImpact.MINOR + override val wcagCriteria = listOf("1.1.1") + override val description = "Content descriptions should not contain role words like 'image', 'icon', 'picture'" + + private val roleWords = listOf("image", "icon", "picture", "graphic", "photo") + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for (call in file.allCalls.filter { it.name == "Icon" || it.name == "Image" }) { + val contentDesc = call.getArgument("contentDescription") ?: continue + val descLower = contentDesc.lowercase() + + for (word in roleWords) { + if (descLower.contains(word) && descLower != "null") { + diagnostics.add( + makeDiagnostic( + message = "Content description contains the role word \"$word\". TalkBack already announces the element type.", + line = call.line, + column = call.column, + context = context, + suggestion = "Remove \"$word\" from the contentDescription — describe what the image shows, not what it is" + ) + ) + break + } + } + } + return diagnostics + } +} + +class EmptyContentDescriptionRule : A11yRule { + override val id = "empty-content-description" + override val name = "Empty Content Description String" + override val severity = A11ySeverity.ERROR + override val impact = A11yImpact.SERIOUS + override val wcagCriteria = listOf("1.1.1") + override val description = "contentDescription should be null for decorative elements, never an empty string" + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for (call in file.allCalls.filter { it.name in setOf("Icon", "Image") }) { + val contentDesc = call.getArgument("contentDescription") + ?: call.getArgument("__pos_1") + if (contentDesc != null && (contentDesc.trim() == "\"\"" || contentDesc.trim() == "\"\".toString()")) { + diagnostics.add( + makeDiagnostic( + message = "Empty string contentDescription makes the element focusable by TalkBack but announces nothing. Use null for decorative elements.", + line = call.line, + column = call.column, + context = context, + suggestion = "Change contentDescription = \"\" to contentDescription = null" + ) + ) + } + } + return diagnostics + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/LabelInNameRules.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/LabelInNameRules.kt new file mode 100644 index 0000000..f2c291e --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/LabelInNameRules.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import com.cvshealth.a11y.agent.scanner.DetectedCall +import com.cvshealth.a11y.agent.scanner.ParsedKotlinFile + +/** + * WCAG 2.5.3 Label in Name (Level A) + * + * Flags composables where the accessible name (contentDescription) does not + * contain the visible text label. Voice Control / Voice Access users speak + * the visible text to activate controls — if the accessible name diverges, + * they cannot interact with the element. + * + * ERROR — visible text is not contained in the accessible name at all + * WARNING — visible text is present but does not appear at the start + */ +class LabelInNameRule : A11yRule { + override val id = "label-in-name" + override val name = "Label in Name" + override val severity = A11ySeverity.ERROR + override val impact = A11yImpact.SERIOUS + override val wcagCriteria = listOf("2.5.3") + override val description = + "Visible text label must be contained in the accessible name (contentDescription). " + + "Voice Control users speak the visible text to activate controls." + + private val targetCalls = setOf( + "Button", "TextButton", "OutlinedButton", + "FilledTonalButton", "ElevatedButton", "IconButton", + "Tab", "LeadingIconTab" + ) + + private val textCallPattern = Regex("""Text\s*\(\s*"((?:[^"\\]|\\.)*)"""") + private val contentDescPattern = Regex("""contentDescription\s*=\s*"((?:[^"\\]|\\.)*)"""") + private val stringLiteralPattern = Regex("""^"((?:[^"\\]|\\.)*)"$""") + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for (call in file.allCalls.filter { it.name in targetCalls }) { + // Skip hidden elements + if (call.hasSemanticsProperty("invisibleToUser") || + call.modifierChain.contains("clearAndSetSemantics")) { + continue + } + + val visibleText = extractVisibleText(call) ?: continue + val accessibleName = extractAccessibleName(call) ?: continue + + val visibleLower = visibleText.lowercase().trim() + val accessibleLower = accessibleName.lowercase().trim() + + if (visibleLower.isBlank() || accessibleLower.isBlank()) continue + + if (!accessibleLower.contains(visibleLower)) { + diagnostics.add( + makeDiagnostic( + message = "${call.name} visible text \"$visibleText\" is not contained in " + + "contentDescription \"$accessibleName\". Voice Control users speak " + + "the visible text to activate controls.", + line = call.line, + column = call.column, + context = context, + severityOverride = A11ySeverity.ERROR, + suggestion = "Change contentDescription to include \"$visibleText\" " + + "or start with it, e.g. contentDescription = \"$visibleText\"" + ) + ) + } else if (!accessibleLower.startsWith(visibleLower)) { + diagnostics.add( + makeDiagnostic( + message = "${call.name} visible text \"$visibleText\" appears in " + + "contentDescription \"$accessibleName\" but not at the start. " + + "WCAG 2.5.3 recommends the accessible name begins with the visible text.", + line = call.line, + column = call.column, + context = context, + severityOverride = A11ySeverity.WARNING, + suggestion = "Reorder contentDescription to start with \"$visibleText\"" + ) + ) + } + } + return diagnostics + } + + /** + * Extract visible text from the composable call. + * Looks for Text("...") calls in the raw argument text, enclosing scope, + * or the text= argument lambda. + */ + private fun extractVisibleText(call: DetectedCall): String? { + // For Tab: check the text argument which is a lambda like { Text("Home") } + val textArg = call.getArgument("text") + if (textArg != null) { + val match = textCallPattern.find(textArg) + if (match != null) return match.groupValues[1] + } + + // Check raw argument text first (inline content) + val rawMatch = textCallPattern.find(call.rawArgumentText) + if (rawMatch != null) return rawMatch.groupValues[1] + + // For Button variants: trailing lambda content is in enclosingScopeText + // but not in rawArgumentText. Search the scope text for Text("...") + // appearing after this call's line. + val scopeMatch = textCallPattern.find(call.enclosingScopeText) + if (scopeMatch != null) return scopeMatch.groupValues[1] + + return null + } + + /** + * Extract the accessible name from contentDescription argument or semantics block. + * Returns null if the value is non-literal (resource ref, variable) or absent. + */ + private fun extractAccessibleName(call: DetectedCall): String? { + // Check direct contentDescription argument + val directArg = call.getArgument("contentDescription") + if (directArg != null && directArg.trim() != "null") { + val literal = stringLiteralPattern.find(directArg.trim()) + if (literal != null) return literal.groupValues[1] + // Non-literal (e.g., stringResource(...)) — skip + return null + } + + // Check semantics block in modifier chain + val semanticsBlock = call.getModifierBlock("semantics") ?: return null + val match = contentDescPattern.find(semanticsBlock) ?: return null + return match.groupValues[1] + + // Also check enclosing scope for contentDescription set externally + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/LinkRules.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/LinkRules.kt new file mode 100644 index 0000000..d55ccaf --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/LinkRules.kt @@ -0,0 +1,92 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import com.cvshealth.a11y.agent.scanner.ParsedKotlinFile + +class GenericLinkTextRule : A11yRule { + override val id = "generic-link-text" + override val name = "Generic Link Text" + override val severity = A11ySeverity.WARNING + override val impact = A11yImpact.MODERATE + override val wcagCriteria = listOf("2.4.4") + override val description = "Clickable elements should have descriptive labels, not generic text" + + private val genericTexts = setOf( + "click here", "here", "learn more", "read more", "more", + "tap here", "press here", "go", "link", "see more", + "more info", "details", "continue" + ) + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for (call in file.allCalls) { + if (!call.modifierChain.contains(".clickable(") && + call.name !in setOf("ClickableText", "TextButton")) continue + + // Extract text content + val textContent = call.getArgument("__pos_0") + ?: call.getArgument("text") + ?: call.rawArgumentText + + for (generic in genericTexts) { + val pattern = Regex(""""${Regex.escape(generic)}"""", RegexOption.IGNORE_CASE) + if (pattern.containsMatchIn(textContent)) { + diagnostics.add( + makeDiagnostic( + message = "Clickable element uses generic text \"$generic\". Provide descriptive text that explains the destination or action.", + line = call.line, + column = call.column, + context = context, + suggestion = "Replace generic text with a description of where the link goes or what it does" + ) + ) + break + } + } + } + return diagnostics + } +} + +class ButtonUsedAsLinkRule : A11yRule { + override val id = "button-used-as-link" + override val name = "Button Used as Link" + override val severity = A11ySeverity.INFO + override val impact = A11yImpact.MINOR + override val wcagCriteria = listOf("2.4.4") + override val description = "Buttons that navigate to URLs should use link semantics" + + private val urlPatterns = listOf( + Regex("""Uri\.parse"""), + Regex("""Intent\.ACTION_VIEW"""), + Regex("""CustomTabsIntent"""), + Regex("""openUri"""), + Regex("""https?://"""), + Regex("""uriHandler\.openUri""") + ) + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + val buttonCalls = setOf("Button", "TextButton", "OutlinedButton", + "FilledTonalButton", "ElevatedButton") + + for (call in file.allCalls.filter { it.name in buttonCalls }) { + val body = call.rawArgumentText + call.enclosingScopeText + val hasUrl = urlPatterns.any { it.containsMatchIn(body) } + if (!hasUrl) continue + + diagnostics.add( + makeDiagnostic( + message = "Button appears to open a URL. Consider adding link role semantics for TalkBack.", + line = call.line, + column = call.column, + context = context, + suggestion = "Add Modifier.semantics { role = Role.Link } or use clickable(onClickLabel = \"Opens in browser\")" + ) + ) + } + return diagnostics + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/PaneTitleRules.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/PaneTitleRules.kt new file mode 100644 index 0000000..22fab72 --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/PaneTitleRules.kt @@ -0,0 +1,73 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import com.cvshealth.a11y.agent.scanner.ParsedKotlinFile + +class MissingPaneTitleRule : A11yRule { + override val id = "missing-pane-title" + override val name = "Missing Pane Title" + override val severity = A11ySeverity.WARNING + override val impact = A11yImpact.SERIOUS + override val wcagCriteria = listOf("2.4.2") + override val description = "Scaffold should have semantics { paneTitle = ... } for screen change announcements" + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for (call in file.allCalls.filter { it.name == "Scaffold" }) { + // Check modifier chain for paneTitle semantics + if (call.hasSemanticsProperty("paneTitle")) continue + if (call.modifierChain.contains("paneTitle")) continue + + // Check if inside a known scaffold wrapper + if (call.enclosingCallName in setOf("GenericScaffold")) continue + + diagnostics.add( + makeDiagnostic( + message = "Scaffold is missing paneTitle semantics. TalkBack won't announce screen changes.", + line = call.line, + column = call.column, + context = context, + suggestion = "Add modifier = Modifier.semantics { paneTitle = screenTitle } to the Scaffold" + ) + ) + } + return diagnostics + } +} + +class TabMissingLabelRule : A11yRule { + override val id = "tab-missing-label" + override val name = "Tab Missing Label" + override val severity = A11ySeverity.WARNING + override val impact = A11yImpact.SERIOUS + override val wcagCriteria = listOf("2.4.2", "4.1.2") + override val description = "Tab items should have accessible labels describing their purpose" + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for (call in file.allCalls.filter { it.name in setOf("Tab", "LeadingIconTab") }) { + // Check if tab has text content + val text = call.getArgument("text") + if (text != null && text.trim() != "null" && text.trim().isNotEmpty()) continue + + // Check for contentDescription in semantics + if (call.hasSemanticsProperty("contentDescription")) continue + + // Check for a Text composable in the body + if (call.rawArgumentText.contains("Text(")) continue + + diagnostics.add( + makeDiagnostic( + message = "Tab has no accessible label. TalkBack users won't know what this tab represents.", + line = call.line, + column = call.column, + context = context, + suggestion = "Add text = { Text(\"Tab name\") } or semantics { contentDescription = \"...\" }" + ) + ) + } + return diagnostics + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/TimingRules.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/TimingRules.kt new file mode 100644 index 0000000..3b70bf2 --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/TimingRules.kt @@ -0,0 +1,179 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import com.cvshealth.a11y.agent.scanner.ParsedKotlinFile + +class TimingAdjustableRule : A11yRule { + override val id = "timing-adjustable" + override val name = "Timing Not Adjustable" + override val severity = A11ySeverity.WARNING + override val impact = A11yImpact.SERIOUS + override val wcagCriteria = listOf("2.2.1") + override val description = "Timed content should be adjustable, extendable, or removable by the user" + + private val timingPatterns = listOf( + Regex("""delay\s*\(\s*\d+"""), + Regex("""withTimeout\s*\(\s*\d+"""), + Regex("""LaunchedEffect.*delay"""), + Regex("""postDelayed"""), + Regex("""CountDownTimer"""), + Regex("""Timer\s*\(""") + ) + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for ((index, line) in context.sourceLines.withIndex()) { + for (pattern in timingPatterns) { + if (!pattern.containsMatchIn(line)) continue + + val start = maxOf(0, index - 10) + val end = minOf(context.sourceLines.size - 1, index + 10) + val scope = context.sourceLines.subList(start, end + 1).joinToString("\n") + + // Skip if there's user control over the timing + if (scope.contains("dismiss") || scope.contains("cancel") || + scope.contains("extend") || scope.contains("pause") || + scope.contains("userAction") || scope.contains("Snackbar")) continue + + diagnostics.add( + makeDiagnostic( + message = "Timed operation detected. Users must be able to extend, adjust, or disable time limits.", + line = index + 1, + column = (pattern.find(line)?.range?.first ?: 0) + 1, + context = context, + suggestion = "Ensure users can extend or disable the time limit (WCAG 2.2.1)" + ) + ) + break + } + } + return diagnostics + } +} + +class DialogFocusManagementRule : A11yRule { + override val id = "dialog-focus-management" + override val name = "Dialog Focus Management" + override val severity = A11ySeverity.INFO + override val impact = A11yImpact.MODERATE + override val wcagCriteria = listOf("2.4.3") + override val description = "Dialogs and bottom sheets should manage focus for keyboard and TalkBack users" + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + val dialogCalls = setOf("AlertDialog", "Dialog", "ModalBottomSheet", "BottomSheetScaffold") + + for (call in file.allCalls.filter { it.name in dialogCalls }) { + // Check for focus management + val scope = call.rawArgumentText + call.enclosingScopeText + if (scope.contains("FocusRequester") || + scope.contains("requestFocus") || + scope.contains("focusRequester") || + scope.contains("onDismissRequest")) continue + + diagnostics.add( + makeDiagnostic( + message = "${call.name} may need explicit focus management for TalkBack and keyboard users.", + line = call.line, + column = call.column, + context = context, + suggestion = "Use FocusRequester to move focus into the dialog when it opens and return focus when it closes" + ) + ) + } + return diagnostics + } +} + +class GestureMissingAlternativeRule : A11yRule { + override val id = "gesture-missing-alternative" + override val name = "Custom Gesture Missing Alternative" + override val severity = A11ySeverity.WARNING + override val impact = A11yImpact.SERIOUS + override val wcagCriteria = listOf("2.1.1", "2.5.1") + override val description = "Custom gestures must have keyboard/TalkBack-accessible alternatives" + + private val gesturePatterns = listOf( + Regex("""pointerInput\s*\("""), + Regex("""detectDragGestures"""), + Regex("""detectTapGestures"""), + Regex("""detectTransformGestures"""), + Regex("""onLongPress"""), + Regex("""swipeable"""), + Regex("""anchoredDraggable""") + ) + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for ((index, line) in context.sourceLines.withIndex()) { + for (pattern in gesturePatterns) { + if (!pattern.containsMatchIn(line)) continue + + val start = maxOf(0, index - 15) + val end = minOf(context.sourceLines.size - 1, index + 15) + val scope = context.sourceLines.subList(start, end + 1).joinToString("\n") + + // Check for alternatives + if (scope.contains("customActions") || + scope.contains("accessibilityAction") || + scope.contains("onClickLabel") || + scope.contains("clickable") || + scope.contains("KeyEvent")) continue + + diagnostics.add( + makeDiagnostic( + message = "Custom gesture detected without an accessible alternative.", + line = index + 1, + column = (pattern.find(line)?.range?.first ?: 0) + 1, + context = context, + suggestion = "Add accessibility custom actions or clickable alternatives for keyboard/TalkBack users" + ) + ) + break + } + } + return diagnostics + } +} + +class InputPurposeRule : A11yRule { + override val id = "input-purpose" + override val name = "Input Purpose Not Identified" + override val severity = A11ySeverity.INFO + override val impact = A11yImpact.MODERATE + override val wcagCriteria = listOf("1.3.5") + override val description = "TextFields should use semantics { contentType = ... } for autofill support" + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + val textFieldCalls = setOf("TextField", "OutlinedTextField", "BasicTextField") + + for (call in file.allCalls.filter { it.name in textFieldCalls }) { + // Check for contentType in semantics + if (call.hasSemanticsProperty("contentType")) continue + if (call.modifierChain.contains("contentType")) continue + + // Check for keyboardOptions hint that this is a specific input type + val keyboardType = call.getArgument("keyboardOptions") + val hintInput = keyboardType != null && ( + keyboardType.contains("Email") || keyboardType.contains("Phone") || + keyboardType.contains("Password") || keyboardType.contains("Uri") || + keyboardType.contains("Number")) + + if (hintInput && !call.hasSemanticsProperty("contentType")) { + diagnostics.add( + makeDiagnostic( + message = "TextField has a specific keyboard type but no contentType semantics for autofill.", + line = call.line, + column = call.column, + context = context, + suggestion = "Add Modifier.semantics { contentType = ContentType.EmailAddress } (or appropriate type)" + ) + ) + } + } + return diagnostics + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/ToggleRules.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/ToggleRules.kt new file mode 100644 index 0000000..f575b7b --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/ToggleRules.kt @@ -0,0 +1,97 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import com.cvshealth.a11y.agent.scanner.ParsedKotlinFile + +class ToggleMissingLabelRule : A11yRule { + override val id = "toggle-missing-label" + override val name = "Toggle Control Missing Label Association" + override val severity = A11ySeverity.ERROR + override val impact = A11yImpact.CRITICAL + override val wcagCriteria = listOf("4.1.2") + override val description = "Checkbox/Switch must be inside a Row with Modifier.toggleable() for proper label association" + + private val toggleControls = setOf("Checkbox", "TriStateCheckbox", "Switch") + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + + for (call in file.allCalls.filter { it.name in toggleControls }) { + // Check if onCheckedChange/onValueChange is null (properly delegated to parent) + val onCheckedChange = call.getArgument("onCheckedChange") + val onValueChange = call.getArgument("onValueChange") + + if (onCheckedChange?.trim() == "null" || onValueChange?.trim() == "null") { + // This is the correct pattern — click handling is on parent Row.toggleable() + continue + } + + // Check if inside a toggleable or selectable Row + if (call.enclosingScopeText.contains(".toggleable(") || + call.enclosingScopeText.contains(".selectable(")) continue + + // Check if inside a component that handles label association + if (call.enclosingCallName in setOf("CheckboxRow", "SwitchRow", "ListItem")) continue + + // Check for semantics with contentDescription nearby + if (call.hasSemanticsProperty("contentDescription") || + call.enclosingScopeText.contains("clearAndSetSemantics")) continue + + val fix = computeToggleFix(call, context) + + diagnostics.add( + makeDiagnostic( + message = "${call.name} handles its own click but may not be properly labeled. Wrap in Row(Modifier.toggleable()) with the label Text.", + line = call.line, + column = call.column, + context = context, + fix = fix, + suggestion = "Use Row(Modifier.toggleable(value, role = Role.${if (call.name == "Switch") "Switch" else "Checkbox"}, onValueChange)) with ${call.name}(onCheckedChange = null)" + ) + ) + } + return diagnostics + } + + private fun computeToggleFix(call: com.cvshealth.a11y.agent.scanner.DetectedCall, context: RuleContext): A11yFix? { + // Replace onCheckedChange = { ... } with onCheckedChange = null + val lineOffset = lineColumnToOffset(context.sourceText, call.line) + val searchEnd = minOf(lineOffset + call.rawArgumentText.length + 200, context.sourceText.length) + val searchArea = context.sourceText.substring(lineOffset, searchEnd) + + val argName = if (call.name == "Switch") "onCheckedChange" else "onCheckedChange" + val argIdx = searchArea.indexOf("$argName =") + if (argIdx < 0) return null + + val valueStart = searchArea.indexOf("=", argIdx + argName.length) + if (valueStart < 0) return null + + // Find the value — it's either a lambda { ... } or a reference + val afterEquals = searchArea.substring(valueStart + 1).trimStart() + val absValueStart = lineOffset + valueStart + 1 + (searchArea.substring(valueStart + 1).length - afterEquals.length) + + if (afterEquals.startsWith("{")) { + // Find matching closing brace + var depth = 0 + var pos = absValueStart + while (pos < context.sourceText.length) { + when (context.sourceText[pos]) { + '{' -> depth++ + '}' -> { + depth-- + if (depth == 0) { + return A11yFix( + description = "Set onCheckedChange = null (move click handling to parent Row.toggleable())", + replacementText = "null", + startOffset = absValueStart, + endOffset = pos + 1 + ) + } + } + } + pos++ + } + } + return null + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/TouchTargetRules.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/TouchTargetRules.kt new file mode 100644 index 0000000..0985bf9 --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/TouchTargetRules.kt @@ -0,0 +1,58 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import com.cvshealth.a11y.agent.scanner.ParsedKotlinFile + +class SmallTouchTargetRule : A11yRule { + override val id = "small-touch-target" + override val name = "Small Touch Target" + override val severity = A11ySeverity.ERROR + override val impact = A11yImpact.SERIOUS + override val wcagCriteria = listOf("2.5.8") + override val description = "Interactive elements must meet minimum touch target size (48dp)" + + override fun check(file: ParsedKotlinFile, context: RuleContext): List { + val diagnostics = mutableListOf() + val minTarget = context.configOptions.minTouchTarget + val sizePattern = Regex("""\.\s*size\s*\(\s*(\d+)\.dp\s*\)""") + val widthPattern = Regex("""\.\s*width\s*\(\s*(\d+)\.dp\s*\)""") + val heightPattern = Regex("""\.\s*height\s*\(\s*(\d+)\.dp\s*\)""") + + val interactiveCalls = setOf("IconButton", "Button", "TextButton", "OutlinedButton", + "FloatingActionButton", "ExtendedFloatingActionButton") + + for (call in file.allCalls.filter { it.name in interactiveCalls }) { + val chain = call.modifierChain + + // Skip if minimumInteractiveComponentSize is used + if (chain.contains("minimumInteractiveComponentSize")) continue + + // Check for explicit small sizes + val sizeMatch = sizePattern.find(chain) + val widthMatch = widthPattern.find(chain) + val heightMatch = heightPattern.find(chain) + + val explicitSize = sizeMatch?.groupValues?.get(1)?.toIntOrNull() + val explicitWidth = widthMatch?.groupValues?.get(1)?.toIntOrNull() + val explicitHeight = heightMatch?.groupValues?.get(1)?.toIntOrNull() + + val tooSmall = (explicitSize != null && explicitSize < minTarget) || + (explicitWidth != null && explicitWidth < minTarget) || + (explicitHeight != null && explicitHeight < minTarget) + + if (tooSmall) { + val dimension = explicitSize ?: minOf(explicitWidth ?: minTarget, explicitHeight ?: minTarget) + diagnostics.add( + makeDiagnostic( + message = "${call.name} has explicit size ${dimension}dp which is below the ${minTarget}dp minimum touch target.", + line = call.line, + column = call.column, + context = context, + suggestion = "Add Modifier.minimumInteractiveComponentSize() or increase size to at least ${minTarget}dp" + ) + ) + } + } + return diagnostics + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/TraitRules.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/TraitRules.kt new file mode 100644 index 0000000..c12f866 --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/rules/TraitRules.kt @@ -0,0 +1,8 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import com.cvshealth.a11y.agent.scanner.ParsedKotlinFile + +// This file is intentionally kept minimal. +// The clickable-missing-role rule is in ClickableRules.kt +// Additional trait-related rules can be added here as needed. diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/scanner/KotlinFileScanner.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/scanner/KotlinFileScanner.kt new file mode 100644 index 0000000..dd2d97e --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/scanner/KotlinFileScanner.kt @@ -0,0 +1,375 @@ +package com.cvshealth.a11y.agent.scanner + +object KotlinFileScanner { + + private val RECOGNIZED_CALLS = setOf( + "Icon", "Image", "Text", "Button", "IconButton", "TextButton", "OutlinedButton", + "FilledTonalButton", "ElevatedButton", "FloatingActionButton", "ExtendedFloatingActionButton", + "TextField", "OutlinedTextField", "BasicTextField", + "Checkbox", "TriStateCheckbox", "Switch", "RadioButton", + "Slider", "RangeSlider", + "Scaffold", "TopAppBar", "CenterAlignedTopAppBar", "MediumTopAppBar", "LargeTopAppBar", + "TabRow", "ScrollableTabRow", "Tab", "LeadingIconTab", + "NavigationBar", "NavigationBarItem", "NavigationRail", "NavigationRailItem", + "BottomSheetScaffold", "ModalBottomSheet", + "Column", "Row", "Box", "LazyColumn", "LazyRow", "LazyVerticalGrid", + "Card", "ElevatedCard", "OutlinedCard", + "AlertDialog", "Dialog", + "DropdownMenu", "DropdownMenuItem", "ExposedDropdownMenuBox", + "AnimatedVisibility", "AnimatedContent", "Crossfade", + "ClickableText", "SelectionContainer", + "Surface", "ListItem" + ) + + private val COMPOSABLE_ANNOTATION = Regex("""@Composable""") + private val FUNCTION_DECL = Regex("""^\s*(private\s+|internal\s+|public\s+)?(suspend\s+)?fun\s+(\w+)\s*\(""") + + fun scan(sourceText: String, filePath: String): ParsedKotlinFile { + val lines = sourceText.lines() + val composables = detectComposables(lines) + val allCalls = detectCalls(lines, composables) + return ParsedKotlinFile(filePath, composables, allCalls) + } + + private fun detectComposables(lines: List): List { + val composables = mutableListOf() + var i = 0 + while (i < lines.size) { + val line = lines[i] + if (COMPOSABLE_ANNOTATION.containsMatchIn(line)) { + // Look ahead for function declaration (may be on same line or next few lines) + for (j in i..minOf(i + 3, lines.size - 1)) { + val funcMatch = FUNCTION_DECL.find(lines[j]) + if (funcMatch != null) { + val funcName = funcMatch.groupValues[3] + val openBraceLine = findOpenBrace(lines, j) + if (openBraceLine != null) { + val closeBraceLine = findMatchingCloseBrace(lines, openBraceLine) + if (closeBraceLine != null) { + val bodyText = lines.subList(openBraceLine, closeBraceLine + 1) + .joinToString("\n") + composables.add( + ComposableFunction( + name = funcName, + startLine = j + 1, // 1-based + endLine = closeBraceLine + 1, // 1-based + bodyText = bodyText + ) + ) + } + } + i = j + 1 + break + } + if (j == minOf(i + 3, lines.size - 1)) { + i = j + 1 + } + } + } else { + i++ + } + } + return composables + } + + private fun findOpenBrace(lines: List, fromLine: Int): Int? { + for (i in fromLine..minOf(fromLine + 10, lines.size - 1)) { + if (lines[i].contains("{")) return i + } + return null + } + + private fun findMatchingCloseBrace(lines: List, openBraceLine: Int): Int? { + var depth = 0 + for (i in openBraceLine until lines.size) { + val line = lines[i] + for (ch in line) { + when (ch) { + '{' -> depth++ + '}' -> { + depth-- + if (depth == 0) return i + } + } + } + } + return null + } + + private fun detectCalls( + lines: List, + composables: List + ): List { + val calls = mutableListOf() + + for (composable in composables) { + val startIdx = composable.startLine - 1 // to 0-based + val endIdx = composable.endLine - 1 + + for (i in startIdx..endIdx) { + val line = lines[i] + for (callName in RECOGNIZED_CALLS) { + val pattern = Regex("""(?, + startLine: Int, + startCol: Int, + openChar: Char, + closeChar: Char + ): String { + val sb = StringBuilder() + var depth = 0 + var started = false + var inString = false + var escaped = false + + for (i in startLine until lines.size) { + val line = lines[i] + val startJ = if (i == startLine) startCol else 0 + + for (j in startJ until line.length) { + val ch = line[j] + + if (escaped) { + sb.append(ch) + escaped = false + continue + } + if (ch == '\\' && inString) { + sb.append(ch) + escaped = true + continue + } + if (ch == '"' && !escaped) { + inString = !inString + sb.append(ch) + continue + } + + if (!inString) { + if (ch == openChar) { + if (!started) started = true + depth++ + } else if (ch == closeChar) { + depth-- + if (started && depth == 0) { + sb.append(ch) + return sb.toString() + } + } + } + if (started) sb.append(ch) + } + if (started) sb.append('\n') + } + return sb.toString() + } + + private fun extractModifierChain(lines: List, callLine: Int, rawArgs: String): String { + // Check for modifier = ... in arguments + val modifierPattern = Regex("""modifier\s*=\s*""") + val modMatch = modifierPattern.find(rawArgs) + if (modMatch != null) { + // Extract the modifier expression from the raw arguments + val afterModifier = rawArgs.substring(modMatch.range.last + 1).trim() + return extractModifierExpression(afterModifier) + } + + // Also look for modifier chain applied after the call (trailing dot syntax) + // e.g., Text("hello").semantics { heading() } + val fullCallText = extractBalancedBlock(lines, callLine, lines[callLine].indexOf(lines[callLine].trimStart().first()), '(', ')') + val endOfCall = rawArgs.length + if (callLine < lines.size) { + val remaining = lines.getOrNull(callLine)?.substring( + minOf(lines[callLine].length, lines[callLine].indexOf(rawArgs.take(10)) + endOfCall) + ) ?: "" + if (remaining.trimStart().startsWith(".")) { + return remaining.trim() + } + } + + return "" + } + + private fun extractModifierExpression(text: String): String { + var depth = 0 + val sb = StringBuilder() + var inString = false + + for (ch in text) { + if (ch == '"') inString = !inString + if (!inString) { + when (ch) { + '(', '{' -> depth++ + ')', '}' -> { + depth-- + if (depth < 0) return sb.toString().trim() + } + ',' -> if (depth == 0) return sb.toString().trim() + } + } + sb.append(ch) + } + return sb.toString().trim() + } + + fun parseArguments(rawArgs: String): Map { + val args = mutableMapOf() + if (rawArgs.isBlank()) return args + + // Remove outer parens + val inner = if (rawArgs.startsWith("(") && rawArgs.endsWith(")")) { + rawArgs.substring(1, rawArgs.length - 1) + } else { + rawArgs + } + + val argParts = splitTopLevelCommas(inner) + var positionalIndex = 0 + + for (part in argParts) { + val trimmed = part.trim() + if (trimmed.isEmpty()) continue + + val equalsIndex = findTopLevelEquals(trimmed) + if (equalsIndex != null) { + val key = trimmed.substring(0, equalsIndex).trim() + val value = trimmed.substring(equalsIndex + 1).trim() + args[key] = value + } else { + // Positional argument + args["__pos_$positionalIndex"] = trimmed + positionalIndex++ + } + } + return args + } + + private fun splitTopLevelCommas(text: String): List { + val parts = mutableListOf() + val sb = StringBuilder() + var depth = 0 + var inString = false + var escaped = false + + for (ch in text) { + if (escaped) { sb.append(ch); escaped = false; continue } + if (ch == '\\' && inString) { sb.append(ch); escaped = true; continue } + if (ch == '"') { inString = !inString; sb.append(ch); continue } + if (!inString) { + when (ch) { + '(', '{', '[', '<' -> depth++ + ')', '}', ']', '>' -> depth-- + ',' -> if (depth == 0) { parts.add(sb.toString()); sb.clear(); continue } + } + } + sb.append(ch) + } + if (sb.isNotEmpty()) parts.add(sb.toString()) + return parts + } + + private fun findTopLevelEquals(text: String): Int? { + var depth = 0 + var inString = false + for (i in text.indices) { + val ch = text[i] + if (ch == '"') inString = !inString + if (!inString) { + when (ch) { + '(', '{', '[' -> depth++ + ')', '}', ']' -> depth-- + '=' -> if (depth == 0 && i + 1 < text.length && text[i + 1] != '=') return i + } + } + } + return null + } + + private fun findEnclosingCall(lines: List, currentLine: Int, scopeStart: Int): String? { + // Walk backwards to find the nearest enclosing recognized call + var depth = 0 + for (i in currentLine - 1 downTo scopeStart) { + val line = lines[i] + for (j in line.length - 1 downTo 0) { + when (line[j]) { + '}', ')' -> depth++ + '{', '(' -> { + depth-- + if (depth < 0) { + // We're inside this block - check if it's a recognized call + for (callName in RECOGNIZED_CALLS) { + val pattern = Regex("""(? scopeStart) { + for (callName in RECOGNIZED_CALLS) { + val pattern = Regex("""(?, + currentLine: Int, + scopeStart: Int, + scopeEnd: Int + ): String { + // Get a window of context around the call (up to 20 lines before, 10 after) + val start = maxOf(scopeStart, currentLine - 20) + val end = minOf(scopeEnd, currentLine + 10) + return lines.subList(start, end + 1).joinToString("\n") + } + + private fun calculateDepth(lines: List, currentLine: Int, scopeStart: Int): Int { + var depth = 0 + for (i in scopeStart until currentLine) { + for (ch in lines[i]) { + when (ch) { + '{' -> depth++ + '}' -> depth-- + } + } + } + return depth + } +} diff --git a/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/scanner/ParsedKotlinFile.kt b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/scanner/ParsedKotlinFile.kt new file mode 100644 index 0000000..3bbe619 --- /dev/null +++ b/A11yAgent/src/main/kotlin/com/cvshealth/a11y/agent/scanner/ParsedKotlinFile.kt @@ -0,0 +1,44 @@ +package com.cvshealth.a11y.agent.scanner + +data class ParsedKotlinFile( + val filePath: String, + val composables: List, + val allCalls: List +) + +data class ComposableFunction( + val name: String, + val startLine: Int, + val endLine: Int, + val bodyText: String +) + +data class DetectedCall( + val name: String, + val line: Int, + val column: Int, + val arguments: Map, + val rawArgumentText: String, + val modifierChain: String, + val parentComposable: String?, + val enclosingCallName: String?, + val enclosingScopeText: String, + val depth: Int +) { + fun hasArgument(argName: String): Boolean = arguments.containsKey(argName) + + fun getArgument(argName: String): String? = arguments[argName] + + fun hasModifier(modifierName: String): Boolean = + modifierChain.contains(".$modifierName(") || modifierChain.contains(".$modifierName {") + + fun hasSemanticsProperty(property: String): Boolean { + val semanticsPattern = Regex("""\.\s*semantics\s*(\([^)]*\))?\s*\{[^}]*$property""", RegexOption.DOT_MATCHES_ALL) + return semanticsPattern.containsMatchIn(modifierChain) || semanticsPattern.containsMatchIn(enclosingScopeText) + } + + fun getModifierBlock(modifierName: String): String? { + val pattern = Regex("""\.$modifierName\s*\{([^}]*)\}""", RegexOption.DOT_MATCHES_ALL) + return pattern.find(modifierChain)?.groupValues?.getOrNull(1)?.trim() + } +} diff --git a/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/core/BaselineTest.kt b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/core/BaselineTest.kt new file mode 100644 index 0000000..f2b2219 --- /dev/null +++ b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/core/BaselineTest.kt @@ -0,0 +1,95 @@ +package com.cvshealth.a11y.agent.core + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class BaselineTest { + + @Test + fun `baseline fingerprint format`() { + val entry = Baseline.Entry( + ruleID = "icon-missing-label", + filePath = "test.kt", + line = 10, + message = "Icon is missing contentDescription" + ) + assertEquals("icon-missing-label|test.kt|Icon is missing contentDescription", entry.fingerprint) + } + + @Test + fun `filterNew removes baselined issues`() { + val baselineEntries = listOf( + Baseline.Entry("icon-missing-label", "test.kt", 10, "Icon is missing contentDescription") + ) + val baseline = Baseline(entries = baselineEntries) + + val diagnostics = listOf( + A11yDiagnostic( + ruleID = "icon-missing-label", + severity = A11ySeverity.ERROR, + message = "Icon is missing contentDescription", + filePath = "test.kt", + line = 10 + ), + A11yDiagnostic( + ruleID = "heading-semantics-missing", + severity = A11ySeverity.WARNING, + message = "Text uses heading typography", + filePath = "test.kt", + line = 20 + ) + ) + + val newIssues = baseline.filterNew(diagnostics) + assertEquals(1, newIssues.size) + assertEquals("heading-semantics-missing", newIssues[0].ruleID) + } + + @Test + fun `from creates baseline from diagnostics`() { + val diagnostics = listOf( + A11yDiagnostic( + ruleID = "icon-missing-label", + severity = A11ySeverity.ERROR, + message = "test message", + filePath = "test.kt", + line = 5 + ) + ) + val baseline = Baseline.from(diagnostics, 85.0) + assertEquals(1, baseline.entries.size) + assertEquals(85.0, baseline.score) + assertEquals("icon-missing-label", baseline.entries[0].ruleID) + } + + @Test + fun `save and load baseline roundtrip`(@TempDir tempDir: File) { + val diagnostics = listOf( + A11yDiagnostic( + ruleID = "icon-missing-label", + severity = A11ySeverity.ERROR, + message = "test", + filePath = "test.kt", + line = 5 + ) + ) + val baseline = Baseline.from(diagnostics, 90.0) + baseline.save(tempDir.absolutePath) + + val loaded = Baseline.loadFrom(tempDir.absolutePath) + assertNotNull(loaded) + assertEquals(1, loaded.entries.size) + assertEquals("icon-missing-label", loaded.entries[0].ruleID) + assertEquals(90.0, loaded.score) + } + + @Test + fun `loadFrom returns null when no baseline exists`(@TempDir tempDir: File) { + val loaded = Baseline.loadFrom(tempDir.absolutePath) + assertEquals(null, loaded) + } +} diff --git a/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/core/ConfigTest.kt b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/core/ConfigTest.kt new file mode 100644 index 0000000..cc7b3b4 --- /dev/null +++ b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/core/ConfigTest.kt @@ -0,0 +1,85 @@ +package com.cvshealth.a11y.agent.core + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConfigTest { + + @Test + fun `parse valid yaml config`() { + val yaml = """ + disabled_rules: + - fixed-font-size + - hardcoded-color + severity_overrides: + icon-missing-label: WARNING + options: + min_touch_target: 44 + exclude_paths: + - "**/test/**" + """.trimIndent() + + val config = ConfigLoader.parse(yaml) + assertTrue(config.disabledRules.contains("fixed-font-size")) + assertTrue(config.disabledRules.contains("hardcoded-color")) + assertEquals(A11ySeverity.WARNING, config.severityOverrides["icon-missing-label"]) + assertEquals(44, config.configOptions.minTouchTarget) + assertTrue(config.shouldExclude("src/test/MyTest.kt")) + } + + @Test + fun `parse empty yaml returns defaults`() { + val config = ConfigLoader.parse("") + assertTrue(config.disabledRules.isEmpty()) + assertTrue(config.severityOverrides.isEmpty()) + assertEquals(48, config.configOptions.minTouchTarget) + assertEquals(4.5, config.configOptions.contrastRatio) + } + + @Test + fun `parse invalid yaml returns empty config`() { + val config = ConfigLoader.parse("{{invalid yaml}}") + assertEquals(A11yConfig.empty, config) + } + + @Test + fun `enabled_only restricts rules`() { + val yaml = """ + enabled_only: + - icon-missing-label + - heading-semantics-missing + """.trimIndent() + + val config = ConfigLoader.parse(yaml) + assertEquals(setOf("icon-missing-label", "heading-semantics-missing"), config.enabledOnly) + } + + @Test + fun `shouldExclude with glob patterns`() { + val config = A11yConfig(exclude_paths = listOf("**/build/**", "*.generated.kt")) + assertTrue(config.shouldExclude("app/build/generated/File.kt")) + assertTrue(config.shouldExclude("Theme.generated.kt")) + assertFalse(config.shouldExclude("app/src/main/MyScreen.kt")) + } + + @Test + fun `shouldExclude with simple pattern`() { + val config = A11yConfig(exclude_paths = listOf("*.test.kt")) + assertTrue(config.shouldExclude("MyFile.test.kt")) + assertFalse(config.shouldExclude("MyFile.kt")) + } + + @Test + fun `severity override mapping is correct`() { + val config = A11yConfig(severity_overrides = mapOf( + "icon-missing-label" to "WARNING", + "heading-semantics-missing" to "INFO", + "invalid-rule" to "INVALID_SEVERITY" + )) + assertEquals(A11ySeverity.WARNING, config.severityOverrides["icon-missing-label"]) + assertEquals(A11ySeverity.INFO, config.severityOverrides["heading-semantics-missing"]) + assertFalse(config.severityOverrides.containsKey("invalid-rule")) + } +} diff --git a/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/core/InlineSuppressionTest.kt b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/core/InlineSuppressionTest.kt new file mode 100644 index 0000000..df4997d --- /dev/null +++ b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/core/InlineSuppressionTest.kt @@ -0,0 +1,96 @@ +package com.cvshealth.a11y.agent.core + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class InlineSuppressionTest { + + @Test + fun `same-line disable suppresses specific rule`() { + val source = """ + Icon(contentDescription = null) // a11y-check:disable icon-missing-label + """.trimIndent() + + val suppressions = InlineSuppression.parseSuppressionsFromSource(source) + assertEquals(1, suppressions.size) + assertEquals(1, suppressions[0].line) + assertTrue(suppressions[0].ruleIDs!!.contains("icon-missing-label")) + } + + @Test + fun `same-line disable without rule IDs suppresses all`() { + val source = """ + Icon(contentDescription = null) // a11y-check:disable + """.trimIndent() + + val suppressions = InlineSuppression.parseSuppressionsFromSource(source) + assertEquals(1, suppressions.size) + assertEquals(null, suppressions[0].ruleIDs) + } + + @Test + fun `disable-next-line suppresses next line`() { + val source = """ + // a11y-check:disable-next-line icon-missing-label + Icon(contentDescription = null) + """.trimIndent() + + val suppressions = InlineSuppression.parseSuppressionsFromSource(source) + assertEquals(1, suppressions.size) + assertEquals(2, suppressions[0].line) + assertTrue(suppressions[0].ruleIDs!!.contains("icon-missing-label")) + } + + @Test + fun `filter removes matching diagnostics`() { + val source = """ + Icon(contentDescription = null) // a11y-check:disable icon-missing-label + """.trimIndent() + + val diags = listOf( + A11yDiagnostic( + ruleID = "icon-missing-label", + severity = A11ySeverity.ERROR, + message = "test", + filePath = "test.kt", + line = 1 + ) + ) + + val filtered = InlineSuppression.filter(diags, source) + assertTrue(filtered.isEmpty()) + } + + @Test + fun `filter keeps non-matching diagnostics`() { + val source = """ + Icon(contentDescription = null) // a11y-check:disable other-rule + """.trimIndent() + + val diags = listOf( + A11yDiagnostic( + ruleID = "icon-missing-label", + severity = A11ySeverity.ERROR, + message = "test", + filePath = "test.kt", + line = 1 + ) + ) + + val filtered = InlineSuppression.filter(diags, source) + assertEquals(1, filtered.size) + } + + @Test + fun `multiple comma-separated rules parsed`() { + val source = """ + Icon(contentDescription = null) // a11y-check:disable icon-missing-label, empty-content-description + """.trimIndent() + + val suppressions = InlineSuppression.parseSuppressionsFromSource(source) + assertEquals(1, suppressions.size) + assertTrue(suppressions[0].ruleIDs!!.contains("icon-missing-label")) + assertTrue(suppressions[0].ruleIDs!!.contains("empty-content-description")) + } +} diff --git a/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/core/RuleRegistryTest.kt b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/core/RuleRegistryTest.kt new file mode 100644 index 0000000..c57e274 --- /dev/null +++ b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/core/RuleRegistryTest.kt @@ -0,0 +1,106 @@ +package com.cvshealth.a11y.agent.core + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RuleRegistryTest { + + @Test + fun `registry has 32 built-in rules`() { + val registry = RuleRegistry() + assertEquals(32, registry.rules.size) + } + + @Test + fun `all rules have unique IDs`() { + val registry = RuleRegistry() + val ids = registry.rules.map { it.id } + assertEquals(ids.size, ids.distinct().size) + } + + @Test + fun `all rules have WCAG criteria`() { + val registry = RuleRegistry() + for (rule in registry.rules) { + assertTrue(rule.wcagCriteria.isNotEmpty(), "Rule ${rule.id} should have WCAG criteria") + } + } + + @Test + fun `disabling a rule excludes it from enabled`() { + val registry = RuleRegistry() + registry.disabledRuleIDs.add("icon-missing-label") + assertTrue(registry.enabledRules.none { it.id == "icon-missing-label" }) + } + + @Test + fun `applyConfig disables rules from config`() { + val config = A11yConfig(disabled_rules = listOf("fixed-font-size", "hardcoded-color")) + val registry = RuleRegistry() + registry.applyConfig(config) + assertTrue(registry.enabledRules.none { it.id == "fixed-font-size" }) + assertTrue(registry.enabledRules.none { it.id == "hardcoded-color" }) + } + + @Test + fun `enabled_only limits rules to specified set`() { + val config = A11yConfig(enabled_only = listOf("icon-missing-label")) + val registry = RuleRegistry() + registry.applyConfig(config) + assertEquals(1, registry.enabledRules.size) + assertEquals("icon-missing-label", registry.enabledRules[0].id) + } + + @Test + fun `analyze returns diagnostics for bad code`() { + val source = """ + @Composable + fun MyScreen() { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null + ) + } + """.trimIndent() + + val registry = RuleRegistry() + val diags = registry.analyze(source, "test.kt") + assertTrue(diags.isNotEmpty()) + assertTrue(diags.any { it.ruleID == "icon-missing-label" }) + } + + @Test + fun `analyze returns empty for clean code`() { + val source = """ + @Composable + fun MyScreen() { + Text( + text = "Hello World", + style = MaterialTheme.typography.bodyLarge + ) + } + """.trimIndent() + + val registry = RuleRegistry() + val diags = registry.analyze(source, "test.kt") + assertTrue(diags.isEmpty()) + } + + @Test + fun `inline suppression works through registry`() { + val source = """ + @Composable + fun MyScreen() { + Icon( // a11y-check:disable icon-missing-label + imageVector = Icons.Default.Star, + contentDescription = null + ) + } + """.trimIndent() + + val registry = RuleRegistry() + val diags = registry.analyze(source, "test.kt") + assertTrue(diags.none { it.ruleID == "icon-missing-label" }) + } +} diff --git a/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/core/ScoreCalculatorTest.kt b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/core/ScoreCalculatorTest.kt new file mode 100644 index 0000000..6d5a6a1 --- /dev/null +++ b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/core/ScoreCalculatorTest.kt @@ -0,0 +1,182 @@ +package com.cvshealth.a11y.agent.core + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ScoreCalculatorTest { + + private val calculator = ScoreCalculator() + + private fun makeRule( + id: String = "test-rule", + severity: A11ySeverity = A11ySeverity.ERROR, + impact: A11yImpact = A11yImpact.SERIOUS, + wcag: List = listOf("1.1.1") + ): A11yRule = object : A11yRule { + override val id = id + override val name = "Test Rule" + override val severity = severity + override val impact = impact + override val wcagCriteria = wcag + override val description = "Test rule" + override fun check( + file: com.cvshealth.a11y.agent.scanner.ParsedKotlinFile, + context: RuleContext + ) = emptyList() + } + + private fun makeDiag( + ruleID: String = "test-rule", + severity: A11ySeverity = A11ySeverity.ERROR, + impact: A11yImpact = A11yImpact.SERIOUS, + wcag: List = listOf("1.1.1"), + filePath: String = "test.kt" + ) = A11yDiagnostic( + ruleID = ruleID, + severity = severity, + impact = impact, + message = "Test issue", + filePath = filePath, + line = 1, + wcagCriteria = wcag + ) + + @Test + fun `perfect score with no diagnostics`() { + val score = calculator.calculate(emptyList(), listOf(makeRule()), listOf("test.kt")) + assertEquals(100.0, score.score) + assertEquals("A+", score.grade) + assertEquals(0, score.totalErrors) + assertEquals(0, score.totalWarnings) + assertEquals(0, score.totalInfo) + } + + @Test + fun `score decreases with errors`() { + val diags = listOf(makeDiag()) + val score = calculator.calculate(diags, listOf(makeRule()), listOf("test.kt")) + assertTrue(score.score < 100.0) + assertEquals(1, score.totalErrors) + } + + @Test + fun `errors penalize more than warnings`() { + val errorDiags = listOf(makeDiag(severity = A11ySeverity.ERROR)) + val warningDiags = listOf(makeDiag(severity = A11ySeverity.WARNING)) + + val rules = listOf(makeRule()) + val files = listOf("test.kt") + + val errorScore = calculator.calculate(errorDiags, rules, files) + val warningScore = calculator.calculate(warningDiags, rules, files) + + assertTrue(errorScore.score < warningScore.score, + "Error score (${errorScore.score}) should be less than warning score (${warningScore.score})") + } + + @Test + fun `critical impact penalizes more than minor`() { + val criticalDiags = listOf(makeDiag(impact = A11yImpact.CRITICAL)) + val minorDiags = listOf(makeDiag(impact = A11yImpact.MINOR)) + + val rules = listOf(makeRule()) + val files = listOf("test.kt") + + val criticalScore = calculator.calculate(criticalDiags, rules, files) + val minorScore = calculator.calculate(minorDiags, rules, files) + + assertTrue(criticalScore.score < minorScore.score, + "Critical score (${criticalScore.score}) should be less than minor score (${minorScore.score})") + } + + @Test + fun `counts errors warnings and info correctly`() { + val diags = listOf( + makeDiag(severity = A11ySeverity.ERROR), + makeDiag(severity = A11ySeverity.ERROR), + makeDiag(severity = A11ySeverity.WARNING), + makeDiag(severity = A11ySeverity.INFO) + ) + val score = calculator.calculate(diags, listOf(makeRule()), listOf("test.kt")) + assertEquals(2, score.totalErrors) + assertEquals(1, score.totalWarnings) + assertEquals(1, score.totalInfo) + } + + @Test + fun `catalogs all 48 WCAG criteria`() { + assertEquals(48, ScoreCalculator.wcagCatalog.size) + } + + @Test + fun `letter grades follow thresholds`() { + assertEquals("A+", A11yScore.letterGrade(100.0)) + assertEquals("A+", A11yScore.letterGrade(97.0)) + assertEquals("A", A11yScore.letterGrade(96.0)) + assertEquals("A", A11yScore.letterGrade(93.0)) + assertEquals("A-", A11yScore.letterGrade(92.0)) + assertEquals("A-", A11yScore.letterGrade(90.0)) + assertEquals("B+", A11yScore.letterGrade(89.0)) + assertEquals("B+", A11yScore.letterGrade(87.0)) + assertEquals("B", A11yScore.letterGrade(86.0)) + assertEquals("B", A11yScore.letterGrade(83.0)) + assertEquals("B-", A11yScore.letterGrade(82.0)) + assertEquals("B-", A11yScore.letterGrade(80.0)) + assertEquals("C+", A11yScore.letterGrade(79.0)) + assertEquals("C+", A11yScore.letterGrade(77.0)) + assertEquals("C", A11yScore.letterGrade(76.0)) + assertEquals("C", A11yScore.letterGrade(73.0)) + assertEquals("C-", A11yScore.letterGrade(72.0)) + assertEquals("C-", A11yScore.letterGrade(70.0)) + assertEquals("D+", A11yScore.letterGrade(69.0)) + assertEquals("D+", A11yScore.letterGrade(67.0)) + assertEquals("D", A11yScore.letterGrade(66.0)) + assertEquals("D", A11yScore.letterGrade(63.0)) + assertEquals("D-", A11yScore.letterGrade(62.0)) + assertEquals("D-", A11yScore.letterGrade(60.0)) + assertEquals("F", A11yScore.letterGrade(59.0)) + assertEquals("F", A11yScore.letterGrade(0.0)) + } + + @Test + fun `file scores are calculated per file`() { + val diags = listOf( + makeDiag(filePath = "a.kt"), + makeDiag(filePath = "a.kt"), + makeDiag(filePath = "b.kt", severity = A11ySeverity.WARNING) + ) + val score = calculator.calculate( + diags, listOf(makeRule()), listOf("a.kt", "b.kt", "clean.kt") + ) + assertEquals(3, score.fileScores.size) + // a.kt has 2 errors, b.kt has 1 warning, clean.kt has none + val aScore = score.fileScores.first { it.filePath == "a.kt" } + val bScore = score.fileScores.first { it.filePath == "b.kt" } + val cleanScore = score.fileScores.first { it.filePath == "clean.kt" } + assertTrue(aScore.score < bScore.score) + assertEquals(100.0, cleanScore.score) + } + + @Test + fun `criteria status reflects diagnostics`() { + val diags = listOf(makeDiag(wcag = listOf("1.1.1"))) + val rule = makeRule(wcag = listOf("1.1.1", "4.1.2")) + val score = calculator.calculate(diags, listOf(rule), listOf("test.kt")) + + val criterion111 = score.criteriaScores.first { it.criterion == "1.1.1" } + assertEquals(CriterionStatus.FAIL, criterion111.status) + + val criterion412 = score.criteriaScores.first { it.criterion == "4.1.2" } + assertEquals(CriterionStatus.PASS, criterion412.status) + } + + @Test + fun `computeFileScore basic calculations`() { + assertEquals(100.0, ScoreCalculator.computeFileScore(0, 0, 0)) + assertEquals(95.0, ScoreCalculator.computeFileScore(1, 0, 0)) + assertEquals(98.0, ScoreCalculator.computeFileScore(0, 1, 0)) + assertEquals(99.5, ScoreCalculator.computeFileScore(0, 0, 1)) + assertEquals(0.0, ScoreCalculator.computeFileScore(100, 0, 0)) + } +} diff --git a/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/rules/FormControlRulesTest.kt b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/rules/FormControlRulesTest.kt new file mode 100644 index 0000000..53c5804 --- /dev/null +++ b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/rules/FormControlRulesTest.kt @@ -0,0 +1,117 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import org.junit.jupiter.api.Test +import kotlin.test.assertTrue + +class FormControlRulesTest { + + private fun analyze(source: String): List { + val registry = RuleRegistry() + return registry.analyze(source, "test.kt") + } + + // --- TextFieldMissingLabelRule --- + + @Test + fun `textfield without label flags error`() { + val source = """ + @Composable + fun MyScreen() { + TextField( + value = text, + onValueChange = { text = it } + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "textfield-missing-label" }) + } + + @Test + fun `textfield with label is clean`() { + val source = """ + @Composable + fun MyScreen() { + TextField( + value = text, + onValueChange = { text = it }, + label = { Text("Username") } + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.none { it.ruleID == "textfield-missing-label" }) + } + + @Test + fun `outlined textfield without label flags error`() { + val source = """ + @Composable + fun MyScreen() { + OutlinedTextField( + value = text, + onValueChange = { text = it } + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "textfield-missing-label" }) + } + + // --- SliderMissingLabelRule --- + + @Test + fun `slider without label flags error`() { + val source = """ + @Composable + fun MyScreen() { + Slider( + value = sliderValue, + onValueChange = { sliderValue = it } + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "slider-missing-label" }) + } + + @Test + fun `slider with semantics contentDescription is clean`() { + val source = """ + @Composable + fun MyScreen() { + Slider( + value = sliderValue, + onValueChange = { sliderValue = it }, + modifier = Modifier.semantics { contentDescription = "Volume" } + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.none { it.ruleID == "slider-missing-label" }) + } + + // --- DropdownMissingLabelRule --- + + @Test + fun `ExposedDropdownMenuBox without labeled TextField flags error`() { + val source = """ + @Composable + fun MyScreen() { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + TextField( + value = selected, + onValueChange = {}, + readOnly = true + ) + } + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "dropdown-missing-label" }) + } +} diff --git a/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/rules/HeadingRulesTest.kt b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/rules/HeadingRulesTest.kt new file mode 100644 index 0000000..b3b6c3b --- /dev/null +++ b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/rules/HeadingRulesTest.kt @@ -0,0 +1,93 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import org.junit.jupiter.api.Test +import kotlin.test.assertTrue + +class HeadingRulesTest { + + private fun analyze(source: String): List { + val registry = RuleRegistry() + return registry.analyze(source, "test.kt") + } + + // --- HeadingSemanticsMissingRule --- + + @Test + fun `text with heading style without heading semantics flags warning`() { + val source = """ + @Composable + fun MyScreen() { + Text( + text = "Section Title", + style = MaterialTheme.typography.headlineSmall + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "heading-semantics-missing" }) + } + + @Test + fun `text with heading style and heading semantics is clean`() { + val source = """ + @Composable + fun MyScreen() { + Text( + text = "Section Title", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.semantics { heading() } + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.none { it.ruleID == "heading-semantics-missing" }) + } + + @Test + fun `text with body style is not flagged`() { + val source = """ + @Composable + fun MyScreen() { + Text( + text = "Regular body text", + style = MaterialTheme.typography.bodyMedium + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.none { it.ruleID == "heading-semantics-missing" }) + } + + @Test + fun `text with titleLarge without heading semantics flags warning`() { + val source = """ + @Composable + fun MyScreen() { + Text( + text = "Page Title", + style = MaterialTheme.typography.titleLarge + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "heading-semantics-missing" }) + } + + // --- FakeHeadingInLabelRule --- + + @Test + fun `contentDescription containing heading flags warning`() { + val source = """ + @Composable + fun MyScreen() { + Icon( + imageVector = Icons.Default.Star, + contentDescription = "Section heading" + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "fake-heading-in-label" }) + } +} diff --git a/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/rules/ImageRulesTest.kt b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/rules/ImageRulesTest.kt new file mode 100644 index 0000000..4c6124f --- /dev/null +++ b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/rules/ImageRulesTest.kt @@ -0,0 +1,143 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import com.cvshealth.a11y.agent.scanner.KotlinFileScanner +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ImageRulesTest { + + private fun analyze(source: String): List { + val registry = RuleRegistry() + return registry.analyze(source, "test.kt") + } + + // --- IconMissingLabelRule --- + + @Test + fun `icon with null contentDescription flags error`() { + val source = """ + @Composable + fun MyScreen() { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "icon-missing-label" }) + } + + @Test + fun `icon with contentDescription is clean`() { + val source = """ + @Composable + fun MyScreen() { + Icon( + imageVector = Icons.Default.Star, + contentDescription = "Star" + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.none { it.ruleID == "icon-missing-label" }) + } + + @Test + fun `icon inside IconButton with null contentDescription is allowed`() { + val source = """ + @Composable + fun MyScreen() { + IconButton(onClick = {}) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = null + ) + } + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.none { it.ruleID == "icon-missing-label" }) + } + + // --- LabelContainsRoleImageRule --- + + @Test + fun `contentDescription containing image flags warning`() { + val source = """ + @Composable + fun MyScreen() { + Icon( + imageVector = Icons.Default.Star, + contentDescription = "star image" + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "label-contains-role-image" }) + } + + @Test + fun `contentDescription containing icon flags warning`() { + val source = """ + @Composable + fun MyScreen() { + Icon( + imageVector = Icons.Default.Star, + contentDescription = "menu icon" + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "label-contains-role-image" }) + } + + @Test + fun `contentDescription without role word is clean`() { + val source = """ + @Composable + fun MyScreen() { + Icon( + imageVector = Icons.Default.Star, + contentDescription = "Favorite" + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.none { it.ruleID == "label-contains-role-image" }) + } + + // --- EmptyContentDescriptionRule --- + + @Test + fun `empty string contentDescription flags error`() { + val source = """ + @Composable + fun MyScreen() { + Icon( + imageVector = Icons.Default.Star, + contentDescription = "" + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "empty-content-description" }) + } + + @Test + fun `null contentDescription does not flag empty content description rule`() { + val source = """ + @Composable + fun MyScreen() { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.none { it.ruleID == "empty-content-description" }) + } +} diff --git a/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/rules/LabelInNameRulesTest.kt b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/rules/LabelInNameRulesTest.kt new file mode 100644 index 0000000..cb1f1a3 --- /dev/null +++ b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/rules/LabelInNameRulesTest.kt @@ -0,0 +1,196 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LabelInNameRulesTest { + + private fun analyze(source: String): List { + val registry = RuleRegistry() + return registry.analyze(source, "test.kt") + } + + private fun labelInNameDiags(source: String) = + analyze(source).filter { it.ruleID == "label-in-name" } + + // --- ERROR cases: visible text not in accessible name --- + + @Test + fun `button with contentDescription that excludes visible text flags error`() { + val source = """ + @Composable + fun MyScreen() { + Button( + onClick = {}, + modifier = Modifier.semantics { contentDescription = "Submit form" } + ) { + Text("Save") + } + } + """.trimIndent() + val diags = labelInNameDiags(source) + assertEquals(1, diags.size) + assertEquals(A11ySeverity.ERROR, diags[0].severity) + assertTrue(diags[0].message.contains("not contained")) + } + + @Test + fun `icon button with mismatched contentDescription flags error`() { + val source = """ + @Composable + fun MyScreen() { + IconButton( + onClick = {}, + modifier = Modifier.semantics { contentDescription = "Navigation drawer" } + ) { + Text("Menu") + } + } + """.trimIndent() + val diags = labelInNameDiags(source) + assertEquals(1, diags.size) + assertEquals(A11ySeverity.ERROR, diags[0].severity) + } + + @Test + fun `tab with mismatched contentDescription flags error`() { + val source = """ + @Composable + fun MyScreen() { + Tab( + selected = true, + onClick = {}, + text = { Text("Home") }, + modifier = Modifier.semantics { contentDescription = "Dashboard" } + ) + } + """.trimIndent() + val diags = labelInNameDiags(source) + assertEquals(1, diags.size) + assertEquals(A11ySeverity.ERROR, diags[0].severity) + } + + // --- WARNING cases: visible text present but not at start --- + + @Test + fun `button with visible text as suffix in accessible name flags warning`() { + val source = """ + @Composable + fun MyScreen() { + Button( + onClick = {}, + modifier = Modifier.semantics { contentDescription = "Quick Save" } + ) { + Text("Save") + } + } + """.trimIndent() + val diags = labelInNameDiags(source) + assertEquals(1, diags.size) + assertEquals(A11ySeverity.WARNING, diags[0].severity) + assertTrue(diags[0].message.contains("not at the start")) + } + + // --- CLEAN cases: no diagnostic expected --- + + @Test + fun `button with accessible name starting with visible text is clean`() { + val source = """ + @Composable + fun MyScreen() { + Button( + onClick = {}, + modifier = Modifier.semantics { contentDescription = "Save document" } + ) { + Text("Save") + } + } + """.trimIndent() + val diags = labelInNameDiags(source) + assertTrue(diags.isEmpty(), "Expected no diagnostics but got: $diags") + } + + @Test + fun `button with exact match accessible name is clean`() { + val source = """ + @Composable + fun MyScreen() { + Button( + onClick = {}, + modifier = Modifier.semantics { contentDescription = "Save" } + ) { + Text("Save") + } + } + """.trimIndent() + val diags = labelInNameDiags(source) + assertTrue(diags.isEmpty()) + } + + @Test + fun `button with case-insensitive match is clean`() { + val source = """ + @Composable + fun MyScreen() { + Button( + onClick = {}, + modifier = Modifier.semantics { contentDescription = "save document" } + ) { + Text("SAVE") + } + } + """.trimIndent() + val diags = labelInNameDiags(source) + assertTrue(diags.isEmpty()) + } + + @Test + fun `button without contentDescription is clean`() { + val source = """ + @Composable + fun MyScreen() { + Button(onClick = {}) { + Text("Save") + } + } + """.trimIndent() + val diags = labelInNameDiags(source) + assertTrue(diags.isEmpty()) + } + + @Test + fun `button with non-literal contentDescription is skipped`() { + val source = """ + @Composable + fun MyScreen() { + Button( + onClick = {}, + modifier = Modifier.semantics { contentDescription = stringResource(R.string.save) } + ) { + Text("Save") + } + } + """.trimIndent() + val diags = labelInNameDiags(source) + assertTrue(diags.isEmpty()) + } + + @Test + fun `button with clearAndSetSemantics is skipped`() { + val source = """ + @Composable + fun MyScreen() { + Button( + onClick = {}, + modifier = Modifier.clearAndSetSemantics { contentDescription = "Submit" } + ) { + Text("Save") + } + } + """.trimIndent() + val diags = labelInNameDiags(source) + assertTrue(diags.isEmpty()) + } +} diff --git a/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/rules/ToggleAndButtonRulesTest.kt b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/rules/ToggleAndButtonRulesTest.kt new file mode 100644 index 0000000..deff1b8 --- /dev/null +++ b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/rules/ToggleAndButtonRulesTest.kt @@ -0,0 +1,161 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import org.junit.jupiter.api.Test +import kotlin.test.assertTrue + +class ToggleAndButtonRulesTest { + + private fun analyze(source: String): List { + val registry = RuleRegistry() + return registry.analyze(source, "test.kt") + } + + // --- ToggleMissingLabelRule --- + + @Test + fun `checkbox with onCheckedChange flags error`() { + val source = """ + @Composable + fun MyScreen() { + Checkbox( + checked = isChecked, + onCheckedChange = { isChecked = it } + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "toggle-missing-label" }) + } + + @Test + fun `checkbox with null onCheckedChange is clean`() { + val source = """ + @Composable + fun MyScreen() { + Row(modifier = Modifier.toggleable(value = checked, onValueChange = { checked = it }, role = Role.Checkbox)) { + Checkbox( + checked = checked, + onCheckedChange = null + ) + Text("Accept terms") + } + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.none { it.ruleID == "toggle-missing-label" }) + } + + @Test + fun `switch with onCheckedChange flags error`() { + val source = """ + @Composable + fun MyScreen() { + Switch( + checked = isOn, + onCheckedChange = { isOn = it } + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "toggle-missing-label" }) + } + + // --- IconButtonMissingLabelRule --- + + @Test + fun `icon button without semantics label flags error`() { + val source = """ + @Composable + fun MyScreen() { + IconButton(onClick = {}) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = null + ) + } + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "icon-button-missing-label" }) + } + + @Test + fun `icon button with semantics contentDescription is clean`() { + val source = """ + @Composable + fun MyScreen() { + IconButton( + onClick = {}, + modifier = Modifier.semantics { contentDescription = "Open menu" } + ) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = null + ) + } + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.none { it.ruleID == "icon-button-missing-label" }) + } + + // --- LabelContainsRoleButtonRule --- + + @Test + fun `button with button in text arg flags warning`() { + // Rule checks rawArgumentText, so the text must be in paren args + val source = """ + @Composable + fun MyScreen() { + TextButton(onClick = {}, content = { Text("Submit button") }) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "label-contains-role-button" }) + } + + @Test + fun `button without role word in args is clean`() { + val source = """ + @Composable + fun MyScreen() { + TextButton(onClick = {}, content = { Text("Submit") }) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.none { it.ruleID == "label-contains-role-button" }) + } + + // --- RadioGroupMissingRule --- + + @Test + fun `radio button outside selectableGroup flags error`() { + val source = """ + @Composable + fun MyScreen() { + Column { + RadioButton(selected = true, onClick = {}) + RadioButton(selected = false, onClick = {}) + } + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "radio-group-missing" }) + } + + @Test + fun `radio button inside selectableGroup is clean`() { + val source = """ + @Composable + fun MyScreen() { + Column(modifier = Modifier.selectableGroup()) { + RadioButton(selected = true, onClick = {}) + RadioButton(selected = false, onClick = {}) + } + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.none { it.ruleID == "radio-group-missing" }) + } +} diff --git a/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/rules/VisualRulesTest.kt b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/rules/VisualRulesTest.kt new file mode 100644 index 0000000..7d5a791 --- /dev/null +++ b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/rules/VisualRulesTest.kt @@ -0,0 +1,124 @@ +package com.cvshealth.a11y.agent.rules + +import com.cvshealth.a11y.agent.core.* +import org.junit.jupiter.api.Test +import kotlin.test.assertTrue + +class VisualRulesTest { + + private fun analyze(source: String): List { + val registry = RuleRegistry() + return registry.analyze(source, "test.kt") + } + + // --- FixedFontSizeRule --- + + @Test + fun `hardcoded sp font size flags warning`() { + val source = """ + @Composable + fun MyScreen() { + Text( + text = "Hello", + fontSize = 16.sp + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "fixed-font-size" }) + } + + @Test + fun `MaterialTheme typography is clean`() { + val source = """ + @Composable + fun MyScreen() { + Text( + text = "Hello", + style = MaterialTheme.typography.bodyLarge + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.none { it.ruleID == "fixed-font-size" }) + } + + // --- MaxLinesOneRule --- + + @Test + fun `maxLines 1 flags info`() { + val source = """ + @Composable + fun MyScreen() { + Text( + text = "Some long text", + maxLines = 1 + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "max-lines-one" }) + } + + // --- HardcodedColorRule --- + + @Test + fun `Color dot Black flags warning`() { + val source = """ + @Composable + fun MyScreen() { + Text( + text = "Hello", + color = Color.Black + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "hardcoded-color" }) + } + + @Test + fun `MaterialTheme colorScheme color is clean`() { + val source = """ + @Composable + fun MyScreen() { + Text( + text = "Hello", + color = MaterialTheme.colorScheme.primary + ) + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.none { it.ruleID == "hardcoded-color" }) + } + + // --- ClickableMissingRoleRule --- + + @Test + fun `clickable without role flags warning`() { + val source = """ + @Composable + fun MyScreen() { + Box(modifier = Modifier.clickable { doSomething() }) { + Text("Click me") + } + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.any { it.ruleID == "clickable-missing-role" }) + } + + @Test + fun `clickable with onClickLabel is clean`() { + val source = """ + @Composable + fun MyScreen() { + Box(modifier = Modifier.clickable(onClickLabel = "Activate item") { doSomething() }) { + Text("Click me") + } + } + """.trimIndent() + val diags = analyze(source) + assertTrue(diags.none { it.ruleID == "clickable-missing-role" }) + } +} diff --git a/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/scanner/KotlinFileScannerTest.kt b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/scanner/KotlinFileScannerTest.kt new file mode 100644 index 0000000..55ed6f4 --- /dev/null +++ b/A11yAgent/src/test/kotlin/com/cvshealth/a11y/agent/scanner/KotlinFileScannerTest.kt @@ -0,0 +1,210 @@ +package com.cvshealth.a11y.agent.scanner + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.assertNotNull + +class KotlinFileScannerTest { + + @Test + fun `detects composable functions`() { + val source = """ + import androidx.compose.runtime.Composable + + @Composable + fun MyScreen() { + Text("Hello") + } + """.trimIndent() + + val result = KotlinFileScanner.scan(source, "test.kt") + assertEquals(1, result.composables.size) + assertEquals("MyScreen", result.composables[0].name) + } + + @Test + fun `detects multiple composable functions`() { + val source = """ + import androidx.compose.runtime.Composable + + @Composable + fun ScreenA() { + Text("A") + } + + @Composable + fun ScreenB() { + Text("B") + } + """.trimIndent() + + val result = KotlinFileScanner.scan(source, "test.kt") + assertEquals(2, result.composables.size) + assertEquals("ScreenA", result.composables[0].name) + assertEquals("ScreenB", result.composables[1].name) + } + + @Test + fun `detects recognized calls within composables`() { + val source = """ + @Composable + fun MyScreen() { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null + ) + Text("Hello World") + Button(onClick = {}) { + Text("Click me") + } + } + """.trimIndent() + + val result = KotlinFileScanner.scan(source, "test.kt") + val callNames = result.allCalls.map { it.name } + assertTrue("Icon" in callNames) + assertTrue("Text" in callNames) + assertTrue("Button" in callNames) + } + + @Test + fun `extracts named arguments`() { + val source = """ + @Composable + fun MyScreen() { + Icon( + imageVector = Icons.Default.Star, + contentDescription = "Star icon" + ) + } + """.trimIndent() + + val result = KotlinFileScanner.scan(source, "test.kt") + val icon = result.allCalls.first { it.name == "Icon" } + assertEquals("Icons.Default.Star", icon.getArgument("imageVector")) + assertEquals("\"Star icon\"", icon.getArgument("contentDescription")) + } + + @Test + fun `extracts modifier chain`() { + val source = """ + @Composable + fun MyScreen() { + Text( + text = "Hello", + modifier = Modifier.semantics { heading() }.padding(8.dp) + ) + } + """.trimIndent() + + val result = KotlinFileScanner.scan(source, "test.kt") + val text = result.allCalls.first { it.name == "Text" } + assertTrue(text.modifierChain.contains("semantics")) + } + + @Test + fun `identifies enclosing call`() { + val source = """ + @Composable + fun MyScreen() { + IconButton(onClick = {}) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = null + ) + } + } + """.trimIndent() + + val result = KotlinFileScanner.scan(source, "test.kt") + val icon = result.allCalls.first { it.name == "Icon" } + assertEquals("IconButton", icon.enclosingCallName) + } + + @Test + fun `parseArguments handles positional args`() { + val args = KotlinFileScanner.parseArguments("""(Icons.Default.Star, "hello")""") + assertEquals("Icons.Default.Star", args["__pos_0"]) + assertEquals("\"hello\"", args["__pos_1"]) + } + + @Test + fun `parseArguments handles mixed positional and named`() { + val args = KotlinFileScanner.parseArguments("""(Icons.Default.Star, contentDescription = "star")""") + assertEquals("Icons.Default.Star", args["__pos_0"]) + assertEquals("\"star\"", args["contentDescription"]) + } + + @Test + fun `ignores non-composable functions`() { + val source = """ + fun helperFunction() { + val x = Icon("something") + } + + @Composable + fun MyScreen() { + Text("Hello") + } + """.trimIndent() + + val result = KotlinFileScanner.scan(source, "test.kt") + assertEquals(1, result.composables.size) + assertEquals("MyScreen", result.composables[0].name) + // Only calls within composables should be detected + val textCalls = result.allCalls.filter { it.name == "Text" } + assertEquals(1, textCalls.size) + } + + @Test + fun `handles nested braces correctly`() { + val source = """ + @Composable + fun MyScreen() { + Column(modifier = Modifier) { + if (condition) { + Text("inside if") + } + Row(modifier = Modifier) { + Icon(Icons.Default.Star, contentDescription = null) + } + } + } + """.trimIndent() + + val result = KotlinFileScanner.scan(source, "test.kt") + assertEquals(1, result.composables.size) + assertTrue(result.allCalls.any { it.name == "Column" }) + assertTrue(result.allCalls.any { it.name == "Row" }) + assertTrue(result.allCalls.any { it.name == "Icon" }) + } + + @Test + fun `extractBalancedBlock handles nested parentheses`() { + val lines = listOf( + "Icon(", + " imageVector = Icons.Default.Star,", + " contentDescription = stringResource(R.string.desc)", + ")" + ) + val block = KotlinFileScanner.extractBalancedBlock(lines, 0, 4, '(', ')') + assertTrue(block.contains("imageVector")) + assertTrue(block.contains("contentDescription")) + assertTrue(block.contains("stringResource")) + } + + @Test + fun `detects private composables`() { + val source = """ + @Composable + private fun InternalComponent() { + Text("Internal") + } + """.trimIndent() + + val result = KotlinFileScanner.scan(source, "test.kt") + assertEquals(1, result.composables.size) + assertEquals("InternalComponent", result.composables[0].name) + } +} diff --git a/Formula/a11y-check-android.rb b/Formula/a11y-check-android.rb new file mode 100644 index 0000000..0221828 --- /dev/null +++ b/Formula/a11y-check-android.rb @@ -0,0 +1,25 @@ +class A11yCheckAndroid < Formula + desc "Static accessibility checker for Jetpack Compose (WCAG 2.2)" + homepage "https://github.com/cvs-health/android-compose-accessibility-techniques" + version "0.1.0" + license "Apache-2.0" + + url "https://github.com/cvs-health/android-compose-accessibility-techniques/releases/download/v#{version}/a11y-check-android-#{version}.jar" + # To update sha256: shasum -a 256 A11yAgent/build/libs/a11y-check-android-0.1.0.jar + sha256 "PLACEHOLDER_UPDATE_ON_RELEASE" + + depends_on "openjdk@21" + + def install + libexec.install "a11y-check-android-#{version}.jar" + + (bin/"a11y-check-android").write <<~EOS + #!/bin/bash + exec "#{Formula["openjdk@21"].opt_bin}/java" -jar "#{libexec}/a11y-check-android-#{version}.jar" "$@" + EOS + end + + test do + assert_match "Available rules", shell_output("#{bin}/a11y-check-android --list-rules") + end +end diff --git a/README.md b/README.md index 8d3c7e1..b0466f9 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,70 @@ Since some of the code demonstrates the effect of inaccessible coding practices, - Other - [x] [Compose Semantics automated testing](doc/AutomatedComposeAccessibilityTesting.md) +## Accessibility static analysis tools + +This project includes static analysis tools and an IDE plugin for detecting Compose accessibility issues: + +### A11yAgent (a11y-check-android) + +A standalone CLI tool with **32 rules** mapped to WCAG 2.2 criteria, WCAG scoring (0-100), trend tracking, baseline suppression, and multiple output formats (terminal, JSON, HTML, SARIF, Gradle). Runs on-demand via `./gradlew a11yCheck` with clickable results in Android Studio's Build tab. + +```bash +./gradlew a11yCheck # Run check + generate HTML report +java -jar A11yAgent/build/libs/a11y-check-android-0.1.0.jar app/src/main/java # Run directly +``` + +See [`A11yAgent/README.md`](A11yAgent/README.md) for full documentation. + +### Custom Android Lint rules (lint-checks) + +A custom Lint module with **32 rules** that integrate into Android's built-in Lint system. Shows **real-time squiggly underlines** in the Android Studio editor and appears in `./gradlew lint` HTML reports alongside standard Lint checks. Works behind any corporate proxy with zero external downloads. + +See [`lint-checks/README.md`](lint-checks/README.md) for details. + +### IDE Plugin (IntelliJ / Android Studio) + +An IntelliJ Platform plugin (`ide-plugin/`) that shows **real-time inline accessibility warnings** in the editor as you type — squiggly underlines with hover tooltips showing WCAG criteria and fix suggestions. Powered by the same 32-rule a11y-check-android engine. + +**Install from disk:** +```bash +./gradlew :A11yAgent:shadowJar # Build the analysis engine first +cd ide-plugin && ./gradlew buildPlugin # Build the plugin zip +``` +Then in Android Studio: **Settings > Plugins > gear icon > Install Plugin from Disk** — select `ide-plugin/build/distributions/compose-accessibility-checker-0.3.0.zip` and restart. Supports **Alt+Enter quick-fixes** for 6 rules with auto-fix support. + +**Install from JetBrains Marketplace** (once published): **Settings > Plugins > Marketplace** — search "Compose Accessibility Checker". + +See [`ide-plugin/README.md`](ide-plugin/README.md) for full documentation including corporate proxy setup and publishing instructions. + +### Gradle Plugin + +A Gradle plugin (`gradle-plugin/`) for easy integration into any Android project: + +```groovy +// settings.gradle +includeBuild 'gradle-plugin' + +// app/build.gradle +plugins { id 'com.cvshealth.a11y' } +a11y { + minScore = 70 + format = 'gradle' + paths = ['src/main/java'] +} +``` + +Run `./gradlew a11yCheck` to analyze your project. See [`gradle-plugin/`](gradle-plugin/) for details. + +### Pre-commit Hook + +A git pre-commit hook (`.githooks/`) that checks staged `.kt` files for accessibility errors before each commit: + +```bash +bash .githooks/install-hooks.sh # One-time setup +``` + +Errors block the commit; warnings are informational. Skip with `git commit --no-verify`. ## Screenshots android-compose-accessibility-techniques app Home screen showing the three accessibility technique topic groups: Informative Content, Interactive Behaviors, and Specific Component Types. The Specific Component Types topic group is expanded and shows topics such as Accordion controls and Dropdown menus. @@ -64,7 +128,7 @@ Since some of the code demonstrates the effect of inaccessible coding practices, ## License android-compose-accessibility-techniques is licensed under under the Apache License, Version 2.0. See [LICENSE](LICENSE) file for more information. -Copyright 2023-2024 CVS Health and/or one of its affiliates +Copyright 2023-2025 CVS Health and/or one of its affiliates Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/app/build.gradle b/app/build.gradle index 46552c5..636b8a1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -75,6 +75,7 @@ dependencies { implementation "androidx.constraintlayout:constraintlayout-compose:1.1.1" implementation "androidx.navigation:navigation-compose:2.9.7" + lintChecks project(':lint-checks') testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' @@ -83,4 +84,41 @@ dependencies { androidTestImplementation "androidx.navigation:navigation-testing:2.9.7" debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-test-manifest' +} + +// a11y-check: runs on every build, produces clickable warnings + HTML report +tasks.register('a11yCheck', JavaExec) { + description = 'Run accessibility check on Compose source files' + group = 'verification' + def jarTask = project(':A11yAgent').tasks.named('shadowJar') + dependsOn jarTask + classpath = files(jarTask.map { it.archiveFile }) + args = ["${projectDir}/src/main/java", '--format', 'gradle'] + ignoreExitValue = true // don't fail the build, just show warnings +} + +tasks.register('a11yCheckHtml', JavaExec) { + description = 'Generate accessibility HTML report' + group = 'verification' + def jarTask = project(':A11yAgent').tasks.named('shadowJar') + dependsOn jarTask + classpath = files(jarTask.map { it.archiveFile }) + args = ["${projectDir}/src/main/java", '--format', 'html'] + + def reportFile = file("${buildDir}/reports/a11y-check.html") + doFirst { + reportFile.parentFile.mkdirs() + standardOutput = new FileOutputStream(reportFile) + } + doLast { + println "" + println "=====================================================================" + println " Accessibility report: file://${reportFile.absolutePath}" + println "=====================================================================" + } + ignoreExitValue = true +} + +tasks.named('a11yCheck').configure { + finalizedBy 'a11yCheckHtml' } \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7b92a13..065c470 100644 --- a/build.gradle +++ b/build.gradle @@ -4,5 +4,7 @@ plugins { id 'com.android.library' version '9.1.0' apply false id 'org.jetbrains.kotlin.android' version '2.3.20' apply false id 'org.jetbrains.kotlin.plugin.compose' version '2.3.20' apply false + id 'org.jetbrains.kotlin.jvm' version '2.3.20' apply false + id 'org.jetbrains.kotlin.plugin.serialization' version '2.3.20' apply false id 'org.jetbrains.dokka' version '2.2.0' } \ No newline at end of file diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts new file mode 100644 index 0000000..ac6dec7 --- /dev/null +++ b/gradle-plugin/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + `kotlin-dsl` + `java-gradle-plugin` +} + +group = "com.cvshealth.a11y" +version = "0.1.0" + +repositories { + mavenCentral() +} + +gradlePlugin { + plugins { + create("a11yPlugin") { + id = "com.cvshealth.a11y" + implementationClass = "com.cvshealth.a11y.gradle.A11yGradlePlugin" + displayName = "Compose Accessibility Checker" + description = "WCAG 2.2 static analysis for Jetpack Compose" + } + } +} diff --git a/gradle-plugin/settings.gradle.kts b/gradle-plugin/settings.gradle.kts new file mode 100644 index 0000000..f016cb4 --- /dev/null +++ b/gradle-plugin/settings.gradle.kts @@ -0,0 +1,8 @@ +rootProject.name = "a11y-gradle-plugin" + +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } +} diff --git a/gradle-plugin/src/main/kotlin/com/cvshealth/a11y/gradle/A11yCheckTask.kt b/gradle-plugin/src/main/kotlin/com/cvshealth/a11y/gradle/A11yCheckTask.kt new file mode 100644 index 0000000..44f20b7 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/com/cvshealth/a11y/gradle/A11yCheckTask.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.a11y.gradle + +import org.gradle.api.Action +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations +import org.gradle.process.JavaExecSpec +import javax.inject.Inject + +abstract class A11yCheckTask @Inject constructor( + private val execOperations: ExecOperations +) : DefaultTask() { + + @get:InputFile + abstract val jarFile: RegularFileProperty + + @get:Input + abstract val paths: ListProperty + + @get:Input + abstract val format: Property + + @get:Input + abstract val minScore: Property + + @get:Input + abstract val failOnError: Property + + @get:Input + @get:Optional + abstract val disable: ListProperty + + @TaskAction + fun run() { + val jar = jarFile.get().asFile + if (!jar.exists()) { + throw GradleException( + "A11yAgent JAR not found at: ${jar.absolutePath}\n" + + "Build it first with: ./gradlew :A11yAgent:shadowJar" + ) + } + + val cliArgs = mutableListOf() + cliArgs.addAll(paths.get()) + cliArgs.addAll(listOf("--format", format.get())) + + val score = minScore.get() + if (score > 0) { + cliArgs.addAll(listOf("--min-score", score.toString())) + } + + val disabledChecks = disable.getOrElse(emptyList()) + if (disabledChecks.isNotEmpty()) { + cliArgs.addAll(listOf("--disable", disabledChecks.joinToString(","))) + } + + logger.lifecycle("Running A11y accessibility check...") + + val jarRef = jar + val argsRef = cliArgs.toList() + + val result = execOperations.javaexec(object : Action { + override fun execute(spec: JavaExecSpec) { + spec.classpath(jarRef) + spec.mainClass.set("com.cvshealth.a11y.agent.cli.A11yCheckKt") + spec.args(argsRef) + spec.isIgnoreExitValue = true + } + }) + + if (result.exitValue != 0) { + val message = "A11y accessibility check failed with exit code ${result.exitValue}." + if (failOnError.get()) { + throw GradleException(message) + } else { + logger.warn(message) + } + } else { + logger.lifecycle("A11y accessibility check passed.") + } + } +} diff --git a/gradle-plugin/src/main/kotlin/com/cvshealth/a11y/gradle/A11yExtension.kt b/gradle-plugin/src/main/kotlin/com/cvshealth/a11y/gradle/A11yExtension.kt new file mode 100644 index 0000000..f2b2dc0 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/com/cvshealth/a11y/gradle/A11yExtension.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2026 CVS Health + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.a11y.gradle + +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import javax.inject.Inject + +/** + * DSL extension for configuring the Compose Accessibility Checker. + * + * Example usage in a consuming project's build.gradle.kts: + * ```kotlin + * a11y { + * minScore.set(80) + * format.set("sarif") + * paths.set(listOf("src/main/java", "src/main/kotlin")) + * failOnError.set(true) + * disable.set(listOf("ContentDescriptionCheck")) + * } + * ``` + */ +abstract class A11yExtension @Inject constructor(objects: ObjectFactory) { + + /** + * Minimum accessibility score (0–100) required to pass. Defaults to 0. + */ + val minScore: Property = objects.property(Int::class.java).convention(0) + + /** + * Output format for the accessibility report. Defaults to "gradle". + * Other supported values: "sarif", "json". + */ + val format: Property = objects.property(String::class.java).convention("gradle") + + /** + * Source paths to scan for Compose UI code. Defaults to ["src/main/java"]. + */ + val paths: ListProperty = + objects.listProperty(String::class.java).convention(listOf("src/main/java")) + + /** + * Whether to fail the build when accessibility violations are found. Defaults to true. + */ + val failOnError: Property = objects.property(Boolean::class.java).convention(true) + + /** + * List of check IDs to disable. Defaults to empty (all checks enabled). + */ + val disable: ListProperty = objects.listProperty(String::class.java).convention(emptyList()) +} diff --git a/gradle-plugin/src/main/kotlin/com/cvshealth/a11y/gradle/A11yGradlePlugin.kt b/gradle-plugin/src/main/kotlin/com/cvshealth/a11y/gradle/A11yGradlePlugin.kt new file mode 100644 index 0000000..e3b97ba --- /dev/null +++ b/gradle-plugin/src/main/kotlin/com/cvshealth/a11y/gradle/A11yGradlePlugin.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.a11y.gradle + +import org.gradle.api.Action +import org.gradle.api.Plugin +import org.gradle.api.Project +import java.io.File + +class A11yGradlePlugin : Plugin { + + override fun apply(project: Project) { + val extension = project.extensions.create("a11y", A11yExtension::class.java) + val jarFile = locateShadowJar(project) + + val taskProvider = project.tasks.register("a11yCheck", A11yCheckTask::class.java) + taskProvider.configure(object : Action { + override fun execute(task: A11yCheckTask) { + task.group = "verification" + task.description = "Run WCAG 2.2 accessibility checks on Compose source files" + task.jarFile.set(jarFile) + task.paths.set(extension.paths) + task.format.set(extension.format) + task.minScore.set(extension.minScore) + task.failOnError.set(extension.failOnError) + task.disable.set(extension.disable) + } + }) + } + + private fun locateShadowJar(project: Project): File { + val rootDir = project.rootProject.projectDir + val candidates = listOf( + File(rootDir.parentFile, "A11yAgent/build/libs"), + File(rootDir, "A11yAgent/build/libs") + ) + for (dir in candidates) { + if (dir.isDirectory) { + val jar = dir.listFiles() + ?.filter { it.extension == "jar" && it.name.startsWith("a11y-check-android") } + ?.maxByOrNull { it.lastModified() } + if (jar != null) return jar + } + } + return File(candidates[1], "a11y-check-android-0.1.0.jar") + } +} diff --git a/ide-plugin/README.md b/ide-plugin/README.md new file mode 100644 index 0000000..7a4ce9f --- /dev/null +++ b/ide-plugin/README.md @@ -0,0 +1,139 @@ +# Compose Accessibility Checker — IDE Plugin + +An IntelliJ Platform plugin that shows **inline accessibility warnings** in the Android Studio editor as you type. It runs the [a11y-check-android](../A11yAgent/README.md) analysis engine with **32 rules mapped to WCAG 2.2** and displays results as squiggly underlines with hover tooltips. + +## What it does + +- Highlights Compose accessibility issues directly in the editor (warnings, errors, info) +- Hover over an underline to see the issue, WCAG criteria, and suggested fix +- Works on any `.kt` file — no build step required, analysis runs as you type +- Severity levels map to IntelliJ's standard error/warning/weak-warning highlighting + +## Install from disk (manual) + +If the plugin is not yet on the JetBrains Marketplace, install it manually: + +### Prerequisites + +1. **Build the A11yAgent shadow JAR** (from the repo root): + ```bash + ./gradlew :A11yAgent:shadowJar + ``` + +2. **Set your Android Studio path** (if not the default). Create `ide-plugin/gradle.properties`: + ```properties + androidStudioPath=/path/to/Android Studio.app/Contents + ``` + On Linux: `/path/to/android-studio/` + On Windows: `C:\\Program Files\\Android\\Android Studio\\` + +3. **Corporate proxy (Zscaler/etc.)**: If behind a corporate SSL proxy, import the CA certificate into your JDK trust store: + ```bash + keytool -importcert -file proxy-ca.pem \ + -keystore $JAVA_HOME/lib/security/cacerts \ + -alias corporate-proxy + ``` + +### Build the plugin + +```bash +cd ide-plugin +./gradlew buildPlugin +``` + +The plugin zip is created at: +``` +ide-plugin/build/distributions/compose-accessibility-checker-0.3.0.zip +``` + +### Install in Android Studio + +1. Open **Settings > Plugins** +2. Click the **gear icon** (top right) > **Install Plugin from Disk...** +3. Select `compose-accessibility-checker-0.3.0.zip` +4. Restart Android Studio + +After restart, open any Compose `.kt` file — accessibility issues appear as squiggly underlines. Hover to see WCAG criteria and fix suggestions. + +### Uninstall + +**Settings > Plugins** > find "Compose Accessibility Checker" > **Uninstall** > Restart. + +## Install from JetBrains Marketplace + +Once published, install directly from Android Studio: + +1. Open **Settings > Plugins > Marketplace** +2. Search for **"Compose Accessibility Checker"** +3. Click **Install** > Restart + +## Publishing to JetBrains Marketplace + +To publish a new version: + +1. Create an account at [plugins.jetbrains.com](https://plugins.jetbrains.com) +2. Generate a **Permanent Upload Token** at [Account > Tokens](https://plugins.jetbrains.com/author/me/tokens) +3. First-time upload — use the web UI: + - Go to [Upload Plugin](https://plugins.jetbrains.com/plugin/add) + - Select the `.zip` from `build/distributions/` + - JetBrains reviews and approves (1-3 business days) +4. Subsequent updates — use Gradle: + ```bash + cd ide-plugin + ./gradlew publishPlugin -PintellijPublishToken=YOUR_TOKEN + ``` + Or set `ORG_GRADLE_PROJECT_intellijPublishToken` as an environment variable. + +### Version bumps + +Update the version in `build.gradle.kts`: +```kotlin +version = "0.3.0" +``` + +And add change notes in `src/main/resources/META-INF/plugin.xml` under ``. + +## Rules + +The plugin runs all 32 rules from the a11y-check-android engine. See [`A11yAgent/README.md`](../A11yAgent/README.md) for the full rule list with WCAG mappings. + +## Architecture + +``` +ide-plugin/ + src/main/kotlin/.../ + A11yExternalAnnotator.kt — ExternalAnnotator that runs analysis and creates annotations + A11yAnnotatorInfo.kt — Data class holding file content and path + src/main/resources/META-INF/ + plugin.xml — Plugin descriptor + build.gradle.kts — Standalone Gradle build (IntelliJ Platform Plugin v2.x) + settings.gradle.kts — Standalone project settings +``` + +The plugin uses IntelliJ's `ExternalAnnotator` extension point: +1. `collectInformation()` — captures file text and path for Kotlin files +2. `doAnnotate()` — runs the A11yAgent `RuleRegistry.analyze()` engine +3. `apply()` — converts diagnostics to editor annotations with severity and tooltips + +## Relationship to lint-checks + +| | IDE Plugin | lint-checks | +|---|---|---| +| **When it runs** | Real-time as you type | During `./gradlew lint` | +| **Where results appear** | Editor squiggly underlines + hover tooltips | HTML report + Build tab | +| **Rules** | 31 (via A11yAgent engine) | 31 (via Android Lint detectors) | +| **Install** | Plugin zip (manual or Marketplace) | `lintChecks project(':lint-checks')` in build.gradle | +| **Requires** | A11yAgent shadow JAR | Nothing extra | + +Both provide the same 31 accessibility rules. Use the IDE plugin for real-time feedback while coding, and lint-checks for CI/CD and build reports. + +## Contributor Guide + +1. Before contributing to this CVS Health sponsored project, you will need to sign the associated [Contributor License Agreement](https://forms.office.com/r/9e9VmE7qLW). +2. See the [contributing](../CONTRIBUTING.md) page. + +## License + +Licensed under the Apache License, Version 2.0. See [LICENSE](../LICENSE) for details. + +Copyright 2026 CVS Health and/or one of its affiliates diff --git a/ide-plugin/build.gradle.kts b/ide-plugin/build.gradle.kts new file mode 100644 index 0000000..b13f089 --- /dev/null +++ b/ide-plugin/build.gradle.kts @@ -0,0 +1,64 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("org.jetbrains.kotlin.jvm") version "2.3.20" + id("org.jetbrains.intellij.platform") version "2.5.0" +} + +// Path to local Android Studio installation — no network download needed. +// Override via gradle.properties: androidStudioPath=/your/path/Android Studio.app/Contents +val androidStudioPath: String = providers.gradleProperty("androidStudioPath") + .getOrElse("/Users/c640728/Applications/Android Studio.app/Contents") + +repositories { + mavenCentral() + intellijPlatform { + localPlatformArtifacts() + // JetBrains repos needed for java-compiler-ant-tasks build dependency. + // If blocked by corporate proxy, import the CVS CA certificate into JDK trust store: + // keytool -importcert -file cvs-ca.pem -keystore $JAVA_HOME/lib/security/cacerts -alias cvs-proxy + defaultRepositories() + } +} + +intellijPlatform { + pluginConfiguration { + id = "com.cvshealth.a11y.compose-checker" + name = "Compose Accessibility Checker" + version = "0.3.0" + ideaVersion { + sinceBuild = "253" + } + } + buildSearchableOptions = false + + // Publish to JetBrains Marketplace: + // 1. Get a token at https://plugins.jetbrains.com/author/me/tokens + // 2. Run: ./gradlew publishPlugin -Dintellij.publish.token=YOUR_TOKEN + // Or set ORG_GRADLE_PROJECT_intellijPublishToken env var + publishing { + token = providers.gradleProperty("intellijPublishToken") + } +} + +dependencies { + intellijPlatform { + local(androidStudioPath) + } + // Use the A11yAgent shadow JAR — build it first with: ./gradlew :A11yAgent:shadowJar + implementation(files("../A11yAgent/build/libs/a11y-check-android-0.1.0.jar")) +} diff --git a/ide-plugin/gradle/gradle-daemon-jvm.properties b/ide-plugin/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..6c1139e --- /dev/null +++ b/ide-plugin/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 diff --git a/ide-plugin/gradle/wrapper/gradle-wrapper.jar b/ide-plugin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/ide-plugin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/ide-plugin/gradle/wrapper/gradle-wrapper.properties b/ide-plugin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..fc458cf --- /dev/null +++ b/ide-plugin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Jun 14 06:24:15 EDT 2023 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/ide-plugin/gradlew b/ide-plugin/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/ide-plugin/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/ide-plugin/settings.gradle.kts b/ide-plugin/settings.gradle.kts new file mode 100644 index 0000000..8709f3e --- /dev/null +++ b/ide-plugin/settings.gradle.kts @@ -0,0 +1,8 @@ +rootProject.name = "compose-accessibility-checker" + +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } +} diff --git a/ide-plugin/src/main/kotlin/com/cvshealth/accessibility/plugin/A11yAnnotatorInfo.kt b/ide-plugin/src/main/kotlin/com/cvshealth/accessibility/plugin/A11yAnnotatorInfo.kt new file mode 100644 index 0000000..d6d18e1 --- /dev/null +++ b/ide-plugin/src/main/kotlin/com/cvshealth/accessibility/plugin/A11yAnnotatorInfo.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.accessibility.plugin + +/** + * Data collected from a PSI file for accessibility analysis. + */ +data class A11yAnnotatorInfo( + val fileContent: String, + val filePath: String +) diff --git a/ide-plugin/src/main/kotlin/com/cvshealth/accessibility/plugin/A11yExternalAnnotator.kt b/ide-plugin/src/main/kotlin/com/cvshealth/accessibility/plugin/A11yExternalAnnotator.kt new file mode 100644 index 0000000..449f037 --- /dev/null +++ b/ide-plugin/src/main/kotlin/com/cvshealth/accessibility/plugin/A11yExternalAnnotator.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.accessibility.plugin + +import com.cvshealth.a11y.agent.core.A11yDiagnostic +import com.cvshealth.a11y.agent.core.A11ySeverity +import com.cvshealth.a11y.agent.core.RuleRegistry +import com.intellij.lang.annotation.AnnotationHolder +import com.intellij.lang.annotation.ExternalAnnotator +import com.intellij.lang.annotation.HighlightSeverity +import com.intellij.openapi.editor.Document +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiFile + +class A11yExternalAnnotator : ExternalAnnotator>() { + + override fun collectInformation(file: PsiFile): A11yAnnotatorInfo? { + if (file.fileType.name != "Kotlin") return null + val virtualFile = file.virtualFile ?: return null + return A11yAnnotatorInfo( + fileContent = file.text, + filePath = virtualFile.path + ) + } + + override fun doAnnotate(info: A11yAnnotatorInfo): List { + val registry = RuleRegistry() + return registry.analyze(info.fileContent, info.filePath) + } + + override fun apply(file: PsiFile, diagnostics: List, holder: AnnotationHolder) { + val document: Document = file.viewProvider.document ?: return + + for (diag in diagnostics) { + val lineIndex = diag.line - 1 + if (lineIndex < 0 || lineIndex >= document.lineCount) continue + + val lineStartOffset = document.getLineStartOffset(lineIndex) + val lineEndOffset = document.getLineEndOffset(lineIndex) + val textRange = TextRange(lineStartOffset, lineEndOffset) + + val severity = when (diag.severity) { + A11ySeverity.ERROR -> HighlightSeverity.ERROR + A11ySeverity.WARNING -> HighlightSeverity.WARNING + A11ySeverity.INFO -> HighlightSeverity.WEAK_WARNING + } + + val wcag = diag.wcagCriteria.joinToString(", ") + + // Short message shown on hover — fits without "toggle info" + val message = buildString { + append("[a11y] ${diag.message}") + if (wcag.isNotEmpty()) append(" (WCAG $wcag)") + } + + // Full HTML tooltip with fix suggestion shown in expanded view + val tooltip = buildString { + append("[a11y] ${diag.message}") + if (wcag.isNotEmpty()) append("
WCAG: $wcag") + if (diag.suggestion != null) append("
Fix: ${diag.suggestion}") + append("") + } + + val builder = holder.newAnnotation(severity, message) + .tooltip(tooltip) + .range(textRange) + + if (diag.fix != null) { + builder.withFix(A11yQuickFix(diag.fix!!)) + } + + builder.create() + } + } +} diff --git a/ide-plugin/src/main/kotlin/com/cvshealth/accessibility/plugin/A11yQuickFix.kt b/ide-plugin/src/main/kotlin/com/cvshealth/accessibility/plugin/A11yQuickFix.kt new file mode 100644 index 0000000..5fb50ff --- /dev/null +++ b/ide-plugin/src/main/kotlin/com/cvshealth/accessibility/plugin/A11yQuickFix.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.accessibility.plugin + +import com.cvshealth.a11y.agent.core.A11yFix +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiFile + +class A11yQuickFix(private val fix: A11yFix) : IntentionAction { + + override fun getText(): String = "[a11y] ${fix.description}" + + override fun getFamilyName(): String = "Accessibility fix" + + override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean = true + + override fun startInWriteAction(): Boolean = true + + override fun invoke(project: Project, editor: Editor?, file: PsiFile?) { + val document = editor?.document ?: return + if (fix.startOffset < 0 || fix.endOffset > document.textLength || + fix.startOffset > fix.endOffset) return + document.replaceString(fix.startOffset, fix.endOffset, fix.replacementText) + } +} diff --git a/ide-plugin/src/main/resources/META-INF/plugin.xml b/ide-plugin/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..987a37b --- /dev/null +++ b/ide-plugin/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,54 @@ + + + com.cvshealth.a11y.compose-checker + Compose Accessibility Checker + CVS Health + + + 0.3.0 +
    +
  • Alt+Enter quick-fixes for rules with auto-fix support (6 rules)
  • +
  • New auto-fixes: clickable missing onClickLabel, IconButton missing label, toggle label
  • +
+ 0.2.0 +
    +
  • Improved tooltip UX — WCAG criteria visible on hover without "toggle info"
  • +
  • Fix suggestions shown in formatted HTML tooltip
  • +
+ 0.1.0 +
    +
  • Initial release with 31 accessibility rules mapped to WCAG 2.2
  • +
  • Inline squiggly underlines for Compose accessibility issues
  • +
  • Severity levels: Error, Warning, and Info
  • +
+ ]]>
+ + com.intellij.modules.platform + org.jetbrains.kotlin + + + + +
diff --git a/lint-checks/README.md b/lint-checks/README.md new file mode 100644 index 0000000..cebc54b --- /dev/null +++ b/lint-checks/README.md @@ -0,0 +1,131 @@ +# Custom Android Lint Accessibility Rules (lint-checks) + +Custom Android Lint rules for Jetpack Compose accessibility issues. These integrate into Android's built-in Lint system and appear in `./gradlew lint` HTML reports alongside standard Lint results. **All 32 rules show real-time squiggly underlines in the Android Studio editor.** + +## Included checks (32 rules) + +### UAST Method-Call Rules (ComposeA11yDetector — 13 rules) + +| Issue ID | Severity | What it detects | WCAG | +|----------|----------|-----------------|------| +| `A11yIconMissingLabel` | Warning | `Icon()` or `Image()` with `contentDescription = null` and no parent semantics | 1.1.1 | +| `A11yEmptyContentDescription` | Warning | `contentDescription = ""` (empty string instead of null for decorative) | 1.1.1 | +| `A11yTextFieldMissingLabel` | Warning | `TextField` or `OutlinedTextField` without a `label` parameter | 4.1.2 | +| `A11yIconButtonMissingLabel` | Warning | `IconButton` without accessible label on inner Icon or modifier | 4.1.2 | +| `A11ySliderMissingLabel` | Warning | `Slider` or `RangeSlider` without contentDescription or labeled container | 4.1.2 | +| `A11yDropdownMissingLabel` | Warning | `ExposedDropdownMenuBox` without labeled TextField inside | 4.1.2 | +| `A11yTabMissingLabel` | Warning | `Tab` or `LeadingIconTab` without text label | 2.4.2 | +| `A11yMissingPaneTitle` | Warning | `Scaffold` without pane title semantics or TopAppBar title | 2.4.2 | +| `A11yToggleMissingLabel` | Warning | `Checkbox`, `Switch`, `TriStateCheckbox` without associated label | 4.1.2 | +| `A11yLabelContainsRoleImage` | Warning | contentDescription contains role words like "image", "icon" | 1.1.1 | +| `A11yLabelContainsRoleButton` | Warning | Button label contains the word "button" | 4.1.2 | +| `A11yLabelInNameError` | Error | Visible text not contained in accessible name (contentDescription) | 2.5.3 | +| `A11yLabelInNameWarning` | Warning | Visible text not at the start of accessible name | 2.5.3 | + +### Source Text Scan Rules (ComposeTextScanDetector — 12 rules) + +| Issue ID | Severity | What it detects | WCAG | +|----------|----------|-----------------|------| +| `A11yFixedFontSize` | Warning | Hardcoded `.sp` font sizes instead of `MaterialTheme.typography` | 1.4.4 | +| `A11yMaxLinesOne` | Info | `maxLines = 1` on Text (truncation risk for larger text) | 1.4.4 | +| `A11yHeadingSemanticsMissing` | Warning | Text with heading typography but no `semantics { heading() }` | 2.4.6, 1.3.1 | +| `A11yFakeHeadingInLabel` | Warning | contentDescription containing the word "heading" | 1.3.1 | +| `A11yClickableMissingRole` | Warning | `.clickable()` without a `role` parameter | 4.1.2 | +| `A11yHardcodedColor` | Warning | Hardcoded `Color.X` or `Color(0x...)` values outside theme files | 1.4.3 | +| `A11yVisuallyDisabledNotSemantically` | Warning | `.alpha()` < 0.5 without `enabled = false` | 4.1.2 | +| `A11yGenericLinkText` | Warning | Generic link text ("click here", "learn more") in clickable context | 2.4.4 | +| `A11yReduceMotion` | Info | Animation APIs without reduced motion check | 2.3.1 | +| `A11yGestureMissingAlternative` | Warning | Custom gestures without keyboard/button alternative | 2.1.1 | +| `A11yTimingAdjustable` | Info | Hardcoded `delay()`/`withTimeout()` durations | 2.2.1 | +| `A11yDialogFocusManagement` | Warning | Dialog/BottomSheet without FocusRequester | 2.4.3 | + +### Form Control Rules (ComposeFormDetector — 2 rules) + +| Issue ID | Severity | What it detects | WCAG | +|----------|----------|-----------------|------| +| `A11yInputPurpose` | Info | TextField with keyboard type but no autofill/content type | 1.3.5 | +| `A11yRadioGroupMissing` | Warning | RadioButton not inside selectableGroup container | 1.3.1 | + +### Structural Rules (ComposeStructureDetector — 4 rules) + +| Issue ID | Severity | What it detects | WCAG | +|----------|----------|-----------------|------| +| `A11yHiddenWithInteractiveChildren` | Warning | `clearAndSetSemantics` on container with interactive children | 4.1.2 | +| `A11yAccessibilityGrouping` | Info | Clickable Row/Column with Icon+Text but no mergeDescendants | 1.3.1 | +| `A11yBoxChildOrder` | Info | Box with overlapping children without traversalIndex | 1.3.2 | +| `A11yButtonUsedAsLink` | Warning | Button that navigates to a URL (should be a link) | 2.4.4 | + +### Color Contrast Rules (ComposeColorContrastDetector — 1 rule) + +| Issue ID | Severity | What it detects | WCAG | +|----------|----------|-----------------|------| +| `A11yColorContrast` | Warning | Hardcoded color pairs below WCAG 4.5:1 contrast ratio | 1.4.3 | + +## How it works + +The module provides five detectors: + +- **`ComposeA11yDetector`** — UAST-based detector using `getApplicableMethodNames()` to intercept Composable calls and inspect their arguments. +- **`ComposeTextScanDetector`** — Source text scanner using regex patterns to find accessibility issues across file content. +- **`ComposeFormDetector`** — UAST-based detector for form control and input-specific rules. +- **`ComposeStructureDetector`** — Hybrid UAST + source text detector for structural/grouping rules. +- **`ComposeColorContrastDetector`** — Source text scanner that computes WCAG contrast ratios for hardcoded color pairs. + +## Setup + +The `app/build.gradle` already includes: + +```groovy +dependencies { + lintChecks project(':lint-checks') +} +``` + +And `settings.gradle` includes: + +```groovy +include ':lint-checks' +``` + +## Running + +```bash +# Run all lint checks (includes these custom rules) +./gradlew lint + +# Results at: +# app/build/reports/lint-results-debug.html +``` + +Custom accessibility issues appear under the **"Accessibility"** category in the Lint HTML report, alongside Android's built-in accessibility checks. + +## Viewing in Android Studio + +Custom lint rules appear as **real-time squiggly underlines** in the editor. Configure severity in **Settings > Inspections > Android Lint: Accessibility**. + +## Relationship to A11yAgent + +This module provides the same 32 rules as A11yAgent, integrated into Android's native Lint system for inline editor feedback. For WCAG scoring, trend tracking, baseline suppression, and additional output formats, see [`A11yAgent/`](../A11yAgent/README.md). + +## Contributor Guide + +1. Before contributing to this CVS Health sponsored project, you will need to sign the associated [Contributor License Agreement](https://forms.office.com/r/9e9VmE7qLW). +2. See the [contributing](../CONTRIBUTING.md) page. + +## License +lint-checks is licensed under the Apache License, Version 2.0. See [LICENSE](../LICENSE) file for more information. + +Copyright 2026 CVS Health and/or one of its affiliates + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. diff --git a/lint-checks/build.gradle b/lint-checks/build.gradle new file mode 100644 index 0000000..679a865 --- /dev/null +++ b/lint-checks/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + compileOnly 'com.android.tools.lint:lint-api:32.1.1' + compileOnly 'com.android.tools.lint:lint-checks:32.1.1' + + testImplementation 'com.android.tools.lint:lint-tests:32.1.1' + testImplementation 'com.android.tools.lint:lint-api:32.1.1' + testImplementation "junit:junit:4.13.2" +} + +jar { + manifest { + attributes("Lint-Registry-v2": "com.cvshealth.accessibility.lint.A11yIssueRegistry") + } +} diff --git a/lint-checks/src/main/kotlin/com/cvshealth/accessibility/lint/A11yIssueRegistry.kt b/lint-checks/src/main/kotlin/com/cvshealth/accessibility/lint/A11yIssueRegistry.kt new file mode 100644 index 0000000..b8b6d4f --- /dev/null +++ b/lint-checks/src/main/kotlin/com/cvshealth/accessibility/lint/A11yIssueRegistry.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.accessibility.lint + +import com.android.tools.lint.client.api.IssueRegistry +import com.android.tools.lint.client.api.Vendor +import com.android.tools.lint.detector.api.CURRENT_API +import com.android.tools.lint.detector.api.Issue + +class A11yIssueRegistry : IssueRegistry() { + override val issues: List = listOf( + // ComposeA11yDetector — UAST method-call rules (11) + ComposeA11yDetector.ICON_MISSING_LABEL, + ComposeA11yDetector.EMPTY_CONTENT_DESCRIPTION, + ComposeA11yDetector.TEXTFIELD_MISSING_LABEL, + ComposeA11yDetector.ICON_BUTTON_MISSING_LABEL, + ComposeA11yDetector.SLIDER_MISSING_LABEL, + ComposeA11yDetector.DROPDOWN_MISSING_LABEL, + ComposeA11yDetector.TAB_MISSING_LABEL, + ComposeA11yDetector.MISSING_PANE_TITLE, + ComposeA11yDetector.TOGGLE_MISSING_LABEL, + ComposeA11yDetector.LABEL_CONTAINS_ROLE_IMAGE, + ComposeA11yDetector.LABEL_CONTAINS_ROLE_BUTTON, + ComposeA11yDetector.LABEL_IN_NAME_ERROR, + ComposeA11yDetector.LABEL_IN_NAME_WARNING, + + // ComposeTextScanDetector — source text scan rules (12) + ComposeTextScanDetector.FIXED_FONT_SIZE, + ComposeTextScanDetector.MAX_LINES_ONE, + ComposeTextScanDetector.HEADING_SEMANTICS_MISSING, + ComposeTextScanDetector.FAKE_HEADING_IN_LABEL, + ComposeTextScanDetector.CLICKABLE_MISSING_ROLE, + ComposeTextScanDetector.HARDCODED_COLOR, + ComposeTextScanDetector.VISUALLY_DISABLED_NOT_SEMANTICALLY, + ComposeTextScanDetector.GENERIC_LINK_TEXT, + ComposeTextScanDetector.REDUCE_MOTION, + ComposeTextScanDetector.GESTURE_MISSING_ALTERNATIVE, + ComposeTextScanDetector.TIMING_ADJUSTABLE, + ComposeTextScanDetector.DIALOG_FOCUS_MANAGEMENT, + + // ComposeFormDetector — form control rules (2) + ComposeFormDetector.INPUT_PURPOSE, + ComposeFormDetector.RADIO_GROUP_MISSING, + + // ComposeStructureDetector — structural rules (4) + ComposeStructureDetector.HIDDEN_WITH_INTERACTIVE_CHILDREN, + ComposeStructureDetector.ACCESSIBILITY_GROUPING, + ComposeStructureDetector.BOX_CHILD_ORDER, + ComposeStructureDetector.BUTTON_USED_AS_LINK, + + // ComposeColorContrastDetector — contrast rules (1) + ComposeColorContrastDetector.COLOR_CONTRAST, + ) + + override val api: Int = CURRENT_API + + override val vendor: Vendor = Vendor( + vendorName = "CVS Health Accessibility", + feedbackUrl = "https://github.com/cvs-health/android-compose-accessibility-techniques/issues" + ) +} diff --git a/lint-checks/src/main/kotlin/com/cvshealth/accessibility/lint/ComposeA11yDetector.kt b/lint-checks/src/main/kotlin/com/cvshealth/accessibility/lint/ComposeA11yDetector.kt new file mode 100644 index 0000000..e192026 --- /dev/null +++ b/lint-checks/src/main/kotlin/com/cvshealth/accessibility/lint/ComposeA11yDetector.kt @@ -0,0 +1,649 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.accessibility.lint + +import com.android.tools.lint.detector.api.Category +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.Severity +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.intellij.psi.PsiMethod +import org.jetbrains.uast.UCallExpression +import org.jetbrains.uast.ULiteralExpression + +class ComposeA11yDetector : Detector(), SourceCodeScanner { + + override fun getApplicableMethodNames() = listOf( + "Icon", "Image", + "TextField", "OutlinedTextField", + "IconButton", + "Slider", "RangeSlider", + "ExposedDropdownMenuBox", + "Tab", "LeadingIconTab", + "Scaffold", + "Checkbox", "TriStateCheckbox", "Switch", + "Button", "TextButton", "OutlinedButton", "FilledTonalButton", "ElevatedButton", + ) + + override fun visitMethodCall( + context: JavaContext, + node: UCallExpression, + method: PsiMethod + ) { + when (node.methodName) { + "Icon", "Image" -> { + checkContentDescription(context, node, method) + checkLabelContainsRoleImage(context, node, method) + } + "TextField", "OutlinedTextField" -> checkTextFieldLabel(context, node, method) + "IconButton" -> { + checkIconButtonLabel(context, node, method) + checkLabelInName(context, node) + } + "Slider", "RangeSlider" -> checkSliderLabel(context, node, method) + "ExposedDropdownMenuBox" -> checkDropdownLabel(context, node) + "Tab", "LeadingIconTab" -> { + checkTabLabel(context, node, method) + checkLabelInName(context, node) + } + "Scaffold" -> checkPaneTitle(context, node) + "Checkbox", "TriStateCheckbox", "Switch" -> checkToggleLabel(context, node) + "Button", "TextButton", "OutlinedButton", "FilledTonalButton", "ElevatedButton" -> { + checkLabelContainsRoleButton(context, node) + checkLabelInName(context, node) + } + } + } + + // --- Existing rules --- + + private fun checkContentDescription( + context: JavaContext, + node: UCallExpression, + method: PsiMethod + ) { + val params = method.parameterList.parameters + val cdIndex = params.indexOfFirst { it.name == "contentDescription" } + if (cdIndex == -1) return + + val arg = node.getArgumentForParameter(cdIndex) ?: return + + if (arg is ULiteralExpression && arg.isNull) { + var parent = node.uastParent + while (parent != null) { + if (parent is UCallExpression && parent.methodName == "IconButton") { + return + } + parent = parent.uastParent + } + + context.report( + ICON_MISSING_LABEL, + node, + context.getNameLocation(node), + "${node.methodName} has `contentDescription = null`. " + + "Provide a descriptive label for screen reader users." + ) + } + + if (arg is ULiteralExpression && arg.value is String && (arg.value as String).isEmpty()) { + context.report( + EMPTY_CONTENT_DESCRIPTION, + node, + context.getNameLocation(node), + "Empty `contentDescription` makes the element invisible to screen readers. " + + "Use `null` for decorative elements or provide a meaningful description." + ) + } + } + + private fun checkTextFieldLabel( + context: JavaContext, + node: UCallExpression, + method: PsiMethod + ) { + val params = method.parameterList.parameters + val labelIndex = params.indexOfFirst { it.name == "label" } + if (labelIndex == -1) return + + val arg = node.getArgumentForParameter(labelIndex) + if (arg == null || (arg is ULiteralExpression && arg.isNull)) { + context.report( + TEXTFIELD_MISSING_LABEL, + node, + context.getNameLocation(node), + "${node.methodName} is missing a `label` parameter. " + + "Screen reader users need labels to identify input fields." + ) + } + } + + // --- New rules --- + + private fun checkIconButtonLabel( + context: JavaContext, + node: UCallExpression, + method: PsiMethod + ) { + val params = method.parameterList.parameters + val cdIndex = params.indexOfFirst { it.name == "contentDescription" } + // IconButton doesn't have contentDescription param — check onClick lambda body + val sourceText = node.asSourceString() + + // Check modifier chain for semantics contentDescription + if (sourceText.contains("contentDescription") || sourceText.contains("clearAndSetSemantics")) { + return + } + + // Check if child Icon has a non-null contentDescription + if (sourceText.contains("contentDescription") && !sourceText.contains("contentDescription = null")) { + return + } + + // If the only Icon inside has null contentDescription or no contentDescription + if (cdIndex >= 0) return // Has its own CD param (unlikely for IconButton, but safe check) + + // Check if the body source has an Icon with a real contentDescription + val bodySource = sourceText + val hasLabeledIcon = bodySource.contains("contentDescription =") && + !bodySource.contains("contentDescription = null") + if (hasLabeledIcon) return + + // Check for onClick label or semantics label + if (bodySource.contains("onClickLabel") || bodySource.contains("semantics")) return + + context.report( + ICON_BUTTON_MISSING_LABEL, + node, + context.getNameLocation(node), + "IconButton has no accessible label. Add `contentDescription` to the inner Icon or add semantics to the IconButton." + ) + } + + private fun checkSliderLabel( + context: JavaContext, + node: UCallExpression, + method: PsiMethod + ) { + val sourceText = node.asSourceString() + if (sourceText.contains("contentDescription") || + sourceText.contains("clearAndSetSemantics") || + sourceText.contains("mergeDescendants") || + sourceText.contains("LabeledContent")) { + return + } + + // Walk parent to check for a labeling container + var parent = node.uastParent + var depth = 0 + while (parent != null && depth < 5) { + if (parent is UCallExpression) { + val parentSource = parent.asSourceString() + if (parentSource.contains("mergeDescendants") || + parentSource.contains("contentDescription") || + parentSource.contains("LabeledContent")) { + return + } + } + parent = parent.uastParent + depth++ + } + + context.report( + SLIDER_MISSING_LABEL, + node, + context.getNameLocation(node), + "${node.methodName} has no accessible label. Add semantics contentDescription or wrap with a labeled container." + ) + } + + private fun checkDropdownLabel( + context: JavaContext, + node: UCallExpression, + ) { + val sourceText = node.asSourceString() + if (sourceText.contains("label =") && sourceText.contains("TextField")) return + if (sourceText.contains("contentDescription")) return + + context.report( + DROPDOWN_MISSING_LABEL, + node, + context.getNameLocation(node), + "ExposedDropdownMenuBox has no accessible label. Include a labeled TextField inside it." + ) + } + + private fun checkTabLabel( + context: JavaContext, + node: UCallExpression, + method: PsiMethod + ) { + val params = method.parameterList.parameters + val textIndex = params.indexOfFirst { it.name == "text" } + if (textIndex >= 0) { + val arg = node.getArgumentForParameter(textIndex) + if (arg != null && !(arg is ULiteralExpression && arg.isNull)) return + } + + val sourceText = node.asSourceString() + if (sourceText.contains("contentDescription") || + sourceText.contains("Text(") || + sourceText.contains("text =")) { + return + } + + context.report( + TAB_MISSING_LABEL, + node, + context.getNameLocation(node), + "${node.methodName} has no text label. Screen reader users need a label to identify each tab." + ) + } + + private fun checkPaneTitle( + context: JavaContext, + node: UCallExpression, + ) { + val sourceText = node.asSourceString() + if (sourceText.contains("paneTitle") || sourceText.contains("testTag")) return + + // Check if topBar has a title + if (sourceText.contains("TopAppBar") && sourceText.contains("title =")) return + if (sourceText.contains("CenterAlignedTopAppBar") && sourceText.contains("title =")) return + + context.report( + MISSING_PANE_TITLE, + node, + context.getNameLocation(node), + "Scaffold has no pane title. Add `Modifier.semantics { paneTitle = \"...\" }` or use a TopAppBar with title." + ) + } + + private fun checkToggleLabel( + context: JavaContext, + node: UCallExpression, + ) { + val sourceText = node.asSourceString() + if (sourceText.contains("contentDescription") || + sourceText.contains("clearAndSetSemantics") || + sourceText.contains("toggleable") || + sourceText.contains("mergeDescendants")) { + return + } + + // Check parent for labeling Row/Column + var parent = node.uastParent + var depth = 0 + while (parent != null && depth < 5) { + if (parent is UCallExpression) { + val parentSource = parent.asSourceString() + if (parentSource.contains("mergeDescendants") || + parentSource.contains("toggleable") || + parentSource.contains("contentDescription") || + parentSource.contains("clearAndSetSemantics")) { + return + } + if (parent.methodName in listOf("Row", "Column") && parentSource.contains("Text(")) { + if (parentSource.contains("mergeDescendants")) return + } + } + parent = parent.uastParent + depth++ + } + + context.report( + TOGGLE_MISSING_LABEL, + node, + context.getNameLocation(node), + "${node.methodName} has no accessible label. Wrap in a Row with mergeDescendants and a Text, or add semantics contentDescription." + ) + } + + private fun checkLabelContainsRoleImage( + context: JavaContext, + node: UCallExpression, + method: PsiMethod + ) { + val params = method.parameterList.parameters + val cdIndex = params.indexOfFirst { it.name == "contentDescription" } + if (cdIndex == -1) return + + val arg = node.getArgumentForParameter(cdIndex) ?: return + if (arg is ULiteralExpression && arg.value is String) { + val desc = (arg.value as String).lowercase() + val roleWords = listOf("image", "icon", "picture", "graphic", "photo") + for (word in roleWords) { + if (desc.contains(word)) { + context.report( + LABEL_CONTAINS_ROLE_IMAGE, + arg, + context.getLocation(arg), + "Content description contains the role word \"$word\". TalkBack already announces the element type." + ) + break + } + } + } + } + + private fun checkLabelInName( + context: JavaContext, + node: UCallExpression, + ) { + val sourceText = node.asSourceString() + + // Extract visible text from Text("...") call in the composable body + val textPattern = Regex("""Text\s*\(\s*"((?:[^"\\]|\\.)*)"""") + val visibleMatch = textPattern.find(sourceText) ?: return + val visibleText = visibleMatch.groupValues[1].trim() + if (visibleText.isBlank()) return + + // Extract accessible name from contentDescription = "..." + val cdPattern = Regex("""contentDescription\s*=\s*"((?:[^"\\]|\\.)*)"""") + val accessibleMatch = cdPattern.find(sourceText) ?: return + val accessibleName = accessibleMatch.groupValues[1].trim() + if (accessibleName.isBlank()) return + + val visibleLower = visibleText.lowercase() + val accessibleLower = accessibleName.lowercase() + + when { + !accessibleLower.contains(visibleLower) -> + context.report( + LABEL_IN_NAME_ERROR, + node, + context.getNameLocation(node), + "Visible text \"$visibleText\" is not contained in " + + "contentDescription \"$accessibleName\". " + + "Voice Control users speak the visible text to activate controls." + ) + !accessibleLower.startsWith(visibleLower) -> + context.report( + LABEL_IN_NAME_WARNING, + node, + context.getNameLocation(node), + "Visible text \"$visibleText\" is not at the start of " + + "contentDescription \"$accessibleName\". " + + "WCAG 2.5.3 recommends the accessible name begins with the visible text." + ) + } + } + + private fun checkLabelContainsRoleButton( + context: JavaContext, + node: UCallExpression, + ) { + val sourceText = node.asSourceString() + val buttonPattern = Regex(""""[^"]*\b[Bb]utton\b[^"]*"""") + val match = buttonPattern.find(sourceText) ?: return + + context.report( + LABEL_CONTAINS_ROLE_BUTTON, + node, + context.getNameLocation(node), + "Button label contains the word \"button\". TalkBack already announces the element as a button." + ) + } + + companion object { + val ICON_MISSING_LABEL = Issue.create( + id = "A11yIconMissingLabel", + briefDescription = "Icon/Image missing content description", + explanation = """ + Icons and images should have a meaningful `contentDescription` for screen \ + reader users. Set `contentDescription` to a descriptive string. Use `null` \ + only for purely decorative elements inside a labeled container (e.g. \ + IconButton with its own contentDescription). + + WCAG 1.1.1 Non-text Content (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 8, + severity = Severity.WARNING, + implementation = Implementation( + ComposeA11yDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val EMPTY_CONTENT_DESCRIPTION = Issue.create( + id = "A11yEmptyContentDescription", + briefDescription = "Empty content description", + explanation = """ + An empty string for `contentDescription` makes the element discoverable \ + but unlabeled by screen readers. Use `null` if the element is decorative, \ + or provide a meaningful description. + + WCAG 1.1.1 Non-text Content (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 7, + severity = Severity.WARNING, + implementation = Implementation( + ComposeA11yDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val TEXTFIELD_MISSING_LABEL = Issue.create( + id = "A11yTextFieldMissingLabel", + briefDescription = "TextField missing label", + explanation = """ + TextField and OutlinedTextField should have a `label` parameter so screen \ + reader users can identify the input field's purpose. + + WCAG 4.1.2 Name, Role, Value (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 8, + severity = Severity.WARNING, + implementation = Implementation( + ComposeA11yDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val ICON_BUTTON_MISSING_LABEL = Issue.create( + id = "A11yIconButtonMissingLabel", + briefDescription = "IconButton missing accessible label", + explanation = """ + IconButton must have an accessible label. Either provide a `contentDescription` \ + on the inner Icon, or add semantics to the IconButton's modifier. + + WCAG 4.1.2 Name, Role, Value (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 8, + severity = Severity.WARNING, + implementation = Implementation( + ComposeA11yDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val SLIDER_MISSING_LABEL = Issue.create( + id = "A11ySliderMissingLabel", + briefDescription = "Slider missing accessible label", + explanation = """ + Slider and RangeSlider must have an associated label via semantics \ + contentDescription or a wrapping composable with mergeDescendants. + + WCAG 4.1.2 Name, Role, Value (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 8, + severity = Severity.WARNING, + implementation = Implementation( + ComposeA11yDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val DROPDOWN_MISSING_LABEL = Issue.create( + id = "A11yDropdownMissingLabel", + briefDescription = "Dropdown menu missing label", + explanation = """ + ExposedDropdownMenuBox must include a labeled TextField inside it so \ + screen reader users can identify the dropdown's purpose. + + WCAG 4.1.2 Name, Role, Value (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 8, + severity = Severity.WARNING, + implementation = Implementation( + ComposeA11yDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val TAB_MISSING_LABEL = Issue.create( + id = "A11yTabMissingLabel", + briefDescription = "Tab missing text label", + explanation = """ + Tab and LeadingIconTab should have a text label so screen reader users \ + can identify each tab. Use the `text` parameter or include a Text composable. + + WCAG 2.4.2 Page Titled (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 7, + severity = Severity.WARNING, + implementation = Implementation( + ComposeA11yDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val MISSING_PANE_TITLE = Issue.create( + id = "A11yMissingPaneTitle", + briefDescription = "Scaffold missing pane title", + explanation = """ + Scaffold screens should have a pane title for screen readers. Use \ + `Modifier.semantics { paneTitle = "..." }` or include a TopAppBar with title. + + WCAG 2.4.2 Page Titled (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 7, + severity = Severity.WARNING, + implementation = Implementation( + ComposeA11yDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val TOGGLE_MISSING_LABEL = Issue.create( + id = "A11yToggleMissingLabel", + briefDescription = "Toggle control missing label", + explanation = """ + Checkbox, Switch, and TriStateCheckbox must have an associated text label. \ + Wrap in a Row with `Modifier.toggleable()` and include a Text composable, \ + or add semantics contentDescription. + + WCAG 4.1.2 Name, Role, Value (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 8, + severity = Severity.WARNING, + implementation = Implementation( + ComposeA11yDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val LABEL_CONTAINS_ROLE_IMAGE = Issue.create( + id = "A11yLabelContainsRoleImage", + briefDescription = "Content description contains role name", + explanation = """ + Content descriptions should not contain role words like "image", "icon", \ + "picture". TalkBack already announces the element type. Describe what the \ + image shows, not what it is. + + WCAG 1.1.1 Non-text Content (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 5, + severity = Severity.WARNING, + implementation = Implementation( + ComposeA11yDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val LABEL_IN_NAME_ERROR = Issue.create( + id = "A11yLabelInNameError", + briefDescription = "Visible text not contained in accessible name", + explanation = """ + The accessible name (contentDescription) must contain the visible text label. \ + Voice Control users speak the visible text to activate the control. \ + If the visible text is "Save" but contentDescription is "Submit Form", \ + the user cannot activate it by saying "tap Save". + + WCAG 2.5.3 Label in Name (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 9, + severity = Severity.ERROR, + implementation = Implementation( + ComposeA11yDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val LABEL_IN_NAME_WARNING = Issue.create( + id = "A11yLabelInNameWarning", + briefDescription = "Visible text not at start of accessible name", + explanation = """ + The accessible name should begin with the visible text label. \ + WCAG 2.5.3 recommends the visible text appears at the start of the \ + accessible name so Voice Control users can activate the control by \ + speaking the label. + + WCAG 2.5.3 Label in Name (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 5, + severity = Severity.WARNING, + implementation = Implementation( + ComposeA11yDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val LABEL_CONTAINS_ROLE_BUTTON = Issue.create( + id = "A11yLabelContainsRoleButton", + briefDescription = "Button label contains role name", + explanation = """ + Button labels should not contain the word "button". TalkBack already \ + announces the element as a button. Describe the action instead. + + WCAG 4.1.2 Name, Role, Value (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 5, + severity = Severity.WARNING, + implementation = Implementation( + ComposeA11yDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + } +} diff --git a/lint-checks/src/main/kotlin/com/cvshealth/accessibility/lint/ComposeColorContrastDetector.kt b/lint-checks/src/main/kotlin/com/cvshealth/accessibility/lint/ComposeColorContrastDetector.kt new file mode 100644 index 0000000..41db0c1 --- /dev/null +++ b/lint-checks/src/main/kotlin/com/cvshealth/accessibility/lint/ComposeColorContrastDetector.kt @@ -0,0 +1,161 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.accessibility.lint + +import com.android.tools.lint.detector.api.Category +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Location +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.Severity +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.android.tools.lint.client.api.UElementHandler +import org.jetbrains.uast.UElement +import org.jetbrains.uast.UFile +import kotlin.math.pow + +class ComposeColorContrastDetector : Detector(), SourceCodeScanner { + + override fun getApplicableUastTypes(): List> = + listOf(UFile::class.java) + + override fun createUastHandler(context: JavaContext) = object : UElementHandler() { + override fun visitFile(node: UFile) { + if (!context.file.name.endsWith(".kt")) return + val fileName = context.file.name + if (fileName in THEME_FILES) return + + val source = context.getContents()?.toString() ?: return + checkColorContrast(context, source) + } + } + + private fun checkColorContrast(context: JavaContext, source: String) { + // Find Text calls with explicit color arg near background colors + val textPattern = Regex("""Text\s*\(""") + for (match in textPattern.findAll(source)) { + val lineStart = source.lastIndexOf('\n', match.range.first) + 1 + val lineText = source.substring(lineStart, source.indexOf('\n', match.range.first).let { if (it == -1) source.length else it }) + if (lineText.trimStart().startsWith("//") || lineText.trimStart().startsWith("*")) continue + + // Get the call text (approximate — next 500 chars) + val callEnd = minOf(match.range.first + 500, source.length) + val callText = source.substring(match.range.first, callEnd) + + // Look for color = ... argument. Use a pattern that handles Color(...) with nested parens. + val colorArgMatch = Regex("""color\s*=\s*([^\n,)]*(?:\([^)]*\))?)""").find(callText) ?: continue + val fgExpr = colorArgMatch.groupValues[1].trim() + val fgColor = parseColor(fgExpr) ?: continue + + // Search surrounding context for background color + val ctxStart = maxOf(0, match.range.first - 500) + val ctxEnd = minOf(match.range.first + 500, source.length) + val contextText = source.substring(ctxStart, ctxEnd) + + val bgMatch = Regex("""\.background\s*\(\s*(?:color\s*=\s*)?(.+?)\s*\)""").find(contextText) ?: continue + val bgExpr = bgMatch.groupValues[1].trim() + val bgColor = parseColor(bgExpr) ?: continue + + val ratio = contrastRatio(fgColor, bgColor) + if (ratio < MIN_CONTRAST_RATIO) { + context.report( + COLOR_CONTRAST, + Location.create(context.file, source, match.range.first, match.range.last + 1), + "Color contrast ratio %.2f:1 is below the minimum 4.5:1 (WCAG 1.4.3). Foreground: %s, Background: %s".format( + ratio, fgExpr, bgExpr + ) + ) + } + } + } + + companion object { + private val THEME_FILES = setOf("Type.kt", "Theme.kt", "Color.kt", "Typography.kt") + private const val MIN_CONTRAST_RATIO = 4.5 + + private val NAMED_COLORS = mapOf( + "Color.Black" to Triple(0, 0, 0), + "Color.DarkGray" to Triple(68, 68, 68), + "Color.Gray" to Triple(136, 136, 136), + "Color.LightGray" to Triple(192, 192, 192), + "Color.White" to Triple(255, 255, 255), + "Color.Red" to Triple(255, 0, 0), + "Color.Green" to Triple(0, 255, 0), + "Color.Blue" to Triple(0, 0, 255), + "Color.Yellow" to Triple(255, 255, 0), + "Color.Cyan" to Triple(0, 255, 255), + "Color.Magenta" to Triple(255, 0, 255), + ) + + private val HEX_PATTERN = Regex("""Color\s*\(\s*0x([0-9A-Fa-f]{6,8})\s*\)""") + + fun parseColor(expr: String): Triple? { + for ((name, rgb) in NAMED_COLORS) { + if (expr.contains(name)) return rgb + } + val hexMatch = HEX_PATTERN.find(expr) + if (hexMatch != null) { + val hex = hexMatch.groupValues[1] + val argb = hex.toLongOrNull(16) ?: return null + return if (hex.length == 8) { + Triple(((argb shr 16) and 0xFF).toInt(), ((argb shr 8) and 0xFF).toInt(), (argb and 0xFF).toInt()) + } else { + Triple(((argb shr 16) and 0xFF).toInt(), ((argb shr 8) and 0xFF).toInt(), (argb and 0xFF).toInt()) + } + } + return null + } + + private fun linearize(channel: Int): Double { + val sRGB = channel / 255.0 + return if (sRGB <= 0.04045) sRGB / 12.92 else ((sRGB + 0.055) / 1.055).pow(2.4) + } + + fun relativeLuminance(r: Int, g: Int, b: Int): Double { + return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b) + } + + fun contrastRatio(fg: Triple, bg: Triple): Double { + val l1 = relativeLuminance(fg.first, fg.second, fg.third) + val l2 = relativeLuminance(bg.first, bg.second, bg.third) + val lighter = maxOf(l1, l2) + val darker = minOf(l1, l2) + return (lighter + 0.05) / (darker + 0.05) + } + + val COLOR_CONTRAST = Issue.create( + id = "A11yColorContrast", + briefDescription = "Insufficient color contrast", + explanation = """ + Hardcoded foreground/background color pairs should meet the WCAG minimum \ + contrast ratio of 4.5:1 for normal text (3.0:1 for large text). Use \ + MaterialTheme.colorScheme tokens for automatic contrast compliance. + + WCAG 1.4.3 Contrast (Minimum) (Level AA) + """.trimIndent(), + category = Category.A11Y, + priority = 7, + severity = Severity.WARNING, + implementation = Implementation( + ComposeColorContrastDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + } +} diff --git a/lint-checks/src/main/kotlin/com/cvshealth/accessibility/lint/ComposeFormDetector.kt b/lint-checks/src/main/kotlin/com/cvshealth/accessibility/lint/ComposeFormDetector.kt new file mode 100644 index 0000000..6e21bec --- /dev/null +++ b/lint-checks/src/main/kotlin/com/cvshealth/accessibility/lint/ComposeFormDetector.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.accessibility.lint + +import com.android.tools.lint.detector.api.Category +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.Severity +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.intellij.psi.PsiMethod +import org.jetbrains.uast.UCallExpression + +class ComposeFormDetector : Detector(), SourceCodeScanner { + + override fun getApplicableMethodNames() = listOf( + "TextField", "OutlinedTextField", "BasicTextField", + "RadioButton", + ) + + override fun visitMethodCall( + context: JavaContext, + node: UCallExpression, + method: PsiMethod + ) { + when (node.methodName) { + "TextField", "OutlinedTextField", "BasicTextField" -> checkInputPurpose(context, node) + "RadioButton" -> checkRadioGroupMissing(context, node) + } + } + + private fun checkInputPurpose(context: JavaContext, node: UCallExpression) { + val sourceText = node.asSourceString() + + // Check if keyboard type suggests a specific input purpose + val hasTypedKeyboard = sourceText.contains("KeyboardType.Email") || + sourceText.contains("KeyboardType.Phone") || + sourceText.contains("KeyboardType.Password") || + sourceText.contains("KeyboardType.Number") + if (!hasTypedKeyboard) return + + // Check if autofill or content type semantics are set + if (sourceText.contains("autofill") || sourceText.contains("contentType") || + sourceText.contains("AutofillType") || sourceText.contains("semantics")) return + + context.report( + INPUT_PURPOSE, + node, + context.getNameLocation(node), + "${node.methodName} specifies a keyboard type but no autofill/content type. " + + "Add autofill hints so the system can assist users with input." + ) + } + + private fun checkRadioGroupMissing(context: JavaContext, node: UCallExpression) { + // Walk parent chain looking for selectableGroup + var parent = node.uastParent + var depth = 0 + while (parent != null && depth < 8) { + if (parent is UCallExpression) { + val parentName = parent.methodName + if (parentName in listOf("Column", "Row")) { + val parentSource = parent.asSourceString() + if (parentSource.contains("selectableGroup")) return + } + } + parent = parent.uastParent + depth++ + } + + context.report( + RADIO_GROUP_MISSING, + node, + context.getNameLocation(node), + "RadioButton is not inside a Column/Row with `selectableGroup()`. " + + "Screen readers need group semantics to announce radio button position." + ) + } + + companion object { + val INPUT_PURPOSE = Issue.create( + id = "A11yInputPurpose", + briefDescription = "Input field missing autofill purpose", + explanation = """ + TextField with a specific keyboard type (Email, Phone, Password) should \ + also declare autofill hints or content type semantics so the system can \ + assist users with input. + + WCAG 1.3.5 Identify Input Purpose (Level AA) + """.trimIndent(), + category = Category.A11Y, + priority = 5, + severity = Severity.INFORMATIONAL, + implementation = Implementation( + ComposeFormDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val RADIO_GROUP_MISSING = Issue.create( + id = "A11yRadioGroupMissing", + briefDescription = "RadioButton not in selectable group", + explanation = """ + RadioButton should be inside a Column or Row with \ + `Modifier.selectableGroup()` so screen readers can announce \ + the radio button's position (e.g., "1 of 3"). + + WCAG 1.3.1 Info and Relationships (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 6, + severity = Severity.WARNING, + implementation = Implementation( + ComposeFormDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + } +} diff --git a/lint-checks/src/main/kotlin/com/cvshealth/accessibility/lint/ComposeStructureDetector.kt b/lint-checks/src/main/kotlin/com/cvshealth/accessibility/lint/ComposeStructureDetector.kt new file mode 100644 index 0000000..92d4aa2 --- /dev/null +++ b/lint-checks/src/main/kotlin/com/cvshealth/accessibility/lint/ComposeStructureDetector.kt @@ -0,0 +1,211 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.accessibility.lint + +import com.android.tools.lint.detector.api.Category +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.Severity +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.intellij.psi.PsiMethod +import org.jetbrains.uast.UCallExpression + +class ComposeStructureDetector : Detector(), SourceCodeScanner { + + override fun getApplicableMethodNames() = listOf( + "Row", "Column", "Box", + "Button", "TextButton", "OutlinedButton", "FilledTonalButton", "ElevatedButton", + ) + + override fun visitMethodCall( + context: JavaContext, + node: UCallExpression, + method: PsiMethod + ) { + when (node.methodName) { + "Row", "Column" -> { + checkHiddenWithInteractiveChildren(context, node) + checkAccessibilityGrouping(context, node) + } + "Box" -> { + checkHiddenWithInteractiveChildren(context, node) + checkBoxChildOrder(context, node) + } + "Button", "TextButton", "OutlinedButton", "FilledTonalButton", "ElevatedButton" -> + checkButtonUsedAsLink(context, node) + } + } + + private fun checkHiddenWithInteractiveChildren(context: JavaContext, node: UCallExpression) { + val sourceText = node.asSourceString() + if (!sourceText.contains("clearAndSetSemantics")) return + + val interactiveChildren = listOf( + "Button(", "TextButton(", "IconButton(", "OutlinedButton(", + "Checkbox(", "Switch(", "RadioButton(", "Slider(", + ".clickable(", ".toggleable(" + ) + val hasInteractive = interactiveChildren.any { sourceText.contains(it) } + if (!hasInteractive) return + + context.report( + HIDDEN_WITH_INTERACTIVE_CHILDREN, + node, + context.getNameLocation(node), + "${node.methodName} uses `clearAndSetSemantics` but contains interactive children. " + + "Interactive elements inside a cleared semantics scope are hidden from screen readers." + ) + } + + private fun checkAccessibilityGrouping(context: JavaContext, node: UCallExpression) { + val sourceText = node.asSourceString() + + // Check if Row/Column contains both Icon and Text (common pattern needing grouping) + val hasIcon = sourceText.contains("Icon(") || sourceText.contains("Image(") + val hasText = sourceText.contains("Text(") + if (!hasIcon || !hasText) return + + // Check if clickable but not merged + val isClickable = sourceText.contains(".clickable(") || sourceText.contains(".toggleable(") + if (!isClickable) return + + if (sourceText.contains("mergeDescendants") || sourceText.contains("clearAndSetSemantics")) return + + context.report( + ACCESSIBILITY_GROUPING, + node, + context.getNameLocation(node), + "Clickable ${node.methodName} with Icon and Text should use `Modifier.semantics(mergeDescendants = true)` " + + "to group content for screen readers." + ) + } + + private fun checkBoxChildOrder(context: JavaContext, node: UCallExpression) { + val sourceText = node.asSourceString() + if (!sourceText.contains(".align(") || !sourceText.contains(".zIndex(")) return + + if (sourceText.contains("traversalIndex") || sourceText.contains("isTraversalGroup")) return + + context.report( + BOX_CHILD_ORDER, + node, + context.getNameLocation(node), + "Box with overlapping children (align + zIndex) may have confusing screen reader order. " + + "Consider using `traversalIndex` or `isTraversalGroup` to control reading order." + ) + } + + private fun checkButtonUsedAsLink(context: JavaContext, node: UCallExpression) { + val sourceText = node.asSourceString() + val linkIndicators = listOf("Uri.parse", "ACTION_VIEW", "https://", "http://", "openUri", "uriHandler") + val hasLinkBehavior = linkIndicators.any { sourceText.contains(it) } + if (!hasLinkBehavior) return + + // Check if role is already set to Link + if (sourceText.contains("Role.Link") || sourceText.contains("UrlAnnotation") || + sourceText.contains("LinkAnnotation")) return + + context.report( + BUTTON_USED_AS_LINK, + node, + context.getNameLocation(node), + "${node.methodName} navigates to a URL but is announced as a button. " + + "Use a link pattern or add `Role.Link` semantics." + ) + } + + companion object { + val HIDDEN_WITH_INTERACTIVE_CHILDREN = Issue.create( + id = "A11yHiddenWithInteractiveChildren", + briefDescription = "Interactive children hidden from screen readers", + explanation = """ + Using `clearAndSetSemantics` on a container hides all children from \ + screen readers. If the container has interactive children (buttons, \ + checkboxes, etc.), they become inaccessible. + + WCAG 4.1.2 Name, Role, Value (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 8, + severity = Severity.WARNING, + implementation = Implementation( + ComposeStructureDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val ACCESSIBILITY_GROUPING = Issue.create( + id = "A11yAccessibilityGrouping", + briefDescription = "Clickable content not grouped for screen readers", + explanation = """ + A clickable Row/Column containing both Icon and Text should use \ + `Modifier.semantics(mergeDescendants = true)` to group the content \ + into a single screen reader element. + + WCAG 1.3.1 Info and Relationships (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 5, + severity = Severity.INFORMATIONAL, + implementation = Implementation( + ComposeStructureDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val BOX_CHILD_ORDER = Issue.create( + id = "A11yBoxChildOrder", + briefDescription = "Box children may have confusing screen reader order", + explanation = """ + Box with overlapping children using `align` and `zIndex` may present \ + content in an unexpected order to screen readers. Use `traversalIndex` \ + or `isTraversalGroup` to control the reading order. + + WCAG 1.3.2 Meaningful Sequence (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 5, + severity = Severity.INFORMATIONAL, + implementation = Implementation( + ComposeStructureDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val BUTTON_USED_AS_LINK = Issue.create( + id = "A11yButtonUsedAsLink", + briefDescription = "Button used for navigation (should be a link)", + explanation = """ + A Button that navigates to a URL is semantically a link, not a button. \ + Screen reader users expect links and buttons to behave differently. Use \ + a link pattern or add `Role.Link` semantics. + + WCAG 2.4.4 Link Purpose (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 6, + severity = Severity.WARNING, + implementation = Implementation( + ComposeStructureDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + } +} diff --git a/lint-checks/src/main/kotlin/com/cvshealth/accessibility/lint/ComposeTextScanDetector.kt b/lint-checks/src/main/kotlin/com/cvshealth/accessibility/lint/ComposeTextScanDetector.kt new file mode 100644 index 0000000..b11efaa --- /dev/null +++ b/lint-checks/src/main/kotlin/com/cvshealth/accessibility/lint/ComposeTextScanDetector.kt @@ -0,0 +1,553 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.accessibility.lint + +import com.android.tools.lint.detector.api.Category +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Location +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.Severity +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.android.tools.lint.client.api.UElementHandler +import org.jetbrains.uast.UElement +import org.jetbrains.uast.UFile + +class ComposeTextScanDetector : Detector(), SourceCodeScanner { + + override fun getApplicableUastTypes(): List> = + listOf(UFile::class.java) + + override fun createUastHandler(context: JavaContext) = object : UElementHandler() { + override fun visitFile(node: UFile) { + if (!context.file.name.endsWith(".kt")) return + + val fileName = context.file.name + if (fileName in THEME_FILES) return + + val source = context.getContents()?.toString() ?: return + + checkFixedFontSize(context, source) + checkMaxLinesOne(context, source) + checkHeadingSemanticsMissing(context, source) + checkFakeHeadingInLabel(context, source) + checkClickableMissingRole(context, source) + checkHardcodedColor(context, source, fileName) + checkVisuallyDisabledNotSemantically(context, source) + checkGenericLinkText(context, source) + checkReduceMotion(context, source) + checkGestureMissingAlternative(context, source) + checkTimingAdjustable(context, source) + checkDialogFocusManagement(context, source) + } + } + + private fun isComment(source: String, matchStart: Int): Boolean { + val lineStart = source.lastIndexOf('\n', matchStart) + 1 + val lineEnd = source.indexOf('\n', matchStart).let { if (it == -1) source.length else it } + val lineText = source.substring(lineStart, lineEnd).trimStart() + return lineText.startsWith("//") || lineText.startsWith("*") + } + + private fun getContext(source: String, matchStart: Int, linesBefore: Int = 5, linesAfter: Int = 5): String { + var start = matchStart + for (i in 0 until linesBefore) { + val prev = source.lastIndexOf('\n', start - 1) + if (prev == -1) { start = 0; break } + start = prev + } + var end = matchStart + for (i in 0 until linesAfter) { + val next = source.indexOf('\n', end + 1) + if (next == -1) { end = source.length; break } + end = next + } + return source.substring(start, end) + } + + // --- Existing rules --- + + private fun checkFixedFontSize(context: JavaContext, source: String) { + val pattern = Regex("""\b(\d+)\.sp\b""") + for (match in pattern.findAll(source)) { + if (isComment(source, match.range.first)) continue + context.report( + FIXED_FONT_SIZE, + Location.create(context.file, source, match.range.first, match.range.last + 1), + "Hardcoded font size `${match.value}`. Use `MaterialTheme.typography` to support user text size preferences." + ) + } + } + + private fun checkMaxLinesOne(context: JavaContext, source: String) { + val pattern = Regex("""maxLines\s*=\s*1\b""") + for (match in pattern.findAll(source)) { + if (isComment(source, match.range.first)) continue + context.report( + MAX_LINES_ONE, + Location.create(context.file, source, match.range.first, match.range.last + 1), + "`maxLines = 1` truncates text, which may hide content from users who need larger text." + ) + } + } + + // --- New rules --- + + private fun checkHeadingSemanticsMissing(context: JavaContext, source: String) { + val headingStyles = listOf( + "headlineLarge", "headlineMedium", "headlineSmall", + "titleLarge", "titleMedium", "titleSmall", + "displayLarge", "displayMedium", "displaySmall" + ) + val pattern = Regex("""Text\s*\(""") + for (match in pattern.findAll(source)) { + if (isComment(source, match.range.first)) continue + + // Get the call context (next ~500 chars or until matching paren) + val callEnd = minOf(match.range.first + 500, source.length) + val callText = source.substring(match.range.first, callEnd) + + val hasHeadingStyle = headingStyles.any { callText.contains(it) } + if (!hasHeadingStyle) continue + + // Check surrounding context for heading() + val ctx = getContext(source, match.range.first, 10, 10) + if (ctx.contains("heading()") || ctx.contains("SimpleHeading") || + ctx.contains("GoodExampleHeading") || ctx.contains("BadExampleHeading")) continue + + context.report( + HEADING_SEMANTICS_MISSING, + Location.create(context.file, source, match.range.first, match.range.last + 1), + "Text uses heading typography but lacks `semantics { heading() }`. Screen readers need heading semantics for navigation." + ) + } + } + + private fun checkFakeHeadingInLabel(context: JavaContext, source: String) { + val pattern = Regex("""contentDescription\s*=\s*"[^"]*\bheading\b[^"]*"""", RegexOption.IGNORE_CASE) + for (match in pattern.findAll(source)) { + if (isComment(source, match.range.first)) continue + context.report( + FAKE_HEADING_IN_LABEL, + Location.create(context.file, source, match.range.first, match.range.last + 1), + "Content description contains 'heading'. Use `semantics { heading() }` instead of role words in labels." + ) + } + } + + private fun checkClickableMissingRole(context: JavaContext, source: String) { + val pattern = Regex("""\.clickable\s*\(""") + for (match in pattern.findAll(source)) { + if (isComment(source, match.range.first)) continue + + val callEnd = minOf(match.range.first + 300, source.length) + val callText = source.substring(match.range.first, callEnd) + if (callText.contains("role =") || callText.contains("Role.")) continue + + context.report( + CLICKABLE_MISSING_ROLE, + Location.create(context.file, source, match.range.first, match.range.last + 1), + "`.clickable()` without a `role` parameter. Specify `role = Role.Button` (or appropriate role) for screen readers." + ) + } + } + + private fun checkHardcodedColor(context: JavaContext, source: String, fileName: String) { + if (fileName in setOf("Color.kt", "Theme.kt", "Colors.kt")) return + + val namedColors = listOf( + "Color.Black", "Color.White", "Color.Red", "Color.Green", "Color.Blue", + "Color.Yellow", "Color.Cyan", "Color.Magenta", "Color.Gray", + "Color.LightGray", "Color.DarkGray" + ) + val hexPattern = Regex("""Color\s*\(\s*0x[0-9A-Fa-f]+\s*\)""") + + for (color in namedColors) { + var idx = source.indexOf(color) + while (idx >= 0) { + if (!isComment(source, idx)) { + // Skip val declarations + val lineStart = source.lastIndexOf('\n', idx) + 1 + val lineText = source.substring(lineStart, source.indexOf('\n', idx).let { if (it == -1) source.length else it }) + if (!lineText.contains("val ") && !lineText.contains("private ")) { + context.report( + HARDCODED_COLOR, + Location.create(context.file, source, idx, idx + color.length), + "Hardcoded color `$color` won't adapt to dark mode or high-contrast themes. Use `MaterialTheme.colorScheme`." + ) + } + } + idx = source.indexOf(color, idx + 1) + } + } + + for (match in hexPattern.findAll(source)) { + if (isComment(source, match.range.first)) continue + val lineStart = source.lastIndexOf('\n', match.range.first) + 1 + val lineText = source.substring(lineStart, source.indexOf('\n', match.range.first).let { if (it == -1) source.length else it }) + if (lineText.contains("val ") || lineText.contains("private ")) continue + context.report( + HARDCODED_COLOR, + Location.create(context.file, source, match.range.first, match.range.last + 1), + "Hardcoded hex color won't adapt to dark mode or high-contrast themes. Use `MaterialTheme.colorScheme`." + ) + } + } + + private fun checkVisuallyDisabledNotSemantically(context: JavaContext, source: String) { + val pattern = Regex("""\.alpha\s*\(\s*(0\.\d+|0)\s*\)""") + for (match in pattern.findAll(source)) { + if (isComment(source, match.range.first)) continue + + val valueStr = match.groupValues.getOrNull(1) ?: continue + val value = valueStr.toDoubleOrNull() ?: continue + if (value >= 0.5) continue + + val ctx = getContext(source, match.range.first) + if (ctx.contains("enabled = false") || ctx.contains("disabled = true")) continue + + context.report( + VISUALLY_DISABLED_NOT_SEMANTICALLY, + Location.create(context.file, source, match.range.first, match.range.last + 1), + "Element is visually dimmed with `alpha($valueStr)` but may still be interactive. Add `enabled = false` or disable semantically." + ) + } + } + + private fun checkGenericLinkText(context: JavaContext, source: String) { + val genericPhrases = listOf("click here", "learn more", "read more", "tap here", "here", "more info") + val pattern = Regex(""""(${genericPhrases.joinToString("|") { Regex.escape(it) }})"""", RegexOption.IGNORE_CASE) + for (match in pattern.findAll(source)) { + if (isComment(source, match.range.first)) continue + val ctx = getContext(source, match.range.first, 3, 3) + if (!ctx.contains("clickable") && !ctx.contains("ClickableText") && + !ctx.contains("LinkAnnotation") && !ctx.contains("pushStringAnnotation")) continue + + context.report( + GENERIC_LINK_TEXT, + Location.create(context.file, source, match.range.first, match.range.last + 1), + "Generic link text \"${match.groupValues[1]}\". Use descriptive text that makes sense out of context." + ) + } + } + + private fun checkReduceMotion(context: JavaContext, source: String) { + val animationCalls = listOf("AnimatedVisibility", "animateFloatAsState", "animateDpAsState", + "animateColorAsState", "InfiniteTransition", "rememberInfiniteTransition", + "updateTransition", "AnimatedContent") + for (call in animationCalls) { + var idx = source.indexOf(call) + while (idx >= 0) { + if (!isComment(source, idx)) { + val ctx = getContext(source, idx, 10, 10) + if (!ctx.contains("reduceMotion") && !ctx.contains("isReduceMotionEnabled") && + !ctx.contains("motionScheme")) { + context.report( + REDUCE_MOTION, + Location.create(context.file, source, idx, idx + call.length), + "`$call` used without checking reduced motion preference. Consider honoring `AccessibilityManager.isReduceMotionEnabled`." + ) + } + } + idx = source.indexOf(call, idx + 1) + } + } + } + + private fun checkGestureMissingAlternative(context: JavaContext, source: String) { + val gestureCalls = listOf("pointerInput", "detectDragGestures", "detectTapGestures", + "onLongPress", "detectHorizontalDragGestures", "detectVerticalDragGestures") + for (call in gestureCalls) { + val pattern = Regex("""\b${Regex.escape(call)}\s*[\({]""") + for (match in pattern.findAll(source)) { + if (isComment(source, match.range.first)) continue + val ctx = getContext(source, match.range.first, 10, 10) + if (ctx.contains("customActions") || ctx.contains("clickable") || + ctx.contains("onClickLabel") || ctx.contains("Button(")) continue + + context.report( + GESTURE_MISSING_ALTERNATIVE, + Location.create(context.file, source, match.range.first, match.range.last + 1), + "`$call` gesture with no alternative input method. Provide a button or accessibility action as a fallback." + ) + } + } + } + + private fun checkTimingAdjustable(context: JavaContext, source: String) { + val pattern = Regex("""\b(delay|withTimeout|withTimeoutOrNull)\s*\(\s*(\d{4,})\s*\)""") + for (match in pattern.findAll(source)) { + if (isComment(source, match.range.first)) continue + val ctx = getContext(source, match.range.first, 5, 5) + if (ctx.contains("adjustable") || ctx.contains("userTimeout") || ctx.contains("extend")) continue + + val duration = match.groupValues[2].toLongOrNull() ?: continue + context.report( + TIMING_ADJUSTABLE, + Location.create(context.file, source, match.range.first, match.range.last + 1), + "Hardcoded timing of ${duration}ms. Users with disabilities may need more time. Consider making the duration adjustable." + ) + } + } + + private fun checkDialogFocusManagement(context: JavaContext, source: String) { + val dialogCalls = listOf("AlertDialog", "Dialog", "ModalBottomSheet") + for (call in dialogCalls) { + val pattern = Regex("""\b${Regex.escape(call)}\s*\(""") + for (match in pattern.findAll(source)) { + if (isComment(source, match.range.first)) continue + + val callEnd = minOf(match.range.first + 1000, source.length) + val callText = source.substring(match.range.first, callEnd) + // Find matching close brace/paren (approximate) + if (callText.contains("FocusRequester") || callText.contains("requestFocus") || + callText.contains("focusRequester")) continue + + context.report( + DIALOG_FOCUS_MANAGEMENT, + Location.create(context.file, source, match.range.first, match.range.last + 1), + "`$call` without explicit focus management. Use `FocusRequester` to direct focus for keyboard and screen reader users." + ) + } + } + } + + companion object { + private val THEME_FILES = setOf("Type.kt", "Theme.kt", "Color.kt", "Typography.kt") + + val FIXED_FONT_SIZE = Issue.create( + id = "A11yFixedFontSize", + briefDescription = "Hardcoded font size", + explanation = """ + Hardcoded `.sp` font sizes don't follow the user's text size preferences \ + set in system settings. Use `MaterialTheme.typography` styles instead to \ + ensure text scales properly. + + WCAG 1.4.4 Resize Text (Level AA) + """.trimIndent(), + category = Category.A11Y, + priority = 6, + severity = Severity.WARNING, + implementation = Implementation( + ComposeTextScanDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val MAX_LINES_ONE = Issue.create( + id = "A11yMaxLinesOne", + briefDescription = "Text limited to single line", + explanation = """ + `maxLines = 1` causes text to be truncated with ellipsis. Users who need \ + larger text sizes may lose content. Consider allowing text to wrap or \ + providing the full text through other means. + + WCAG 1.4.4 Resize Text (Level AA) + """.trimIndent(), + category = Category.A11Y, + priority = 5, + severity = Severity.INFORMATIONAL, + implementation = Implementation( + ComposeTextScanDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val HEADING_SEMANTICS_MISSING = Issue.create( + id = "A11yHeadingSemanticsMissing", + briefDescription = "Heading typography without heading semantics", + explanation = """ + Text using heading typography (headline*, title*, display*) should have \ + `Modifier.semantics { heading() }` so screen readers can navigate by headings. + + WCAG 2.4.6 Headings and Labels (Level AA) / WCAG 1.3.1 Info and Relationships (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 7, + severity = Severity.WARNING, + implementation = Implementation( + ComposeTextScanDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val FAKE_HEADING_IN_LABEL = Issue.create( + id = "A11yFakeHeadingInLabel", + briefDescription = "Content description contains 'heading'", + explanation = """ + Content descriptions should not contain the word "heading". Use \ + `semantics { heading() }` to mark headings programmatically instead. + + WCAG 1.3.1 Info and Relationships (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 6, + severity = Severity.WARNING, + implementation = Implementation( + ComposeTextScanDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val CLICKABLE_MISSING_ROLE = Issue.create( + id = "A11yClickableMissingRole", + briefDescription = "Clickable modifier without role", + explanation = """ + `.clickable()` without a `role` parameter doesn't convey the element's \ + purpose to screen readers. Specify `role = Role.Button` or the appropriate role. + + WCAG 4.1.2 Name, Role, Value (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 7, + severity = Severity.WARNING, + implementation = Implementation( + ComposeTextScanDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val HARDCODED_COLOR = Issue.create( + id = "A11yHardcodedColor", + briefDescription = "Hardcoded color value", + explanation = """ + Hardcoded colors (Color.Black, Color(0xFF...) etc.) won't adapt to dark \ + mode or high-contrast themes. Use `MaterialTheme.colorScheme` tokens instead. + + WCAG 1.4.3 Contrast (Minimum) (Level AA) + """.trimIndent(), + category = Category.A11Y, + priority = 5, + severity = Severity.WARNING, + implementation = Implementation( + ComposeTextScanDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val VISUALLY_DISABLED_NOT_SEMANTICALLY = Issue.create( + id = "A11yVisuallyDisabledNotSemantically", + briefDescription = "Visually disabled but not semantically", + explanation = """ + Element is visually dimmed with `.alpha()` but may still be interactive. \ + Add `enabled = false` or use semantic properties to mark it as disabled. + + WCAG 4.1.2 Name, Role, Value (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 6, + severity = Severity.WARNING, + implementation = Implementation( + ComposeTextScanDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val GENERIC_LINK_TEXT = Issue.create( + id = "A11yGenericLinkText", + briefDescription = "Generic link text", + explanation = """ + Link text like "click here" or "learn more" is not meaningful out of context. \ + Screen reader users often navigate by links — use descriptive text. + + WCAG 2.4.4 Link Purpose (In Context) (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 6, + severity = Severity.WARNING, + implementation = Implementation( + ComposeTextScanDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val REDUCE_MOTION = Issue.create( + id = "A11yReduceMotion", + briefDescription = "Animation without reduced motion check", + explanation = """ + Animations should respect the user's reduced motion preference. Check \ + `AccessibilityManager.isReduceMotionEnabled` and provide alternatives. + + WCAG 2.3.1 Three Flashes or Below Threshold (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 5, + severity = Severity.INFORMATIONAL, + implementation = Implementation( + ComposeTextScanDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val GESTURE_MISSING_ALTERNATIVE = Issue.create( + id = "A11yGestureMissingAlternative", + briefDescription = "Gesture without alternative input", + explanation = """ + Custom gestures (drag, long press, swipe) need an alternative input method \ + for users who cannot perform them. Provide buttons or accessibility actions. + + WCAG 2.1.1 Keyboard (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 6, + severity = Severity.WARNING, + implementation = Implementation( + ComposeTextScanDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val TIMING_ADJUSTABLE = Issue.create( + id = "A11yTimingAdjustable", + briefDescription = "Hardcoded timing duration", + explanation = """ + Hardcoded timing (delay, timeout) may not give users with disabilities \ + enough time. Consider making durations adjustable or extending on interaction. + + WCAG 2.2.1 Timing Adjustable (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 5, + severity = Severity.INFORMATIONAL, + implementation = Implementation( + ComposeTextScanDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + + val DIALOG_FOCUS_MANAGEMENT = Issue.create( + id = "A11yDialogFocusManagement", + briefDescription = "Dialog without focus management", + explanation = """ + Dialogs and bottom sheets should manage focus explicitly using \ + `FocusRequester` to ensure keyboard and screen reader users land on \ + the correct element when the dialog opens. + + WCAG 2.4.3 Focus Order (Level A) + """.trimIndent(), + category = Category.A11Y, + priority = 6, + severity = Severity.WARNING, + implementation = Implementation( + ComposeTextScanDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + ) + } +} diff --git a/lint-checks/src/test/kotlin/com/cvshealth/accessibility/lint/ComposeA11yDetectorTest.kt b/lint-checks/src/test/kotlin/com/cvshealth/accessibility/lint/ComposeA11yDetectorTest.kt new file mode 100644 index 0000000..075ec81 --- /dev/null +++ b/lint-checks/src/test/kotlin/com/cvshealth/accessibility/lint/ComposeA11yDetectorTest.kt @@ -0,0 +1,740 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.accessibility.lint + +import com.android.tools.lint.checks.infrastructure.LintDetectorTest +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Issue +import org.junit.Test + +class ComposeA11yDetectorTest : LintDetectorTest() { + + override fun getDetector(): Detector = ComposeA11yDetector() + + override fun allowCompilationErrors(): Boolean = true + + override fun getIssues(): List = listOf( + ComposeA11yDetector.ICON_MISSING_LABEL, + ComposeA11yDetector.EMPTY_CONTENT_DESCRIPTION, + ComposeA11yDetector.TEXTFIELD_MISSING_LABEL, + ComposeA11yDetector.ICON_BUTTON_MISSING_LABEL, + ComposeA11yDetector.SLIDER_MISSING_LABEL, + ComposeA11yDetector.DROPDOWN_MISSING_LABEL, + ComposeA11yDetector.TAB_MISSING_LABEL, + ComposeA11yDetector.MISSING_PANE_TITLE, + ComposeA11yDetector.TOGGLE_MISSING_LABEL, + ComposeA11yDetector.LABEL_CONTAINS_ROLE_IMAGE, + ComposeA11yDetector.LABEL_CONTAINS_ROLE_BUTTON, + ComposeA11yDetector.LABEL_IN_NAME_ERROR, + ComposeA11yDetector.LABEL_IN_NAME_WARNING, + ) + + // ---- A11yIconMissingLabel ---- + + @Test + fun testIconWithNullContentdescriptionTriggersA11yiconmissinglabel() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Icon + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Icon(painter = painterResource(R.drawable.ic_check), contentDescription = null) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testIconWithNonnullContentdescriptionIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Icon + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Icon(painter = painterResource(R.drawable.ic_check), contentDescription = "Checkmark") + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testImageWithNullContentdescriptionTriggersA11yiconmissinglabel() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.foundation.Image + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Image(painter = painterResource(R.drawable.logo), contentDescription = null) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testImageWithNonnullContentdescriptionIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.foundation.Image + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Image(painter = painterResource(R.drawable.logo), contentDescription = "Company logo") + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yEmptyContentDescription ---- + + @Test + fun testIconWithEmptyContentdescriptionTriggersA11yemptycontentdescription() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Icon + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Icon(painter = painterResource(R.drawable.ic_star), contentDescription = "") + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testImageWithEmptyContentdescriptionTriggersA11yemptycontentdescription() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.foundation.Image + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Image(painter = painterResource(R.drawable.bg), contentDescription = "") + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testImageWithDescriptiveContentdescriptionIsCleanForEmptyCheck() { + lint().files( + kotlin( + """ + package test + import androidx.compose.foundation.Image + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Image(painter = painterResource(R.drawable.bg), contentDescription = "Background scenery") + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yTextFieldMissingLabel ---- + + @Test + fun testTextfieldWithoutLabelTriggersA11ytextfieldmissinglabel() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.TextField + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + TextField(value = "", onValueChange = {}) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testOutlinedtextfieldWithoutLabelTriggersA11ytextfieldmissinglabel() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.OutlinedTextField + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + OutlinedTextField(value = "", onValueChange = {}) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testTextfieldWithLabelIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.TextField + import androidx.compose.material3.Text + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + TextField(value = "", onValueChange = {}, label = { Text("Email") }) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yIconButtonMissingLabel ---- + + @Test + fun testIconbuttonWithNoContentdescriptionTriggersA11yiconbuttonmissinglabel() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.IconButton + import androidx.compose.material3.Icon + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + IconButton(onClick = {}) { + Icon(painter = painterResource(R.drawable.ic_close), contentDescription = null) + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testIconbuttonWithLabeledIconIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.IconButton + import androidx.compose.material3.Icon + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + IconButton(onClick = {}) { + Icon(painter = painterResource(R.drawable.ic_close), contentDescription = "Close") + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11ySliderMissingLabel ---- + + @Test + fun testSliderWithoutLabelTriggersA11yslidermissinglabel() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Slider + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Slider(value = 0.5f, onValueChange = {}) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testSliderWithContentdescriptionSemanticsIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Slider + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Slider( + value = 0.5f, + onValueChange = {}, + modifier = Modifier.semantics { contentDescription = "Volume" } + ) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testRangesliderWithoutLabelTriggersA11yslidermissinglabel() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.RangeSlider + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + RangeSlider(value = 0f..1f, onValueChange = {}) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yDropdownMissingLabel ---- + + @Test + fun testExposeddropdownmenuboxWithoutLabeledTextfieldTriggersA11ydropdownmissinglabel() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.ExposedDropdownMenuBox + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + ExposedDropdownMenuBox(expanded = false, onExpandedChange = {}) { + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testExposeddropdownmenuboxWithLabeledTextfieldIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.ExposedDropdownMenuBox + import androidx.compose.material3.TextField + import androidx.compose.material3.Text + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + ExposedDropdownMenuBox(expanded = false, onExpandedChange = {}) { + TextField(value = "Option A", onValueChange = {}, label = { Text("Category") }) + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yTabMissingLabel ---- + + @Test + fun testTabWithoutTextLabelTriggersA11ytabmissinglabel() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Tab + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Tab(selected = true, onClick = {}) { + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testTabWithTextParameterIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Tab + import androidx.compose.material3.Text + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Tab(selected = true, onClick = {}, text = { Text("Home") }) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testLeadingicontabWithoutTextTriggersA11ytabmissinglabel() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.LeadingIconTab + import androidx.compose.material3.Icon + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + LeadingIconTab(selected = false, onClick = {}, icon = { + Icon(painter = painterResource(R.drawable.ic_home), contentDescription = null) + }) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yMissingPaneTitle ---- + + @Test + fun testScaffoldWithoutPanetitleTriggersA11ymissingpanetitle() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Scaffold + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Scaffold { + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testScaffoldWithPanetitleSemanticsIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Scaffold + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Scaffold(modifier = Modifier.semantics { paneTitle = "Home Screen" }) { + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testScaffoldWithTopappbarAndTitleIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Scaffold + import androidx.compose.material3.TopAppBar + import androidx.compose.material3.Text + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Scaffold(topBar = { + TopAppBar(title = { Text("Settings") }) + }) { + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yToggleMissingLabel ---- + + @Test + fun testCheckboxWithoutLabelTriggersA11ytogglemissinglabel() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Checkbox + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Checkbox(checked = false, onCheckedChange = {}) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testSwitchWithoutLabelTriggersA11ytogglemissinglabel() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Switch + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Switch(checked = true, onCheckedChange = {}) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testCheckboxWithContentdescriptionSemanticsIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Checkbox + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Checkbox( + checked = false, + onCheckedChange = {}, + modifier = Modifier.semantics { contentDescription = "Accept terms" } + ) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yLabelContainsRoleImage ---- + + @Test + fun testIconWithImageInContentdescriptionTriggersA11ylabelcontainsroleimage() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Icon + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Icon(painter = painterResource(R.drawable.ic_logo), contentDescription = "Company logo image") + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testImageWithIconInContentdescriptionTriggersA11ylabelcontainsroleimage() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.foundation.Image + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Image(painter = painterResource(R.drawable.close), contentDescription = "close icon") + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testIconWithDescriptiveContentdescriptionWithoutRoleWordIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Icon + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Icon(painter = painterResource(R.drawable.ic_logo), contentDescription = "Company logo") + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yLabelContainsRoleButton ---- + + @Test + fun testButtonWithButtonInLabelTriggersA11ylabelcontainsrolebutton() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Button + import androidx.compose.material3.Text + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Button(onClick = {}) { + Text("Submit button") + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testTextbuttonWithButtonInLabelTriggersA11ylabelcontainsrolebutton() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.TextButton + import androidx.compose.material3.Text + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + TextButton(onClick = {}) { + Text("Cancel Button") + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testButtonWithActiononlyLabelIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Button + import androidx.compose.material3.Text + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Button(onClick = {}) { + Text("Submit") + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yLabelInNameError / A11yLabelInNameWarning ---- + + @Test + fun testButtonWithMismatchedContentdescriptionTriggersA11ylabelinnameerror() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Button + import androidx.compose.material3.Text + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Button( + onClick = {}, + modifier = Modifier.semantics { contentDescription = "Submit form" } + ) { + Text("Save") + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testButtonWithMatchingLabelIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.material3.Button + import androidx.compose.material3.Text + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Button( + onClick = {}, + modifier = Modifier.semantics { contentDescription = "Save document" } + ) { + Text("Save") + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } +} diff --git a/lint-checks/src/test/kotlin/com/cvshealth/accessibility/lint/ComposeColorContrastDetectorTest.kt b/lint-checks/src/test/kotlin/com/cvshealth/accessibility/lint/ComposeColorContrastDetectorTest.kt new file mode 100644 index 0000000..25198ce --- /dev/null +++ b/lint-checks/src/test/kotlin/com/cvshealth/accessibility/lint/ComposeColorContrastDetectorTest.kt @@ -0,0 +1,215 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.accessibility.lint + +import com.android.tools.lint.checks.infrastructure.LintDetectorTest +import com.android.tools.lint.checks.infrastructure.TestMode +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Issue +import org.junit.Test + +/** + * Tests for ComposeColorContrastDetector which scans raw source for color pairs. + * We use TestMode.DEFAULT to avoid whitespace-transformation modes that alter + * the source patterns the detector uses for color parsing. + */ +class ComposeColorContrastDetectorTest : LintDetectorTest() { + + override fun getDetector(): Detector = ComposeColorContrastDetector() + + override fun allowCompilationErrors(): Boolean = true + + override fun getIssues(): List = listOf( + ComposeColorContrastDetector.COLOR_CONTRAST, + ) + + // ---- A11yColorContrast ---- + + @Test + fun testTextWithColorGrayOnColorWhiteTriggersA11ycolorcontrast() { + // Color.Gray (136,136,136) on Color.White (255,255,255) has contrast ~3.95:1, below 4.5:1 + lint().files( + kotlin( + """ + package test + @Composable + fun Foo() { + Text( + text = "Low contrast text", + color = Color.Gray, + modifier = Modifier.background(Color.White) + ) + } + """.trimIndent() + ) + ).testModes(TestMode.DEFAULT).allowCompilationErrors().run().expectContains("A11yColorContrast") + } + + @Test + fun testTextWithColorDarkgrayOnColorLightgrayTriggersA11ycolorcontrast() { + // Color.DarkGray (68,68,68) on Color.LightGray (192,192,192) — actual contrast is ~5.35:1, + // which is above the 4.5:1 threshold. The detector correctly does not flag this pair. + lint().files( + kotlin( + """ + package test + @Composable + fun Foo() { + Text( + text = "Low contrast", + color = Color.DarkGray, + modifier = Modifier.background(Color.LightGray) + ) + } + """.trimIndent() + ) + ).testModes(TestMode.DEFAULT).allowCompilationErrors().run().expectClean() + } + + @Test + fun testTextWithColorBlackOnColorWhiteIsClean() { + // Color.Black (0,0,0) on Color.White (255,255,255) = 21:1 contrast ratio + lint().files( + kotlin( + """ + package test + @Composable + fun Foo() { + Text( + text = "High contrast text", + color = Color.Black, + modifier = Modifier.background(Color.White) + ) + } + """.trimIndent() + ) + ).testModes(TestMode.DEFAULT).allowCompilationErrors().run().expectClean() + } + + @Test + fun testTextWithColorWhiteOnColorBlackIsClean() { + // Color.White (255,255,255) on Color.Black (0,0,0) = 21:1 contrast ratio + lint().files( + kotlin( + """ + package test + @Composable + fun Foo() { + Text( + text = "White on black", + color = Color.White, + modifier = Modifier.background(Color.Black) + ) + } + """.trimIndent() + ) + ).testModes(TestMode.DEFAULT).allowCompilationErrors().run().expectClean() + } + + @Test + fun testTextWithoutExplicitColorIsClean() { + lint().files( + kotlin( + """ + package test + @Composable + fun Foo() { + Text(text = "Default color text") + } + """.trimIndent() + ) + ).testModes(TestMode.DEFAULT).allowCompilationErrors().run().expectClean() + } + + @Test + fun testTextWithMaterialthemeColorsIsClean() { + lint().files( + kotlin( + """ + package test + @Composable + fun Foo() { + Text( + text = "Themed text", + color = MaterialTheme.colorScheme.onSurface + ) + } + """.trimIndent() + ) + ).testModes(TestMode.DEFAULT).allowCompilationErrors().run().expectClean() + } + + @Test + fun testTextWithColorRedOnColorWhiteTriggersA11ycolorcontrast() { + // Color.Red (255,0,0) on Color.White (255,255,255) = ~4.0:1 contrast, below 4.5:1 + lint().files( + kotlin( + """ + package test + @Composable + fun Foo() { + Text( + text = "Red text on white", + color = Color.Red, + modifier = Modifier.background(Color.White) + ) + } + """.trimIndent() + ) + ).testModes(TestMode.DEFAULT).allowCompilationErrors().run().expectContains("A11yColorContrast") + } + + @Test + fun testTextWithColorLightgrayOnColorWhiteTriggersA11ycolorcontrast() { + // Color.LightGray (192,192,192) on Color.White (255,255,255) — contrast ~1.74:1, below 4.5:1 + lint().files( + kotlin( + """ + package test + @Composable + fun Foo() { + Text( + text = "Light gray text", + color = Color.LightGray, + modifier = Modifier.background(Color.White) + ) + } + """.trimIndent() + ) + ).testModes(TestMode.DEFAULT).allowCompilationErrors().run().expectContains("A11yColorContrast") + } + + @Test + fun testTextWithHexColorHighContrastIsClean() { + // Color(0xFF212121) = RGB(33,33,33) on Color.White = ~16:1 contrast ratio + lint().files( + kotlin( + """ + package test + @Composable + fun Foo() { + Text( + text = "Dark text on white", + color = Color(0xFF212121), + modifier = Modifier.background(Color.White) + ) + } + """.trimIndent() + ) + ).testModes(TestMode.DEFAULT).allowCompilationErrors().run().expectClean() + } +} diff --git a/lint-checks/src/test/kotlin/com/cvshealth/accessibility/lint/ComposeFormDetectorTest.kt b/lint-checks/src/test/kotlin/com/cvshealth/accessibility/lint/ComposeFormDetectorTest.kt new file mode 100644 index 0000000..8d54487 --- /dev/null +++ b/lint-checks/src/test/kotlin/com/cvshealth/accessibility/lint/ComposeFormDetectorTest.kt @@ -0,0 +1,269 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.accessibility.lint + +import com.android.tools.lint.checks.infrastructure.LintDetectorTest +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Issue +import org.junit.Test + +class ComposeFormDetectorTest : LintDetectorTest() { + + override fun getDetector(): Detector = ComposeFormDetector() + + override fun allowCompilationErrors(): Boolean = true + + override fun getIssues(): List = listOf( + ComposeFormDetector.INPUT_PURPOSE, + ComposeFormDetector.RADIO_GROUP_MISSING, + ) + + // ---- A11yInputPurpose ---- + + @Test + fun testTextfieldWithEmailKeyboardTypeAndNoAutofillTriggersA11yinputpurpose() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.TextField + import androidx.compose.foundation.text.KeyboardOptions + import androidx.compose.ui.text.input.KeyboardType + @Composable + fun Foo() { + TextField( + value = "", + onValueChange = {}, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email) + ) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testTextfieldWithPhoneKeyboardTypeAndNoAutofillTriggersA11yinputpurpose() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.TextField + import androidx.compose.foundation.text.KeyboardOptions + import androidx.compose.ui.text.input.KeyboardType + @Composable + fun Foo() { + TextField( + value = "", + onValueChange = {}, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone) + ) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testOutlinedtextfieldWithPasswordKeyboardTypeAndNoAutofillTriggersA11yinputpurpose() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.OutlinedTextField + import androidx.compose.foundation.text.KeyboardOptions + import androidx.compose.ui.text.input.KeyboardType + @Composable + fun Foo() { + OutlinedTextField( + value = "", + onValueChange = {}, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) + ) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testTextfieldWithEmailKeyboardTypeAndAutofillHintIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.TextField + import androidx.compose.foundation.text.KeyboardOptions + import androidx.compose.ui.text.input.KeyboardType + @Composable + fun Foo() { + TextField( + value = "", + onValueChange = {}, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.semantics { + contentType = ContentType.EmailAddress + } + ) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testTextfieldWithTextKeyboardTypeNoSpecificPurposeIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.TextField + @Composable + fun Foo() { + TextField( + value = "", + onValueChange = {}, + label = { Text("Name") } + ) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testTextfieldWithNumberKeyboardTypeAndAutofillIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.TextField + import androidx.compose.foundation.text.KeyboardOptions + import androidx.compose.ui.text.input.KeyboardType + @Composable + fun Foo() { + TextField( + value = "", + onValueChange = {}, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.semantics { autofill = true } + ) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yRadioGroupMissing ---- + + @Test + fun testRadiobuttonOutsideSelectablegroupColumnTriggersA11yradiogroupmissing() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.RadioButton + import androidx.compose.foundation.layout.Column + @Composable + fun Foo() { + Column { + RadioButton(selected = true, onClick = {}) + RadioButton(selected = false, onClick = {}) + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testRadiobuttonWithoutAnyWrappingGroupTriggersA11yradiogroupmissing() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.RadioButton + @Composable + fun Foo() { + RadioButton(selected = false, onClick = {}) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testRadiobuttonInColumnWithSelectablegroupIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.RadioButton + import androidx.compose.foundation.layout.Column + @Composable + fun Foo() { + Column(modifier = Modifier.selectableGroup()) { + RadioButton(selected = true, onClick = {}) + RadioButton(selected = false, onClick = {}) + RadioButton(selected = false, onClick = {}) + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testRadiobuttonInRowWithSelectablegroupIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.RadioButton + import androidx.compose.foundation.layout.Row + @Composable + fun Foo() { + Row(modifier = Modifier.selectableGroup()) { + RadioButton(selected = true, onClick = {}) + RadioButton(selected = false, onClick = {}) + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } +} diff --git a/lint-checks/src/test/kotlin/com/cvshealth/accessibility/lint/ComposeStructureDetectorTest.kt b/lint-checks/src/test/kotlin/com/cvshealth/accessibility/lint/ComposeStructureDetectorTest.kt new file mode 100644 index 0000000..9638091 --- /dev/null +++ b/lint-checks/src/test/kotlin/com/cvshealth/accessibility/lint/ComposeStructureDetectorTest.kt @@ -0,0 +1,398 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.accessibility.lint + +import com.android.tools.lint.checks.infrastructure.LintDetectorTest +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Issue +import org.junit.Test + +class ComposeStructureDetectorTest : LintDetectorTest() { + + override fun getDetector(): Detector = ComposeStructureDetector() + + override fun getIssues(): List = listOf( + ComposeStructureDetector.HIDDEN_WITH_INTERACTIVE_CHILDREN, + ComposeStructureDetector.ACCESSIBILITY_GROUPING, + ComposeStructureDetector.BOX_CHILD_ORDER, + ComposeStructureDetector.BUTTON_USED_AS_LINK, + ) + + // ---- A11yHiddenWithInteractiveChildren ---- + + @Test + fun testRowWithClearandsetsemanticsAndButtonChildTriggersA11yhiddenwithinteractivechildren() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Row + @Composable + fun Foo() { + Row(modifier = Modifier.clearAndSetSemantics {}) { + Button(onClick = {}) { Text("Action") } + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testColumnWithClearandsetsemanticsAndCheckboxChildTriggersA11yhiddenwithinteractivechildren() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Column + import androidx.compose.material3.Checkbox + @Composable + fun Foo() { + Column(modifier = Modifier.clearAndSetSemantics {}) { + Checkbox(checked = false, onCheckedChange = {}) + Text("Option") + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testRowWithClearandsetsemanticsAndOnlyTextChildIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Row + @Composable + fun Foo() { + Row(modifier = Modifier.clearAndSetSemantics { contentDescription = "Status: Active" }) { + Icon(painter = painterResource(R.drawable.ic_active), contentDescription = null) + Text("Active") + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testBoxWithClearandsetsemanticsAndSwitchChildTriggersA11yhiddenwithinteractivechildren() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Box + import androidx.compose.material3.Switch + @Composable + fun Foo() { + Box(modifier = Modifier.clearAndSetSemantics {}) { + Switch(checked = true, onCheckedChange = {}) + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yAccessibilityGrouping ---- + + @Test + fun testClickableRowWithIconAndTextButNoMergedescendantsTriggersA11yaccessibilitygrouping() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Row + import androidx.compose.material3.Icon + import androidx.compose.material3.Text + @Composable + fun Foo() { + Row(modifier = Modifier.clickable { openDetail() }) { + Icon(painter = painterResource(R.drawable.ic_info), contentDescription = null) + Text("View Details") + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testClickableColumnWithImageAndTextButNoMergedescendantsTriggersA11yaccessibilitygrouping() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Column + import androidx.compose.foundation.Image + import androidx.compose.material3.Text + @Composable + fun Foo() { + Column(modifier = Modifier.clickable { navigate() }) { + Image(painter = painterResource(R.drawable.thumbnail), contentDescription = null) + Text("Article Title") + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testClickableRowWithIconAndTextAndMergedescendantsIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Row + import androidx.compose.material3.Icon + import androidx.compose.material3.Text + @Composable + fun Foo() { + Row(modifier = Modifier + .semantics(mergeDescendants = true) {} + .clickable { openDetail() } + ) { + Icon(painter = painterResource(R.drawable.ic_info), contentDescription = null) + Text("View Details") + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testNonclickableRowWithIconAndTextIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Row + import androidx.compose.material3.Icon + import androidx.compose.material3.Text + @Composable + fun Foo() { + Row { + Icon(painter = painterResource(R.drawable.ic_info), contentDescription = null) + Text("Status") + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yBoxChildOrder ---- + + @Test + fun testBoxWithAlignAndZindexButNoTraversalindexTriggersA11yboxchildorder() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Box + @Composable + fun Foo() { + Box { + Image( + painter = painterResource(R.drawable.bg), + contentDescription = null, + modifier = Modifier.align(Alignment.Center).zIndex(0f) + ) + Text( + "Overlay Text", + modifier = Modifier.align(Alignment.BottomCenter).zIndex(1f) + ) + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testBoxWithAlignAndZindexAndTraversalindexIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Box + @Composable + fun Foo() { + Box { + Image( + painter = painterResource(R.drawable.bg), + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + .zIndex(0f) + .semantics { traversalIndex = 1f } + ) + Text( + "Overlay Text", + modifier = Modifier + .align(Alignment.BottomCenter) + .zIndex(1f) + .semantics { traversalIndex = 0f } + ) + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testBoxWithoutZindexIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Box + @Composable + fun Foo() { + Box { + Image( + painter = painterResource(R.drawable.bg), + contentDescription = null, + modifier = Modifier.align(Alignment.Center) + ) + Text("Text", modifier = Modifier.align(Alignment.BottomCenter)) + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yButtonUsedAsLink ---- + + @Test + fun testButtonWithHttpsUrlTriggersA11ybuttonusedaslink() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.Button + @Composable + fun Foo() { + Button(onClick = { + uriHandler.openUri("https://www.example.com") + }) { + Text("Visit Website") + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testTextbuttonWithUriParseTriggersA11ybuttonusedaslink() { + // TODO: UAST cannot resolve unresolved Compose imports with allowCompilationErrors(), + // so visitMethodCall is not invoked and the detector does not trigger in tests. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.TextButton + import android.net.Uri + @Composable + fun Foo() { + TextButton(onClick = { + val uri = Uri.parse("https://example.com/page") + startActivity(Intent(Intent.ACTION_VIEW, uri)) + }) { + Text("Open Link") + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testButtonWithRoleDotLinkSemanticsIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.Button + @Composable + fun Foo() { + Button( + onClick = { uriHandler.openUri("https://www.example.com") }, + modifier = Modifier.semantics { role = Role.Link } + ) { + Text("Visit Website") + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testButtonWithoutLinkBehaviorIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.Button + @Composable + fun Foo() { + Button(onClick = { submitForm() }) { + Text("Submit") + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } +} diff --git a/lint-checks/src/test/kotlin/com/cvshealth/accessibility/lint/ComposeTextScanDetectorTest.kt b/lint-checks/src/test/kotlin/com/cvshealth/accessibility/lint/ComposeTextScanDetectorTest.kt new file mode 100644 index 0000000..d8555d2 --- /dev/null +++ b/lint-checks/src/test/kotlin/com/cvshealth/accessibility/lint/ComposeTextScanDetectorTest.kt @@ -0,0 +1,798 @@ +/* + * Copyright 2026 CVS Health and/or one of its affiliates + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cvshealth.accessibility.lint + +import com.android.tools.lint.checks.infrastructure.LintDetectorTest +import com.android.tools.lint.checks.infrastructure.TestMode +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Issue +import org.junit.Test + +class ComposeTextScanDetectorTest : LintDetectorTest() { + + override fun getDetector(): Detector = ComposeTextScanDetector() + + override fun allowCompilationErrors(): Boolean = true + + override fun getIssues(): List = listOf( + ComposeTextScanDetector.FIXED_FONT_SIZE, + ComposeTextScanDetector.MAX_LINES_ONE, + ComposeTextScanDetector.HEADING_SEMANTICS_MISSING, + ComposeTextScanDetector.FAKE_HEADING_IN_LABEL, + ComposeTextScanDetector.CLICKABLE_MISSING_ROLE, + ComposeTextScanDetector.HARDCODED_COLOR, + ComposeTextScanDetector.VISUALLY_DISABLED_NOT_SEMANTICALLY, + ComposeTextScanDetector.GENERIC_LINK_TEXT, + ComposeTextScanDetector.REDUCE_MOTION, + ComposeTextScanDetector.GESTURE_MISSING_ALTERNATIVE, + ComposeTextScanDetector.TIMING_ADJUSTABLE, + ComposeTextScanDetector.DIALOG_FOCUS_MANAGEMENT, + ) + + // ---- A11yFixedFontSize ---- + + @Test + fun testHardcodedSpFontSizeTriggersA11yfixedfontsize() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.Text + import androidx.compose.ui.unit.sp + @Composable + fun Foo() { + Text(text = "Hello", fontSize = 16.sp) + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yFixedFontSize") + } + + @Test + fun testAnotherHardcodedSpValueTriggersA11yfixedfontsize() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.Text + import androidx.compose.ui.unit.sp + @Composable + fun Bar() { + Text(text = "Subtitle", fontSize = 14.sp) + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yFixedFontSize") + } + + @Test + fun testTypographyStyleWithoutHardcodedSpIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.Text + import androidx.compose.material3.MaterialTheme + @Composable + fun Foo() { + Text(text = "Hello", style = MaterialTheme.typography.bodyMedium) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yMaxLinesOne ---- + + @Test + fun testMaxlines1TriggersA11ymaxlinesone() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.Text + @Composable + fun Foo() { + Text(text = "Long text that might be truncated", maxLines = 1) + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yMaxLinesOne") + } + + @Test + fun testMaxlines2DoesNotTriggerA11ymaxlinesone() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.Text + @Composable + fun Foo() { + Text(text = "Long text", maxLines = 2) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yHeadingSemanticsMissing ---- + + @Test + fun testTextWithHeadlinelargeWithoutHeadingSemanticsTriggersA11yheadingsemanticsmissing() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.Text + import androidx.compose.material3.MaterialTheme + @Composable + fun Foo() { + Text(text = "Section Title", style = MaterialTheme.typography.headlineLarge) + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yHeadingSemanticsMissing") + } + + @Test + fun testTextWithTitlemediumWithoutHeadingSemanticsTriggersA11yheadingsemanticsmissing() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.Text + import androidx.compose.material3.MaterialTheme + @Composable + fun Foo() { + Text(text = "Card Title", style = MaterialTheme.typography.titleMedium) + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yHeadingSemanticsMissing") + } + + @Test + fun testTextWithHeadlinemediumAndHeadingSemanticsIsClean() { + // Skip WHITESPACE mode: extra whitespace may cause the heading() keyword search to + // miss the context window, producing a false positive in whitespace mode. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.Text + import androidx.compose.material3.MaterialTheme + @Composable + fun Foo() { + Text( + text = "Section", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.semantics { heading() } + ) + } + """.trimIndent() + ) + ).skipTestModes(TestMode.WHITESPACE) + .allowCompilationErrors().run().expectClean() + } + + // ---- A11yFakeHeadingInLabel ---- + + @Test + fun testContentdescriptionContainingHeadingTriggersA11yfakeheadinginlabel() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.Icon + @Composable + fun Foo() { + Icon(painter = painterResource(R.drawable.ic_section), contentDescription = "Section heading") + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yFakeHeadingInLabel") + } + + @Test + fun testContentdescriptionContainingHeadingCaseInsensitiveTriggersA11yfakeheadinginlabel() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + val desc = contentDescription = "My Heading Icon" + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yFakeHeadingInLabel") + } + + @Test + fun testContentdescriptionWithoutHeadingWordIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.Icon + @Composable + fun Foo() { + Icon(painter = painterResource(R.drawable.ic_section), contentDescription = "Section title") + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yClickableMissingRole ---- + + @Test + fun testClickableWithoutRoleTriggersA11yclickablemissingrole() { + // The detector regex is \.clickable\s*\( — use explicit parentheses before the lambda + // so the regex matches. This detector uses Location.create() without an AST node. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Box + @Composable + fun Foo() { + Box(modifier = Modifier.clickable() { doAction() }) { + } + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yClickableMissingRole") + } + + @Test + fun testClickableWithRoleIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Box + @Composable + fun Foo() { + Box(modifier = Modifier.clickable(role = Role.Button) { doAction() }) { + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + @Test + fun testClickableWithRoleDotExpressionIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Box + @Composable + fun Foo() { + Box(modifier = Modifier.clickable( + role = Role.Checkbox, + onClick = { toggle() } + )) { + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yHardcodedColor ---- + + @Test + fun testColorDotBlackUsageTriggersA11yhardcodedcolor() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.Text + import androidx.compose.ui.graphics.Color + @Composable + fun Foo() { + Text(text = "Hello", color = Color.Black) + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yHardcodedColor") + } + + @Test + fun testColorDotWhiteUsageTriggersA11yhardcodedcolor() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.Text + import androidx.compose.ui.graphics.Color + @Composable + fun Foo() { + Text(text = "Hello", color = Color.White) + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yHardcodedColor") + } + + @Test + fun testMaterialthemeColorschemeUsageIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.Text + import androidx.compose.material3.MaterialTheme + @Composable + fun Foo() { + Text(text = "Hello", color = MaterialTheme.colorScheme.onBackground) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yVisuallyDisabledNotSemantically ---- + + @Test + fun testAlphaWithLowValueTriggersA11yvisuallydisablednotsemantically() { + // The detector regex is \.alpha\s*\(\s*(0\.\d+|0)\s*\) — use a value without the + // 'f' float suffix so the regex matches. This detector uses Location.create() without + // an AST node. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Box + @Composable + fun Foo() { + Box(modifier = Modifier.alpha(0.3)) { + } + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yVisuallyDisabledNotSemantically") + } + + @Test + fun testAlphaZeroTriggersA11yvisuallydisablednotsemantically() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Box + @Composable + fun Foo() { + Box(modifier = Modifier.alpha(0)) { + } + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yVisuallyDisabledNotSemantically") + } + + @Test + fun testAlphaWithEnabledFalseIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Box + @Composable + fun Foo(enabled: Boolean) { + Box(modifier = Modifier.alpha(0.3)) { + SomeButton(enabled = false) { } + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yGenericLinkText ---- + + @Test + fun testGenericLinkTextClickHereInClickableContextTriggersA11ygenericlinktext() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Text( + text = "click here", + modifier = Modifier.clickable { openUrl() } + ) + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yGenericLinkText") + } + + @Test + fun testGenericLinkTextLearnMoreInClickabletextContextTriggersA11ygenericlinktext() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + ClickableText( + text = AnnotatedString("learn more"), + onClick = { openPage() } + ) + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yGenericLinkText") + } + + @Test + fun testDescriptiveLinkTextIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Text( + text = "View accessibility guidelines", + modifier = Modifier.clickable { openUrl() } + ) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yReduceMotion ---- + + @Test + fun testAnimatedvisibilityWithoutReducemotionCheckTriggersA11yreducemotion() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.animation.AnimatedVisibility + @Composable + fun Foo(visible: Boolean) { + AnimatedVisibility(visible = visible) { + Content() + } + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yReduceMotion") + } + + @Test + fun testAnimatefloatasstateWithoutReducemotionCheckTriggersA11yreducemotion() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.animation.core.animateFloatAsState + @Composable + fun Foo(expanded: Boolean) { + val rotation by animateFloatAsState(targetValue = if (expanded) 180f else 0f) + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yReduceMotion") + } + + @Test + fun testAnimatedvisibilityWithReducemotionCheckIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.animation.AnimatedVisibility + @Composable + fun Foo(visible: Boolean) { + val isReduceMotionEnabled = LocalReduceMotion.current + AnimatedVisibility(visible = visible && !isReduceMotionEnabled) { + Content() + } + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yGestureMissingAlternative ---- + + @Test + fun testPointerinputWithoutAlternativeTriggersA11ygesturemissingalternative() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Box + @Composable + fun Foo() { + Box(modifier = Modifier.pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + handleDrag(dragAmount) + } + }) + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yGestureMissingAlternative") + } + + @Test + fun testDetectdraggesturesWithoutAlternativeTriggersA11ygesturemissingalternative() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + @Composable + fun Foo() { + Modifier.pointerInput(Unit) { + detectDragGestures { change, amount -> + processDrag(amount) + } + } + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yGestureMissingAlternative") + } + + @Test + fun testPointerinputWithCustomactionsAlternativeIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.foundation.layout.Box + @Composable + fun Foo() { + Box(modifier = Modifier + .pointerInput(Unit) { + detectDragGestures { _, _ -> } + } + .semantics { + customActions = listOf(CustomAccessibilityAction("Dismiss") { true }) + } + ) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yTimingAdjustable ---- + + @Test + fun testDelayWithLongDurationTriggersA11ytimingadjustable() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import kotlinx.coroutines.delay + suspend fun Foo() { + delay(5000) + hideToast() + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yTimingAdjustable") + } + + @Test + fun testWithtimeoutWithLongDurationTriggersA11ytimingadjustable() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import kotlinx.coroutines.withTimeout + suspend fun Foo() { + withTimeout(10000) { + fetchData() + } + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yTimingAdjustable") + } + + @Test + fun testShortDelayUnder1000msIsClean() { + lint().files( + kotlin( + """ + package test + import kotlinx.coroutines.delay + suspend fun Foo() { + delay(300) + triggerAnimation() + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } + + // ---- A11yDialogFocusManagement ---- + + @Test + fun testAlertdialogWithoutFocusrequesterTriggersA11ydialogfocusmanagement() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.AlertDialog + @Composable + fun Foo(onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Warning") }, + text = { Text("Are you sure?") }, + confirmButton = { Button(onClick = onDismiss) { Text("OK") } } + ) + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yDialogFocusManagement") + } + + @Test + fun testDialogWithoutFocusrequesterTriggersA11ydialogfocusmanagement() { + // This detector uses Location.create() without an AST node, so SUPPRESSIBLE and + // WHITESPACE test modes are not applicable. + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.ui.window.Dialog + @Composable + fun Foo(onDismiss: () -> Unit) { + Dialog(onDismissRequest = onDismiss) { + Text("Dialog content") + } + } + """.trimIndent() + ) + ).skipTestModes(TestMode.SUPPRESSIBLE, TestMode.WHITESPACE) + .allowCompilationErrors().run().expectContains("A11yDialogFocusManagement") + } + + @Test + fun testAlertdialogWithFocusrequesterIsClean() { + lint().files( + kotlin( + """ + package test + import androidx.compose.runtime.Composable + import androidx.compose.material3.AlertDialog + import androidx.compose.ui.focus.FocusRequester + import androidx.compose.ui.focus.focusRequester + @Composable + fun Foo(onDismiss: () -> Unit) { + val focusRequester = remember { FocusRequester() } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Warning") }, + confirmButton = { + Button( + onClick = onDismiss, + modifier = Modifier.focusRequester(focusRequester) + ) { Text("OK") } + } + ) + } + """.trimIndent() + ) + ).allowCompilationErrors().run().expectClean() + } +} diff --git a/settings.gradle b/settings.gradle index 4909e26..2ac9f37 100644 --- a/settings.gradle +++ b/settings.gradle @@ -14,3 +14,7 @@ dependencyResolutionManagement { } rootProject.name = "Compose Accessibility Techniques" include ':app' +include ':A11yAgent' +include ':lint-checks' + +includeBuild 'gradle-plugin'