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.
- kctl_react/__init__.py +3 -0
- kctl_react/__main__.py +5 -0
- kctl_react/cli.py +201 -0
- kctl_react/commands/__init__.py +0 -0
- kctl_react/commands/a11y.py +78 -0
- kctl_react/commands/affected.py +170 -0
- kctl_react/commands/apps.py +353 -0
- kctl_react/commands/build.py +376 -0
- kctl_react/commands/bundle_cmd.py +217 -0
- kctl_react/commands/cap.py +1465 -0
- kctl_react/commands/clean.py +76 -0
- kctl_react/commands/codegen.py +491 -0
- kctl_react/commands/compliance.py +587 -0
- kctl_react/commands/config_cmd.py +368 -0
- kctl_react/commands/dashboard.py +163 -0
- kctl_react/commands/deploy.py +318 -0
- kctl_react/commands/deps.py +792 -0
- kctl_react/commands/dev.py +96 -0
- kctl_react/commands/docker_cmd.py +73 -0
- kctl_react/commands/doctor.py +170 -0
- kctl_react/commands/e2e.py +343 -0
- kctl_react/commands/env.py +155 -0
- kctl_react/commands/i18n.py +310 -0
- kctl_react/commands/lint.py +306 -0
- kctl_react/commands/maintenance.py +308 -0
- kctl_react/commands/monitor_cmd.py +50 -0
- kctl_react/commands/observe.py +34 -0
- kctl_react/commands/packages.py +129 -0
- kctl_react/commands/perf.py +762 -0
- kctl_react/commands/pipeline.py +289 -0
- kctl_react/commands/pwa.py +193 -0
- kctl_react/commands/scaffold.py +323 -0
- kctl_react/commands/security.py +660 -0
- kctl_react/commands/skill_cmd.py +54 -0
- kctl_react/commands/state.py +254 -0
- kctl_react/commands/test_cmd.py +418 -0
- kctl_react/commands/ui_audit.py +889 -0
- kctl_react/core/__init__.py +0 -0
- kctl_react/core/analyzers.py +200 -0
- kctl_react/core/callbacks.py +70 -0
- kctl_react/core/compliance/__init__.py +3 -0
- kctl_react/core/compliance/api_check/__init__.py +3 -0
- kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
- kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
- kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
- kctl_react/core/compliance/api_check/checks/naming.py +60 -0
- kctl_react/core/compliance/api_check/checks/params.py +44 -0
- kctl_react/core/compliance/api_check/checks/requests.py +57 -0
- kctl_react/core/compliance/api_check/checks/types.py +55 -0
- kctl_react/core/compliance/api_check/hooks.py +133 -0
- kctl_react/core/compliance/api_check/matcher.py +55 -0
- kctl_react/core/compliance/api_check/schema.py +151 -0
- kctl_react/core/compliance/api_health/__init__.py +35 -0
- kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
- kctl_react/core/compliance/api_health/checks/auth.py +72 -0
- kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
- kctl_react/core/compliance/api_health/checks/response.py +55 -0
- kctl_react/core/compliance/api_health/checks/timing.py +38 -0
- kctl_react/core/compliance/api_health/client.py +99 -0
- kctl_react/core/compliance/api_health/sampler.py +16 -0
- kctl_react/core/compliance/checks/__init__.py +47 -0
- kctl_react/core/compliance/checks/api.py +101 -0
- kctl_react/core/compliance/checks/codegen.py +94 -0
- kctl_react/core/compliance/checks/darkmode.py +57 -0
- kctl_react/core/compliance/checks/errors.py +68 -0
- kctl_react/core/compliance/checks/features.py +66 -0
- kctl_react/core/compliance/checks/i18n_check.py +105 -0
- kctl_react/core/compliance/checks/imports.py +86 -0
- kctl_react/core/compliance/checks/navigation.py +62 -0
- kctl_react/core/compliance/checks/practices.py +122 -0
- kctl_react/core/compliance/checks/providers.py +85 -0
- kctl_react/core/compliance/checks/pwa.py +101 -0
- kctl_react/core/compliance/checks/responsive.py +47 -0
- kctl_react/core/compliance/checks/scripts.py +85 -0
- kctl_react/core/compliance/checks/shadcn.py +51 -0
- kctl_react/core/compliance/checks/structure.py +76 -0
- kctl_react/core/compliance/checks/testing.py +83 -0
- kctl_react/core/compliance/checks/theme.py +92 -0
- kctl_react/core/compliance/checks/ui_standard.py +185 -0
- kctl_react/core/compliance/checks/vite.py +83 -0
- kctl_react/core/compliance/engine.py +87 -0
- kctl_react/core/compliance/exceptions_map.py +15 -0
- kctl_react/core/compliance/fixes/__init__.py +33 -0
- kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
- kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
- kctl_react/core/compliance/fixes/imports_fix.py +36 -0
- kctl_react/core/compliance/fixes/structure_fix.py +20 -0
- kctl_react/core/compliance/fixes/theme_fix.py +29 -0
- kctl_react/core/compliance/models.py +106 -0
- kctl_react/core/config.py +201 -0
- kctl_react/core/discovery.py +185 -0
- kctl_react/core/exceptions.py +17 -0
- kctl_react/core/git.py +146 -0
- kctl_react/core/history.py +121 -0
- kctl_react/core/output.py +5 -0
- kctl_react/core/plugins.py +13 -0
- kctl_react/core/runner.py +34 -0
- kctl_react/py.typed +0 -0
- kctl_react-0.6.2.dist-info/METADATA +17 -0
- kctl_react-0.6.2.dist-info/RECORD +102 -0
- kctl_react-0.6.2.dist-info/WHEEL +4 -0
- 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
|
+
)
|