Add Harper workflow for grammar and style checks #27
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Docs Quality | |
| on: | |
| pull_request: | |
| workflow_dispatch: | |
| inputs: | |
| allow_failure: | |
| type: boolean | |
| default: false | |
| jobs: | |
| spelling: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: "20" | |
| - uses: actions/cache@v4 | |
| with: | |
| path: ~/.npm | |
| key: ${{ runner.os }}-npm-cspell-${{ hashFiles('package-lock.json') }} | |
| restore-keys: ${{ runner.os }}-npm-cspell- | |
| - run: npm ci | |
| - run: | | |
| npx cspell lint \ | |
| "docs/**/*.{md,mdx}" \ | |
| "src/pages/**/*.{md,mdx}" \ | |
| --config cspell.json \ | |
| --no-progress \ | |
| --reporter json \ | |
| > cspell-report.json || true | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: cspell-report | |
| path: cspell-report.json | |
| grammar: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.cargo/bin | |
| ~/.cargo/registry | |
| ~/.cargo/git | |
| key: ${{ runner.os }}-cargo-harper | |
| restore-keys: ${{ runner.os }}-cargo-harper | |
| - run: | | |
| command -v harper-cli >/dev/null 2>&1 || \ | |
| cargo install --git https://github.com/Automattic/harper \ | |
| --rev <PIN_THIS_TO_A_COMMIT_SHA> \ | |
| harper-cli --locked | |
| - run: | | |
| find docs src/pages -type f \( -name "*.md" -o -name "*.mdx" \) -print0 | \ | |
| xargs -0 harper-cli lint --format json > harper-report.json || true | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: harper-report | |
| path: harper-report.json | |
| heading-case: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - id: check | |
| continue-on-error: true | |
| run: python check_heading_case.py docs src/pages | |
| - run: echo "${{ steps.check.outcome }}" > heading-case-result.txt | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: heading-case-result | |
| path: heading-case-result.txt | |
| aggregate: | |
| needs: [spelling, grammar, heading-case] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/download-artifact@v4 | |
| - env: | |
| ALLOW_FAILURE: ${{ github.event_name == 'workflow_dispatch' && inputs.allow_failure }} | |
| run: | | |
| python - <<'PY' | |
| import json | |
| import os | |
| import sys | |
| def load_json(path): | |
| try: | |
| with open(path, encoding="utf-8") as f: | |
| return json.load(f) | |
| except FileNotFoundError: | |
| return None | |
| issues = [] | |
| cspell_data = load_json("cspell-report/cspell-report.json") | |
| for issue in (cspell_data or {}).get("issues", []): | |
| issues.append( | |
| f"[Spelling] {issue.get('uri')}:{issue.get('row')}: " | |
| f"{issue.get('text')!r} - {issue.get('message', 'unknown word')}" | |
| ) | |
| ALLOWED_KINDS = {"Grammar", "Repetition"} | |
| for report in load_json("harper-report/harper-report.json") or []: | |
| file = report.get("file") | |
| for lint in report.get("lints", []): | |
| kind = lint.get("kind") | |
| if kind not in ALLOWED_KINDS: | |
| continue | |
| issues.append( | |
| f"[{kind}::{lint.get('rule')}] {file}:{lint.get('line')}: " | |
| f"{lint.get('matched_text')!r} - {lint.get('message')}" | |
| ) | |
| with open("heading-case-result/heading-case-result.txt", encoding="utf-8") as f: | |
| if f.read().strip() == "failure": | |
| issues.append("[HeadingCase] Title case violations found, see heading-case job log.") | |
| for item in issues: | |
| print(item) | |
| print(f"\n{len(issues)} issue(s) found.") | |
| allow_failure = os.environ.get("ALLOW_FAILURE") == "true" | |
| sys.exit(1 if issues and not allow_failure else 0) | |
| PY print("Suggested dictionary candidates:") | |
| for word in sorted(words, key=str.lower): | |
| print(word) | |
| PY | |
| - name: Check Selected Harper Rules | |
| run: | | |
| python - <<'PY' | |
| import json | |
| import re | |
| import sys | |
| with open("harper-report.json", encoding="utf-8") as f: | |
| reports = json.load(f) | |
| selected = [] | |
| total_lints = 0 | |
| IGNORED_RULES = { | |
| ("Spelling", "OkToOkay"), | |
| ("Spelling", "DisjointPrefixes"), | |
| } | |
| def should_ignore_spelling_text(text): | |
| if not text: | |
| return False | |
| stripped = text.strip() | |
| # URLs and URL fragments | |
| if stripped.startswith(("http://", "https://", "www.")): | |
| return True | |
| # File paths / command paths | |
| if "/" in stripped: | |
| return True | |
| # Long hashes, IDs, tokens | |
| if len(stripped) >= 16 and any(c.isdigit() for c in stripped): | |
| return True | |
| # Hex-like strings, e.g. d5442a5dc4baadd48b32 | |
| if len(stripped) >= 8 and re.fullmatch(r"[a-fA-F0-9]+", stripped): | |
| return True | |
| # Mostly random-looking alphanumeric tokens | |
| if len(stripped) >= 12 and re.fullmatch(r"[A-Za-z0-9_-]+", stripped): | |
| return True | |
| return False | |
| for report in reports: | |
| file = report.get("file") | |
| for lint in report.get("lints", []): | |
| total_lints += 1 | |
| kind = lint.get("kind") | |
| rule = lint.get("rule") | |
| line_no = lint.get("line") | |
| message = lint.get("message") | |
| text = lint.get("matched_text") | |
| if (kind, rule) in IGNORED_RULES: | |
| continue | |
| if kind == "Spelling" and should_ignore_spelling_text(text): | |
| continue | |
| if kind in {"Spelling", "Grammar", "Repetition"}: | |
| selected.append( | |
| f"{file}:{line_no}: [{kind}::{rule}] {text!r} - {message}" | |
| ) | |
| print(f"Total Harper lint count: {total_lints}") | |
| print(f"Selected lint count: {len(selected)}") | |
| if selected: | |
| print("Selected Harper lints found:") | |
| for item in selected: | |
| print(item) | |
| sys.exit(1) | |
| print("No selected Harper lints found.") | |
| PY |