create-caspian-app 1.8.0 → 1.8.2

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