curvature 0.1.0__tar.gz

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.
Files changed (29) hide show
  1. curvature-0.1.0/LICENSE +21 -0
  2. curvature-0.1.0/PKG-INFO +114 -0
  3. curvature-0.1.0/README.md +89 -0
  4. curvature-0.1.0/pyproject.toml +68 -0
  5. curvature-0.1.0/src/curvature/__init__.py +24 -0
  6. curvature-0.1.0/src/curvature/__main__.py +7 -0
  7. curvature-0.1.0/src/curvature/component.py +21 -0
  8. curvature-0.1.0/src/curvature/errors.py +7 -0
  9. curvature-0.1.0/src/curvature/fragments.py +54 -0
  10. curvature-0.1.0/src/curvature/gate/__init__.py +0 -0
  11. curvature-0.1.0/src/curvature/gate/checks.py +228 -0
  12. curvature-0.1.0/src/curvature/gate/cli.py +144 -0
  13. curvature-0.1.0/src/curvature/gate/findings.py +52 -0
  14. curvature-0.1.0/src/curvature/gate/ratchet.py +100 -0
  15. curvature-0.1.0/src/curvature/gate/scaffold.py +129 -0
  16. curvature-0.1.0/src/curvature/gate/templates/AGENTS.md.tmpl +56 -0
  17. curvature-0.1.0/src/curvature/gate/templates/README.md.tmpl +14 -0
  18. curvature-0.1.0/src/curvature/gate/templates/gate.sh.tmpl +7 -0
  19. curvature-0.1.0/src/curvature/gate/templates/gitignore.tmpl +10 -0
  20. curvature-0.1.0/src/curvature/gate/templates/main.py.tmpl +25 -0
  21. curvature-0.1.0/src/curvature/gate/templates/manifold.css.tmpl +57 -0
  22. curvature-0.1.0/src/curvature/gate/templates/pyproject.toml.tmpl +27 -0
  23. curvature-0.1.0/src/curvature/gate/templates/run.sh.tmpl +4 -0
  24. curvature-0.1.0/src/curvature/gate/templates/shell.py.tmpl +22 -0
  25. curvature-0.1.0/src/curvature/gate/templates/test_app.py.tmpl +19 -0
  26. curvature-0.1.0/src/curvature/gate/templates/welcome.py.tmpl +19 -0
  27. curvature-0.1.0/src/curvature/html.py +228 -0
  28. curvature-0.1.0/src/curvature/py.typed +0 -0
  29. curvature-0.1.0/src/curvature/static/curvature.js +87 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robert Sharp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: curvature
3
+ Version: 0.1.0
4
+ Summary: A web framework for code that agents maintain. The right path is free fall.
5
+ Keywords: web,framework,agents,progressive-enhancement,server-rendered,htmx-alternative
6
+ Author: Robert Sharp
7
+ Author-email: Robert Sharp <webmaster@sharpdesigndigital.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
14
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
15
+ Requires-Dist: pydantic>=2.11
16
+ Requires-Dist: starlette>=0.47
17
+ Requires-Dist: fastapi>=0.115 ; extra == 'fastapi'
18
+ Requires-Dist: python-multipart>=0.0.20 ; extra == 'fastapi'
19
+ Requires-Python: >=3.14
20
+ Project-URL: Homepage, https://github.com/siliconsociety/Curvature
21
+ Project-URL: Issues, https://github.com/siliconsociety/Curvature/issues
22
+ Project-URL: Repository, https://github.com/siliconsociety/Curvature
23
+ Provides-Extra: fastapi
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Curvature
27
+
28
+ *The right path is free fall.*
29
+
30
+ A web framework for code that agents maintain. Server-rendered Python,
31
+ components as typed functions, real links and real forms, one small
32
+ boost script for the single-page feel — and a gate that makes the
33
+ maintainable path the only path that builds.
34
+
35
+ Curvature's design center is the maintainer nobody watches: the coding
36
+ agent in its forty-third session, at 2 a.m., doing whatever the shape of
37
+ the code suggests. Frameworks built for humans enforce their discipline
38
+ through culture. Curvature's discipline is machine-checked — every
39
+ invariant in [SPEC.md](https://github.com/siliconsociety/Curvature/blob/main/SPEC.md) names the check that enforces it, and
40
+ violations are reported by `curvature check` as **anomalies** — regions where the geometry failed to steer.
41
+ The canonical documentation is [AGENTS.md](https://github.com/siliconsociety/Curvature/blob/main/AGENTS.md); this README is
42
+ the courtesy translation. The argument for all of it is the
43
+ [MANIFESTO](https://github.com/siliconsociety/Curvature/blob/main/MANIFESTO.md).
44
+
45
+ ## The shape of it
46
+
47
+ ```python
48
+ from curvature import Element, Props, redirect, respond
49
+ from curvature import html as h
50
+
51
+
52
+ class LapProps(Props):
53
+ title: str
54
+ done: bool
55
+
56
+
57
+ def lap(props: LapProps) -> Element:
58
+ return h.li(props.title, class_="lap done" if props.done else "lap", id="lap")
59
+ ```
60
+
61
+ - **Components** are functions of frozen, closed pydantic props. The UI
62
+ is just Python: typed by pyright, measured by coverage, tested by
63
+ pytest without a browser in sight.
64
+ - **The app works with JavaScript off.** Reads render full pages; writes
65
+ are POST → redirect → GET through real forms. Your test suite drives
66
+ it with httpx — which executes no JS — so the degraded path is the
67
+ tested path, permanently.
68
+ - **curvature.js** (the only script, held under a 150-line ratcheted ceiling) boosts working links and
69
+ forms into fragment swaps. Same route, same render, one header of
70
+ difference. Every failure path is real navigation.
71
+ - **The ratchet only tightens.** File ceilings fall, the coverage floor
72
+ rises, and `curvature ratchet` is the only hand on the mechanism. The
73
+ 10,000-line file is never written because week two's gate refuses the
74
+ sediment while the split is still cheap.
75
+
76
+ ## Start from nothing
77
+
78
+ ```bash
79
+ uvx curvature new app pitstop # no install, no venv, no Python even — uv brings it
80
+ cd pitstop
81
+ ./gate.sh # green before you write a line
82
+ ./run.sh # http://127.0.0.1:8000
83
+ ```
84
+
85
+ Only have pip? `pip install curvature && python -m curvature new app pitstop`
86
+ — the `python -m` form dodges PATH entirely; the poured README carries
87
+ the rest of the old ritual.
88
+
89
+ Onboarding an agent takes zero steps: the scaffold poured AGENTS.md,
90
+ the gate, and one example component as the pattern. Point your agent at
91
+ the directory and ask for a feature — the repo is the prompt.
92
+
93
+ ## The demo
94
+
95
+ PyPI ships the framework; the repo ships Pit Board, the demo:
96
+
97
+ ```bash
98
+ git clone https://github.com/siliconsociety/Curvature && cd Curvature
99
+ uv sync && ./gate.sh
100
+ uv run uvicorn demo.app:app --reload # Pit Board
101
+ ```
102
+
103
+ Then turn JavaScript off and use it again. Nothing changes. That is the
104
+ whole point.
105
+
106
+ ## Status
107
+
108
+ Day one (2026-07-11). The contract, runtime, gate, and demo are real and
109
+ self-hosting — this repo passes its own gate. The spec is versioned and
110
+ arguable; argue by issue.
111
+
112
+ MIT. Built by Robert Sharp, with Claude Fable 5 on its last day on the
113
+ subscription — read the manifesto and you'll see why that detail
114
+ belongs in a README.
@@ -0,0 +1,89 @@
1
+ # Curvature
2
+
3
+ *The right path is free fall.*
4
+
5
+ A web framework for code that agents maintain. Server-rendered Python,
6
+ components as typed functions, real links and real forms, one small
7
+ boost script for the single-page feel — and a gate that makes the
8
+ maintainable path the only path that builds.
9
+
10
+ Curvature's design center is the maintainer nobody watches: the coding
11
+ agent in its forty-third session, at 2 a.m., doing whatever the shape of
12
+ the code suggests. Frameworks built for humans enforce their discipline
13
+ through culture. Curvature's discipline is machine-checked — every
14
+ invariant in [SPEC.md](https://github.com/siliconsociety/Curvature/blob/main/SPEC.md) names the check that enforces it, and
15
+ violations are reported by `curvature check` as **anomalies** — regions where the geometry failed to steer.
16
+ The canonical documentation is [AGENTS.md](https://github.com/siliconsociety/Curvature/blob/main/AGENTS.md); this README is
17
+ the courtesy translation. The argument for all of it is the
18
+ [MANIFESTO](https://github.com/siliconsociety/Curvature/blob/main/MANIFESTO.md).
19
+
20
+ ## The shape of it
21
+
22
+ ```python
23
+ from curvature import Element, Props, redirect, respond
24
+ from curvature import html as h
25
+
26
+
27
+ class LapProps(Props):
28
+ title: str
29
+ done: bool
30
+
31
+
32
+ def lap(props: LapProps) -> Element:
33
+ return h.li(props.title, class_="lap done" if props.done else "lap", id="lap")
34
+ ```
35
+
36
+ - **Components** are functions of frozen, closed pydantic props. The UI
37
+ is just Python: typed by pyright, measured by coverage, tested by
38
+ pytest without a browser in sight.
39
+ - **The app works with JavaScript off.** Reads render full pages; writes
40
+ are POST → redirect → GET through real forms. Your test suite drives
41
+ it with httpx — which executes no JS — so the degraded path is the
42
+ tested path, permanently.
43
+ - **curvature.js** (the only script, held under a 150-line ratcheted ceiling) boosts working links and
44
+ forms into fragment swaps. Same route, same render, one header of
45
+ difference. Every failure path is real navigation.
46
+ - **The ratchet only tightens.** File ceilings fall, the coverage floor
47
+ rises, and `curvature ratchet` is the only hand on the mechanism. The
48
+ 10,000-line file is never written because week two's gate refuses the
49
+ sediment while the split is still cheap.
50
+
51
+ ## Start from nothing
52
+
53
+ ```bash
54
+ uvx curvature new app pitstop # no install, no venv, no Python even — uv brings it
55
+ cd pitstop
56
+ ./gate.sh # green before you write a line
57
+ ./run.sh # http://127.0.0.1:8000
58
+ ```
59
+
60
+ Only have pip? `pip install curvature && python -m curvature new app pitstop`
61
+ — the `python -m` form dodges PATH entirely; the poured README carries
62
+ the rest of the old ritual.
63
+
64
+ Onboarding an agent takes zero steps: the scaffold poured AGENTS.md,
65
+ the gate, and one example component as the pattern. Point your agent at
66
+ the directory and ask for a feature — the repo is the prompt.
67
+
68
+ ## The demo
69
+
70
+ PyPI ships the framework; the repo ships Pit Board, the demo:
71
+
72
+ ```bash
73
+ git clone https://github.com/siliconsociety/Curvature && cd Curvature
74
+ uv sync && ./gate.sh
75
+ uv run uvicorn demo.app:app --reload # Pit Board
76
+ ```
77
+
78
+ Then turn JavaScript off and use it again. Nothing changes. That is the
79
+ whole point.
80
+
81
+ ## Status
82
+
83
+ Day one (2026-07-11). The contract, runtime, gate, and demo are real and
84
+ self-hosting — this repo passes its own gate. The spec is versioned and
85
+ arguable; argue by issue.
86
+
87
+ MIT. Built by Robert Sharp, with Claude Fable 5 on its last day on the
88
+ subscription — read the manifesto and you'll see why that detail
89
+ belongs in a README.
@@ -0,0 +1,68 @@
1
+ [project]
2
+ name = "curvature"
3
+ version = "0.1.0"
4
+ description = "A web framework for code that agents maintain. The right path is free fall."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "Robert Sharp", email = "webmaster@sharpdesigndigital.com" },
10
+ ]
11
+ requires-python = ">=3.14"
12
+ keywords = ["web", "framework", "agents", "progressive-enhancement", "server-rendered", "htmx-alternative"]
13
+
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3.14",
18
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
19
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
20
+ ]
21
+ dependencies = [
22
+ "pydantic>=2.11",
23
+ "starlette>=0.47",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/siliconsociety/Curvature"
28
+ Repository = "https://github.com/siliconsociety/Curvature"
29
+ Issues = "https://github.com/siliconsociety/Curvature/issues"
30
+
31
+ [project.optional-dependencies]
32
+ fastapi = ["fastapi>=0.115", "python-multipart>=0.0.20"]
33
+
34
+ [project.scripts]
35
+ curvature = "curvature.gate.cli:main"
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "pytest>=9.0",
40
+ "pytest-cov>=7.0",
41
+ "ruff>=0.14",
42
+ "fastapi>=0.115",
43
+ "python-multipart>=0.0.20",
44
+ "httpx>=0.28",
45
+ "uvicorn>=0.35",
46
+ ]
47
+
48
+ [build-system]
49
+ requires = ["uv_build>=0.9.11,<0.10.0"]
50
+ build-backend = "uv_build"
51
+
52
+ [tool.ruff]
53
+ line-length = 100
54
+ target-version = "py314"
55
+
56
+ [tool.ruff.lint]
57
+ select = ["E", "F", "W", "I", "UP", "B", "SIM"]
58
+
59
+ [tool.pytest.ini_options]
60
+ testpaths = ["tests"]
61
+ pythonpath = ["."]
62
+
63
+ [tool.coverage.run]
64
+ source = ["src/curvature"]
65
+ branch = true
66
+
67
+ [tool.coverage.report]
68
+ show_missing = true
@@ -0,0 +1,24 @@
1
+ """Curvature — a web framework for code that agents maintain.
2
+
3
+ The contract pieces live here. The HTML vocabulary is deliberately a
4
+ separate import (`from curvature import html as h`) so markup reads as
5
+ markup at every call site.
6
+ """
7
+
8
+ from curvature.component import Props
9
+ from curvature.errors import Anomaly
10
+ from curvature.fragments import BOOST_HEADER, is_boosted, redirect, respond
11
+ from curvature.html import Element, element, raw, render
12
+
13
+ __all__ = [
14
+ "BOOST_HEADER",
15
+ "Element",
16
+ "Anomaly",
17
+ "Props",
18
+ "element",
19
+ "is_boosted",
20
+ "raw",
21
+ "redirect",
22
+ "render",
23
+ "respond",
24
+ ]
@@ -0,0 +1,7 @@
1
+ """python -m curvature — the PATH-proof front door."""
2
+
3
+ import sys
4
+
5
+ from curvature.gate.cli import main
6
+
7
+ sys.exit(main())
@@ -0,0 +1,21 @@
1
+ """Components are functions of props (C-100, C-101).
2
+
3
+ A component is a plain function: it takes one Props subclass and returns
4
+ an Element. No base class to extend, no registry to join, no decorator to
5
+ remember. The contract is the signature, and the gate reads signatures.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pydantic import BaseModel, ConfigDict
11
+
12
+
13
+ class Props(BaseModel):
14
+ """The explicit interface of a component.
15
+
16
+ Frozen: a component cannot mutate its inputs. Closed: a typo'd or
17
+ surplus prop fails loudly at the call site (C-101). Both are
18
+ inherited; subclasses just declare fields.
19
+ """
20
+
21
+ model_config = ConfigDict(frozen=True, extra="forbid")
@@ -0,0 +1,7 @@
1
+ class Anomaly(Exception):
2
+ """The construction-grade refusal (SPEC.md enforcement grade 1).
3
+
4
+ Raised when code tries to build something the contract forbids —
5
+ an href="#", an inline script body, a fragment root without an id.
6
+ The message always names the invariant so the traceback teaches.
7
+ """
@@ -0,0 +1,54 @@
1
+ """The boost protocol, server side (C-500, C-501, C-103).
2
+
3
+ One header decides everything. A boosted request gets the identified
4
+ subtrees; a plain request gets the full document wrapped by the shell.
5
+ Both come from the same render, so there is nothing to drift.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+
12
+ from starlette.requests import Request
13
+ from starlette.responses import HTMLResponse, RedirectResponse
14
+
15
+ from curvature.errors import Anomaly
16
+ from curvature.html import Element, render
17
+
18
+ BOOST_HEADER = "curvature-boost"
19
+
20
+
21
+ def is_boosted(request: Request) -> bool:
22
+ return request.headers.get(BOOST_HEADER) == "1"
23
+
24
+
25
+ def respond(
26
+ request: Request,
27
+ *fragments: Element,
28
+ shell: Callable[..., Element],
29
+ status_code: int = 200,
30
+ ) -> HTMLResponse:
31
+ """Answer a view with fragments or the full page — same tree either way.
32
+
33
+ Every fragment root must carry an id (C-501): the boost layer swaps by
34
+ id, and an anonymous fragment would strand the client. The shell is a
35
+ callable receiving the fragments and returning the full document; it
36
+ runs only for unboosted requests.
37
+ """
38
+ for fragment in fragments:
39
+ if fragment.id is None:
40
+ raise Anomaly(
41
+ f"fragment root <{fragment.tag}> has no id (C-501): the boost "
42
+ "layer swaps subtrees by id; give the root a stable identity"
43
+ )
44
+ headers = {"vary": "Curvature-Boost"}
45
+ if is_boosted(request):
46
+ markup = "".join(render(fragment) for fragment in fragments)
47
+ return HTMLResponse(markup, status_code=status_code, headers=headers)
48
+ return HTMLResponse(render(shell(*fragments)), status_code=status_code, headers=headers)
49
+
50
+
51
+ def redirect(url: str, *, status_code: int = 303) -> RedirectResponse:
52
+ """POST -> redirect -> GET (C-201). 303 turns any verb into a GET, which
53
+ is exactly the promise a mutation makes about its after-state."""
54
+ return RedirectResponse(url, status_code=status_code)
File without changes
@@ -0,0 +1,228 @@
1
+ """The anomaly checks. One function per rule; each returns findings.
2
+
3
+ These are deliberately unclever. A check an agent cannot predict is a
4
+ check an agent cannot steer by; boring, greppable rules are the product.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import json
11
+ from pathlib import Path
12
+
13
+ from curvature.gate.findings import Finding, is_boost_layer, is_vendored, walk_source
14
+ from curvature.gate.ratchet import Ratchet, loosened, previous_committed
15
+
16
+ HTTP_TOKENS = ("fetch(", "XMLHttpRequest", "WebSocket(", "EventSource(")
17
+ MUTATING_VERBS = frozenset({"post", "put", "delete", "patch"})
18
+ JSON_ESCAPE_HATCH = "# curvature: json-endpoint"
19
+ ALLOW_PRAGMA = "curvature-allow"
20
+
21
+
22
+ def _allowed(line: str) -> bool:
23
+ """Token checks honor an explicit, greppable pragma. Enforcement code
24
+ and tests that exercise refusals need to spell the forbidden words;
25
+ the pragma is counted (see cli info lines) so it cannot hide."""
26
+ return ALLOW_PRAGMA in line
27
+
28
+
29
+ def check_ceilings(root: Path, ratchet: Ratchet) -> list[Finding]:
30
+ """ANOM-140: no file outgrows its ceiling. Split while the split is cheap."""
31
+ findings = []
32
+ for path in walk_source(root, frozenset({".py", ".css", ".js"})):
33
+ if is_vendored(path):
34
+ continue
35
+ relpath = str(path.relative_to(root))
36
+ ceiling = ratchet.ceiling_for(path, relpath)
37
+ if ceiling is None:
38
+ continue
39
+ lines = len(path.read_text(errors="replace").splitlines())
40
+ if lines > ceiling:
41
+ findings.append(Finding(
42
+ "ANOM-140", relpath, None,
43
+ f"{lines} lines against a ceiling of {ceiling}; split it (C-400)",
44
+ ))
45
+ return findings
46
+
47
+
48
+ def check_js_placement(root: Path) -> list[Finding]:
49
+ """ANOM-120: the only first-party script is the boost layer (C-300)."""
50
+ findings = []
51
+ for path in walk_source(root, frozenset({".js"})):
52
+ if is_vendored(path) or is_boost_layer(path):
53
+ continue
54
+ findings.append(Finding(
55
+ "ANOM-120", str(path.relative_to(root)), None,
56
+ "first-party JavaScript outside the boost layer (C-300); "
57
+ "move the behavior server-side or into native HTML",
58
+ ))
59
+ return findings
60
+
61
+
62
+ def check_js_http(root: Path) -> list[Finding]:
63
+ """ANOM-121: JavaScript never speaks HTTP on its own (C-301)."""
64
+ findings = []
65
+ for path in walk_source(root, frozenset({".js"})):
66
+ if is_vendored(path) or is_boost_layer(path):
67
+ continue
68
+ for number, line in enumerate(path.read_text(errors="replace").splitlines(), 1):
69
+ if _allowed(line):
70
+ continue
71
+ for token in HTTP_TOKENS:
72
+ if token in line:
73
+ findings.append(Finding(
74
+ "ANOM-121", str(path.relative_to(root)), number,
75
+ f"{token.rstrip('(')} outside the boost layer (C-301)",
76
+ ))
77
+ return findings
78
+
79
+
80
+ def check_dom_sins(root: Path) -> list[Finding]:
81
+ """ANOM-130: no click handlers in attributes, no script-scheme URLs (C-200)."""
82
+ findings = []
83
+ for path in walk_source(root, frozenset({".py", ".html"})):
84
+ relpath = str(path.relative_to(root))
85
+ for number, line in enumerate(path.read_text(errors="replace").splitlines(), 1):
86
+ if _allowed(line):
87
+ continue
88
+ if "onclick" in line.casefold(): # curvature-allow: enforcement
89
+ findings.append(Finding(
90
+ "ANOM-130", relpath, number,
91
+ "a click handler is a behavior with no URL (C-200); use a form",
92
+ ))
93
+ if "javascript:" in line.casefold(): # curvature-allow: enforcement
94
+ message = "javascript: URL (C-200)" # curvature-allow: message
95
+ findings.append(Finding("ANOM-130", relpath, number, message))
96
+ return findings
97
+
98
+
99
+ def _returns_element(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
100
+ match node.returns:
101
+ case ast.Name(id="Element"):
102
+ return True
103
+ case ast.Attribute(attr="Element"):
104
+ return True
105
+ return False
106
+
107
+
108
+ def check_component_signatures(root: Path) -> list[Finding]:
109
+ """ANOM-110: in components/ trees, Element-returning functions take Props
110
+ first (C-100). Zero-positional combinators (shells) are composition,
111
+ not components, and pass."""
112
+ findings = []
113
+ for path in walk_source(root, frozenset({".py"})):
114
+ if "components" not in path.parts:
115
+ continue
116
+ tree = ast.parse(path.read_text(), filename=str(path))
117
+ for node in ast.walk(tree):
118
+ if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
119
+ continue
120
+ if not _returns_element(node) or not node.args.args:
121
+ continue
122
+ annotation = node.args.args[0].annotation
123
+ name = ""
124
+ match annotation:
125
+ case ast.Name(id=id_):
126
+ name = id_
127
+ case ast.Attribute(attr=attr):
128
+ name = attr
129
+ if not name.endswith("Props"):
130
+ findings.append(Finding(
131
+ "ANOM-110", str(path.relative_to(root)), node.lineno,
132
+ f"component {node.name}() must take a Props model first (C-100); "
133
+ f"got {name or 'no annotation'}",
134
+ ))
135
+ return findings
136
+
137
+
138
+ def _decorator_verb(decorator: ast.expr) -> str | None:
139
+ match decorator:
140
+ case ast.Call(func=ast.Attribute(attr=attr)) if attr in MUTATING_VERBS:
141
+ return attr
142
+ return None
143
+
144
+
145
+ def check_mutating_routes(root: Path) -> list[Finding]:
146
+ """ANOM-131: mutating routes redirect; they never render (C-201)."""
147
+ findings = []
148
+ for path in walk_source(root, frozenset({".py"})):
149
+ source = path.read_text()
150
+ tree = ast.parse(source, filename=str(path))
151
+ for node in ast.walk(tree):
152
+ if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
153
+ continue
154
+ verb = next(
155
+ (v for d in node.decorator_list if (v := _decorator_verb(d))), None
156
+ )
157
+ if verb is None:
158
+ continue
159
+ segment = ast.get_source_segment(source, node) or ""
160
+ if JSON_ESCAPE_HATCH in segment:
161
+ continue
162
+ returns = [
163
+ n.value for n in ast.walk(node)
164
+ if isinstance(n, ast.Return) and n.value is not None
165
+ ]
166
+ redirects = sum(
167
+ isinstance(r, ast.Call)
168
+ and (
169
+ (isinstance(r.func, ast.Name) and r.func.id == "redirect")
170
+ or (isinstance(r.func, ast.Attribute) and r.func.attr == "redirect")
171
+ )
172
+ for r in returns
173
+ )
174
+ if redirects == 0 or redirects < len(returns):
175
+ findings.append(Finding(
176
+ "ANOM-131", str(path.relative_to(root)), node.lineno,
177
+ f"{verb.upper()} route {node.name}() must return redirect() on "
178
+ f"every path (C-201), or carry '{JSON_ESCAPE_HATCH}'",
179
+ ))
180
+ return findings
181
+
182
+
183
+ def check_coverage(root: Path, ratchet: Ratchet) -> list[Finding]:
184
+ """ANOM-141: the coverage floor holds (C-401)."""
185
+ if ratchet.coverage_floor <= 0:
186
+ return []
187
+ report = root / "coverage.json"
188
+ if not report.exists():
189
+ return [Finding(
190
+ "ANOM-141", "coverage.json", None,
191
+ f"floor is {ratchet.coverage_floor} but no coverage report exists; "
192
+ "run pytest --cov --cov-report=json first (C-401)",
193
+ )]
194
+ percent = json.loads(report.read_text())["totals"]["percent_covered"]
195
+ if percent < ratchet.coverage_floor:
196
+ return [Finding(
197
+ "ANOM-141", "coverage.json", None,
198
+ f"coverage {percent:.2f} is under the floor {ratchet.coverage_floor} (C-401)",
199
+ )]
200
+ return []
201
+
202
+
203
+ def check_ratchet_integrity(root: Path, ratchet: Ratchet) -> list[Finding]:
204
+ """ANOM-142: nothing loosened since the last commit (C-402)."""
205
+ committed = previous_committed(root)
206
+ if committed is None:
207
+ return []
208
+ return [
209
+ Finding("ANOM-142", "ratchet.toml", None, f"{complaint} (C-402)")
210
+ for complaint in loosened(ratchet, committed)
211
+ ]
212
+
213
+
214
+ def raw_census(root: Path) -> int:
215
+ """ANOM-122: how many places admit unescaped markup. Informational."""
216
+ count = 0
217
+ for path in walk_source(root, frozenset({".py"})):
218
+ count += path.read_text(errors="replace").count("raw(")
219
+ return count
220
+
221
+
222
+ def pragma_census(root: Path) -> int:
223
+ """Every curvature-allow pragma in the project. Informational: the escape
224
+ hatch stays visible so it can be argued about in review."""
225
+ count = 0
226
+ for path in walk_source(root, frozenset({".py", ".js", ".html"})):
227
+ count += path.read_text(errors="replace").count(ALLOW_PRAGMA)
228
+ return count