terp-arch 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.
- terp_arch-0.1.0/.gitignore +47 -0
- terp_arch-0.1.0/PKG-INFO +6 -0
- terp_arch-0.1.0/pyproject.toml +16 -0
- terp_arch-0.1.0/src/terp/arch/__init__.py +138 -0
- terp_arch-0.1.0/src/terp/arch/_ast.py +69 -0
- terp_arch-0.1.0/src/terp/arch/py.typed +0 -0
- terp_arch-0.1.0/src/terp/arch/rules/__init__.py +341 -0
- terp_arch-0.1.0/src/terp/arch/rules/_support.py +473 -0
- terp_arch-0.1.0/src/terp/arch/rules/authz.py +461 -0
- terp_arch-0.1.0/src/terp/arch/rules/budget.py +167 -0
- terp_arch-0.1.0/src/terp/arch/rules/events.py +96 -0
- terp_arch-0.1.0/src/terp/arch/rules/http.py +772 -0
- terp_arch-0.1.0/src/terp/arch/rules/imports.py +321 -0
- terp_arch-0.1.0/src/terp/arch/rules/jobs.py +86 -0
- terp_arch-0.1.0/src/terp/arch/rules/migrations.py +108 -0
- terp_arch-0.1.0/src/terp/arch/rules/persistence.py +624 -0
- terp_arch-0.1.0/src/terp/arch/rules/secrets.py +167 -0
- terp_arch-0.1.0/src/terp/arch/rules/structure.py +71 -0
- terp_arch-0.1.0/src/terp/arch/rules/traits.py +477 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
.venv-*/
|
|
10
|
+
venv/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
|
|
17
|
+
# uv
|
|
18
|
+
uv.lock
|
|
19
|
+
|
|
20
|
+
# Node
|
|
21
|
+
node_modules/
|
|
22
|
+
.pnpm-store/
|
|
23
|
+
*.tsbuildinfo
|
|
24
|
+
|
|
25
|
+
# Playwright (conformance e2e) artifacts
|
|
26
|
+
test-results/
|
|
27
|
+
playwright-report/
|
|
28
|
+
blob-report/
|
|
29
|
+
playwright/.cache/
|
|
30
|
+
.last-run.json
|
|
31
|
+
|
|
32
|
+
# Local frontend template render checks
|
|
33
|
+
apps/example/_frontend_tpl_check/
|
|
34
|
+
|
|
35
|
+
# Editor / OS
|
|
36
|
+
.DS_Store
|
|
37
|
+
.idea/
|
|
38
|
+
*.local
|
|
39
|
+
|
|
40
|
+
# Local environment overrides — never commit (a real .env may hold SECRET_KEY).
|
|
41
|
+
# The tracked template is `.env.example`.
|
|
42
|
+
.env
|
|
43
|
+
.env.*
|
|
44
|
+
!.env.example
|
|
45
|
+
!.env.example.jinja
|
|
46
|
+
# Rendered app-declared variables (environment.schema.json) — may hold secrets.
|
|
47
|
+
.app.env
|
terp_arch-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "terp-arch"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Terp enforcement harness — secure-by-default fitness rules, shipped as a dependency clients run but cannot edit."
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
# PEP 420 namespace package: this distribution owns only `terp.arch`.
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
sources = ["src"]
|
|
16
|
+
only-include = ["src/terp/arch"]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""terp.arch — the Terp enforcement harness, shipped as a versioned dependency.
|
|
2
|
+
|
|
3
|
+
The bespoke secure-by-default fitness rules (design §5.10): each is the
|
|
4
|
+
**build-time layer** that pairs with a fail-closed runtime control in
|
|
5
|
+
``terp.core`` (or a capability). Clients *run* these rules against their own
|
|
6
|
+
``app/`` but cannot edit them — the harness travels as a package.
|
|
7
|
+
|
|
8
|
+
Typical use in a client repo::
|
|
9
|
+
|
|
10
|
+
from terp.arch import assert_app_clean
|
|
11
|
+
|
|
12
|
+
def test_architecture() -> None:
|
|
13
|
+
assert_app_clean("app")
|
|
14
|
+
|
|
15
|
+
Generic layering/boundary checks are delegated to Tach/import-linter and
|
|
16
|
+
dependency hygiene to deptry/pip-audit (design §8); only the domain-specific
|
|
17
|
+
rules are hand-rolled here. Secure-by-default opt-outs are governed: a justified
|
|
18
|
+
``# arch-allow-<rule>: <reason>`` comment suppresses a single violation, and
|
|
19
|
+
:func:`check_escape_hatch_budget` ratchets the marker counts against a checked-in
|
|
20
|
+
budget so opt-outs stay visible, greppable, and can only shrink.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from terp.arch.rules import (
|
|
26
|
+
GUIDE_TOPIC_BY_RULE,
|
|
27
|
+
ArchViolation,
|
|
28
|
+
assert_app_clean,
|
|
29
|
+
check_app,
|
|
30
|
+
check_base_query_not_overridden,
|
|
31
|
+
check_canonical_module_shape,
|
|
32
|
+
check_escape_hatch_budget,
|
|
33
|
+
check_events_reference_catalog,
|
|
34
|
+
check_input_schemas_exclude_managed_columns,
|
|
35
|
+
check_input_str_fields_have_max_length,
|
|
36
|
+
check_jobs_reference_catalog,
|
|
37
|
+
check_list_routes_paginate,
|
|
38
|
+
check_modules_declare_policy,
|
|
39
|
+
check_mutations_emit_audit,
|
|
40
|
+
check_mutations_require_write_role,
|
|
41
|
+
check_no_adhoc_background_runtime,
|
|
42
|
+
check_no_adhoc_config_decrypt,
|
|
43
|
+
check_no_adhoc_logging_config,
|
|
44
|
+
check_no_adhoc_middleware,
|
|
45
|
+
check_no_adhoc_permission_literals,
|
|
46
|
+
check_no_destructive_migrations,
|
|
47
|
+
check_no_dynamic_sql,
|
|
48
|
+
check_no_hardcoded_credentials,
|
|
49
|
+
check_no_raw_outbound_http,
|
|
50
|
+
check_policy_refs_resolve,
|
|
51
|
+
check_no_app_instantiation,
|
|
52
|
+
check_no_cross_module_imports,
|
|
53
|
+
check_no_internal_imports,
|
|
54
|
+
check_no_manual_actor_stamping,
|
|
55
|
+
check_no_manual_ownership_checks,
|
|
56
|
+
check_no_dependency_overrides,
|
|
57
|
+
check_no_raw_app_routes,
|
|
58
|
+
check_no_raw_file_references,
|
|
59
|
+
check_no_manual_scope_filtering,
|
|
60
|
+
check_no_raw_connection_access,
|
|
61
|
+
check_no_raw_session_construction,
|
|
62
|
+
check_no_unique_columns_on_soft_delete_models,
|
|
63
|
+
check_public_modules_are_read_only,
|
|
64
|
+
check_reads_use_base_query,
|
|
65
|
+
check_response_model_not_table_model,
|
|
66
|
+
check_routes_declare_response_model,
|
|
67
|
+
check_safe_methods_are_read_only,
|
|
68
|
+
check_schemas_exclude_sensitive_fields,
|
|
69
|
+
check_session_imported_from_sqlmodel,
|
|
70
|
+
check_table_models_use_base_table,
|
|
71
|
+
check_no_manual_table_schema,
|
|
72
|
+
check_tables_have_migrations,
|
|
73
|
+
check_tenant_scoped_models_use_scoped_service,
|
|
74
|
+
guide_topic_for,
|
|
75
|
+
ungoverned_marker_violations,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
#: The Terp Standard version this harness is certified against — the ``spec_version``
|
|
79
|
+
#: a check report (``app-check-report.schema.json``) carries. A constant rather than a
|
|
80
|
+
#: runtime ``terp_spec`` read: the spec data package is a certification dependency of
|
|
81
|
+
#: the platform repo, not of a generated app, and the version is a property of the
|
|
82
|
+
#: toolchain build. Held equal to the pinned ``terp-spec`` release by the framework
|
|
83
|
+
#: gate (``tests/architecture/test_check_json.py``), so it cannot drift silently.
|
|
84
|
+
SPEC_VERSION = "0.12.0"
|
|
85
|
+
|
|
86
|
+
__all__ = [
|
|
87
|
+
"ArchViolation",
|
|
88
|
+
"GUIDE_TOPIC_BY_RULE",
|
|
89
|
+
"SPEC_VERSION",
|
|
90
|
+
"assert_app_clean",
|
|
91
|
+
"check_app",
|
|
92
|
+
"check_base_query_not_overridden",
|
|
93
|
+
"check_canonical_module_shape",
|
|
94
|
+
"check_escape_hatch_budget",
|
|
95
|
+
"check_events_reference_catalog",
|
|
96
|
+
"check_input_schemas_exclude_managed_columns",
|
|
97
|
+
"check_input_str_fields_have_max_length",
|
|
98
|
+
"check_jobs_reference_catalog",
|
|
99
|
+
"check_list_routes_paginate",
|
|
100
|
+
"check_modules_declare_policy",
|
|
101
|
+
"check_mutations_emit_audit",
|
|
102
|
+
"check_mutations_require_write_role",
|
|
103
|
+
"check_no_adhoc_background_runtime",
|
|
104
|
+
"check_no_adhoc_config_decrypt",
|
|
105
|
+
"check_no_adhoc_logging_config",
|
|
106
|
+
"check_no_adhoc_middleware",
|
|
107
|
+
"check_no_adhoc_permission_literals",
|
|
108
|
+
"check_no_app_instantiation",
|
|
109
|
+
"check_no_destructive_migrations",
|
|
110
|
+
"check_no_dynamic_sql",
|
|
111
|
+
"check_no_hardcoded_credentials",
|
|
112
|
+
"check_no_raw_outbound_http",
|
|
113
|
+
"check_no_cross_module_imports",
|
|
114
|
+
"check_no_internal_imports",
|
|
115
|
+
"check_no_manual_actor_stamping",
|
|
116
|
+
"check_no_manual_ownership_checks",
|
|
117
|
+
"check_no_dependency_overrides",
|
|
118
|
+
"check_no_raw_app_routes",
|
|
119
|
+
"check_no_raw_file_references",
|
|
120
|
+
"check_no_manual_scope_filtering",
|
|
121
|
+
"check_no_raw_connection_access",
|
|
122
|
+
"check_no_raw_session_construction",
|
|
123
|
+
"check_no_unique_columns_on_soft_delete_models",
|
|
124
|
+
"check_policy_refs_resolve",
|
|
125
|
+
"check_public_modules_are_read_only",
|
|
126
|
+
"check_reads_use_base_query",
|
|
127
|
+
"check_response_model_not_table_model",
|
|
128
|
+
"check_routes_declare_response_model",
|
|
129
|
+
"check_safe_methods_are_read_only",
|
|
130
|
+
"check_schemas_exclude_sensitive_fields",
|
|
131
|
+
"check_session_imported_from_sqlmodel",
|
|
132
|
+
"check_table_models_use_base_table",
|
|
133
|
+
"check_no_manual_table_schema",
|
|
134
|
+
"check_tables_have_migrations",
|
|
135
|
+
"check_tenant_scoped_models_use_scoped_service",
|
|
136
|
+
"guide_topic_for",
|
|
137
|
+
"ungoverned_marker_violations",
|
|
138
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""AST + filesystem helpers for the Terp architecture harness (internal).
|
|
2
|
+
|
|
3
|
+
Pure, side-effect-free scanning utilities shared by the rules in
|
|
4
|
+
:mod:`terp.arch.rules`. Nothing here imports an app's domain code — the harness
|
|
5
|
+
is a static analyser, so it runs in well under a second and can gate every push.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import pathlib
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
|
|
14
|
+
# Directory names that never contain enforceable application surface. ``tests``
|
|
15
|
+
# is skipped because test code legitimately constructs sessions/engines and
|
|
16
|
+
# fixtures that the runtime rules forbid in app code.
|
|
17
|
+
_SKIP_DIRS = frozenset({"__pycache__", "tests", ".venv", "node_modules", "migrations"})
|
|
18
|
+
|
|
19
|
+
# The skip set for *security* rules: ``tests`` and ``migrations`` are still
|
|
20
|
+
# importable Python, so credentials, dynamic SQL, and raw egress hidden there
|
|
21
|
+
# would otherwise dodge every scan while running at import time. Security rules
|
|
22
|
+
# therefore skip only the dirs that genuinely hold no application code.
|
|
23
|
+
_SECURITY_SKIP_DIRS = frozenset({"__pycache__", ".venv", "node_modules"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def iter_python_files(
|
|
27
|
+
root: pathlib.Path, *, skip_dirs: frozenset[str] = _SKIP_DIRS
|
|
28
|
+
) -> Iterator[pathlib.Path]:
|
|
29
|
+
"""Yield every ``*.py`` file under *root*, skipping dirs named in *skip_dirs*."""
|
|
30
|
+
for path in sorted(root.rglob("*.py")):
|
|
31
|
+
if any(part in skip_dirs for part in path.parts):
|
|
32
|
+
continue
|
|
33
|
+
yield path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse(path: pathlib.Path) -> ast.Module:
|
|
37
|
+
"""Parse *path* into an AST module."""
|
|
38
|
+
return ast.parse(path.read_text(encoding="utf-8"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def iter_imports(tree: ast.Module) -> Iterator[tuple[str, int]]:
|
|
42
|
+
"""Yield ``(absolute_module, lineno)`` for every absolute import in *tree*."""
|
|
43
|
+
for node in ast.walk(tree):
|
|
44
|
+
if isinstance(node, ast.Import):
|
|
45
|
+
for alias in node.names:
|
|
46
|
+
yield alias.name, node.lineno
|
|
47
|
+
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
|
48
|
+
yield node.module, node.lineno
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def base_name(node: ast.expr) -> str | None:
|
|
52
|
+
"""Return the simple name of a base / decorator / annotation expression.
|
|
53
|
+
|
|
54
|
+
Unwraps attribute access (``a.b.C`` → ``"C"``) and subscripts
|
|
55
|
+
(``BaseService[X]`` → ``"BaseService"``) so callers can match on the leaf
|
|
56
|
+
identifier without importing anything.
|
|
57
|
+
"""
|
|
58
|
+
if isinstance(node, ast.Name):
|
|
59
|
+
return node.id
|
|
60
|
+
if isinstance(node, ast.Attribute):
|
|
61
|
+
return node.attr
|
|
62
|
+
if isinstance(node, ast.Subscript):
|
|
63
|
+
return base_name(node.value)
|
|
64
|
+
if isinstance(node, ast.Call):
|
|
65
|
+
return base_name(node.func)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
__all__ = ["_SECURITY_SKIP_DIRS", "base_name", "iter_imports", "iter_python_files", "parse"]
|
|
File without changes
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""The Terp secure-by-default fitness rules (design §5.10), shipped as a dependency.
|
|
2
|
+
|
|
3
|
+
Each rule is a pure function that scans a client app's source tree and returns a
|
|
4
|
+
list of :class:`ArchViolation`. They are the **build-time layer** of Terp's
|
|
5
|
+
two-layer enforcement: a rule whose invariant the running system can observe
|
|
6
|
+
pairs with a fail-closed runtime control in ``terp.core`` (or a capability),
|
|
7
|
+
and which rules those are is recorded per rule in the Terp Standard catalog
|
|
8
|
+
(``runtime.applicability``, ADR 0084) -- a source-form rule is build-time-only
|
|
9
|
+
by recorded decision, with its rationale in its catalog entry. Clients *run*
|
|
10
|
+
these rules against their own ``app/`` but cannot edit them -- the harness
|
|
11
|
+
travels as a versioned package.
|
|
12
|
+
|
|
13
|
+
Run them all with :func:`assert_app_clean`::
|
|
14
|
+
|
|
15
|
+
from terp.arch import assert_app_clean
|
|
16
|
+
|
|
17
|
+
def test_architecture() -> None:
|
|
18
|
+
assert_app_clean("app") # the app package on sys.path
|
|
19
|
+
|
|
20
|
+
The rules are deliberately precise (not heuristic) so a green run is meaningful
|
|
21
|
+
and a red run names an exact file/line and a fixable reason.
|
|
22
|
+
|
|
23
|
+
This package is the **facade**: the rules live in themed modules (``imports`` /
|
|
24
|
+
``authz`` / ``http`` / ``persistence`` / ``events`` / ``traits`` / ``budget``)
|
|
25
|
+
over the shared ``_support`` machinery, and are gathered into :data:`_ALL_RULES`
|
|
26
|
+
and the :func:`check_app` / :func:`assert_app_clean` orchestrators here. The
|
|
27
|
+
public surface (and the ``terp.arch.rules`` import path) is unchanged.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import pathlib
|
|
33
|
+
from collections.abc import Callable
|
|
34
|
+
|
|
35
|
+
from terp.arch.rules._support import (
|
|
36
|
+
ArchViolation,
|
|
37
|
+
_apply_suppressions,
|
|
38
|
+
_scan_allow_markers,
|
|
39
|
+
)
|
|
40
|
+
from terp.arch.rules.authz import (
|
|
41
|
+
check_modules_declare_policy,
|
|
42
|
+
check_mutations_require_write_role,
|
|
43
|
+
check_no_adhoc_permission_literals,
|
|
44
|
+
check_policy_refs_resolve,
|
|
45
|
+
check_public_modules_are_read_only,
|
|
46
|
+
)
|
|
47
|
+
from terp.arch.rules.budget import check_escape_hatch_budget
|
|
48
|
+
from terp.arch.rules.events import check_events_reference_catalog
|
|
49
|
+
from terp.arch.rules.http import (
|
|
50
|
+
check_list_routes_paginate,
|
|
51
|
+
check_no_adhoc_logging_config,
|
|
52
|
+
check_no_adhoc_middleware,
|
|
53
|
+
check_no_app_instantiation,
|
|
54
|
+
check_no_dependency_overrides,
|
|
55
|
+
check_no_raw_app_routes,
|
|
56
|
+
check_response_model_not_table_model,
|
|
57
|
+
check_routes_declare_response_model,
|
|
58
|
+
check_safe_methods_are_read_only,
|
|
59
|
+
)
|
|
60
|
+
from terp.arch.rules.imports import (
|
|
61
|
+
check_no_adhoc_background_runtime,
|
|
62
|
+
check_no_cross_module_imports,
|
|
63
|
+
check_no_internal_imports,
|
|
64
|
+
check_no_raw_outbound_http,
|
|
65
|
+
check_session_imported_from_sqlmodel,
|
|
66
|
+
)
|
|
67
|
+
from terp.arch.rules.migrations import check_no_destructive_migrations
|
|
68
|
+
from terp.arch.rules.jobs import check_jobs_reference_catalog
|
|
69
|
+
from terp.arch.rules.persistence import (
|
|
70
|
+
check_input_schemas_exclude_managed_columns,
|
|
71
|
+
check_input_str_fields_have_max_length,
|
|
72
|
+
check_mutations_emit_audit,
|
|
73
|
+
check_no_manual_table_schema,
|
|
74
|
+
check_no_dynamic_sql,
|
|
75
|
+
check_no_raw_connection_access,
|
|
76
|
+
check_no_raw_session_construction,
|
|
77
|
+
check_no_unique_columns_on_soft_delete_models,
|
|
78
|
+
check_schemas_exclude_sensitive_fields,
|
|
79
|
+
check_table_models_use_base_table,
|
|
80
|
+
check_tables_have_migrations,
|
|
81
|
+
)
|
|
82
|
+
from terp.arch.rules.secrets import (
|
|
83
|
+
check_no_adhoc_config_decrypt,
|
|
84
|
+
check_no_hardcoded_credentials,
|
|
85
|
+
)
|
|
86
|
+
from terp.arch.rules.structure import check_canonical_module_shape
|
|
87
|
+
from terp.arch.rules.traits import (
|
|
88
|
+
check_base_query_not_overridden,
|
|
89
|
+
check_no_manual_actor_stamping,
|
|
90
|
+
check_no_manual_ownership_checks,
|
|
91
|
+
check_no_manual_scope_filtering,
|
|
92
|
+
check_no_raw_file_references,
|
|
93
|
+
check_reads_use_base_query,
|
|
94
|
+
check_tenant_scoped_models_use_scoped_service,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Every rule's fix recipe: the ``terp guide`` topic that teaches the compliant
|
|
98
|
+
# pattern the rule enforces. Keyed by the bare rule name (no ``check_`` prefix) so
|
|
99
|
+
# a violation can carry its own remedy ("fix recipe: terp guide <topic>") instead
|
|
100
|
+
# of relying on the author remembering to look one up. Completeness (every rule in
|
|
101
|
+
# :data:`_ALL_RULES` has a topic, and every topic is a real ``terp guide`` topic)
|
|
102
|
+
# is locked by ``test_arch_harness`` / ``test_docs_parity`` meta-tests.
|
|
103
|
+
GUIDE_TOPIC_BY_RULE: dict[str, str] = {
|
|
104
|
+
"no_internal_imports": "module",
|
|
105
|
+
"no_cross_module_imports": "module",
|
|
106
|
+
"no_raw_outbound_http": "capability",
|
|
107
|
+
"no_adhoc_background_runtime": "jobs",
|
|
108
|
+
"modules_declare_policy": "policy",
|
|
109
|
+
"mutations_require_write_role": "policy",
|
|
110
|
+
"public_modules_are_read_only": "policy",
|
|
111
|
+
"no_adhoc_permission_literals": "policy",
|
|
112
|
+
"policy_refs_resolve": "policy",
|
|
113
|
+
"routes_declare_response_model": "module",
|
|
114
|
+
"response_model_not_table_model": "module",
|
|
115
|
+
"schemas_exclude_sensitive_fields": "module",
|
|
116
|
+
"list_routes_paginate": "service",
|
|
117
|
+
"safe_methods_are_read_only": "module",
|
|
118
|
+
"no_raw_session_construction": "service",
|
|
119
|
+
"no_raw_connection_access": "service",
|
|
120
|
+
"no_dynamic_sql": "service",
|
|
121
|
+
"mutations_emit_audit": "service",
|
|
122
|
+
"events_reference_catalog": "events",
|
|
123
|
+
"jobs_reference_catalog": "jobs",
|
|
124
|
+
"no_adhoc_config_decrypt": "capability",
|
|
125
|
+
"no_hardcoded_credentials": "capability",
|
|
126
|
+
"input_str_fields_have_max_length": "module",
|
|
127
|
+
"input_schemas_exclude_managed_columns": "module",
|
|
128
|
+
"tenant_scoped_models_use_scoped_service": "tenancy",
|
|
129
|
+
"base_query_not_overridden": "service",
|
|
130
|
+
"reads_use_base_query": "service",
|
|
131
|
+
"no_manual_scope_filtering": "tenancy",
|
|
132
|
+
"no_manual_actor_stamping": "service",
|
|
133
|
+
"no_manual_ownership_checks": "ownership",
|
|
134
|
+
"no_raw_file_references": "files",
|
|
135
|
+
"table_models_use_base_table": "module",
|
|
136
|
+
"tables_have_migrations": "migrations",
|
|
137
|
+
"no_manual_table_schema": "migrations",
|
|
138
|
+
"no_destructive_migrations": "migrations",
|
|
139
|
+
"no_unique_columns_on_soft_delete_models": "module",
|
|
140
|
+
"canonical_module_shape": "module",
|
|
141
|
+
"session_imported_from_sqlmodel": "service",
|
|
142
|
+
"no_app_instantiation": "capability",
|
|
143
|
+
"no_raw_app_routes": "capability",
|
|
144
|
+
"no_dependency_overrides": "capability",
|
|
145
|
+
"no_adhoc_middleware": "capability",
|
|
146
|
+
"no_adhoc_logging_config": "capability",
|
|
147
|
+
"escape_hatch_budget": "rules",
|
|
148
|
+
"ungoverned_escape_hatch": "rules",
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def guide_topic_for(rule: str) -> str:
|
|
153
|
+
"""The ``terp guide`` topic teaching the fix for *rule* (``rules`` if unmapped).
|
|
154
|
+
|
|
155
|
+
The unmapped fallback is deliberate: a violation renderer must never crash on a
|
|
156
|
+
rule the mapping missed — the completeness meta-test catches the gap at build
|
|
157
|
+
time, and ``rules`` (the generated every-rule topic) is always a safe pointer.
|
|
158
|
+
"""
|
|
159
|
+
return GUIDE_TOPIC_BY_RULE.get(rule, "rules")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
_ALL_RULES: tuple[Callable[..., list[ArchViolation]], ...] = (
|
|
163
|
+
check_no_internal_imports,
|
|
164
|
+
check_no_cross_module_imports,
|
|
165
|
+
check_no_raw_outbound_http,
|
|
166
|
+
check_no_adhoc_background_runtime,
|
|
167
|
+
check_modules_declare_policy,
|
|
168
|
+
check_mutations_require_write_role,
|
|
169
|
+
check_public_modules_are_read_only,
|
|
170
|
+
check_no_adhoc_permission_literals,
|
|
171
|
+
check_policy_refs_resolve,
|
|
172
|
+
check_routes_declare_response_model,
|
|
173
|
+
check_response_model_not_table_model,
|
|
174
|
+
check_schemas_exclude_sensitive_fields,
|
|
175
|
+
check_list_routes_paginate,
|
|
176
|
+
check_safe_methods_are_read_only,
|
|
177
|
+
check_no_raw_session_construction,
|
|
178
|
+
check_no_raw_connection_access,
|
|
179
|
+
check_no_dynamic_sql,
|
|
180
|
+
check_mutations_emit_audit,
|
|
181
|
+
check_events_reference_catalog,
|
|
182
|
+
check_jobs_reference_catalog,
|
|
183
|
+
check_no_adhoc_config_decrypt,
|
|
184
|
+
check_no_hardcoded_credentials,
|
|
185
|
+
check_input_str_fields_have_max_length,
|
|
186
|
+
check_input_schemas_exclude_managed_columns,
|
|
187
|
+
check_tenant_scoped_models_use_scoped_service,
|
|
188
|
+
check_base_query_not_overridden,
|
|
189
|
+
check_reads_use_base_query,
|
|
190
|
+
check_no_manual_scope_filtering,
|
|
191
|
+
check_no_manual_actor_stamping,
|
|
192
|
+
check_no_manual_ownership_checks,
|
|
193
|
+
check_no_raw_file_references,
|
|
194
|
+
check_table_models_use_base_table,
|
|
195
|
+
check_tables_have_migrations,
|
|
196
|
+
check_no_manual_table_schema,
|
|
197
|
+
check_no_destructive_migrations,
|
|
198
|
+
check_no_unique_columns_on_soft_delete_models,
|
|
199
|
+
check_canonical_module_shape,
|
|
200
|
+
check_session_imported_from_sqlmodel,
|
|
201
|
+
check_no_app_instantiation,
|
|
202
|
+
check_no_raw_app_routes,
|
|
203
|
+
check_no_dependency_overrides,
|
|
204
|
+
check_no_adhoc_middleware,
|
|
205
|
+
check_no_adhoc_logging_config,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def check_app(
|
|
210
|
+
app_root: str | pathlib.Path,
|
|
211
|
+
*,
|
|
212
|
+
package: str = "app",
|
|
213
|
+
budget_path: str | pathlib.Path | None = None,
|
|
214
|
+
) -> list[ArchViolation]:
|
|
215
|
+
"""Run every rule against *app_root* and return all effective violations, sorted.
|
|
216
|
+
|
|
217
|
+
A justified ``# arch-allow-<rule>: <reason>`` comment on a violation's line
|
|
218
|
+
suppresses it (an unjustified one is reported, never silently honoured). Pass
|
|
219
|
+
*budget_path* to also enforce the escape-hatch budget ratchet over those
|
|
220
|
+
markers (design §8).
|
|
221
|
+
"""
|
|
222
|
+
root = pathlib.Path(app_root)
|
|
223
|
+
if not root.is_dir():
|
|
224
|
+
raise NotADirectoryError(f"app root not found: {root}")
|
|
225
|
+
raw: list[ArchViolation] = []
|
|
226
|
+
for rule in _ALL_RULES:
|
|
227
|
+
raw.extend(rule(root, package=package))
|
|
228
|
+
violations = _apply_suppressions(raw, _scan_allow_markers(root))
|
|
229
|
+
if budget_path is not None:
|
|
230
|
+
violations.extend(check_escape_hatch_budget(root, budget_path=budget_path, package=package))
|
|
231
|
+
return sorted(violations, key=lambda violation: (violation.path, violation.line, violation.rule))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def assert_app_clean(
|
|
235
|
+
app_root: str | pathlib.Path,
|
|
236
|
+
*,
|
|
237
|
+
package: str = "app",
|
|
238
|
+
budget_path: str | pathlib.Path | None = None,
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Raise ``AssertionError`` listing every architecture violation in *app_root*.
|
|
241
|
+
|
|
242
|
+
Governed opt-out: if the app uses any ``# arch-allow-*`` marker but no
|
|
243
|
+
*budget_path* is supplied, this fails closed — an opt-out must be governed by a
|
|
244
|
+
checked-in escape-hatch budget, never used silently.
|
|
245
|
+
"""
|
|
246
|
+
root = pathlib.Path(app_root)
|
|
247
|
+
if budget_path is None and _scan_allow_markers(root):
|
|
248
|
+
raise AssertionError(
|
|
249
|
+
"terp.arch found '# arch-allow-*' opt-out marker(s) but no budget_path was "
|
|
250
|
+
"supplied; govern opt-outs with a checked-in escape-hatch budget — call "
|
|
251
|
+
"assert_app_clean(app, budget_path='escape-hatch-budget.json')"
|
|
252
|
+
)
|
|
253
|
+
violations = check_app(root, package=package, budget_path=budget_path)
|
|
254
|
+
if violations:
|
|
255
|
+
listing = "\n".join(
|
|
256
|
+
f" - {violation} (fix recipe: terp guide {violation.rule})"
|
|
257
|
+
for violation in violations
|
|
258
|
+
)
|
|
259
|
+
raise AssertionError(
|
|
260
|
+
f"terp.arch found {len(violations)} architecture violation(s):\n{listing}"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def ungoverned_marker_violations(
|
|
265
|
+
app_root: str | pathlib.Path, *, package: str = "app"
|
|
266
|
+
) -> list[ArchViolation]:
|
|
267
|
+
"""The fail-closed ungoverned-opt-out condition, as structured violations.
|
|
268
|
+
|
|
269
|
+
:func:`assert_app_clean` refuses (with a plain ``AssertionError``) to honour any
|
|
270
|
+
``# arch-allow-*`` marker when no escape-hatch budget governs it. This is the
|
|
271
|
+
same condition projected as :class:`ArchViolation` values (rule
|
|
272
|
+
``ungoverned_escape_hatch``, one per marker line), so a structured renderer
|
|
273
|
+
(``terp check --format json``) reports it in-band instead of crashing.
|
|
274
|
+
"""
|
|
275
|
+
root = pathlib.Path(app_root)
|
|
276
|
+
violations = [
|
|
277
|
+
ArchViolation(
|
|
278
|
+
rule="ungoverned_escape_hatch",
|
|
279
|
+
path=path,
|
|
280
|
+
line=line,
|
|
281
|
+
message=(
|
|
282
|
+
"'# arch-allow-*' opt-out marker is not governed by an escape-hatch "
|
|
283
|
+
"budget; pass --budget escape-hatch-budget.json (a checked-in ratchet) "
|
|
284
|
+
"or remove the marker"
|
|
285
|
+
),
|
|
286
|
+
)
|
|
287
|
+
for path, per_line in _scan_allow_markers(root).items()
|
|
288
|
+
for line in sorted(per_line)
|
|
289
|
+
]
|
|
290
|
+
return sorted(violations, key=lambda violation: (violation.path, violation.line))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
__all__ = [
|
|
294
|
+
"ArchViolation",
|
|
295
|
+
"GUIDE_TOPIC_BY_RULE",
|
|
296
|
+
"assert_app_clean",
|
|
297
|
+
"check_app",
|
|
298
|
+
"check_canonical_module_shape",
|
|
299
|
+
"check_escape_hatch_budget",
|
|
300
|
+
"check_events_reference_catalog",
|
|
301
|
+
"check_list_routes_paginate",
|
|
302
|
+
"check_input_str_fields_have_max_length",
|
|
303
|
+
"check_jobs_reference_catalog",
|
|
304
|
+
"check_modules_declare_policy",
|
|
305
|
+
"check_mutations_emit_audit",
|
|
306
|
+
"check_mutations_require_write_role",
|
|
307
|
+
"check_no_adhoc_background_runtime",
|
|
308
|
+
"check_no_adhoc_config_decrypt",
|
|
309
|
+
"check_no_adhoc_logging_config",
|
|
310
|
+
"check_no_adhoc_middleware",
|
|
311
|
+
"check_no_adhoc_permission_literals",
|
|
312
|
+
"check_no_app_instantiation",
|
|
313
|
+
"check_no_destructive_migrations",
|
|
314
|
+
"check_no_dynamic_sql",
|
|
315
|
+
"check_no_cross_module_imports",
|
|
316
|
+
"check_no_hardcoded_credentials",
|
|
317
|
+
"check_no_internal_imports",
|
|
318
|
+
"check_no_manual_actor_stamping",
|
|
319
|
+
"check_no_manual_ownership_checks",
|
|
320
|
+
"check_no_dependency_overrides",
|
|
321
|
+
"check_no_raw_app_routes",
|
|
322
|
+
"check_no_raw_file_references",
|
|
323
|
+
"check_no_manual_scope_filtering",
|
|
324
|
+
"check_no_raw_connection_access",
|
|
325
|
+
"check_no_raw_outbound_http",
|
|
326
|
+
"check_no_raw_session_construction",
|
|
327
|
+
"check_no_unique_columns_on_soft_delete_models",
|
|
328
|
+
"check_policy_refs_resolve",
|
|
329
|
+
"check_reads_use_base_query",
|
|
330
|
+
"check_response_model_not_table_model",
|
|
331
|
+
"check_routes_declare_response_model",
|
|
332
|
+
"check_safe_methods_are_read_only",
|
|
333
|
+
"check_schemas_exclude_sensitive_fields",
|
|
334
|
+
"check_session_imported_from_sqlmodel",
|
|
335
|
+
"check_table_models_use_base_table",
|
|
336
|
+
"check_no_manual_table_schema",
|
|
337
|
+
"check_tables_have_migrations",
|
|
338
|
+
"check_tenant_scoped_models_use_scoped_service",
|
|
339
|
+
"guide_topic_for",
|
|
340
|
+
"ungoverned_marker_violations",
|
|
341
|
+
]
|