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
|
File without changes
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Shared static analysis utilities for TSX/TS files.
|
|
2
|
+
|
|
3
|
+
Used by ui_audit, lint, state, and i18n commands to scan React source
|
|
4
|
+
files for shadcn violations, import patterns, query keys, and i18n issues.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# SHADCN_ELEMENT_MAP: regex pattern -> (element_name, shadcn_replacement)
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
SHADCN_ELEMENT_MAP: dict[str, tuple[str, str]] = {
|
|
18
|
+
# Basic HTML elements
|
|
19
|
+
r"<button[\s>]": ("button", "Button"),
|
|
20
|
+
r"<input[\s/>]": ("input", "Input"),
|
|
21
|
+
r"<select[\s>]": ("select", "Select + SelectTrigger + SelectContent + SelectItem"),
|
|
22
|
+
r"<textarea[\s/>]": ("textarea", "Textarea"),
|
|
23
|
+
r"<label[\s>]": ("label", "Label"),
|
|
24
|
+
r"<table[\s>]": ("table", "Table + TableHeader + TableBody + TableRow"),
|
|
25
|
+
r"<hr\s*/?\s*>": ("hr", "Separator"),
|
|
26
|
+
# Anti-patterns: title= attribute
|
|
27
|
+
r'\btitle\s*=\s*["\'{]': ("title= attr", "Tooltip + TooltipTrigger + TooltipContent"),
|
|
28
|
+
# Anti-patterns: custom modal (fixed inset-0)
|
|
29
|
+
r'className="[^"]*fixed\s+inset-0': ("fixed inset-0 div", "Dialog or Sheet"),
|
|
30
|
+
r"className='[^']*fixed\s+inset-0": ("fixed inset-0 div", "Dialog or Sheet"),
|
|
31
|
+
# Anti-patterns: animate-pulse skeleton
|
|
32
|
+
r'className="[^"]*animate-pulse': ("animate-pulse div", "Skeleton"),
|
|
33
|
+
r"className='[^']*animate-pulse": ("animate-pulse div", "Skeleton"),
|
|
34
|
+
# Anti-patterns: absolute dropdown
|
|
35
|
+
r'className="[^"]*absolute[^"]*top-': ("absolute dropdown div", "DropdownMenu"),
|
|
36
|
+
r"className='[^']*absolute[^']*top-": ("absolute dropdown div", "DropdownMenu"),
|
|
37
|
+
# More HTML elements
|
|
38
|
+
r"<img[\s/>]": ("img", "Avatar + AvatarImage + AvatarFallback (for avatars)"),
|
|
39
|
+
r"<progress[\s/>]": ("progress", "Progress"),
|
|
40
|
+
r"<details[\s>]": ("details", "Collapsible + CollapsibleTrigger + CollapsibleContent"),
|
|
41
|
+
r"<summary[\s>]": ("summary", "CollapsibleTrigger"),
|
|
42
|
+
# Anti-patterns: overflow-auto scroll area
|
|
43
|
+
r'className="[^"]*overflow-auto': ("overflow-auto div", "ScrollArea"),
|
|
44
|
+
r"className='[^']*overflow-auto": ("overflow-auto div", "ScrollArea"),
|
|
45
|
+
# Anti-patterns: custom toggle
|
|
46
|
+
r'role="switch"': ("role=switch", "Toggle or Switch"),
|
|
47
|
+
r'role="tab"': ("role=tab", "Tabs + TabsList + TabsTrigger + TabsContent"),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def find_raw_html_elements(file_path: Path) -> list[dict[str, str | int]]:
|
|
52
|
+
"""Scan a TSX file for raw HTML that should use shadcn/ui components.
|
|
53
|
+
|
|
54
|
+
Returns a list of dicts with keys: file, line, element, replacement, code.
|
|
55
|
+
"""
|
|
56
|
+
results: list[dict[str, str | int]] = []
|
|
57
|
+
try:
|
|
58
|
+
lines = file_path.read_text().splitlines()
|
|
59
|
+
except OSError:
|
|
60
|
+
return results
|
|
61
|
+
|
|
62
|
+
for line_num, line in enumerate(lines, start=1):
|
|
63
|
+
for pattern, (element, replacement) in SHADCN_ELEMENT_MAP.items():
|
|
64
|
+
if re.search(pattern, line):
|
|
65
|
+
results.append(
|
|
66
|
+
{
|
|
67
|
+
"file": str(file_path),
|
|
68
|
+
"line": line_num,
|
|
69
|
+
"element": element,
|
|
70
|
+
"replacement": replacement,
|
|
71
|
+
"code": line.strip(),
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
return results
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def scan_imports(file_path: Path, package_name: str) -> list[str]:
|
|
78
|
+
"""Extract named imports from a specific package.
|
|
79
|
+
|
|
80
|
+
Handles both single-line and multi-line import statements.
|
|
81
|
+
Returns a sorted list of imported names.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
text = file_path.read_text()
|
|
85
|
+
except OSError:
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
# Collapse multi-line imports into single lines for easier parsing
|
|
89
|
+
# Match: import { ... } from "package"
|
|
90
|
+
escaped = re.escape(package_name)
|
|
91
|
+
pattern = rf'import\s*\{{([^}}]*)\}}\s*from\s*["\']{escaped}["\']'
|
|
92
|
+
names: list[str] = []
|
|
93
|
+
for match in re.finditer(pattern, text, re.DOTALL):
|
|
94
|
+
raw = match.group(1)
|
|
95
|
+
for name in raw.split(","):
|
|
96
|
+
name = name.strip()
|
|
97
|
+
if name:
|
|
98
|
+
# Handle "Foo as Bar" — keep the local name
|
|
99
|
+
parts = name.split(" as ")
|
|
100
|
+
names.append(parts[-1].strip())
|
|
101
|
+
return sorted(names)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def read_tsconfig_strict(app_dir: Path) -> bool:
|
|
105
|
+
"""Check if tsconfig.json has ``strict: true``.
|
|
106
|
+
|
|
107
|
+
Handles JSON with ``//`` comments (common in tsconfig files).
|
|
108
|
+
"""
|
|
109
|
+
tsconfig = app_dir / "tsconfig.json"
|
|
110
|
+
if not tsconfig.exists():
|
|
111
|
+
return False
|
|
112
|
+
try:
|
|
113
|
+
text = tsconfig.read_text()
|
|
114
|
+
# Strip single-line comments
|
|
115
|
+
text = re.sub(r"//.*", "", text)
|
|
116
|
+
data = json.loads(text)
|
|
117
|
+
return bool(data.get("compilerOptions", {}).get("strict", False))
|
|
118
|
+
except (json.JSONDecodeError, OSError):
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def find_query_keys(file_path: Path) -> list[dict[str, str | int]]:
|
|
123
|
+
"""Extract TanStack Query queryKey definitions from a file.
|
|
124
|
+
|
|
125
|
+
Returns a list of dicts with keys: file, line, key_prefix, full_key.
|
|
126
|
+
"""
|
|
127
|
+
results: list[dict[str, str | int]] = []
|
|
128
|
+
try:
|
|
129
|
+
lines = file_path.read_text().splitlines()
|
|
130
|
+
except OSError:
|
|
131
|
+
return results
|
|
132
|
+
|
|
133
|
+
pattern = re.compile(r"queryKey\s*:\s*\[([^\]]+)\]")
|
|
134
|
+
|
|
135
|
+
for line_num, line in enumerate(lines, start=1):
|
|
136
|
+
match = pattern.search(line)
|
|
137
|
+
if match:
|
|
138
|
+
full_key = match.group(1).strip()
|
|
139
|
+
# Extract first element as prefix (strip quotes)
|
|
140
|
+
parts = [p.strip().strip("\"'") for p in full_key.split(",")]
|
|
141
|
+
key_prefix = parts[0] if parts else ""
|
|
142
|
+
results.append(
|
|
143
|
+
{
|
|
144
|
+
"file": str(file_path),
|
|
145
|
+
"line": line_num,
|
|
146
|
+
"key_prefix": key_prefix,
|
|
147
|
+
"full_key": full_key,
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
return results
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def find_hook_files(src_dir: Path) -> list[Path]:
|
|
154
|
+
"""Find all TanStack Query hook files (use*.ts) in src/hooks/.
|
|
155
|
+
|
|
156
|
+
Returns sorted list of Paths matching use*.ts in the hooks directory.
|
|
157
|
+
"""
|
|
158
|
+
hooks_dir = src_dir / "hooks"
|
|
159
|
+
if not hooks_dir.is_dir():
|
|
160
|
+
return []
|
|
161
|
+
return sorted(p for p in hooks_dir.iterdir() if p.is_file() and p.name.startswith("use") and p.suffix == ".ts")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def check_interpolation_vars(en: dict, id_: dict, prefix: str = "") -> list[dict[str, str | set[str]]]:
|
|
165
|
+
"""Compare ``{{var}}`` placeholders between en and id translation dicts.
|
|
166
|
+
|
|
167
|
+
Recursively walks nested dicts. Returns a list of dicts with keys:
|
|
168
|
+
key, en_vars, id_vars, missing_in_id, extra_in_id.
|
|
169
|
+
"""
|
|
170
|
+
var_pattern = re.compile(r"\{\{(\w+)\}\}")
|
|
171
|
+
mismatches: list[dict[str, str | set[str]]] = []
|
|
172
|
+
|
|
173
|
+
for key in en:
|
|
174
|
+
full_key = f"{prefix}.{key}" if prefix else key
|
|
175
|
+
en_val = en[key]
|
|
176
|
+
id_val = id_.get(key)
|
|
177
|
+
|
|
178
|
+
if isinstance(en_val, dict):
|
|
179
|
+
id_sub = id_val if isinstance(id_val, dict) else {}
|
|
180
|
+
mismatches.extend(check_interpolation_vars(en_val, id_sub, prefix=full_key))
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
if not isinstance(en_val, str):
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
en_vars = set(var_pattern.findall(en_val))
|
|
187
|
+
id_vars = set(var_pattern.findall(id_val)) if isinstance(id_val, str) else set()
|
|
188
|
+
|
|
189
|
+
if en_vars != id_vars:
|
|
190
|
+
mismatches.append(
|
|
191
|
+
{
|
|
192
|
+
"key": full_key,
|
|
193
|
+
"en_vars": en_vars,
|
|
194
|
+
"id_vars": id_vars,
|
|
195
|
+
"missing_in_id": en_vars - id_vars,
|
|
196
|
+
"extra_in_id": id_vars - en_vars,
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return mismatches
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Typer global callback and shared context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from kctl_lib.callbacks import AppContextBase
|
|
10
|
+
|
|
11
|
+
from kctl_react.core.config import resolve_project_root
|
|
12
|
+
from kctl_react.core.discovery import discover_apps, discover_packages, get_app_dir as _get_app_dir
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class AppContext(AppContextBase):
|
|
17
|
+
"""kctl-react application context with monorepo discovery."""
|
|
18
|
+
|
|
19
|
+
root_override: str | None = None
|
|
20
|
+
_project_root: Path | None = field(default=None, repr=False)
|
|
21
|
+
_app_registry: dict[str, dict[str, Any]] | None = field(default=None, repr=False)
|
|
22
|
+
_package_list: list[str] | None = field(default=None, repr=False)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def project_root(self) -> Path:
|
|
26
|
+
"""Lazy-resolved monorepo root path."""
|
|
27
|
+
if self._project_root is None:
|
|
28
|
+
self._project_root = resolve_project_root(
|
|
29
|
+
profile_name=self.profile,
|
|
30
|
+
root_override=self.root_override,
|
|
31
|
+
)
|
|
32
|
+
return self._project_root
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def apps(self) -> dict[str, dict[str, Any]]:
|
|
36
|
+
"""Auto-discovered app registry from apps/ directory."""
|
|
37
|
+
if self._app_registry is None:
|
|
38
|
+
self._app_registry = discover_apps(self.project_root)
|
|
39
|
+
return self._app_registry
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def app_names(self) -> list[str]:
|
|
43
|
+
"""Sorted list of discovered app names."""
|
|
44
|
+
return list(self.apps.keys())
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def packages(self) -> list[str]:
|
|
48
|
+
"""Auto-discovered package names from packages/ directory."""
|
|
49
|
+
if self._package_list is None:
|
|
50
|
+
self._package_list = discover_packages(self.project_root)
|
|
51
|
+
return self._package_list
|
|
52
|
+
|
|
53
|
+
def get_framework(self, app_name: str) -> str:
|
|
54
|
+
"""Return framework for an app: 'vite' or 'nextjs'."""
|
|
55
|
+
return self.apps.get(app_name, {}).get("framework", "vite")
|
|
56
|
+
|
|
57
|
+
def is_nextjs(self, app_name: str) -> bool:
|
|
58
|
+
"""Check if an app uses Next.js."""
|
|
59
|
+
return self.get_framework(app_name) == "nextjs"
|
|
60
|
+
|
|
61
|
+
def get_app_dir(self, app_name: str) -> Path:
|
|
62
|
+
"""Get the filesystem path for an app."""
|
|
63
|
+
return _get_app_dir(self.project_root, app_name, self.apps)
|
|
64
|
+
|
|
65
|
+
def validate_app(self, app_name: str) -> None:
|
|
66
|
+
"""Validate app name against discovered apps."""
|
|
67
|
+
if app_name not in self.apps:
|
|
68
|
+
from kctl_lib.exceptions import AppNotFoundError
|
|
69
|
+
|
|
70
|
+
raise AppNotFoundError(app_name, self.app_names)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""API check registry — all 6 checkers."""
|
|
2
|
+
|
|
3
|
+
from .endpoints import EndpointChecker
|
|
4
|
+
from .envelope import EnvelopeChecker
|
|
5
|
+
from .naming import NamingChecker
|
|
6
|
+
from .params import ParamChecker
|
|
7
|
+
from .requests import RequestChecker
|
|
8
|
+
from .types import TypeChecker
|
|
9
|
+
|
|
10
|
+
ALL_API_CHECKERS = [
|
|
11
|
+
EndpointChecker(),
|
|
12
|
+
TypeChecker(),
|
|
13
|
+
RequestChecker(),
|
|
14
|
+
ParamChecker(),
|
|
15
|
+
EnvelopeChecker(),
|
|
16
|
+
NamingChecker(),
|
|
17
|
+
]
|
|
18
|
+
CHECKER_MAP = {c.name: c for c in ALL_API_CHECKERS}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Endpoint coverage checker — orphan and dead endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kctl_react.core.compliance.api_check.hooks import HookCall
|
|
6
|
+
from kctl_react.core.compliance.api_check.matcher import MatchResult
|
|
7
|
+
from kctl_react.core.compliance.api_check.schema import ParsedSchema
|
|
8
|
+
from kctl_react.core.compliance.models import CategoryResult, Violation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EndpointChecker:
|
|
12
|
+
name = "endpoints"
|
|
13
|
+
label = "Endpoint Coverage"
|
|
14
|
+
max_points = 10
|
|
15
|
+
|
|
16
|
+
def check(
|
|
17
|
+
self,
|
|
18
|
+
schema: ParsedSchema,
|
|
19
|
+
hooks: list[HookCall],
|
|
20
|
+
match: MatchResult,
|
|
21
|
+
) -> CategoryResult:
|
|
22
|
+
violations: list[Violation] = []
|
|
23
|
+
|
|
24
|
+
# Skip auth endpoints — handled by @kodemeio/core, not app hooks
|
|
25
|
+
_SKIP_PREFIXES = ("/auth/",)
|
|
26
|
+
|
|
27
|
+
for op in match.orphan_endpoints:
|
|
28
|
+
if any(op.path.startswith(p) for p in _SKIP_PREFIXES):
|
|
29
|
+
continue
|
|
30
|
+
violations.append(
|
|
31
|
+
Violation(
|
|
32
|
+
file="openapi.json",
|
|
33
|
+
message=f"Orphan endpoint: {op.method.upper()} {op.path} — no hook calls this",
|
|
34
|
+
fix_hint=f"Create a hook that calls {op.method.upper()} {op.path}",
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
for hook in match.dead_endpoints:
|
|
39
|
+
violations.append(
|
|
40
|
+
Violation(
|
|
41
|
+
file=hook.file,
|
|
42
|
+
line=hook.line,
|
|
43
|
+
message=f"Dead endpoint: {hook.method.upper()} {hook.normalized_url} in {hook.file} — not in OpenAPI schema",
|
|
44
|
+
fix_hint="Remove this API call or add the endpoint to the OpenAPI schema",
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return CategoryResult(
|
|
49
|
+
name=self.name,
|
|
50
|
+
label=self.label,
|
|
51
|
+
max_points=self.max_points,
|
|
52
|
+
violations=violations,
|
|
53
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Response envelope conformance checker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kctl_react.core.compliance.api_check.hooks import HookCall
|
|
6
|
+
from kctl_react.core.compliance.api_check.matcher import MatchResult
|
|
7
|
+
from kctl_react.core.compliance.api_check.schema import ParsedSchema
|
|
8
|
+
from kctl_react.core.compliance.models import CategoryResult, Violation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EnvelopeChecker:
|
|
12
|
+
name = "envelope"
|
|
13
|
+
label = "Response Envelope"
|
|
14
|
+
max_points = 5
|
|
15
|
+
|
|
16
|
+
def check(
|
|
17
|
+
self,
|
|
18
|
+
schema: ParsedSchema,
|
|
19
|
+
hooks: list[HookCall],
|
|
20
|
+
match: MatchResult,
|
|
21
|
+
) -> CategoryResult:
|
|
22
|
+
violations: list[Violation] = []
|
|
23
|
+
|
|
24
|
+
for name, schema_def in schema.response_schemas.items():
|
|
25
|
+
# Only check schemas that are response envelopes (end with Response)
|
|
26
|
+
if not name.endswith("Response"):
|
|
27
|
+
continue
|
|
28
|
+
properties = schema_def.get("properties", {})
|
|
29
|
+
for required_key in ("success", "data"):
|
|
30
|
+
if required_key not in properties:
|
|
31
|
+
violations.append(
|
|
32
|
+
Violation(
|
|
33
|
+
file="openapi.json",
|
|
34
|
+
message=f"Schema {name} missing {required_key} in response envelope",
|
|
35
|
+
fix_hint=f"Add '{required_key}' property to {name}",
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return CategoryResult(
|
|
40
|
+
name=self.name,
|
|
41
|
+
label=self.label,
|
|
42
|
+
max_points=self.max_points,
|
|
43
|
+
violations=violations,
|
|
44
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Hook naming convention checker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from kctl_react.core.compliance.api_check.hooks import HookCall
|
|
8
|
+
from kctl_react.core.compliance.api_check.matcher import MatchResult
|
|
9
|
+
from kctl_react.core.compliance.api_check.schema import ParsedSchema
|
|
10
|
+
from kctl_react.core.compliance.models import CategoryResult, Violation
|
|
11
|
+
|
|
12
|
+
_USE_PREFIX_RE = re.compile(r"use([A-Z]\w*)\.ts$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NamingChecker:
|
|
16
|
+
name = "naming"
|
|
17
|
+
label = "Hook Naming"
|
|
18
|
+
max_points = 5
|
|
19
|
+
|
|
20
|
+
def check(
|
|
21
|
+
self,
|
|
22
|
+
schema: ParsedSchema,
|
|
23
|
+
hooks: list[HookCall],
|
|
24
|
+
match: MatchResult,
|
|
25
|
+
) -> CategoryResult:
|
|
26
|
+
violations: list[Violation] = []
|
|
27
|
+
|
|
28
|
+
# Collect all tags from the schema
|
|
29
|
+
tags = {op.tag.lower() for op in schema.operations}
|
|
30
|
+
|
|
31
|
+
# Group hooks by file to avoid duplicate violations
|
|
32
|
+
seen_files: set[str] = set()
|
|
33
|
+
for hook in hooks:
|
|
34
|
+
if hook.file in seen_files:
|
|
35
|
+
continue
|
|
36
|
+
seen_files.add(hook.file)
|
|
37
|
+
|
|
38
|
+
# Extract resource name from filename
|
|
39
|
+
filename = hook.file.rsplit("/", 1)[-1] if "/" in hook.file else hook.file
|
|
40
|
+
m = _USE_PREFIX_RE.match(filename)
|
|
41
|
+
if not m:
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
resource = m.group(1).lower()
|
|
45
|
+
|
|
46
|
+
if not any(resource in tag for tag in tags):
|
|
47
|
+
violations.append(
|
|
48
|
+
Violation(
|
|
49
|
+
file=hook.file,
|
|
50
|
+
message=f"Hook {hook.file} name doesn't match any OpenAPI tag",
|
|
51
|
+
fix_hint="Rename the hook file to match a tag in the OpenAPI schema",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return CategoryResult(
|
|
56
|
+
name=self.name,
|
|
57
|
+
label=self.label,
|
|
58
|
+
max_points=self.max_points,
|
|
59
|
+
violations=violations,
|
|
60
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Query parameter validation checker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kctl_react.core.compliance.api_check.hooks import HookCall
|
|
6
|
+
from kctl_react.core.compliance.api_check.matcher import MatchResult
|
|
7
|
+
from kctl_react.core.compliance.api_check.schema import ParsedSchema
|
|
8
|
+
from kctl_react.core.compliance.models import CategoryResult, Violation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ParamChecker:
|
|
12
|
+
name = "params"
|
|
13
|
+
label = "Query Parameters"
|
|
14
|
+
max_points = 5
|
|
15
|
+
|
|
16
|
+
def check(
|
|
17
|
+
self,
|
|
18
|
+
schema: ParsedSchema,
|
|
19
|
+
hooks: list[HookCall],
|
|
20
|
+
match: MatchResult,
|
|
21
|
+
) -> CategoryResult:
|
|
22
|
+
violations: list[Violation] = []
|
|
23
|
+
|
|
24
|
+
for hook, operation in match.matched:
|
|
25
|
+
for param in hook.query_params:
|
|
26
|
+
if param not in operation.parameters:
|
|
27
|
+
violations.append(
|
|
28
|
+
Violation(
|
|
29
|
+
file=hook.file,
|
|
30
|
+
line=hook.line,
|
|
31
|
+
message=(
|
|
32
|
+
f"Unknown query param '{param}' on "
|
|
33
|
+
f"{hook.method.upper()} {hook.normalized_url} in {hook.file}"
|
|
34
|
+
),
|
|
35
|
+
fix_hint=f"Remove '{param}' or add it to the OpenAPI schema",
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return CategoryResult(
|
|
40
|
+
name=self.name,
|
|
41
|
+
label=self.label,
|
|
42
|
+
max_points=self.max_points,
|
|
43
|
+
violations=violations,
|
|
44
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Request type matching checker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kctl_react.core.compliance.api_check.hooks import HookCall
|
|
6
|
+
from kctl_react.core.compliance.api_check.matcher import MatchResult
|
|
7
|
+
from kctl_react.core.compliance.api_check.schema import ParsedSchema
|
|
8
|
+
from kctl_react.core.compliance.models import CategoryResult, Violation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RequestChecker:
|
|
12
|
+
name = "requests"
|
|
13
|
+
label = "Request Types"
|
|
14
|
+
max_points = 5
|
|
15
|
+
|
|
16
|
+
def check(
|
|
17
|
+
self,
|
|
18
|
+
schema: ParsedSchema,
|
|
19
|
+
hooks: list[HookCall],
|
|
20
|
+
match: MatchResult,
|
|
21
|
+
) -> CategoryResult:
|
|
22
|
+
violations: list[Violation] = []
|
|
23
|
+
|
|
24
|
+
for hook, operation in match.matched:
|
|
25
|
+
if operation.method not in ("post", "put", "patch"):
|
|
26
|
+
continue
|
|
27
|
+
|
|
28
|
+
if operation.request_schema and hook.request_type is None:
|
|
29
|
+
violations.append(
|
|
30
|
+
Violation(
|
|
31
|
+
file=hook.file,
|
|
32
|
+
line=hook.line,
|
|
33
|
+
message=(
|
|
34
|
+
f"Missing request body type on {hook.method.upper()} {hook.normalized_url} in {hook.file}"
|
|
35
|
+
),
|
|
36
|
+
fix_hint=f"Add request type {operation.request_schema}",
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
elif hook.request_type and operation.request_schema and hook.request_type != operation.request_schema:
|
|
40
|
+
violations.append(
|
|
41
|
+
Violation(
|
|
42
|
+
file=hook.file,
|
|
43
|
+
line=hook.line,
|
|
44
|
+
message=(
|
|
45
|
+
f"Request type mismatch: hook uses {hook.request_type} "
|
|
46
|
+
f"but schema expects {operation.request_schema}"
|
|
47
|
+
),
|
|
48
|
+
fix_hint=f"Change request type to {operation.request_schema}",
|
|
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,55 @@
|
|
|
1
|
+
"""Response type matching checker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kctl_react.core.compliance.api_check.hooks import HookCall
|
|
6
|
+
from kctl_react.core.compliance.api_check.matcher import MatchResult
|
|
7
|
+
from kctl_react.core.compliance.api_check.schema import ParsedSchema
|
|
8
|
+
from kctl_react.core.compliance.models import CategoryResult, Violation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TypeChecker:
|
|
12
|
+
name = "types"
|
|
13
|
+
label = "Response Types"
|
|
14
|
+
max_points = 10
|
|
15
|
+
|
|
16
|
+
def check(
|
|
17
|
+
self,
|
|
18
|
+
schema: ParsedSchema,
|
|
19
|
+
hooks: list[HookCall],
|
|
20
|
+
match: MatchResult,
|
|
21
|
+
) -> CategoryResult:
|
|
22
|
+
violations: list[Violation] = []
|
|
23
|
+
|
|
24
|
+
for hook, operation in match.matched:
|
|
25
|
+
if hook.response_type is None:
|
|
26
|
+
violations.append(
|
|
27
|
+
Violation(
|
|
28
|
+
file=hook.file,
|
|
29
|
+
line=hook.line,
|
|
30
|
+
message=(
|
|
31
|
+
f"Missing response type generic on "
|
|
32
|
+
f"{hook.method.upper()} {hook.normalized_url} in {hook.file}"
|
|
33
|
+
),
|
|
34
|
+
fix_hint="Add a response type generic to the API call",
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
elif operation.response_schema and hook.response_type != operation.response_schema:
|
|
38
|
+
violations.append(
|
|
39
|
+
Violation(
|
|
40
|
+
file=hook.file,
|
|
41
|
+
line=hook.line,
|
|
42
|
+
message=(
|
|
43
|
+
f"Type mismatch: hook uses {hook.response_type} "
|
|
44
|
+
f"but schema expects {operation.response_schema}"
|
|
45
|
+
),
|
|
46
|
+
fix_hint=f"Change response type to {operation.response_schema}",
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return CategoryResult(
|
|
51
|
+
name=self.name,
|
|
52
|
+
label=self.label,
|
|
53
|
+
max_points=self.max_points,
|
|
54
|
+
violations=violations,
|
|
55
|
+
)
|