kctl-react 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. kctl_react/__init__.py +3 -0
  2. kctl_react/__main__.py +5 -0
  3. kctl_react/cli.py +201 -0
  4. kctl_react/commands/__init__.py +0 -0
  5. kctl_react/commands/a11y.py +78 -0
  6. kctl_react/commands/affected.py +170 -0
  7. kctl_react/commands/apps.py +353 -0
  8. kctl_react/commands/build.py +376 -0
  9. kctl_react/commands/bundle_cmd.py +217 -0
  10. kctl_react/commands/cap.py +1465 -0
  11. kctl_react/commands/clean.py +76 -0
  12. kctl_react/commands/codegen.py +491 -0
  13. kctl_react/commands/compliance.py +587 -0
  14. kctl_react/commands/config_cmd.py +368 -0
  15. kctl_react/commands/dashboard.py +163 -0
  16. kctl_react/commands/deploy.py +318 -0
  17. kctl_react/commands/deps.py +792 -0
  18. kctl_react/commands/dev.py +96 -0
  19. kctl_react/commands/docker_cmd.py +73 -0
  20. kctl_react/commands/doctor.py +170 -0
  21. kctl_react/commands/e2e.py +343 -0
  22. kctl_react/commands/env.py +155 -0
  23. kctl_react/commands/i18n.py +310 -0
  24. kctl_react/commands/lint.py +306 -0
  25. kctl_react/commands/maintenance.py +308 -0
  26. kctl_react/commands/monitor_cmd.py +50 -0
  27. kctl_react/commands/observe.py +34 -0
  28. kctl_react/commands/packages.py +129 -0
  29. kctl_react/commands/perf.py +762 -0
  30. kctl_react/commands/pipeline.py +289 -0
  31. kctl_react/commands/pwa.py +193 -0
  32. kctl_react/commands/scaffold.py +323 -0
  33. kctl_react/commands/security.py +660 -0
  34. kctl_react/commands/skill_cmd.py +54 -0
  35. kctl_react/commands/state.py +254 -0
  36. kctl_react/commands/test_cmd.py +418 -0
  37. kctl_react/commands/ui_audit.py +889 -0
  38. kctl_react/core/__init__.py +0 -0
  39. kctl_react/core/analyzers.py +200 -0
  40. kctl_react/core/callbacks.py +70 -0
  41. kctl_react/core/compliance/__init__.py +3 -0
  42. kctl_react/core/compliance/api_check/__init__.py +3 -0
  43. kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
  44. kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
  45. kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
  46. kctl_react/core/compliance/api_check/checks/naming.py +60 -0
  47. kctl_react/core/compliance/api_check/checks/params.py +44 -0
  48. kctl_react/core/compliance/api_check/checks/requests.py +57 -0
  49. kctl_react/core/compliance/api_check/checks/types.py +55 -0
  50. kctl_react/core/compliance/api_check/hooks.py +133 -0
  51. kctl_react/core/compliance/api_check/matcher.py +55 -0
  52. kctl_react/core/compliance/api_check/schema.py +151 -0
  53. kctl_react/core/compliance/api_health/__init__.py +35 -0
  54. kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
  55. kctl_react/core/compliance/api_health/checks/auth.py +72 -0
  56. kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
  57. kctl_react/core/compliance/api_health/checks/response.py +55 -0
  58. kctl_react/core/compliance/api_health/checks/timing.py +38 -0
  59. kctl_react/core/compliance/api_health/client.py +99 -0
  60. kctl_react/core/compliance/api_health/sampler.py +16 -0
  61. kctl_react/core/compliance/checks/__init__.py +47 -0
  62. kctl_react/core/compliance/checks/api.py +101 -0
  63. kctl_react/core/compliance/checks/codegen.py +94 -0
  64. kctl_react/core/compliance/checks/darkmode.py +57 -0
  65. kctl_react/core/compliance/checks/errors.py +68 -0
  66. kctl_react/core/compliance/checks/features.py +66 -0
  67. kctl_react/core/compliance/checks/i18n_check.py +105 -0
  68. kctl_react/core/compliance/checks/imports.py +86 -0
  69. kctl_react/core/compliance/checks/navigation.py +62 -0
  70. kctl_react/core/compliance/checks/practices.py +122 -0
  71. kctl_react/core/compliance/checks/providers.py +85 -0
  72. kctl_react/core/compliance/checks/pwa.py +101 -0
  73. kctl_react/core/compliance/checks/responsive.py +47 -0
  74. kctl_react/core/compliance/checks/scripts.py +85 -0
  75. kctl_react/core/compliance/checks/shadcn.py +51 -0
  76. kctl_react/core/compliance/checks/structure.py +76 -0
  77. kctl_react/core/compliance/checks/testing.py +83 -0
  78. kctl_react/core/compliance/checks/theme.py +92 -0
  79. kctl_react/core/compliance/checks/ui_standard.py +185 -0
  80. kctl_react/core/compliance/checks/vite.py +83 -0
  81. kctl_react/core/compliance/engine.py +87 -0
  82. kctl_react/core/compliance/exceptions_map.py +15 -0
  83. kctl_react/core/compliance/fixes/__init__.py +33 -0
  84. kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
  85. kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
  86. kctl_react/core/compliance/fixes/imports_fix.py +36 -0
  87. kctl_react/core/compliance/fixes/structure_fix.py +20 -0
  88. kctl_react/core/compliance/fixes/theme_fix.py +29 -0
  89. kctl_react/core/compliance/models.py +106 -0
  90. kctl_react/core/config.py +201 -0
  91. kctl_react/core/discovery.py +185 -0
  92. kctl_react/core/exceptions.py +17 -0
  93. kctl_react/core/git.py +146 -0
  94. kctl_react/core/history.py +121 -0
  95. kctl_react/core/output.py +5 -0
  96. kctl_react/core/plugins.py +13 -0
  97. kctl_react/core/runner.py +34 -0
  98. kctl_react/py.typed +0 -0
  99. kctl_react-0.6.2.dist-info/METADATA +17 -0
  100. kctl_react-0.6.2.dist-info/RECORD +102 -0
  101. kctl_react-0.6.2.dist-info/WHEEL +4 -0
  102. kctl_react-0.6.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,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
+ )