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.
@@ -1,188 +1,188 @@
1
- """Robust static preview server for the exported `static/` build.
2
-
3
- Replaces the fragile ``python -m http.server ... 8000`` one-liner, which crashes
4
- with ``OSError: [Errno 98/10048] address already in use`` the moment port 8000 is
5
- taken (a second preview, another dev tool, XAMPP, etc.). This script instead:
6
-
7
- - serves ONLY the ``static/`` directory (no traversal outside it),
8
- - binds to loopback ``127.0.0.1`` by default so the preview is NOT exposed to
9
- the local network,
10
- - auto-selects a free port: it tries the preferred port and walks upward until
11
- one binds, so an occupied port never aborts the preview,
12
- - fails fast with a clear message if ``static/`` was never built,
13
- - shuts down cleanly on Ctrl+C.
14
-
15
- Config via environment (all optional):
16
- HOST bind address (default 127.0.0.1; use 0.0.0.0 to expose)
17
- PORT preferred start port (default 8000)
18
- PORT_TRIES how many ports to try (default 50)
19
-
20
- Run via ``npm run static:serve`` (after ``npm run static`` has produced static/).
21
- """
22
-
23
- from __future__ import annotations
24
-
25
- import errno
26
- import os
27
- import socket
28
- import sys
29
- from functools import partial
30
- from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
31
- from pathlib import Path
32
-
33
- ROOT = Path(__file__).resolve().parent.parent
34
- STATIC_DIR = ROOT / "static"
35
-
36
- DEFAULT_HOST = "127.0.0.1"
37
- DEFAULT_PORT = 8000
38
- DEFAULT_PORT_TRIES = 50
39
-
40
- GREEN = "\033[32m"
41
- YELLOW = "\033[33m"
42
- RED = "\033[31m"
43
- DIM = "\033[2m"
44
- RESET = "\033[0m"
45
-
46
- # Errnos that mean "this port is taken, try the next one" across platforms.
47
- # EADDRINUSE covers POSIX (98) and Windows WSAEADDRINUSE (10048);
48
- # EACCES/WSAEACCES can appear on Windows when a port is held by another owner.
49
- _PORT_TAKEN = {errno.EADDRINUSE, errno.EACCES}
50
- if hasattr(errno, "WSAEADDRINUSE"): # Windows-only alias
51
- _PORT_TAKEN.add(errno.WSAEADDRINUSE) # type: ignore[attr-defined]
52
-
53
-
54
- class SecureThreadingHTTPServer(ThreadingHTTPServer):
55
- """Threaded server with reliable "is this port really free?" semantics.
56
-
57
- ``allow_reuse_address`` maps to ``SO_REUSEADDR``. On Windows that flag lets a
58
- NEW socket bind a port another socket is ACTIVELY listening on, which would
59
- make our probe silently "succeed" onto an occupied port and collide. So we
60
- disable it here: bind() then genuinely fails on a taken port and our
61
- port-walk loop can move on. (On POSIX SO_REUSEADDR only affects TIME_WAIT
62
- sockets, but keeping it off is harmless and keeps behavior identical.)
63
- """
64
-
65
- allow_reuse_address = False
66
- daemon_threads = True
67
-
68
-
69
- class StaticRequestHandler(SimpleHTTPRequestHandler):
70
- """Serve static/ with light hardening and quieter, useful logging.
71
-
72
- SimpleHTTPRequestHandler already normalizes and confines paths to the served
73
- ``directory`` (no ``..`` traversal), so this only adds conservative response
74
- headers appropriate for a local static preview.
75
- """
76
-
77
- def end_headers(self) -> None:
78
- self.send_header("X-Content-Type-Options", "nosniff")
79
- self.send_header("Cache-Control", "no-store")
80
- super().end_headers()
81
-
82
- def log_message(self, format: str, *args) -> None:
83
- sys.stderr.write(f"{DIM} {self.address_string()} - {format % args}{RESET}\n")
84
-
85
-
86
- def _looks_built(static_dir: Path) -> bool:
87
- """True only if static/ exists and holds a real export (an index.html)."""
88
- return static_dir.is_dir() and (static_dir / "index.html").is_file()
89
-
90
-
91
- def _env_int(name: str, default: int) -> int:
92
- raw = os.environ.get(name, "").strip()
93
- if not raw:
94
- return default
95
- try:
96
- value = int(raw)
97
- except ValueError:
98
- print(f"{YELLOW} Ignoring invalid {name}={raw!r}; using {default}.{RESET}")
99
- return default
100
- return value
101
-
102
-
103
- def _serve(host: str, start_port: int, tries: int) -> int:
104
- handler = partial(StaticRequestHandler, directory=str(STATIC_DIR))
105
-
106
- last_error: OSError | None = None
107
- for offset in range(max(1, tries)):
108
- port = start_port + offset
109
- if port > 65535:
110
- break
111
- try:
112
- httpd = SecureThreadingHTTPServer((host, port), handler)
113
- except OSError as exc:
114
- if exc.errno in _PORT_TAKEN:
115
- last_error = exc
116
- if offset == 0:
117
- print(
118
- f"{YELLOW} Port {start_port} is busy; searching for a free "
119
- f"port...{RESET}"
120
- )
121
- continue
122
- # A different error (bad host, permissions, etc.) is not recoverable
123
- # by trying another port -- surface it.
124
- print(f"{RED} Failed to bind {host}:{port} -- {exc}{RESET}")
125
- return 1
126
-
127
- with httpd:
128
- shown_host = "localhost" if host in ("127.0.0.1", "0.0.0.0") else host
129
- url = f"http://{shown_host}:{port}"
130
- if port != start_port:
131
- print(
132
- f"{YELLOW} Default port {start_port} was occupied; "
133
- f"using {port} instead.{RESET}"
134
- )
135
- print(f"\n{GREEN}Serving{RESET} {DIM}{STATIC_DIR}{RESET}")
136
- print(f"{GREEN} -> {url}{RESET}")
137
- if host == "0.0.0.0":
138
- lan = _lan_hint(port)
139
- if lan:
140
- print(f"{DIM} -> {lan} (exposed on your network){RESET}")
141
- print(f"{DIM} Press Ctrl+C to stop.{RESET}\n")
142
- try:
143
- httpd.serve_forever()
144
- except KeyboardInterrupt:
145
- print(f"\n{DIM} Stopped.{RESET}")
146
- return 0
147
-
148
- ports = f"{start_port}-{start_port + max(1, tries) - 1}"
149
- print(
150
- f"{RED} Could not find a free port in {ports} on {host}.{RESET}\n"
151
- f"{DIM} Last error: {last_error}. Free a port or set PORT to another "
152
- f"start value.{RESET}"
153
- )
154
- return 1
155
-
156
-
157
- def _lan_hint(port: int) -> str | None:
158
- """Best-effort LAN URL to show only when the user opted into 0.0.0.0."""
159
- try:
160
- with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
161
- s.connect(("8.8.8.8", 80))
162
- ip = s.getsockname()[0]
163
- return f"http://{ip}:{port}"
164
- except OSError:
165
- return None
166
-
167
-
168
- def main() -> int:
169
- if not _looks_built(STATIC_DIR):
170
- print(
171
- f"{RED}No static build found at {STATIC_DIR}.{RESET}\n"
172
- f"{DIM} Build it first: {RESET}{GREEN}npm run static{RESET}"
173
- )
174
- return 1
175
-
176
- host = os.environ.get("HOST", "").strip() or DEFAULT_HOST
177
- start_port = _env_int("PORT", DEFAULT_PORT)
178
- tries = _env_int("PORT_TRIES", DEFAULT_PORT_TRIES)
179
-
180
- if not (1 <= start_port <= 65535):
181
- print(f"{RED} PORT must be 1-65535 (got {start_port}).{RESET}")
182
- return 1
183
-
184
- return _serve(host, start_port, tries)
185
-
186
-
187
- if __name__ == "__main__":
188
- raise SystemExit(main())
1
+ """Robust static preview server for the exported `static/` build.
2
+
3
+ Replaces the fragile ``python -m http.server ... 8000`` one-liner, which crashes
4
+ with ``OSError: [Errno 98/10048] address already in use`` the moment port 8000 is
5
+ taken (a second preview, another dev tool, XAMPP, etc.). This script instead:
6
+
7
+ - serves ONLY the ``static/`` directory (no traversal outside it),
8
+ - binds to loopback ``127.0.0.1`` by default so the preview is NOT exposed to
9
+ the local network,
10
+ - auto-selects a free port: it tries the preferred port and walks upward until
11
+ one binds, so an occupied port never aborts the preview,
12
+ - fails fast with a clear message if ``static/`` was never built,
13
+ - shuts down cleanly on Ctrl+C.
14
+
15
+ Config via environment (all optional):
16
+ HOST bind address (default 127.0.0.1; use 0.0.0.0 to expose)
17
+ PORT preferred start port (default 8000)
18
+ PORT_TRIES how many ports to try (default 50)
19
+
20
+ Run via ``npm run static:serve`` (after ``npm run static`` has produced static/).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import errno
26
+ import os
27
+ import socket
28
+ import sys
29
+ from functools import partial
30
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
31
+ from pathlib import Path
32
+
33
+ ROOT = Path(__file__).resolve().parent.parent
34
+ STATIC_DIR = ROOT / "static"
35
+
36
+ DEFAULT_HOST = "127.0.0.1"
37
+ DEFAULT_PORT = 8000
38
+ DEFAULT_PORT_TRIES = 50
39
+
40
+ GREEN = "\033[32m"
41
+ YELLOW = "\033[33m"
42
+ RED = "\033[31m"
43
+ DIM = "\033[2m"
44
+ RESET = "\033[0m"
45
+
46
+ # Errnos that mean "this port is taken, try the next one" across platforms.
47
+ # EADDRINUSE covers POSIX (98) and Windows WSAEADDRINUSE (10048);
48
+ # EACCES/WSAEACCES can appear on Windows when a port is held by another owner.
49
+ _PORT_TAKEN = {errno.EADDRINUSE, errno.EACCES}
50
+ if hasattr(errno, "WSAEADDRINUSE"): # Windows-only alias
51
+ _PORT_TAKEN.add(errno.WSAEADDRINUSE) # type: ignore[attr-defined]
52
+
53
+
54
+ class SecureThreadingHTTPServer(ThreadingHTTPServer):
55
+ """Threaded server with reliable "is this port really free?" semantics.
56
+
57
+ ``allow_reuse_address`` maps to ``SO_REUSEADDR``. On Windows that flag lets a
58
+ NEW socket bind a port another socket is ACTIVELY listening on, which would
59
+ make our probe silently "succeed" onto an occupied port and collide. So we
60
+ disable it here: bind() then genuinely fails on a taken port and our
61
+ port-walk loop can move on. (On POSIX SO_REUSEADDR only affects TIME_WAIT
62
+ sockets, but keeping it off is harmless and keeps behavior identical.)
63
+ """
64
+
65
+ allow_reuse_address = False
66
+ daemon_threads = True
67
+
68
+
69
+ class StaticRequestHandler(SimpleHTTPRequestHandler):
70
+ """Serve static/ with light hardening and quieter, useful logging.
71
+
72
+ SimpleHTTPRequestHandler already normalizes and confines paths to the served
73
+ ``directory`` (no ``..`` traversal), so this only adds conservative response
74
+ headers appropriate for a local static preview.
75
+ """
76
+
77
+ def end_headers(self) -> None:
78
+ self.send_header("X-Content-Type-Options", "nosniff")
79
+ self.send_header("Cache-Control", "no-store")
80
+ super().end_headers()
81
+
82
+ def log_message(self, format: str, *args) -> None:
83
+ sys.stderr.write(f"{DIM} {self.address_string()} - {format % args}{RESET}\n")
84
+
85
+
86
+ def _looks_built(static_dir: Path) -> bool:
87
+ """True only if static/ exists and holds a real export (an index.html)."""
88
+ return static_dir.is_dir() and (static_dir / "index.html").is_file()
89
+
90
+
91
+ def _env_int(name: str, default: int) -> int:
92
+ raw = os.environ.get(name, "").strip()
93
+ if not raw:
94
+ return default
95
+ try:
96
+ value = int(raw)
97
+ except ValueError:
98
+ print(f"{YELLOW} Ignoring invalid {name}={raw!r}; using {default}.{RESET}")
99
+ return default
100
+ return value
101
+
102
+
103
+ def _serve(host: str, start_port: int, tries: int) -> int:
104
+ handler = partial(StaticRequestHandler, directory=str(STATIC_DIR))
105
+
106
+ last_error: OSError | None = None
107
+ for offset in range(max(1, tries)):
108
+ port = start_port + offset
109
+ if port > 65535:
110
+ break
111
+ try:
112
+ httpd = SecureThreadingHTTPServer((host, port), handler)
113
+ except OSError as exc:
114
+ if exc.errno in _PORT_TAKEN:
115
+ last_error = exc
116
+ if offset == 0:
117
+ print(
118
+ f"{YELLOW} Port {start_port} is busy; searching for a free "
119
+ f"port...{RESET}"
120
+ )
121
+ continue
122
+ # A different error (bad host, permissions, etc.) is not recoverable
123
+ # by trying another port -- surface it.
124
+ print(f"{RED} Failed to bind {host}:{port} -- {exc}{RESET}")
125
+ return 1
126
+
127
+ with httpd:
128
+ shown_host = "localhost" if host in ("127.0.0.1", "0.0.0.0") else host
129
+ url = f"http://{shown_host}:{port}"
130
+ if port != start_port:
131
+ print(
132
+ f"{YELLOW} Default port {start_port} was occupied; "
133
+ f"using {port} instead.{RESET}"
134
+ )
135
+ print(f"\n{GREEN}Serving{RESET} {DIM}{STATIC_DIR}{RESET}")
136
+ print(f"{GREEN} -> {url}{RESET}")
137
+ if host == "0.0.0.0":
138
+ lan = _lan_hint(port)
139
+ if lan:
140
+ print(f"{DIM} -> {lan} (exposed on your network){RESET}")
141
+ print(f"{DIM} Press Ctrl+C to stop.{RESET}\n")
142
+ try:
143
+ httpd.serve_forever()
144
+ except KeyboardInterrupt:
145
+ print(f"\n{DIM} Stopped.{RESET}")
146
+ return 0
147
+
148
+ ports = f"{start_port}-{start_port + max(1, tries) - 1}"
149
+ print(
150
+ f"{RED} Could not find a free port in {ports} on {host}.{RESET}\n"
151
+ f"{DIM} Last error: {last_error}. Free a port or set PORT to another "
152
+ f"start value.{RESET}"
153
+ )
154
+ return 1
155
+
156
+
157
+ def _lan_hint(port: int) -> str | None:
158
+ """Best-effort LAN URL to show only when the user opted into 0.0.0.0."""
159
+ try:
160
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
161
+ s.connect(("8.8.8.8", 80))
162
+ ip = s.getsockname()[0]
163
+ return f"http://{ip}:{port}"
164
+ except OSError:
165
+ return None
166
+
167
+
168
+ def main() -> int:
169
+ if not _looks_built(STATIC_DIR):
170
+ print(
171
+ f"{RED}No static build found at {STATIC_DIR}.{RESET}\n"
172
+ f"{DIM} Build it first: {RESET}{GREEN}npm run static{RESET}"
173
+ )
174
+ return 1
175
+
176
+ host = os.environ.get("HOST", "").strip() or DEFAULT_HOST
177
+ start_port = _env_int("PORT", DEFAULT_PORT)
178
+ tries = _env_int("PORT_TRIES", DEFAULT_PORT_TRIES)
179
+
180
+ if not (1 <= start_port <= 65535):
181
+ print(f"{RED} PORT must be 1-65535 (got {start_port}).{RESET}")
182
+ return 1
183
+
184
+ return _serve(host, start_port, tries)
185
+
186
+
187
+ if __name__ == "__main__":
188
+ raise SystemExit(main())
@@ -1,135 +1,135 @@
1
- # App tests & quality gate
2
-
3
- Type check + lint + template lint + tests for the **application** code
4
- (`main.py`, `src/**`, and authored markup) — not the Caspian framework under
5
- `.venv/` or `node_modules/`.
6
-
7
- ## The command
8
-
9
- ```bash
10
- npm run check
11
- ```
12
-
13
- That is the single production gate. It runs **pyright** (types), **ruff**
14
- (lint), **templates** (markup lint), and **pytest** (tests) in one pass, prints
15
- every problem as `path:line:col [tool:code] message`, and exits non-zero on
16
- failure — so CI, a pre-commit hook, or an agent is told exactly which file and
17
- location to fix.
18
-
19
- While debugging you can narrow to one tool:
20
-
21
- ```bash
22
- uv run python settings/check.py --only pyright # or ruff / templates / pytest
23
- ```
24
-
25
- ## The `templates` check
26
-
27
- `settings/check_templates.py` scans `src/**/*.html` and the markup inside
28
- single-file Python components for JSX and directives PulsePoint does not have.
29
-
30
- PulsePoint borrows React's hook API inside `<script>` and React's component
31
- decomposition — never React's markup syntax. Before this check existed, the
32
- whole `.html` surface was unvalidated, and two failures shipped repeatedly:
33
-
34
- - `{users.map(user => (<tr/>))}` renders one literal row plus stray text.
35
- - `class={...}` (unquoted) is **invalid HTML** — the parser shreds the element,
36
- the component root never compiles, and the route serves a blank page with *no
37
- console error at all*.
38
-
39
- It skips `<script>`, `<pre>`/`<code>`, and HTML comments, so real component
40
- JavaScript (`rows.map(...)` is correct there) and documentation samples do not
41
- trip it. Run it alone with:
42
-
43
- ```bash
44
- uv run python settings/check_templates.py
45
- ```
46
-
47
- Its own coverage lives in `tests/test_check_templates.py`, which asserts both
48
- directions — every JSX shape is caught, and correct markup stays silent — plus a
49
- repo-wide assertion that `src/` is currently clean.
50
-
51
- ## Browser errors in the dev terminal — and in a file
52
-
53
- `npm run dev` forwards PulsePoint's browser-side `[PP-ERROR]` / `[PP-WARN]`
54
- output, uncaught errors, and unhandled rejections into the terminal, so a broken
55
- route is visible without opening DevTools. See the `AGENTS.md` entry for how the
56
- pieces fit (`settings/dev-log-bridge.ts` + `_inject_dev_console_bridge` in
57
- `main.py`). It is development-only: the injecting branch is gated on
58
- `CASPIAN_BROWSER_SYNC_PORT`, which only the dev stack sets.
59
-
60
- The same events are appended to `.casp/browser-log.jsonl`, because stdout only
61
- reaches whoever owns that terminal. Read it with:
62
-
63
- ```bash
64
- npm run logs
65
- ```
66
-
67
- `npm run check` prints the same digest at the end of its run, but never lets it
68
- change the exit code — whether a route has been exercised depends on someone
69
- opening a browser, and a gate that flaky gets ignored. Use `--fail-on-error` if
70
- you want a non-zero exit in a script you control.
71
-
72
- The log records **successful page loads too**, which is what makes it safe to
73
- trust: a route's status is whatever happened during its most recent load, so a
74
- fixed error stops being reported after one clean reload, and an empty log reads
75
- as "nothing observed" rather than "healthy".
76
-
77
- A reload only proves what a reload re-runs. Errors are classified by how long
78
- after their page load they arrived: a **mount** error is cleared by a later load,
79
- an **interaction** error (a click handler throwing seconds later) is not — it
80
- carries forward as `NEEDS RECHECK` until you repeat the interaction. Reporting
81
- those as clean is how a live bug gets signed off.
82
-
83
- Every source change **compacts** the log to the session header, a `restart`
84
- marker, and the errors still open, so a dev session running for hours cannot grow
85
- an unbounded file. **Don't diagnose from the raw JSONL** — it is history, not
86
- state. `npm run logs` derives the current status and costs the same tokens
87
- regardless of session length. An error with no matching load — a tab left open
88
- across a dev restart — is reported as `UNCONFIRMED` rather than as a fresh
89
- failure. Coverage is in `tests/test_browser_log.py`.
90
-
91
- ## Auto-fixing lint issues
92
-
93
- `npm run check` only **reports**. To auto-fix the ruff findings it lists, run:
94
-
95
- ```bash
96
- npm run check:fix
97
- ```
98
-
99
- That runs `settings/fix.py` (safely fixes lint issues — dead imports, redundant
100
- code, etc.) and then re-runs the full gate so you see what's left. Type errors
101
- (pyright) and failing tests (pytest) are never auto-fixed — fix those at the
102
- reported `path:line:col`.
103
-
104
- ### How unused-import (F401) removal stays safe
105
-
106
- Removing "unused" imports is the one fix that is dangerous in this app. Caspian
107
- single-file components import their children and then use them only as `<x-*>`
108
- tags inside `html(...)`/`render_html(...)` template strings (e.g. `from .Dialog
109
- import DialogContent` → `<x-dialog-content>`). Ruff can't parse the template, so
110
- it sees the import as unused — but casp resolves the tag from the module's
111
- globals at render time, so deleting it breaks the page.
112
-
113
- Two layers keep this safe, so `check:fix` still cleans real dead imports:
114
-
115
- - **A raw `ruff check --fix` never deletes any import.** `F401` is marked
116
- `unfixable` in `pyproject.toml`, so even if someone runs ruff directly, no
117
- component import is ever stripped.
118
- - **`npm run check:fix` removes only genuinely dead imports.** `settings/fix.py`
119
- asks ruff which files have an `F401`, skips any file that contains an import
120
- used as an `<x-*>` tag (leaving those whole), and removes dead imports from the
121
- rest via an isolated ruff run. Component-guarded files are left for the gate to
122
- report, so you decide by hand there.
123
-
124
- `settings/check.py` also suppresses the `F401` *reports* whose symbol is used as
125
- an `<x-*>` tag, so **the gate fails only on genuinely dead imports**. The
126
- `<x-*>`-tag detection is shared between the fixer and the gate in
127
- `settings/_component_imports.py`.
128
-
129
- ## Tools (Python dev group in `pyproject.toml`)
130
-
131
- - **pyright** — type checker. Config in `[tool.pyright]`: `include = ["main.py", "src", "settings/*.py"]` with `exclude = [".venv", "node_modules", "**/__pycache__"]`, so it checks `main.py`, all of `src` (including the generated `src/lib/prisma/**` ORM), and the top-level `settings/*.py` tooling scripts (mirroring ruff's `include`). Pylance reads the same config, so the IDE and `npm run check` agree.
132
- - **ruff** — linter. Config in `[tool.ruff]`; correctness-focused rules.
133
- - **pytest** — test runner. Tests live in `tests/`.
134
-
135
- Install/refresh them with `uv sync --group dev`.
1
+ # App tests & quality gate
2
+
3
+ Type check + lint + template lint + tests for the **application** code
4
+ (`main.py`, `src/**`, and authored markup) — not the Caspian framework under
5
+ `.venv/` or `node_modules/`.
6
+
7
+ ## The command
8
+
9
+ ```bash
10
+ npm run check
11
+ ```
12
+
13
+ That is the single production gate. It runs **pyright** (types), **ruff**
14
+ (lint), **templates** (markup lint), and **pytest** (tests) in one pass, prints
15
+ every problem as `path:line:col [tool:code] message`, and exits non-zero on
16
+ failure — so CI, a pre-commit hook, or an agent is told exactly which file and
17
+ location to fix.
18
+
19
+ While debugging you can narrow to one tool:
20
+
21
+ ```bash
22
+ uv run python settings/check.py --only pyright # or ruff / templates / pytest
23
+ ```
24
+
25
+ ## The `templates` check
26
+
27
+ `settings/check_templates.py` scans `src/**/*.html` and the markup inside
28
+ single-file Python components for JSX and directives PulsePoint does not have.
29
+
30
+ PulsePoint borrows React's hook API inside `<script>` and React's component
31
+ decomposition — never React's markup syntax. Before this check existed, the
32
+ whole `.html` surface was unvalidated, and two failures shipped repeatedly:
33
+
34
+ - `{users.map(user => (<tr/>))}` renders one literal row plus stray text.
35
+ - `class={...}` (unquoted) is **invalid HTML** — the parser shreds the element,
36
+ the component root never compiles, and the route serves a blank page with *no
37
+ console error at all*.
38
+
39
+ It skips `<script>`, `<pre>`/`<code>`, and HTML comments, so real component
40
+ JavaScript (`rows.map(...)` is correct there) and documentation samples do not
41
+ trip it. Run it alone with:
42
+
43
+ ```bash
44
+ uv run python settings/check_templates.py
45
+ ```
46
+
47
+ Its own coverage lives in `tests/test_check_templates.py`, which asserts both
48
+ directions — every JSX shape is caught, and correct markup stays silent — plus a
49
+ repo-wide assertion that `src/` is currently clean.
50
+
51
+ ## Browser errors in the dev terminal — and in a file
52
+
53
+ `npm run dev` forwards PulsePoint's browser-side `[PP-ERROR]` / `[PP-WARN]`
54
+ output, uncaught errors, and unhandled rejections into the terminal, so a broken
55
+ route is visible without opening DevTools. See the `AGENTS.md` entry for how the
56
+ pieces fit (`settings/dev-log-bridge.ts` + `_inject_dev_console_bridge` in
57
+ `main.py`). It is development-only: the injecting branch is gated on
58
+ `CASPIAN_BROWSER_SYNC_PORT`, which only the dev stack sets.
59
+
60
+ The same events are appended to `.casp/browser-log.jsonl`, because stdout only
61
+ reaches whoever owns that terminal. Read it with:
62
+
63
+ ```bash
64
+ npm run logs
65
+ ```
66
+
67
+ `npm run check` prints the same digest at the end of its run, but never lets it
68
+ change the exit code — whether a route has been exercised depends on someone
69
+ opening a browser, and a gate that flaky gets ignored. Use `--fail-on-error` if
70
+ you want a non-zero exit in a script you control.
71
+
72
+ The log records **successful page loads too**, which is what makes it safe to
73
+ trust: a route's status is whatever happened during its most recent load, so a
74
+ fixed error stops being reported after one clean reload, and an empty log reads
75
+ as "nothing observed" rather than "healthy".
76
+
77
+ A reload only proves what a reload re-runs. Errors are classified by how long
78
+ after their page load they arrived: a **mount** error is cleared by a later load,
79
+ an **interaction** error (a click handler throwing seconds later) is not — it
80
+ carries forward as `NEEDS RECHECK` until you repeat the interaction. Reporting
81
+ those as clean is how a live bug gets signed off.
82
+
83
+ Every source change **compacts** the log to the session header, a `restart`
84
+ marker, and the errors still open, so a dev session running for hours cannot grow
85
+ an unbounded file. **Don't diagnose from the raw JSONL** — it is history, not
86
+ state. `npm run logs` derives the current status and costs the same tokens
87
+ regardless of session length. An error with no matching load — a tab left open
88
+ across a dev restart — is reported as `UNCONFIRMED` rather than as a fresh
89
+ failure. Coverage is in `tests/test_browser_log.py`.
90
+
91
+ ## Auto-fixing lint issues
92
+
93
+ `npm run check` only **reports**. To auto-fix the ruff findings it lists, run:
94
+
95
+ ```bash
96
+ npm run check:fix
97
+ ```
98
+
99
+ That runs `settings/fix.py` (safely fixes lint issues — dead imports, redundant
100
+ code, etc.) and then re-runs the full gate so you see what's left. Type errors
101
+ (pyright) and failing tests (pytest) are never auto-fixed — fix those at the
102
+ reported `path:line:col`.
103
+
104
+ ### How unused-import (F401) removal stays safe
105
+
106
+ Removing "unused" imports is the one fix that is dangerous in this app. Caspian
107
+ single-file components import their children and then use them only as `<x-*>`
108
+ tags inside `html(...)`/`render_html(...)` template strings (e.g. `from .Dialog
109
+ import DialogContent` → `<x-dialog-content>`). Ruff can't parse the template, so
110
+ it sees the import as unused — but casp resolves the tag from the module's
111
+ globals at render time, so deleting it breaks the page.
112
+
113
+ Two layers keep this safe, so `check:fix` still cleans real dead imports:
114
+
115
+ - **A raw `ruff check --fix` never deletes any import.** `F401` is marked
116
+ `unfixable` in `pyproject.toml`, so even if someone runs ruff directly, no
117
+ component import is ever stripped.
118
+ - **`npm run check:fix` removes only genuinely dead imports.** `settings/fix.py`
119
+ asks ruff which files have an `F401`, skips any file that contains an import
120
+ used as an `<x-*>` tag (leaving those whole), and removes dead imports from the
121
+ rest via an isolated ruff run. Component-guarded files are left for the gate to
122
+ report, so you decide by hand there.
123
+
124
+ `settings/check.py` also suppresses the `F401` *reports* whose symbol is used as
125
+ an `<x-*>` tag, so **the gate fails only on genuinely dead imports**. The
126
+ `<x-*>`-tag detection is shared between the fixer and the gate in
127
+ `settings/_component_imports.py`.
128
+
129
+ ## Tools (Python dev group in `pyproject.toml`)
130
+
131
+ - **pyright** — type checker. Config in `[tool.pyright]`: `include = ["main.py", "src", "settings/*.py"]` with `exclude = [".venv", "node_modules", "**/__pycache__"]`, so it checks `main.py`, all of `src` (including the generated `src/lib/prisma/**` ORM), and the top-level `settings/*.py` tooling scripts (mirroring ruff's `include`). Pylance reads the same config, so the IDE and `npm run check` agree.
132
+ - **ruff** — linter. Config in `[tool.ruff]`; correctness-focused rules.
133
+ - **pytest** — test runner. Tests live in `tests/`.
134
+
135
+ Install/refresh them with `uv sync --group dev`.