Skip to content

feat(nodedex): add user-defined node groups with many-to-many membership #294

feat(nodedex): add user-defined node groups with many-to-many membership

feat(nodedex): add user-defined node groups with many-to-many membership #294

name: Check Translations
on:
push:
branches: [main]
paths:
- "lib/l10n/**"
- "l10n.yaml"
- "scripts/check_translation_coverage.py"
pull_request:
branches: [main]
paths:
- "lib/l10n/**"
- "l10n.yaml"
- "scripts/check_translation_coverage.py"
workflow_dispatch:
permissions: read-all
jobs:
validate-translations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Create .env for CI
run: cp .env.ci .env
- name: Setup Flutter
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
with:
flutter-version: "3.41.6"
channel: "stable"
cache: true
- name: Install dependencies
run: flutter pub get
- name: Validate ARB files (flutter gen-l10n)
run: |
echo "Running flutter gen-l10n to validate ARB files..."
flutter gen-l10n
echo "All ARB files are valid."
- name: Check ARB file integrity
run: |
python3 << 'SCRIPT'
import json
import os
import re
import sys
l10n_dir = "lib/l10n"
template = "app_en.arb"
with open(os.path.join(l10n_dir, template), "r") as f:
template_data = json.load(f)
template_keys = sorted(
k for k in template_data if not k.startswith("@") and k != "@@locale"
)
print(f"Template ({template}): {len(template_keys)} keys")
errors = []
warnings = []
arb_files = sorted(
f for f in os.listdir(l10n_dir)
if f.endswith(".arb") and f != template
)
for arb_file in arb_files:
path = os.path.join(l10n_dir, arb_file)
try:
with open(path, "r") as f:
locale_data = json.load(f)
except json.JSONDecodeError as e:
errors.append(f"{arb_file}: Invalid JSON - {e}")
continue
locale_keys = sorted(
k for k in locale_data if not k.startswith("@") and k != "@@locale"
)
# Extra keys not in template are always an error — stale or mistyped keys
extra = set(locale_keys) - set(template_keys)
if extra:
errors.append(
f"{arb_file}: {len(extra)} extra key(s) not in template: "
+ ", ".join(sorted(extra)[:10])
+ ("..." if len(extra) > 10 else "")
)
# @@locale must be present
if "@@locale" not in locale_data:
errors.append(f"{arb_file}: Missing @@locale field")
# Check that parameterized keys preserve placeholders
for key in locale_keys:
if key not in template_data:
continue
locale_val = locale_data[key]
if not isinstance(locale_val, str):
continue
# Skip if value is identical to English (untranslated placeholder)
if locale_val == template_data[key]:
continue
# Get declared placeholders from @key metadata
meta_key = f"@{key}"
if meta_key in template_data and isinstance(template_data[meta_key], dict):
placeholders = template_data[meta_key].get("placeholders", {})
declared_params = set(placeholders.keys())
else:
continue
if not declared_params:
continue
# Each declared placeholder must appear in the translation
locale_params = set(re.findall(r"\{(\w+)\}", locale_val))
missing_params = declared_params - locale_params
if missing_params:
errors.append(
f"{arb_file}: Key '{key}' is missing placeholder(s): "
+ ", ".join(sorted(missing_params))
)
# Missing keys are an ERROR. Flutter would silently fall back to
# English, but that lets feature commits add keys to en.arb and
# forget every other locale - this is how locales drift out of
# sync. Every key in app_en.arb MUST exist in every locale file,
# even if the value is identical to English (intentional verbatim
# for brand names, units, format templates). Use
# `python3 scripts/backfill_verbatim_keys.py` to add missing
# keys with the English value when they are intentional verbatim.
missing = set(template_keys) - set(locale_keys)
if missing:
errors.append(
f"{arb_file}: {len(missing)} key(s) missing from locale "
f"(must be present in every locale file - run "
f"scripts/backfill_verbatim_keys.py for intentional "
f"verbatim, or translate them): "
+ ", ".join(sorted(missing)[:10])
+ ("..." if len(missing) > 10 else "")
)
else:
print(f" {arb_file}: {len(locale_keys)} keys (complete)")
if warnings:
print(f"\n{'='*60}")
print(f"WARNINGS ({len(warnings)}):")
for w in warnings:
print(f" ⚠️ {w}")
if errors:
print(f"\n{'='*60}")
print(f"ERRORS ({len(errors)}):")
for e in errors:
print(f" ❌ {e}")
sys.exit(1)
print(f"\nAll translation files passed integrity checks.")
SCRIPT
- name: Translation coverage report
if: always()
run: python3 scripts/check_translation_coverage.py
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: translation-status
path: TRANSLATION_STATUS.md
- name: Post coverage summary
if: github.event_name == 'pull_request'
run: |
python3 << 'SCRIPT'
import json
import os
l10n_dir = "lib/l10n"
template = "app_en.arb"
with open(os.path.join(l10n_dir, template), "r") as f:
template_data = json.load(f)
template_keys = [
k for k in template_data if not k.startswith("@") and k != "@@locale"
]
total = len(template_keys)
arb_files = sorted(
f for f in os.listdir(l10n_dir)
if f.endswith(".arb") and f != template
)
lines = ["## 🌐 Translation Coverage", ""]
lines.append(f"| Locale | Translated | Coverage |")
lines.append(f"|--------|-----------|----------|")
for arb_file in arb_files:
path = os.path.join(l10n_dir, arb_file)
try:
with open(path, "r") as f:
locale_data = json.load(f)
except json.JSONDecodeError:
lines.append(f"| {arb_file} | ❌ INVALID JSON | — |")
continue
translated = 0
for key in template_keys:
if key in locale_data and locale_data[key] != template_data[key]:
translated += 1
pct = (translated / total * 100) if total > 0 else 0
locale = arb_file.replace("app_", "").replace(".arb", "")
lines.append(f"| {locale} | {translated}/{total} | {pct:.1f}% |")
lines.append("")
lines.append(f"Keys with values identical to English are counted as untranslated.")
lines.append(f"Missing keys fall back to English automatically.")
summary = "\n".join(lines)
summary_path = os.environ.get("GITHUB_STEP_SUMMARY", "")
if summary_path:
with open(summary_path, "a") as f:
f.write(summary + "\n")
print(summary)
SCRIPT