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.
@@ -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
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: terp-arch
3
+ Version: 0.1.0
4
+ Summary: Terp enforcement harness — secure-by-default fitness rules, shipped as a dependency clients run but cannot edit.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
@@ -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
+ ]