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,587 @@
|
|
|
1
|
+
"""Compliance audit, fix, prompt, api-check, and api-health commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from kctl_react.core.callbacks import AppContext
|
|
12
|
+
from kctl_react.core.discovery import get_app_dir
|
|
13
|
+
from kctl_react.core.compliance.engine import ComplianceEngine
|
|
14
|
+
from kctl_react.core.compliance.fixes import apply_fixes
|
|
15
|
+
from kctl_react.core.compliance.models import AppReport, CategoryResult
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(help="Audit app compliance, auto-fix violations, and generate fix prompts.")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_categories(categories: str | None) -> list[str] | None:
|
|
23
|
+
"""Parse comma-separated category string into list, or None."""
|
|
24
|
+
if not categories:
|
|
25
|
+
return None
|
|
26
|
+
return [c.strip() for c in categories.split(",") if c.strip()]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _grade_color(grade: str) -> str:
|
|
30
|
+
"""Return Rich color tag for a grade."""
|
|
31
|
+
if grade in ("A+", "A"):
|
|
32
|
+
return "green"
|
|
33
|
+
if grade == "B":
|
|
34
|
+
return "yellow"
|
|
35
|
+
return "red"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _audit_single(actx: AppContext, report: AppReport) -> None:
|
|
39
|
+
"""Print detailed single-app audit report."""
|
|
40
|
+
out = actx.output
|
|
41
|
+
|
|
42
|
+
if actx.json_mode:
|
|
43
|
+
out.raw_json(report.to_dict())
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
color = _grade_color(report.grade)
|
|
47
|
+
pct = int(report.total_score / report.max_score * 100) if report.max_score > 0 else 100
|
|
48
|
+
|
|
49
|
+
out.text("")
|
|
50
|
+
out.text(f" Compliance Report: {report.app} ([{color}]{report.grade}[/{color}])")
|
|
51
|
+
out.text(f" Score: {pct}% ({report.total_score}/{report.max_score})")
|
|
52
|
+
out.text("")
|
|
53
|
+
|
|
54
|
+
# Category summary
|
|
55
|
+
for cat in report.categories:
|
|
56
|
+
if cat.violations:
|
|
57
|
+
vcount = len(cat.violations)
|
|
58
|
+
out.text(
|
|
59
|
+
f" [yellow]![/yellow] {cat.label:<20s} {cat.score}/{cat.max_points} ({vcount} violation{'s' if vcount != 1 else ''})"
|
|
60
|
+
)
|
|
61
|
+
else:
|
|
62
|
+
out.text(f" [green]\u2713[/green] {cat.label:<20s} {cat.score}/{cat.max_points}")
|
|
63
|
+
|
|
64
|
+
# Violations detail
|
|
65
|
+
if report.total_violations > 0:
|
|
66
|
+
out.text("")
|
|
67
|
+
out.text(f" Violations ({report.total_violations}):")
|
|
68
|
+
|
|
69
|
+
# Group by category
|
|
70
|
+
for cat in report.categories:
|
|
71
|
+
if not cat.violations:
|
|
72
|
+
continue
|
|
73
|
+
out.text("")
|
|
74
|
+
out.text(f" {cat.label} ({len(cat.violations)}):")
|
|
75
|
+
for v in cat.violations:
|
|
76
|
+
loc = f":{v.line}" if v.line else ""
|
|
77
|
+
out.text(f" [yellow]![/yellow] {v.file}{loc} {v.message}")
|
|
78
|
+
|
|
79
|
+
out.text("")
|
|
80
|
+
out.text(f" Auto-fixable: {report.auto_fixable_count} Needs review: {report.needs_review_count}")
|
|
81
|
+
out.text("")
|
|
82
|
+
out.text(f" Run: kctl-react compliance fix {report.app} --dry-run")
|
|
83
|
+
out.text(f" Run: kctl-react compliance prompt {report.app} -o fix-{report.app}.md")
|
|
84
|
+
else:
|
|
85
|
+
out.text("")
|
|
86
|
+
out.success("No violations found!")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _audit_all(actx: AppContext, reports: list[AppReport]) -> None:
|
|
90
|
+
"""Print scorecard table for all apps."""
|
|
91
|
+
out = actx.output
|
|
92
|
+
|
|
93
|
+
if actx.json_mode:
|
|
94
|
+
out.raw_json([r.to_dict() for r in reports])
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
# Build table
|
|
98
|
+
# Collect all category names from the first report (all should be the same)
|
|
99
|
+
cat_names: list[str] = []
|
|
100
|
+
if reports:
|
|
101
|
+
cat_names = [c.name for c in reports[0].categories]
|
|
102
|
+
|
|
103
|
+
columns: list[tuple[str, str]] = [
|
|
104
|
+
("App", "cyan"),
|
|
105
|
+
("Score", "green"),
|
|
106
|
+
("Grade", "yellow"),
|
|
107
|
+
]
|
|
108
|
+
for cn in cat_names:
|
|
109
|
+
columns.append((cn[:6], "dim"))
|
|
110
|
+
|
|
111
|
+
rows: list[list[str]] = []
|
|
112
|
+
json_data: list[dict] = []
|
|
113
|
+
for r in reports:
|
|
114
|
+
color = _grade_color(r.grade)
|
|
115
|
+
pct = int(r.total_score / r.max_score * 100) if r.max_score > 0 else 100
|
|
116
|
+
row: list[str] = [
|
|
117
|
+
r.app,
|
|
118
|
+
f"{pct}%",
|
|
119
|
+
f"[{color}]{r.grade}[/{color}]",
|
|
120
|
+
]
|
|
121
|
+
for cat in r.categories:
|
|
122
|
+
row.append(f"{cat.score}/{cat.max_points}")
|
|
123
|
+
rows.append(row)
|
|
124
|
+
json_data.append(r.to_dict())
|
|
125
|
+
|
|
126
|
+
out.table("Compliance Scorecard", columns, rows, data_for_json=json_data)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command()
|
|
130
|
+
def audit(
|
|
131
|
+
ctx: typer.Context,
|
|
132
|
+
app_name: Annotated[str | None, typer.Argument(help="App name to audit")] = None,
|
|
133
|
+
all_apps: Annotated[bool, typer.Option("--all", help="Audit all apps")] = False,
|
|
134
|
+
min_score: Annotated[
|
|
135
|
+
int | None, typer.Option("--min-score", help="Minimum passing score (percentage 0-100)")
|
|
136
|
+
] = None,
|
|
137
|
+
categories: Annotated[
|
|
138
|
+
str | None, typer.Option("--categories", "-c", help="Comma-separated category filter")
|
|
139
|
+
] = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Run compliance audit on one or all apps.
|
|
142
|
+
|
|
143
|
+
Examples:
|
|
144
|
+
kctl-react compliance audit sfa
|
|
145
|
+
kctl-react compliance audit --all
|
|
146
|
+
kctl-react compliance audit sfa --min-score 80
|
|
147
|
+
kctl-react compliance audit sfa --categories structure,imports
|
|
148
|
+
kctl-react --json compliance audit --all
|
|
149
|
+
"""
|
|
150
|
+
actx: AppContext = ctx.obj
|
|
151
|
+
out = actx.output
|
|
152
|
+
root = actx.project_root
|
|
153
|
+
|
|
154
|
+
if not app_name and not all_apps:
|
|
155
|
+
out.error("Specify an app name or use --all")
|
|
156
|
+
raise typer.Exit(1) from None
|
|
157
|
+
|
|
158
|
+
cat_list = _parse_categories(categories)
|
|
159
|
+
engine = ComplianceEngine()
|
|
160
|
+
|
|
161
|
+
if all_apps:
|
|
162
|
+
reports: list[AppReport] = []
|
|
163
|
+
for name in actx.app_names:
|
|
164
|
+
app_path = get_app_dir(root, name)
|
|
165
|
+
report = engine.audit(app_path, name, categories=cat_list)
|
|
166
|
+
reports.append(report)
|
|
167
|
+
|
|
168
|
+
_audit_all(actx, reports)
|
|
169
|
+
|
|
170
|
+
if min_score is not None:
|
|
171
|
+
any_below = False
|
|
172
|
+
for r in reports:
|
|
173
|
+
pct = int(r.total_score / r.max_score * 100) if r.max_score > 0 else 100
|
|
174
|
+
if pct < min_score:
|
|
175
|
+
out.error(f"{r.app} scored {pct}% (below {min_score}%)")
|
|
176
|
+
any_below = True
|
|
177
|
+
if any_below:
|
|
178
|
+
raise typer.Exit(1) from None
|
|
179
|
+
else:
|
|
180
|
+
assert app_name is not None
|
|
181
|
+
actx.validate_app(app_name)
|
|
182
|
+
app_path = get_app_dir(root, app_name)
|
|
183
|
+
report = engine.audit(app_path, app_name, categories=cat_list)
|
|
184
|
+
_audit_single(actx, report)
|
|
185
|
+
|
|
186
|
+
if min_score is not None:
|
|
187
|
+
pct = int(report.total_score / report.max_score * 100) if report.max_score > 0 else 100
|
|
188
|
+
if pct < min_score:
|
|
189
|
+
out.error(f"{report.app} scored {pct}% (below {min_score}%)")
|
|
190
|
+
raise typer.Exit(1) from None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@app.command()
|
|
194
|
+
def fix(
|
|
195
|
+
ctx: typer.Context,
|
|
196
|
+
app_name: Annotated[str | None, typer.Argument(help="App name to fix")] = None,
|
|
197
|
+
all_apps: Annotated[bool, typer.Option("--all", help="Fix all apps")] = False,
|
|
198
|
+
dry_run: Annotated[
|
|
199
|
+
bool, typer.Option("--dry-run", help="Show what would be fixed without modifying files")
|
|
200
|
+
] = False,
|
|
201
|
+
categories: Annotated[
|
|
202
|
+
str | None, typer.Option("--categories", "-c", help="Comma-separated category filter")
|
|
203
|
+
] = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Apply auto-fixes for compliance violations.
|
|
206
|
+
|
|
207
|
+
Examples:
|
|
208
|
+
kctl-react compliance fix sfa
|
|
209
|
+
kctl-react compliance fix sfa --dry-run
|
|
210
|
+
kctl-react compliance fix --all
|
|
211
|
+
kctl-react compliance fix sfa --categories structure,i18n
|
|
212
|
+
"""
|
|
213
|
+
actx: AppContext = ctx.obj
|
|
214
|
+
out = actx.output
|
|
215
|
+
root = actx.project_root
|
|
216
|
+
|
|
217
|
+
if not app_name and not all_apps:
|
|
218
|
+
out.error("Specify an app name or use --all")
|
|
219
|
+
raise typer.Exit(1) from None
|
|
220
|
+
|
|
221
|
+
cat_list = _parse_categories(categories)
|
|
222
|
+
engine = ComplianceEngine()
|
|
223
|
+
|
|
224
|
+
names = actx.app_names if all_apps else [app_name] # type: ignore[list-item]
|
|
225
|
+
for name in names:
|
|
226
|
+
if not all_apps:
|
|
227
|
+
actx.validate_app(name)
|
|
228
|
+
app_path = get_app_dir(root, name)
|
|
229
|
+
|
|
230
|
+
prefix = "(dry-run) " if dry_run else ""
|
|
231
|
+
count = apply_fixes(app_path, name, dry_run=dry_run, categories=cat_list)
|
|
232
|
+
out.info(f"{prefix}{name}: {count} fix{'es' if count != 1 else ''} applied")
|
|
233
|
+
|
|
234
|
+
# Re-audit to show remaining issues
|
|
235
|
+
report = engine.audit(app_path, name, categories=cat_list)
|
|
236
|
+
remaining = report.needs_review_count
|
|
237
|
+
if remaining > 0:
|
|
238
|
+
out.text(f" Remaining issues needing review: {remaining}")
|
|
239
|
+
else:
|
|
240
|
+
out.success(f" {name}: all clear!")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@app.command()
|
|
244
|
+
def prompt(
|
|
245
|
+
ctx: typer.Context,
|
|
246
|
+
app_name: Annotated[str | None, typer.Argument(help="App name to generate prompt for")] = None,
|
|
247
|
+
all_apps: Annotated[bool, typer.Option("--all", help="Generate prompts for all apps")] = False,
|
|
248
|
+
output_file: Annotated[
|
|
249
|
+
str | None, typer.Option("-o", "--output", help="Output file path (directory if --all)")
|
|
250
|
+
] = None,
|
|
251
|
+
categories: Annotated[
|
|
252
|
+
str | None, typer.Option("--categories", "-c", help="Comma-separated category filter")
|
|
253
|
+
] = None,
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Generate markdown fix prompts for AI or human review.
|
|
256
|
+
|
|
257
|
+
Examples:
|
|
258
|
+
kctl-react compliance prompt sfa
|
|
259
|
+
kctl-react compliance prompt sfa -o fix-sfa.md
|
|
260
|
+
kctl-react compliance prompt --all -o fix-prompts/
|
|
261
|
+
"""
|
|
262
|
+
actx: AppContext = ctx.obj
|
|
263
|
+
out = actx.output
|
|
264
|
+
root = actx.project_root
|
|
265
|
+
|
|
266
|
+
if not app_name and not all_apps:
|
|
267
|
+
out.error("Specify an app name or use --all")
|
|
268
|
+
raise typer.Exit(1) from None
|
|
269
|
+
|
|
270
|
+
cat_list = _parse_categories(categories)
|
|
271
|
+
engine = ComplianceEngine()
|
|
272
|
+
|
|
273
|
+
if all_apps:
|
|
274
|
+
for name in actx.app_names:
|
|
275
|
+
app_path = get_app_dir(root, name)
|
|
276
|
+
report = engine.audit(app_path, name, categories=cat_list)
|
|
277
|
+
md = engine.generate_prompt(report)
|
|
278
|
+
|
|
279
|
+
if output_file:
|
|
280
|
+
out_dir = Path(output_file)
|
|
281
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
282
|
+
dest = out_dir / f"fix-{name}.md"
|
|
283
|
+
dest.write_text(md)
|
|
284
|
+
out.info(f"Saved: {dest}")
|
|
285
|
+
else:
|
|
286
|
+
out.text(md)
|
|
287
|
+
else:
|
|
288
|
+
assert app_name is not None
|
|
289
|
+
actx.validate_app(app_name)
|
|
290
|
+
app_path = get_app_dir(root, app_name)
|
|
291
|
+
report = engine.audit(app_path, app_name, categories=cat_list)
|
|
292
|
+
md = engine.generate_prompt(report)
|
|
293
|
+
|
|
294
|
+
if output_file:
|
|
295
|
+
dest = Path(output_file)
|
|
296
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
dest.write_text(md)
|
|
298
|
+
out.info(f"Saved: {dest}")
|
|
299
|
+
else:
|
|
300
|
+
out.text(md)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
# Helpers for api-check / api-health
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _read_env_var(app_path: Path, var_name: str) -> str | None:
|
|
309
|
+
"""Read a single variable from the app's .env file."""
|
|
310
|
+
env_file = app_path / ".env"
|
|
311
|
+
if not env_file.is_file():
|
|
312
|
+
return None
|
|
313
|
+
for line in env_file.read_text().splitlines():
|
|
314
|
+
if line.startswith(f"{var_name}="):
|
|
315
|
+
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _run_api_check_for_app(
|
|
320
|
+
app_path: Path,
|
|
321
|
+
app_name: str,
|
|
322
|
+
*,
|
|
323
|
+
offline: bool,
|
|
324
|
+
cat_list: list[str] | None,
|
|
325
|
+
) -> AppReport | None:
|
|
326
|
+
"""Run api-check checkers for a single app, returning AppReport or None if no schema."""
|
|
327
|
+
from kctl_react.core.compliance.api_check.checks import ALL_API_CHECKERS, CHECKER_MAP
|
|
328
|
+
from kctl_react.core.compliance.api_check.hooks import parse_hooks
|
|
329
|
+
from kctl_react.core.compliance.api_check.matcher import match_hooks_to_schema
|
|
330
|
+
from kctl_react.core.compliance.api_check.schema import load_schema, parse_schema
|
|
331
|
+
|
|
332
|
+
schema_raw = load_schema(app_path, app_name, offline=offline)
|
|
333
|
+
if schema_raw is None:
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
schema = parse_schema(schema_raw)
|
|
337
|
+
hooks = parse_hooks(app_path)
|
|
338
|
+
match = match_hooks_to_schema(hooks, schema)
|
|
339
|
+
|
|
340
|
+
# Select checkers (filtered by --categories if given)
|
|
341
|
+
if cat_list:
|
|
342
|
+
checkers = [CHECKER_MAP[c] for c in cat_list if c in CHECKER_MAP]
|
|
343
|
+
else:
|
|
344
|
+
checkers = list(ALL_API_CHECKERS)
|
|
345
|
+
|
|
346
|
+
categories: list[CategoryResult] = []
|
|
347
|
+
for checker in checkers:
|
|
348
|
+
result = checker.check(schema, hooks, match)
|
|
349
|
+
categories.append(result)
|
|
350
|
+
|
|
351
|
+
return AppReport(app=app_name, categories=categories)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _run_api_health_for_app(
|
|
355
|
+
app_path: Path,
|
|
356
|
+
app_name: str,
|
|
357
|
+
*,
|
|
358
|
+
url: str | None,
|
|
359
|
+
timeout: float,
|
|
360
|
+
token: str | None,
|
|
361
|
+
cat_list: list[str] | None,
|
|
362
|
+
) -> AppReport | None:
|
|
363
|
+
"""Run api-health checkers for a single app, returning AppReport or None if no schema."""
|
|
364
|
+
from kctl_react.core.compliance.api_check.schema import (
|
|
365
|
+
APP_BACKEND_MAP,
|
|
366
|
+
load_schema,
|
|
367
|
+
parse_schema,
|
|
368
|
+
)
|
|
369
|
+
from kctl_react.core.compliance.api_health import run_health_checks
|
|
370
|
+
from kctl_react.core.compliance.api_health.checks import AuthChecker
|
|
371
|
+
from kctl_react.core.compliance.api_health.checks import ReachableChecker, ResponseChecker, TimingChecker
|
|
372
|
+
from kctl_react.core.compliance.api_health.client import HealthClient
|
|
373
|
+
from kctl_react.core.compliance.api_health.sampler import sample_endpoints
|
|
374
|
+
|
|
375
|
+
schema_raw = load_schema(app_path, app_name, offline=False)
|
|
376
|
+
if schema_raw is None:
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
schema = parse_schema(schema_raw)
|
|
380
|
+
|
|
381
|
+
# Resolve base URL
|
|
382
|
+
base_url = url
|
|
383
|
+
if not base_url:
|
|
384
|
+
base_url = _read_env_var(app_path, "VITE_API_BASE_URL")
|
|
385
|
+
if not base_url:
|
|
386
|
+
base_url = _read_env_var(app_path, "NEXT_PUBLIC_API_BASE_URL")
|
|
387
|
+
if not base_url:
|
|
388
|
+
backend = APP_BACKEND_MAP.get(app_name, app_name)
|
|
389
|
+
base_url = f"http://localhost:8069/{backend}/api"
|
|
390
|
+
|
|
391
|
+
client = HealthClient(base_url=base_url, token=token, timeout=timeout)
|
|
392
|
+
|
|
393
|
+
# Authenticate if no token provided
|
|
394
|
+
if not client.token:
|
|
395
|
+
if not client.authenticate(app_path, app_name):
|
|
396
|
+
pass
|
|
397
|
+
|
|
398
|
+
endpoints = sample_endpoints(schema)
|
|
399
|
+
results = run_health_checks(client, endpoints)
|
|
400
|
+
|
|
401
|
+
# Run checkers (filtered by --categories if given)
|
|
402
|
+
categories: list[CategoryResult] = []
|
|
403
|
+
|
|
404
|
+
checker_instances = {
|
|
405
|
+
"auth": lambda: AuthChecker().check(client, schema),
|
|
406
|
+
"reachable": lambda: ReachableChecker().check(results),
|
|
407
|
+
"response": lambda: ResponseChecker().check(results),
|
|
408
|
+
"timing": lambda: TimingChecker().check(results, threshold=timeout),
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if cat_list:
|
|
412
|
+
selected = [c for c in cat_list if c in checker_instances]
|
|
413
|
+
else:
|
|
414
|
+
selected = list(checker_instances.keys())
|
|
415
|
+
|
|
416
|
+
for name in selected:
|
|
417
|
+
categories.append(checker_instances[name]())
|
|
418
|
+
|
|
419
|
+
return AppReport(app=app_name, categories=categories)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ---------------------------------------------------------------------------
|
|
423
|
+
# api-check command
|
|
424
|
+
# ---------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@app.command("api-check")
|
|
428
|
+
def api_check_cmd(
|
|
429
|
+
ctx: typer.Context,
|
|
430
|
+
app_name: Annotated[str | None, typer.Argument(help="App name to check")] = None,
|
|
431
|
+
all_apps: Annotated[bool, typer.Option("--all", help="Check all apps")] = False,
|
|
432
|
+
offline: Annotated[bool, typer.Option("--offline", help="Skip schema fetching, use cached only")] = False,
|
|
433
|
+
categories: Annotated[
|
|
434
|
+
str | None, typer.Option("--categories", "-c", help="Comma-separated category filter")
|
|
435
|
+
] = None,
|
|
436
|
+
min_score: Annotated[
|
|
437
|
+
int | None, typer.Option("--min-score", help="Minimum passing score (percentage 0-100)")
|
|
438
|
+
] = None,
|
|
439
|
+
) -> None:
|
|
440
|
+
"""Check frontend API hooks against OpenAPI schema.
|
|
441
|
+
|
|
442
|
+
Examples:
|
|
443
|
+
kctl-react compliance api-check sfa
|
|
444
|
+
kctl-react compliance api-check --all
|
|
445
|
+
kctl-react compliance api-check sfa --offline
|
|
446
|
+
kctl-react compliance api-check sfa --categories endpoints,types
|
|
447
|
+
kctl-react compliance api-check --all --min-score 80
|
|
448
|
+
"""
|
|
449
|
+
actx: AppContext = ctx.obj
|
|
450
|
+
out = actx.output
|
|
451
|
+
root = actx.project_root
|
|
452
|
+
|
|
453
|
+
if not app_name and not all_apps:
|
|
454
|
+
out.error("Specify an app name or use --all")
|
|
455
|
+
raise typer.Exit(1) from None
|
|
456
|
+
|
|
457
|
+
cat_list = _parse_categories(categories)
|
|
458
|
+
|
|
459
|
+
if all_apps:
|
|
460
|
+
reports: list[AppReport] = []
|
|
461
|
+
for name in actx.app_names:
|
|
462
|
+
app_path = get_app_dir(root, name)
|
|
463
|
+
report = _run_api_check_for_app(app_path, name, offline=offline, cat_list=cat_list)
|
|
464
|
+
if report is None:
|
|
465
|
+
out.warn(f"No OpenAPI schema available for {name}")
|
|
466
|
+
continue
|
|
467
|
+
reports.append(report)
|
|
468
|
+
|
|
469
|
+
_audit_all(actx, reports)
|
|
470
|
+
|
|
471
|
+
if min_score is not None:
|
|
472
|
+
any_below = False
|
|
473
|
+
for r in reports:
|
|
474
|
+
pct = int(r.total_score / r.max_score * 100) if r.max_score > 0 else 100
|
|
475
|
+
if pct < min_score:
|
|
476
|
+
out.error(f"{r.app} scored {pct}% (below {min_score}%)")
|
|
477
|
+
any_below = True
|
|
478
|
+
if any_below:
|
|
479
|
+
raise typer.Exit(1) from None
|
|
480
|
+
else:
|
|
481
|
+
assert app_name is not None
|
|
482
|
+
actx.validate_app(app_name)
|
|
483
|
+
app_path = get_app_dir(root, app_name)
|
|
484
|
+
report = _run_api_check_for_app(app_path, app_name, offline=offline, cat_list=cat_list)
|
|
485
|
+
if report is None:
|
|
486
|
+
out.warn(f"No OpenAPI schema available for {app_name}")
|
|
487
|
+
raise typer.Exit(0) from None
|
|
488
|
+
|
|
489
|
+
_audit_single(actx, report)
|
|
490
|
+
|
|
491
|
+
if min_score is not None:
|
|
492
|
+
pct = int(report.total_score / report.max_score * 100) if report.max_score > 0 else 100
|
|
493
|
+
if pct < min_score:
|
|
494
|
+
out.error(f"{report.app} scored {pct}% (below {min_score}%)")
|
|
495
|
+
raise typer.Exit(1) from None
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# ---------------------------------------------------------------------------
|
|
499
|
+
# api-health command
|
|
500
|
+
# ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
@app.command("api-health")
|
|
504
|
+
def api_health_cmd(
|
|
505
|
+
ctx: typer.Context,
|
|
506
|
+
app_name: Annotated[str | None, typer.Argument(help="App name to health-check")] = None,
|
|
507
|
+
all_apps: Annotated[bool, typer.Option("--all", help="Health-check all apps")] = False,
|
|
508
|
+
url: Annotated[str | None, typer.Option("--url", help="Override base URL for backend")] = None,
|
|
509
|
+
timeout: Annotated[float, typer.Option("--timeout", help="Per-request timeout in seconds")] = 3.0,
|
|
510
|
+
token: Annotated[str | None, typer.Option("--token", help="Pre-authenticated JWT token")] = None,
|
|
511
|
+
categories: Annotated[
|
|
512
|
+
str | None, typer.Option("--categories", "-c", help="Comma-separated category filter")
|
|
513
|
+
] = None,
|
|
514
|
+
min_score: Annotated[
|
|
515
|
+
int | None, typer.Option("--min-score", help="Minimum passing score (percentage 0-100)")
|
|
516
|
+
] = None,
|
|
517
|
+
) -> None:
|
|
518
|
+
"""Run live health checks against backend API endpoints.
|
|
519
|
+
|
|
520
|
+
Examples:
|
|
521
|
+
kctl-react compliance api-health sfa
|
|
522
|
+
kctl-react compliance api-health sfa --url https://api.kodeme.io
|
|
523
|
+
kctl-react compliance api-health --all --url http://localhost:8069
|
|
524
|
+
kctl-react compliance api-health sfa --timeout 5
|
|
525
|
+
kctl-react compliance api-health sfa --token eyJ...
|
|
526
|
+
"""
|
|
527
|
+
actx: AppContext = ctx.obj
|
|
528
|
+
out = actx.output
|
|
529
|
+
root = actx.project_root
|
|
530
|
+
|
|
531
|
+
if not app_name and not all_apps:
|
|
532
|
+
out.error("Specify an app name or use --all")
|
|
533
|
+
raise typer.Exit(1) from None
|
|
534
|
+
|
|
535
|
+
cat_list = _parse_categories(categories)
|
|
536
|
+
|
|
537
|
+
if all_apps:
|
|
538
|
+
reports: list[AppReport] = []
|
|
539
|
+
for name in actx.app_names:
|
|
540
|
+
app_path = get_app_dir(root, name)
|
|
541
|
+
report = _run_api_health_for_app(
|
|
542
|
+
app_path,
|
|
543
|
+
name,
|
|
544
|
+
url=url,
|
|
545
|
+
timeout=timeout,
|
|
546
|
+
token=token,
|
|
547
|
+
cat_list=cat_list,
|
|
548
|
+
)
|
|
549
|
+
if report is None:
|
|
550
|
+
out.warn(f"No OpenAPI schema available for {name}")
|
|
551
|
+
continue
|
|
552
|
+
reports.append(report)
|
|
553
|
+
|
|
554
|
+
_audit_all(actx, reports)
|
|
555
|
+
|
|
556
|
+
if min_score is not None:
|
|
557
|
+
any_below = False
|
|
558
|
+
for r in reports:
|
|
559
|
+
pct = int(r.total_score / r.max_score * 100) if r.max_score > 0 else 100
|
|
560
|
+
if pct < min_score:
|
|
561
|
+
out.error(f"{r.app} scored {pct}% (below {min_score}%)")
|
|
562
|
+
any_below = True
|
|
563
|
+
if any_below:
|
|
564
|
+
raise typer.Exit(1) from None
|
|
565
|
+
else:
|
|
566
|
+
assert app_name is not None
|
|
567
|
+
actx.validate_app(app_name)
|
|
568
|
+
app_path = get_app_dir(root, app_name)
|
|
569
|
+
report = _run_api_health_for_app(
|
|
570
|
+
app_path,
|
|
571
|
+
app_name,
|
|
572
|
+
url=url,
|
|
573
|
+
timeout=timeout,
|
|
574
|
+
token=token,
|
|
575
|
+
cat_list=cat_list,
|
|
576
|
+
)
|
|
577
|
+
if report is None:
|
|
578
|
+
out.warn(f"No OpenAPI schema available for {app_name}")
|
|
579
|
+
raise typer.Exit(0) from None
|
|
580
|
+
|
|
581
|
+
_audit_single(actx, report)
|
|
582
|
+
|
|
583
|
+
if min_score is not None:
|
|
584
|
+
pct = int(report.total_score / report.max_score * 100) if report.max_score > 0 else 100
|
|
585
|
+
if pct < min_score:
|
|
586
|
+
out.error(f"{report.app} scored {pct}% (below {min_score}%)")
|
|
587
|
+
raise typer.Exit(1) from None
|