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.
Files changed (36) hide show
  1. {terp_cli-0.22.0 → terp_cli-0.24.0}/PKG-INFO +8 -8
  2. {terp_cli-0.22.0 → terp_cli-0.24.0}/pyproject.toml +8 -8
  3. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/__init__.py +256 -46
  4. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/access.py +10 -1
  5. terp_cli-0.24.0/src/terp/cli/authz_surface.py +169 -0
  6. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/docker.py +20 -0
  7. terp_cli-0.24.0/src/terp/cli/ports.py +996 -0
  8. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/scaffold.py +41 -4
  9. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/verify.py +174 -3
  10. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/version.py +166 -43
  11. {terp_cli-0.22.0 → terp_cli-0.24.0}/.gitignore +0 -0
  12. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/_appref.py +0 -0
  13. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/_subjects.py +0 -0
  14. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/apidocs.py +0 -0
  15. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/capabilities.py +0 -0
  16. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/deploy_safety.py +0 -0
  17. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/dev.py +0 -0
  18. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/envfile.py +0 -0
  19. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/envschema.py +0 -0
  20. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/envseams.py +0 -0
  21. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/fmt.py +0 -0
  22. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/grants.py +0 -0
  23. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/jobs.py +0 -0
  24. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/leases.py +0 -0
  25. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/module_roles.py +0 -0
  26. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/openapi.py +0 -0
  27. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/outbox.py +0 -0
  28. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/profiles.py +0 -0
  29. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/py.typed +0 -0
  30. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/routes.py +0 -0
  31. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/schema.py +0 -0
  32. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/seed.py +0 -0
  33. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/service_accounts.py +0 -0
  34. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/smoke.py +0 -0
  35. {terp_cli-0.22.0 → terp_cli-0.24.0}/src/terp/cli/users.py +0 -0
  36. {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.22.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.22.0
11
- Requires-Dist: terp-core==0.22.0
12
- Requires-Dist: terp-migrations==0.22.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.22.0; extra == 'jobs'
15
- Requires-Dist: terp-cap-scheduler-apscheduler==0.22.0; extra == 'jobs'
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.22.0; extra == 'scheduler'
17
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.24.0; extra == 'scheduler'
18
18
  Provides-Extra: worker
19
- Requires-Dist: terp-cap-outbox==0.22.0; extra == 'worker'
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.22.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.22.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.22.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.22.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.22.0"]
33
- scheduler = ["terp-cap-scheduler-apscheduler==0.22.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.22.0",
36
- "terp-cap-scheduler-apscheduler==0.22.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 takes none; something above it owns this lifecycle
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` defaults to scanning `app/`; it
670
- deliberately does not own generic import-graph checks, because a graph contract is
671
- exactly what a generic tool already does better (and terp.arch would then be a second,
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
- - YOU CAN SCAN THE SIDECAR, and you should. `terp check` takes the package to scan:
708
- terp check --package engine
709
- It runs the same rule set and reports the same file:line, so the hygiene rules that
710
- apply to any Python at all — `no_print`, `no_naive_datetime`, `no_dynamic_sql`,
711
- `no_raw_outbound_http` — cover the second package too. A hand-written AST test per
712
- rule is not the alternative to this; it is a weaker copy of it.
713
- One honest caveat: the check REPORT names the whole rule inventory, and a package that
714
- is not a Terp app cannot exercise most of it (it has no modules, no ModuleSpec, no
715
- schemas). So read a green here as "the rules that apply are clean", and do not publish
716
- that report as a Terp Standard claim over the sidecar.
717
- - The worker's own gate is one command, and both halves are in it:
718
- uv run terp verify # Terp rules over app/, then the declared
719
- # package graph over both
720
- To put the sidecar's own scan in the same gate, declare it — the profile is open at
721
- the app end (ADR 0106):
722
- [[tool.terp.verify.checks]]
723
- id = "engine-architecture"
724
- command = "terp check --package engine"
725
- profile = "quick"
726
- scope = ["engine/**"]
727
- It then runs in `quick`, `full` and `release`, appears in `terp verify --list`, and
728
- carries the exit code — instead of living in a pytest wrapper the gate never reads.
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 = ".", *, package: str = "app", budget_path: str | None = None
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
- violations = list(check_app(scan_root, package=package, budget_path=budget_path))
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(scan_root, package=package))
2262
+ violations.extend(ungoverned_marker_violations(*roots, package=package))
2138
2263
  violations.sort(key=lambda violation: (violation.path, violation.line, violation.rule))
2139
- rules = set(GUIDE_TOPIC_BY_RULE)
2140
- if budget_path is None:
2141
- rules.discard("escape_hatch_budget")
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 = ".", *, package: str = "app", budget_path: str | None = None
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(root, package=package, budget_path=budget_path)
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) or json (structured, for any tool or agent "
2633
- "reading this; default: text)",
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, package=args.package, budget_path=args.budget
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(args.root, package=args.package, budget_path=args.budget)
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
- print("terp.arch: app is clean")
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 ``json``."""
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)