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