create-caspian-app 1.5.8 → 1.6.0

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.
@@ -1,418 +1,418 @@
1
- """App-level quality gate: type check + lint + template lint + tests in one command.
2
-
3
- Runs the four app-owned checks against `main.py`, `src/**`, and authored markup,
4
- then prints a single, AI-friendly list of problems as `path:line:col` with the
5
- message, so an agent (or a human) is told exactly which file and location to fix.
6
-
7
- The `templates` check covers `.html` templates and the markup inside single-file
8
- Python components -- the surface the other three tools cannot see. See
9
- `check_templates.py` for why that gap mattered.
10
-
11
- Usage (from the project root):
12
-
13
- python settings/check.py # run everything (the gate)
14
- python settings/check.py --only pyright # run one tool while debugging
15
-
16
- Exit code is 0 only when every selected check passes, so it works as a CI /
17
- pre-commit gate. Prefer `npm run check` for day-to-day use.
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import argparse
23
- import itertools
24
- import json
25
- import os
26
- import subprocess
27
- import sys
28
- import threading
29
- import time
30
- from dataclasses import dataclass, field
31
- from pathlib import Path
32
-
33
- import _component_imports as ci
34
- import browser_log as bl
35
- import check_templates as ct
36
-
37
- PROJECT_ROOT = Path(__file__).resolve().parents[1]
38
-
39
-
40
- def _is_component_import_false_positive(issue: Issue) -> bool:
41
- # Keep the gate honest for `<x-*>` tags: a component imports its children and
42
- # uses them only as tags in a template string ruff can't parse, so ruff
43
- # reports the import as F401. Those are load-bearing (see _component_imports),
44
- # so drop the report; genuinely dead imports still fail.
45
- if issue.tool != "ruff" or issue.code != "F401":
46
- return False
47
- return ci.is_component_tag_f401(issue.message, issue.path)
48
-
49
-
50
- # Terminal colors (disabled automatically when output is not a TTY).
51
- _TTY = sys.stdout.isatty()
52
-
53
- # On Windows a redirected stdout defaults to cp1252, which can't encode some
54
- # characters; ask for UTF-8 with a safe fallback so output never crashes.
55
- try:
56
- sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
57
- except AttributeError, ValueError:
58
- pass
59
-
60
-
61
- def _c(code: str, text: str) -> str:
62
- return f"\033[{code}m{text}\033[0m" if _TTY else text
63
-
64
-
65
- def red(t: str) -> str:
66
- return _c("31", t)
67
-
68
-
69
- def green(t: str) -> str:
70
- return _c("32", t)
71
-
72
-
73
- def yellow(t: str) -> str:
74
- return _c("33", t)
75
-
76
-
77
- def bold(t: str) -> str:
78
- return _c("1", t)
79
-
80
-
81
- def cyan(t: str) -> str:
82
- return _c("36", t)
83
-
84
-
85
- class _Heartbeat:
86
- """Live "still working" indicator for a captured (non-streaming) tool.
87
-
88
- Tools like pyright/ruff emit one JSON blob only when they finish, so without
89
- this the terminal looks frozen while they run. On a TTY a background thread
90
- ticks a spinner + elapsed seconds on one line; off a TTY (CI/pipe) it prints
91
- a single start line instead of spamming carriage returns.
92
- """
93
-
94
- def __init__(self, label: str) -> None:
95
- self.label = label
96
- self._stop = threading.Event()
97
- self._thread: threading.Thread | None = None
98
-
99
- def __enter__(self) -> "_Heartbeat":
100
- if _TTY:
101
- self._thread = threading.Thread(target=self._spin, daemon=True)
102
- self._thread.start()
103
- else:
104
- print(f" {cyan('>')} {self.label} ... (running)", flush=True)
105
- return self
106
-
107
- def _spin(self) -> None:
108
- start = time.perf_counter()
109
- for frame in itertools.cycle("|/-\\"):
110
- if self._stop.wait(0.4):
111
- return
112
- elapsed = time.perf_counter() - start
113
- sys.stdout.write(f"\r {cyan(frame)} {self.label} ... {elapsed:0.0f}s ")
114
- sys.stdout.flush()
115
-
116
- def __exit__(self, *exc: object) -> None:
117
- self._stop.set()
118
- if self._thread is not None:
119
- self._thread.join()
120
- if _TTY:
121
- # Wipe the spinner line so the result line prints cleanly over it.
122
- sys.stdout.write("\r" + " " * 48 + "\r")
123
- sys.stdout.flush()
124
-
125
-
126
- def _run_streamed(cmd: list[str]) -> subprocess.CompletedProcess[str]:
127
- """Run a tool and echo its output live while also capturing it.
128
-
129
- Used for pytest so each test's progress line appears as it happens instead
130
- of after a multi-minute silence. The captured text is still returned so the
131
- caller can parse `FAILED` lines from it.
132
- """
133
- env = {**os.environ, "PYTHONUNBUFFERED": "1"}
134
- proc = subprocess.Popen(
135
- cmd,
136
- cwd=PROJECT_ROOT,
137
- stdout=subprocess.PIPE,
138
- stderr=subprocess.STDOUT,
139
- text=True,
140
- encoding="utf-8",
141
- errors="replace",
142
- bufsize=1,
143
- env=env,
144
- )
145
- captured: list[str] = []
146
- assert proc.stdout is not None
147
- for line in proc.stdout:
148
- captured.append(line)
149
- sys.stdout.write(" " + line)
150
- sys.stdout.flush()
151
- proc.wait()
152
- return subprocess.CompletedProcess(cmd, proc.returncode, "".join(captured), "")
153
-
154
-
155
- @dataclass
156
- class Issue:
157
- path: str
158
- line: int
159
- column: int
160
- tool: str
161
- code: str
162
- message: str
163
-
164
- def location(self) -> str:
165
- return f"{self.path}:{self.line}:{self.column}"
166
-
167
-
168
- @dataclass
169
- class Result:
170
- tool: str
171
- ok: bool
172
- issues: list[Issue] = field(default_factory=list)
173
- note: str = ""
174
-
175
-
176
- def _run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
177
- return subprocess.run(
178
- cmd,
179
- cwd=PROJECT_ROOT,
180
- capture_output=True,
181
- text=True,
182
- encoding="utf-8",
183
- errors="replace",
184
- )
185
-
186
-
187
- def run_pyright() -> Result:
188
- # `--outputjson` emits a single JSON object the gate can parse; pyright
189
- # picks up its config (scope, mode) from `[tool.pyright]` in pyproject.toml.
190
- cmd = [sys.executable, "-m", "pyright", "--outputjson"]
191
- proc = _run(cmd)
192
-
193
- issues: list[Issue] = []
194
- try:
195
- data = json.loads(proc.stdout or "{}")
196
- except json.JSONDecodeError:
197
- # pyright failed to run (e.g. config error); surface stderr as a note.
198
- return Result("pyright", ok=False, note=proc.stderr.strip() or proc.stdout.strip())
199
-
200
- for diag in data.get("generalDiagnostics", []):
201
- if diag.get("severity") != "error":
202
- # warnings/information don't fail the gate, only `error` does.
203
- continue
204
- start = (diag.get("range") or {}).get("start") or {}
205
- issues.append(
206
- Issue(
207
- path=diag.get("file", "?"),
208
- # pyright ranges are 0-based; the gate reports 1-based.
209
- line=int(start.get("line", 0)) + 1,
210
- column=int(start.get("character", 0)) + 1,
211
- tool="pyright",
212
- code=diag.get("rule") or "type-error",
213
- message=diag.get("message", "type error"),
214
- )
215
- )
216
- return Result("pyright", ok=not issues, issues=issues)
217
-
218
-
219
- def run_ruff() -> Result:
220
- cmd = [sys.executable, "-m", "ruff", "check", ".", "--output-format", "json"]
221
- proc = _run(cmd)
222
-
223
- issues: list[Issue] = []
224
- try:
225
- data = json.loads(proc.stdout or "[]")
226
- except json.JSONDecodeError:
227
- return Result("ruff", ok=False, note=proc.stderr.strip() or proc.stdout.strip())
228
-
229
- for err in data:
230
- loc = err.get("location") or {}
231
- issue = Issue(
232
- path=err.get("filename", "?"),
233
- line=int(loc.get("row", 0)),
234
- column=int(loc.get("column", 0)),
235
- tool="ruff",
236
- code=err.get("code") or "lint",
237
- message=err.get("message", "lint error"),
238
- )
239
- # Drop F401 for imports that are actually used as `<x-*>` component tags
240
- # in the same file; keep genuinely dead imports so they still fail.
241
- if _is_component_import_false_positive(issue):
242
- continue
243
- issues.append(issue)
244
- return Result("ruff", ok=not issues, issues=issues)
245
-
246
-
247
- def run_templates() -> Result:
248
- """Lint authored markup for JSX and unsupported PulsePoint directives.
249
-
250
- pyright/ruff/pytest cover Python only, which left `.html` templates entirely
251
- unchecked. That is where the most expensive failure lives: an unquoted brace
252
- attribute is invalid HTML, so the component root never compiles and the route
253
- serves a blank page with no console error at all.
254
- """
255
- issues = [
256
- Issue(
257
- path=item.path,
258
- line=item.line,
259
- column=item.column,
260
- tool="templates",
261
- code=item.code,
262
- message=item.message,
263
- )
264
- for item in ct.lint_templates()
265
- ]
266
- return Result("templates", ok=not issues, issues=issues)
267
-
268
-
269
- def run_pytest() -> Result:
270
- # `-o addopts=` drops the ini `-q` so `-v` can print one live line per test
271
- # (the "which test is running" progress); `-rfE` keeps the `FAILED nodeid -
272
- # reason` summary lines this function parses below.
273
- cmd = [
274
- sys.executable,
275
- "-m",
276
- "pytest",
277
- "-o",
278
- "addopts=",
279
- "-v",
280
- "--no-header",
281
- "-rfE",
282
- ]
283
- proc = _run_streamed(cmd)
284
- ok = proc.returncode == 0
285
-
286
- issues: list[Issue] = []
287
- if not ok:
288
- # Pull the `FAILED path::test - reason` lines from pytest's summary.
289
- for line in (proc.stdout + proc.stderr).splitlines():
290
- stripped = line.strip()
291
- if stripped.startswith("FAILED "):
292
- body = stripped[len("FAILED ") :]
293
- nodeid, _, reason = body.partition(" - ")
294
- path, _, _ = nodeid.partition("::")
295
- issues.append(
296
- Issue(
297
- path=path.strip(),
298
- line=0,
299
- column=0,
300
- tool="pytest",
301
- code=nodeid.strip(),
302
- message=reason.strip() or "test failed",
303
- )
304
- )
305
- note = ""
306
- if not ok and not issues:
307
- # No parseable FAILED lines (e.g. a collection/import error) — keep the
308
- # last summary line so the failure is still visible.
309
- summary_lines = proc.stdout.strip().splitlines()
310
- note = summary_lines[-1] if summary_lines else "pytest failed"
311
- return Result("pytest", ok=ok, issues=issues, note=note)
312
-
313
-
314
- def print_report(results: list[Result]) -> bool:
315
- all_issues = [i for r in results for i in r.issues]
316
- print()
317
- print(bold("Caspian app checks"))
318
- print("=" * 60)
319
-
320
- for r in results:
321
- if r.ok:
322
- print(f" {green('PASS')} {r.tool}")
323
- else:
324
- count = len(r.issues)
325
- detail = f"{count} issue(s)" if count else (r.note or "failed")
326
- print(f" {red('FAIL')} {r.tool} ({detail})")
327
-
328
- if all_issues:
329
- print()
330
- print(bold(red("Issues to fix (file:line:col):")))
331
- print("-" * 60)
332
- # Group by file so the fix targets are obvious.
333
- by_file: dict[str, list[Issue]] = {}
334
- for issue in all_issues:
335
- by_file.setdefault(issue.path, []).append(issue)
336
- for path in sorted(by_file):
337
- print(yellow(path))
338
- for issue in sorted(by_file[path], key=lambda i: (i.line, i.column)):
339
- loc = f"{issue.line}:{issue.column}" if issue.line else "-"
340
- print(f" {loc:>8} [{issue.tool}:{issue.code}] {issue.message}")
341
-
342
- print()
343
- ok = all(r.ok for r in results)
344
- if ok:
345
- print(green(bold("All checks passed.")))
346
- else:
347
- print(red(bold(f"{len(all_issues)} issue(s) found. Fix the locations above.")))
348
- print()
349
- return ok
350
-
351
-
352
- def _execute(label: str, runner, *, streamed: bool) -> Result:
353
- """Run one tool with live progress, then print a one-line result."""
354
- start = time.perf_counter()
355
- if streamed:
356
- # The tool echoes its own progress live (e.g. pytest's per-test lines).
357
- print(f" {cyan('>')} {label} ... (live output below)", flush=True)
358
- result = runner()
359
- else:
360
- # Captured tool — show a ticking heartbeat so it never looks frozen.
361
- with _Heartbeat(label):
362
- result = runner()
363
- elapsed = time.perf_counter() - start
364
- mark = green("OK ") if result.ok else red("FAIL")
365
- count = len(result.issues)
366
- detail = (
367
- "" if result.ok else f" ({count} issue(s))" if count else f" ({result.note or 'failed'})"
368
- )
369
- print(f" {mark} {label} {elapsed:0.1f}s{detail}")
370
- return result
371
-
372
-
373
- def main() -> int:
374
- parser = argparse.ArgumentParser(description="Run app type check, lint, and tests.")
375
- parser.add_argument(
376
- "--only",
377
- action="append",
378
- choices=["pyright", "ruff", "templates", "pytest"],
379
- help="Run only the named tool(s). Repeatable. Default: all.",
380
- )
381
- parser.add_argument(
382
- "--no-browser",
383
- action="store_true",
384
- help="Skip the browser-log section (see settings/browser_log.py).",
385
- )
386
- args = parser.parse_args()
387
-
388
- selected = args.only or ["pyright", "ruff", "templates", "pytest"]
389
-
390
- print()
391
- print(bold("Caspian app checks") + " (live progress)")
392
- print("=" * 60)
393
-
394
- results: list[Result] = []
395
- if "pyright" in selected:
396
- results.append(_execute("pyright", run_pyright, streamed=False))
397
- if "ruff" in selected:
398
- results.append(_execute("ruff", run_ruff, streamed=False))
399
- if "templates" in selected:
400
- results.append(_execute("templates", run_templates, streamed=False))
401
- if "pytest" in selected:
402
- results.append(_execute("pytest", run_pytest, streamed=True))
403
-
404
- ok = print_report(results)
405
-
406
- # Browser status is reported, never enforced. The four tools above are
407
- # deterministic; whether a route has been exercised in a browser depends on
408
- # someone clicking around, so folding it into the exit code would make the
409
- # gate flaky and people would learn to ignore it. Printing it here is enough:
410
- # this is the command an agent already runs.
411
- if not args.no_browser:
412
- bl.print_report(bl.build_report())
413
-
414
- return 0 if ok else 1
415
-
416
-
417
- if __name__ == "__main__":
418
- raise SystemExit(main())
1
+ """App-level quality gate: type check + lint + template lint + tests in one command.
2
+
3
+ Runs the four app-owned checks against `main.py`, `src/**`, and authored markup,
4
+ then prints a single, AI-friendly list of problems as `path:line:col` with the
5
+ message, so an agent (or a human) is told exactly which file and location to fix.
6
+
7
+ The `templates` check covers `.html` templates and the markup inside single-file
8
+ Python components -- the surface the other three tools cannot see. See
9
+ `check_templates.py` for why that gap mattered.
10
+
11
+ Usage (from the project root):
12
+
13
+ python settings/check.py # run everything (the gate)
14
+ python settings/check.py --only pyright # run one tool while debugging
15
+
16
+ Exit code is 0 only when every selected check passes, so it works as a CI /
17
+ pre-commit gate. Prefer `npm run test` for day-to-day use.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import itertools
24
+ import json
25
+ import os
26
+ import subprocess
27
+ import sys
28
+ import threading
29
+ import time
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+
33
+ import _component_imports as ci
34
+ import browser_log as bl
35
+ import check_templates as ct
36
+
37
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
38
+
39
+
40
+ def _is_component_import_false_positive(issue: Issue) -> bool:
41
+ # Keep the gate honest for `<x-*>` tags: a component imports its children and
42
+ # uses them only as tags in a template string ruff can't parse, so ruff
43
+ # reports the import as F401. Those are load-bearing (see _component_imports),
44
+ # so drop the report; genuinely dead imports still fail.
45
+ if issue.tool != "ruff" or issue.code != "F401":
46
+ return False
47
+ return ci.is_component_tag_f401(issue.message, issue.path)
48
+
49
+
50
+ # Terminal colors (disabled automatically when output is not a TTY).
51
+ _TTY = sys.stdout.isatty()
52
+
53
+ # On Windows a redirected stdout defaults to cp1252, which can't encode some
54
+ # characters; ask for UTF-8 with a safe fallback so output never crashes.
55
+ try:
56
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
57
+ except AttributeError, ValueError:
58
+ pass
59
+
60
+
61
+ def _c(code: str, text: str) -> str:
62
+ return f"\033[{code}m{text}\033[0m" if _TTY else text
63
+
64
+
65
+ def red(t: str) -> str:
66
+ return _c("31", t)
67
+
68
+
69
+ def green(t: str) -> str:
70
+ return _c("32", t)
71
+
72
+
73
+ def yellow(t: str) -> str:
74
+ return _c("33", t)
75
+
76
+
77
+ def bold(t: str) -> str:
78
+ return _c("1", t)
79
+
80
+
81
+ def cyan(t: str) -> str:
82
+ return _c("36", t)
83
+
84
+
85
+ class _Heartbeat:
86
+ """Live "still working" indicator for a captured (non-streaming) tool.
87
+
88
+ Tools like pyright/ruff emit one JSON blob only when they finish, so without
89
+ this the terminal looks frozen while they run. On a TTY a background thread
90
+ ticks a spinner + elapsed seconds on one line; off a TTY (CI/pipe) it prints
91
+ a single start line instead of spamming carriage returns.
92
+ """
93
+
94
+ def __init__(self, label: str) -> None:
95
+ self.label = label
96
+ self._stop = threading.Event()
97
+ self._thread: threading.Thread | None = None
98
+
99
+ def __enter__(self) -> "_Heartbeat":
100
+ if _TTY:
101
+ self._thread = threading.Thread(target=self._spin, daemon=True)
102
+ self._thread.start()
103
+ else:
104
+ print(f" {cyan('>')} {self.label} ... (running)", flush=True)
105
+ return self
106
+
107
+ def _spin(self) -> None:
108
+ start = time.perf_counter()
109
+ for frame in itertools.cycle("|/-\\"):
110
+ if self._stop.wait(0.4):
111
+ return
112
+ elapsed = time.perf_counter() - start
113
+ sys.stdout.write(f"\r {cyan(frame)} {self.label} ... {elapsed:0.0f}s ")
114
+ sys.stdout.flush()
115
+
116
+ def __exit__(self, *exc: object) -> None:
117
+ self._stop.set()
118
+ if self._thread is not None:
119
+ self._thread.join()
120
+ if _TTY:
121
+ # Wipe the spinner line so the result line prints cleanly over it.
122
+ sys.stdout.write("\r" + " " * 48 + "\r")
123
+ sys.stdout.flush()
124
+
125
+
126
+ def _run_streamed(cmd: list[str]) -> subprocess.CompletedProcess[str]:
127
+ """Run a tool and echo its output live while also capturing it.
128
+
129
+ Used for pytest so each test's progress line appears as it happens instead
130
+ of after a multi-minute silence. The captured text is still returned so the
131
+ caller can parse `FAILED` lines from it.
132
+ """
133
+ env = {**os.environ, "PYTHONUNBUFFERED": "1"}
134
+ proc = subprocess.Popen(
135
+ cmd,
136
+ cwd=PROJECT_ROOT,
137
+ stdout=subprocess.PIPE,
138
+ stderr=subprocess.STDOUT,
139
+ text=True,
140
+ encoding="utf-8",
141
+ errors="replace",
142
+ bufsize=1,
143
+ env=env,
144
+ )
145
+ captured: list[str] = []
146
+ assert proc.stdout is not None
147
+ for line in proc.stdout:
148
+ captured.append(line)
149
+ sys.stdout.write(" " + line)
150
+ sys.stdout.flush()
151
+ proc.wait()
152
+ return subprocess.CompletedProcess(cmd, proc.returncode, "".join(captured), "")
153
+
154
+
155
+ @dataclass
156
+ class Issue:
157
+ path: str
158
+ line: int
159
+ column: int
160
+ tool: str
161
+ code: str
162
+ message: str
163
+
164
+ def location(self) -> str:
165
+ return f"{self.path}:{self.line}:{self.column}"
166
+
167
+
168
+ @dataclass
169
+ class Result:
170
+ tool: str
171
+ ok: bool
172
+ issues: list[Issue] = field(default_factory=list)
173
+ note: str = ""
174
+
175
+
176
+ def _run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
177
+ return subprocess.run(
178
+ cmd,
179
+ cwd=PROJECT_ROOT,
180
+ capture_output=True,
181
+ text=True,
182
+ encoding="utf-8",
183
+ errors="replace",
184
+ )
185
+
186
+
187
+ def run_pyright() -> Result:
188
+ # `--outputjson` emits a single JSON object the gate can parse; pyright
189
+ # picks up its config (scope, mode) from `[tool.pyright]` in pyproject.toml.
190
+ cmd = [sys.executable, "-m", "pyright", "--outputjson"]
191
+ proc = _run(cmd)
192
+
193
+ issues: list[Issue] = []
194
+ try:
195
+ data = json.loads(proc.stdout or "{}")
196
+ except json.JSONDecodeError:
197
+ # pyright failed to run (e.g. config error); surface stderr as a note.
198
+ return Result("pyright", ok=False, note=proc.stderr.strip() or proc.stdout.strip())
199
+
200
+ for diag in data.get("generalDiagnostics", []):
201
+ if diag.get("severity") != "error":
202
+ # warnings/information don't fail the gate, only `error` does.
203
+ continue
204
+ start = (diag.get("range") or {}).get("start") or {}
205
+ issues.append(
206
+ Issue(
207
+ path=diag.get("file", "?"),
208
+ # pyright ranges are 0-based; the gate reports 1-based.
209
+ line=int(start.get("line", 0)) + 1,
210
+ column=int(start.get("character", 0)) + 1,
211
+ tool="pyright",
212
+ code=diag.get("rule") or "type-error",
213
+ message=diag.get("message", "type error"),
214
+ )
215
+ )
216
+ return Result("pyright", ok=not issues, issues=issues)
217
+
218
+
219
+ def run_ruff() -> Result:
220
+ cmd = [sys.executable, "-m", "ruff", "check", ".", "--output-format", "json"]
221
+ proc = _run(cmd)
222
+
223
+ issues: list[Issue] = []
224
+ try:
225
+ data = json.loads(proc.stdout or "[]")
226
+ except json.JSONDecodeError:
227
+ return Result("ruff", ok=False, note=proc.stderr.strip() or proc.stdout.strip())
228
+
229
+ for err in data:
230
+ loc = err.get("location") or {}
231
+ issue = Issue(
232
+ path=err.get("filename", "?"),
233
+ line=int(loc.get("row", 0)),
234
+ column=int(loc.get("column", 0)),
235
+ tool="ruff",
236
+ code=err.get("code") or "lint",
237
+ message=err.get("message", "lint error"),
238
+ )
239
+ # Drop F401 for imports that are actually used as `<x-*>` component tags
240
+ # in the same file; keep genuinely dead imports so they still fail.
241
+ if _is_component_import_false_positive(issue):
242
+ continue
243
+ issues.append(issue)
244
+ return Result("ruff", ok=not issues, issues=issues)
245
+
246
+
247
+ def run_templates() -> Result:
248
+ """Lint authored markup for JSX and unsupported PulsePoint directives.
249
+
250
+ pyright/ruff/pytest cover Python only, which left `.html` templates entirely
251
+ unchecked. That is where the most expensive failure lives: an unquoted brace
252
+ attribute is invalid HTML, so the component root never compiles and the route
253
+ serves a blank page with no console error at all.
254
+ """
255
+ issues = [
256
+ Issue(
257
+ path=item.path,
258
+ line=item.line,
259
+ column=item.column,
260
+ tool="templates",
261
+ code=item.code,
262
+ message=item.message,
263
+ )
264
+ for item in ct.lint_templates()
265
+ ]
266
+ return Result("templates", ok=not issues, issues=issues)
267
+
268
+
269
+ def run_pytest() -> Result:
270
+ # `-o addopts=` drops the ini `-q` so `-v` can print one live line per test
271
+ # (the "which test is running" progress); `-rfE` keeps the `FAILED nodeid -
272
+ # reason` summary lines this function parses below.
273
+ cmd = [
274
+ sys.executable,
275
+ "-m",
276
+ "pytest",
277
+ "-o",
278
+ "addopts=",
279
+ "-v",
280
+ "--no-header",
281
+ "-rfE",
282
+ ]
283
+ proc = _run_streamed(cmd)
284
+ ok = proc.returncode == 0
285
+
286
+ issues: list[Issue] = []
287
+ if not ok:
288
+ # Pull the `FAILED path::test - reason` lines from pytest's summary.
289
+ for line in (proc.stdout + proc.stderr).splitlines():
290
+ stripped = line.strip()
291
+ if stripped.startswith("FAILED "):
292
+ body = stripped[len("FAILED ") :]
293
+ nodeid, _, reason = body.partition(" - ")
294
+ path, _, _ = nodeid.partition("::")
295
+ issues.append(
296
+ Issue(
297
+ path=path.strip(),
298
+ line=0,
299
+ column=0,
300
+ tool="pytest",
301
+ code=nodeid.strip(),
302
+ message=reason.strip() or "test failed",
303
+ )
304
+ )
305
+ note = ""
306
+ if not ok and not issues:
307
+ # No parseable FAILED lines (e.g. a collection/import error) — keep the
308
+ # last summary line so the failure is still visible.
309
+ summary_lines = proc.stdout.strip().splitlines()
310
+ note = summary_lines[-1] if summary_lines else "pytest failed"
311
+ return Result("pytest", ok=ok, issues=issues, note=note)
312
+
313
+
314
+ def print_report(results: list[Result]) -> bool:
315
+ all_issues = [i for r in results for i in r.issues]
316
+ print()
317
+ print(bold("Caspian app checks"))
318
+ print("=" * 60)
319
+
320
+ for r in results:
321
+ if r.ok:
322
+ print(f" {green('PASS')} {r.tool}")
323
+ else:
324
+ count = len(r.issues)
325
+ detail = f"{count} issue(s)" if count else (r.note or "failed")
326
+ print(f" {red('FAIL')} {r.tool} ({detail})")
327
+
328
+ if all_issues:
329
+ print()
330
+ print(bold(red("Issues to fix (file:line:col):")))
331
+ print("-" * 60)
332
+ # Group by file so the fix targets are obvious.
333
+ by_file: dict[str, list[Issue]] = {}
334
+ for issue in all_issues:
335
+ by_file.setdefault(issue.path, []).append(issue)
336
+ for path in sorted(by_file):
337
+ print(yellow(path))
338
+ for issue in sorted(by_file[path], key=lambda i: (i.line, i.column)):
339
+ loc = f"{issue.line}:{issue.column}" if issue.line else "-"
340
+ print(f" {loc:>8} [{issue.tool}:{issue.code}] {issue.message}")
341
+
342
+ print()
343
+ ok = all(r.ok for r in results)
344
+ if ok:
345
+ print(green(bold("All checks passed.")))
346
+ else:
347
+ print(red(bold(f"{len(all_issues)} issue(s) found. Fix the locations above.")))
348
+ print()
349
+ return ok
350
+
351
+
352
+ def _execute(label: str, runner, *, streamed: bool) -> Result:
353
+ """Run one tool with live progress, then print a one-line result."""
354
+ start = time.perf_counter()
355
+ if streamed:
356
+ # The tool echoes its own progress live (e.g. pytest's per-test lines).
357
+ print(f" {cyan('>')} {label} ... (live output below)", flush=True)
358
+ result = runner()
359
+ else:
360
+ # Captured tool — show a ticking heartbeat so it never looks frozen.
361
+ with _Heartbeat(label):
362
+ result = runner()
363
+ elapsed = time.perf_counter() - start
364
+ mark = green("OK ") if result.ok else red("FAIL")
365
+ count = len(result.issues)
366
+ detail = (
367
+ "" if result.ok else f" ({count} issue(s))" if count else f" ({result.note or 'failed'})"
368
+ )
369
+ print(f" {mark} {label} {elapsed:0.1f}s{detail}")
370
+ return result
371
+
372
+
373
+ def main() -> int:
374
+ parser = argparse.ArgumentParser(description="Run app type check, lint, and tests.")
375
+ parser.add_argument(
376
+ "--only",
377
+ action="append",
378
+ choices=["pyright", "ruff", "templates", "pytest"],
379
+ help="Run only the named tool(s). Repeatable. Default: all.",
380
+ )
381
+ parser.add_argument(
382
+ "--no-browser",
383
+ action="store_true",
384
+ help="Skip the browser-log section (see settings/browser_log.py).",
385
+ )
386
+ args = parser.parse_args()
387
+
388
+ selected = args.only or ["pyright", "ruff", "templates", "pytest"]
389
+
390
+ print()
391
+ print(bold("Caspian app checks") + " (live progress)")
392
+ print("=" * 60)
393
+
394
+ results: list[Result] = []
395
+ if "pyright" in selected:
396
+ results.append(_execute("pyright", run_pyright, streamed=False))
397
+ if "ruff" in selected:
398
+ results.append(_execute("ruff", run_ruff, streamed=False))
399
+ if "templates" in selected:
400
+ results.append(_execute("templates", run_templates, streamed=False))
401
+ if "pytest" in selected:
402
+ results.append(_execute("pytest", run_pytest, streamed=True))
403
+
404
+ ok = print_report(results)
405
+
406
+ # Browser status is reported, never enforced. The four tools above are
407
+ # deterministic; whether a route has been exercised in a browser depends on
408
+ # someone clicking around, so folding it into the exit code would make the
409
+ # gate flaky and people would learn to ignore it. Printing it here is enough:
410
+ # this is the command an agent already runs.
411
+ if not args.no_browser:
412
+ bl.print_report(bl.build_report())
413
+
414
+ return 0 if ok else 1
415
+
416
+
417
+ if __name__ == "__main__":
418
+ raise SystemExit(main())