terp-cli 0.22.0__tar.gz → 0.24.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_cli-0.22.0 → terp_cli-0.24.0}/PKG-INFO +8 -8
- {terp_cli-0.22.0 → terp_cli-0.24.0}/pyproject.toml +8 -8
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/__init__.py +256 -46
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/access.py +10 -1
- terp_cli-0.24.0/src/terp/cli/authz_surface.py +169 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/docker.py +20 -0
- terp_cli-0.24.0/src/terp/cli/ports.py +996 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/scaffold.py +41 -4
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/verify.py +174 -3
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/version.py +166 -43
- {terp_cli-0.22.0 → terp_cli-0.24.0}/.gitignore +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/_appref.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/_subjects.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/apidocs.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/capabilities.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/deploy_safety.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/dev.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/envfile.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/envschema.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/envseams.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/fmt.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/grants.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/jobs.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/leases.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/module_roles.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/openapi.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/outbox.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/profiles.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/py.typed +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/routes.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/schema.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/seed.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/service_accounts.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/smoke.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/users.py +0 -0
- {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/workbench.py +0 -0
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: terp-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.24.0
|
|
4
4
|
Summary: Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs.
|
|
5
5
|
Project-URL: Repository, https://github.com/AITT-NL/terp-framework
|
|
6
6
|
Project-URL: Changelog, https://github.com/AITT-NL/terp-framework/blob/main/CHANGELOG.md
|
|
7
7
|
License-Expression: Apache-2.0
|
|
8
8
|
Requires-Python: >=3.13
|
|
9
9
|
Requires-Dist: pyyaml>=6.0
|
|
10
|
-
Requires-Dist: terp-arch==0.
|
|
11
|
-
Requires-Dist: terp-core==0.
|
|
12
|
-
Requires-Dist: terp-migrations==0.
|
|
10
|
+
Requires-Dist: terp-arch==0.24.0
|
|
11
|
+
Requires-Dist: terp-core==0.24.0
|
|
12
|
+
Requires-Dist: terp-migrations==0.24.0
|
|
13
13
|
Provides-Extra: jobs
|
|
14
|
-
Requires-Dist: terp-cap-outbox==0.
|
|
15
|
-
Requires-Dist: terp-cap-scheduler-apscheduler==0.
|
|
14
|
+
Requires-Dist: terp-cap-outbox==0.24.0; extra == 'jobs'
|
|
15
|
+
Requires-Dist: terp-cap-scheduler-apscheduler==0.24.0; extra == 'jobs'
|
|
16
16
|
Provides-Extra: scheduler
|
|
17
|
-
Requires-Dist: terp-cap-scheduler-apscheduler==0.
|
|
17
|
+
Requires-Dist: terp-cap-scheduler-apscheduler==0.24.0; extra == 'scheduler'
|
|
18
18
|
Provides-Extra: worker
|
|
19
|
-
Requires-Dist: terp-cap-outbox==0.
|
|
19
|
+
Requires-Dist: terp-cap-outbox==0.24.0; extra == 'worker'
|
|
@@ -4,18 +4,18 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "terp-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.24.0"
|
|
8
8
|
description = "Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs."
|
|
9
9
|
requires-python = ">=3.13"
|
|
10
10
|
license = "Apache-2.0"
|
|
11
11
|
dependencies = [
|
|
12
|
-
"terp-core==0.
|
|
12
|
+
"terp-core==0.24.0",
|
|
13
13
|
# `terp migrate` delegates to terp-migrations (lazily imported), keeping Alembic
|
|
14
14
|
# off the path for `terp inspect` / `terp guide` while making migrations work.
|
|
15
|
-
"terp-migrations==0.
|
|
15
|
+
"terp-migrations==0.24.0",
|
|
16
16
|
# `terp guide rules` projects the live rule registry; terp-arch is imported lazily
|
|
17
17
|
# (only when that topic is rendered), so it stays off the common `terp guide` path.
|
|
18
|
-
"terp-arch==0.
|
|
18
|
+
"terp-arch==0.24.0",
|
|
19
19
|
# `terp verify --only env-seams` and `terp smoke` read the compose profiles as data
|
|
20
20
|
# (anchors and `<<:` merge keys resolved), so both answer without a Docker daemon or
|
|
21
21
|
# even the `docker` binary. Declared explicitly rather than leant on: every app
|
|
@@ -29,11 +29,11 @@ dependencies = [
|
|
|
29
29
|
# or a scheduler engine into apps that only use inspect/check/migrate. Deployments can select
|
|
30
30
|
# one process role, while `jobs` is the convenient complete jobs-process bundle.
|
|
31
31
|
[project.optional-dependencies]
|
|
32
|
-
worker = ["terp-cap-outbox==0.
|
|
33
|
-
scheduler = ["terp-cap-scheduler-apscheduler==0.
|
|
32
|
+
worker = ["terp-cap-outbox==0.24.0"]
|
|
33
|
+
scheduler = ["terp-cap-scheduler-apscheduler==0.24.0"]
|
|
34
34
|
jobs = [
|
|
35
|
-
"terp-cap-outbox==0.
|
|
36
|
-
"terp-cap-scheduler-apscheduler==0.
|
|
35
|
+
"terp-cap-outbox==0.24.0",
|
|
36
|
+
"terp-cap-scheduler-apscheduler==0.24.0",
|
|
37
37
|
]
|
|
38
38
|
|
|
39
39
|
[project.scripts]
|
|
@@ -9,9 +9,13 @@ import pathlib
|
|
|
9
9
|
import re
|
|
10
10
|
import sys
|
|
11
11
|
from collections.abc import Callable, Sequence
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
12
13
|
|
|
13
14
|
from terp.core import ControlPlane, CorsPolicy, ModuleSpec
|
|
14
15
|
|
|
16
|
+
if TYPE_CHECKING: # terp-arch stays off the common `terp guide` / `terp inspect` path
|
|
17
|
+
from terp.arch import ScanRoot
|
|
18
|
+
|
|
15
19
|
from terp.cli.access import (
|
|
16
20
|
build_access_graph_for_app,
|
|
17
21
|
render_access,
|
|
@@ -60,6 +64,7 @@ from terp.cli.service_accounts import create_service_account_command
|
|
|
60
64
|
from terp.cli.users import create_user_command
|
|
61
65
|
from terp.cli.envfile import run_env_command
|
|
62
66
|
from terp.cli.outbox import render_backlog
|
|
67
|
+
from terp.cli.ports import run_ports_command
|
|
63
68
|
from terp.cli.verify import (
|
|
64
69
|
profile_ids,
|
|
65
70
|
run_verify_command,
|
|
@@ -575,7 +580,17 @@ Stored references (Ref + OnDelete)
|
|
|
575
580
|
RESTRICT the parent must not vanish underneath this row (a ledger entry)
|
|
576
581
|
SET NULL a pointer allowed to go slack (an optional assignee) -- nullable
|
|
577
582
|
SET_DEFAULT point at the column default instead (that default must exist)
|
|
578
|
-
NO_ACTION the database
|
|
583
|
+
NO_ACTION the database corrects nothing; something above it owns this
|
|
584
|
+
lifecycle -- it still REFUSES a delete that would orphan a row
|
|
585
|
+
- NO_ACTION STILL REFUSES. It means the database takes no corrective action, not that
|
|
586
|
+
it stands aside: the foreign key is enforced either way, and NO ACTION differs from
|
|
587
|
+
RESTRICT only in deferrability, never in whether the parent delete is blocked. So
|
|
588
|
+
against a HARD-deletable parent, expect the refusal and plan for it -- BaseService
|
|
589
|
+
turns it into the uniform 409 ConflictError, the same envelope as any other
|
|
590
|
+
integrity conflict, so you do not map it. Clear the children first; the _after_write
|
|
591
|
+
recipe below runs BEFORE the parent row is deleted, which is exactly what makes that
|
|
592
|
+
ordering work. (Against a soft-deletable target none of this can fire at all -- see
|
|
593
|
+
the SOFT-DELETE note below, where NO_ACTION really does mean nothing happens.)
|
|
579
594
|
- NO_ACTION is a real answer, not a cop-out, and it emits NO clause at all. Two
|
|
580
595
|
consequences worth knowing: adopting the declaration on an existing schema costs
|
|
581
596
|
no migration (the DDL is unchanged, only the source now says the silence was
|
|
@@ -666,10 +681,10 @@ Boundaries for a second top-level package (an ungated worker)
|
|
|
666
681
|
|
|
667
682
|
- The shape: an app whose work cannot run under the gate — a legacy-DB connector, a
|
|
668
683
|
device, a non-Python runtime — keeps a SECOND top-level package beside `app/`, and the
|
|
669
|
-
two must not import each other. `terp check`
|
|
670
|
-
deliberately does not own generic import-graph
|
|
671
|
-
exactly what a generic tool already does better
|
|
672
|
-
weaker copy).
|
|
684
|
+
two must not import each other. `terp check` scans `app/` plus whatever the project
|
|
685
|
+
declares as a companion (below); it deliberately does not own generic import-graph
|
|
686
|
+
checks, because a graph contract is exactly what a generic tool already does better
|
|
687
|
+
(and terp.arch would then be a second, weaker copy).
|
|
673
688
|
- So declare the graph with import-linter, which the platform itself uses for its own
|
|
674
689
|
layer-0 keystone. In the app's pyproject.toml:
|
|
675
690
|
[tool.importlinter]
|
|
@@ -703,29 +718,49 @@ Boundaries for a second top-level package (an ungated worker)
|
|
|
703
718
|
- What terp.arch still owns, because these are Terp semantics and not graph shape:
|
|
704
719
|
`no_dynamic_sql` (SQL must be a static, reviewable literal — the containment check an
|
|
705
720
|
app would otherwise hand-roll), `no_raw_outbound_http`, `no_adhoc_background_runtime`,
|
|
706
|
-
and every rule about BaseService / ModuleSpec / schemas.
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
721
|
+
and every rule about BaseService / ModuleSpec / schemas. The first two reach the second
|
|
722
|
+
package as well (see below); `no_adhoc_background_runtime` does not, because a worker
|
|
723
|
+
IS its own runtime.
|
|
724
|
+
- SCAN IT, AND SAY SO IN ONE PLACE. Every root beyond `app/` is declared once, in the
|
|
725
|
+
app's pyproject.toml:
|
|
726
|
+
[tool.terp.arch]
|
|
727
|
+
app_packages = ["control_plane"] # more of the app; held to every rule
|
|
728
|
+
companions = ["engine"] # ships beside it; held to the rules below
|
|
729
|
+
`terp check` reads that with no flag, so `terp verify` picks it up too, and the app's
|
|
730
|
+
own architecture test spreads the same declaration into the harness call:
|
|
731
|
+
from terp.arch import assert_app_clean, declared_roots
|
|
732
|
+
|
|
733
|
+
assert_app_clean("app", *declared_roots(), budget_path="escape-hatch-budget.json")
|
|
734
|
+
A scaffolded app already declares `control_plane` — it has always been a second
|
|
735
|
+
package, and until this it was the app's permission, operation, event and job
|
|
736
|
+
declarations sitting outside the gate.
|
|
737
|
+
ONE declaration, because the escape-hatch budget is SHARED across the scanned roots: a
|
|
738
|
+
run that missed the companion would count its markers as absent, read that as a win to
|
|
739
|
+
lock in, and fail the ratchet. Declaring it in only one of the two gates is therefore
|
|
740
|
+
not a smaller version of this — it is a broken build. (`--companion engine` still
|
|
741
|
+
exists for an ad-hoc companion scan, and adds to the declaration rather than
|
|
742
|
+
replacing it.)
|
|
743
|
+
- WHAT THE COMPANION IS HELD TO, and what it is not. A companion root gets the rules whose
|
|
744
|
+
invariant holds for any Python that ships — `no_hardcoded_credentials`, `no_dynamic_sql`,
|
|
745
|
+
`no_raw_outbound_http`, `no_adhoc_config_decrypt`, `no_internal_imports`, `no_print`,
|
|
746
|
+
`no_naive_datetime`, `no_eval_or_exec`, `no_star_imports`, `no_blocking_sleep`,
|
|
747
|
+
`no_mutable_default_args`, `no_todo_fixme`, `no_oversized_python_files`,
|
|
748
|
+
`no_empty_tests`, `no_manual_table_schema`, `frozen_values_hold_no_mutable_collection`.
|
|
749
|
+
It does NOT get the rules that are properties of being a mounted app: a route's response
|
|
750
|
+
model, a module's policy, a table's migration, the guarded session (ADR 0141). The check
|
|
751
|
+
report says which, per root, under `roots` — so a green over the companion names the
|
|
752
|
+
rules it is green about, and is a claim you can publish.
|
|
753
|
+
Expect the first declared run to be red. Sixteen rules arriving at once over a package
|
|
754
|
+
nothing has ever scanned will find things; they were violations all along.
|
|
755
|
+
- WHAT A HAND-WRITTEN TEST IS STILL FOR. Not `no_dynamic_sql` or `no_print` — those now
|
|
756
|
+
reach the second package, and a bespoke AST scan beside them is a weaker copy that has
|
|
757
|
+
to be maintained. What a general tool cannot express is what to keep: an ALLOWLIST of
|
|
758
|
+
the third-party distributions the worker may import (a `forbidden` contract is a
|
|
759
|
+
denylist, and nobody can keep a list of every package that must never appear), or a
|
|
760
|
+
containment boundary particular to this worker's layout.
|
|
761
|
+
- The worker's own gate is one command:
|
|
762
|
+
uv run terp verify # Terp rules over app/ AND every declared companion,
|
|
763
|
+
# then the declared package graph over both
|
|
729
764
|
- Sharing types across the boundary: neither package may import the other, but BOTH may
|
|
730
765
|
import a third. A `contracts/` package of frozen value objects (with `max_length` caps
|
|
731
766
|
declared, so the app half satisfies the input-schema rule) is one declaration and two
|
|
@@ -1375,6 +1410,21 @@ Database migrations (terp migrate)
|
|
|
1375
1410
|
- Destructive DDL (drop table/column or alter-column type changes) is refused by
|
|
1376
1411
|
`terp check` unless the operation carries `# arch-allow-no-destructive-migrations:
|
|
1377
1412
|
<reason>` on (or immediately above) its line, budgeted by the escape-hatch ratchet.
|
|
1413
|
+
- ADDING A NOT NULL COLUMN is the revision that passes everything you can run and then
|
|
1414
|
+
fails in production. For a new non-nullable field autogenerate writes
|
|
1415
|
+
`add_column(sa.Column('rank', sa.Integer(), nullable=False))`, and that statement
|
|
1416
|
+
succeeds against an empty database and fails against one that holds rows - the rows
|
|
1417
|
+
already there need a value it never supplies. Nothing local catches it, because a
|
|
1418
|
+
migration test upgrades a FRESH scratch database, where the statement is correct.
|
|
1419
|
+
Give the column a server_default and the database back-fills the existing rows as it
|
|
1420
|
+
adds it; new rows still take the model-side default:
|
|
1421
|
+
op.add_column('note', sa.Column('rank', sa.Integer(), nullable=False,
|
|
1422
|
+
server_default='0'))
|
|
1423
|
+
Where no literal default is right, expand/contract instead - add the column nullable,
|
|
1424
|
+
back-fill it in the same release, tighten it in a LATER revision. `terp check`
|
|
1425
|
+
refuses the unbackfilled form (`not_null_columns_are_backfilled`); a table that
|
|
1426
|
+
genuinely holds no rows yet opts out with
|
|
1427
|
+
`# arch-allow-not-null-columns-are-backfilled: <reason>`.
|
|
1378
1428
|
- MOVING A MODEL TO ANOTHER MODULE is the one edit that looks free and is not. Just
|
|
1379
1429
|
moving the class emits NO ddl at all - the losing package stops owning the table so
|
|
1380
1430
|
its scoped autogenerate cannot propose a drop, and the gaining package diffs against
|
|
@@ -1859,6 +1909,28 @@ not concatenate sequences), so both files have to be listed.
|
|
|
1859
1909
|
_GENERATED_TOPICS: tuple[str, ...] = ("changelog", "rules")
|
|
1860
1910
|
|
|
1861
1911
|
_RULE_GUIDE_DETAILS: dict[str, str] = {
|
|
1912
|
+
"not_null_columns_are_backfilled": """\
|
|
1913
|
+
Compliant decision path for a new NOT NULL column
|
|
1914
|
+
|
|
1915
|
+
1. Decide what the rows that already exist should hold. That is the whole question;
|
|
1916
|
+
the statement fails precisely because it never answers it.
|
|
1917
|
+
2. When a literal answers it, give the column a server_default. The database writes it
|
|
1918
|
+
into every existing row as it adds the column, and new rows still take the
|
|
1919
|
+
model-side default:
|
|
1920
|
+
op.add_column('note', sa.Column('rank', sa.Integer(), nullable=False,
|
|
1921
|
+
server_default='0'))
|
|
1922
|
+
3. When no literal is right - the value has to be computed, or looked up - use
|
|
1923
|
+
expand/contract across two releases: add the column NULLABLE, back-fill it (a data
|
|
1924
|
+
migration or a backfill job), and tighten it to NOT NULL in a LATER revision, once
|
|
1925
|
+
nothing writes a NULL.
|
|
1926
|
+
4. Do not make the column nullable in the model merely to silence this. The model is
|
|
1927
|
+
the contract; if the field is genuinely optional, say so there and the finding goes
|
|
1928
|
+
away on its own.
|
|
1929
|
+
5. Opt out only for a table that genuinely holds no rows in any environment - a table
|
|
1930
|
+
this release introduces, or one seeded from empty - with
|
|
1931
|
+
`# arch-allow-not-null-columns-are-backfilled: <reason>` naming which. A table
|
|
1932
|
+
created in the same upgrade() needs no marker; it is already exempt.
|
|
1933
|
+
""",
|
|
1862
1934
|
"no_raw_outbound_http": """\
|
|
1863
1935
|
Compliant decision path for outbound HTTP
|
|
1864
1936
|
|
|
@@ -2108,8 +2180,51 @@ def gate_root(root: str | pathlib.Path = ".", *, package: str = "app") -> pathli
|
|
|
2108
2180
|
return candidate if candidate.is_dir() else path
|
|
2109
2181
|
|
|
2110
2182
|
|
|
2183
|
+
def extra_scan_roots(
|
|
2184
|
+
companions: Sequence[str] = (), *, root: str | pathlib.Path = ".", package: str = "app"
|
|
2185
|
+
) -> tuple[ScanRoot, ...]:
|
|
2186
|
+
"""Every root the gate scans **beyond** the app package, for a CLI invocation.
|
|
2187
|
+
|
|
2188
|
+
That is the project's own ``[tool.terp.arch]`` declaration — more of the application
|
|
2189
|
+
(``app_packages``, scanned as ``app/`` is) and what merely ships beside it
|
|
2190
|
+
(``companions``) — plus any ad-hoc ``--companion`` directories the invocation names.
|
|
2191
|
+
|
|
2192
|
+
*root* accepts the same two spellings :func:`gate_root` does, the project root or the
|
|
2193
|
+
app package itself, so both resolve the declaration from the same place.
|
|
2194
|
+
|
|
2195
|
+
The declaration is always read, with no flag, and ``--companion`` *adds* to it. That
|
|
2196
|
+
is what keeps the gate's two entry points from disagreeing: ``terp verify`` runs a
|
|
2197
|
+
fixed ``terp check`` command carrying no flags of the app's choosing, so a root the
|
|
2198
|
+
app has declared has to be picked up without one — and because the escape-hatch
|
|
2199
|
+
budget is shared across the scanned roots, a run that missed one would count its
|
|
2200
|
+
markers as *absent* and fail the ratchet. Both spellings are statements that a root
|
|
2201
|
+
should be scanned; neither is a statement that another should not be, so the union is
|
|
2202
|
+
the only reading that is not a silent narrowing.
|
|
2203
|
+
"""
|
|
2204
|
+
from terp.arch import RootKind, ScanRoot, declared_roots
|
|
2205
|
+
|
|
2206
|
+
base = pathlib.Path(root)
|
|
2207
|
+
if not (base / package).is_dir():
|
|
2208
|
+
base = base.parent # --root was already the app package
|
|
2209
|
+
roots = list(declared_roots(base))
|
|
2210
|
+
seen = {scanned.path.resolve() for scanned in roots}
|
|
2211
|
+
for name in companions:
|
|
2212
|
+
path = base / name
|
|
2213
|
+
if path.resolve() in seen:
|
|
2214
|
+
continue
|
|
2215
|
+
seen.add(path.resolve())
|
|
2216
|
+
roots.append(
|
|
2217
|
+
ScanRoot(path, package=pathlib.Path(name).name, kind=RootKind.COMPANION)
|
|
2218
|
+
)
|
|
2219
|
+
return tuple(roots)
|
|
2220
|
+
|
|
2221
|
+
|
|
2111
2222
|
def check_report(
|
|
2112
|
-
root: str = ".",
|
|
2223
|
+
root: str = ".",
|
|
2224
|
+
*,
|
|
2225
|
+
package: str = "app",
|
|
2226
|
+
budget_path: str | None = None,
|
|
2227
|
+
companions: Sequence[str] = (),
|
|
2113
2228
|
) -> dict[str, object]:
|
|
2114
2229
|
"""The architecture gate as a structured report (the ``terp check --format json`` body).
|
|
2115
2230
|
|
|
@@ -2127,21 +2242,44 @@ def check_report(
|
|
|
2127
2242
|
OUT of the inventory, so a consumer joining verdicts to the Terp Standard catalog
|
|
2128
2243
|
can never claim ``escape_hatch_budget`` passed on a run that never enforced it
|
|
2129
2244
|
(fail closed under version skew and configuration alike).
|
|
2245
|
+
|
|
2246
|
+
``companions`` names the repository's other deployables (``--companion engine``),
|
|
2247
|
+
each scanned as a :class:`terp.arch.RootKind.COMPANION` root. ``roots`` then
|
|
2248
|
+
reports what was scanned and, per root, the rules that root was held to — because
|
|
2249
|
+
a companion is held to fewer of them, and a scope that is not written down is the
|
|
2250
|
+
thing this seam exists to stop being folklore. Top-level ``rules`` stays the union
|
|
2251
|
+
over the scanned roots: it answers "did this run evaluate rule X", and with an app
|
|
2252
|
+
root present the answer is unchanged, which is why an app-only run reports exactly
|
|
2253
|
+
what it always did.
|
|
2130
2254
|
"""
|
|
2131
|
-
from terp.arch import check_app, guide_topic_for, ungoverned_marker_violations
|
|
2132
|
-
from terp.arch.rules import GUIDE_TOPIC_BY_RULE
|
|
2255
|
+
from terp.arch import check_app, guide_topic_for, root_kinds_for, ungoverned_marker_violations
|
|
2256
|
+
from terp.arch.rules import GUIDE_TOPIC_BY_RULE, ScanRoot
|
|
2133
2257
|
|
|
2134
|
-
scan_root = gate_root(root, package=package)
|
|
2135
|
-
|
|
2258
|
+
scan_root = ScanRoot(gate_root(root, package=package), package=package)
|
|
2259
|
+
roots = (scan_root, *extra_scan_roots(companions, root=root, package=package))
|
|
2260
|
+
violations = list(check_app(*roots, package=package, budget_path=budget_path))
|
|
2136
2261
|
if budget_path is None:
|
|
2137
|
-
violations.extend(ungoverned_marker_violations(
|
|
2262
|
+
violations.extend(ungoverned_marker_violations(*roots, package=package))
|
|
2138
2263
|
violations.sort(key=lambda violation: (violation.path, violation.line, violation.rule))
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
rules.
|
|
2264
|
+
|
|
2265
|
+
def _inventory(scanned: ScanRoot) -> list[str]:
|
|
2266
|
+
rules = {rule for rule in GUIDE_TOPIC_BY_RULE if scanned.kind in root_kinds_for(rule)}
|
|
2267
|
+
if budget_path is None:
|
|
2268
|
+
rules.discard("escape_hatch_budget")
|
|
2269
|
+
return sorted(rules)
|
|
2270
|
+
|
|
2271
|
+
# Named by package, never by filesystem path: this report is a machine contract a
|
|
2272
|
+
# tool joins to the catalog, and an absolute checkout path is neither stable across
|
|
2273
|
+
# machines nor anyone else's business. The package is what names a root everywhere
|
|
2274
|
+
# else in Terp.
|
|
2275
|
+
per_root = [
|
|
2276
|
+
{"package": scanned.package, "kind": scanned.kind.value, "rules": _inventory(scanned)}
|
|
2277
|
+
for scanned in roots
|
|
2278
|
+
]
|
|
2142
2279
|
return {
|
|
2143
2280
|
"ok": not violations,
|
|
2144
|
-
"rules": sorted(rules),
|
|
2281
|
+
"rules": sorted({rule for scanned in per_root for rule in scanned["rules"]}),
|
|
2282
|
+
"roots": per_root,
|
|
2145
2283
|
"violation_count": len(violations),
|
|
2146
2284
|
"violations": [
|
|
2147
2285
|
{
|
|
@@ -2160,7 +2298,11 @@ def check_report(
|
|
|
2160
2298
|
|
|
2161
2299
|
|
|
2162
2300
|
def check_report_envelope(
|
|
2163
|
-
root: str = ".",
|
|
2301
|
+
root: str = ".",
|
|
2302
|
+
*,
|
|
2303
|
+
package: str = "app",
|
|
2304
|
+
budget_path: str | None = None,
|
|
2305
|
+
companions: Sequence[str] = (),
|
|
2164
2306
|
) -> dict[str, object]:
|
|
2165
2307
|
"""The architecture gate as a Terp Standard **check report** (``terp check
|
|
2166
2308
|
--format check-report``).
|
|
@@ -2172,12 +2314,20 @@ def check_report_envelope(
|
|
|
2172
2314
|
and findings in the finding format's shape (``fix_hint`` = the ``terp guide``
|
|
2173
2315
|
recipe). The legacy ``--format json`` report keeps its published shape for
|
|
2174
2316
|
existing consumers; this is the successor surface driving tools migrate to.
|
|
2317
|
+
|
|
2318
|
+
Companion roots are scanned and their findings reported, but the envelope carries
|
|
2319
|
+
no per-root breakdown: ``app-check-report.schema.json`` is the standard's shape
|
|
2320
|
+
and gains a field through the standard, not through this renderer. The inventory
|
|
2321
|
+
is the union over the scanned roots, which for any run that includes an app root
|
|
2322
|
+
is every rule, so the envelope means exactly what it did before.
|
|
2175
2323
|
"""
|
|
2176
2324
|
import importlib.metadata
|
|
2177
2325
|
|
|
2178
2326
|
from terp.arch import SPEC_VERSION
|
|
2179
2327
|
|
|
2180
|
-
report = check_report(
|
|
2328
|
+
report = check_report(
|
|
2329
|
+
root, package=package, budget_path=budget_path, companions=companions
|
|
2330
|
+
)
|
|
2181
2331
|
try:
|
|
2182
2332
|
version = importlib.metadata.version("terp-arch")
|
|
2183
2333
|
except importlib.metadata.PackageNotFoundError: # a source checkout (the platform repo)
|
|
@@ -2627,10 +2777,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
2627
2777
|
)
|
|
2628
2778
|
access_parser.add_argument(
|
|
2629
2779
|
"--format",
|
|
2630
|
-
choices=("text", "json"),
|
|
2780
|
+
choices=("text", "json", "surface"),
|
|
2631
2781
|
default="text",
|
|
2632
|
-
help="Output format: text (human)
|
|
2633
|
-
"reading this
|
|
2782
|
+
help="Output format: text (human), json (structured, for any tool or agent "
|
|
2783
|
+
"reading this), or surface (the authority baseline the authz-surface check "
|
|
2784
|
+
"pins — redirect it to authz-surface.json; default: text)",
|
|
2634
2785
|
)
|
|
2635
2786
|
capabilities_parser = inspect_subcommands.add_parser(
|
|
2636
2787
|
"capabilities",
|
|
@@ -2816,6 +2967,33 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
2816
2967
|
env_unset_parser.add_argument(
|
|
2817
2968
|
"--root", default=".", help="Project root (default: .)"
|
|
2818
2969
|
)
|
|
2970
|
+
ports_parser = subcommands.add_parser(
|
|
2971
|
+
"ports",
|
|
2972
|
+
help="The host ports this checkout owns, recorded where every starter reads them",
|
|
2973
|
+
)
|
|
2974
|
+
ports_subcommands = ports_parser.add_subparsers(
|
|
2975
|
+
dest="ports_command", required=True
|
|
2976
|
+
)
|
|
2977
|
+
for _name, _help in (
|
|
2978
|
+
("show", "This checkout's claim, and whether .env publishes it"),
|
|
2979
|
+
("release", "Drop the claim and remove the published block"),
|
|
2980
|
+
("list", "Every host-port claim recorded on this machine"),
|
|
2981
|
+
):
|
|
2982
|
+
_sub = ports_subcommands.add_parser(_name, help=_help)
|
|
2983
|
+
_sub.add_argument("--root", default=".", help="Project root (default: .)")
|
|
2984
|
+
ports_assign_parser = ports_subcommands.add_parser(
|
|
2985
|
+
"assign",
|
|
2986
|
+
help="Claim a free pair and publish it into .env, in one act",
|
|
2987
|
+
)
|
|
2988
|
+
ports_assign_parser.add_argument(
|
|
2989
|
+
"--root", default=".", help="Project root (default: .)"
|
|
2990
|
+
)
|
|
2991
|
+
ports_assign_parser.add_argument(
|
|
2992
|
+
"--reassign",
|
|
2993
|
+
action="store_true",
|
|
2994
|
+
help="Pick a new pair even if one is already claimed or published",
|
|
2995
|
+
)
|
|
2996
|
+
|
|
2819
2997
|
outbox_parser = subcommands.add_parser(
|
|
2820
2998
|
"outbox",
|
|
2821
2999
|
help="Report the durable outbox's backlog - whether anything is draining it",
|
|
@@ -3032,6 +3210,16 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
3032
3210
|
check_parser.add_argument(
|
|
3033
3211
|
"--budget", default=None, help="Escape-hatch budget JSON (governs # arch-allow markers)"
|
|
3034
3212
|
)
|
|
3213
|
+
check_parser.add_argument(
|
|
3214
|
+
"--companion",
|
|
3215
|
+
action="append",
|
|
3216
|
+
default=[],
|
|
3217
|
+
metavar="DIR",
|
|
3218
|
+
help="A second deployable in this repo that create_app does not mount (a worker, "
|
|
3219
|
+
"a publisher, a CLI). Repeatable, and added to whatever [tool.terp.arch] "
|
|
3220
|
+
"declares. Held to the rules that are about the code rather than "
|
|
3221
|
+
"about being a mounted app, under the same escape-hatch budget",
|
|
3222
|
+
)
|
|
3035
3223
|
check_parser.add_argument(
|
|
3036
3224
|
"--format",
|
|
3037
3225
|
choices=("text", "json", "check-report"),
|
|
@@ -3375,6 +3563,14 @@ def main(argv: Sequence[str] | None = None) -> None:
|
|
|
3375
3563
|
declare=getattr(args, "declare", False),
|
|
3376
3564
|
)
|
|
3377
3565
|
)
|
|
3566
|
+
if args.command == "ports":
|
|
3567
|
+
raise SystemExit(
|
|
3568
|
+
run_ports_command(
|
|
3569
|
+
action=args.ports_command,
|
|
3570
|
+
root=args.root,
|
|
3571
|
+
reassign=getattr(args, "reassign", False),
|
|
3572
|
+
)
|
|
3573
|
+
)
|
|
3378
3574
|
if args.command == "outbox" and args.outbox_command == "backlog":
|
|
3379
3575
|
print(
|
|
3380
3576
|
render_backlog(
|
|
@@ -3450,26 +3646,40 @@ def main(argv: Sequence[str] | None = None) -> None:
|
|
|
3450
3646
|
if args.command == "check":
|
|
3451
3647
|
if args.format == "check-report":
|
|
3452
3648
|
payload = check_report_envelope(
|
|
3453
|
-
args.root,
|
|
3649
|
+
args.root,
|
|
3650
|
+
package=args.package,
|
|
3651
|
+
budget_path=args.budget,
|
|
3652
|
+
companions=args.companion,
|
|
3454
3653
|
)
|
|
3455
3654
|
print(json.dumps(payload, indent=2))
|
|
3456
3655
|
if not payload["ok"]:
|
|
3457
3656
|
raise SystemExit(1)
|
|
3458
3657
|
return
|
|
3459
3658
|
if args.format == "json":
|
|
3460
|
-
payload = check_report(
|
|
3659
|
+
payload = check_report(
|
|
3660
|
+
args.root,
|
|
3661
|
+
package=args.package,
|
|
3662
|
+
budget_path=args.budget,
|
|
3663
|
+
companions=args.companion,
|
|
3664
|
+
)
|
|
3461
3665
|
print(json.dumps(payload, indent=2))
|
|
3462
3666
|
if not payload["ok"]:
|
|
3463
3667
|
raise SystemExit(1)
|
|
3464
3668
|
return
|
|
3465
3669
|
from terp.arch import assert_app_clean
|
|
3466
3670
|
|
|
3671
|
+
extra = extra_scan_roots(args.companion, root=args.root, package=args.package)
|
|
3467
3672
|
assert_app_clean(
|
|
3468
3673
|
gate_root(args.root, package=args.package),
|
|
3674
|
+
*extra,
|
|
3469
3675
|
package=args.package,
|
|
3470
3676
|
budget_path=args.budget,
|
|
3471
3677
|
)
|
|
3472
|
-
|
|
3678
|
+
# "app is clean" is the line a green gate has always printed; a run that scanned
|
|
3679
|
+
# more than the app says what more, because a verdict that does not name its
|
|
3680
|
+
# scope is the thing this whole seam exists to stop.
|
|
3681
|
+
scanned = ", ".join(["app", *(root.package for root in extra)])
|
|
3682
|
+
print(f"terp.arch: {scanned} are clean" if extra else "terp.arch: app is clean")
|
|
3473
3683
|
return
|
|
3474
3684
|
if args.command == "verify":
|
|
3475
3685
|
raise SystemExit(
|
|
@@ -402,7 +402,16 @@ def _render_access_text(graph: dict[str, object]) -> str:
|
|
|
402
402
|
|
|
403
403
|
|
|
404
404
|
def render_access_graph(graph: dict[str, object], fmt: str = "text") -> str:
|
|
405
|
-
"""Render a prebuilt access *graph* as ``text`` or ``
|
|
405
|
+
"""Render a prebuilt access *graph* as ``text``, ``json`` or ``surface``.
|
|
406
|
+
|
|
407
|
+
``surface`` is the authority baseline the ``authz-surface`` verify check pins
|
|
408
|
+
(:mod:`terp.cli.authz_surface`): the same projection reduced to what is an
|
|
409
|
+
authority claim, sorted at every level so the committed file is diffable.
|
|
410
|
+
"""
|
|
411
|
+
if fmt == "surface":
|
|
412
|
+
from terp.cli.authz_surface import render_authz_surface
|
|
413
|
+
|
|
414
|
+
return render_authz_surface(graph)
|
|
406
415
|
if fmt == "json":
|
|
407
416
|
return json.dumps(graph, indent=2)
|
|
408
417
|
return _render_access_text(graph)
|