Files
Q-Wallets/.agents/skills/i18n/i18n_scan_hardcoded.py
2026-08-09 07:39:39 +02:00

135 lines
4.8 KiB
Python

"""Find hardcoded user-visible strings that should go through i18next.
Catches three kinds of hit: JSX attributes (aria-label, label, placeholder,
title, alt, helperText), JSX text content, and quoted English sentences in
plain code -- including the `throw new Error('...')` messages that surface in
the UI as save/snackbar errors.
Usage:
python3 .agents/skills/i18n/i18n_scan_hardcoded.py # whole src tree
python3 .agents/skills/i18n/i18n_scan_hardcoded.py src/components/AddressBook
python3 .agents/skills/i18n/i18n_scan_hardcoded.py --detail src/components/ExternalSendForm.tsx
python3 .agents/skills/i18n/i18n_scan_hardcoded.py -o /tmp/hits.json src/
Counts are candidates, not confirmed defects -- a string may legitimately be a
CSS value, a qortalRequest action, a localStorage key or a log message. Read the
hits before acting on them.
"""
import argparse
import json
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
ATTRS = 'aria-label|label|placeholder|title|alt|helperText|tooltip|titleAccess'
ATTR_RE = re.compile(
rf'\b(?:{ATTRS})=(?:"([^"]{{2,}})"|\{{\s*[\'"]([^\'"]{{2,}})[\'"]\s*\}})'
)
JSX_RE = re.compile(r'>\s*([A-Z][A-Za-z0-9][^<>{}\n]{3,})\s*<')
STR_RE = re.compile(
r'[\'"]([A-Z][a-z]+(?:\s+[A-Za-z0-9\'".,!?()-]+){1,}[.!?]?)[\'"]'
)
SKIP_VALUE = re.compile(r'^(https?:|/|#|[A-Z_]+$)')
# Lines that cannot produce user-visible text in this codebase: logging, the
# qortalRequest bridge, storage keys and QDN identifiers, imports.
SKIP_LINE = re.compile(
r'console\.|qortalRequest|localStorage\.|sessionStorage\.|'
r'data-testid|identifier:|service:|import\s|require\('
)
CSS_ISH = re.compile(r'\b(px|rem|em|solid|dashed|rgba?\(|translate|calc\()')
EXCLUDE_DIRS = {'node_modules', 'dist', 'build', '.git', '__tests__'}
def scan_file(path: Path):
hits = []
try:
lines = path.read_text(encoding='utf-8').splitlines()
except (UnicodeDecodeError, OSError):
return hits
for number, line in enumerate(lines, 1):
stripped = line.strip()
if stripped.startswith(('//', '*', '/*')) or SKIP_LINE.search(line):
continue
# One entry per distinct string on the line. An attribute value also
# matches the generic string-literal pattern, so the more specific kind
# wins rather than the string being reported twice.
found = {}
for match in STR_RE.finditer(line):
value = match.group(1)
if ' ' in value and not SKIP_VALUE.match(value) and not CSS_ISH.search(value):
found[value] = 'str'
for match in JSX_RE.finditer(line):
value = match.group(1).strip()
if value and not SKIP_VALUE.match(value):
found[value] = 'jsx'
for match in ATTR_RE.finditer(line):
value = match.group(1) or match.group(2)
if value and not SKIP_VALUE.match(value) and re.search(r'[a-z]', value):
found[value] = 'attr'
for value, kind in sorted(found.items()):
hits.append({'line': number, 'kind': kind, 'text': value})
return hits
def iter_sources(target: Path):
if target.is_file():
yield target
return
for path in sorted(target.rglob('*')):
if path.suffix not in ('.ts', '.tsx'):
continue
if set(path.parts) & EXCLUDE_DIRS or '.test.' in path.name:
continue
yield path
def main():
parser = argparse.ArgumentParser(description=__doc__.split('\n')[0])
parser.add_argument('target', nargs='?', default='src', help='file or directory')
parser.add_argument('--detail', action='store_true', help='print every hit')
parser.add_argument('-o', '--output', help='write full results to a JSON file')
args = parser.parse_args()
target = Path(args.target)
if not target.is_absolute():
target = REPO_ROOT / target
if not target.exists():
sys.exit(f'no such path: {target}')
report, total = {}, 0
for path in iter_sources(target):
hits = scan_file(path)
if not hits:
continue
rel = str(path.relative_to(REPO_ROOT))
report[rel] = hits
total += len(hits)
for rel, hits in sorted(report.items(), key=lambda kv: -len(kv[1])):
print(f'{len(hits):5d} {rel}')
if args.detail:
for hit in hits:
print(f" {hit['line']:5d} {hit['kind']:4s} {hit['text'][:88]}")
if total:
print(f'\n{total} candidates across {len(report)} files')
else:
print(f'No hardcoded strings found in {target.relative_to(REPO_ROOT)}')
if args.output:
Path(args.output).write_text(
json.dumps(report, ensure_ascii=False, indent=1), encoding='utf-8'
)
print(f'Report written to {args.output}')
if __name__ == '__main__':
main()