create-caspian-app 1.5.8 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,497 +1,497 @@
1
- """Template lint: reject JSX and unknown directives in PulsePoint markup.
2
-
3
- Why this exists
4
- ---------------
5
- `npm run check` validated Python only. A route template could contain JSX --
6
- `{users.map(user => (<tr/>))}`, `class={...}`, `className` -- and the gate stayed
7
- green, because nothing in the toolchain reads `.html` files. The failure then
8
- surfaced only in the browser, and the worst variant surfaced nowhere at all: an
9
- unquoted brace attribute is invalid HTML, so the parser shreds the element, the
10
- component root never compiles, the runtime's reveal step never clears
11
- `<body style="opacity: 0">`, and the route serves a blank page with **no console
12
- error**.
13
-
14
- PulsePoint borrows React's hook API inside `<script>` and React's component
15
- decomposition. It borrows none of React's markup syntax. This module enforces
16
- that boundary at authoring time, where the fix is cheap.
17
-
18
- Scope
19
- -----
20
- Scans authored markup under `src/`:
21
-
22
- - `**/*.html` -- route, layout, and component templates
23
- - `**/*.py` -- single-file components that embed markup via `html(\"\"\"...\"\"\")`
24
-
25
- Regions that legitimately contain JavaScript or sample code are removed before
26
- matching, so a real `array.map(...)` in a component script and a JSX snippet in
27
- a docs `<pre>` block are both ignored:
28
-
29
- - `<script>...</script>` (the component script is JS, `.map()` is correct there)
30
- - `<pre>` / `<code>` (documentation examples)
31
- - `<!-- ... -->` (commented-out markup)
32
-
33
- Usage:
34
-
35
- python settings/check_templates.py # run standalone
36
- npm run check # runs as part of the gate
37
- """
38
-
39
- from __future__ import annotations
40
-
41
- import re
42
- from dataclasses import dataclass
43
- from pathlib import Path
44
-
45
- PROJECT_ROOT = Path(__file__).resolve().parents[1]
46
- SCAN_ROOT = PROJECT_ROOT / "src"
47
-
48
- # Generated or vendored trees that are not hand-authored markup.
49
- EXCLUDED_PARTS = {"__pycache__", "node_modules", ".venv", "prisma"}
50
-
51
-
52
- @dataclass
53
- class TemplateIssue:
54
- path: str
55
- line: int
56
- column: int
57
- code: str
58
- message: str
59
-
60
-
61
- @dataclass
62
- class Rule:
63
- code: str
64
- pattern: re.Pattern[str]
65
- message: str
66
-
67
-
68
- # Each rule names the JSX/unsupported construct and the PulsePoint replacement,
69
- # so the report is directly actionable without opening the docs.
70
- RULES: list[Rule] = [
71
- Rule(
72
- "jsx-map",
73
- # `{items.map(item => (` and `{items.map((item, i) => (` -- the trailing
74
- # `(` is what distinguishes returning markup from a normal value map.
75
- re.compile(r"\{[^{}\n]*?\.map\s*\(\s*\(?[\w\s,]*\)?\s*=>\s*\(", re.MULTILINE),
76
- 'JSX .map() returning markup. Use <template pp-for="item in items"> with key="{item.id}".',
77
- ),
78
- Rule(
79
- "jsx-logical",
80
- # `{cond && (<div` -- element after a logical AND.
81
- re.compile(r"\{[^{}]*?&&\s*\(\s*<", re.DOTALL),
82
- 'JSX `{cond && (<element/>)}`. Use hidden="{!cond}" on the element.',
83
- ),
84
- Rule(
85
- "jsx-ternary-element",
86
- # `{cond ? <A` -- element directly after a ternary branch.
87
- re.compile(r"\{[^{}]*?\?\s*\(?\s*<[a-zA-Z]", re.DOTALL),
88
- 'JSX `{cond ? <A/> : <B/>}`. Use two elements with complementary hidden="{...}" bindings.',
89
- ),
90
- Rule(
91
- "unquoted-brace-attr",
92
- # `class={...}` / `selected={...}` -- invalid HTML, silently blanks the page.
93
- #
94
- # An attribute only exists *inside an opening tag*, and the rule must say
95
- # so. A bare `\s[\w:.\-]+=\{` also matches `DIR={path}` in a shell script
96
- # and `ENV PORT={port}` in a Dockerfile -- and this repo embeds both in
97
- # triple-quoted strings under `src/lib/aws/`, which the Python scan keeps
98
- # because it cannot tell a heredoc from a template. Requiring the opening
99
- # tag is not a loosening: a real violation is always inside one.
100
- #
101
- # `[^<>]*?` bounds the attribute run to a single tag; it matches newlines
102
- # (negated classes do), so an attribute on its own line is still caught.
103
- re.compile(r"<[a-zA-Z][\w:.\-]*(?:[^<>]*?)?\s[\w:.\-]+=\{"),
104
- "Unquoted brace attribute. This is invalid HTML: the parser splits the "
105
- "value on spaces, the component root never compiles, and the page "
106
- 'renders blank with no console error. Quote it: attr="{expr}".',
107
- ),
108
- Rule(
109
- "react-attribute",
110
- re.compile(r"\s(className|htmlFor|dangerouslySetInnerHTML)\s*="),
111
- "React DOM property. Use class=, for=, or server-rendered markup.",
112
- ),
113
- Rule(
114
- "camelcase-event",
115
- # `onClick="…"` / `onClick={…}` in attribute position. The value-start
116
- # class and the declaration lookbehinds keep ordinary JavaScript out:
117
- # `const onPointerEnter = () => {` is a valid handler name in a component
118
- # script or an injected snippet, not a JSX prop.
119
- re.compile(
120
- r"(?<!\bconst )(?<!\blet )(?<!\bvar )(?<!\bfunction )"
121
- r"\son[A-Z][a-zA-Z]*\s*=\s*[\"'{]"
122
- ),
123
- "camelCase event prop. PulsePoint binds native lowercase event "
124
- 'attributes: onclick="{handler()}". A component prop uses kebab-case '
125
- "(on-click), which arrives as pp.props.onClick.",
126
- ),
127
- Rule(
128
- "jsx-fragment",
129
- # `</>` is unambiguous, and a well-formed fragment always has one. A bare
130
- # `<>` is not: it is SQL's not-equals operator, and this repo runs
131
- # `WHERE pid <> pg_backend_pid()` from a triple-quoted string. So the
132
- # open tag counts only when an element follows it, which is what a
133
- # fragment looks like and what `<> value` in SQL never does.
134
- re.compile(r"</>|<>\s*<"),
135
- "JSX fragment. A template needs exactly one real root element.",
136
- ),
137
- Rule(
138
- "style-object",
139
- re.compile(r"style\s*=\s*\{\{"),
140
- "JSX style object. pp-style takes a CSS *string*: pp-style=\"{'color: red'}\".",
141
- ),
142
- Rule(
143
- "unknown-directive",
144
- re.compile(r"\spp-(if|show|else|elif|key|class|text|html|model|bind|on)\s*[=>\s]"),
145
- "Directive does not exist in PulsePoint. Conditionals use "
146
- 'hidden="{...}", lists use <template pp-for>, keys use plain key="{...}".',
147
- ),
148
- ]
149
-
150
- # `pp-for` is valid only on <template>. Matching the opening tag it sits in is
151
- # enough: the attribute cannot appear before its own tag name.
152
- PP_FOR_TAG = re.compile(r"<\s*([a-zA-Z][\w:-]*)([^>]*?\spp-for\s*=)", re.DOTALL)
153
-
154
- SCRIPT_BLOCK = re.compile(r"<script\b.*?</script\s*>", re.IGNORECASE | re.DOTALL)
155
- PRE_BLOCK = re.compile(r"<(pre|code)\b.*?</\1\s*>", re.IGNORECASE | re.DOTALL)
156
- HTML_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
157
-
158
- # Components are imported with Python imports; an `@import` HTML comment does
159
- # not import anything and the compiler refuses it. Matched BEFORE comments are
160
- # blanked out — the construct *is* a comment.
161
- IMPORT_COMMENT = re.compile(r"<!--\s*@import\b")
162
-
163
- # In a single-file component the markup lives in a triple-quoted string handed to
164
- # `html(...)`. Surrounding Python must not be matched: `onValueChange=...` is an
165
- # ordinary keyword argument, and flagging it as a camelCase event prop would make
166
- # the gate unusable for every generated maddex component.
167
- TRIPLE_QUOTED = re.compile(r'"""(?:.|\n)*?"""|\'\'\'(?:.|\n)*?\'\'\'')
168
-
169
-
170
- def _blank_out(text: str, pattern: re.Pattern[str]) -> str:
171
- """Replace matched regions with same-length whitespace.
172
-
173
- Offsets stay valid, so reported line/column numbers still point at the real
174
- location in the original file.
175
- """
176
-
177
- def replace(match: re.Match[str]) -> str:
178
- return "".join("\n" if ch == "\n" else " " for ch in match.group(0))
179
-
180
- return pattern.sub(replace, text)
181
-
182
-
183
- def _keep_only(text: str, pattern: re.Pattern[str]) -> str:
184
- """Inverse of `_blank_out`: blank everything *outside* the matched regions."""
185
- kept = ["\n" if ch == "\n" else " " for ch in text]
186
- for match in pattern.finditer(text):
187
- for index in range(match.start(), match.end()):
188
- kept[index] = text[index]
189
- return "".join(kept)
190
-
191
-
192
- def _markup_regions(text: str, *, is_python: bool) -> tuple[str, str]:
193
- """Reduce a source file to just the markup a template rule may match.
194
-
195
- Returns ``(markup, markup_with_comments)``: the first has HTML comments
196
- blanked for the JSX/directive rules; the second keeps them so the
197
- ``@import``-comment rule can still see its target.
198
- """
199
- if is_python:
200
- # Only triple-quoted regions can hold markup in a single-file component.
201
- text = _keep_only(text, TRIPLE_QUOTED)
202
- for pattern in (SCRIPT_BLOCK, PRE_BLOCK):
203
- text = _blank_out(text, pattern)
204
- return _blank_out(text, HTML_COMMENT), text
205
-
206
-
207
- def _position(text: str, offset: int) -> tuple[int, int]:
208
- line = text.count("\n", 0, offset) + 1
209
- line_start = text.rfind("\n", 0, offset) + 1
210
- return line, offset - line_start + 1
211
-
212
-
213
- def lint_text(text: str, rel_path: str, *, is_python: bool = False) -> list[TemplateIssue]:
214
- """Return every template issue found in one file's contents."""
215
- markup, markup_with_comments = _markup_regions(text, is_python=is_python)
216
- issues: list[TemplateIssue] = []
217
-
218
- for match in IMPORT_COMMENT.finditer(markup_with_comments):
219
- line, column = _position(markup_with_comments, match.start())
220
- issues.append(
221
- TemplateIssue(
222
- rel_path,
223
- line,
224
- column,
225
- "import-comment",
226
- "An '@import' HTML comment does not import a component. Import "
227
- "it in the owning Python module "
228
- "(from src.lib.maddex.Button import Button) and keep the "
229
- "<x-button> tag in the markup.",
230
- )
231
- )
232
-
233
- for rule in RULES:
234
- for match in rule.pattern.finditer(markup):
235
- line, column = _position(markup, match.start())
236
- issues.append(TemplateIssue(rel_path, line, column, rule.code, rule.message))
237
-
238
- for match in PP_FOR_TAG.finditer(markup):
239
- tag = match.group(1).lower()
240
- if tag == "template":
241
- continue
242
- line, column = _position(markup, match.start())
243
- issues.append(
244
- TemplateIssue(
245
- rel_path,
246
- line,
247
- column,
248
- "pp-for-placement",
249
- f"pp-for on <{tag}>. It belongs only on <template>: "
250
- f'<template pp-for="item in items"><{tag} key="{{item.id}}">…',
251
- )
252
- )
253
-
254
- return issues
255
-
256
-
257
- def _iter_files() -> list[Path]:
258
- if not SCAN_ROOT.exists():
259
- return []
260
- files: list[Path] = []
261
- for pattern in ("**/*.html", "**/*.py"):
262
- for path in SCAN_ROOT.glob(pattern):
263
- if EXCLUDED_PARTS.intersection(path.parts):
264
- continue
265
- files.append(path)
266
- return sorted(files)
267
-
268
-
269
- # ---------------------------------------------------------------------------
270
- # f-string component returns (ratchet)
271
- # ---------------------------------------------------------------------------
272
- # `html(...)` is the single markup entrypoint. A component that returns an
273
- # f-string instead skips it entirely, and the two forms disagree in ways that
274
- # are invisible at the call site:
275
- #
276
- # * The brace dialects are INVERTED. `html()` writes `{{ name }}` for server
277
- # interpolation and `{count}` for a PulsePoint binding; an f-string writes
278
- # `{name}` for the server and needs `{{count}}` to emit a binding. Same
279
- # characters, opposite meanings.
280
- # * There is no autoescaping. `Component.acall` wraps the returned string in
281
- # `Markup`, so interpolated request data is emitted raw AND marked trusted.
282
- # * `<x-*>` scope is not stashed, so a directly-called component
283
- # (`{{ Card() }}`) cannot resolve nested component tags.
284
- #
285
- # The existing returns are recorded in a baseline and allowed; anything new
286
- # fails the gate. Convert one and delete its baseline line. Regenerate with
287
- # `python settings/check_templates.py --update-baseline`.
288
- FSTRING_BASELINE_PATH = PROJECT_ROOT / "settings" / "fstring-components.json"
289
-
290
- FSTRING_MESSAGE = (
291
- "Component returns an f-string instead of html(...). The brace dialects are "
292
- "inverted between the two forms ({{ x }} vs {x}) and an f-string is not "
293
- "autoescaped, so interpolated data is emitted raw and marked trusted. "
294
- "Return html(r'''...''', x=x) instead."
295
- )
296
-
297
- HTML_FORM_MESSAGE = (
298
- "html(...) must take a raw triple-quoted literal: html(r'''...'''). It is "
299
- "the single markup entrypoint, and one form keeps it readable and greppable. "
300
- "A non-raw string silently rewrites backslashes, so a JS regex or a \\n in a "
301
- "component script changes meaning between authoring and render; an f-string "
302
- "additionally inverts the brace dialects and emits interpolated data raw. "
303
- "Pass server values as context instead: html(r'''...{{ x }}...''', x=x)."
304
- )
305
-
306
-
307
- def _own_returns(func):
308
- """The `return` statements belonging to `func` itself.
309
-
310
- `ast.walk` would descend into nested `def`s and lambdas and attribute their
311
- returns to the enclosing function. A `@component` may legitimately define a
312
- private helper that builds a string fragment, so walking blind reports a
313
- component that already returns `html(...)` and tells its author to do what
314
- they have done -- which is how a gate loses its credibility.
315
- """
316
- import ast
317
-
318
- stack = list(func.body)
319
- while stack:
320
- node = stack.pop()
321
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
322
- continue
323
- if isinstance(node, ast.Return):
324
- yield node
325
- continue
326
- stack.extend(ast.iter_child_nodes(node))
327
-
328
-
329
- def _fstring_component_returns() -> list[tuple[str, str, int, int]]:
330
- """Every `@component` whose return value is an f-string.
331
-
332
- Entries are `(rel_path, function_name, line, column)`, sorted.
333
- """
334
- import ast
335
-
336
- found: list[tuple[str, str, int, int]] = []
337
- for path in _iter_files():
338
- if path.suffix != ".py":
339
- continue
340
- try:
341
- tree = ast.parse(path.read_text(encoding="utf-8"))
342
- except OSError, UnicodeDecodeError, SyntaxError:
343
- continue
344
- rel = path.relative_to(PROJECT_ROOT).as_posix()
345
- for node in ast.walk(tree):
346
- if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
347
- continue
348
- decorators = {
349
- getattr(d, "id", None) or getattr(d, "attr", None) for d in node.decorator_list
350
- }
351
- if "component" not in decorators:
352
- continue
353
- for stmt in _own_returns(node):
354
- if isinstance(stmt.value, ast.JoinedStr):
355
- found.append((rel, node.name, stmt.lineno, stmt.col_offset + 1))
356
- break
357
- return sorted(found)
358
-
359
-
360
- def _load_fstring_baseline() -> set[str]:
361
- import json
362
-
363
- try:
364
- raw = json.loads(FSTRING_BASELINE_PATH.read_text(encoding="utf-8"))
365
- except OSError, ValueError:
366
- return set()
367
- return set(raw.get("allowed", []))
368
-
369
-
370
- def write_fstring_baseline() -> int:
371
- """Record today's f-string components as the allowed set."""
372
- import json
373
-
374
- entries = sorted({f"{rel}::{name}" for rel, name, _, _ in _fstring_component_returns()})
375
- FSTRING_BASELINE_PATH.write_text(
376
- json.dumps(
377
- {
378
- "_comment": (
379
- "Components that still return an f-string instead of html(...). "
380
- "This list may only shrink: converting one means deleting its "
381
- "line. New entries fail `npm run check`."
382
- ),
383
- "allowed": entries,
384
- },
385
- indent=2,
386
- )
387
- + "\n",
388
- encoding="utf-8",
389
- )
390
- return len(entries)
391
-
392
-
393
- def _html_call_template_args():
394
- """Every `html(...)` call's first argument, with its source text.
395
-
396
- Yields `(rel_path, lineno, col, source_segment, node)`.
397
- """
398
- import ast
399
-
400
- for path in _iter_files():
401
- if path.suffix != ".py":
402
- continue
403
- try:
404
- source = path.read_text(encoding="utf-8")
405
- tree = ast.parse(source)
406
- except OSError, UnicodeDecodeError, SyntaxError:
407
- continue
408
- rel = path.relative_to(PROJECT_ROOT).as_posix()
409
- for node in ast.walk(tree):
410
- if not isinstance(node, ast.Call):
411
- continue
412
- name = getattr(node.func, "id", None) or getattr(node.func, "attr", None)
413
- if name != "html" or not node.args:
414
- continue
415
- arg = node.args[0]
416
- segment = ast.get_source_segment(source, arg) or ""
417
- yield rel, arg.lineno, arg.col_offset + 1, segment, arg
418
-
419
-
420
- def lint_html_call_form() -> list[TemplateIssue]:
421
- """`html(...)` takes a raw triple-quoted literal -- one form, no exceptions.
422
-
423
- Two forms drifted apart in this repo once already: 437 calls used
424
- `html(r'''...''')` and 133 did not, which makes the markup surface
425
- un-greppable and lets a backslash mean two different things depending on
426
- which call you are reading.
427
- """
428
- import ast
429
-
430
- issues: list[TemplateIssue] = []
431
- for rel, line, col, segment, arg in _html_call_template_args():
432
- if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
433
- if segment.startswith(('r"""', "r'''", 'R"""', "R'''")):
434
- continue
435
- issues.append(TemplateIssue(rel, line, col, "html-form", HTML_FORM_MESSAGE))
436
- return issues
437
-
438
-
439
- def lint_fstring_components() -> list[TemplateIssue]:
440
- allowed = _load_fstring_baseline()
441
- return [
442
- TemplateIssue(rel, line, col, "fstring-component", FSTRING_MESSAGE)
443
- for rel, name, line, col in _fstring_component_returns()
444
- if f"{rel}::{name}" not in allowed
445
- ]
446
-
447
-
448
- def lint_templates() -> list[TemplateIssue]:
449
- """Lint every authored template under `src/`."""
450
- issues: list[TemplateIssue] = []
451
- for path in _iter_files():
452
- try:
453
- text = path.read_text(encoding="utf-8")
454
- except OSError, UnicodeDecodeError:
455
- continue
456
- # Cheap pre-filter: a file with no brace expression and no angle-bracket
457
- # markup cannot trip any rule.
458
- if "{" not in text and "<" not in text:
459
- continue
460
- rel = path.relative_to(PROJECT_ROOT).as_posix()
461
- issues.extend(lint_text(text, rel, is_python=path.suffix == ".py"))
462
- issues.extend(lint_fstring_components())
463
- issues.extend(lint_html_call_form())
464
- return issues
465
-
466
-
467
- def main() -> int:
468
- import sys
469
-
470
- if "--update-baseline" in sys.argv:
471
- count = write_fstring_baseline()
472
- print(
473
- f"templates: recorded {count} f-string component(s) in "
474
- f"{FSTRING_BASELINE_PATH.relative_to(PROJECT_ROOT).as_posix()}."
475
- )
476
- return 0
477
-
478
- issues = lint_templates()
479
- if not issues:
480
- print("templates: no JSX or unknown directives found.")
481
- return 0
482
-
483
- by_file: dict[str, list[TemplateIssue]] = {}
484
- for issue in issues:
485
- by_file.setdefault(issue.path, []).append(issue)
486
-
487
- for path in sorted(by_file):
488
- print(path)
489
- for issue in sorted(by_file[path], key=lambda i: (i.line, i.column)):
490
- print(f" {issue.line}:{issue.column} [templates:{issue.code}] {issue.message}")
491
-
492
- print(f"\n{len(issues)} template issue(s) found.")
493
- return 1
494
-
495
-
496
- if __name__ == "__main__":
497
- raise SystemExit(main())
1
+ """Template lint: reject JSX and unknown directives in PulsePoint markup.
2
+
3
+ Why this exists
4
+ ---------------
5
+ `npm run test` validated Python only. A route template could contain JSX --
6
+ `{users.map(user => (<tr/>))}`, `class={...}`, `className` -- and the gate stayed
7
+ green, because nothing in the toolchain reads `.html` files. The failure then
8
+ surfaced only in the browser, and the worst variant surfaced nowhere at all: an
9
+ unquoted brace attribute is invalid HTML, so the parser shreds the element, the
10
+ component root never compiles, the runtime's reveal step never clears
11
+ `<body style="opacity: 0">`, and the route serves a blank page with **no console
12
+ error**.
13
+
14
+ PulsePoint borrows React's hook API inside `<script>` and React's component
15
+ decomposition. It borrows none of React's markup syntax. This module enforces
16
+ that boundary at authoring time, where the fix is cheap.
17
+
18
+ Scope
19
+ -----
20
+ Scans authored markup under `src/`:
21
+
22
+ - `**/*.html` -- route, layout, and component templates
23
+ - `**/*.py` -- single-file components that embed markup via `html(\"\"\"...\"\"\")`
24
+
25
+ Regions that legitimately contain JavaScript or sample code are removed before
26
+ matching, so a real `array.map(...)` in a component script and a JSX snippet in
27
+ a docs `<pre>` block are both ignored:
28
+
29
+ - `<script>...</script>` (the component script is JS, `.map()` is correct there)
30
+ - `<pre>` / `<code>` (documentation examples)
31
+ - `<!-- ... -->` (commented-out markup)
32
+
33
+ Usage:
34
+
35
+ python settings/check_templates.py # run standalone
36
+ npm run test # runs as part of the gate
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import re
42
+ from dataclasses import dataclass
43
+ from pathlib import Path
44
+
45
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
46
+ SCAN_ROOT = PROJECT_ROOT / "src"
47
+
48
+ # Generated or vendored trees that are not hand-authored markup.
49
+ EXCLUDED_PARTS = {"__pycache__", "node_modules", ".venv", "prisma"}
50
+
51
+
52
+ @dataclass
53
+ class TemplateIssue:
54
+ path: str
55
+ line: int
56
+ column: int
57
+ code: str
58
+ message: str
59
+
60
+
61
+ @dataclass
62
+ class Rule:
63
+ code: str
64
+ pattern: re.Pattern[str]
65
+ message: str
66
+
67
+
68
+ # Each rule names the JSX/unsupported construct and the PulsePoint replacement,
69
+ # so the report is directly actionable without opening the docs.
70
+ RULES: list[Rule] = [
71
+ Rule(
72
+ "jsx-map",
73
+ # `{items.map(item => (` and `{items.map((item, i) => (` -- the trailing
74
+ # `(` is what distinguishes returning markup from a normal value map.
75
+ re.compile(r"\{[^{}\n]*?\.map\s*\(\s*\(?[\w\s,]*\)?\s*=>\s*\(", re.MULTILINE),
76
+ 'JSX .map() returning markup. Use <template pp-for="item in items"> with key="{item.id}".',
77
+ ),
78
+ Rule(
79
+ "jsx-logical",
80
+ # `{cond && (<div` -- element after a logical AND.
81
+ re.compile(r"\{[^{}]*?&&\s*\(\s*<", re.DOTALL),
82
+ 'JSX `{cond && (<element/>)}`. Use hidden="{!cond}" on the element.',
83
+ ),
84
+ Rule(
85
+ "jsx-ternary-element",
86
+ # `{cond ? <A` -- element directly after a ternary branch.
87
+ re.compile(r"\{[^{}]*?\?\s*\(?\s*<[a-zA-Z]", re.DOTALL),
88
+ 'JSX `{cond ? <A/> : <B/>}`. Use two elements with complementary hidden="{...}" bindings.',
89
+ ),
90
+ Rule(
91
+ "unquoted-brace-attr",
92
+ # `class={...}` / `selected={...}` -- invalid HTML, silently blanks the page.
93
+ #
94
+ # An attribute only exists *inside an opening tag*, and the rule must say
95
+ # so. A bare `\s[\w:.\-]+=\{` also matches `DIR={path}` in a shell script
96
+ # and `ENV PORT={port}` in a Dockerfile -- and this repo embeds both in
97
+ # triple-quoted strings under `src/lib/aws/`, which the Python scan keeps
98
+ # because it cannot tell a heredoc from a template. Requiring the opening
99
+ # tag is not a loosening: a real violation is always inside one.
100
+ #
101
+ # `[^<>]*?` bounds the attribute run to a single tag; it matches newlines
102
+ # (negated classes do), so an attribute on its own line is still caught.
103
+ re.compile(r"<[a-zA-Z][\w:.\-]*(?:[^<>]*?)?\s[\w:.\-]+=\{"),
104
+ "Unquoted brace attribute. This is invalid HTML: the parser splits the "
105
+ "value on spaces, the component root never compiles, and the page "
106
+ 'renders blank with no console error. Quote it: attr="{expr}".',
107
+ ),
108
+ Rule(
109
+ "react-attribute",
110
+ re.compile(r"\s(className|htmlFor|dangerouslySetInnerHTML)\s*="),
111
+ "React DOM property. Use class=, for=, or server-rendered markup.",
112
+ ),
113
+ Rule(
114
+ "camelcase-event",
115
+ # `onClick="…"` / `onClick={…}` in attribute position. The value-start
116
+ # class and the declaration lookbehinds keep ordinary JavaScript out:
117
+ # `const onPointerEnter = () => {` is a valid handler name in a component
118
+ # script or an injected snippet, not a JSX prop.
119
+ re.compile(
120
+ r"(?<!\bconst )(?<!\blet )(?<!\bvar )(?<!\bfunction )"
121
+ r"\son[A-Z][a-zA-Z]*\s*=\s*[\"'{]"
122
+ ),
123
+ "camelCase event prop. PulsePoint binds native lowercase event "
124
+ 'attributes: onclick="{handler()}". A component prop uses kebab-case '
125
+ "(on-click), which arrives as pp.props.onClick.",
126
+ ),
127
+ Rule(
128
+ "jsx-fragment",
129
+ # `</>` is unambiguous, and a well-formed fragment always has one. A bare
130
+ # `<>` is not: it is SQL's not-equals operator, and this repo runs
131
+ # `WHERE pid <> pg_backend_pid()` from a triple-quoted string. So the
132
+ # open tag counts only when an element follows it, which is what a
133
+ # fragment looks like and what `<> value` in SQL never does.
134
+ re.compile(r"</>|<>\s*<"),
135
+ "JSX fragment. A template needs exactly one real root element.",
136
+ ),
137
+ Rule(
138
+ "style-object",
139
+ re.compile(r"style\s*=\s*\{\{"),
140
+ "JSX style object. pp-style takes a CSS *string*: pp-style=\"{'color: red'}\".",
141
+ ),
142
+ Rule(
143
+ "unknown-directive",
144
+ re.compile(r"\spp-(if|show|else|elif|key|class|text|html|model|bind|on)\s*[=>\s]"),
145
+ "Directive does not exist in PulsePoint. Conditionals use "
146
+ 'hidden="{...}", lists use <template pp-for>, keys use plain key="{...}".',
147
+ ),
148
+ ]
149
+
150
+ # `pp-for` is valid only on <template>. Matching the opening tag it sits in is
151
+ # enough: the attribute cannot appear before its own tag name.
152
+ PP_FOR_TAG = re.compile(r"<\s*([a-zA-Z][\w:-]*)([^>]*?\spp-for\s*=)", re.DOTALL)
153
+
154
+ SCRIPT_BLOCK = re.compile(r"<script\b.*?</script\s*>", re.IGNORECASE | re.DOTALL)
155
+ PRE_BLOCK = re.compile(r"<(pre|code)\b.*?</\1\s*>", re.IGNORECASE | re.DOTALL)
156
+ HTML_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
157
+
158
+ # Components are imported with Python imports; an `@import` HTML comment does
159
+ # not import anything and the compiler refuses it. Matched BEFORE comments are
160
+ # blanked out — the construct *is* a comment.
161
+ IMPORT_COMMENT = re.compile(r"<!--\s*@import\b")
162
+
163
+ # In a single-file component the markup lives in a triple-quoted string handed to
164
+ # `html(...)`. Surrounding Python must not be matched: `onValueChange=...` is an
165
+ # ordinary keyword argument, and flagging it as a camelCase event prop would make
166
+ # the gate unusable for every generated maddex component.
167
+ TRIPLE_QUOTED = re.compile(r'"""(?:.|\n)*?"""|\'\'\'(?:.|\n)*?\'\'\'')
168
+
169
+
170
+ def _blank_out(text: str, pattern: re.Pattern[str]) -> str:
171
+ """Replace matched regions with same-length whitespace.
172
+
173
+ Offsets stay valid, so reported line/column numbers still point at the real
174
+ location in the original file.
175
+ """
176
+
177
+ def replace(match: re.Match[str]) -> str:
178
+ return "".join("\n" if ch == "\n" else " " for ch in match.group(0))
179
+
180
+ return pattern.sub(replace, text)
181
+
182
+
183
+ def _keep_only(text: str, pattern: re.Pattern[str]) -> str:
184
+ """Inverse of `_blank_out`: blank everything *outside* the matched regions."""
185
+ kept = ["\n" if ch == "\n" else " " for ch in text]
186
+ for match in pattern.finditer(text):
187
+ for index in range(match.start(), match.end()):
188
+ kept[index] = text[index]
189
+ return "".join(kept)
190
+
191
+
192
+ def _markup_regions(text: str, *, is_python: bool) -> tuple[str, str]:
193
+ """Reduce a source file to just the markup a template rule may match.
194
+
195
+ Returns ``(markup, markup_with_comments)``: the first has HTML comments
196
+ blanked for the JSX/directive rules; the second keeps them so the
197
+ ``@import``-comment rule can still see its target.
198
+ """
199
+ if is_python:
200
+ # Only triple-quoted regions can hold markup in a single-file component.
201
+ text = _keep_only(text, TRIPLE_QUOTED)
202
+ for pattern in (SCRIPT_BLOCK, PRE_BLOCK):
203
+ text = _blank_out(text, pattern)
204
+ return _blank_out(text, HTML_COMMENT), text
205
+
206
+
207
+ def _position(text: str, offset: int) -> tuple[int, int]:
208
+ line = text.count("\n", 0, offset) + 1
209
+ line_start = text.rfind("\n", 0, offset) + 1
210
+ return line, offset - line_start + 1
211
+
212
+
213
+ def lint_text(text: str, rel_path: str, *, is_python: bool = False) -> list[TemplateIssue]:
214
+ """Return every template issue found in one file's contents."""
215
+ markup, markup_with_comments = _markup_regions(text, is_python=is_python)
216
+ issues: list[TemplateIssue] = []
217
+
218
+ for match in IMPORT_COMMENT.finditer(markup_with_comments):
219
+ line, column = _position(markup_with_comments, match.start())
220
+ issues.append(
221
+ TemplateIssue(
222
+ rel_path,
223
+ line,
224
+ column,
225
+ "import-comment",
226
+ "An '@import' HTML comment does not import a component. Import "
227
+ "it in the owning Python module "
228
+ "(from src.lib.maddex.Button import Button) and keep the "
229
+ "<x-button> tag in the markup.",
230
+ )
231
+ )
232
+
233
+ for rule in RULES:
234
+ for match in rule.pattern.finditer(markup):
235
+ line, column = _position(markup, match.start())
236
+ issues.append(TemplateIssue(rel_path, line, column, rule.code, rule.message))
237
+
238
+ for match in PP_FOR_TAG.finditer(markup):
239
+ tag = match.group(1).lower()
240
+ if tag == "template":
241
+ continue
242
+ line, column = _position(markup, match.start())
243
+ issues.append(
244
+ TemplateIssue(
245
+ rel_path,
246
+ line,
247
+ column,
248
+ "pp-for-placement",
249
+ f"pp-for on <{tag}>. It belongs only on <template>: "
250
+ f'<template pp-for="item in items"><{tag} key="{{item.id}}">…',
251
+ )
252
+ )
253
+
254
+ return issues
255
+
256
+
257
+ def _iter_files() -> list[Path]:
258
+ if not SCAN_ROOT.exists():
259
+ return []
260
+ files: list[Path] = []
261
+ for pattern in ("**/*.html", "**/*.py"):
262
+ for path in SCAN_ROOT.glob(pattern):
263
+ if EXCLUDED_PARTS.intersection(path.parts):
264
+ continue
265
+ files.append(path)
266
+ return sorted(files)
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # f-string component returns (ratchet)
271
+ # ---------------------------------------------------------------------------
272
+ # `html(...)` is the single markup entrypoint. A component that returns an
273
+ # f-string instead skips it entirely, and the two forms disagree in ways that
274
+ # are invisible at the call site:
275
+ #
276
+ # * The brace dialects are INVERTED. `html()` writes `{{ name }}` for server
277
+ # interpolation and `{count}` for a PulsePoint binding; an f-string writes
278
+ # `{name}` for the server and needs `{{count}}` to emit a binding. Same
279
+ # characters, opposite meanings.
280
+ # * There is no autoescaping. `Component.acall` wraps the returned string in
281
+ # `Markup`, so interpolated request data is emitted raw AND marked trusted.
282
+ # * `<x-*>` scope is not stashed, so a directly-called component
283
+ # (`{{ Card() }}`) cannot resolve nested component tags.
284
+ #
285
+ # The existing returns are recorded in a baseline and allowed; anything new
286
+ # fails the gate. Convert one and delete its baseline line. Regenerate with
287
+ # `python settings/check_templates.py --update-baseline`.
288
+ FSTRING_BASELINE_PATH = PROJECT_ROOT / "settings" / "fstring-components.json"
289
+
290
+ FSTRING_MESSAGE = (
291
+ "Component returns an f-string instead of html(...). The brace dialects are "
292
+ "inverted between the two forms ({{ x }} vs {x}) and an f-string is not "
293
+ "autoescaped, so interpolated data is emitted raw and marked trusted. "
294
+ "Return html(r'''...''', x=x) instead."
295
+ )
296
+
297
+ HTML_FORM_MESSAGE = (
298
+ "html(...) must take a raw triple-quoted literal: html(r'''...'''). It is "
299
+ "the single markup entrypoint, and one form keeps it readable and greppable. "
300
+ "A non-raw string silently rewrites backslashes, so a JS regex or a \\n in a "
301
+ "component script changes meaning between authoring and render; an f-string "
302
+ "additionally inverts the brace dialects and emits interpolated data raw. "
303
+ "Pass server values as context instead: html(r'''...{{ x }}...''', x=x)."
304
+ )
305
+
306
+
307
+ def _own_returns(func):
308
+ """The `return` statements belonging to `func` itself.
309
+
310
+ `ast.walk` would descend into nested `def`s and lambdas and attribute their
311
+ returns to the enclosing function. A `@component` may legitimately define a
312
+ private helper that builds a string fragment, so walking blind reports a
313
+ component that already returns `html(...)` and tells its author to do what
314
+ they have done -- which is how a gate loses its credibility.
315
+ """
316
+ import ast
317
+
318
+ stack = list(func.body)
319
+ while stack:
320
+ node = stack.pop()
321
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
322
+ continue
323
+ if isinstance(node, ast.Return):
324
+ yield node
325
+ continue
326
+ stack.extend(ast.iter_child_nodes(node))
327
+
328
+
329
+ def _fstring_component_returns() -> list[tuple[str, str, int, int]]:
330
+ """Every `@component` whose return value is an f-string.
331
+
332
+ Entries are `(rel_path, function_name, line, column)`, sorted.
333
+ """
334
+ import ast
335
+
336
+ found: list[tuple[str, str, int, int]] = []
337
+ for path in _iter_files():
338
+ if path.suffix != ".py":
339
+ continue
340
+ try:
341
+ tree = ast.parse(path.read_text(encoding="utf-8"))
342
+ except OSError, UnicodeDecodeError, SyntaxError:
343
+ continue
344
+ rel = path.relative_to(PROJECT_ROOT).as_posix()
345
+ for node in ast.walk(tree):
346
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
347
+ continue
348
+ decorators = {
349
+ getattr(d, "id", None) or getattr(d, "attr", None) for d in node.decorator_list
350
+ }
351
+ if "component" not in decorators:
352
+ continue
353
+ for stmt in _own_returns(node):
354
+ if isinstance(stmt.value, ast.JoinedStr):
355
+ found.append((rel, node.name, stmt.lineno, stmt.col_offset + 1))
356
+ break
357
+ return sorted(found)
358
+
359
+
360
+ def _load_fstring_baseline() -> set[str]:
361
+ import json
362
+
363
+ try:
364
+ raw = json.loads(FSTRING_BASELINE_PATH.read_text(encoding="utf-8"))
365
+ except OSError, ValueError:
366
+ return set()
367
+ return set(raw.get("allowed", []))
368
+
369
+
370
+ def write_fstring_baseline() -> int:
371
+ """Record today's f-string components as the allowed set."""
372
+ import json
373
+
374
+ entries = sorted({f"{rel}::{name}" for rel, name, _, _ in _fstring_component_returns()})
375
+ FSTRING_BASELINE_PATH.write_text(
376
+ json.dumps(
377
+ {
378
+ "_comment": (
379
+ "Components that still return an f-string instead of html(...). "
380
+ "This list may only shrink: converting one means deleting its "
381
+ "line. New entries fail `npm run test`."
382
+ ),
383
+ "allowed": entries,
384
+ },
385
+ indent=2,
386
+ )
387
+ + "\n",
388
+ encoding="utf-8",
389
+ )
390
+ return len(entries)
391
+
392
+
393
+ def _html_call_template_args():
394
+ """Every `html(...)` call's first argument, with its source text.
395
+
396
+ Yields `(rel_path, lineno, col, source_segment, node)`.
397
+ """
398
+ import ast
399
+
400
+ for path in _iter_files():
401
+ if path.suffix != ".py":
402
+ continue
403
+ try:
404
+ source = path.read_text(encoding="utf-8")
405
+ tree = ast.parse(source)
406
+ except OSError, UnicodeDecodeError, SyntaxError:
407
+ continue
408
+ rel = path.relative_to(PROJECT_ROOT).as_posix()
409
+ for node in ast.walk(tree):
410
+ if not isinstance(node, ast.Call):
411
+ continue
412
+ name = getattr(node.func, "id", None) or getattr(node.func, "attr", None)
413
+ if name != "html" or not node.args:
414
+ continue
415
+ arg = node.args[0]
416
+ segment = ast.get_source_segment(source, arg) or ""
417
+ yield rel, arg.lineno, arg.col_offset + 1, segment, arg
418
+
419
+
420
+ def lint_html_call_form() -> list[TemplateIssue]:
421
+ """`html(...)` takes a raw triple-quoted literal -- one form, no exceptions.
422
+
423
+ Two forms drifted apart in this repo once already: 437 calls used
424
+ `html(r'''...''')` and 133 did not, which makes the markup surface
425
+ un-greppable and lets a backslash mean two different things depending on
426
+ which call you are reading.
427
+ """
428
+ import ast
429
+
430
+ issues: list[TemplateIssue] = []
431
+ for rel, line, col, segment, arg in _html_call_template_args():
432
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
433
+ if segment.startswith(('r"""', "r'''", 'R"""', "R'''")):
434
+ continue
435
+ issues.append(TemplateIssue(rel, line, col, "html-form", HTML_FORM_MESSAGE))
436
+ return issues
437
+
438
+
439
+ def lint_fstring_components() -> list[TemplateIssue]:
440
+ allowed = _load_fstring_baseline()
441
+ return [
442
+ TemplateIssue(rel, line, col, "fstring-component", FSTRING_MESSAGE)
443
+ for rel, name, line, col in _fstring_component_returns()
444
+ if f"{rel}::{name}" not in allowed
445
+ ]
446
+
447
+
448
+ def lint_templates() -> list[TemplateIssue]:
449
+ """Lint every authored template under `src/`."""
450
+ issues: list[TemplateIssue] = []
451
+ for path in _iter_files():
452
+ try:
453
+ text = path.read_text(encoding="utf-8")
454
+ except OSError, UnicodeDecodeError:
455
+ continue
456
+ # Cheap pre-filter: a file with no brace expression and no angle-bracket
457
+ # markup cannot trip any rule.
458
+ if "{" not in text and "<" not in text:
459
+ continue
460
+ rel = path.relative_to(PROJECT_ROOT).as_posix()
461
+ issues.extend(lint_text(text, rel, is_python=path.suffix == ".py"))
462
+ issues.extend(lint_fstring_components())
463
+ issues.extend(lint_html_call_form())
464
+ return issues
465
+
466
+
467
+ def main() -> int:
468
+ import sys
469
+
470
+ if "--update-baseline" in sys.argv:
471
+ count = write_fstring_baseline()
472
+ print(
473
+ f"templates: recorded {count} f-string component(s) in "
474
+ f"{FSTRING_BASELINE_PATH.relative_to(PROJECT_ROOT).as_posix()}."
475
+ )
476
+ return 0
477
+
478
+ issues = lint_templates()
479
+ if not issues:
480
+ print("templates: no JSX or unknown directives found.")
481
+ return 0
482
+
483
+ by_file: dict[str, list[TemplateIssue]] = {}
484
+ for issue in issues:
485
+ by_file.setdefault(issue.path, []).append(issue)
486
+
487
+ for path in sorted(by_file):
488
+ print(path)
489
+ for issue in sorted(by_file[path], key=lambda i: (i.line, i.column)):
490
+ print(f" {issue.line}:{issue.column} [templates:{issue.code}] {issue.message}")
491
+
492
+ print(f"\n{len(issues)} template issue(s) found.")
493
+ return 1
494
+
495
+
496
+ if __name__ == "__main__":
497
+ raise SystemExit(main())