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,76 @@
|
|
|
1
|
+
"""Top-level clean command group."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from kctl_react.core.callbacks import AppContext
|
|
11
|
+
from kctl_react.core.discovery import get_app_dir
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Clean build artifacts and caches.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_clean(actx: AppContext, app_name: str | None = None, all_: bool = False) -> None:
|
|
17
|
+
"""Clean dist, .turbo, and coverage directories."""
|
|
18
|
+
out = actx.output
|
|
19
|
+
root = actx.project_root
|
|
20
|
+
|
|
21
|
+
if app_name:
|
|
22
|
+
actx.validate_app(app_name)
|
|
23
|
+
|
|
24
|
+
apps = [app_name] if app_name else actx.app_names
|
|
25
|
+
removed = 0
|
|
26
|
+
|
|
27
|
+
for name in apps:
|
|
28
|
+
app_dir = get_app_dir(root, name)
|
|
29
|
+
for dirname in ("dist", ".next", ".turbo", "coverage"):
|
|
30
|
+
target = app_dir / dirname
|
|
31
|
+
if target.is_dir():
|
|
32
|
+
shutil.rmtree(target)
|
|
33
|
+
removed += 1
|
|
34
|
+
|
|
35
|
+
for dirname in (".turbo",):
|
|
36
|
+
target = root / dirname
|
|
37
|
+
if target.is_dir():
|
|
38
|
+
shutil.rmtree(target)
|
|
39
|
+
removed += 1
|
|
40
|
+
|
|
41
|
+
packages_dir = root / "packages"
|
|
42
|
+
if packages_dir.is_dir():
|
|
43
|
+
for pkg_dir in packages_dir.iterdir():
|
|
44
|
+
for dirname in ("dist", ".turbo"):
|
|
45
|
+
target = pkg_dir / dirname
|
|
46
|
+
if target.is_dir():
|
|
47
|
+
shutil.rmtree(target)
|
|
48
|
+
removed += 1
|
|
49
|
+
|
|
50
|
+
if all_:
|
|
51
|
+
nm = root / "node_modules"
|
|
52
|
+
if nm.is_dir():
|
|
53
|
+
out.info("Removing node_modules (this may take a moment)...")
|
|
54
|
+
shutil.rmtree(nm)
|
|
55
|
+
removed += 1
|
|
56
|
+
|
|
57
|
+
out.success(f"Cleaned {removed} directories")
|
|
58
|
+
if all_:
|
|
59
|
+
out.info("Run `pnpm install` to reinstall dependencies")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command("run")
|
|
63
|
+
def run(
|
|
64
|
+
ctx: typer.Context,
|
|
65
|
+
app_name: Annotated[str | None, typer.Argument(help="App name (omit for all)")] = None,
|
|
66
|
+
all_: Annotated[bool, typer.Option("--all", "-a", help="Also remove node_modules.")] = False,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Clean dist, .turbo, and coverage directories.
|
|
69
|
+
|
|
70
|
+
Examples:
|
|
71
|
+
kctl-react clean run # Clean all apps
|
|
72
|
+
kctl-react clean run sfa # Clean SFA only
|
|
73
|
+
kctl-react clean run --all # Clean + remove node_modules
|
|
74
|
+
"""
|
|
75
|
+
actx: AppContext = ctx.obj
|
|
76
|
+
run_clean(actx, app_name=app_name, all_=all_)
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""OpenAPI code generation commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
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.runner import run_pnpm
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(help="OpenAPI schema fetch and type generation.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.callback(invoke_without_command=True)
|
|
19
|
+
def codegen(
|
|
20
|
+
ctx: typer.Context,
|
|
21
|
+
app_name: Annotated[str | None, typer.Option("--app", "-a", help="App name (omit for all apps)")] = None,
|
|
22
|
+
module: Annotated[
|
|
23
|
+
str | None,
|
|
24
|
+
typer.Option("--module", "-m", help="Regenerate a single module (erp app only). Requires --app erp."),
|
|
25
|
+
] = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Fetch OpenAPI schema and regenerate TypeScript types.
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
kctl-react codegen --app sfa # Generate types for SFA
|
|
31
|
+
kctl-react codegen --app erp # Generate types for the erp app
|
|
32
|
+
kctl-react codegen --app erp --module sfa # Regenerate erp/sfa module types only
|
|
33
|
+
kctl-react codegen # Generate types for all apps
|
|
34
|
+
"""
|
|
35
|
+
if ctx.invoked_subcommand is not None:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
actx: AppContext = ctx.obj
|
|
39
|
+
out = actx.output
|
|
40
|
+
root = actx.project_root
|
|
41
|
+
|
|
42
|
+
# --module requires --app erp
|
|
43
|
+
if module is not None:
|
|
44
|
+
if app_name is None:
|
|
45
|
+
out.error("--module requires --app erp")
|
|
46
|
+
raise typer.Exit(1)
|
|
47
|
+
if app_name != "erp":
|
|
48
|
+
out.error(f"--module is only supported for the erp app, not '{app_name}'")
|
|
49
|
+
raise typer.Exit(1)
|
|
50
|
+
|
|
51
|
+
actx.validate_app("erp")
|
|
52
|
+
erp_dir = get_app_dir(root, "erp")
|
|
53
|
+
config_path = erp_dir / "src" / "modules" / module / "module.openapi.ts"
|
|
54
|
+
|
|
55
|
+
if not config_path.exists():
|
|
56
|
+
out.error(
|
|
57
|
+
f"Module '{module}' does not have a codegen config yet. "
|
|
58
|
+
f"Add src/modules/{module}/module.openapi.ts first."
|
|
59
|
+
)
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
|
|
62
|
+
out.info(f"Regenerating types for erp/{module}...")
|
|
63
|
+
try:
|
|
64
|
+
subprocess.run(
|
|
65
|
+
["pnpm", "--filter", "@kodemeio/erp", "exec", "openapi-ts", "--config", str(config_path)],
|
|
66
|
+
cwd=root,
|
|
67
|
+
timeout=60,
|
|
68
|
+
check=True,
|
|
69
|
+
)
|
|
70
|
+
out.success(f"erp/{module}: types generated")
|
|
71
|
+
except subprocess.CalledProcessError as e:
|
|
72
|
+
out.error(f"erp/{module}: codegen failed — {e}")
|
|
73
|
+
raise typer.Exit(1) from None
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
apps_to_gen = [app_name] if app_name else actx.app_names
|
|
77
|
+
if app_name:
|
|
78
|
+
actx.validate_app(app_name)
|
|
79
|
+
|
|
80
|
+
succeeded = 0
|
|
81
|
+
failed = 0
|
|
82
|
+
|
|
83
|
+
for name in apps_to_gen:
|
|
84
|
+
app_dir = get_app_dir(root, name)
|
|
85
|
+
|
|
86
|
+
if not (app_dir / "openapi-ts.config.ts").exists():
|
|
87
|
+
out.warn(f"{name}: no openapi-ts.config.ts, skipping")
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
out.info(f"Generating types for {name}...")
|
|
91
|
+
try:
|
|
92
|
+
run_pnpm(["generate:api"], cwd=app_dir, timeout=60)
|
|
93
|
+
out.success(f"{name}: types generated")
|
|
94
|
+
succeeded += 1
|
|
95
|
+
except Exception as e:
|
|
96
|
+
out.error(f"{name}: codegen failed — {e}")
|
|
97
|
+
failed += 1
|
|
98
|
+
|
|
99
|
+
if failed:
|
|
100
|
+
out.warn(f"Codegen: {succeeded} succeeded, {failed} failed")
|
|
101
|
+
raise typer.Exit(1) from None
|
|
102
|
+
|
|
103
|
+
out.success(f"Codegen complete: {succeeded} app(s)")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command()
|
|
107
|
+
def status(ctx: typer.Context) -> None:
|
|
108
|
+
"""Show codegen setup status for each app."""
|
|
109
|
+
actx: AppContext = ctx.obj
|
|
110
|
+
out = actx.output
|
|
111
|
+
root = actx.project_root
|
|
112
|
+
|
|
113
|
+
rows: list[list[str]] = []
|
|
114
|
+
json_data: list[dict] = []
|
|
115
|
+
|
|
116
|
+
def icon(ok: bool) -> str:
|
|
117
|
+
return "[green]OK[/green]" if ok else "[red]--[/red]"
|
|
118
|
+
|
|
119
|
+
for name in actx.app_names:
|
|
120
|
+
app_dir = get_app_dir(root, name)
|
|
121
|
+
has_config = (app_dir / "openapi-ts.config.ts").exists()
|
|
122
|
+
has_generated = (app_dir / "src" / "generated").is_dir()
|
|
123
|
+
has_types_api = (app_dir / "src" / "types" / "api.ts").exists()
|
|
124
|
+
|
|
125
|
+
rows.append([name, icon(has_config), icon(has_generated), icon(has_types_api)])
|
|
126
|
+
json_data.append(
|
|
127
|
+
{
|
|
128
|
+
"app": name,
|
|
129
|
+
"config": has_config,
|
|
130
|
+
"generated": has_generated,
|
|
131
|
+
"types_api": has_types_api,
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
out.table(
|
|
136
|
+
"OpenAPI Codegen Status",
|
|
137
|
+
[("App", "cyan"), ("Config", ""), ("Generated", ""), ("types/api.ts", "")],
|
|
138
|
+
rows,
|
|
139
|
+
data_for_json=json_data,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.command()
|
|
144
|
+
def diff(
|
|
145
|
+
ctx: typer.Context,
|
|
146
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Show what types changed after regenerating OpenAPI types.
|
|
149
|
+
|
|
150
|
+
Runs codegen, then shows git diff of the generated files.
|
|
151
|
+
"""
|
|
152
|
+
import subprocess
|
|
153
|
+
|
|
154
|
+
actx: AppContext = ctx.obj
|
|
155
|
+
out = actx.output
|
|
156
|
+
root = actx.project_root
|
|
157
|
+
|
|
158
|
+
actx.validate_app(app_name)
|
|
159
|
+
app_dir = get_app_dir(root, app_name)
|
|
160
|
+
gen_dir = app_dir / "src" / "generated"
|
|
161
|
+
|
|
162
|
+
if not (app_dir / "openapi-ts.config.ts").exists():
|
|
163
|
+
out.error(f"{app_name}: no openapi-ts.config.ts")
|
|
164
|
+
raise typer.Exit(1)
|
|
165
|
+
|
|
166
|
+
out.info(f"Regenerating types for {app_name}...")
|
|
167
|
+
try:
|
|
168
|
+
run_pnpm(["generate:api"], cwd=app_dir, timeout=60)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
out.error(f"Codegen failed: {e}")
|
|
171
|
+
raise typer.Exit(1) from None
|
|
172
|
+
|
|
173
|
+
# Show git diff of generated files
|
|
174
|
+
out.info("Changes in generated types:")
|
|
175
|
+
try:
|
|
176
|
+
result = subprocess.run(
|
|
177
|
+
["git", "diff", "--stat", str(gen_dir)],
|
|
178
|
+
cwd=root,
|
|
179
|
+
capture_output=True,
|
|
180
|
+
text=True,
|
|
181
|
+
timeout=10,
|
|
182
|
+
)
|
|
183
|
+
if result.stdout.strip():
|
|
184
|
+
out.text(result.stdout)
|
|
185
|
+
# Also show detailed diff
|
|
186
|
+
detail = subprocess.run(
|
|
187
|
+
["git", "diff", str(gen_dir)],
|
|
188
|
+
cwd=root,
|
|
189
|
+
capture_output=True,
|
|
190
|
+
text=True,
|
|
191
|
+
timeout=10,
|
|
192
|
+
)
|
|
193
|
+
if detail.stdout.strip():
|
|
194
|
+
# Count additions/deletions
|
|
195
|
+
adds = sum(
|
|
196
|
+
1 for line in detail.stdout.splitlines() if line.startswith("+") and not line.startswith("+++")
|
|
197
|
+
)
|
|
198
|
+
dels = sum(
|
|
199
|
+
1 for line in detail.stdout.splitlines() if line.startswith("-") and not line.startswith("---")
|
|
200
|
+
)
|
|
201
|
+
out.success(f"{adds} addition(s), {dels} deletion(s)")
|
|
202
|
+
else:
|
|
203
|
+
out.success("No changes — types are up to date")
|
|
204
|
+
except Exception:
|
|
205
|
+
out.warn("Could not compute diff (not in git repo?)")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.command()
|
|
209
|
+
def endpoints(
|
|
210
|
+
ctx: typer.Context,
|
|
211
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
212
|
+
) -> None:
|
|
213
|
+
"""List API endpoints from the app's generated types.
|
|
214
|
+
|
|
215
|
+
Parses the generated types.gen.ts to extract endpoint paths.
|
|
216
|
+
"""
|
|
217
|
+
import re
|
|
218
|
+
|
|
219
|
+
actx: AppContext = ctx.obj
|
|
220
|
+
out = actx.output
|
|
221
|
+
root = actx.project_root
|
|
222
|
+
|
|
223
|
+
actx.validate_app(app_name)
|
|
224
|
+
app_dir = get_app_dir(root, app_name)
|
|
225
|
+
|
|
226
|
+
# Look for generated types file
|
|
227
|
+
types_file = app_dir / "src" / "generated" / "types.gen.ts"
|
|
228
|
+
if not types_file.exists():
|
|
229
|
+
out.error(f"No generated types at {types_file.relative_to(root)}")
|
|
230
|
+
out.info("Run `kctl-react codegen` first")
|
|
231
|
+
raise typer.Exit(1) from None
|
|
232
|
+
|
|
233
|
+
content = types_file.read_text()
|
|
234
|
+
|
|
235
|
+
# Extract endpoint paths from type names (e.g., GetCustomersResponse, PostOrdersData)
|
|
236
|
+
# Look for patterns like '/api/v1/customers/' in the file
|
|
237
|
+
path_pattern = re.compile(r"""['"](/[a-z0-9/_-]+/?)['"]""", re.IGNORECASE)
|
|
238
|
+
paths = sorted(set(path_pattern.findall(content)))
|
|
239
|
+
|
|
240
|
+
# Also extract operation types (e.g., GetSfaCustomersListResponse)
|
|
241
|
+
type_pattern = re.compile(r"export\s+type\s+(\w+(?:Response|Data|Error))\b")
|
|
242
|
+
types = sorted(set(type_pattern.findall(content)))
|
|
243
|
+
|
|
244
|
+
rows: list[list[str]] = []
|
|
245
|
+
json_data: list[dict] = []
|
|
246
|
+
|
|
247
|
+
if paths:
|
|
248
|
+
out.header("API Endpoints")
|
|
249
|
+
for path in paths:
|
|
250
|
+
rows.append([path])
|
|
251
|
+
json_data.append({"path": path})
|
|
252
|
+
out.table(
|
|
253
|
+
f"Endpoints: {app_name}",
|
|
254
|
+
[("Path", "cyan")],
|
|
255
|
+
rows,
|
|
256
|
+
data_for_json=json_data,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if types:
|
|
260
|
+
out.header("Generated Types")
|
|
261
|
+
type_rows: list[list[str]] = []
|
|
262
|
+
for t in types[:30]: # Limit to 30
|
|
263
|
+
kind = "Response" if "Response" in t else ("Data" if "Data" in t else "Error")
|
|
264
|
+
type_rows.append([t, kind])
|
|
265
|
+
out.table(
|
|
266
|
+
f"Types: {app_name} ({len(types)} total)",
|
|
267
|
+
[("Type", "cyan"), ("Kind", "dim")],
|
|
268
|
+
type_rows,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
out.success(f"{len(paths)} endpoint(s), {len(types)} type(s) found")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@app.command()
|
|
275
|
+
def verify(
|
|
276
|
+
ctx: typer.Context,
|
|
277
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
278
|
+
) -> None:
|
|
279
|
+
"""Verify generated types are properly wired.
|
|
280
|
+
|
|
281
|
+
Checks that types.gen.ts exists, src/types/api.ts re-exports from @/generated/,
|
|
282
|
+
and no .ts/.tsx files import directly from @/generated/ (should use @/types/api).
|
|
283
|
+
"""
|
|
284
|
+
actx: AppContext = ctx.obj
|
|
285
|
+
out = actx.output
|
|
286
|
+
root = actx.project_root
|
|
287
|
+
|
|
288
|
+
actx.validate_app(app_name)
|
|
289
|
+
app_dir = get_app_dir(root, app_name)
|
|
290
|
+
src_dir = app_dir / "src"
|
|
291
|
+
|
|
292
|
+
issues: list[list[str]] = []
|
|
293
|
+
json_data: list[dict] = []
|
|
294
|
+
|
|
295
|
+
# Check 1: types.gen.ts exists
|
|
296
|
+
types_gen = src_dir / "generated" / "types.gen.ts"
|
|
297
|
+
if not types_gen.exists():
|
|
298
|
+
issues.append(["types.gen.ts", "missing", str(types_gen.relative_to(root))])
|
|
299
|
+
json_data.append({"check": "types.gen.ts", "status": "missing", "detail": str(types_gen.relative_to(root))})
|
|
300
|
+
else:
|
|
301
|
+
json_data.append({"check": "types.gen.ts", "status": "ok", "detail": ""})
|
|
302
|
+
|
|
303
|
+
# Check 2: src/types/api.ts exists and re-exports from @/generated/
|
|
304
|
+
types_api = src_dir / "types" / "api.ts"
|
|
305
|
+
if not types_api.exists():
|
|
306
|
+
issues.append(["src/types/api.ts", "missing", "file not found"])
|
|
307
|
+
json_data.append({"check": "src/types/api.ts", "status": "missing", "detail": "file not found"})
|
|
308
|
+
else:
|
|
309
|
+
content = types_api.read_text()
|
|
310
|
+
if "@/generated/" not in content:
|
|
311
|
+
issues.append(["src/types/api.ts", "no re-export", "does not import from @/generated/"])
|
|
312
|
+
json_data.append(
|
|
313
|
+
{"check": "src/types/api.ts", "status": "no re-export", "detail": "does not import from @/generated/"}
|
|
314
|
+
)
|
|
315
|
+
else:
|
|
316
|
+
json_data.append({"check": "src/types/api.ts", "status": "ok", "detail": ""})
|
|
317
|
+
|
|
318
|
+
# Check 3: scan all .ts/.tsx files for direct imports from @/generated/
|
|
319
|
+
direct_import_pattern = re.compile(r"""from\s+["']@/generated/""")
|
|
320
|
+
direct_violations: list[str] = []
|
|
321
|
+
if src_dir.exists():
|
|
322
|
+
for ts_file in list(src_dir.rglob("*.ts")) + list(src_dir.rglob("*.tsx")):
|
|
323
|
+
# Skip the generated files themselves and types/api.ts
|
|
324
|
+
rel = ts_file.relative_to(src_dir)
|
|
325
|
+
if str(rel).startswith("generated/") or str(rel) == "types/api.ts":
|
|
326
|
+
continue
|
|
327
|
+
try:
|
|
328
|
+
file_content = ts_file.read_text(encoding="utf-8", errors="replace")
|
|
329
|
+
except OSError:
|
|
330
|
+
continue
|
|
331
|
+
if direct_import_pattern.search(file_content):
|
|
332
|
+
direct_violations.append(str(ts_file.relative_to(root)))
|
|
333
|
+
|
|
334
|
+
for path in direct_violations:
|
|
335
|
+
issues.append(["direct @/generated/ import", "violation", path])
|
|
336
|
+
json_data.append({"check": "direct @/generated/ import", "status": "violation", "detail": path})
|
|
337
|
+
|
|
338
|
+
if issues:
|
|
339
|
+
rows = [[i[0], f"[red]{i[1]}[/red]", i[2]] for i in issues]
|
|
340
|
+
out.table(
|
|
341
|
+
f"Verify: {app_name} — {len(issues)} issue(s)",
|
|
342
|
+
[("Check", "cyan"), ("Status", ""), ("Detail", "dim")],
|
|
343
|
+
rows,
|
|
344
|
+
data_for_json=json_data,
|
|
345
|
+
)
|
|
346
|
+
else:
|
|
347
|
+
out.success(f"{app_name}: all wiring checks passed")
|
|
348
|
+
if out.json_mode:
|
|
349
|
+
out.raw_json(json_data)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@app.command()
|
|
353
|
+
def drift(
|
|
354
|
+
ctx: typer.Context,
|
|
355
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Detect stale type references.
|
|
358
|
+
|
|
359
|
+
Reads exported names from types.gen.ts, then finds hooks/pages importing
|
|
360
|
+
types from @/types/api that no longer exist in the generated file.
|
|
361
|
+
"""
|
|
362
|
+
actx: AppContext = ctx.obj
|
|
363
|
+
out = actx.output
|
|
364
|
+
root = actx.project_root
|
|
365
|
+
|
|
366
|
+
actx.validate_app(app_name)
|
|
367
|
+
app_dir = get_app_dir(root, app_name)
|
|
368
|
+
src_dir = app_dir / "src"
|
|
369
|
+
|
|
370
|
+
types_gen = src_dir / "generated" / "types.gen.ts"
|
|
371
|
+
types_api = src_dir / "types" / "api.ts"
|
|
372
|
+
|
|
373
|
+
if not types_gen.exists():
|
|
374
|
+
out.error(f"{app_name}: types.gen.ts not found — run `kctl-react codegen {app_name}` first")
|
|
375
|
+
raise typer.Exit(1) from None
|
|
376
|
+
|
|
377
|
+
# Collect all exported type names from types.gen.ts
|
|
378
|
+
export_pattern = re.compile(r"export\s+(?:type|interface|enum|const|function|class)\s+(\w+)")
|
|
379
|
+
gen_exports: set[str] = set(export_pattern.findall(types_gen.read_text()))
|
|
380
|
+
|
|
381
|
+
# Collect re-exported names from types/api.ts
|
|
382
|
+
api_exports: set[str] = set()
|
|
383
|
+
if types_api.exists():
|
|
384
|
+
reexport_pattern = re.compile(r"export\s+(?:type\s+)?\{([^}]+)\}")
|
|
385
|
+
for match in reexport_pattern.finditer(types_api.read_text()):
|
|
386
|
+
for name in match.group(1).split(","):
|
|
387
|
+
name = name.strip().split(" as ")[0].strip()
|
|
388
|
+
if name:
|
|
389
|
+
api_exports.add(name)
|
|
390
|
+
|
|
391
|
+
# Scan hooks and pages for imports from @/types/api
|
|
392
|
+
import_from_api_pattern = re.compile(r"""import\s+(?:type\s+)?\{([^}]+)\}\s+from\s+["']@/types/api["']""")
|
|
393
|
+
stale_refs: list[dict] = []
|
|
394
|
+
|
|
395
|
+
for search_dir in [src_dir / "hooks", src_dir / "pages"]:
|
|
396
|
+
if not search_dir.exists():
|
|
397
|
+
continue
|
|
398
|
+
for ts_file in list(search_dir.rglob("*.ts")) + list(search_dir.rglob("*.tsx")):
|
|
399
|
+
try:
|
|
400
|
+
file_content = ts_file.read_text(encoding="utf-8", errors="replace")
|
|
401
|
+
except OSError:
|
|
402
|
+
continue
|
|
403
|
+
for match in import_from_api_pattern.finditer(file_content):
|
|
404
|
+
for name in match.group(1).split(","):
|
|
405
|
+
name = name.strip().split(" as ")[0].strip()
|
|
406
|
+
if name and name not in gen_exports and name not in api_exports:
|
|
407
|
+
stale_refs.append(
|
|
408
|
+
{
|
|
409
|
+
"type": name,
|
|
410
|
+
"file": str(ts_file.relative_to(root)),
|
|
411
|
+
"status": "stale",
|
|
412
|
+
}
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
rows: list[list[str]] = []
|
|
416
|
+
for ref in stale_refs:
|
|
417
|
+
rows.append([ref["type"], ref["file"], f"[red]{ref['status']}[/red]"])
|
|
418
|
+
|
|
419
|
+
if rows:
|
|
420
|
+
out.table(
|
|
421
|
+
f"Drift: {app_name} — {len(rows)} stale reference(s)",
|
|
422
|
+
[("Type", "cyan"), ("File", "dim"), ("Status", "")],
|
|
423
|
+
rows,
|
|
424
|
+
data_for_json=stale_refs,
|
|
425
|
+
)
|
|
426
|
+
else:
|
|
427
|
+
out.success(f"{app_name}: no stale type references detected")
|
|
428
|
+
if out.json_mode:
|
|
429
|
+
out.raw_json([])
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@app.command(name="schema-health")
|
|
433
|
+
def schema_health(
|
|
434
|
+
ctx: typer.Context,
|
|
435
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
436
|
+
) -> None:
|
|
437
|
+
"""Check OpenAPI codegen health for an app.
|
|
438
|
+
|
|
439
|
+
Verifies that openapi-ts.config.ts, src/generated/, types.gen.ts (non-empty),
|
|
440
|
+
and src/types/api.ts are all present, and extracts the schema URL from config.
|
|
441
|
+
"""
|
|
442
|
+
actx: AppContext = ctx.obj
|
|
443
|
+
out = actx.output
|
|
444
|
+
root = actx.project_root
|
|
445
|
+
|
|
446
|
+
actx.validate_app(app_name)
|
|
447
|
+
app_dir = get_app_dir(root, app_name)
|
|
448
|
+
src_dir = app_dir / "src"
|
|
449
|
+
|
|
450
|
+
checks: list[dict] = []
|
|
451
|
+
|
|
452
|
+
def _add(name: str, passed: bool, detail: str = "") -> None:
|
|
453
|
+
checks.append({"check": name, "passed": passed, "detail": detail})
|
|
454
|
+
|
|
455
|
+
# 1. openapi-ts.config.ts exists
|
|
456
|
+
config_file = app_dir / "openapi-ts.config.ts"
|
|
457
|
+
_add("openapi-ts.config.ts", config_file.exists())
|
|
458
|
+
|
|
459
|
+
# 2. src/generated/ directory exists
|
|
460
|
+
gen_dir = src_dir / "generated"
|
|
461
|
+
_add("src/generated/ directory", gen_dir.is_dir())
|
|
462
|
+
|
|
463
|
+
# 3. types.gen.ts is non-empty (>50 bytes)
|
|
464
|
+
types_gen = gen_dir / "types.gen.ts"
|
|
465
|
+
gen_size = types_gen.stat().st_size if types_gen.exists() else 0
|
|
466
|
+
_add("types.gen.ts non-empty (>50 bytes)", gen_size > 50, f"{gen_size} bytes")
|
|
467
|
+
|
|
468
|
+
# 4. src/types/api.ts exists
|
|
469
|
+
types_api = src_dir / "types" / "api.ts"
|
|
470
|
+
_add("src/types/api.ts", types_api.exists())
|
|
471
|
+
|
|
472
|
+
# 5. Extract schema URL from config
|
|
473
|
+
schema_url = ""
|
|
474
|
+
if config_file.exists():
|
|
475
|
+
config_text = config_file.read_text(encoding="utf-8", errors="replace")
|
|
476
|
+
url_match = re.search(r"""input:\s*["']([^"']+)["']""", config_text)
|
|
477
|
+
if url_match:
|
|
478
|
+
schema_url = url_match.group(1)
|
|
479
|
+
_add("schema URL configured", bool(schema_url), schema_url or "not found")
|
|
480
|
+
|
|
481
|
+
rows: list[list[str]] = []
|
|
482
|
+
for c in checks:
|
|
483
|
+
icon = "[green]PASS[/green]" if c["passed"] else "[red]FAIL[/red]"
|
|
484
|
+
rows.append([c["check"], icon, c["detail"]])
|
|
485
|
+
|
|
486
|
+
out.table(
|
|
487
|
+
f"Schema Health: {app_name}",
|
|
488
|
+
[("Check", "cyan"), ("Status", ""), ("Detail", "dim")],
|
|
489
|
+
rows,
|
|
490
|
+
data_for_json=checks,
|
|
491
|
+
)
|