kctl-react 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. kctl_react/__init__.py +3 -0
  2. kctl_react/__main__.py +5 -0
  3. kctl_react/cli.py +201 -0
  4. kctl_react/commands/__init__.py +0 -0
  5. kctl_react/commands/a11y.py +78 -0
  6. kctl_react/commands/affected.py +170 -0
  7. kctl_react/commands/apps.py +353 -0
  8. kctl_react/commands/build.py +376 -0
  9. kctl_react/commands/bundle_cmd.py +217 -0
  10. kctl_react/commands/cap.py +1465 -0
  11. kctl_react/commands/clean.py +76 -0
  12. kctl_react/commands/codegen.py +491 -0
  13. kctl_react/commands/compliance.py +587 -0
  14. kctl_react/commands/config_cmd.py +368 -0
  15. kctl_react/commands/dashboard.py +163 -0
  16. kctl_react/commands/deploy.py +318 -0
  17. kctl_react/commands/deps.py +792 -0
  18. kctl_react/commands/dev.py +96 -0
  19. kctl_react/commands/docker_cmd.py +73 -0
  20. kctl_react/commands/doctor.py +170 -0
  21. kctl_react/commands/e2e.py +343 -0
  22. kctl_react/commands/env.py +155 -0
  23. kctl_react/commands/i18n.py +310 -0
  24. kctl_react/commands/lint.py +306 -0
  25. kctl_react/commands/maintenance.py +308 -0
  26. kctl_react/commands/monitor_cmd.py +50 -0
  27. kctl_react/commands/observe.py +34 -0
  28. kctl_react/commands/packages.py +129 -0
  29. kctl_react/commands/perf.py +762 -0
  30. kctl_react/commands/pipeline.py +289 -0
  31. kctl_react/commands/pwa.py +193 -0
  32. kctl_react/commands/scaffold.py +323 -0
  33. kctl_react/commands/security.py +660 -0
  34. kctl_react/commands/skill_cmd.py +54 -0
  35. kctl_react/commands/state.py +254 -0
  36. kctl_react/commands/test_cmd.py +418 -0
  37. kctl_react/commands/ui_audit.py +889 -0
  38. kctl_react/core/__init__.py +0 -0
  39. kctl_react/core/analyzers.py +200 -0
  40. kctl_react/core/callbacks.py +70 -0
  41. kctl_react/core/compliance/__init__.py +3 -0
  42. kctl_react/core/compliance/api_check/__init__.py +3 -0
  43. kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
  44. kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
  45. kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
  46. kctl_react/core/compliance/api_check/checks/naming.py +60 -0
  47. kctl_react/core/compliance/api_check/checks/params.py +44 -0
  48. kctl_react/core/compliance/api_check/checks/requests.py +57 -0
  49. kctl_react/core/compliance/api_check/checks/types.py +55 -0
  50. kctl_react/core/compliance/api_check/hooks.py +133 -0
  51. kctl_react/core/compliance/api_check/matcher.py +55 -0
  52. kctl_react/core/compliance/api_check/schema.py +151 -0
  53. kctl_react/core/compliance/api_health/__init__.py +35 -0
  54. kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
  55. kctl_react/core/compliance/api_health/checks/auth.py +72 -0
  56. kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
  57. kctl_react/core/compliance/api_health/checks/response.py +55 -0
  58. kctl_react/core/compliance/api_health/checks/timing.py +38 -0
  59. kctl_react/core/compliance/api_health/client.py +99 -0
  60. kctl_react/core/compliance/api_health/sampler.py +16 -0
  61. kctl_react/core/compliance/checks/__init__.py +47 -0
  62. kctl_react/core/compliance/checks/api.py +101 -0
  63. kctl_react/core/compliance/checks/codegen.py +94 -0
  64. kctl_react/core/compliance/checks/darkmode.py +57 -0
  65. kctl_react/core/compliance/checks/errors.py +68 -0
  66. kctl_react/core/compliance/checks/features.py +66 -0
  67. kctl_react/core/compliance/checks/i18n_check.py +105 -0
  68. kctl_react/core/compliance/checks/imports.py +86 -0
  69. kctl_react/core/compliance/checks/navigation.py +62 -0
  70. kctl_react/core/compliance/checks/practices.py +122 -0
  71. kctl_react/core/compliance/checks/providers.py +85 -0
  72. kctl_react/core/compliance/checks/pwa.py +101 -0
  73. kctl_react/core/compliance/checks/responsive.py +47 -0
  74. kctl_react/core/compliance/checks/scripts.py +85 -0
  75. kctl_react/core/compliance/checks/shadcn.py +51 -0
  76. kctl_react/core/compliance/checks/structure.py +76 -0
  77. kctl_react/core/compliance/checks/testing.py +83 -0
  78. kctl_react/core/compliance/checks/theme.py +92 -0
  79. kctl_react/core/compliance/checks/ui_standard.py +185 -0
  80. kctl_react/core/compliance/checks/vite.py +83 -0
  81. kctl_react/core/compliance/engine.py +87 -0
  82. kctl_react/core/compliance/exceptions_map.py +15 -0
  83. kctl_react/core/compliance/fixes/__init__.py +33 -0
  84. kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
  85. kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
  86. kctl_react/core/compliance/fixes/imports_fix.py +36 -0
  87. kctl_react/core/compliance/fixes/structure_fix.py +20 -0
  88. kctl_react/core/compliance/fixes/theme_fix.py +29 -0
  89. kctl_react/core/compliance/models.py +106 -0
  90. kctl_react/core/config.py +201 -0
  91. kctl_react/core/discovery.py +185 -0
  92. kctl_react/core/exceptions.py +17 -0
  93. kctl_react/core/git.py +146 -0
  94. kctl_react/core/history.py +121 -0
  95. kctl_react/core/output.py +5 -0
  96. kctl_react/core/plugins.py +13 -0
  97. kctl_react/core/runner.py +34 -0
  98. kctl_react/py.typed +0 -0
  99. kctl_react-0.6.2.dist-info/METADATA +17 -0
  100. kctl_react-0.6.2.dist-info/RECORD +102 -0
  101. kctl_react-0.6.2.dist-info/WHEEL +4 -0
  102. kctl_react-0.6.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,99 @@
1
+ """HTTP client for API health checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ import httpx
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dataclass
17
+ class HealthClient:
18
+ base_url: str
19
+ token: str | None = None
20
+ timeout: float = 3.0
21
+ rate_limit: float = 0.2 # seconds between requests
22
+ _last_request: float = field(default=0.0, repr=False)
23
+
24
+ def authenticate(self, app_path: Path, app_name: str) -> bool:
25
+ """Try dev-login. Read VITE_DEV_USER/VITE_DEV_PASSWORD from .env if available.
26
+
27
+ Fallback: try POST /auth/dev-login with admin/admin.
28
+ Store token if successful. Return True/False.
29
+ """
30
+ username = "admin"
31
+ password = "admin"
32
+
33
+ env_file = app_path / ".env"
34
+ if env_file.exists():
35
+ try:
36
+ content = env_file.read_text()
37
+ user_match = re.search(r"VITE_DEV_USER\s*=\s*(.+)", content)
38
+ if user_match:
39
+ username = user_match.group(1).strip().strip("\"'")
40
+ pass_match = re.search(r"VITE_DEV_PASSWORD\s*=\s*(.+)", content)
41
+ if pass_match:
42
+ password = pass_match.group(1).strip().strip("\"'")
43
+ except OSError as exc:
44
+ logger.warning("Failed to read .env: %s", exc)
45
+
46
+ try:
47
+ resp = httpx.post(
48
+ f"{self.base_url}/auth/dev-login",
49
+ json={"username": username, "password": password},
50
+ timeout=self.timeout,
51
+ )
52
+ if resp.status_code == 200:
53
+ data = resp.json()
54
+ if data.get("success") and data.get("data", {}).get("access_token"):
55
+ self.token = data["data"]["access_token"]
56
+ return True
57
+ except (httpx.HTTPError, ValueError) as exc:
58
+ logger.warning("Dev-login failed: %s", exc)
59
+
60
+ return False
61
+
62
+ def get(self, path: str) -> tuple[int, dict | None, float]:
63
+ """GET endpoint with auth header and rate limiting.
64
+
65
+ Returns (status_code, response_body_or_None, elapsed_seconds).
66
+ Handles httpx errors gracefully (return 0, None, 0.0 for connection errors).
67
+ """
68
+ self._rate_limit()
69
+ try:
70
+ start = time.monotonic()
71
+ resp = httpx.get(
72
+ f"{self.base_url}{path}",
73
+ headers=self._headers(),
74
+ timeout=self.timeout,
75
+ )
76
+ elapsed = time.monotonic() - start
77
+ self._last_request = time.monotonic()
78
+ try:
79
+ body = resp.json()
80
+ except ValueError:
81
+ body = None
82
+ return resp.status_code, body, elapsed
83
+ except httpx.HTTPError as exc:
84
+ logger.warning("GET %s failed: %s", path, exc)
85
+ return 0, None, 0.0
86
+
87
+ def _headers(self) -> dict[str, str]:
88
+ """Return headers with Authorization: Bearer {token} if token set."""
89
+ headers: dict[str, str] = {}
90
+ if self.token:
91
+ headers["Authorization"] = f"Bearer {self.token}"
92
+ return headers
93
+
94
+ def _rate_limit(self) -> None:
95
+ """Sleep if needed to respect rate_limit interval."""
96
+ if self._last_request > 0:
97
+ elapsed = time.monotonic() - self._last_request
98
+ if elapsed < self.rate_limit:
99
+ time.sleep(self.rate_limit - elapsed)
@@ -0,0 +1,16 @@
1
+ """Select endpoints to test."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from kctl_react.core.compliance.api_check.schema import Operation, ParsedSchema
6
+
7
+
8
+ def sample_endpoints(schema: ParsedSchema, max_count: int = 20) -> list[Operation]:
9
+ """Select GET endpoints without path parameters (list endpoints).
10
+
11
+ These are safe to call without knowing valid IDs.
12
+ Cap at max_count. Sort by path for deterministic order.
13
+ """
14
+ candidates = [op for op in schema.operations if op.method == "get" and "{" not in op.path]
15
+ candidates.sort(key=lambda op: op.path)
16
+ return candidates[:max_count]
@@ -0,0 +1,47 @@
1
+ """Compliance checkers registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .api import ApiChecker
6
+ from .codegen import CodegenChecker
7
+ from .darkmode import DarkmodeChecker
8
+ from .errors import ErrorsChecker
9
+ from .features import FeaturesChecker
10
+ from .i18n_check import I18nChecker
11
+ from .imports import ImportsChecker
12
+ from .navigation import NavigationChecker
13
+ from .practices import PracticesChecker
14
+ from .providers import ProviderChecker
15
+ from .pwa import PwaChecker
16
+ from .responsive import ResponsiveChecker
17
+ from .scripts import ScriptsChecker
18
+ from .shadcn import ShadcnChecker
19
+ from .structure import StructureChecker
20
+ from .testing import TestingChecker
21
+ from .theme import ThemeChecker
22
+ from .ui_standard import UiStandardChecker
23
+ from .vite import ViteChecker
24
+
25
+ ALL_CHECKERS = [
26
+ StructureChecker(),
27
+ ProviderChecker(),
28
+ ShadcnChecker(),
29
+ ImportsChecker(),
30
+ CodegenChecker(),
31
+ I18nChecker(),
32
+ FeaturesChecker(),
33
+ ThemeChecker(),
34
+ ResponsiveChecker(),
35
+ DarkmodeChecker(),
36
+ NavigationChecker(),
37
+ ErrorsChecker(),
38
+ ApiChecker(),
39
+ PwaChecker(),
40
+ ViteChecker(),
41
+ ScriptsChecker(),
42
+ TestingChecker(),
43
+ PracticesChecker(),
44
+ UiStandardChecker(),
45
+ ]
46
+
47
+ CHECKER_MAP = {c.name: c.check for c in ALL_CHECKERS}
@@ -0,0 +1,101 @@
1
+ """Checker: API client patterns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from kctl_react.core.compliance.models import CategoryResult, Violation
9
+
10
+
11
+ class ApiChecker:
12
+ name = "api"
13
+ label = "API Client"
14
+ max_points = 6
15
+
16
+ def check(self, app_path: Path, app_name: str) -> CategoryResult:
17
+ violations: list[Violation] = []
18
+ src = app_path / "src"
19
+
20
+ # client.ts must import from @kodemeio/core
21
+ client_ts = src / "api" / "client.ts"
22
+ if client_ts.is_file():
23
+ content = client_ts.read_text()
24
+ if "@kodemeio/core" not in content:
25
+ violations.append(
26
+ Violation(
27
+ file="src/api/client.ts",
28
+ message="client.ts does not import from @kodemeio/core",
29
+ fix_hint="Use ApiClient from @kodemeio/core",
30
+ )
31
+ )
32
+ else:
33
+ violations.append(
34
+ Violation(
35
+ file="src/api/client.ts",
36
+ message="Missing api/client.ts",
37
+ )
38
+ )
39
+
40
+ # No raw fetch() or axios in pages/components
41
+ for subdir in ("pages", "components"):
42
+ scan_dir = src / subdir
43
+ if not scan_dir.is_dir():
44
+ continue
45
+ for tsx_file in scan_dir.rglob("*.tsx"):
46
+ if "__tests__" in tsx_file.parts:
47
+ continue
48
+ try:
49
+ lines = tsx_file.read_text().splitlines()
50
+ except OSError:
51
+ continue
52
+ rel = str(tsx_file.relative_to(app_path))
53
+ for i, line in enumerate(lines, 1):
54
+ if re.search(r"\bfetch\s*\(", line) and "import" not in line:
55
+ violations.append(
56
+ Violation(
57
+ file=rel,
58
+ line=i,
59
+ message="Raw fetch() call — use ApiClient via hooks",
60
+ fix_hint="Move API call to a hook using apiClient",
61
+ )
62
+ )
63
+ if re.search(r"\baxios\b", line):
64
+ violations.append(
65
+ Violation(
66
+ file=rel,
67
+ line=i,
68
+ message="axios usage — use ApiClient from @kodemeio/core",
69
+ fix_hint="Replace axios with apiClient",
70
+ )
71
+ )
72
+
73
+ # Hook files should use useQuery/useMutation
74
+ # Skip utility hooks that wrap non-fetch functionality
75
+ _SKIP_HOOKS = {"useExport", "useBarcode", "useSettings", "useWebSocket", "useEventSource"}
76
+ api_dir = src / "api"
77
+ if api_dir.is_dir():
78
+ for hook_file in api_dir.glob("use*.ts"):
79
+ hook_name = hook_file.stem
80
+ if hook_name in _SKIP_HOOKS:
81
+ continue
82
+ try:
83
+ content = hook_file.read_text()
84
+ except OSError:
85
+ continue
86
+ rel = str(hook_file.relative_to(app_path))
87
+ if "useQuery" not in content and "useMutation" not in content and "useInfiniteQuery" not in content:
88
+ violations.append(
89
+ Violation(
90
+ file=rel,
91
+ message="Hook file does not use useQuery/useMutation",
92
+ fix_hint="Use TanStack Query hooks",
93
+ )
94
+ )
95
+
96
+ return CategoryResult(
97
+ name=self.name,
98
+ label=self.label,
99
+ max_points=self.max_points,
100
+ violations=violations,
101
+ )
@@ -0,0 +1,94 @@
1
+ """Checker: OpenAPI codegen setup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from kctl_react.core.compliance.models import CategoryResult, Violation
10
+
11
+
12
+ class CodegenChecker:
13
+ name = "codegen"
14
+ label = "OpenAPI Codegen"
15
+ max_points = 6
16
+
17
+ def check(self, app_path: Path, app_name: str) -> CategoryResult:
18
+ violations: list[Violation] = []
19
+
20
+ # openapi-ts.config.ts must exist
21
+ if not (app_path / "openapi-ts.config.ts").is_file():
22
+ violations.append(
23
+ Violation(
24
+ file="openapi-ts.config.ts",
25
+ message="Missing openapi-ts.config.ts",
26
+ fix_hint="Create OpenAPI codegen config",
27
+ auto_fixable=True,
28
+ )
29
+ )
30
+
31
+ # types/api.ts must reference @/generated/types.gen
32
+ api_ts = app_path / "src" / "types" / "api.ts"
33
+ if api_ts.is_file():
34
+ content = api_ts.read_text()
35
+ if "@/generated/types.gen" not in content:
36
+ violations.append(
37
+ Violation(
38
+ file="src/types/api.ts",
39
+ message="types/api.ts does not re-export from @/generated/types.gen",
40
+ fix_hint="Add export from @/generated/types.gen",
41
+ )
42
+ )
43
+ else:
44
+ violations.append(
45
+ Violation(
46
+ file="src/types/api.ts",
47
+ message="Missing src/types/api.ts",
48
+ )
49
+ )
50
+
51
+ # Check for manual API type files in types/
52
+ types_dir = app_path / "src" / "types"
53
+ if types_dir.is_dir():
54
+ manual_pattern = re.compile(r"export\s+(interface|type)\s+\w+(Response|Request|Item|Data)\b")
55
+ for ts_file in types_dir.glob("*.ts"):
56
+ if ts_file.name == "api.ts":
57
+ continue
58
+ try:
59
+ text = ts_file.read_text()
60
+ except OSError:
61
+ continue
62
+ if manual_pattern.search(text):
63
+ violations.append(
64
+ Violation(
65
+ file=f"src/types/{ts_file.name}",
66
+ message=f"Manual API type definitions in {ts_file.name} — use OpenAPI codegen",
67
+ fix_hint="Move types to OpenAPI schema and regenerate",
68
+ )
69
+ )
70
+
71
+ # package.json scripts
72
+ pkg = app_path / "package.json"
73
+ if pkg.is_file():
74
+ try:
75
+ data = json.loads(pkg.read_text())
76
+ except (json.JSONDecodeError, OSError):
77
+ data = {}
78
+ scripts = data.get("scripts", {})
79
+ for script_name in ("fetch:schema", "generate:api"):
80
+ if script_name not in scripts:
81
+ violations.append(
82
+ Violation(
83
+ file="package.json",
84
+ message=f'Missing script: "{script_name}"',
85
+ fix_hint=f"Add {script_name} script to package.json",
86
+ )
87
+ )
88
+
89
+ return CategoryResult(
90
+ name=self.name,
91
+ label=self.label,
92
+ max_points=self.max_points,
93
+ violations=violations,
94
+ )
@@ -0,0 +1,57 @@
1
+ """Checker: dark mode support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from kctl_react.core.compliance.models import CategoryResult, Violation
9
+
10
+ HARDCODED_COLORS = re.compile(r"\b(?:bg-white|text-black|border-gray-\w+|bg-gray-\w+|text-gray-\w+)\b")
11
+
12
+
13
+ class DarkmodeChecker:
14
+ name = "darkmode"
15
+ label = "Dark Mode"
16
+ max_points = 6
17
+
18
+ def check(self, app_path: Path, app_name: str) -> CategoryResult:
19
+ violations: list[Violation] = []
20
+ src = app_path / "src"
21
+
22
+ # ThemeToggle check is in theme checker — skip here to avoid duplicates
23
+
24
+ # Scan pages/ and components/ for hardcoded colors
25
+ for subdir in ("pages", "components"):
26
+ scan_dir = src / subdir
27
+ if not scan_dir.is_dir():
28
+ continue
29
+ for tsx_file in scan_dir.rglob("*.tsx"):
30
+ if "__tests__" in tsx_file.parts:
31
+ continue
32
+ try:
33
+ lines = tsx_file.read_text().splitlines()
34
+ except OSError:
35
+ continue
36
+ rel = str(tsx_file.relative_to(app_path))
37
+ for i, line in enumerate(lines, 1):
38
+ # Skip lines that have dark: variants — they handle dark mode
39
+ if "dark:" in line:
40
+ continue
41
+ matches = HARDCODED_COLORS.findall(line)
42
+ for m in matches:
43
+ violations.append(
44
+ Violation(
45
+ file=rel,
46
+ line=i,
47
+ message=f"Hardcoded color class: {m} — breaks dark mode",
48
+ fix_hint=f"Replace {m} with semantic token (e.g. bg-background, text-foreground)",
49
+ )
50
+ )
51
+
52
+ return CategoryResult(
53
+ name=self.name,
54
+ label=self.label,
55
+ max_points=self.max_points,
56
+ violations=violations,
57
+ )
@@ -0,0 +1,68 @@
1
+ """Checker: error handling patterns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from kctl_react.core.compliance.models import CategoryResult, Violation
9
+
10
+ MAIN_TSX_CHECKS = ["ErrorBoundary", "Sentry.init", "getErrorMessage"]
11
+
12
+ BARE_CATCH_PATTERN = re.compile(r"catch\s*\([^)]*\)\s*\{[^}]*(?:console\.log|console\.error)\b")
13
+
14
+
15
+ class ErrorsChecker:
16
+ name = "errors"
17
+ label = "Error Handling"
18
+ max_points = 6
19
+
20
+ def check(self, app_path: Path, app_name: str) -> CategoryResult:
21
+ violations: list[Violation] = []
22
+ src = app_path / "src"
23
+
24
+ # main.tsx checks
25
+ main_tsx = src / "main.tsx"
26
+ if main_tsx.is_file():
27
+ content = main_tsx.read_text()
28
+ for check in MAIN_TSX_CHECKS:
29
+ if check not in content:
30
+ violations.append(
31
+ Violation(
32
+ file="src/main.tsx",
33
+ message=f"Missing {check} in main.tsx",
34
+ fix_hint=f"Add {check} for proper error handling",
35
+ )
36
+ )
37
+ else:
38
+ violations.append(Violation(file="src/main.tsx", message="main.tsx not found"))
39
+
40
+ # Scan for bare catch + console.log/error patterns
41
+ for ext in ("*.ts", "*.tsx"):
42
+ for f in src.rglob(ext):
43
+ if "__tests__" in f.parts:
44
+ continue
45
+ try:
46
+ text = f.read_text()
47
+ except OSError:
48
+ continue
49
+
50
+ rel = str(f.relative_to(app_path))
51
+ for match in BARE_CATCH_PATTERN.finditer(text):
52
+ # Find the line number
53
+ line_num = text[: match.start()].count("\n") + 1
54
+ violations.append(
55
+ Violation(
56
+ file=rel,
57
+ line=line_num,
58
+ message="Bare catch with console.log/error — use getErrorMessage + toast",
59
+ fix_hint="Use getErrorMessage(err) and toast.error() or Sentry.captureException()",
60
+ )
61
+ )
62
+
63
+ return CategoryResult(
64
+ name=self.name,
65
+ label=self.label,
66
+ max_points=self.max_points,
67
+ violations=violations,
68
+ )
@@ -0,0 +1,66 @@
1
+ """Checker: cross-app feature completeness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from kctl_react.core.compliance.models import CategoryResult, Violation
8
+
9
+ # Only features NOT already checked by other checkers:
10
+ # - ErrorBoundary, Sentry, getErrorMessage → errors checker
11
+ # - OfflineProvider, AiProvider, TooltipProvider → providers checker
12
+ # - PwaUpdatePrompt → pwa checker
13
+ MAIN_TSX_FEATURES = [
14
+ "Toaster",
15
+ ]
16
+
17
+ APP_TSX_FEATURES = [
18
+ "DeepLinkHandler",
19
+ "OfflineIndicator",
20
+ ]
21
+
22
+
23
+ class FeaturesChecker:
24
+ name = "features"
25
+ label = "Cross-App Features"
26
+ max_points = 6
27
+
28
+ def check(self, app_path: Path, app_name: str) -> CategoryResult:
29
+ violations: list[Violation] = []
30
+
31
+ main_tsx = app_path / "src" / "main.tsx"
32
+ if main_tsx.is_file():
33
+ content = main_tsx.read_text()
34
+ for feat in MAIN_TSX_FEATURES:
35
+ if feat not in content:
36
+ violations.append(
37
+ Violation(
38
+ file="src/main.tsx",
39
+ message=f"Missing feature: {feat}",
40
+ fix_hint=f"Add {feat} to main.tsx",
41
+ )
42
+ )
43
+ else:
44
+ violations.append(Violation(file="src/main.tsx", message="main.tsx not found"))
45
+
46
+ app_tsx = app_path / "src" / "App.tsx"
47
+ if app_tsx.is_file():
48
+ content = app_tsx.read_text()
49
+ for feat in APP_TSX_FEATURES:
50
+ if feat not in content:
51
+ violations.append(
52
+ Violation(
53
+ file="src/App.tsx",
54
+ message=f"Missing feature: {feat}",
55
+ fix_hint=f"Add {feat} to App.tsx",
56
+ )
57
+ )
58
+ else:
59
+ violations.append(Violation(file="src/App.tsx", message="App.tsx not found"))
60
+
61
+ return CategoryResult(
62
+ name=self.name,
63
+ label=self.label,
64
+ max_points=self.max_points,
65
+ violations=violations,
66
+ )
@@ -0,0 +1,105 @@
1
+ """Checker: i18n translation coverage and setup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from kctl_react.core.compliance.models import CategoryResult, Violation
9
+
10
+
11
+ def _flatten_keys(d: dict, prefix: str = "") -> set[str]:
12
+ """Flatten nested dict keys with dot notation."""
13
+ keys: set[str] = set()
14
+ for k, v in d.items():
15
+ full = f"{prefix}.{k}" if prefix else k
16
+ if isinstance(v, dict):
17
+ keys |= _flatten_keys(v, full)
18
+ else:
19
+ keys.add(full)
20
+ return keys
21
+
22
+
23
+ class I18nChecker:
24
+ name = "i18n"
25
+ label = "Internationalization"
26
+ max_points = 6
27
+
28
+ def check(self, app_path: Path, app_name: str) -> CategoryResult:
29
+ violations: list[Violation] = []
30
+ i18n_dir = app_path / "src" / "i18n"
31
+
32
+ en_file = i18n_dir / "en.json"
33
+ id_file = i18n_dir / "id.json"
34
+
35
+ if not en_file.is_file():
36
+ violations.append(Violation(file="src/i18n/en.json", message="Missing en.json"))
37
+ if not id_file.is_file():
38
+ violations.append(Violation(file="src/i18n/id.json", message="Missing id.json"))
39
+
40
+ # Key comparison
41
+ if en_file.is_file() and id_file.is_file():
42
+ try:
43
+ en_data = json.loads(en_file.read_text())
44
+ id_data = json.loads(id_file.read_text())
45
+ except (json.JSONDecodeError, OSError):
46
+ violations.append(
47
+ Violation(
48
+ file="src/i18n/",
49
+ message="Failed to parse i18n JSON files",
50
+ )
51
+ )
52
+ en_data, id_data = {}, {}
53
+
54
+ en_keys = _flatten_keys(en_data)
55
+ id_keys = _flatten_keys(id_data)
56
+
57
+ missing_in_id = en_keys - id_keys
58
+ missing_in_en = id_keys - en_keys
59
+
60
+ for key in sorted(missing_in_id):
61
+ violations.append(
62
+ Violation(
63
+ file="src/i18n/id.json",
64
+ message=f"Missing key in id.json: {key}",
65
+ fix_hint=f'Add "{key}" to id.json',
66
+ auto_fixable=True,
67
+ )
68
+ )
69
+
70
+ for key in sorted(missing_in_en):
71
+ violations.append(
72
+ Violation(
73
+ file="src/i18n/en.json",
74
+ message=f"Missing key in en.json: {key}",
75
+ fix_hint=f'Add "{key}" to en.json',
76
+ auto_fixable=True,
77
+ )
78
+ )
79
+
80
+ # Check i18n/index.ts uses createI18n
81
+ index_ts = i18n_dir / "index.ts"
82
+ if index_ts.is_file():
83
+ content = index_ts.read_text()
84
+ if "createI18n" not in content:
85
+ violations.append(
86
+ Violation(
87
+ file="src/i18n/index.ts",
88
+ message="i18n/index.ts does not use createI18n factory",
89
+ fix_hint="Use createI18n from @kodemeio/core/i18n",
90
+ )
91
+ )
92
+ else:
93
+ violations.append(
94
+ Violation(
95
+ file="src/i18n/index.ts",
96
+ message="Missing i18n/index.ts",
97
+ )
98
+ )
99
+
100
+ return CategoryResult(
101
+ name=self.name,
102
+ label=self.label,
103
+ max_points=self.max_points,
104
+ violations=violations,
105
+ )