fix(i18n): modularize engine, resolve cli syntax error, wire prompt directive and report localization

This commit is contained in:
MuchoRio 2026-10-10 19:02:12 +07:00
parent 204766607d
commit 2ab6fd14b8
16 changed files with 611 additions and 197 deletions

View file

@ -117,6 +117,11 @@ hiddenimports = [
# Strix modules
'strix',
'strix.i18n',
'strix.i18n.catalog',
'strix.i18n.context',
'strix.i18n.detector',
'strix.i18n.directive',
'strix.i18n.translator',
'strix.interface',
'strix.interface.main',
'strix.interface.cli',

View file

@ -7,6 +7,7 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader, select_autoescape
from strix.i18n import get_language_directive
from strix.skills import get_available_skills, load_skills, skill_search_dirs
from strix.utils.resource_paths import get_strix_resource_path
@ -131,6 +132,7 @@ def render_system_prompt(
available_skills=get_available_skills(),
interactive=interactive,
is_root=is_root,
language_directive=get_language_directive(),
system_prompt_context=system_prompt_context or {},
include_scope=include_scope,
**skill_content,

View file

@ -1,145 +0,0 @@
"""Robust Internationalization (i18n) Engine for Strix."""
from __future__ import annotations
import contextvars
import json
import os
from pathlib import Path
import threading
from typing import Any, Dict, Optional, Set
SUPPORTED_LANGUAGES: Set[str] = {"en", "es", "id"}
DEFAULT_LANGUAGE: str = "en"
# ContextVar for async execution & multi-tenant thread safety
_current_language: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
"strix_current_language", default=None
)
_locales_lock = threading.Lock()
_locales_cache: Dict[str, Dict[str, str]] = {}
def _normalize_lang(lang: Optional[str]) -> str:
"""Normalize language tags (e.g. 'es_ES.UTF-8' -> 'es')."""
if not lang:
return DEFAULT_LANGUAGE
clean = lang.strip().lower().split("_")[0].split("-")[0]
return clean if clean in SUPPORTED_LANGUAGES else DEFAULT_LANGUAGE
def set_language(lang: Optional[str]) -> str:
"""Set language preference for the current async execution context."""
normalized = _normalize_lang(lang)
_current_language.set(normalized)
return normalized
def get_language() -> str:
"""Get active language preference or auto-detect from environment/config."""
lang = _current_language.get()
if lang is not None:
return lang
return _detect_language()
def _detect_language() -> str:
"""Detect language via STRIX_LANGUAGE, config file, or system LANG/LC_ALL."""
# 1. Environment Variable STRIX_LANGUAGE
env_lang = os.getenv("STRIX_LANGUAGE")
if env_lang:
return _normalize_lang(env_lang)
# 2. Config File ~/.strix/cli-config.json
try:
cfg_path = Path.home() / ".strix" / "cli-config.json"
if cfg_path.exists():
data = json.loads(cfg_path.read_text(encoding="utf-8"))
cfg_lang = data.get("env", {}).get("STRIX_LANGUAGE") or data.get("language")
if cfg_lang:
return _normalize_lang(cfg_lang)
except Exception:
pass
# 3. System Locale (LC_ALL takes precedence over LANG)
sys_lang = os.getenv("LC_ALL") or os.getenv("LANG")
if sys_lang:
return _normalize_lang(sys_lang)
return DEFAULT_LANGUAGE
def _load_locale(lang: str) -> Dict[str, str]:
"""Load locale JSON file with thread-safe caching, type validation, and user overrides."""
with _locales_lock:
if lang in _locales_cache:
return _locales_cache[lang]
translations: Dict[str, str] = {}
def _safe_merge(source_data: Any) -> None:
if isinstance(source_data, dict):
for k, v in source_data.items():
if isinstance(k, str) and isinstance(v, str):
translations[k] = v
# Built-in locale
builtin_path = Path(__file__).parent / "locales" / f"{lang}.json"
if builtin_path.exists():
try:
_safe_merge(json.loads(builtin_path.read_text(encoding="utf-8")))
except Exception:
pass
# User override locale (~/.strix/locales/{lang}.json)
user_path = Path.home() / ".strix" / "locales" / f"{lang}.json"
if user_path.exists():
try:
_safe_merge(json.loads(user_path.read_text(encoding="utf-8")))
except Exception:
pass
with _locales_lock:
_locales_cache[lang] = translations
return translations
def t(key: str, **kwargs: Any) -> str:
"""Translate key into active language with safe keyword interpolation."""
lang = get_language()
locale = _load_locale(lang)
msg = locale.get(key)
if msg is None and lang != DEFAULT_LANGUAGE:
msg = _load_locale(DEFAULT_LANGUAGE).get(key)
if msg is None:
msg = key
if kwargs and "{" in msg and "}" in msg:
try:
msg = msg.format(**kwargs)
except (KeyError, ValueError, IndexError):
for k, v in kwargs.items():
msg = msg.replace(f"{{{k}}}", str(v))
return msg
def get_language_directive() -> str:
"""Generate prompt directive for LLM agents when target language is non-English."""
lang = get_language()
if lang == "en":
return ""
lang_names = {
"es": "Spanish",
"id": "Indonesian",
}
target_name = lang_names.get(lang, lang.upper())
return (
f"IMPORTANT LANGUAGE INSTRUCTION:\n"
f"Write all human-readable findings, executive summaries, descriptions, and remediation steps in {target_name}.\n"
f"Do NOT translate technical identifiers, including CVE IDs, CWE IDs, CVSS scores, "
f"source code snippets, file paths, or shell commands."
)

52
strix/i18n/__init__.py Normal file
View file

@ -0,0 +1,52 @@
"""Robust Internationalization (i18n) Engine for Strix."""
from __future__ import annotations
from typing import Any
from strix.i18n.catalog import clear_locales_cache, get_available_languages
from strix.i18n.context import reset_context_language, set_context_language
from strix.i18n.detector import DEFAULT_LANGUAGE, detect_language, normalize_language
from strix.i18n.directive import LANGUAGE_NAMES, get_language_directive
from strix.i18n.translator import translate
t = translate
def set_language(lang: str | None) -> str:
"""Set language preference for the current async execution context."""
normalized = normalize_language(lang)
set_context_language(normalized)
return normalized
def get_language() -> str:
"""Get active language preference or auto-detect from environment/config."""
return detect_language()
def reset_language() -> None:
"""Reset the execution context language to default/unspecified."""
reset_context_language()
def __getattr__(name: str) -> Any:
if name == "SUPPORTED_LANGUAGES":
return get_available_languages()
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"DEFAULT_LANGUAGE",
"LANGUAGE_NAMES",
"clear_locales_cache",
"get_available_languages",
"get_language",
"get_language_directive",
"normalize_language",
"reset_language",
"set_language",
"t",
"translate",
]

87
strix/i18n/catalog.py Normal file
View file

@ -0,0 +1,87 @@
"""Locale catalogue discovery, validation, and thread-safe caching."""
from __future__ import annotations
import contextlib
import json
import logging
import threading
from pathlib import Path
from typing import Any
from strix.utils.resource_paths import get_strix_resource_path
logger = logging.getLogger(__name__)
_locales_lock = threading.RLock()
_locales_cache: dict[str, dict[str, str]] = {}
def get_builtin_locales_dir() -> Path:
"""Return the filesystem path to bundled locale catalogs."""
return get_strix_resource_path("locales")
def get_user_locales_dir() -> Path:
"""Return the filesystem path to user-defined community locale catalogs."""
return Path.home() / ".strix" / "locales"
def get_available_languages() -> set[str]:
"""Discover all available language codes across built-in and user directories."""
languages: set[str] = {"en"}
builtin_dir = get_builtin_locales_dir()
if builtin_dir.is_dir():
for path in builtin_dir.glob("*.json"):
languages.add(path.stem.lower())
user_dir = get_user_locales_dir()
if user_dir.is_dir():
for path in user_dir.glob("*.json"):
languages.add(path.stem.lower())
return languages
def _safe_merge(target: dict[str, str], source_data: Any) -> None:
"""Safely merge key-value translation strings from source into target."""
if not isinstance(source_data, dict):
return
target.update(
{k: v for k, v in source_data.items() if isinstance(k, str) and isinstance(v, str)}
)
def load_locale(lang: str) -> dict[str, str]:
"""Load locale catalog JSON with thread-safe caching, type validation, and user overrides."""
with _locales_lock:
if lang in _locales_cache:
return _locales_cache[lang]
translations: dict[str, str] = {}
# 1. Built-in locale catalog
builtin_file = get_builtin_locales_dir() / f"{lang}.json"
if builtin_file.is_file():
with contextlib.suppress(OSError, json.JSONDecodeError):
raw_data = json.loads(builtin_file.read_text(encoding="utf-8"))
_safe_merge(translations, raw_data)
# 2. User community override catalog (~/.strix/locales/{lang}.json)
user_file = get_user_locales_dir() / f"{lang}.json"
if user_file.is_file():
with contextlib.suppress(OSError, json.JSONDecodeError):
raw_data = json.loads(user_file.read_text(encoding="utf-8"))
_safe_merge(translations, raw_data)
with _locales_lock:
_locales_cache[lang] = translations
return translations
def clear_locales_cache() -> None:
"""Evict all cached locale catalogs (useful for testing or hot-reload)."""
with _locales_lock:
_locales_cache.clear()

26
strix/i18n/context.py Normal file
View file

@ -0,0 +1,26 @@
"""Execution-context and task isolation for Strix i18n."""
from __future__ import annotations
import contextvars
_current_language: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"strix_current_language",
default=None,
)
def get_context_language() -> str | None:
"""Return the active language code bound to the current async context, or None."""
return _current_language.get()
def set_context_language(lang: str | None) -> None:
"""Bind a normalized language code to the active async execution context."""
_current_language.set(lang)
def reset_context_language() -> None:
"""Clear the active language code from the current async execution context."""
_current_language.set(None)

64
strix/i18n/detector.py Normal file
View file

@ -0,0 +1,64 @@
"""Language detection, normalization, and precedence policy."""
from __future__ import annotations
import contextlib
import json
import os
from pathlib import Path
from strix.i18n.catalog import get_available_languages
from strix.i18n.context import get_context_language
DEFAULT_LANGUAGE: str = "en"
def normalize_language(lang: str | None, supported: set[str] | None = None) -> str:
"""Normalize language tag (e.g. 'es_ES.UTF-8' -> 'es') and validate.
Checks against supported locales and returns DEFAULT_LANGUAGE if unrecognized.
"""
if not lang:
return DEFAULT_LANGUAGE
clean = lang.strip().lower().split(".")[0].split("_")[0].split("-")[0]
available = supported if supported is not None else get_available_languages()
return clean if clean in available else DEFAULT_LANGUAGE
def detect_language() -> str:
"""Detect language following the canonical precedence chain:
1. Active execution context (ContextVar)
2. STRIX_LANGUAGE environment variable
3. User CLI configuration file (~/.strix/cli-config.json)
4. System locale (LC_ALL takes precedence over LANG)
5. Default fallback language ('en')
"""
ctx_lang = get_context_language()
if ctx_lang:
return ctx_lang
# 1. Environment Variable STRIX_LANGUAGE
env_lang = os.getenv("STRIX_LANGUAGE")
if env_lang:
return normalize_language(env_lang)
# 2. Config File ~/.strix/cli-config.json
cfg_path = Path.home() / ".strix" / "cli-config.json"
if cfg_path.is_file():
with contextlib.suppress(OSError, json.JSONDecodeError):
data = json.loads(cfg_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
cfg_lang = data.get("env", {}).get("STRIX_LANGUAGE") or data.get("language")
if isinstance(cfg_lang, str) and cfg_lang.strip():
return normalize_language(cfg_lang)
# 3. System Locale (LC_ALL takes precedence over LANG)
sys_lang = os.getenv("LC_ALL") or os.getenv("LANG")
if sys_lang:
return normalize_language(sys_lang)
return DEFAULT_LANGUAGE

43
strix/i18n/directive.py Normal file
View file

@ -0,0 +1,43 @@
"""Agent prompt language directive generator for localized security findings."""
from __future__ import annotations
from strix.i18n.detector import detect_language
LANGUAGE_NAMES: dict[str, str] = {
"en": "English",
"es": "Spanish",
"id": "Indonesian",
"ko": "Korean",
"pt": "Portuguese",
"fr": "French",
"de": "German",
"ja": "Japanese",
"zh": "Chinese",
"it": "Italian",
"ru": "Russian",
"tr": "Turkish",
"ar": "Arabic",
"vi": "Vietnamese",
}
def get_language_directive(lang: str | None = None) -> str:
"""Generate prompt directive for LLM agents when target language is non-English.
Ensures technical security artifacts (CVE, CWE, CVSS, code, commands) remain unaltered.
"""
effective_lang = lang if lang is not None else detect_language()
if effective_lang == "en":
return ""
target_name = LANGUAGE_NAMES.get(effective_lang, effective_lang.upper())
return (
f"IMPORTANT LANGUAGE INSTRUCTION:\n"
f"Write all human-readable findings, executive summaries, descriptions, "
f"and remediation steps in {target_name}.\n"
f"Do NOT translate technical identifiers, including CVE IDs, CWE IDs, CVSS scores, "
f"source code snippets, file paths, or shell commands."
)

29
strix/i18n/translator.py Normal file
View file

@ -0,0 +1,29 @@
"""Translation lookup, fallback resolution, and safe keyword interpolation."""
from __future__ import annotations
from typing import Any
from strix.i18n.catalog import load_locale
from strix.i18n.detector import DEFAULT_LANGUAGE, detect_language
def translate(key: str, **kwargs: Any) -> str:
"""Translate key into the active language with fallback and safe interpolation."""
lang = detect_language()
catalog = load_locale(lang)
msg = catalog.get(key)
if msg is None and lang != DEFAULT_LANGUAGE:
msg = load_locale(DEFAULT_LANGUAGE).get(key)
if msg is None:
msg = key
if kwargs and "{" in msg and "}" in msg:
try:
msg = msg.format(**kwargs)
except (KeyError, ValueError, IndexError):
for k, v in kwargs.items():
msg = msg.replace(f"{{{k}}}", str(v))
return msg

View file

@ -61,6 +61,7 @@ def parse_arguments() -> argparse.Namespace:
for idx, arg in enumerate(sys.argv[:-1]):
if arg in ("--language", "-l") and idx + 1 < len(sys.argv):
from strix.i18n import set_language
set_language(sys.argv[idx + 1])
break
@ -134,6 +135,8 @@ Strix Cloud:
help="Update strix to the latest version and exit. Self-updates the "
"standalone binary install; for pip/pipx/uv installs, prints the "
"matching upgrade command instead.",
)
parser.add_argument(
"--language",
"-l",
@ -336,6 +339,11 @@ Strix Cloud:
args.diff_scope = {"active": False}
args.run_name = None
if args.language:
from strix.i18n import set_language
set_language(args.language)
if args.config:
apply_config_override(validate_config_file(args.config))

View file

@ -3,9 +3,9 @@
"cli.scan_started": "Starting scan on target: {target}",
"cli.scan_completed": "Scan completed for target: {target}",
"cli.vulnerabilities_found": "Found {count} vulnerability(ies)",
"report.title": "Security Audit Executive Report",
"report.title": "Security Penetration Test Report",
"report.description": "Description",
"report.evidence": "Evidence",
"report.impact": "Impact",
"report.remediation": "Remediation Steps"
"report.remediation": "Remediation"
}

View file

@ -3,9 +3,9 @@
"cli.scan_started": "Iniciando escaneo en el objetivo: {target}",
"cli.scan_completed": "Escaneo completado para el objetivo: {target}",
"cli.vulnerabilities_found": "Se encontraron {count} vulnerabilidad(es)",
"report.title": "Informe Ejecutivo de Auditoría de Seguridad",
"report.title": "Informe de Prueba de Penetración de Seguridad",
"report.description": "Descripción",
"report.evidence": "Evidencia",
"report.impact": "Impacto",
"report.remediation": "Pasos de Remediación"
"report.remediation": "Remediación"
}

View file

@ -3,7 +3,7 @@
"cli.scan_started": "Memulai pemindaian pada target: {target}",
"cli.scan_completed": "Pemindaian selesai untuk target: {target}",
"cli.vulnerabilities_found": "Ditemukan {count} kerentanan",
"report.title": "Laporan Eksekutif Audit Keamanan",
"report.title": "Laporan Uji Penetrasi Keamanan",
"report.description": "Deskripsi",
"report.evidence": "Bukti",
"report.impact": "Dampak",

11
strix/locales/ko.json Normal file
View file

@ -0,0 +1,11 @@
{
"cli.description": "Strix - 자율형 AI 보안 모의해킹 에이전트",
"cli.scan_started": "대상에 대한 스캔 시작: {target}",
"cli.scan_completed": "대상에 대한 스캔 완료: {target}",
"cli.vulnerabilities_found": "{count}개의 취약점이 발견되었습니다",
"report.title": "보안 침투 테스트 보고서",
"report.description": "설명",
"report.evidence": "증거",
"report.impact": "영향도",
"report.remediation": "조치 방안"
}

View file

@ -17,6 +17,7 @@ from pygments.lexers.special import TextLexer
from pygments.util import ClassNotFound
from strix.core.paths import run_record_path
from strix.i18n import t
if TYPE_CHECKING:
@ -139,7 +140,7 @@ def write_run_record(run_dir: Path, run_record: dict[str, Any]) -> None:
def write_executive_report(run_dir: Path, final_scan_result: str) -> None:
path = run_dir / "penetration_test_report.md"
with path.open("w", encoding="utf-8") as f:
f.write("# Security Penetration Test Report\n\n")
f.write(f"# {t('report.title')}\n\n")
f.write(f"**Generated:** {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}\n\n")
f.write(f"{final_scan_result}\n")
logger.info("Saved final penetration test report to: %s", path)
@ -259,17 +260,17 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
lines.append(f"**{label}:** {value}")
lines.append("")
lines.append("## Description\n")
lines.append(f"## {t('report.description')}\n")
lines.append(report.get("description") or "No description provided.")
lines.append("")
if report.get("evidence"):
lines.append("## Evidence\n")
lines.append(f"## {t('report.evidence')}\n")
lines.append(str(report["evidence"]))
lines.append("")
if report.get("impact"):
lines.append("## Impact\n")
lines.append(f"## {t('report.impact')}\n")
lines.append(str(report["impact"]))
lines.append("")
@ -342,7 +343,7 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
lines.append("")
if report.get("remediation_steps"):
lines.append("## Remediation\n")
lines.append(f"## {t('report.remediation')}\n")
lines.append(str(report["remediation_steps"]))
lines.append("")

View file

@ -1,67 +1,298 @@
"""Comprehensive tests for the Strix i18n engine."""
"""Comprehensive test suite for Strix internationalization (i18n) engine."""
from __future__ import annotations
import asyncio
import os
import json
from unittest.mock import patch
import pytest
import strix.i18n as i18n
from strix.i18n import get_language, get_language_directive, set_language, t
from strix.agents.prompt import render_system_prompt
from strix.i18n import (
DEFAULT_LANGUAGE,
clear_locales_cache,
get_available_languages,
get_language,
get_language_directive,
normalize_language,
reset_language,
set_language,
t,
)
from strix.i18n.catalog import _safe_merge, load_locale
from strix.i18n.detector import detect_language
from strix.interface.cli_args import parse_arguments
from strix.report.writer import render_vulnerability_md, write_executive_report
@pytest.fixture(autouse=True)
def _reset_i18n():
i18n._current_language.set(None)
with i18n._locales_lock:
i18n._locales_cache.clear()
def _reset_i18n_state():
"""Ensure clean i18n context and cache for every test."""
reset_language()
clear_locales_cache()
yield
i18n._current_language.set(None)
reset_language()
clear_locales_cache()
# ---------------------------------------------------------------------------
# 1. Core State & Normalization Tests
# ---------------------------------------------------------------------------
def test_set_and_get_language():
set_language("es")
assert set_language("es") == "es"
assert get_language() == "es"
set_language("id")
assert set_language("id") == "id"
assert get_language() == "id"
assert set_language("ko") == "ko"
assert get_language() == "ko"
def test_fallback_unsupported_language():
set_language("fr")
assert get_language() == "en"
reset_language()
assert get_language() == DEFAULT_LANGUAGE
def test_translation_formatting():
def test_normalize_language_tags():
assert normalize_language("es_ES.UTF-8") == "es"
assert normalize_language("id-ID") == "id"
assert normalize_language("ko_KR.eucKR") == "ko"
assert normalize_language("EN_us") == "en"
assert normalize_language(None) == DEFAULT_LANGUAGE
assert normalize_language("unsupported_xyz") == DEFAULT_LANGUAGE
def test_dynamic_available_languages():
available = get_available_languages()
assert "en" in available
assert "es" in available
assert "id" in available
assert "ko" in available
# ---------------------------------------------------------------------------
# 2. Async Context Isolation Tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_async_contextvar_isolation():
"""Verify concurrent async tasks maintain strictly isolated language contexts."""
async def task_worker(lang: str, delay: float) -> str:
set_language(lang)
await asyncio.sleep(delay)
return get_language()
results = await asyncio.gather(
task_worker("es", 0.02),
task_worker("id", 0.01),
task_worker("ko", 0.015),
)
assert results == ["es", "id", "ko"]
# Outer context remains untouched
assert get_language() == DEFAULT_LANGUAGE
# ---------------------------------------------------------------------------
# 3. Precedence & Detection Tests
# ---------------------------------------------------------------------------
def test_precedence_context_over_env_and_system(monkeypatch):
monkeypatch.setenv("STRIX_LANGUAGE", "es")
monkeypatch.setenv("LC_ALL", "id")
# Context explicitly set wins over all
set_language("ko")
assert detect_language() == "ko"
# When context is reset, STRIX_LANGUAGE environment variable wins
reset_language()
assert detect_language() == "es"
def test_precedence_env_over_config_and_system(monkeypatch, tmp_path):
monkeypatch.setenv("STRIX_LANGUAGE", "id")
fake_config = tmp_path / ".strix" / "cli-config.json"
fake_config.parent.mkdir(parents=True)
fake_config.write_text(json.dumps({"env": {"STRIX_LANGUAGE": "es"}}), encoding="utf-8")
with patch("pathlib.Path.home", return_value=tmp_path):
assert detect_language() == "id"
def test_precedence_config_over_system(monkeypatch, tmp_path):
monkeypatch.delenv("STRIX_LANGUAGE", raising=False)
monkeypatch.setenv("LC_ALL", "es")
fake_config = tmp_path / ".strix" / "cli-config.json"
fake_config.parent.mkdir(parents=True)
fake_config.write_text(json.dumps({"env": {"STRIX_LANGUAGE": "id"}}), encoding="utf-8")
with patch("pathlib.Path.home", return_value=tmp_path):
assert detect_language() == "id"
def test_precedence_lc_all_over_lang(monkeypatch):
monkeypatch.delenv("STRIX_LANGUAGE", raising=False)
monkeypatch.setenv("LC_ALL", "es_ES.UTF-8")
monkeypatch.setenv("LANG", "id_ID.UTF-8")
assert detect_language() == "es"
def test_fallback_to_default_when_empty(monkeypatch):
monkeypatch.delenv("STRIX_LANGUAGE", raising=False)
monkeypatch.delenv("LC_ALL", raising=False)
monkeypatch.delenv("LANG", raising=False)
assert detect_language() == DEFAULT_LANGUAGE
# ---------------------------------------------------------------------------
# 4. Catalog Loading, Caching & Override Tests
# ---------------------------------------------------------------------------
def test_catalog_load_builtin_and_caching():
catalog_en = load_locale("en")
assert "report.title" in catalog_en
assert catalog_en["report.title"] == "Security Penetration Test Report"
# Subsequent load hits cache
cached = load_locale("en")
assert cached is catalog_en
def test_safe_merge_validates_string_types():
target = {"existing": "value"}
_safe_merge(target, {"valid_key": "valid_value", 123: "bad_key", "bad_val": 456})
assert target["valid_key"] == "valid_value"
assert 123 not in target
assert "bad_val" not in target
def test_user_community_override_merging(tmp_path):
user_locales = tmp_path / ".strix" / "locales"
user_locales.mkdir(parents=True)
override_file = user_locales / "es.json"
override_file.write_text(
json.dumps({"custom.greeting": "Hola Mundo", "cli.description": "Custom Strix"}),
encoding="utf-8",
)
with patch("pathlib.Path.home", return_value=tmp_path):
catalog_es = load_locale("es")
assert catalog_es.get("custom.greeting") == "Hola Mundo"
assert catalog_es.get("cli.description") == "Custom Strix"
assert catalog_es.get("report.title") == "Informe de Prueba de Penetración de Seguridad"
# ---------------------------------------------------------------------------
# 5. Translation & Fallback Tests
# ---------------------------------------------------------------------------
def test_translation_formatting_interpolations():
set_language("id")
result = t("cli.scan_started", target="example.com")
assert "example.com" in result
assert "Memulai" in result
def test_safe_interpolation_on_missing_key():
set_language("en")
result = t("cli.scan_started") # target missing
assert "target" in result or isinstance(result, str)
@pytest.mark.asyncio
async def test_async_contextvar_isolation():
"""Verify concurrent async tasks maintain isolated language contexts."""
async def task_es():
set_language("es")
await asyncio.sleep(0.01)
assert get_language() == "es"
async def task_id():
set_language("id")
await asyncio.sleep(0.01)
assert get_language() == "id"
await asyncio.gather(task_es(), task_id())
def test_language_directive():
def test_fallback_missing_key_in_target_language():
set_language("es")
directive = get_language_directive()
assert "Spanish" in directive
assert "CVE" in directive
# Non-existent key in all catalogs falls back to key itself
assert t("untranslated.nonexistent_key") == "untranslated.nonexistent_key"
def test_safe_interpolation_on_missing_parameter():
set_language("en")
result = t("cli.scan_started") # 'target' parameter missing
assert "{target}" in result or isinstance(result, str)
def test_safe_interpolation_with_special_characters():
set_language("en")
payload = "'; DROP TABLE users; -- {bad}"
result = t("cli.scan_started", target=payload)
assert payload in result
# ---------------------------------------------------------------------------
# 6. Prompt Language Directive Tests
# ---------------------------------------------------------------------------
def test_language_directive_empty_for_english():
set_language("en")
assert get_language_directive() == ""
def test_language_directive_for_supported_languages():
set_language("es")
directive_es = get_language_directive()
assert "Spanish" in directive_es
assert "CVE IDs" in directive_es
assert "Do NOT translate technical identifiers" in directive_es
set_language("id")
directive_id = get_language_directive()
assert "Indonesian" in directive_id
set_language("ko")
directive_ko = get_language_directive()
assert "Korean" in directive_ko
# ---------------------------------------------------------------------------
# 7. End-to-End System & Integration Tests
# ---------------------------------------------------------------------------
def test_system_prompt_rendering_injects_language_directive():
set_language("es")
prompt_es = render_system_prompt(scan_mode="quick")
assert "<language_directive>" in prompt_es
assert "Spanish" in prompt_es
set_language("en")
prompt_en = render_system_prompt(scan_mode="quick")
assert "<language_directive>" not in prompt_en
def test_markdown_report_writer_localization(tmp_path):
set_language("es")
sample_report = {
"id": "vuln-001",
"title": "SQL Injection",
"severity": "critical",
"timestamp": "2026-10-10 12:00:00 UTC",
"description": "Exploitable SQL query.",
"evidence": "Payload proof.",
"impact": "Full database read.",
"remediation_steps": "Use parameters.",
}
md = render_vulnerability_md(sample_report)
assert "## Descripción" in md
assert "## Evidencia" in md
assert "## Impacto" in md
assert "## Remediación" in md
# Executive report title
write_executive_report(tmp_path, "Scan findings summary.")
exec_file = tmp_path / "penetration_test_report.md"
content = exec_file.read_text(encoding="utf-8")
assert "# Informe de Prueba de Penetración de Seguridad" in content
def test_cli_argument_parser_language_flag(monkeypatch):
monkeypatch.setattr("sys.argv", ["strix", "-t", "example.com", "--language", "id", "-n"])
args = parse_arguments()
assert args.language == "id"
assert get_language() == "id"