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.
- curvature-0.1.0/LICENSE +21 -0
- curvature-0.1.0/PKG-INFO +114 -0
- curvature-0.1.0/README.md +89 -0
- curvature-0.1.0/pyproject.toml +68 -0
- curvature-0.1.0/src/curvature/__init__.py +24 -0
- curvature-0.1.0/src/curvature/__main__.py +7 -0
- curvature-0.1.0/src/curvature/component.py +21 -0
- curvature-0.1.0/src/curvature/errors.py +7 -0
- curvature-0.1.0/src/curvature/fragments.py +54 -0
- curvature-0.1.0/src/curvature/gate/__init__.py +0 -0
- curvature-0.1.0/src/curvature/gate/checks.py +228 -0
- curvature-0.1.0/src/curvature/gate/cli.py +144 -0
- curvature-0.1.0/src/curvature/gate/findings.py +52 -0
- curvature-0.1.0/src/curvature/gate/ratchet.py +100 -0
- curvature-0.1.0/src/curvature/gate/scaffold.py +129 -0
- curvature-0.1.0/src/curvature/gate/templates/AGENTS.md.tmpl +56 -0
- curvature-0.1.0/src/curvature/gate/templates/README.md.tmpl +14 -0
- curvature-0.1.0/src/curvature/gate/templates/gate.sh.tmpl +7 -0
- curvature-0.1.0/src/curvature/gate/templates/gitignore.tmpl +10 -0
- curvature-0.1.0/src/curvature/gate/templates/main.py.tmpl +25 -0
- curvature-0.1.0/src/curvature/gate/templates/manifold.css.tmpl +57 -0
- curvature-0.1.0/src/curvature/gate/templates/pyproject.toml.tmpl +27 -0
- curvature-0.1.0/src/curvature/gate/templates/run.sh.tmpl +4 -0
- curvature-0.1.0/src/curvature/gate/templates/shell.py.tmpl +22 -0
- curvature-0.1.0/src/curvature/gate/templates/test_app.py.tmpl +19 -0
- curvature-0.1.0/src/curvature/gate/templates/welcome.py.tmpl +19 -0
- curvature-0.1.0/src/curvature/html.py +228 -0
- curvature-0.1.0/src/curvature/py.typed +0 -0
- curvature-0.1.0/src/curvature/static/curvature.js +87 -0
curvature-0.1.0/LICENSE
ADDED
|
@@ -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.
|
curvature-0.1.0/PKG-INFO
ADDED
|
@@ -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,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
|